From c4ae5074a0710c45b5e3bea3e282a6fe1e394bd6 Mon Sep 17 00:00:00 2001 From: Pluze Zhu Date: Fri, 5 Jun 2026 12:32:02 -0500 Subject: [PATCH] refactor: migrate image apps to owned packages --- AGENTS.md | 2 +- apps/AGENTS.md | 18 +++-- .../+export/buildManifest.m} | 10 +-- .../+export/writeOutputs.m} | 16 ++--- .../+io/imageDialogFilter.m} | 4 +- .../+io/selectedImagePaths.m} | 10 +-- .../+ops}/cropCanvasFixedSize.m | 4 +- .../+ops/cropImage.m} | 14 ++-- .../+ops/rotateCanvas.m} | 6 +- .../+state/emptyItem.m} | 4 +- .../+state/emptyResult.m} | 6 +- .../+state/readItems.m} | 6 +- .../+ui/createControls.m} | 4 +- .../+view/detailLines.m} | 6 +- .../+view}/displayNameFromPath.m | 0 .../+view/listboxItems.m} | 6 +- .../+view/previewFillValue.m} | 6 +- .../+view/rectanglePosition.m} | 4 +- .../+view/summaryTableData.m} | 6 +- .../batch_crop/batchImageCropWorkflow.m | 22 ------ .../batch_crop/labkit_BatchImageCrop_app.m | 28 ++++---- .../+export/buildResultTable.m} | 10 +-- .../+ops}/computeCurvatureFit.m | 14 ++-- .../+ops}/computeCurveLength.m | 10 +-- .../+curvature/+ops/computeFitFromOptions.m | 19 +++++ .../+ops/computeLengthFromOptions.m | 13 ++++ .../+ops}/optionValue.m | 6 +- .../+ops}/removeDuplicateNeighbors.m | 4 +- .../+ops}/scaleOptionsFromStruct.m | 16 +++-- .../+state}/emptyFitResult.m | 6 +- .../+state}/emptyLengthResult.m | 6 +- .../+state}/lengthResultFromFit.m | 8 +-- .../+ui/createControls.m} | 6 +- .../+ui}/isReferenceEditReason.m | 0 .../+ui/shellOptions.m} | 4 +- .../+view}/clampLimits.m | 0 .../{private => +curvature/+view}/emptyDash.m | 0 .../+view}/fitResultTableData.m | 2 +- .../+view}/initialResultTable.m | 0 .../+view}/insideImageBounds.m | 0 .../+view}/lengthResultTableData.m | 0 .../+view}/plotAnchorResiduals.m | 0 .../+view}/plotDenseFitPoints.m | 0 .../+view/plotStaticCurveAnchors.m} | 8 +-- .../+view/summaryViewData.m} | 14 ++-- .../{private => +curvature/+view}/ternary.m | 0 .../+view}/zoomAxesAtPoint.m | 4 +- .../curvature/curvatureMeasurementWorkflow.m | 33 --------- .../labkit_CurvatureMeasurement_app.m | 70 +++++++++---------- .../+export/buildSummaryTable.m} | 12 ++-- .../+io/assertSupportedImagePaths.m} | 10 +-- .../+io/findImages.m} | 12 ++-- .../+io/imageDialogFilter.m} | 4 +- .../+focus_stack/+io/isSupportedImagePath.m | 11 +++ .../+io/readImages.m} | 6 +- .../+io/selectedImagePaths.m} | 12 ++-- .../+io/sortPathsByName.m} | 8 +-- .../+io/supportedImageExtensions.m} | 8 +-- .../+ops/alignImages.m} | 20 +++--- .../{private => +focus_stack/+ops}/boxMean2.m | 4 +- .../+ops}/computeFocusStack.m | 28 ++++---- .../+ops}/normalizeGray.m | 4 +- .../+ops}/normalizeImageCell.m | 4 +- .../+ops}/resizeImageToReference.m | 4 +- .../+ops}/resizeImageToSize.m | 4 +- .../+state/emptyResult.m} | 10 +-- .../+state/fusionPresetSettings.m} | 6 +- .../+view/details.m} | 6 +- .../+view}/displayImageNames.m | 2 +- .../+view}/displayImageNamesForDetails.m | 4 +- .../+view}/displayNameFromPath.m | 6 +- .../+view}/focusIndexRgb.m | 0 .../+view}/initialResultTable.m | 0 .../+view}/previewImage.m | 0 .../+view/resultTableData.m} | 4 +- .../{private => +focus_stack/+view}/ternary.m | 0 .../focus_stack/focusStackWorkflow.m | 23 ------ .../focus_stack/labkit_FocusStack_app.m | 48 ++++++------- .../private/isSupportedFocusImagePath.m | 11 --- docs/apps.md | 46 ++++++++++-- docs/architecture.md | 12 +++- docs/testing.md | 2 +- tests/helpers/architectureTestHelpers.m | 57 +++++++++++++-- .../project/AppOwnedWorkflowBoundariesTest.m | 2 + .../ProjectDocumentationGuardrailTest.m | 6 +- .../project/ProjectStructureGuardrailTest.m | 42 +++++++++++ .../project/StartupBoundariesTest.m | 6 ++ .../image_measurement/BatchImageCropTest.m | 19 +++-- .../image_measurement/FocusStackFusionTest.m | 32 ++++----- .../ImageCurvatureMeasurementTest.m | 29 ++++---- 90 files changed, 522 insertions(+), 437 deletions(-) rename apps/image_measurement/batch_crop/{private/buildBatchCropManifest.m => +batch_crop/+export/buildManifest.m} (86%) rename apps/image_measurement/batch_crop/{private/writeBatchCropOutputs.m => +batch_crop/+export/writeOutputs.m} (87%) rename apps/image_measurement/batch_crop/{private/batchCropImageDialogFilter.m => +batch_crop/+io/imageDialogFilter.m} (70%) rename apps/image_measurement/batch_crop/{private/selectedBatchCropImagePaths.m => +batch_crop/+io/selectedImagePaths.m} (82%) rename apps/image_measurement/batch_crop/{private => +batch_crop/+ops}/cropCanvasFixedSize.m (92%) rename apps/image_measurement/batch_crop/{private/batchCropImage.m => +batch_crop/+ops/cropImage.m} (85%) rename apps/image_measurement/batch_crop/{private/rotateImageCanvas.m => +batch_crop/+ops/rotateCanvas.m} (92%) rename apps/image_measurement/batch_crop/{private/emptyBatchCropItem.m => +batch_crop/+state/emptyItem.m} (77%) rename apps/image_measurement/batch_crop/{private/emptyBatchCropResult.m => +batch_crop/+state/emptyResult.m} (77%) rename apps/image_measurement/batch_crop/{private/readBatchCropItems.m => +batch_crop/+state/readItems.m} (72%) rename apps/image_measurement/batch_crop/{private/createBatchCropControls.m => +batch_crop/+ui/createControls.m} (97%) rename apps/image_measurement/batch_crop/{private/batchCropDetailLines.m => +batch_crop/+view/detailLines.m} (84%) rename apps/image_measurement/batch_crop/{private => +batch_crop/+view}/displayNameFromPath.m (100%) rename apps/image_measurement/batch_crop/{private/batchCropListboxItems.m => +batch_crop/+view/listboxItems.m} (74%) rename apps/image_measurement/batch_crop/{private/batchCropPreviewFillValue.m => +batch_crop/+view/previewFillValue.m} (76%) rename apps/image_measurement/batch_crop/{private/batchCropRectanglePosition.m => +batch_crop/+view/rectanglePosition.m} (71%) rename apps/image_measurement/batch_crop/{private/batchCropSummaryTableData.m => +batch_crop/+view/summaryTableData.m} (86%) delete mode 100644 apps/image_measurement/batch_crop/batchImageCropWorkflow.m rename apps/image_measurement/curvature/{private/buildCurvatureResultTable.m => +curvature/+export/buildResultTable.m} (86%) rename apps/image_measurement/curvature/{private => +curvature/+ops}/computeCurvatureFit.m (90%) rename apps/image_measurement/curvature/{private => +curvature/+ops}/computeCurveLength.m (82%) create mode 100644 apps/image_measurement/curvature/+curvature/+ops/computeFitFromOptions.m create mode 100644 apps/image_measurement/curvature/+curvature/+ops/computeLengthFromOptions.m rename apps/image_measurement/curvature/{private => +curvature/+ops}/optionValue.m (70%) rename apps/image_measurement/curvature/{private => +curvature/+ops}/removeDuplicateNeighbors.m (79%) rename apps/image_measurement/curvature/{private => +curvature/+ops}/scaleOptionsFromStruct.m (66%) rename apps/image_measurement/curvature/{private => +curvature/+state}/emptyFitResult.m (84%) rename apps/image_measurement/curvature/{private => +curvature/+state}/emptyLengthResult.m (77%) rename apps/image_measurement/curvature/{private => +curvature/+state}/lengthResultFromFit.m (80%) rename apps/image_measurement/curvature/{private/createCurvatureControls.m => +curvature/+ui/createControls.m} (96%) rename apps/image_measurement/curvature/{private => +curvature/+ui}/isReferenceEditReason.m (100%) rename apps/image_measurement/curvature/{private/curvatureShellOptions.m => +curvature/+ui/shellOptions.m} (88%) rename apps/image_measurement/curvature/{private => +curvature/+view}/clampLimits.m (100%) rename apps/image_measurement/curvature/{private => +curvature/+view}/emptyDash.m (100%) rename apps/image_measurement/curvature/{private => +curvature/+view}/fitResultTableData.m (93%) rename apps/image_measurement/curvature/{private => +curvature/+view}/initialResultTable.m (100%) rename apps/image_measurement/curvature/{private => +curvature/+view}/insideImageBounds.m (100%) rename apps/image_measurement/curvature/{private => +curvature/+view}/lengthResultTableData.m (100%) rename apps/image_measurement/curvature/{private => +curvature/+view}/plotAnchorResiduals.m (100%) rename apps/image_measurement/curvature/{private => +curvature/+view}/plotDenseFitPoints.m (100%) rename apps/image_measurement/curvature/{private/plotStaticCurveAnchorsView.m => +curvature/+view/plotStaticCurveAnchors.m} (77%) rename apps/image_measurement/curvature/{private/curvatureSummaryViewData.m => +curvature/+view/summaryViewData.m} (80%) rename apps/image_measurement/curvature/{private => +curvature/+view}/ternary.m (100%) rename apps/image_measurement/curvature/{private => +curvature/+view}/zoomAxesAtPoint.m (90%) delete mode 100644 apps/image_measurement/curvature/curvatureMeasurementWorkflow.m rename apps/image_measurement/focus_stack/{private/buildFocusStackSummaryTable.m => +focus_stack/+export/buildSummaryTable.m} (81%) rename apps/image_measurement/focus_stack/{private/assertSupportedFocusImagePaths.m => +focus_stack/+io/assertSupportedImagePaths.m} (58%) rename apps/image_measurement/focus_stack/{private/findFocusStackImages.m => +focus_stack/+io/findImages.m} (73%) rename apps/image_measurement/focus_stack/{private/focusImageDialogFilter.m => +focus_stack/+io/imageDialogFilter.m} (73%) create mode 100644 apps/image_measurement/focus_stack/+focus_stack/+io/isSupportedImagePath.m rename apps/image_measurement/focus_stack/{private/readFocusStackImages.m => +focus_stack/+io/readImages.m} (81%) rename apps/image_measurement/focus_stack/{private/selectedFocusImagePaths.m => +focus_stack/+io/selectedImagePaths.m} (69%) rename apps/image_measurement/focus_stack/{private/sortFocusStackPathsByName.m => +focus_stack/+io/sortPathsByName.m} (65%) rename apps/image_measurement/focus_stack/{private/supportedFocusImageExtensions.m => +focus_stack/+io/supportedImageExtensions.m} (53%) rename apps/image_measurement/focus_stack/{private/alignFocusStackImages.m => +focus_stack/+ops/alignImages.m} (89%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+ops}/boxMean2.m (83%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+ops}/computeFocusStack.m (91%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+ops}/normalizeGray.m (87%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+ops}/normalizeImageCell.m (90%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+ops}/resizeImageToReference.m (82%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+ops}/resizeImageToSize.m (85%) rename apps/image_measurement/focus_stack/{private/emptyFocusStackResult.m => +focus_stack/+state/emptyResult.m} (70%) rename apps/image_measurement/focus_stack/{private/focusFusionPresetSettings.m => +focus_stack/+state/fusionPresetSettings.m} (83%) rename apps/image_measurement/focus_stack/{private/focusStackDetails.m => +focus_stack/+view/details.m} (83%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+view}/displayImageNames.m (85%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+view}/displayImageNamesForDetails.m (77%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+view}/displayNameFromPath.m (69%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+view}/focusIndexRgb.m (100%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+view}/initialResultTable.m (100%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+view}/previewImage.m (100%) rename apps/image_measurement/focus_stack/{private/focusStackResultTableData.m => +focus_stack/+view/resultTableData.m} (86%) rename apps/image_measurement/focus_stack/{private => +focus_stack/+view}/ternary.m (100%) delete mode 100644 apps/image_measurement/focus_stack/focusStackWorkflow.m delete mode 100644 apps/image_measurement/focus_stack/private/isSupportedFocusImagePath.m diff --git a/AGENTS.md b/AGENTS.md index 6d6683e..092b33b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Do not duplicate long policy text across human docs. Human docs may explain arch Every public library function under `+labkit/+ui`, `+labkit/+dta`, and `+labkit/+biosignal` must document its app-facing call contract immediately after the function declaration. Include inputs, outputs, options/spec fields, defaults, legal values, and examples where useful. -Private package helpers must include concise top-of-file implementation contracts: expected caller, input/output shapes, side effects, and non-obvious assumptions. +Private and app-owned package helpers must include concise top-of-file implementation contracts: expected caller, input/output shapes, side effects, and non-obvious assumptions. ## Sensitive Sample Data diff --git a/apps/AGENTS.md b/apps/AGENTS.md index c959bea..bf0c308 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -20,13 +20,17 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl - Image apps with custom preview scroll, drawing, ROI, scale-bar, or other axes interaction should create a `labkit.ui.tool.createRuntime` and pass that runtime into reusable tools. Do not set image-tool `WindowScrollWheelFcn`, `WindowButtonMotionFcn`, `WindowButtonUpFcn`, or axes `ButtonDownFcn` directly in app code. - DTA-backed apps use `labkit.dta.*` for discovery, loading, sessions, pulse detection, and parsed curve/table access. - Biosignal-backed apps use `labkit.biosignal.*` for recording loading, channel extraction, waveform processing, events, segments, measurements, and group comparisons. -- Do not create app-specific public helper packages to make local workflow code look reusable. -- App-owned private helpers are acceptable only when they stay under the owning app tree and do not become public reusable APIs. -- Callback-heavy migrated apps may use an app-private runner to keep the public - launcher small, but the runner remains app-owned production code and must not - become a reusable facade. -- When a public app file grows large, prefer moving GUI-free app-owned calculations, export builders, formatting utilities, and deterministic image/signal transforms into `apps///private/`. -- Use `apps//private/` only for helpers that are genuinely shared by multiple apps in that family. +- Do not create app-specific helper packages outside the owning app tree, and do not move app-specific helper code into `+labkit`. +- When an app needs extracted helpers, prefer an app-owned package under the app folder. The package name should match the app folder slug, such as `apps/image_measurement/batch_crop/+batch_crop/`. +- New extracted app helper code should use component packages such as `+ui`, + `+state`, `+ops`, `+view`, `+export`, and `+io` as needed. Do not use a fixed + `+app` namespace; the app folder already provides ownership context, while a + shared `+app` package name creates MATLAB package-resolution ambiguity. +- Callback-heavy migrated apps should move app-owned production code into these + package components instead of adding new `private/` runners or string-dispatch + workflow adapters. +- When a public app file grows large, prefer moving GUI-free app-owned calculations, export builders, formatting utilities, deterministic image/signal transforms, and focused control construction into `apps///+/...`. +- Do not add new `apps//private/` helpers unless the helper is genuinely shared by multiple apps in that family and the user approves that family-level boundary. - Keep the public app entry point responsible for GUI state, callbacks, user alerts, app workflow order, debug launch routing, and user-facing log wording. ## Documentation Sync diff --git a/apps/image_measurement/batch_crop/private/buildBatchCropManifest.m b/apps/image_measurement/batch_crop/+batch_crop/+export/buildManifest.m similarity index 86% rename from apps/image_measurement/batch_crop/private/buildBatchCropManifest.m rename to apps/image_measurement/batch_crop/+batch_crop/+export/buildManifest.m index 3b05f9c..b488f37 100644 --- a/apps/image_measurement/batch_crop/private/buildBatchCropManifest.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+export/buildManifest.m @@ -1,10 +1,10 @@ % App-owned export manifest helper. Expected caller: batch-crop app export -% callback and workflow tests. Input is a result struct vector. Output is a +% callback and package tests. Input is a result struct vector. Output is a % table suitable for CSV export and has no file side effects. -function T = buildBatchCropManifest(results) -%BUILDBATCHCROPMANIFEST Build a per-image crop/export manifest table. -% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. -% Input is a struct vector returned by batchCropImage or writeBatchCropOutputs. +function T = buildManifest(results) +%BUILDMANIFEST Build a per-image crop/export manifest table. +% Expected caller: labkit_BatchImageCrop_app and batch_crop package tests. +% Input is a struct vector returned by cropImage or writeOutputs. % Output columns describe source/output files, crop geometry, and status. if isempty(results) diff --git a/apps/image_measurement/batch_crop/private/writeBatchCropOutputs.m b/apps/image_measurement/batch_crop/+batch_crop/+export/writeOutputs.m similarity index 87% rename from apps/image_measurement/batch_crop/private/writeBatchCropOutputs.m rename to apps/image_measurement/batch_crop/+batch_crop/+export/writeOutputs.m index fa49bff..c9afcf6 100644 --- a/apps/image_measurement/batch_crop/private/writeBatchCropOutputs.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+export/writeOutputs.m @@ -1,9 +1,9 @@ % App-owned batch crop export helper. Expected caller: batch-crop app export -% callback and workflow tests. Inputs are crop items and export options. This +% callback and package tests. Inputs are crop items and export options. This % helper writes cropped images and a CSV manifest. -function payload = writeBatchCropOutputs(items, opts) -%WRITEBATCHCROPOUTPUTS Write cropped images and a manifest CSV. -% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. Items +function payload = writeOutputs(items, opts) +%WRITEOUTPUTS Write cropped images and a manifest CSV. +% Expected caller: labkit_BatchImageCrop_app and batch_crop package tests. Items % must contain path, image, angleDeg, and centerXY fields. Options contain % outputFolder, format, cropWidth, cropHeight, and fillMode/fillValue. @@ -22,16 +22,16 @@ end outputFormat = normalizeOutputFormat(optionValue(opts, 'format', 'PNG')); - results = repmat(emptyBatchCropResult(), numel(items), 1); + results = repmat(batch_crop.state.emptyResult(), numel(items), 1); reservedPaths = strings(0, 1); for k = 1:numel(items) - result = emptyBatchCropResult(); + result = batch_crop.state.emptyResult(); result.sourcePath = string(items(k).path); try cropOpts = opts; cropOpts.angleDeg = items(k).angleDeg; cropOpts.centerXY = items(k).centerXY; - crop = batchCropImage(items(k).image, cropOpts); + crop = batch_crop.ops.cropImage(items(k).image, cropOpts); outputPath = uniqueBatchCropOutputPath(outputFolder, ... string(items(k).path), outputFormat.extension, reservedPaths, "_crop"); reservedPaths(end+1, 1) = outputPath; %#ok @@ -50,7 +50,7 @@ results(k) = result; end - manifest = buildBatchCropManifest(results); + manifest = batch_crop.export.buildManifest(results); manifestPath = uniqueBatchCropOutputPath(outputFolder, ... "batch_crop_manifest.csv", ".csv", reservedPaths, ""); writetable(manifest, char(manifestPath)); diff --git a/apps/image_measurement/batch_crop/private/batchCropImageDialogFilter.m b/apps/image_measurement/batch_crop/+batch_crop/+io/imageDialogFilter.m similarity index 70% rename from apps/image_measurement/batch_crop/private/batchCropImageDialogFilter.m rename to apps/image_measurement/batch_crop/+batch_crop/+io/imageDialogFilter.m index ef6d70a..d541d60 100644 --- a/apps/image_measurement/batch_crop/private/batchCropImageDialogFilter.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+io/imageDialogFilter.m @@ -1,7 +1,7 @@ % App-owned image dialog filter helper. Expected caller: batch-crop app open % callback. Output is a uigetfile filter cell array and has no side effects. -function filter = batchCropImageDialogFilter() -%BATCHCROPIMAGEDIALOGFILTER Return supported image file dialog filters. +function filter = imageDialogFilter() +%IMAGEDIALOGFILTER Return supported image file dialog filters. filter = {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'}; diff --git a/apps/image_measurement/batch_crop/private/selectedBatchCropImagePaths.m b/apps/image_measurement/batch_crop/+batch_crop/+io/selectedImagePaths.m similarity index 82% rename from apps/image_measurement/batch_crop/private/selectedBatchCropImagePaths.m rename to apps/image_measurement/batch_crop/+batch_crop/+io/selectedImagePaths.m index 340fded..940874b 100644 --- a/apps/image_measurement/batch_crop/private/selectedBatchCropImagePaths.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+io/selectedImagePaths.m @@ -1,9 +1,9 @@ % App-owned selected-file normalization helper. Expected caller: -% labkit_BatchImageCrop_app and batchImageCropWorkflow. Inputs are raw +% labkit_BatchImageCrop_app and batch_crop package tests. Inputs are raw % uigetfile values. Output is a sorted string column and has no file effects. -function paths = selectedBatchCropImagePaths(files, folder) -%SELECTEDBATCHCROPIMAGEPATHS Normalize manually selected image paths. -% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. Inputs +function paths = selectedImagePaths(files, folder) +%SELECTEDIMAGEPATHS Normalize manually selected image paths. +% Expected caller: labkit_BatchImageCrop_app and batch_crop package tests. Inputs % are raw uigetfile file/folder values. Output validates image extensions and % sorts by display filename. @@ -36,7 +36,7 @@ function paths = sortBatchCropPathsByName(paths) names = strings(numel(paths), 1); for k = 1:numel(paths) - names(k) = lower(string(displayNameFromPath(paths(k)))); + names(k) = lower(string(batch_crop.view.displayNameFromPath(paths(k)))); end [~, order] = sort(names); paths = paths(order); diff --git a/apps/image_measurement/batch_crop/private/cropCanvasFixedSize.m b/apps/image_measurement/batch_crop/+batch_crop/+ops/cropCanvasFixedSize.m similarity index 92% rename from apps/image_measurement/batch_crop/private/cropCanvasFixedSize.m rename to apps/image_measurement/batch_crop/+batch_crop/+ops/cropCanvasFixedSize.m index ac0f33b..02cee69 100644 --- a/apps/image_measurement/batch_crop/private/cropCanvasFixedSize.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+ops/cropCanvasFixedSize.m @@ -1,9 +1,9 @@ -% App-owned fixed-size crop helper. Expected caller: batchCropImage. Inputs +% App-owned fixed-size crop helper. Expected caller: cropImage. Inputs % are a rotated canvas, center coordinates in canvas pixels, crop size, and % scalar fill value. Output preserves class and pads out-of-bounds regions. function cropped = cropCanvasFixedSize(canvas, centerXY, cropSize, fillValue) %CROPCANVASFIXEDSIZE Crop a fixed pixel rectangle from a canvas. -% Expected caller: batchCropImage. Inputs are a 2-D or 3-D image canvas, +% Expected caller: cropImage. Inputs are a 2-D or 3-D image canvas, % centerXY as [x y], cropSize as [width height], and a scalar fill value. % Output is exactly height-by-width pixels and has no file side effects. diff --git a/apps/image_measurement/batch_crop/private/batchCropImage.m b/apps/image_measurement/batch_crop/+batch_crop/+ops/cropImage.m similarity index 85% rename from apps/image_measurement/batch_crop/private/batchCropImage.m rename to apps/image_measurement/batch_crop/+batch_crop/+ops/cropImage.m index 6f3c49f..a6afa37 100644 --- a/apps/image_measurement/batch_crop/private/batchCropImage.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+ops/cropImage.m @@ -1,9 +1,9 @@ % App-owned microscope image crop helper. Expected caller: batch-crop app -% callbacks and workflow tests. Inputs are an image array and crop options. +% callbacks and package tests. Inputs are an image array and crop options. % Output is a result struct with the cropped image and crop metadata. -function result = batchCropImage(imageData, opts) -%BATCHCROPIMAGE Rotate an image canvas and crop a fixed pixel rectangle. -% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. +function result = cropImage(imageData, opts) +%CROPIMAGE Rotate an image canvas and crop a fixed pixel rectangle. +% Expected caller: labkit_BatchImageCrop_app and batch_crop package tests. % Inputs are an image array and opts with cropWidth, cropHeight, angleDeg, % centerXY, fillMode, or fillValue. Output preserves image class and returns % exactly cropHeight-by-cropWidth pixels, padding when the crop crosses canvas @@ -19,7 +19,7 @@ angleDeg = double(optionValue(opts, 'angleDeg', 0)); fillValue = fillValueForImage(imageData, opts); - [canvas, mask] = rotateImageCanvas(imageData, angleDeg, fillValue); + [canvas, mask] = batch_crop.ops.rotateCanvas(imageData, angleDeg, fillValue); centerXY = optionValue(opts, 'centerXY', []); if isempty(centerXY) || numel(centerXY) ~= 2 || any(~isfinite(double(centerXY))) centerXY = [(size(canvas, 2) + 1) / 2, (size(canvas, 1) + 1) / 2]; @@ -27,9 +27,9 @@ centerXY = double(centerXY(:)).'; end - cropped = cropCanvasFixedSize(canvas, centerXY, [cropWidth, cropHeight], fillValue); + cropped = batch_crop.ops.cropCanvasFixedSize(canvas, centerXY, [cropWidth, cropHeight], fillValue); - result = emptyBatchCropResult(); + result = batch_crop.state.emptyResult(); result.ok = true; result.status = "cropped"; result.image = cropped; diff --git a/apps/image_measurement/batch_crop/private/rotateImageCanvas.m b/apps/image_measurement/batch_crop/+batch_crop/+ops/rotateCanvas.m similarity index 92% rename from apps/image_measurement/batch_crop/private/rotateImageCanvas.m rename to apps/image_measurement/batch_crop/+batch_crop/+ops/rotateCanvas.m index 5651a8d..5cb5a11 100644 --- a/apps/image_measurement/batch_crop/private/rotateImageCanvas.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+ops/rotateCanvas.m @@ -1,9 +1,9 @@ -% App-owned image rotation helper. Expected caller: batchCropImage. Inputs are +% App-owned image rotation helper. Expected caller: cropImage. Inputs are % image data, angle in degrees, and fill value. Output is a loose rotated % canvas with background filled consistently for grayscale/RGB images. -function [canvas, mask] = rotateImageCanvas(imageData, angleDeg, fillValue) +function [canvas, mask] = rotateCanvas(imageData, angleDeg, fillValue) %ROTATEIMAGECANVAS Rotate an image without resizing its pixel scale. -% Expected caller: batchCropImage. The output canvas may be larger than the +% Expected caller: cropImage. The output canvas may be larger than the % input when angleDeg is nonzero. The implementation uses base MATLAB % interpolation so CI does not require Image Processing Toolbox. diff --git a/apps/image_measurement/batch_crop/private/emptyBatchCropItem.m b/apps/image_measurement/batch_crop/+batch_crop/+state/emptyItem.m similarity index 77% rename from apps/image_measurement/batch_crop/private/emptyBatchCropItem.m rename to apps/image_measurement/batch_crop/+batch_crop/+state/emptyItem.m index e676300..a0e3693 100644 --- a/apps/image_measurement/batch_crop/private/emptyBatchCropItem.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+state/emptyItem.m @@ -1,8 +1,8 @@ % App-owned state factory. Expected caller: batch-crop app startup and reset % callbacks. Output is a scalar item struct for one loaded image and has no % side effects. -function item = emptyBatchCropItem() -%EMPTYBATCHCROPITEM Return an empty loaded-image crop item. +function item = emptyItem() +%EMPTYITEM Return an empty loaded-image crop item. item = struct( ... 'path', "", ... diff --git a/apps/image_measurement/batch_crop/private/emptyBatchCropResult.m b/apps/image_measurement/batch_crop/+batch_crop/+state/emptyResult.m similarity index 77% rename from apps/image_measurement/batch_crop/private/emptyBatchCropResult.m rename to apps/image_measurement/batch_crop/+batch_crop/+state/emptyResult.m index 59bea05..e75ef4e 100644 --- a/apps/image_measurement/batch_crop/private/emptyBatchCropResult.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+state/emptyResult.m @@ -1,9 +1,9 @@ % App-owned result factory. Expected caller: batch-crop calculation/export % helpers. Output is a scalar result struct with stable fields and no side % effects. -function result = emptyBatchCropResult() -%EMPTYBATCHCROPRESULT Return an empty batch crop result struct. -% Expected caller: batchCropImage and writeBatchCropOutputs. Output fields are +function result = emptyResult() +%EMPTYRESULT Return an empty batch crop result struct. +% Expected caller: cropImage and writeOutputs. Output fields are % stable so result arrays can be preallocated before crop/export work. result = struct( ... diff --git a/apps/image_measurement/batch_crop/private/readBatchCropItems.m b/apps/image_measurement/batch_crop/+batch_crop/+state/readItems.m similarity index 72% rename from apps/image_measurement/batch_crop/private/readBatchCropItems.m rename to apps/image_measurement/batch_crop/+batch_crop/+state/readItems.m index 65d8b43..d16a163 100644 --- a/apps/image_measurement/batch_crop/private/readBatchCropItems.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+state/readItems.m @@ -1,10 +1,10 @@ % App-owned image loading helper. Expected caller: batch-crop app open-files % callback. Input is a string vector of image paths. Output is an item struct % vector with images loaded through imread. -function items = readBatchCropItems(paths) -%READBATCHCROPITEMS Load selected image paths into crop item structs. +function items = readItems(paths) +%READITEMS Load selected image paths into crop item structs. - items = repmat(emptyBatchCropItem(), numel(paths), 1); + items = repmat(batch_crop.state.emptyItem(), numel(paths), 1); for k = 1:numel(paths) img = imread(paths(k)); items(k).path = string(paths(k)); diff --git a/apps/image_measurement/batch_crop/private/createBatchCropControls.m b/apps/image_measurement/batch_crop/+batch_crop/+ui/createControls.m similarity index 97% rename from apps/image_measurement/batch_crop/private/createBatchCropControls.m rename to apps/image_measurement/batch_crop/+batch_crop/+ui/createControls.m index e059ea3..6deca15 100644 --- a/apps/image_measurement/batch_crop/private/createBatchCropControls.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+ui/createControls.m @@ -2,8 +2,8 @@ % labkit_BatchImageCrop_app during startup. Inputs are shell tab grids, % initial output folder, and callback handles. Output is a struct of UI % handles. This helper creates controls only and has no file side effects. -function controls = createBatchCropControls(layFA, laySR, layLog, initialOutputFolder, callbacks) -%CREATEBATCHCROPCONTROLS Create controls for the batch image crop app. +function controls = createControls(layFA, laySR, layLog, initialOutputFolder, callbacks) +%CREATECONTROLS Create controls for the batch image crop app. filePanel = labkit.ui.view.section(layFA, 'Images', 1, [5 2], ... struct('rowHeight', {{'fit', 'fit', 105, 'fit', 'fit'}}, ... diff --git a/apps/image_measurement/batch_crop/private/batchCropDetailLines.m b/apps/image_measurement/batch_crop/+batch_crop/+view/detailLines.m similarity index 84% rename from apps/image_measurement/batch_crop/private/batchCropDetailLines.m rename to apps/image_measurement/batch_crop/+batch_crop/+view/detailLines.m index b667ba6..35de687 100644 --- a/apps/image_measurement/batch_crop/private/batchCropDetailLines.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+view/detailLines.m @@ -1,8 +1,8 @@ % App-owned detail view helper. Expected caller: batch-crop app refreshSummary. % Inputs are app state, current index, crop size, and fill mode. Output is a % cell vector of text lines and has no side effects. -function lines = batchCropDetailLines(state, currentIndex, cropWidth, cropHeight, fillMode) -%BATCHCROPDETAILLINES Build detail text for the selected crop item. +function lines = detailLines(state, currentIndex, cropWidth, cropHeight, fillMode) +%DETAILLINES Build detail text for the selected crop item. if isempty(state.items) || currentIndex < 1 || currentIndex > numel(state.items) lines = {'No images loaded.'}; @@ -13,7 +13,7 @@ stateText = ternary(item.centerSet, 'confirmed', 'needs confirmation'); lines = { ... sprintf('Image %d of %d: %s', currentIndex, numel(state.items), ... - displayNameFromPath(item.path)), ... + batch_crop.view.displayNameFromPath(item.path)), ... sprintf('Crop center: x %.1f, y %.1f (%s)', ... item.centerXY(1), item.centerXY(2), stateText), ... sprintf('Output size: %d x %d px; rotation: %.3g deg; fill: %s', ... diff --git a/apps/image_measurement/batch_crop/private/displayNameFromPath.m b/apps/image_measurement/batch_crop/+batch_crop/+view/displayNameFromPath.m similarity index 100% rename from apps/image_measurement/batch_crop/private/displayNameFromPath.m rename to apps/image_measurement/batch_crop/+batch_crop/+view/displayNameFromPath.m diff --git a/apps/image_measurement/batch_crop/private/batchCropListboxItems.m b/apps/image_measurement/batch_crop/+batch_crop/+view/listboxItems.m similarity index 74% rename from apps/image_measurement/batch_crop/private/batchCropListboxItems.m rename to apps/image_measurement/batch_crop/+batch_crop/+view/listboxItems.m index 70fae8f..935b346 100644 --- a/apps/image_measurement/batch_crop/private/batchCropListboxItems.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+view/listboxItems.m @@ -1,14 +1,14 @@ % App-owned list display helper. Expected caller: batch-crop app refreshList. % Input is an item struct vector. Output is listbox display text and has no % side effects. -function items = batchCropListboxItems(cropItems) -%BATCHCROPLISTBOXITEMS Build listbox labels for loaded crop items. +function items = listboxItems(cropItems) +%LISTBOXITEMS Build listbox labels for loaded crop items. items = cell(numel(cropItems), 1); for k = 1:numel(cropItems) marker = ternary(cropItems(k).centerSet, 'set', 'needs center'); items{k} = sprintf('%02d %s [%s]', k, ... - displayNameFromPath(cropItems(k).path), marker); + batch_crop.view.displayNameFromPath(cropItems(k).path), marker); end end diff --git a/apps/image_measurement/batch_crop/private/batchCropPreviewFillValue.m b/apps/image_measurement/batch_crop/+batch_crop/+view/previewFillValue.m similarity index 76% rename from apps/image_measurement/batch_crop/private/batchCropPreviewFillValue.m rename to apps/image_measurement/batch_crop/+batch_crop/+view/previewFillValue.m index e021222..0cbdeb4 100644 --- a/apps/image_measurement/batch_crop/private/batchCropPreviewFillValue.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+view/previewFillValue.m @@ -1,8 +1,8 @@ % App-owned preview fill helper. Expected caller: batch-crop app preview % rendering. Inputs are image data and fill mode. Output is a scalar fill -% value compatible with rotateImageCanvas. -function value = batchCropPreviewFillValue(imageData, fillMode) -%BATCHCROPPREVIEWFILLVALUE Resolve black/white preview fill value. +% value compatible with rotateCanvas. +function value = previewFillValue(imageData, fillMode) +%PREVIEWFILLVALUE Resolve black/white preview fill value. if strcmp(char(string(fillMode)), 'White') if islogical(imageData) diff --git a/apps/image_measurement/batch_crop/private/batchCropRectanglePosition.m b/apps/image_measurement/batch_crop/+batch_crop/+view/rectanglePosition.m similarity index 71% rename from apps/image_measurement/batch_crop/private/batchCropRectanglePosition.m rename to apps/image_measurement/batch_crop/+batch_crop/+view/rectanglePosition.m index 9489ed9..0b40523 100644 --- a/apps/image_measurement/batch_crop/private/batchCropRectanglePosition.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+view/rectanglePosition.m @@ -1,8 +1,8 @@ % App-owned overlay geometry helper. Expected caller: batch-crop app preview % rendering. Inputs are center and crop size in pixels. Output is a MATLAB % rectangle Position vector and has no side effects. -function position = batchCropRectanglePosition(centerXY, cropWidth, cropHeight) -%BATCHCROPRECTANGLEPOSITION Return rectangle overlay position for a crop box. +function position = rectanglePosition(centerXY, cropWidth, cropHeight) +%RECTANGLEPOSITION Return rectangle overlay position for a crop box. colStart = round(centerXY(1) - (cropWidth - 1) / 2); rowStart = round(centerXY(2) - (cropHeight - 1) / 2); diff --git a/apps/image_measurement/batch_crop/private/batchCropSummaryTableData.m b/apps/image_measurement/batch_crop/+batch_crop/+view/summaryTableData.m similarity index 86% rename from apps/image_measurement/batch_crop/private/batchCropSummaryTableData.m rename to apps/image_measurement/batch_crop/+batch_crop/+view/summaryTableData.m index 00b9e08..33b9f38 100644 --- a/apps/image_measurement/batch_crop/private/batchCropSummaryTableData.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+view/summaryTableData.m @@ -1,8 +1,8 @@ % App-owned summary view helper. Expected caller: batch-crop app refreshSummary. % Inputs are app state, current index, canvas size, crop size, and output format. % Output is metric/value cell data and has no side effects. -function data = batchCropSummaryTableData(state, currentIndex, canvasSize, cropWidth, cropHeight, outputFormat) -%BATCHCROPSUMMARYTABLEDATA Build the batch crop summary table cell data. +function data = summaryTableData(state, currentIndex, canvasSize, cropWidth, cropHeight, outputFormat) +%SUMMARYTABLEDATA Build the batch crop summary table cell data. if isempty(state.items) || currentIndex < 1 || currentIndex > numel(state.items) data = { ... @@ -17,7 +17,7 @@ item = state.items(currentIndex); data = { ... 'Images loaded', sprintf('%d', numel(state.items)); ... - 'Current image', displayNameFromPath(item.path); ... + 'Current image', batch_crop.view.displayNameFromPath(item.path); ... 'Crop size', sprintf('%d x %d px', cropWidth, cropHeight); ... 'Aspect ratio', aspectRatioText(cropWidth, cropHeight); ... 'Rotation', sprintf('%.3g deg', item.angleDeg); ... diff --git a/apps/image_measurement/batch_crop/batchImageCropWorkflow.m b/apps/image_measurement/batch_crop/batchImageCropWorkflow.m deleted file mode 100644 index 56da1ed..0000000 --- a/apps/image_measurement/batch_crop/batchImageCropWorkflow.m +++ /dev/null @@ -1,22 +0,0 @@ -function varargout = batchImageCropWorkflow(command, varargin) -%BATCHIMAGECROPWORKFLOW Dispatch app-owned batch crop helpers. -% Expected caller: batch-crop app tests and migration-time workflow checks. -% Inputs are a workflow command plus command-specific arguments. Outputs match -% the selected app-private helper. Export commands have file side effects. - - switch string(command) - case "cropImage" - varargout{1} = batchCropImage(varargin{1}, varargin{2}); - case "buildBatchCropManifest" - varargout{1} = buildBatchCropManifest(varargin{1}); - case "selectedBatchCropImagePaths" - varargout{1} = selectedBatchCropImagePaths(varargin{1}, varargin{2}); - case "writeBatchCropOutputs" - varargout{1} = writeBatchCropOutputs(varargin{1}, varargin{2}); - case "batchCropImageDialogFilter" - varargout{1} = batchCropImageDialogFilter(); - otherwise - error('labkit:BatchImageCrop:UnknownWorkflowCommand', ... - 'Unknown batch image crop workflow helper command: %s.', command); - end -end diff --git a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m index df6268e..0ea9860 100644 --- a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m +++ b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m @@ -18,7 +18,7 @@ end S = struct(); - S.items = repmat(emptyBatchCropItem(), 0, 1); + S.items = repmat(batch_crop.state.emptyItem(), 0, 1); S.currentIndex = 0; S.outputFolder = string(pwd); S.lastExport = []; @@ -73,7 +73,7 @@ 'onExportSettingChanged', @onExportSettingChanged, ... 'onChooseOutputFolder', @onChooseOutputFolder, ... 'onExportCrops', @onExportCrops); - controls = createBatchCropControls(layFA, laySR, layLog, ... + controls = batch_crop.ui.createControls(layFA, laySR, layLog, ... S.outputFolder, callbacks); btnOpenFiles = controls.btnOpenFiles; btnClearImages = controls.btnClearImages; @@ -113,7 +113,7 @@ end function onOpenFiles(~, ~) - [files, folder] = uigetfile(batchCropImageDialogFilter(), ... + [files, folder] = uigetfile(batch_crop.io.imageDialogFilter(), ... 'Select microscope images', pwd, 'MultiSelect', 'on'); if isequal(files, 0) addLog('Image file selection cancelled.'); @@ -121,7 +121,7 @@ function onOpenFiles(~, ~) end try - paths = selectedBatchCropImagePaths(files, folder); + paths = batch_crop.io.selectedImagePaths(files, folder); items = readCropItems(paths); catch ME showError('Could not load images', ME.message); @@ -136,7 +136,7 @@ function onOpenFiles(~, ~) end function onClearImages(~, ~) - S.items = repmat(emptyBatchCropItem(), 0, 1); + S.items = repmat(batch_crop.state.emptyItem(), 0, 1); S.currentIndex = 0; S.lastExport = []; addLog('Cleared loaded images.'); @@ -147,7 +147,7 @@ function onImageSelectionChanged(~, ~) if isempty(S.items) return; end - items = batchCropListboxItems(S.items); + items = batch_crop.view.listboxItems(S.items); idx = find(strcmp(items, lbImages.Value), 1); if isempty(idx) return; @@ -268,7 +268,7 @@ function onExportCrops(~, ~) btnChooseOutput, btnPrevious, btnNext, btnUseCanvasCenter]; try payload = labkit.ui.app.runBusy(fig, ... - @() writeBatchCropOutputs(S.items, opts), busyOpts); + @() batch_crop.export.writeOutputs(S.items, opts), busyOpts); catch ME showError('Export failed', ME.message); return; @@ -303,7 +303,7 @@ function refreshList() return; end - items = batchCropListboxItems(S.items); + items = batch_crop.view.listboxItems(S.items); lbImages.Items = items; S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); lbImages.Value = items{S.currentIndex}; @@ -359,7 +359,7 @@ function refreshPreview() item = S.items(S.currentIndex); cropWidth = currentCropWidth(); cropHeight = currentCropHeight(); - position = batchCropRectanglePosition(item.centerXY, cropWidth, cropHeight); + position = batch_crop.view.rectanglePosition(item.centerXY, cropWidth, cropHeight); hRect = rectangle(ui.previewAxes, 'Position', position, ... 'EdgeColor', [1 0.84 0], ... 'LineWidth', 1.5, ... @@ -388,9 +388,9 @@ function refreshSummary() else canvasSize = [0, 0]; end - resultTable.Data = batchCropSummaryTableData(S, S.currentIndex, ... + resultTable.Data = batch_crop.view.summaryTableData(S, S.currentIndex, ... canvasSize, currentCropWidth(), currentCropHeight(), ddFormat.Value); - txtDetails.Value = batchCropDetailLines(S, S.currentIndex, ... + txtDetails.Value = batch_crop.view.detailLines(S, S.currentIndex, ... currentCropWidth(), currentCropHeight(), ddFillMode.Value); end @@ -418,8 +418,8 @@ function resetPreviewAxes() function [canvas, mask] = currentCanvas() item = S.items(S.currentIndex); - fillValue = batchCropPreviewFillValue(item.image, ddFillMode.Value); - [canvas, mask] = rotateImageCanvas(item.image, item.angleDeg, fillValue); + fillValue = batch_crop.view.previewFillValue(item.image, ddFillMode.Value); + [canvas, mask] = batch_crop.ops.rotateCanvas(item.image, item.angleDeg, fillValue); end function ensureCurrentCenter() @@ -449,7 +449,7 @@ function ensureCurrentCenter() end function items = readCropItems(paths) - items = readBatchCropItems(paths); + items = batch_crop.state.readItems(paths); end function addLog(message) diff --git a/apps/image_measurement/curvature/private/buildCurvatureResultTable.m b/apps/image_measurement/curvature/+curvature/+export/buildResultTable.m similarity index 86% rename from apps/image_measurement/curvature/private/buildCurvatureResultTable.m rename to apps/image_measurement/curvature/+curvature/+export/buildResultTable.m index fd15357..bb5a36e 100644 --- a/apps/image_measurement/curvature/private/buildCurvatureResultTable.m +++ b/apps/image_measurement/curvature/+curvature/+export/buildResultTable.m @@ -1,8 +1,8 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. -function T = buildCurvatureResultTable(fit, imagePath, lengthResult) -%BUILDCURVATURERESULTTABLE Build export table for labkit_CurvatureMeasurement_app. +function T = buildResultTable(fit, imagePath, lengthResult) +%BUILDRESULTTABLE Build export table for labkit_CurvatureMeasurement_app. % % Expected caller: % labkit_CurvatureMeasurement_app export callback and temporary compatibility @@ -16,7 +16,7 @@ % None. The caller owns file writing. if nargin < 3 || isempty(lengthResult) - lengthResult = lengthResultFromFit(fit); + lengthResult = curvature.state.lengthResultFromFit(fit); end scaleInfo = resultScaleInfo(fit, lengthResult); T = table( ... diff --git a/apps/image_measurement/curvature/private/computeCurvatureFit.m b/apps/image_measurement/curvature/+curvature/+ops/computeCurvatureFit.m similarity index 90% rename from apps/image_measurement/curvature/private/computeCurvatureFit.m rename to apps/image_measurement/curvature/+curvature/+ops/computeCurvatureFit.m index 9394622..73969de 100644 --- a/apps/image_measurement/curvature/private/computeCurvatureFit.m +++ b/apps/image_measurement/curvature/+curvature/+ops/computeCurvatureFit.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function fit = computeCurvatureFit(xPix, yPix, calibration, doDensify, denseN, fitPathX, fitPathY) %COMPUTECURVATUREFIT Fit image-curve curvature for labkit_CurvatureMeasurement_app. % % Expected caller: -% labkit_CurvatureMeasurement_app callbacks and workflow tests. +% labkit_CurvatureMeasurement_app callbacks and package tests. % % Inputs/outputs: % Pixel anchor vectors, a labkit.ui scale-bar calibration struct, and @@ -15,10 +15,10 @@ % Side effects: % None. This helper performs GUI-free numeric fitting only. - fit = emptyFitResult(); + fit = curvature.state.emptyFitResult(); xPix = xPix(:); yPix = yPix(:); - [xPix, yPix] = removeDuplicateNeighbors(xPix, yPix, 1e-9); + [xPix, yPix] = curvature.ops.removeDuplicateNeighbors(xPix, yPix, 1e-9); if numel(xPix) < 3 error('labkit_CurvatureMeasurement_app:NotEnoughPoints', ... @@ -44,7 +44,7 @@ fitPathX = fitPathX(:); fitPathY = fitPathY(:); if numel(fitPathX) == numel(fitPathY) - [fitPathX, fitPathY] = removeDuplicateNeighbors(fitPathX, fitPathY, 1e-9); + [fitPathX, fitPathY] = curvature.ops.removeDuplicateNeighbors(fitPathX, fitPathY, 1e-9); if numel(fitPathX) >= 3 fitSourceX = fitPathX; fitSourceY = fitPathY; @@ -69,7 +69,7 @@ pxPerUnit = calibration.pixelsPerUnit; usePhysicalScale = calibration.isCalibrated; kappa_px = 1 / R_px; - lengthResult = computeCurveLength(fitSourceX, fitSourceY, calibration); + lengthResult = curvature.ops.computeCurveLength(fitSourceX, fitSourceY, calibration); if usePhysicalScale unitLen = scaleUnit; diff --git a/apps/image_measurement/curvature/private/computeCurveLength.m b/apps/image_measurement/curvature/+curvature/+ops/computeCurveLength.m similarity index 82% rename from apps/image_measurement/curvature/private/computeCurveLength.m rename to apps/image_measurement/curvature/+curvature/+ops/computeCurveLength.m index 54ec4e4..a4cdb1e 100644 --- a/apps/image_measurement/curvature/private/computeCurveLength.m +++ b/apps/image_measurement/curvature/+curvature/+ops/computeCurveLength.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function lengthResult = computeCurveLength(xPix, yPix, calibration) %COMPUTECURVELENGTH Measure traced curve length for labkit_CurvatureMeasurement_app. % % Expected caller: -% labkit_CurvatureMeasurement_app callbacks, workflow tests, and private +% labkit_CurvatureMeasurement_app callbacks, package tests, and private % curvature fit helpers. % % Inputs/outputs: @@ -15,10 +15,10 @@ % Side effects: % None. This helper performs GUI-free numeric length measurement only. - lengthResult = emptyLengthResult(); + lengthResult = curvature.state.emptyLengthResult(); xPix = xPix(:); yPix = yPix(:); - [xPix, yPix] = removeDuplicateNeighbors(xPix, yPix, 1e-9); + [xPix, yPix] = curvature.ops.removeDuplicateNeighbors(xPix, yPix, 1e-9); if numel(xPix) < 2 error('labkit_CurvatureMeasurement_app:NotEnoughLengthPoints', ... diff --git a/apps/image_measurement/curvature/+curvature/+ops/computeFitFromOptions.m b/apps/image_measurement/curvature/+curvature/+ops/computeFitFromOptions.m new file mode 100644 index 0000000..2fde896 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+ops/computeFitFromOptions.m @@ -0,0 +1,19 @@ +% App-owned curvature options adapter. Expected caller: curvature tests and +% advanced debug code. Inputs are curve points plus app option struct; output is +% the same fit struct returned by computeCurvatureFit. No GUI or file side effects. +function fit = computeFitFromOptions(xPix, yPix, opts) +%COMPUTEFITFROMOPTIONS Fit curvature from app-style option fields. + + if nargin < 3 + opts = struct(); + end + + calibration = curvature.ops.scaleOptionsFromStruct(opts); + doDensify = curvature.ops.optionValue(opts, 'doDensify', true); + denseN = curvature.ops.optionValue(opts, 'denseN', 300); + fitPathX = curvature.ops.optionValue(opts, 'fitPathX', []); + fitPathY = curvature.ops.optionValue(opts, 'fitPathY', []); + + fit = curvature.ops.computeCurvatureFit(xPix, yPix, calibration, ... + doDensify, denseN, fitPathX, fitPathY); +end diff --git a/apps/image_measurement/curvature/+curvature/+ops/computeLengthFromOptions.m b/apps/image_measurement/curvature/+curvature/+ops/computeLengthFromOptions.m new file mode 100644 index 0000000..1846e28 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+ops/computeLengthFromOptions.m @@ -0,0 +1,13 @@ +% App-owned curvature length options adapter. Expected caller: curvature tests +% and advanced debug code. Inputs are curve points plus app option struct; +% output is the same length result returned by computeCurveLength. No side effects. +function lengthResult = computeLengthFromOptions(xPix, yPix, opts) +%COMPUTELENGTHFROMOPTIONS Measure curve length from app-style option fields. + + if nargin < 3 + opts = struct(); + end + + calibration = curvature.ops.scaleOptionsFromStruct(opts); + lengthResult = curvature.ops.computeCurveLength(xPix, yPix, calibration); +end diff --git a/apps/image_measurement/curvature/private/optionValue.m b/apps/image_measurement/curvature/+curvature/+ops/optionValue.m similarity index 70% rename from apps/image_measurement/curvature/private/optionValue.m rename to apps/image_measurement/curvature/+curvature/+ops/optionValue.m index 1b1ecd4..a923565 100644 --- a/apps/image_measurement/curvature/private/optionValue.m +++ b/apps/image_measurement/curvature/+curvature/+ops/optionValue.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function value = optionValue(opts, name, defaultValue) %OPTIONVALUE Read a scalar option field with default fallback. % % Expected caller: -% labkit_CurvatureMeasurement_app workflow tests and app-private option +% labkit_CurvatureMeasurement_app package tests and package-owned option % normalization helpers. % % Inputs/outputs: diff --git a/apps/image_measurement/curvature/private/removeDuplicateNeighbors.m b/apps/image_measurement/curvature/+curvature/+ops/removeDuplicateNeighbors.m similarity index 79% rename from apps/image_measurement/curvature/private/removeDuplicateNeighbors.m rename to apps/image_measurement/curvature/+curvature/+ops/removeDuplicateNeighbors.m index f6b5aa2..89241c6 100644 --- a/apps/image_measurement/curvature/private/removeDuplicateNeighbors.m +++ b/apps/image_measurement/curvature/+curvature/+ops/removeDuplicateNeighbors.m @@ -1,5 +1,5 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function [x, y] = removeDuplicateNeighbors(x, y, tol) %REMOVEDUPLICATENEIGHBORS Remove consecutive duplicate curve points. diff --git a/apps/image_measurement/curvature/private/scaleOptionsFromStruct.m b/apps/image_measurement/curvature/+curvature/+ops/scaleOptionsFromStruct.m similarity index 66% rename from apps/image_measurement/curvature/private/scaleOptionsFromStruct.m rename to apps/image_measurement/curvature/+curvature/+ops/scaleOptionsFromStruct.m index 9ef14ef..21a9f51 100644 --- a/apps/image_measurement/curvature/private/scaleOptionsFromStruct.m +++ b/apps/image_measurement/curvature/+curvature/+ops/scaleOptionsFromStruct.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function calibration = scaleOptionsFromStruct(opts) %SCALEOPTIONSFROMSTRUCT Normalize test and app scale options. % % Expected caller: -% labkit_CurvatureMeasurement_app workflow tests. +% labkit_CurvatureMeasurement_app package tests. % % Inputs/outputs: % Option struct with current and legacy scale fields. Returns a @@ -19,15 +19,17 @@ return; end - referencePx = optionValue(opts, 'referencePx', optionValue(opts, 'rawpx', NaN)); - referenceLength = optionValue(opts, 'referenceLength', optionValue(opts, 'scaleLengthMm', 0)); - scaleUnit = optionValue(opts, 'scaleUnit', ''); + referencePx = curvature.ops.optionValue(opts, 'referencePx', ... + curvature.ops.optionValue(opts, 'rawpx', NaN)); + referenceLength = curvature.ops.optionValue(opts, 'referenceLength', ... + curvature.ops.optionValue(opts, 'scaleLengthMm', 0)); + scaleUnit = curvature.ops.optionValue(opts, 'scaleUnit', ''); referencePx = positiveOrNaN(referencePx); if isempty(referenceLength) || ~isfinite(referenceLength) || referenceLength < 0 referenceLength = 0; end - manualPxPerMm = optionValue(opts, 'manualPxPerMm', 0); + manualPxPerMm = curvature.ops.optionValue(opts, 'manualPxPerMm', 0); if isempty(manualPxPerMm) || ~isfinite(manualPxPerMm) || manualPxPerMm < 0 manualPxPerMm = 0; end diff --git a/apps/image_measurement/curvature/private/emptyFitResult.m b/apps/image_measurement/curvature/+curvature/+state/emptyFitResult.m similarity index 84% rename from apps/image_measurement/curvature/private/emptyFitResult.m rename to apps/image_measurement/curvature/+curvature/+state/emptyFitResult.m index 82592cb..c990e9f 100644 --- a/apps/image_measurement/curvature/private/emptyFitResult.m +++ b/apps/image_measurement/curvature/+curvature/+state/emptyFitResult.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function fit = emptyFitResult() %EMPTYFITRESULT Return default fit result for labkit_CurvatureMeasurement_app. % % Expected caller: -% labkit_CurvatureMeasurement_app and app-private curvature helpers. +% labkit_CurvatureMeasurement_app and package-owned curvature helpers. % % Inputs/outputs: % No inputs. Returns the app-owned fit-result struct shape used by GUI diff --git a/apps/image_measurement/curvature/private/emptyLengthResult.m b/apps/image_measurement/curvature/+curvature/+state/emptyLengthResult.m similarity index 77% rename from apps/image_measurement/curvature/private/emptyLengthResult.m rename to apps/image_measurement/curvature/+curvature/+state/emptyLengthResult.m index 629f90c..d34a9c2 100644 --- a/apps/image_measurement/curvature/private/emptyLengthResult.m +++ b/apps/image_measurement/curvature/+curvature/+state/emptyLengthResult.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function lengthResult = emptyLengthResult() %EMPTYLENGTHRESULT Return default length result for labkit_CurvatureMeasurement_app. % % Expected caller: -% labkit_CurvatureMeasurement_app and app-private curvature helpers. +% labkit_CurvatureMeasurement_app and package-owned curvature helpers. % % Inputs/outputs: % No inputs. Returns the app-owned length-result struct shape used by GUI diff --git a/apps/image_measurement/curvature/private/lengthResultFromFit.m b/apps/image_measurement/curvature/+curvature/+state/lengthResultFromFit.m similarity index 80% rename from apps/image_measurement/curvature/private/lengthResultFromFit.m rename to apps/image_measurement/curvature/+curvature/+state/lengthResultFromFit.m index a19cac9..ec72077 100644 --- a/apps/image_measurement/curvature/private/lengthResultFromFit.m +++ b/apps/image_measurement/curvature/+curvature/+state/lengthResultFromFit.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function lengthResult = lengthResultFromFit(fit) %LENGTHRESULTFROMFIT Derive curve-length result from a fit result. % % Expected caller: -% labkit_CurvatureMeasurement_app display/export helpers and app-private +% labkit_CurvatureMeasurement_app display/export helpers and package-owned % result-table code. % % Inputs/outputs: @@ -15,7 +15,7 @@ % Side effects: % None. - lengthResult = emptyLengthResult(); + lengthResult = curvature.state.emptyLengthResult(); if isstruct(fit) && isfield(fit, 'curveLength_px') && ... isfinite(fit.curveLength_px) && fit.curveLength_px >= 0 lengthResult.ok = true; diff --git a/apps/image_measurement/curvature/private/createCurvatureControls.m b/apps/image_measurement/curvature/+curvature/+ui/createControls.m similarity index 96% rename from apps/image_measurement/curvature/private/createCurvatureControls.m rename to apps/image_measurement/curvature/+curvature/+ui/createControls.m index acc11c6..1fd18e6 100644 --- a/apps/image_measurement/curvature/private/createCurvatureControls.m +++ b/apps/image_measurement/curvature/+curvature/+ui/createControls.m @@ -2,8 +2,8 @@ % labkit_CurvatureMeasurement_app. Inputs are shell grids, an image tool runtime, % and callbacks. Output is a struct of app UI handles. Side effects are limited % to creating UI components under the supplied parents. -function controls = createCurvatureControls(layFA, laySR, layLog, imageRuntime, callbacks) -%CREATECURVATURECONTROLS Create the curvature app control panels. +function controls = createControls(layFA, laySR, layLog, imageRuntime, callbacks) +%CREATECONTROLS Create the curvature app control panels. imagePanel = labkit.ui.view.section(layFA, 'Image', 1, [3 2], ... struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... @@ -107,7 +107,7 @@ controls.resultTable = uitable(laySR, ... 'ColumnName', {'Metric', 'Value'}, ... - 'Data', initialResultTable()); + 'Data', curvature.view.initialResultTable()); controls.resultTable.Layout.Row = 1; controls.txtDetails = uitextarea(laySR, 'Editable', 'off'); diff --git a/apps/image_measurement/curvature/private/isReferenceEditReason.m b/apps/image_measurement/curvature/+curvature/+ui/isReferenceEditReason.m similarity index 100% rename from apps/image_measurement/curvature/private/isReferenceEditReason.m rename to apps/image_measurement/curvature/+curvature/+ui/isReferenceEditReason.m diff --git a/apps/image_measurement/curvature/private/curvatureShellOptions.m b/apps/image_measurement/curvature/+curvature/+ui/shellOptions.m similarity index 88% rename from apps/image_measurement/curvature/private/curvatureShellOptions.m rename to apps/image_measurement/curvature/+curvature/+ui/shellOptions.m index cf7fd31..b5f5fa8 100644 --- a/apps/image_measurement/curvature/private/curvatureShellOptions.m +++ b/apps/image_measurement/curvature/+curvature/+ui/shellOptions.m @@ -1,8 +1,8 @@ % App-owned curvature shell options helper. Expected caller: % labkit_CurvatureMeasurement_app. Output is the createShell options struct. % Encodes only layout constants and has no side effects. -function opts = curvatureShellOptions() -%CURVATURESHELLOPTIONS Return shell options for the curvature app. +function opts = shellOptions() +%SHELLOPTIONS Return shell options for the curvature app. opts = struct( ... 'rightTitle', 'Measurement Preview', ... diff --git a/apps/image_measurement/curvature/private/clampLimits.m b/apps/image_measurement/curvature/+curvature/+view/clampLimits.m similarity index 100% rename from apps/image_measurement/curvature/private/clampLimits.m rename to apps/image_measurement/curvature/+curvature/+view/clampLimits.m diff --git a/apps/image_measurement/curvature/private/emptyDash.m b/apps/image_measurement/curvature/+curvature/+view/emptyDash.m similarity index 100% rename from apps/image_measurement/curvature/private/emptyDash.m rename to apps/image_measurement/curvature/+curvature/+view/emptyDash.m diff --git a/apps/image_measurement/curvature/private/fitResultTableData.m b/apps/image_measurement/curvature/+curvature/+view/fitResultTableData.m similarity index 93% rename from apps/image_measurement/curvature/private/fitResultTableData.m rename to apps/image_measurement/curvature/+curvature/+view/fitResultTableData.m index 38ffc30..e884f3d 100644 --- a/apps/image_measurement/curvature/private/fitResultTableData.m +++ b/apps/image_measurement/curvature/+curvature/+view/fitResultTableData.m @@ -5,7 +5,7 @@ %FITRESULTTABLEDATA Return visible result table rows for a fit result. if nargin < 2 || isempty(lengthResult) - lengthResult = lengthResultFromFit(fit); + lengthResult = curvature.state.lengthResultFromFit(fit); end data = { ... 'Curve length', sprintf('%.6g %s', lengthResult.length_show, lengthResult.unitLen); ... diff --git a/apps/image_measurement/curvature/private/initialResultTable.m b/apps/image_measurement/curvature/+curvature/+view/initialResultTable.m similarity index 100% rename from apps/image_measurement/curvature/private/initialResultTable.m rename to apps/image_measurement/curvature/+curvature/+view/initialResultTable.m diff --git a/apps/image_measurement/curvature/private/insideImageBounds.m b/apps/image_measurement/curvature/+curvature/+view/insideImageBounds.m similarity index 100% rename from apps/image_measurement/curvature/private/insideImageBounds.m rename to apps/image_measurement/curvature/+curvature/+view/insideImageBounds.m diff --git a/apps/image_measurement/curvature/private/lengthResultTableData.m b/apps/image_measurement/curvature/+curvature/+view/lengthResultTableData.m similarity index 100% rename from apps/image_measurement/curvature/private/lengthResultTableData.m rename to apps/image_measurement/curvature/+curvature/+view/lengthResultTableData.m diff --git a/apps/image_measurement/curvature/private/plotAnchorResiduals.m b/apps/image_measurement/curvature/+curvature/+view/plotAnchorResiduals.m similarity index 100% rename from apps/image_measurement/curvature/private/plotAnchorResiduals.m rename to apps/image_measurement/curvature/+curvature/+view/plotAnchorResiduals.m diff --git a/apps/image_measurement/curvature/private/plotDenseFitPoints.m b/apps/image_measurement/curvature/+curvature/+view/plotDenseFitPoints.m similarity index 100% rename from apps/image_measurement/curvature/private/plotDenseFitPoints.m rename to apps/image_measurement/curvature/+curvature/+view/plotDenseFitPoints.m diff --git a/apps/image_measurement/curvature/private/plotStaticCurveAnchorsView.m b/apps/image_measurement/curvature/+curvature/+view/plotStaticCurveAnchors.m similarity index 77% rename from apps/image_measurement/curvature/private/plotStaticCurveAnchorsView.m rename to apps/image_measurement/curvature/+curvature/+view/plotStaticCurveAnchors.m index fffe5b4..284f8bb 100644 --- a/apps/image_measurement/curvature/private/plotStaticCurveAnchorsView.m +++ b/apps/image_measurement/curvature/+curvature/+view/plotStaticCurveAnchors.m @@ -2,8 +2,8 @@ % labkit_CurvatureMeasurement_app overlay rendering. Inputs are axes, anchor % points, display curve, fit result, and dense-point visibility. Draws only into % the supplied axes. -function plotStaticCurveAnchorsView(ax, points, curve, fit, showDense) -%PLOTSTATICCURVEANCHORSVIEW Draw inactive curve anchors and fit residuals. +function plotStaticCurveAnchors(ax, points, curve, fit, showDense) +%PLOTSTATICCURVEANCHORS Draw inactive curve anchors and fit residuals. if isempty(points) return; @@ -18,9 +18,9 @@ function plotStaticCurveAnchorsView(ax, points, curve, fit, showDense) end if fit.ok if showDense - plotDenseFitPoints(ax, fit); + curvature.view.plotDenseFitPoints(ax, fit); end - plotAnchorResiduals(ax, points, fit); + curvature.view.plotAnchorResiduals(ax, points, fit); end plot(ax, points(:, 1), points(:, 2), 'o', ... 'LineStyle', 'none', ... diff --git a/apps/image_measurement/curvature/private/curvatureSummaryViewData.m b/apps/image_measurement/curvature/+curvature/+view/summaryViewData.m similarity index 80% rename from apps/image_measurement/curvature/private/curvatureSummaryViewData.m rename to apps/image_measurement/curvature/+curvature/+view/summaryViewData.m index cbef5d1..0d8f1d1 100644 --- a/apps/image_measurement/curvature/private/curvatureSummaryViewData.m +++ b/apps/image_measurement/curvature/+curvature/+view/summaryViewData.m @@ -2,15 +2,15 @@ % labkit_CurvatureMeasurement_app refreshSummary. Inputs are app state values % and edit-mode flags. Output contains point text, result table data, and detail % lines. This helper has no UI side effects. -function summary = curvatureSummaryViewData(imagePath, xPix, fit, lengthResult, curveEditActive, referenceEditActive) -%CURVATURESUMMARYVIEWDATA Build visible summary/table data for app state. +function summary = summaryViewData(imagePath, xPix, fit, lengthResult, curveEditActive, referenceEditActive) +%SUMMARYVIEWDATA Build visible summary/table data for app state. summary = struct(); summary.pointCountText = sprintf('Points: %d', numel(xPix)); if fit.ok - summary.tableData = fitResultTableData(fit, lengthResult); + summary.tableData = curvature.view.fitResultTableData(fit, lengthResult); summary.details = { ... - sprintf('Image: %s', emptyDash(imagePath)), ... + sprintf('Image: %s', curvature.view.emptyDash(imagePath)), ... sprintf('Center: xc = %.6f px, yc = %.6f px', fit.xc_px, fit.yc_px), ... sprintf('Radius: %.6f %s', fit.R_show, fit.unitLen), ... sprintf('Curvature: %.6f %s', fit.kappa_show, fit.unitK), ... @@ -20,15 +20,15 @@ fit.referencePx, fit.referenceLength, fit.scaleUnit, ... fit.scaleUnit, fit.px_per_unit)}; elseif lengthResult.ok - summary.tableData = lengthResultTableData(lengthResult); + summary.tableData = curvature.view.lengthResultTableData(lengthResult); summary.details = { ... - sprintf('Image: %s', emptyDash(imagePath)), ... + sprintf('Image: %s', curvature.view.emptyDash(imagePath)), ... sprintf('Curve length: %.6f %s', lengthResult.length_show, lengthResult.unitLen), ... sprintf('Curve length: %.6f px', lengthResult.length_px), ... sprintf('Points used: %d; px/%s = %.6g', ... lengthResult.pointCount, lengthResult.scaleUnit, lengthResult.px_per_unit)}; else - summary.tableData = initialResultTable(); + summary.tableData = curvature.view.initialResultTable(); if curveEditActive summary.details = {'Curve edit active. Double-click blank image space to add/insert points, drag points to move them, double-click a point to delete it. Use the scroll wheel over the image to zoom.'}; elseif referenceEditActive diff --git a/apps/image_measurement/curvature/private/ternary.m b/apps/image_measurement/curvature/+curvature/+view/ternary.m similarity index 100% rename from apps/image_measurement/curvature/private/ternary.m rename to apps/image_measurement/curvature/+curvature/+view/ternary.m diff --git a/apps/image_measurement/curvature/private/zoomAxesAtPoint.m b/apps/image_measurement/curvature/+curvature/+view/zoomAxesAtPoint.m similarity index 90% rename from apps/image_measurement/curvature/private/zoomAxesAtPoint.m rename to apps/image_measurement/curvature/+curvature/+view/zoomAxesAtPoint.m index 4a8547a..d124718 100644 --- a/apps/image_measurement/curvature/private/zoomAxesAtPoint.m +++ b/apps/image_measurement/curvature/+curvature/+view/zoomAxesAtPoint.m @@ -29,6 +29,6 @@ function zoomAxesAtPoint(ax, x, y, scrollCount, imageSize) newX = [x - xFrac * newWidth, x + (1 - xFrac) * newWidth]; newY = [y - yFrac * newHeight, y + (1 - yFrac) * newHeight]; - ax.XLim = clampLimits(newX, fullX); - ax.YLim = clampLimits(newY, fullY); + ax.XLim = curvature.view.clampLimits(newX, fullX); + ax.YLim = curvature.view.clampLimits(newY, fullY); end diff --git a/apps/image_measurement/curvature/curvatureMeasurementWorkflow.m b/apps/image_measurement/curvature/curvatureMeasurementWorkflow.m deleted file mode 100644 index 31f9009..0000000 --- a/apps/image_measurement/curvature/curvatureMeasurementWorkflow.m +++ /dev/null @@ -1,33 +0,0 @@ -function varargout = curvatureMeasurementWorkflow(command, varargin) -%CURVATUREMEASUREMENTWORKFLOW Dispatch app-owned curvature helpers. -% Expected caller: curvature app tests and migration-time workflow checks. -% Inputs are a workflow command plus command-specific arguments. Outputs match -% the selected app-private helper. This helper has no file side effects. - - switch string(command) - case "computeCurvatureFit" - opts = varargin{3}; - calibration = scaleOptionsFromStruct(opts); - doDensify = optionValue(opts, 'doDensify', true); - denseN = optionValue(opts, 'denseN', 300); - fitPathX = optionValue(opts, 'fitPathX', []); - fitPathY = optionValue(opts, 'fitPathY', []); - varargout{1} = computeCurvatureFit(varargin{1}, varargin{2}, ... - calibration, doDensify, denseN, fitPathX, fitPathY); - case "computeCurveLength" - opts = varargin{3}; - calibration = scaleOptionsFromStruct(opts); - varargout{1} = computeCurveLength(varargin{1}, varargin{2}, calibration); - case "buildCurvatureResultTable" - if numel(varargin) >= 3 - lengthResult = varargin{3}; - else - lengthResult = lengthResultFromFit(varargin{1}); - end - varargout{1} = buildCurvatureResultTable(varargin{1}, ... - string(varargin{2}), lengthResult); - otherwise - error('labkit:CurvatureMeasurement:UnknownWorkflowCommand', ... - 'Unknown curvature workflow helper command: %s.', command); - end -end diff --git a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m index 5c2d243..37d8cae 100644 --- a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m +++ b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m @@ -24,14 +24,14 @@ S.yPix = []; S.curveEditor = []; S.curveEditActive = false; - S.fit = emptyFitResult(); - S.length = emptyLengthResult(); + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); ui = labkit.ui.app.createShell(struct( ... 'title', 'Image Curvature Measurement', ... 'position', [90 70 1420 860], ... 'leftWidth', 390, ... - 'options', curvatureShellOptions())); + 'options', curvature.ui.shellOptions())); fig = ui.fig; layFA = ui.filesAnalysisGrid; laySR = ui.summaryResultsGrid; @@ -43,7 +43,7 @@ 'defaultScrollFcn', @onPreviewScroll, ... 'onTrace', debugLog.trace)); - controls = createCurvatureControls(layFA, laySR, layLog, imageRuntime, struct( ... + controls = curvature.ui.createControls(layFA, laySR, layLog, imageRuntime, struct( ... 'onOpenImage', @onOpenImage, ... 'onStartCurveEdit', @onStartCurveEdit, ... 'onUndoCurvePoint', @onUndoCurvePoint, ... @@ -120,8 +120,8 @@ function onOpenImage(~, ~) S.curveEditor.delete(); end S.curveEditor = []; - S.fit = emptyFitResult(); - S.length = emptyLengthResult(); + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); txtImage.Value = char(filepath); addLog(sprintf('Loaded image: %s', filepath)); refreshAll(); @@ -147,7 +147,7 @@ function onStartCurveEdit(~, ~) S.curveEditActive = true; ensureCurveEditor(); S.curveEditor.start([S.xPix(:), S.yPix(:)]); - S.fit = emptyFitResult(); + S.fit = curvature.state.emptyFitResult(); addLog('Started curve edit. Double-click blank image space to add/insert points; drag points to move; double-click a point to delete it.'); refreshAll(); end @@ -155,8 +155,8 @@ function onStartCurveEdit(~, ~) function onCurveEditorChanged(points, reason) S.xPix = points(:, 1); S.yPix = points(:, 2); - S.fit = emptyFitResult(); - S.length = emptyLengthResult(); + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); refreshSummary(); if any(strcmp(reason, {'add point', 'delete point', 'move point'})) addLog(sprintf('Curve edit updated: %d point(s).', numel(S.xPix))); @@ -175,8 +175,8 @@ function onClearCurve(~, ~) else S.xPix = []; S.yPix = []; - S.fit = emptyFitResult(); - S.length = emptyLengthResult(); + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); refreshAll(); end addLog('Cleared curve points.'); @@ -190,8 +190,8 @@ function onBeforeReferenceEdit(~, ~) end function onReferenceEditChanged(~, reason) - S.fit = emptyFitResult(); - S.length = emptyLengthResult(); + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); reasonText = char(string(reason)); if strcmp(reasonText, 'start') addLog('Started reference-pixel edit. Double-click two endpoints, then drag endpoints to refine.'); @@ -222,10 +222,10 @@ function onFitCurvature(~, ~) try fitPath = currentCurveFitPoints(); - S.fit = computeCurvatureFit(S.xPix, S.yPix, scaleTool.calibration(), ... + S.fit = curvature.ops.computeCurvatureFit(S.xPix, S.yPix, scaleTool.calibration(), ... chkDensify.Value, round(edtDenseN.Value), ... fitPath(:, 1), fitPath(:, 2)); - S.length = lengthResultFromFit(S.fit); + S.length = curvature.state.lengthResultFromFit(S.fit); catch ME showError('Circle fit failed', ME.message); return; @@ -248,7 +248,7 @@ function onMeasureCurveLength(~, ~) points = currentCurveLengthPoints(); try - S.length = computeCurveLength(points(:, 1), points(:, 2), ... + S.length = curvature.ops.computeCurveLength(points(:, 1), points(:, 2), ... scaleTool.calibration()); catch ME showError('Curve length failed', ME.message); @@ -275,7 +275,7 @@ function onExportCSV(~, ~) filepath = string(fullfile(fp, fn)); try - T = buildCurvatureResultTable(S.fit, S.imagePath, S.length); + T = curvature.export.buildResultTable(S.fit, S.imagePath, S.length); writetable(T, filepath); catch ME showError('Could not export result CSV', ME.message); @@ -330,10 +330,10 @@ function ensureCurveEditor() end function onCalibrationSettingsChanged(~, reason) - S.fit = emptyFitResult(); - S.length = emptyLengthResult(); + S.fit = curvature.state.emptyFitResult(); + S.length = curvature.state.emptyLengthResult(); scaleTool.clearScaleBar(); - if isReferenceEditReason(reason) + if curvature.ui.isReferenceEditReason(reason) refreshScaleReadout(); refreshSummary(); else @@ -379,23 +379,23 @@ function updateModeControls() referenceEditActive = scaleTool.isReferenceEditActive(); editActive = S.curveEditActive || referenceEditActive; - btnStartCurve.Enable = ternary(hasImage, 'on', 'off'); - btnStartCurve.Text = ternary(S.curveEditActive, ... + btnStartCurve.Enable = curvature.view.ternary(hasImage, 'on', 'off'); + btnStartCurve.Text = curvature.view.ternary(S.curveEditActive, ... 'Finish curve edit', 'Start curve edit'); scaleTool.setEnabled(struct( ... 'hasImage', hasImage, ... 'blockInputs', S.curveEditActive, ... 'blockPlacement', editActive)); - btnUndoPoint.Enable = ternary(hasCurve && ~referenceEditActive, 'on', 'off'); - btnClearCurve.Enable = ternary(hasCurve && ~referenceEditActive, 'on', 'off'); - chkDensify.Enable = ternary(~editActive, 'on', 'off'); - edtDenseN.Enable = ternary(~editActive, 'on', 'off'); - chkShowDense.Enable = ternary(S.fit.ok && ~editActive, 'on', 'off'); - btnFit.Enable = ternary(numel(S.xPix) >= 3 && ~editActive, 'on', 'off'); - btnMeasureLength.Enable = ternary(numel(S.xPix) >= 2 && ~editActive, 'on', 'off'); - btnExportCSV.Enable = ternary((S.fit.ok || S.length.ok) && ~editActive, 'on', 'off'); - btnExportOverlay.Enable = ternary(hasImage && ~editActive, 'on', 'off'); + btnUndoPoint.Enable = curvature.view.ternary(hasCurve && ~referenceEditActive, 'on', 'off'); + btnClearCurve.Enable = curvature.view.ternary(hasCurve && ~referenceEditActive, 'on', 'off'); + chkDensify.Enable = curvature.view.ternary(~editActive, 'on', 'off'); + edtDenseN.Enable = curvature.view.ternary(~editActive, 'on', 'off'); + chkShowDense.Enable = curvature.view.ternary(S.fit.ok && ~editActive, 'on', 'off'); + btnFit.Enable = curvature.view.ternary(numel(S.xPix) >= 3 && ~editActive, 'on', 'off'); + btnMeasureLength.Enable = curvature.view.ternary(numel(S.xPix) >= 2 && ~editActive, 'on', 'off'); + btnExportCSV.Enable = curvature.view.ternary((S.fit.ok || S.length.ok) && ~editActive, 'on', 'off'); + btnExportOverlay.Enable = curvature.view.ternary(hasImage && ~editActive, 'on', 'off'); end function refreshImageOverlay() @@ -455,7 +455,7 @@ function plotStaticCurveAnchors(ax) if ~isempty(S.curveEditor) curve = S.curveEditor.curvePoints(); end - plotStaticCurveAnchorsView(ax, points, curve, S.fit, chkShowDense.Value); + curvature.view.plotStaticCurveAnchors(ax, points, curve, S.fit, chkShowDense.Value); end function onPreviewScroll(~, event) @@ -465,14 +465,14 @@ function onPreviewScroll(~, event) point = ui.topAxes.CurrentPoint; x = point(1, 1); y = point(1, 2); - if ~insideImageBounds(x, y, size(S.image)) + if ~curvature.view.insideImageBounds(x, y, size(S.image)) return; end - zoomAxesAtPoint(ui.topAxes, x, y, event.VerticalScrollCount, size(S.image)); + curvature.view.zoomAxesAtPoint(ui.topAxes, x, y, event.VerticalScrollCount, size(S.image)); end function refreshSummary() - summary = curvatureSummaryViewData(S.imagePath, S.xPix, S.fit, ... + summary = curvature.view.summaryViewData(S.imagePath, S.xPix, S.fit, ... S.length, S.curveEditActive, scaleTool.isReferenceEditActive()); txtPointCount.Value = summary.pointCountText; resultTable.Data = summary.tableData; diff --git a/apps/image_measurement/focus_stack/private/buildFocusStackSummaryTable.m b/apps/image_measurement/focus_stack/+focus_stack/+export/buildSummaryTable.m similarity index 81% rename from apps/image_measurement/focus_stack/private/buildFocusStackSummaryTable.m rename to apps/image_measurement/focus_stack/+focus_stack/+export/buildSummaryTable.m index a6a2028..f28c936 100644 --- a/apps/image_measurement/focus_stack/private/buildFocusStackSummaryTable.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+export/buildSummaryTable.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. -function T = buildFocusStackSummaryTable(result, paths) -%BUILDFOCUSSTACKSUMMARYTABLE Build summary CSV table for labkit_FocusStack_app. +function T = buildSummaryTable(result, paths) +%BUILDSUMMARYTABLE Build summary CSV table for labkit_FocusStack_app. % % Expected caller: -% labkit_FocusStack_app export callback and workflow tests. +% labkit_FocusStack_app export callback and package tests. % % Inputs/outputs: % Completed focus-stack result and source image paths. Returns the app-owned @@ -25,7 +25,7 @@ imageNames = strings(result.inputCount, 1); for k = 1:result.inputCount - imageNames(k) = string(displayNameFromPath(paths(k))); + imageNames(k) = string(focus_stack.view.displayNameFromPath(paths(k))); end T = table( ... diff --git a/apps/image_measurement/focus_stack/private/assertSupportedFocusImagePaths.m b/apps/image_measurement/focus_stack/+focus_stack/+io/assertSupportedImagePaths.m similarity index 58% rename from apps/image_measurement/focus_stack/private/assertSupportedFocusImagePaths.m rename to apps/image_measurement/focus_stack/+focus_stack/+io/assertSupportedImagePaths.m index 5a055b3..4b80a8e 100644 --- a/apps/image_measurement/focus_stack/private/assertSupportedFocusImagePaths.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/assertSupportedImagePaths.m @@ -1,14 +1,14 @@ % App-owned focus-stack extension validator. Expected caller: focus-stack app -% private loading helpers. Input is a path vector. Throws on unsupported image +% package loading helpers. Input is a path vector. Throws on unsupported image % extensions and has no side effects. -function assertSupportedFocusImagePaths(paths) -%ASSERTSUPPORTEDFOCUSIMAGEPATHS Validate focus-stack image path extensions. -% Expected caller: focus-stack app private loading helpers. Input is a path +function assertSupportedImagePaths(paths) +%ASSERTSUPPORTEDIMAGEPATHS Validate focus-stack image path extensions. +% Expected caller: focus-stack app package loading helpers. Input is a path % vector. This helper throws on unsupported image extensions and has no side % effects. for k = 1:numel(paths) - if ~isSupportedFocusImagePath(paths(k)) + if ~focus_stack.io.isSupportedImagePath(paths(k)) error('labkit_FocusStack_app:UnsupportedImageFile', ... 'Unsupported image file type: %s', char(paths(k))); end diff --git a/apps/image_measurement/focus_stack/private/findFocusStackImages.m b/apps/image_measurement/focus_stack/+focus_stack/+io/findImages.m similarity index 73% rename from apps/image_measurement/focus_stack/private/findFocusStackImages.m rename to apps/image_measurement/focus_stack/+focus_stack/+io/findImages.m index 973715f..c3bc2fd 100644 --- a/apps/image_measurement/focus_stack/private/findFocusStackImages.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/findImages.m @@ -1,10 +1,10 @@ % App-owned focus-stack folder discovery helper. Expected caller: -% labkit_FocusStack_app and focusStackWorkflow. Input is a folder path. Output +% labkit_FocusStack_app and focus_stack package tests. Input is a folder path. Output % is a sorted string column of supported image paths. Reads directory metadata % only and has no write side effects. -function paths = findFocusStackImages(folder) -%FINDFOCUSSTACKIMAGES Find supported focus-stack image files in a folder. -% Expected caller: labkit_FocusStack_app and focusStackWorkflow. Input is a +function paths = findImages(folder) +%FINDIMAGES Find supported focus-stack image files in a folder. +% Expected caller: labkit_FocusStack_app and focus_stack package tests. Input is a % folder path. Output is a sorted string column of supported image file paths. % This helper reads directory metadata only and has no write side effects. @@ -20,7 +20,7 @@ if entry.isdir continue; end - keep(k) = isSupportedFocusImagePath(entry.name); + keep(k) = focus_stack.io.isSupportedImagePath(entry.name); end entries = entries(keep); @@ -28,7 +28,7 @@ for k = 1:numel(entries) paths(k) = string(fullfile(folder, entries(k).name)); end - paths = sortFocusStackPathsByName(paths); + paths = focus_stack.io.sortPathsByName(paths); if numel(paths) < 2 error('labkit_FocusStack_app:NotEnoughImages', ... 'Focus stacking requires at least two image files in the selected folder.'); diff --git a/apps/image_measurement/focus_stack/private/focusImageDialogFilter.m b/apps/image_measurement/focus_stack/+focus_stack/+io/imageDialogFilter.m similarity index 73% rename from apps/image_measurement/focus_stack/private/focusImageDialogFilter.m rename to apps/image_measurement/focus_stack/+focus_stack/+io/imageDialogFilter.m index 198eb68..35d8ce1 100644 --- a/apps/image_measurement/focus_stack/private/focusImageDialogFilter.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/imageDialogFilter.m @@ -1,8 +1,8 @@ % App-owned focus-stack file-dialog filter helper. Expected caller: % labkit_FocusStack_app open-files callback. Output is the uigetfile filter % cell array. This helper has no side effects. -function filter = focusImageDialogFilter() -%FOCUSIMAGEDIALOGFILTER Return the supported focus image dialog filter. +function filter = imageDialogFilter() +%IMAGEDIALOGFILTER Return the supported focus image dialog filter. filter = {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'}; diff --git a/apps/image_measurement/focus_stack/+focus_stack/+io/isSupportedImagePath.m b/apps/image_measurement/focus_stack/+focus_stack/+io/isSupportedImagePath.m new file mode 100644 index 0000000..85931ca --- /dev/null +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/isSupportedImagePath.m @@ -0,0 +1,11 @@ +% App-owned focus-stack extension predicate. Expected caller: focus-stack app +% package loading helpers. Input is a path or filename. Output is a scalar +% logical based on the file extension only. +function tf = isSupportedImagePath(pathValue) +%ISSUPPORTEDIMAGEPATH Return true for supported focus-stack image files. +% Expected caller: focus-stack app package loading helpers. Input is a path or +% filename. Output is a scalar logical based on the file extension only. + + [~, ~, ext] = fileparts(char(pathValue)); + tf = any(strcmpi(ext, focus_stack.io.supportedImageExtensions())); +end diff --git a/apps/image_measurement/focus_stack/private/readFocusStackImages.m b/apps/image_measurement/focus_stack/+focus_stack/+io/readImages.m similarity index 81% rename from apps/image_measurement/focus_stack/private/readFocusStackImages.m rename to apps/image_measurement/focus_stack/+focus_stack/+io/readImages.m index 6296f2e..f8d7ba1 100644 --- a/apps/image_measurement/focus_stack/private/readFocusStackImages.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/readImages.m @@ -2,15 +2,15 @@ % labkit_FocusStack_app run callback. Input is a vector of image paths. Output % is a cell column of image arrays. Reads image files and has no write side % effects. -function images = readFocusStackImages(paths) -%READFOCUSSTACKIMAGES Read selected focus-stack images from disk. +function images = readImages(paths) +%READIMAGES Read selected focus-stack images from disk. paths = string(paths(:)); if isempty(paths) error('labkit_FocusStack_app:NoImagesSelected', ... 'Select at least one image file.'); end - assertSupportedFocusImagePaths(paths); + focus_stack.io.assertSupportedImagePaths(paths); images = cell(numel(paths), 1); for k = 1:numel(paths) diff --git a/apps/image_measurement/focus_stack/private/selectedFocusImagePaths.m b/apps/image_measurement/focus_stack/+focus_stack/+io/selectedImagePaths.m similarity index 69% rename from apps/image_measurement/focus_stack/private/selectedFocusImagePaths.m rename to apps/image_measurement/focus_stack/+focus_stack/+io/selectedImagePaths.m index f473adf..64623bd 100644 --- a/apps/image_measurement/focus_stack/private/selectedFocusImagePaths.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/selectedImagePaths.m @@ -1,10 +1,10 @@ % App-owned focus-stack selected-file normalization helper. Expected caller: -% labkit_FocusStack_app and focusStackWorkflow. Inputs are raw uigetfile values. +% labkit_FocusStack_app and focus_stack package tests. Inputs are raw uigetfile values. % Output is a sorted string column of image paths. Validates extensions only and % has no file side effects. -function paths = selectedFocusImagePaths(files, folder) -%SELECTEDFOCUSIMAGEPATHS Normalize manually selected focus-stack image paths. -% Expected caller: labkit_FocusStack_app and focusStackWorkflow. Inputs are the +function paths = selectedImagePaths(files, folder) +%SELECTEDIMAGEPATHS Normalize manually selected focus-stack image paths. +% Expected caller: labkit_FocusStack_app and focus_stack package tests. Inputs are the % raw uigetfile files value and folder value. Output is a sorted string column. % This helper validates extensions only and has no file side effects. @@ -30,6 +30,6 @@ for k = 1:numel(names) paths(k) = string(fullfile(folder, names(k))); end - paths = sortFocusStackPathsByName(paths); - assertSupportedFocusImagePaths(paths); + paths = focus_stack.io.sortPathsByName(paths); + focus_stack.io.assertSupportedImagePaths(paths); end diff --git a/apps/image_measurement/focus_stack/private/sortFocusStackPathsByName.m b/apps/image_measurement/focus_stack/+focus_stack/+io/sortPathsByName.m similarity index 65% rename from apps/image_measurement/focus_stack/private/sortFocusStackPathsByName.m rename to apps/image_measurement/focus_stack/+focus_stack/+io/sortPathsByName.m index c317e20..5d14239 100644 --- a/apps/image_measurement/focus_stack/private/sortFocusStackPathsByName.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/sortPathsByName.m @@ -1,9 +1,9 @@ % App-owned focus-stack path sorting helper. Expected caller: focus-stack app -% private loading helpers. Input is a path vector. Output is a string column +% package loading helpers. Input is a path vector. Output is a string column % sorted by base filename plus extension. -function paths = sortFocusStackPathsByName(paths) -%SORTFOCUSSTACKPATHSBYNAME Sort focus-stack paths by case-insensitive name. -% Expected caller: focus-stack app private loading helpers. Input is a path +function paths = sortPathsByName(paths) +%SORTPATHSBYNAME Sort focus-stack paths by case-insensitive name. +% Expected caller: focus-stack app package loading helpers. Input is a path % vector. Output is a string column sorted by base filename plus extension. paths = string(paths(:)); diff --git a/apps/image_measurement/focus_stack/private/supportedFocusImageExtensions.m b/apps/image_measurement/focus_stack/+focus_stack/+io/supportedImageExtensions.m similarity index 53% rename from apps/image_measurement/focus_stack/private/supportedFocusImageExtensions.m rename to apps/image_measurement/focus_stack/+focus_stack/+io/supportedImageExtensions.m index 930d53d..72d4541 100644 --- a/apps/image_measurement/focus_stack/private/supportedFocusImageExtensions.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+io/supportedImageExtensions.m @@ -1,9 +1,9 @@ % App-owned focus-stack extension list helper. Expected caller: focus-stack app -% private loading helpers. Output is a cell array of lowercase extension strings +% package loading helpers. Output is a cell array of lowercase extension strings % and the helper has no side effects. -function extensions = supportedFocusImageExtensions() -%SUPPORTEDFOCUSIMAGEEXTENSIONS Return supported focus-stack image extensions. -% Expected caller: focus-stack app private loading helpers. Output is a cell +function extensions = supportedImageExtensions() +%SUPPORTEDIMAGEEXTENSIONS Return supported focus-stack image extensions. +% Expected caller: focus-stack app package loading helpers. Output is a cell % array of lowercase extension strings. This helper has no side effects. extensions = {'.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'}; diff --git a/apps/image_measurement/focus_stack/private/alignFocusStackImages.m b/apps/image_measurement/focus_stack/+focus_stack/+ops/alignImages.m similarity index 89% rename from apps/image_measurement/focus_stack/private/alignFocusStackImages.m rename to apps/image_measurement/focus_stack/+focus_stack/+ops/alignImages.m index 03a62ff..e9bb094 100644 --- a/apps/image_measurement/focus_stack/private/alignFocusStackImages.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+ops/alignImages.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. -function [alignedImages, lines] = alignFocusStackImages(images) -%ALIGNFOCUSSTACKIMAGES Align focus-stack images for labkit_FocusStack_app. +function [alignedImages, lines] = alignImages(images) +%ALIGNIMAGES Align focus-stack images for labkit_FocusStack_app. % % Expected caller: -% labkit_FocusStack_app run callback and workflow tests. +% labkit_FocusStack_app run callback and package tests. % % Inputs/outputs: % Cell array or numeric stack of images. Returns images aligned to the @@ -14,7 +14,7 @@ % Side effects: % None. This helper performs GUI-free registration only. - images = normalizeImageCell(images); + images = focus_stack.ops.normalizeImageCell(images); alignedImages = images; lines = {}; if numel(images) < 2 @@ -32,7 +32,7 @@ [alignedImages{k}, method] = alignImageToReference(reference, images{k}); lines{end+1} = sprintf('Registered image %d using %s.', k, method); %#ok catch ME - alignedImages{k} = resizeImageToReference(images{k}, size(reference)); + alignedImages{k} = focus_stack.ops.resizeImageToReference(images{k}, size(reference)); lines{end+1} = sprintf('Image %d registration skipped: %s', k, ME.message); %#ok end end @@ -40,7 +40,7 @@ function [alignedImage, method] = alignImageToReference(referenceImage, movingImage) origClass = class(movingImage); - movingImage = resizeImageToReference(movingImage, size(referenceImage)); + movingImage = focus_stack.ops.resizeImageToReference(movingImage, size(referenceImage)); fixedGray = alignmentGray(referenceImage); movingGray = alignmentGray(movingImage); @@ -93,8 +93,8 @@ end function gray = alignmentGray(imageData) - gray = normalizeGray(imageData); - lowpass = boxMean2(gray, 31); + gray = focus_stack.ops.normalizeGray(imageData); + lowpass = focus_stack.ops.boxMean2(gray, 31); gray = gray - lowpass; mx = max(abs(gray(:))); if mx > 0 diff --git a/apps/image_measurement/focus_stack/private/boxMean2.m b/apps/image_measurement/focus_stack/+focus_stack/+ops/boxMean2.m similarity index 83% rename from apps/image_measurement/focus_stack/private/boxMean2.m rename to apps/image_measurement/focus_stack/+focus_stack/+ops/boxMean2.m index c315564..95a9b1e 100644 --- a/apps/image_measurement/focus_stack/private/boxMean2.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+ops/boxMean2.m @@ -1,5 +1,5 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function meanImage = boxMean2(imageData, windowSize) %BOXMEAN2 Compute a normalized box mean for focus-stack helpers. diff --git a/apps/image_measurement/focus_stack/private/computeFocusStack.m b/apps/image_measurement/focus_stack/+focus_stack/+ops/computeFocusStack.m similarity index 91% rename from apps/image_measurement/focus_stack/private/computeFocusStack.m rename to apps/image_measurement/focus_stack/+focus_stack/+ops/computeFocusStack.m index 3cf1663..d6e28fe 100644 --- a/apps/image_measurement/focus_stack/private/computeFocusStack.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+ops/computeFocusStack.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function result = computeFocusStack(images, opts) %COMPUTEFOCUSSTACK Fuse focus-stack images for labkit_FocusStack_app. % % Expected caller: -% labkit_FocusStack_app run callback and workflow tests. +% labkit_FocusStack_app run callback and package tests. % % Inputs/outputs: % Cell array or numeric stack of images plus fusion options. Returns the @@ -18,7 +18,7 @@ if nargin < 2 opts = struct(); end - images = normalizeImageCell(images); + images = focus_stack.ops.normalizeImageCell(images); if numel(images) < 2 error('labkit_FocusStack_app:NotEnoughImages', ... 'Focus stacking requires at least two images.'); @@ -47,7 +47,7 @@ coverage(k) = mean(focusIndex(:) == k); end - result = emptyFocusStackResult(); + result = focus_stack.state.emptyResult(); result.ok = true; result.message = ''; result.fused = fused; @@ -103,7 +103,7 @@ fused = weightedPyramidLevel(gaussPyramids, pyramidLevels + 1, baseWeights, channels); for level = pyramidLevels:-1:1 - fused = resizeImageToSize(fused, size(fusedLap{level})) + fusedLap{level}; + fused = focus_stack.ops.resizeImageToSize(fused, size(fusedLap{level})) + fusedLap{level}; end fused = min(max(fused, 0), 1); end @@ -115,7 +115,7 @@ for level = 1:levels blurred = gaussianBlurImage(gaussPyramid{level}, 1); gaussPyramid{level + 1} = imresize(blurred, 0.5, 'bilinear'); - expanded = resizeImageToSize(gaussPyramid{level + 1}, size(gaussPyramid{level})); + expanded = focus_stack.ops.resizeImageToSize(gaussPyramid{level + 1}, size(gaussPyramid{level})); lapPyramid{level} = gaussPyramid{level} - expanded; end end @@ -132,7 +132,7 @@ function score = focusDetailEnergy(detailImage, focusWindow) gray = grayImage(detailImage); - score = boxMean2(gray .^ 2, focusWindow); + score = focus_stack.ops.boxMean2(gray .^ 2, focusWindow); score(~isfinite(score)) = 0; score = max(score, 0); end @@ -142,13 +142,13 @@ sample = pyramids{1}{level}; scoreStack = zeros(size(sample, 1), size(sample, 2), imageCount); for k = 1:imageCount - scoreStack(:, :, k) = localVarianceScore(normalizeGray(pyramids{k}{level}), 5); + scoreStack(:, :, k) = localVarianceScore(focus_stack.ops.normalizeGray(pyramids{k}{level}), 5); end end function score = localVarianceScore(gray, windowSize) - meanValue = boxMean2(gray, windowSize); - score = boxMean2(gray .^ 2, windowSize) - meanValue .^ 2; + meanValue = focus_stack.ops.boxMean2(gray, windowSize); + score = focus_stack.ops.boxMean2(gray .^ 2, windowSize) - meanValue .^ 2; score(~isfinite(score)) = 0; score = max(score, 0); end @@ -168,7 +168,7 @@ w(lowConfidence & zeroScore) = 1 / imageCount; end if smoothRadius > 0 - w = boxMean2(w, 2 * smoothRadius + 1); + w = focus_stack.ops.boxMean2(w, 2 * smoothRadius + 1); end weights(:, :, k) = w; end @@ -212,7 +212,7 @@ for k = 1:imageCount img = images{k}; if ~isequal(size(img, 1), heightPx) || ~isequal(size(img, 2), widthPx) - img = resizeImageToReference(img, refSize); + img = focus_stack.ops.resizeImageToReference(img, refSize); resizedCount = resizedCount + 1; end img = convertChannels(im2double(img), channels); @@ -233,7 +233,7 @@ function img = convertChannels(img, channels) if channels == 1 if ndims(img) == 3 - img = normalizeGray(img); + img = focus_stack.ops.normalizeGray(img); end return; end diff --git a/apps/image_measurement/focus_stack/private/normalizeGray.m b/apps/image_measurement/focus_stack/+focus_stack/+ops/normalizeGray.m similarity index 87% rename from apps/image_measurement/focus_stack/private/normalizeGray.m rename to apps/image_measurement/focus_stack/+focus_stack/+ops/normalizeGray.m index aa4a343..2e71eb3 100644 --- a/apps/image_measurement/focus_stack/private/normalizeGray.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+ops/normalizeGray.m @@ -1,5 +1,5 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function gray = normalizeGray(imageData) %NORMALIZEGRAY Convert focus-stack image data to normalized grayscale. diff --git a/apps/image_measurement/focus_stack/private/normalizeImageCell.m b/apps/image_measurement/focus_stack/+focus_stack/+ops/normalizeImageCell.m similarity index 90% rename from apps/image_measurement/focus_stack/private/normalizeImageCell.m rename to apps/image_measurement/focus_stack/+focus_stack/+ops/normalizeImageCell.m index a26c2ad..eb918e0 100644 --- a/apps/image_measurement/focus_stack/private/normalizeImageCell.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+ops/normalizeImageCell.m @@ -1,5 +1,5 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function images = normalizeImageCell(images) %NORMALIZEIMAGECELL Normalize focus-stack input image containers. diff --git a/apps/image_measurement/focus_stack/private/resizeImageToReference.m b/apps/image_measurement/focus_stack/+focus_stack/+ops/resizeImageToReference.m similarity index 82% rename from apps/image_measurement/focus_stack/private/resizeImageToReference.m rename to apps/image_measurement/focus_stack/+focus_stack/+ops/resizeImageToReference.m index c250d3b..9156501 100644 --- a/apps/image_measurement/focus_stack/private/resizeImageToReference.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+ops/resizeImageToReference.m @@ -1,5 +1,5 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function imageOut = resizeImageToReference(imageIn, referenceSize) %RESIZEIMAGETOREFERENCE Resize focus-stack image data to a reference frame. diff --git a/apps/image_measurement/focus_stack/private/resizeImageToSize.m b/apps/image_measurement/focus_stack/+focus_stack/+ops/resizeImageToSize.m similarity index 85% rename from apps/image_measurement/focus_stack/private/resizeImageToSize.m rename to apps/image_measurement/focus_stack/+focus_stack/+ops/resizeImageToSize.m index 6b76699..80c8970 100644 --- a/apps/image_measurement/focus_stack/private/resizeImageToSize.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+ops/resizeImageToSize.m @@ -1,5 +1,5 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function imageOut = resizeImageToSize(imageIn, targetSize) %RESIZEIMAGETOSIZE Resize focus-stack image data to an explicit size. diff --git a/apps/image_measurement/focus_stack/private/emptyFocusStackResult.m b/apps/image_measurement/focus_stack/+focus_stack/+state/emptyResult.m similarity index 70% rename from apps/image_measurement/focus_stack/private/emptyFocusStackResult.m rename to apps/image_measurement/focus_stack/+focus_stack/+state/emptyResult.m index 3ed6699..dc36973 100644 --- a/apps/image_measurement/focus_stack/private/emptyFocusStackResult.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+state/emptyResult.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. -function result = emptyFocusStackResult() -%EMPTYFOCUSSTACKRESULT Return default result for labkit_FocusStack_app. +function result = emptyResult() +%EMPTYRESULT Return default result for labkit_FocusStack_app. % % Expected caller: -% labkit_FocusStack_app and app-private focus fusion helpers. +% labkit_FocusStack_app and package-owned focus fusion helpers. % % Inputs/outputs: % No inputs. Returns the app-owned result struct shape used by GUI preview, diff --git a/apps/image_measurement/focus_stack/private/focusFusionPresetSettings.m b/apps/image_measurement/focus_stack/+focus_stack/+state/fusionPresetSettings.m similarity index 83% rename from apps/image_measurement/focus_stack/private/focusFusionPresetSettings.m rename to apps/image_measurement/focus_stack/+focus_stack/+state/fusionPresetSettings.m index 5014d94..b1c856b 100644 --- a/apps/image_measurement/focus_stack/private/focusFusionPresetSettings.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+state/fusionPresetSettings.m @@ -1,7 +1,7 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. -function settings = focusFusionPresetSettings(preset) +function settings = fusionPresetSettings(preset) %FOCUSFUSIONPRESETSETTINGS Return preset options for labkit_FocusStack_app. % % Expected caller: diff --git a/apps/image_measurement/focus_stack/private/focusStackDetails.m b/apps/image_measurement/focus_stack/+focus_stack/+view/details.m similarity index 83% rename from apps/image_measurement/focus_stack/private/focusStackDetails.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/details.m index 4e12721..318dca8 100644 --- a/apps/image_measurement/focus_stack/private/focusStackDetails.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+view/details.m @@ -1,8 +1,8 @@ % App-owned focus-stack summary text helper. Expected caller: % labkit_FocusStack_app result refresh. Inputs are result, source paths, and % registration lines. Output is a cell row of detail strings. -function lines = focusStackDetails(result, paths, registrationLines) -%FOCUSSTACKDETAILS Return user-facing focus-stack detail lines. +function lines = details(result, paths, registrationLines) +%DETAILS Return user-facing focus-stack detail lines. lines = { ... sprintf('Method: %s', result.method), ... @@ -12,7 +12,7 @@ sprintf('Detail scale: %d px; blend radius: %d px; uncertain blend: %.1f%%', ... result.focusWindow, result.smoothRadius, 100 * result.minConfidence), ... 'Selected pixel coverage by source:'}; - names = displayImageNamesForDetails(paths, result.inputCount); + names = focus_stack.view.displayImageNamesForDetails(paths, result.inputCount); for k = 1:result.inputCount lines{end+1} = sprintf(' %d. %s: %.2f%%', ... k, names{k}, 100 * result.focusCoverage(k)); %#ok diff --git a/apps/image_measurement/focus_stack/private/displayImageNames.m b/apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNames.m similarity index 85% rename from apps/image_measurement/focus_stack/private/displayImageNames.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNames.m index 284d02e..efce602 100644 --- a/apps/image_measurement/focus_stack/private/displayImageNames.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNames.m @@ -7,6 +7,6 @@ paths = string(paths(:)); names = cell(numel(paths), 1); for k = 1:numel(paths) - names{k} = displayNameFromPath(paths(k)); + names{k} = focus_stack.view.displayNameFromPath(paths(k)); end end diff --git a/apps/image_measurement/focus_stack/private/displayImageNamesForDetails.m b/apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNamesForDetails.m similarity index 77% rename from apps/image_measurement/focus_stack/private/displayImageNamesForDetails.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNamesForDetails.m index c51616c..bfa29a2 100644 --- a/apps/image_measurement/focus_stack/private/displayImageNamesForDetails.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+view/displayImageNamesForDetails.m @@ -1,5 +1,5 @@ % App-owned focus-stack display-name helper. Expected caller: -% focusStackDetails. Inputs are source paths and expected count. Output is a +% details. Inputs are source paths and expected count. Output is a % cell column of display names with synthetic fallbacks for missing paths. function names = displayImageNamesForDetails(paths, count) %DISPLAYIMAGENAMESFORDETAILS Return detail display names for stack sources. @@ -8,7 +8,7 @@ names = cell(count, 1); for k = 1:count if k <= numel(paths) - names{k} = displayNameFromPath(paths(k)); + names{k} = focus_stack.view.displayNameFromPath(paths(k)); else names{k} = sprintf('slice_%03d', k); end diff --git a/apps/image_measurement/focus_stack/private/displayNameFromPath.m b/apps/image_measurement/focus_stack/+focus_stack/+view/displayNameFromPath.m similarity index 69% rename from apps/image_measurement/focus_stack/private/displayNameFromPath.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/displayNameFromPath.m index c7bf09d..fbd37c7 100644 --- a/apps/image_measurement/focus_stack/private/displayNameFromPath.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+view/displayNameFromPath.m @@ -1,11 +1,11 @@ -% App-private image measurement helper. Expected caller: owning app callbacks -% and workflow tests. Inputs, outputs, and side effects are +% App-owned image measurement package helper. Expected caller: owning app callbacks +% and package tests. Inputs, outputs, and side effects are % documented with the helper function below. function name = displayNameFromPath(pathValue) %DISPLAYNAMEFROMPATH Return the app display name for a source image path. % % Expected caller: -% labkit_FocusStack_app display helpers and app-private summary-table code. +% labkit_FocusStack_app display helpers and package-owned summary-table code. % % Inputs/outputs: % String-like path value. Returns base filename plus extension, or the diff --git a/apps/image_measurement/focus_stack/private/focusIndexRgb.m b/apps/image_measurement/focus_stack/+focus_stack/+view/focusIndexRgb.m similarity index 100% rename from apps/image_measurement/focus_stack/private/focusIndexRgb.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/focusIndexRgb.m diff --git a/apps/image_measurement/focus_stack/private/initialResultTable.m b/apps/image_measurement/focus_stack/+focus_stack/+view/initialResultTable.m similarity index 100% rename from apps/image_measurement/focus_stack/private/initialResultTable.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/initialResultTable.m diff --git a/apps/image_measurement/focus_stack/private/previewImage.m b/apps/image_measurement/focus_stack/+focus_stack/+view/previewImage.m similarity index 100% rename from apps/image_measurement/focus_stack/private/previewImage.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/previewImage.m diff --git a/apps/image_measurement/focus_stack/private/focusStackResultTableData.m b/apps/image_measurement/focus_stack/+focus_stack/+view/resultTableData.m similarity index 86% rename from apps/image_measurement/focus_stack/private/focusStackResultTableData.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/resultTableData.m index 6483fd1..897ae8d 100644 --- a/apps/image_measurement/focus_stack/private/focusStackResultTableData.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+view/resultTableData.m @@ -1,8 +1,8 @@ % App-owned focus-stack visible table formatter. Expected caller: % labkit_FocusStack_app. Input is a focus-stack result struct. Output is % metric/value cell table data. This helper has no side effects. -function data = focusStackResultTableData(result) -%FOCUSSTACKRESULTTABLEDATA Return visible focus-stack result table rows. +function data = resultTableData(result) +%RESULTTABLEDATA Return visible focus-stack result table rows. [dominantCoverage, dominantIndex] = max(result.focusCoverage); data = { ... diff --git a/apps/image_measurement/focus_stack/private/ternary.m b/apps/image_measurement/focus_stack/+focus_stack/+view/ternary.m similarity index 100% rename from apps/image_measurement/focus_stack/private/ternary.m rename to apps/image_measurement/focus_stack/+focus_stack/+view/ternary.m diff --git a/apps/image_measurement/focus_stack/focusStackWorkflow.m b/apps/image_measurement/focus_stack/focusStackWorkflow.m deleted file mode 100644 index 2af5486..0000000 --- a/apps/image_measurement/focus_stack/focusStackWorkflow.m +++ /dev/null @@ -1,23 +0,0 @@ -function varargout = focusStackWorkflow(command, varargin) -%FOCUSSTACKWORKFLOW Dispatch app-owned focus-stack helpers. -% Expected caller: focus-stack app tests and migration-time workflow checks. -% Inputs are a workflow command plus command-specific arguments. Outputs match -% the selected app-private helper. This helper has no file side effects. - - switch string(command) - case "computeFocusStack" - varargout{1} = computeFocusStack(varargin{1}, varargin{2}); - case "buildFocusStackSummaryTable" - varargout{1} = buildFocusStackSummaryTable(varargin{1}, ... - string(varargin{2})); - case "findFocusStackImages" - varargout{1} = findFocusStackImages(string(varargin{1})); - case "selectedFocusImagePaths" - varargout{1} = selectedFocusImagePaths(varargin{1}, varargin{2}); - case "alignFocusStackImages" - [varargout{1:nargout}] = alignFocusStackImages(varargin{1}); - otherwise - error('labkit:FocusStack:UnknownWorkflowCommand', ... - 'Unknown focus-stack workflow helper command: %s.', command); - end -end diff --git a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m index 1131ccd..9b8c698 100644 --- a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m +++ b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m @@ -23,7 +23,7 @@ S.images = {}; S.alignedImages = {}; S.registrationLines = {}; - S.result = emptyFocusStackResult(); + S.result = focus_stack.state.emptyResult(); workbenchOpts = struct('rightKind', 'dualPlot', ... 'rightTitle', 'Focus Stack Preview', ... @@ -152,7 +152,7 @@ resultTable = uitable(laySR, ... 'ColumnName', {'Metric', 'Value'}, ... - 'Data', initialResultTable()); + 'Data', focus_stack.view.initialResultTable()); resultTable.Layout.Row = 1; txtDetails = uitextarea(laySR, 'Editable', 'off'); @@ -187,7 +187,7 @@ function onOpenFolder(~, ~) end function onOpenFiles(~, ~) - [files, folder] = uigetfile(focusImageDialogFilter(), ... + [files, folder] = uigetfile(focus_stack.io.imageDialogFilter(), ... 'Select focus image files', pwd, 'MultiSelect', 'on'); if isequal(files, 0) addLog('Image file selection cancelled.'); @@ -195,7 +195,7 @@ function onOpenFiles(~, ~) end try - paths = selectedFocusImagePaths(files, folder); + paths = focus_stack.io.selectedImagePaths(files, folder); catch ME showError('Could not select focus images', ME.message); return; @@ -207,7 +207,7 @@ function onOpenFiles(~, ~) function loadImageFolder(folder) try - paths = findFocusStackImages(folder); + paths = focus_stack.io.findImages(folder); catch ME showError('Could not load focus stack', ME.message); return; @@ -218,7 +218,7 @@ function loadImageFolder(folder) function loadImagePaths(paths, sourceFolder, sourceDescription, logMessage) try - images = readFocusStackImages(paths); + images = focus_stack.io.readImages(paths); catch ME showError('Could not load focus stack', ME.message); return; @@ -229,11 +229,11 @@ function loadImagePaths(paths, sourceFolder, sourceDescription, logMessage) S.images = images; S.alignedImages = {}; S.registrationLines = {}; - S.result = emptyFocusStackResult(); + S.result = focus_stack.state.emptyResult(); S.folder = string(sourceFolder); txtFolder.Value = char(sourceDescription); - lbImages.Items = displayImageNames(paths); + lbImages.Items = focus_stack.view.displayImageNames(paths); if ~isempty(lbImages.Items) lbImages.Value = lbImages.Items{1}; end @@ -282,7 +282,7 @@ function onRunFocusStack(~, ~) end function onFusionPresetChanged(~, ~) - settings = focusFusionPresetSettings(ddFusionPreset.Value); + settings = focus_stack.state.fusionPresetSettings(ddFusionPreset.Value); edtFocusWindow.Value = settings.focusWindow; edtSmoothRadius.Value = settings.smoothRadius; edtUncertainBlend.Value = settings.minConfidencePercent; @@ -293,13 +293,13 @@ function onFusionPresetChanged(~, ~) imagesForFusion = S.images; registrationLines = {}; if registerStack - [imagesForFusion, registrationLines] = alignFocusStackImages(S.images); + [imagesForFusion, registrationLines] = focus_stack.ops.alignImages(S.images); end payload = struct(); payload.imagesForFusion = imagesForFusion; payload.registrationLines = registrationLines; - payload.result = computeFocusStack(imagesForFusion, opts); + payload.result = focus_stack.ops.computeFocusStack(imagesForFusion, opts); end function controls = focusStackBusyControls() @@ -338,7 +338,7 @@ function onExportFocusMap(~, ~) return; end try - imwrite(focusIndexRgb(S.result.focusIndex, S.result.inputCount), filepath); + imwrite(focus_stack.view.focusIndexRgb(S.result.focusIndex, S.result.inputCount), filepath); catch ME showError('Could not export focus map PNG', ME.message); return; @@ -357,7 +357,7 @@ function onExportSummary(~, ~) return; end try - T = buildFocusStackSummaryTable(S.result, S.paths); + T = focus_stack.export.buildSummaryTable(S.result, S.paths); writetable(T, filepath); catch ME showError('Could not export summary CSV', ME.message); @@ -388,10 +388,10 @@ function refreshPreview() labkit.ui.view.draw(ui.topAxes, 'image', S.result.fused, ... 'Fused all-in-focus image'); labkit.ui.view.draw(ui.bottomAxes, 'image', ... - focusIndexRgb(S.result.focusIndex, S.result.inputCount), ... + focus_stack.view.focusIndexRgb(S.result.focusIndex, S.result.inputCount), ... 'Focus-depth index map'); elseif ~isempty(S.images) - labkit.ui.view.draw(ui.topAxes, 'image', previewImage(S.images{1}), ... + labkit.ui.view.draw(ui.topAxes, 'image', focus_stack.view.previewImage(S.images{1}), ... 'First source image'); labkit.ui.view.draw(ui.bottomAxes, 'reset', 'Focus-depth index map', true); else @@ -403,20 +403,20 @@ function refreshPreview() function refreshSummary() txtStackStatus.Value = sprintf('Images: %d', numel(S.images)); if S.result.ok - resultTable.Data = focusStackResultTableData(S.result); - txtDetails.Value = focusStackDetails(S.result, S.paths, S.registrationLines); + resultTable.Data = focus_stack.view.resultTableData(S.result); + txtDetails.Value = focus_stack.view.details(S.result, S.paths, S.registrationLines); elseif numel(S.images) >= 2 - resultTable.Data = initialResultTable(); + resultTable.Data = focus_stack.view.initialResultTable(); txtDetails.Value = { ... sprintf('Loaded images: %d', numel(S.images)), ... 'Run focus stack to compute the fused image and focus-depth map.'}; elseif ~isempty(S.images) - resultTable.Data = initialResultTable(); + resultTable.Data = focus_stack.view.initialResultTable(); txtDetails.Value = { ... sprintf('Loaded images: %d', numel(S.images)), ... 'Load at least two images before running focus stack.'}; else - resultTable.Data = initialResultTable(); + resultTable.Data = focus_stack.view.initialResultTable(); txtDetails.Value = {'Load a focus image folder or select image files to begin.'}; end updateControls(); @@ -425,10 +425,10 @@ function refreshSummary() function updateControls() hasStack = numel(S.images) >= 2; hasResult = S.result.ok; - btnRun.Enable = ternary(hasStack, 'on', 'off'); - btnExportFused.Enable = ternary(hasResult, 'on', 'off'); - btnExportMap.Enable = ternary(hasResult, 'on', 'off'); - btnExportSummary.Enable = ternary(hasResult, 'on', 'off'); + btnRun.Enable = focus_stack.view.ternary(hasStack, 'on', 'off'); + btnExportFused.Enable = focus_stack.view.ternary(hasResult, 'on', 'off'); + btnExportMap.Enable = focus_stack.view.ternary(hasResult, 'on', 'off'); + btnExportSummary.Enable = focus_stack.view.ternary(hasResult, 'on', 'off'); end function resetPreviewAxes() diff --git a/apps/image_measurement/focus_stack/private/isSupportedFocusImagePath.m b/apps/image_measurement/focus_stack/private/isSupportedFocusImagePath.m deleted file mode 100644 index 2020960..0000000 --- a/apps/image_measurement/focus_stack/private/isSupportedFocusImagePath.m +++ /dev/null @@ -1,11 +0,0 @@ -% App-owned focus-stack extension predicate. Expected caller: focus-stack app -% private loading helpers. Input is a path or filename. Output is a scalar -% logical based on the file extension only. -function tf = isSupportedFocusImagePath(pathValue) -%ISSUPPORTEDFOCUSIMAGEPATH Return true for supported focus-stack image files. -% Expected caller: focus-stack app private loading helpers. Input is a path or -% filename. Output is a scalar logical based on the file extension only. - - [~, ~, ext] = fileparts(char(pathValue)); - tf = any(strcmpi(ext, supportedFocusImageExtensions())); -end diff --git a/docs/apps.md b/docs/apps.md index 8248099..ccb4fe8 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -11,6 +11,9 @@ startup_labkit ``` This adds the repository root, `apps/`, and nested app category folders to the MATLAB path. +MATLAB package folders below app folders, such as +`apps/image_measurement/batch_crop/+batch_crop/`, are not added as direct path +entries; they are resolved through their owning app folder. ## Current Apps @@ -66,7 +69,26 @@ Move code into `+labkit` only when it is reusable without app vocabulary, testab ## App File Shape -New lab apps should start as explicit public entry points under `apps//` or `apps///` when the app needs private helpers. A typical single-file order is: +New lab apps should start as explicit public entry points under `apps//` +or `apps///` when the app needs extracted helpers. A small +app may remain a single file. When helper extraction is needed, use an app-owned +package whose name matches the app folder slug: + +```text +apps///labkit__app.m +apps///+/+ui/ +apps///+/+state/ +apps///+/+ops/ +apps///+/+view/ +apps///+/+export/ +apps///+/+io/ +``` + +Create component packages only when the app has code for that responsibility. +Use the app slug package name, not a fixed `+app` namespace, so MATLAB package +resolution cannot mix helpers from sibling apps. + +A typical single-file order before extraction is: ```text 1. Entry validation and optional debug launch hook @@ -80,11 +102,23 @@ New lab apps should start as explicit public entry points under `apps/ 9. Small formatting, parsing, interpolation, and numeric utilities ``` -Nested functions may read and update GUI handles or app state. Local functions after the app `end` should be GUI-free when practical; extracted app-owned workflow helpers can give focused tests direct access without adding reusable `+labkit` APIs. - -The preferred public shape is one launchable app entry point per workflow. If an app becomes too large, app-owned private helpers are acceptable when they stay under the owning app tree and do not become public reusable APIs. Move GUI-free calculations, export builders, deterministic image/signal transforms, and formatting utilities to `apps///private/` when that makes the public app file easier to scan. Use `apps//private/` only for helpers that are genuinely shared by multiple apps in that family. Keep GUI state, callbacks, user alerts, workflow ordering, and debug launch routing in the public app file. - -For callback-heavy migrated apps, the public launcher may delegate the app body to an app-private runner under the same app tree when that is the smallest behavior-preserving way to keep the launch contract clear. The runner remains app-owned production code; it is not a reusable facade and should not move app-specific workflow decisions into `+labkit`. +Nested functions may read and update GUI handles or app state. Local functions +after the app `end` should be GUI-free when practical; extracted app-owned +package helpers give focused tests direct access without adding reusable +`+labkit` APIs. + +The preferred public shape is one launchable app entry point per workflow. The +entry point owns GUI state, callbacks, user alerts, workflow ordering, debug +launch routing, and user-facing log wording. Extracted package helpers should +own focused responsibilities: control construction in `+ui`, state/result +defaults in `+state`, deterministic calculations and image/signal transforms in +`+ops`, display/table formatting in `+view`, CSV/image output builders in +`+export`, and dialog/file normalization in `+io`. + +Do not add new string-dispatch workflow adapters such as `*Workflow.m` for +tests. Tests should call the app-owned package function that owns the behavior. +Use `apps//private/` only for helpers that are genuinely shared by +multiple apps in that family and are not ready for a reusable `+labkit` facade. ## New App Checklist diff --git a/docs/architecture.md b/docs/architecture.md index f0776e0..bd894aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ labkit_ECGPrint_app | Area | Responsibility | | --- | --- | -| `apps/` | Public app entry points and app-specific workflow code, including app-owned private helpers. | +| `apps/` | Public app entry points and app-specific workflow code, including app-owned package helpers under the owning app folder. | | `+labkit/+ui` | Reusable GUI app/view/tool/diagnostics facades plus private implementation helpers. | | `+labkit/+dta` | GUI-free DTA discovery, loading, session, pulse, and parsed curve/table facade. | | `+labkit/+biosignal` | GUI-free recording loading, channel extraction, waveform processing, events, segments, templates, measurements, and group comparisons. | @@ -79,7 +79,15 @@ The app-facing UI API is intentionally layered: | Tool | Exclusive interaction runtime and composed tools. | `labkit.ui.tool.createRuntime`, `anchorEditor`, `scaleBar`, `scaleBarCalibration`. | | Diagnostics | Debug launch, visible trace, callback instrumentation. | `labkit.ui.diag.createContext`. | -App-specific analysis, plotting annotations, result summaries, CSV schemas, failed-row behavior, and workflow wording belong in the owning app file or app-owned private helpers. The default private-helper location for a large app is `apps///private/`; `apps//private/` should be reserved for helpers shared by multiple apps in that family. +App-specific analysis, plotting annotations, result summaries, CSV schemas, +failed-row behavior, and workflow wording belong in the owning app file or an +app-owned package under the owning app folder. For large apps, the default +helper location is `apps///+/...`, with component +packages such as `+ui`, `+state`, `+ops`, `+view`, `+export`, and `+io` created +as needed. The app-owned package name should match the app folder slug; do not +use a fixed `+app` namespace for every app. `apps//private/` should be +reserved for helpers that are genuinely shared by multiple apps in that family +and are not ready for a reusable `+labkit` facade. ## Library Extraction Rule diff --git a/docs/testing.md b/docs/testing.md index c95c9a4..24e63e0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -191,7 +191,7 @@ interact with controls. Shared setup, structural GUI assertions, and focused support routines live under `tests/helpers/`. Keep helpers limited to setup and assertions; app-specific formulas, result schemas, export formats, and expected scientific values should remain in focused suite tests. -Architecture guardrails are split by concern under `tests/integration/project/`: public package surface, reusable package dependency boundaries, app entrypoint boundaries, and app-owned workflow boundaries. These guardrails may require workflow code to remain under the owning app tree, but they should not require GUI-free helpers to stay in the public app entry-point file. App-private helpers are checked by boundary rules rather than exact file-list assertions. +Architecture guardrails are split by concern under `tests/integration/project/`: public package surface, reusable package dependency boundaries, app entrypoint boundaries, and app-owned workflow boundaries. These guardrails may require workflow code to remain under the owning app tree, but they should not require GUI-free helpers to stay in the public app entry-point file. App-owned packages are checked by boundary rules rather than exact file-list assertions. When a suite file becomes broad enough that unrelated changes must read hundreds of lines, add a narrower `test_*.m` file in the same suite instead of appending more coverage to the broad file. diff --git a/tests/helpers/architectureTestHelpers.m b/tests/helpers/architectureTestHelpers.m index d65dd47..b3c8b19 100644 --- a/tests/helpers/architectureTestHelpers.m +++ b/tests/helpers/architectureTestHelpers.m @@ -100,17 +100,37 @@ function source = readAppOwnedSource(appFile) appDir = fileparts(appFile); sourceParts = {fileread(appFile)}; + privateDir = fullfile(appDir, 'private'); if exist(privateDir, 'dir') == 7 - fileEntries = dir(fullfile(privateDir, '*.m')); - fileNames = sort({fileEntries.name}); - for iFile = 1:numel(fileNames) - sourceParts{end+1} = fileread(fullfile(privateDir, fileNames{iFile})); %#ok - end + sourceParts = appendSourceFiles(sourceParts, collectMFiles(privateDir)); + end + + packageEntries = dir(fullfile(appDir, '+*')); + packageDirs = packageEntries([packageEntries.isdir]); + for iDir = 1:numel(packageDirs) + packageDir = fullfile(packageDirs(iDir).folder, packageDirs(iDir).name); + sourceParts = appendSourceFiles(sourceParts, collectMFiles(packageDir)); end + source = strjoin(sourceParts, newline); end +function sourceParts = appendSourceFiles(sourceParts, files) + for iFile = 1:numel(files) + sourceParts{end+1} = fileread(files{iFile}); %#ok + end +end + +function files = collectMFiles(folder) + fileEntries = dir(fullfile(folder, '**', '*.m')); + files = cell(numel(fileEntries), 1); + for iFile = 1:numel(fileEntries) + files{iFile} = fullfile(fileEntries(iFile).folder, fileEntries(iFile).name); + end + files = sort(files); +end + function assertDTAFacadeUsage(source, appName, expectedKind, expectsFolderDiscovery) usesDTAFacade = contains(source, 'labkit.dta.loadFile') || ... contains(source, 'labkit.dta.addFilesToSession'); @@ -145,9 +165,36 @@ function assertImageMeasurementAppBoundary(source, appName) [appName ' should not depend on DIC implementation packages.']); assert(~contains(source, '+labkit/+image_measurement'), ... [appName ' should keep image-measurement workflow code app-local.']); + packageName = imageMeasurementPackageForApp(appName); + assert(contains(source, [packageName '.']), ... + [appName ' should use its app-owned package namespace.']); + allPackageNames = {'batch_crop', 'curvature', 'focus_stack'}; + otherPackageNames = setdiff(allPackageNames, {packageName}); + for iPackage = 1:numel(otherPackageNames) + assert(~contains(source, [otherPackageNames{iPackage} '.']), ... + [appName ' should not call sibling image-measurement app package ' ... + otherPackageNames{iPackage} '.']); + end + assert(~contains(source, 'batchImageCropWorkflow') && ... + ~contains(source, 'focusStackWorkflow') && ... + ~contains(source, 'curvatureMeasurementWorkflow'), ... + [appName ' should not use string-dispatch workflow adapters.']); assertAppUsesManagedImageInteractions(source, appName); end +function packageName = imageMeasurementPackageForApp(appName) + switch appName + case 'labkit_BatchImageCrop_app' + packageName = 'batch_crop'; + case 'labkit_CurvatureMeasurement_app' + packageName = 'curvature'; + case 'labkit_FocusStack_app' + packageName = 'focus_stack'; + otherwise + error('Unknown image-measurement app entrypoint: %s', appName); + end +end + function assertWearableAppBoundary(source, appName) assert(~contains(source, 'labkit.dta.'), ... [appName ' should not use the electrochemistry DTA facade.']); diff --git a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m index 3caa42a..1ea3d40 100644 --- a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m +++ b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m @@ -139,6 +139,8 @@ function verify_app_owned_workflow_boundaries() 'labkit_BatchImageCrop_app', ... 'launchBatchImageCropApp', ... 'batch_crop_gui('); + h.assertImageMeasurementAppBoundary(curvatureSource, 'labkit_CurvatureMeasurement_app'); + h.assertImageMeasurementAppBoundary(focusStackSource, 'labkit_FocusStack_app'); h.assertImageMeasurementAppBoundary(batchCropSource, 'labkit_BatchImageCrop_app'); assert(exist(fullfile(root, '+labkit', '+image_measurement'), 'dir') ~= 7, ... 'Image measurement workflow code should not be promoted to a reusable +labkit package yet.'); diff --git a/tests/integration/project/ProjectDocumentationGuardrailTest.m b/tests/integration/project/ProjectDocumentationGuardrailTest.m index cd45988..3f38bb2 100644 --- a/tests/integration/project/ProjectDocumentationGuardrailTest.m +++ b/tests/integration/project/ProjectDocumentationGuardrailTest.m @@ -25,10 +25,8 @@ function privateHelperContractDebtDoesNotGrow(testCase) '+labkit/+dta/private', ... '+labkit/+ui/+app/private', ... '+labkit/+ui/+tool/private', ... - '+labkit/+ui/+view/private', ... - 'apps/image_measurement/curvature/private', ... - 'apps/image_measurement/focus_stack/private'}, ... - 'missingCount', {15, 20, 4, 11, 23, 9, 11}); + '+labkit/+ui/+view/private'}, ... + 'missingCount', {15, 20, 4, 11, 23}); actual = collectPrivateContractDebt(root); expectedFolders = sort(string({expectedDebt.folder})); diff --git a/tests/integration/project/ProjectStructureGuardrailTest.m b/tests/integration/project/ProjectStructureGuardrailTest.m index 548527e..b67cd3e 100644 --- a/tests/integration/project/ProjectStructureGuardrailTest.m +++ b/tests/integration/project/ProjectStructureGuardrailTest.m @@ -132,6 +132,20 @@ function appOwnedWorkflowDoesNotLeakToReusablePackages(testCase) end end + function imageMeasurementAppsUseOwnedPackageNamespaces(testCase) + root = setupLabKitTestPath(); + + assertImageMeasurementPackageLayout(testCase, root, ... + 'batch_crop', 'batch_crop', ... + {'+export', '+io', '+ops', '+state', '+ui', '+view'}); + assertImageMeasurementPackageLayout(testCase, root, ... + 'curvature', 'curvature', ... + {'+export', '+ops', '+state', '+ui', '+view'}); + assertImageMeasurementPackageLayout(testCase, root, ... + 'focus_stack', 'focus_stack', ... + {'+export', '+io', '+ops', '+state', '+view'}); + end + function sensitiveSampleHygieneScansTrackedText(testCase) root = setupLabKitTestPath(); files = collectTrackedTextScope(root); @@ -163,10 +177,38 @@ function startupPathKeepsPrivateHelpersPrivate(testCase) 'startup_labkit should add nested image measurement app folders.'); testCase.verifyFalse(pathContains(fullfile(root, 'apps', 'image_measurement', 'curvature', 'private')), ... 'startup_labkit should not expose app-private helper folders.'); + testCase.verifyFalse(pathContains(fullfile(root, 'apps', 'image_measurement', 'curvature', '+curvature')), ... + 'startup_labkit should not expose app-owned package folders directly.'); end end end +function assertImageMeasurementPackageLayout(testCase, root, appFolder, packageName, componentDirs) + appDir = fullfile(root, 'apps', 'image_measurement', appFolder); + packageDir = fullfile(appDir, ['+' packageName]); + + testCase.verifyTrue(isfolder(appDir), ... + ['Missing image-measurement app folder: apps/image_measurement/' appFolder]); + testCase.verifyFalse(isfolder(fullfile(appDir, 'private')), ... + ['Image-measurement app should use an app-owned package, not private/: ' appFolder]); + testCase.verifyFalse(isfolder(fullfile(appDir, '+app')), ... + ['Image-measurement app should not use a fixed +app namespace: ' appFolder]); + workflowFiles = dir(fullfile(appDir, '*Workflow.m')); + testCase.verifyTrue(isempty(workflowFiles), ... + ['Image-measurement app should not keep workflow dispatch adapters: ' appFolder]); + testCase.verifyTrue(isfolder(packageDir), ... + ['Missing app-owned package namespace: ' relativePath(root, packageDir)]); + + packageFiles = dir(fullfile(packageDir, '**', '*.m')); + testCase.verifyFalse(isempty(packageFiles), ... + ['App-owned package should contain helper files: ' relativePath(root, packageDir)]); + for iDir = 1:numel(componentDirs) + testCase.verifyTrue(isfolder(fullfile(packageDir, componentDirs{iDir})), ... + ['Missing app-owned component package ' componentDirs{iDir} ... + ' under ' relativePath(root, packageDir)]); + end +end + function assertAppFamilyBoundary(h, source, appName) if contains(appName, 'ChronoOverlay') h.assertDTAFacadeUsage(source, appName, 'chrono', true); diff --git a/tests/integration/project/StartupBoundariesTest.m b/tests/integration/project/StartupBoundariesTest.m index 034f01f..376d37b 100644 --- a/tests/integration/project/StartupBoundariesTest.m +++ b/tests/integration/project/StartupBoundariesTest.m @@ -36,6 +36,12 @@ function verify_startup_boundaries() 'startup_labkit should not expose app-private helper folders as public path entries.'); assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'batch_crop', 'private')), ... 'startup_labkit should not expose app-private helper folders as public path entries.'); + assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'curvature', '+curvature')), ... + 'startup_labkit should not expose app-owned package folders as direct path entries.'); + assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'focus_stack', '+focus_stack')), ... + 'startup_labkit should not expose app-owned package folders as direct path entries.'); + assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'batch_crop', '+batch_crop')), ... + 'startup_labkit should not expose app-owned package folders as direct path entries.'); assert(pathContains(fullfile(root, 'apps', 'wearable')), ... 'startup_labkit should add wearable app category folders to the path.'); diff --git a/tests/unit/apps/image_measurement/BatchImageCropTest.m b/tests/unit/apps/image_measurement/BatchImageCropTest.m index 750a006..26c3ba8 100644 --- a/tests/unit/apps/image_measurement/BatchImageCropTest.m +++ b/tests/unit/apps/image_measurement/BatchImageCropTest.m @@ -22,7 +22,7 @@ function verify_batchImageCrop() function checkFixedPixelCropPreservesClassAndSize() img = uint8(reshape(1:100, 10, 10)); - result = batchImageCropWorkflow("cropImage", img, struct( ... + result = batch_crop.ops.cropImage(img, struct( ... 'cropWidth', 4, ... 'cropHeight', 3, ... 'centerXY', [5, 6], ... @@ -39,7 +39,7 @@ function checkFixedPixelCropPreservesClassAndSize() function checkOutOfBoundsCropPadsWithFill() img = uint8(10 * ones(5, 5)); - result = batchImageCropWorkflow("cropImage", img, struct( ... + result = batch_crop.ops.cropImage(img, struct( ... 'cropWidth', 4, ... 'cropHeight', 4, ... 'centerXY', [1, 1], ... @@ -57,7 +57,7 @@ function checkOutOfBoundsCropPadsWithFill() function checkRotatedCropKeepsRequestedSize() img = uint8(zeros(8, 12, 3)); img(:, 4:8, 1) = 200; - result = batchImageCropWorkflow("cropImage", img, struct( ... + result = batch_crop.ops.cropImage(img, struct( ... 'cropWidth', 6, ... 'cropHeight', 5, ... 'angleDeg', 35, ... @@ -74,20 +74,19 @@ function checkSelectedFileNormalization() mkdir(folder); cleanup = onCleanup(@() removeTempFolder(folder)); %#ok - paths = batchImageCropWorkflow( ... - "selectedBatchCropImagePaths", {'frame_b.png', 'frame_a.tif'}, folder); + paths = batch_crop.io.selectedImagePaths( ... + {'frame_b.png', 'frame_a.tif'}, folder); names = fileNames(paths); assert(isequal(names, {'frame_a.tif'; 'frame_b.png'}), ... 'Selected batch crop images should be sorted by filename.'); - assertThrows(@() batchImageCropWorkflow( ... - "selectedBatchCropImagePaths", 'notes.txt', folder), ... + assertThrows(@() batch_crop.io.selectedImagePaths('notes.txt', folder), ... 'labkit_BatchImageCrop_app:UnsupportedImageFile', ... 'Manual selection should reject unsupported file types.'); end function checkManifestContract() - result = batchImageCropWorkflow("cropImage", uint8(ones(5, 6)), struct( ... + result = batch_crop.ops.cropImage(uint8(ones(5, 6)), struct( ... 'cropWidth', 3, ... 'cropHeight', 4, ... 'centerXY', [3, 3], ... @@ -97,7 +96,7 @@ function checkManifestContract() result.status = "saved"; result.message = "Saved"; - T = batchImageCropWorkflow("buildBatchCropManifest", result); + T = batch_crop.export.buildManifest(result); assert(isequal(T.Properties.VariableNames, expectedManifestColumns()), ... 'Batch crop manifest columns changed.'); assert(height(T) == 1, 'Manifest should include one row per crop result.'); @@ -118,7 +117,7 @@ function checkExportWritesUniqueOutputs() 'centerXY', [3, 3], ... 'centerSet', true); - payload = batchImageCropWorkflow("writeBatchCropOutputs", item, struct( ... + payload = batch_crop.export.writeOutputs(item, struct( ... 'outputFolder', string(folder), ... 'format', 'PNG', ... 'cropWidth', 4, ... diff --git a/tests/unit/apps/image_measurement/FocusStackFusionTest.m b/tests/unit/apps/image_measurement/FocusStackFusionTest.m index 394902b..4d7ab9e 100644 --- a/tests/unit/apps/image_measurement/FocusStackFusionTest.m +++ b/tests/unit/apps/image_measurement/FocusStackFusionTest.m @@ -24,8 +24,7 @@ function checkSyntheticFocusSelection() [nearImage, farImage, mid] = syntheticFocusPair(); opts = struct('focusWindow', 5, 'smoothRadius', 0, 'minConfidence', 0); - result = focusStackWorkflow( ... - "computeFocusStack", {nearImage, farImage}, opts); + result = focus_stack.ops.computeFocusStack({nearImage, farImage}, opts); assert(result.ok, 'Focus stack should succeed for a two-image synthetic stack.'); assert(result.inputCount == 2, 'Input image count changed.'); @@ -52,12 +51,11 @@ function checkSyntheticFocusSelection() function checkSummaryTableContract() [nearImage, farImage] = syntheticFocusPair(); - result = focusStackWorkflow( ... - "computeFocusStack", {nearImage, farImage}, ... + result = focus_stack.ops.computeFocusStack({nearImage, farImage}, ... struct('focusWindow', 5, 'smoothRadius', 1, 'minConfidence', 0.05)); - T = focusStackWorkflow( ... - "buildFocusStackSummaryTable", result, ["slice_a.png"; "slice_b.png"]); + T = focus_stack.export.buildSummaryTable( ... + result, ["slice_a.png"; "slice_b.png"]); assert(isequal(T.Properties.VariableNames, expectedSummaryColumns()), ... 'Focus stack summary columns changed.'); @@ -81,7 +79,7 @@ function checkFolderDiscovery() fprintf(fid, 'not an image fixture'); fclose(fid); - paths = focusStackWorkflow("findFocusStackImages", folder); + paths = focus_stack.io.findImages(folder); names = cell(numel(paths), 1); for k = 1:numel(paths) [~, base, ext] = fileparts(char(paths(k))); @@ -97,19 +95,17 @@ function checkSelectedFileSelection() mkdir(folder); cleanup = onCleanup(@() removeTempFolder(folder)); %#ok - paths = focusStackWorkflow( ... - "selectedFocusImagePaths", {'frame_b.png', 'frame_a.tif'}, folder); + paths = focus_stack.io.selectedImagePaths( ... + {'frame_b.png', 'frame_a.tif'}, folder); names = fileNames(paths); assert(isequal(names, {'frame_a.tif'; 'frame_b.png'}), ... 'Selected image files should be normalized and sorted by name.'); - onePath = focusStackWorkflow( ... - "selectedFocusImagePaths", 'frame_c.jpg', folder); + onePath = focus_stack.io.selectedImagePaths('frame_c.jpg', folder); assert(numel(onePath) == 1 && endsWith(onePath, "frame_c.jpg"), ... 'Single-file selection should be accepted for preview before stacking.'); - assertThrows(@() focusStackWorkflow( ... - "selectedFocusImagePaths", 'notes.txt', folder), ... + assertThrows(@() focus_stack.io.selectedImagePaths('notes.txt', folder), ... 'labkit_FocusStack_app:UnsupportedImageFile', ... 'Manual selection should reject unsupported file types.'); end @@ -118,8 +114,7 @@ function checkRegistrationImprovesSyntheticDrift() reference = syntheticRegistrationImage(); moving = integerTranslateImage(reference, -3, 4, median(reference(:))); - [aligned, lines] = focusStackWorkflow( ... - "alignFocusStackImages", {moving, reference}); + [aligned, lines] = focus_stack.ops.alignImages({moving, reference}); beforeErr = mean((im2double(moving(:)) - im2double(reference(:))) .^ 2); afterErr = mean((im2double(aligned{1}(:)) - im2double(reference(:))) .^ 2); @@ -139,12 +134,11 @@ function checkRegistrationImprovesSyntheticDrift() end function checkInvalidInputs() - assertThrows(@() focusStackWorkflow( ... - "computeFocusStack", {zeros(8, 8)}, struct()), ... + assertThrows(@() focus_stack.ops.computeFocusStack({zeros(8, 8)}, struct()), ... 'labkit_FocusStack_app:NotEnoughImages', ... 'Single-image stacks should be rejected.'); - assertThrows(@() focusStackWorkflow( ... - "computeFocusStack", {zeros(8, 8), zeros(8, 8)}, ... + assertThrows(@() focus_stack.ops.computeFocusStack( ... + {zeros(8, 8), zeros(8, 8)}, ... struct('focusWindow', 0)), ... 'MATLAB:expectedPositive', ... 'Invalid focus window should be rejected.'); diff --git a/tests/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m b/tests/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m index 723f43c..bac9ad7 100644 --- a/tests/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m +++ b/tests/unit/apps/image_measurement/ImageCurvatureMeasurementTest.m @@ -33,7 +33,7 @@ function checkCircularFitWithMeasuredScale() 'scaleUnit', 'mm', ... 'doDensify', false, ... 'denseN', 200); - fit = curvatureMeasurementWorkflow("computeCurvatureFit", x, y, opts); + fit = curvature.ops.computeFitFromOptions(x, y, opts); assert(fit.ok, 'Curvature fit should succeed for circular points.'); assertClose(fit.xc_px, xc, 1e-6, 'Fitted center x changed.'); @@ -45,8 +45,7 @@ function checkCircularFitWithMeasuredScale() assertClose(fit.curveLength_px, sum(hypot(diff(x), diff(y))), 1e-9, ... 'Fitted result should include curve length in pixels.'); - T = curvatureMeasurementWorkflow("buildCurvatureResultTable", ... - fit, "sample.png"); + T = curvature.export.buildResultTable(fit, "sample.png"); assert(isequal(T.Properties.VariableNames, expectedResultColumns()), ... 'Curvature result table columns changed.'); assert(T.Radius_px == fit.R_px, 'Result table should preserve pixel radius.'); @@ -61,7 +60,7 @@ function checkPixelAndTypedScaleModes() x = 12 + 30*cos(theta); y = 22 + 30*sin(theta); - pxOnly = curvatureMeasurementWorkflow("computeCurvatureFit", x, y, ... + pxOnly = curvature.ops.computeFitFromOptions(x, y, ... struct('referencePx', NaN, 'referenceLength', 0, 'scaleUnit', 'um', ... 'doDensify', false)); assert(pxOnly.ok, 'Curvature fit should work without a physical scale.'); @@ -73,7 +72,7 @@ function checkPixelAndTypedScaleModes() assertClose(pxOnly.kappa_show, pxOnly.kappa_per_px, 1e-12, ... 'Pixel-only displayed curvature should equal pixel curvature'); - typedScale = curvatureMeasurementWorkflow("computeCurvatureFit", x, y, ... + typedScale = curvature.ops.computeFitFromOptions(x, y, ... struct('referencePx', 15, 'referenceLength', 1, 'scaleUnit', 'mm', ... 'doDensify', false)); assert(typedScale.ok, 'Typed reference scale should produce a fit.'); @@ -90,8 +89,7 @@ function checkCurveLengthMeasurement() x = [0; 3; 6]; y = [0; 4; 8]; - pxLength = curvatureMeasurementWorkflow( ... - "computeCurveLength", x, y, ... + pxLength = curvature.ops.computeLengthFromOptions(x, y, ... struct('referencePx', NaN, 'referenceLength', 0, 'scaleUnit', 'um')); assert(pxLength.ok, 'Curve length should succeed for two or more points.'); assertClose(pxLength.length_px, 10, 1e-12, ... @@ -101,8 +99,7 @@ function checkCurveLengthMeasurement() assert(strcmp(pxLength.unitLen, 'px'), ... 'Pixel-only curve length unit changed.'); - mmLength = curvatureMeasurementWorkflow( ... - "computeCurveLength", x, y, ... + mmLength = curvature.ops.computeLengthFromOptions(x, y, ... struct('referencePx', 5, 'referenceLength', 1, 'scaleUnit', 'mm')); assert(mmLength.ok, 'Typed reference scale should scale curve length.'); assertClose(mmLength.length_show, 2, 1e-12, ... @@ -119,8 +116,7 @@ function checkDensifyUsesCurvePath() curveX = [0; 10; 20]; curveY = [0; 10; 0]; - fit = curvatureMeasurementWorkflow( ... - "computeCurvatureFit", anchorX, anchorY, ... + fit = curvature.ops.computeFitFromOptions(anchorX, anchorY, ... struct('referencePx', NaN, 'referenceLength', 0, ... 'scaleUnit', 'um', 'doDensify', true, 'denseN', 5, ... 'fitPathX', curveX, 'fitPathY', curveY)); @@ -138,16 +134,15 @@ function checkInvalidCurvePoints() opts = struct('referencePx', NaN, 'referenceLength', 0, ... 'scaleUnit', 'um', 'doDensify', false); - assertThrows(@() curvatureMeasurementWorkflow( ... - "computeCurvatureFit", [5; 5; 5], [7; 7; 7], opts), ... + assertThrows(@() curvature.ops.computeFitFromOptions( ... + [5; 5; 5], [7; 7; 7], opts), ... 'labkit_CurvatureMeasurement_app:NotEnoughPoints', ... 'Duplicate-only curve points should be rejected.'); - assertThrows(@() curvatureMeasurementWorkflow( ... - "computeCurvatureFit", [1; 2], [3; 4], opts), ... + assertThrows(@() curvature.ops.computeFitFromOptions( ... + [1; 2], [3; 4], opts), ... 'labkit_CurvatureMeasurement_app:NotEnoughPoints', ... 'Two unique curve points should be rejected.'); - assertThrows(@() curvatureMeasurementWorkflow( ... - "computeCurveLength", [1], [3], opts), ... + assertThrows(@() curvature.ops.computeLengthFromOptions([1], [3], opts), ... 'labkit_CurvatureMeasurement_app:NotEnoughLengthPoints', ... 'Single-point curve length should be rejected.'); end