From 5cdc31e2f176cec0b00150b881abbd183cd93bf6 Mon Sep 17 00:00:00 2001 From: Pluze Zhu Date: Thu, 11 Jun 2026 15:59:07 -0500 Subject: [PATCH 1/2] feat: add image apps and semantic tab resizing --- +labkit/+ui/+app/createShell.m | 6 +- .../+app/private/createTabbedWorkbenchShell.m | 23 +- +labkit/+ui/+app/tab.m | 12 +- README.md | 10 +- .../labkit_DICPostprocess_app.m | 6 +- .../+dic_preprocess/+ui/createLayout.m | 6 +- .../batch_crop/labkit_BatchImageCrop_app.m | 6 +- .../+curvature/+ui/appShellOptions.m | 6 +- .../focus_stack/labkit_FocusStack_app.m | 6 +- .../+image_enhance/+export/buildManifest.m | 27 ++ .../+image_enhance/+export/writeOutputs.m | 103 +++++ .../+image_enhance/+io/imageDialogFilter.m | 9 + .../+image_enhance/+io/readImages.m | 33 ++ .../+image_enhance/+io/selectedImagePaths.m | 25 ++ .../+io/supportedImageExtensions.m | 6 + .../+image_enhance/+ops/applyPipeline.m | 31 ++ .../+image_enhance/+ops/applyStep.m | 95 +++++ .../+image_enhance/+ops/defaultStepValues.m | 52 +++ .../+image_enhance/+ops/describeStep.m | 31 ++ .../+image_enhance/+ops/makeStep.m | 16 + .../+image_enhance/+state/emptyItem.m | 10 + .../+image_enhance/+state/emptyStep.m | 12 + .../+image_enhance/+ui/createEditorUi.m | 195 +++++++++ .../+image_enhance/+view/beforeAfterImage.m | 21 + .../+image_enhance/+view/detailLines.m | 22 + .../+image_enhance/+view/displayImageNames.m | 14 + .../+image_enhance/+view/historyTableData.m | 17 + .../+image_enhance/+view/resultTableData.m | 19 + .../+image_enhance/+view/ternary.m | 10 + .../image_enhance/labkit_ImageEnhance_app.m | 382 ++++++++++++++++++ .../+image_match/+export/buildManifest.m | 27 ++ .../+image_match/+export/writeOutputs.m | 103 +++++ .../+image_match/+io/imageDialogFilter.m | 9 + .../image_match/+image_match/+io/readImages.m | 33 ++ .../+image_match/+io/selectedImagePaths.m | 25 ++ .../+io/supportedImageExtensions.m | 6 + .../+image_match/+ops/applyMatch.m | 168 ++++++++ .../+image_match/+ops/applyPipeline.m | 33 ++ .../image_match/+image_match/+ops/applyStep.m | 7 + .../+image_match/+ops/describeStep.m | 13 + .../image_match/+image_match/+ops/makeStep.m | 27 ++ .../+image_match/+state/emptyItem.m | 10 + .../+image_match/+state/emptyStep.m | 14 + .../+image_match/+ui/createEditorUi.m | 216 ++++++++++ .../+image_match/+view/beforeAfterImage.m | 21 + .../+image_match/+view/detailLines.m | 22 + .../+image_match/+view/displayImageNames.m | 14 + .../+image_match/+view/historyTableData.m | 22 + .../+image_match/+view/matchFlowLines.m | 43 ++ .../+image_match/+view/resultTableData.m | 19 + .../image_match/+image_match/+view/ternary.m | 10 + .../image_match/labkit_ImageMatch_app.m | 355 ++++++++++++++++ .../ecg_print/+ecg_print/+ui/runApp.m | 6 +- docs/apps.md | 4 + docs/architecture.md | 2 + docs/ui.md | 5 +- labkit_launcher.m | 3 +- .../GuiLayoutImageMeasurementTest.m | 54 +++ .../gui/structural/apps/smoke/GuiSmokeTest.m | 30 ++ .../labkit/ui/GuiLayoutUiAxesWorkbenchTest.m | 22 +- tests/helpers/appEntryManifest.m | 2 + tests/helpers/architectureTestHelpers.m | 11 +- .../project/AppEntrypointBoundariesTest.m | 12 + .../project/AppOwnedWorkflowBoundariesTest.m | 10 + .../project/ProjectStructureGuardrailTest.m | 10 + .../project/StartupBoundariesTest.m | 8 + .../apps/image_measurement/ImageEnhanceTest.m | 133 ++++++ .../apps/image_measurement/ImageMatchTest.m | 147 +++++++ 68 files changed, 2822 insertions(+), 45 deletions(-) create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+export/buildManifest.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+export/writeOutputs.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+io/imageDialogFilter.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+io/readImages.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+io/selectedImagePaths.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+io/supportedImageExtensions.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+ops/applyPipeline.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+ops/applyStep.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+ops/defaultStepValues.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+ops/describeStep.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+ops/makeStep.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+state/emptyItem.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+state/emptyStep.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+ui/createEditorUi.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+view/beforeAfterImage.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+view/detailLines.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+view/displayImageNames.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+view/historyTableData.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+view/resultTableData.m create mode 100644 apps/image_measurement/image_enhance/+image_enhance/+view/ternary.m create mode 100644 apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m create mode 100644 apps/image_measurement/image_match/+image_match/+export/buildManifest.m create mode 100644 apps/image_measurement/image_match/+image_match/+export/writeOutputs.m create mode 100644 apps/image_measurement/image_match/+image_match/+io/imageDialogFilter.m create mode 100644 apps/image_measurement/image_match/+image_match/+io/readImages.m create mode 100644 apps/image_measurement/image_match/+image_match/+io/selectedImagePaths.m create mode 100644 apps/image_measurement/image_match/+image_match/+io/supportedImageExtensions.m create mode 100644 apps/image_measurement/image_match/+image_match/+ops/applyMatch.m create mode 100644 apps/image_measurement/image_match/+image_match/+ops/applyPipeline.m create mode 100644 apps/image_measurement/image_match/+image_match/+ops/applyStep.m create mode 100644 apps/image_measurement/image_match/+image_match/+ops/describeStep.m create mode 100644 apps/image_measurement/image_match/+image_match/+ops/makeStep.m create mode 100644 apps/image_measurement/image_match/+image_match/+state/emptyItem.m create mode 100644 apps/image_measurement/image_match/+image_match/+state/emptyStep.m create mode 100644 apps/image_measurement/image_match/+image_match/+ui/createEditorUi.m create mode 100644 apps/image_measurement/image_match/+image_match/+view/beforeAfterImage.m create mode 100644 apps/image_measurement/image_match/+image_match/+view/detailLines.m create mode 100644 apps/image_measurement/image_match/+image_match/+view/displayImageNames.m create mode 100644 apps/image_measurement/image_match/+image_match/+view/historyTableData.m create mode 100644 apps/image_measurement/image_match/+image_match/+view/matchFlowLines.m create mode 100644 apps/image_measurement/image_match/+image_match/+view/resultTableData.m create mode 100644 apps/image_measurement/image_match/+image_match/+view/ternary.m create mode 100644 apps/image_measurement/image_match/labkit_ImageMatch_app.m create mode 100644 tests/unit/apps/image_measurement/ImageEnhanceTest.m create mode 100644 tests/unit/apps/image_measurement/ImageMatchTest.m diff --git a/+labkit/+ui/+app/createShell.m b/+labkit/+ui/+app/createShell.m index 457d55d..3257a65 100644 --- a/+labkit/+ui/+app/createShell.m +++ b/+labkit/+ui/+app/createShell.m @@ -58,11 +58,9 @@ function tabs = standardTabs() tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [3 1], ... - {260, 'fit', 'fit'}, ... - struct('resizeRows', [1 2])), ... + {260, 'fit', 'fit'}), ... labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {'fit', '1x'}, ... - struct('resizeRows', 1)), ... + {'fit', '1x'}), ... labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; end diff --git a/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m b/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m index 3db0e36..600901d 100644 --- a/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m +++ b/+labkit/+ui/+app/private/createTabbedWorkbenchShell.m @@ -142,10 +142,29 @@ function enableScrollableGrid(grid) function rows = validResizeRows(spec, logicalRows) rows = []; - if ~isfield(spec, 'resizeRows') || isempty(spec.resizeRows) + if isfield(spec, 'resizeRows') && ~isempty(spec.resizeRows) + rows = unique(spec.resizeRows(:).'); + rows = rows(rows >= 1 & rows < logicalRows & isfinite(rows)); return; end - rows = unique(spec.resizeRows(:).'); + + mode = optionValue(spec, 'resize', 'betweenRows'); + if islogical(mode) + if mode + rows = 1:max(logicalRows - 1, 0); + end + return; + end + mode = lower(char(string(mode))); + switch mode + case {'betweenrows', 'auto', 'all'} + rows = 1:max(logicalRows - 1, 0); + case {'none', 'off', 'false'} + rows = []; + otherwise + error('labkit:ui:InvalidTabResizeMode', ... + 'Unsupported tab resize mode "%s".', char(string(mode))); + end rows = rows(rows >= 1 & rows < logicalRows & isfinite(rows)); end diff --git a/+labkit/+ui/+app/tab.m b/+labkit/+ui/+app/tab.m index 47c49d7..1a966bc 100644 --- a/+labkit/+ui/+app/tab.m +++ b/+labkit/+ui/+app/tab.m @@ -3,7 +3,7 @@ % % Usage: % spec = labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', ... -% [4 1], {240, 220, 280, 160}, struct('resizeRows', [1 2 3])); +% [4 1], {240, 220, 280, 160}); % % Inputs: % key - valid field-name style identifier used in the returned ui struct. @@ -14,7 +14,8 @@ % % Options: % columnWidth - cell row of column widths, default all {'1x'}. -% resizeRows - numeric logical-row boundaries after which drag handles are added. +% resize - row-resize behavior: 'betweenRows' default, or 'none'. +% resizeRows - legacy numeric logical-row boundaries. Prefer resize. % resizeOptions - struct passed to row-resize handle creation. % padding, rowSpacing, columnSpacing - grid layout properties. % @@ -27,6 +28,8 @@ if nargin < 5 opts = struct(); end + optsHasResize = isfield(opts, 'resize'); + optsHasResizeRows = isfield(opts, 'resizeRows'); spec = struct( ... 'key', char(key), ... @@ -34,6 +37,7 @@ 'gridSize', gridSize, ... 'rowHeight', {asCellRow(rowHeight)}, ... 'columnWidth', {repmat({'1x'}, 1, gridSize(2))}, ... + 'resize', 'betweenRows', ... 'resizeRows', [], ... 'resizeOptions', struct()); @@ -41,6 +45,10 @@ for k = 1:numel(fields) spec.(fields{k}) = opts.(fields{k}); end + + if optsHasResizeRows && isempty(opts.resizeRows) && ~optsHasResize + spec.resize = 'none'; + end end function value = asCellRow(value) diff --git a/README.md b/README.md index 4c2311b..afc54f0 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![MATLAB](https://img.shields.io/badge/MATLAB-apps-orange.svg)](https://www.mathworks.com/products/matlab.html) Focused MATLAB GUI apps for lab workflows in electrochemistry, DIC, image -measurement, microscopy focus stacking, batch image cropping, and wearable -biosignal review. +measurement, microscopy focus stacking, image enhancement, batch image +cropping, and wearable biosignal review. LabKit MATLAB Workbench is an app-first research workbench. Each workflow keeps its own launch command, app-owned calculations, plots, summaries, and exports. @@ -21,7 +21,7 @@ processing. | --- | --- | | App-first workflows | Independent MATLAB GUI apps for daily lab tasks instead of one monolithic analysis launcher. | | Electrochemistry support | Gamry DTA loading, chrono overlays, CIC, CSC, VT resistance, EIS plotting, pulse handling, and CSV export paths. | -| Image and DIC workflows | DIC preprocessing/postprocessing, curve measurement, calibrated scale bars, focus-stack fusion, and batch microscope image crops. | +| Image and DIC workflows | DIC preprocessing/postprocessing, curve measurement, calibrated scale bars, focus-stack fusion, paper image enhancement, and batch microscope image crops. | | Wearable biosignals | ECG/table import, filtering, peak detection, event segments, templates, and SNR-style measurement summaries. | | Reusable foundation | Layered `labkit.ui`, GUI-free `labkit.dta`, and GUI-free `labkit.biosignal` facades. | | Guarded behavior | MATLAB build tasks, synthetic fixtures, architecture guardrails, and GitHub Actions CI. | @@ -53,6 +53,8 @@ labkit_DICPostprocess_app % Image measurement and microscopy utilities labkit_CurvatureMeasurement_app labkit_FocusStack_app +labkit_ImageEnhance_app +labkit_ImageMatch_app labkit_BatchImageCrop_app % Wearable biosignal review @@ -75,6 +77,8 @@ options, and export outputs when the selected app provides an export action. | `labkit_DICPostprocess_app` | Ncorr strain overlays, ROI summary, and colorbar export | Ncorr MAT, reference image, ROI mask | EXX/EYY overlays, summary CSV, colorbar files | | `labkit_CurvatureMeasurement_app` | Editable curve tracing, calibrated scale, length, and circle-fit curvature | Image files | Overlay PNG and curvature/length CSV | | `labkit_FocusStack_app` | Microscope focus-stack fusion into an all-in-focus image | Focus image folder or selected image files | Fused PNG, focus map PNG, summary CSV | +| `labkit_ImageEnhance_app` | Stepwise brightness, contrast, clarity, color, and white-balance enhancement for figures | Image files | Enhanced images and processing manifest CSV | +| `labkit_ImageMatch_app` | Reference-based white-balance, tone, and color-style matching for figure images | Image files | Matched images and processing manifest CSV | | `labkit_BatchImageCrop_app` | Fixed-size batch microscope crops with per-image center and rotation | Microscope image files | Cropped images and crop manifest CSV | | `labkit_ECGPrint_app` | ECG waveform preview, filtering, peak/segment SNR, and SNR-over-time display | MAT timetable, CSV, or TSV recordings | Segment SNR CSV and waveform PNG | diff --git a/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m b/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m index 08b70c7..0069825 100644 --- a/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m +++ b/apps/dic/dic_postprocess/labkit_DICPostprocess_app.m @@ -36,12 +36,10 @@ workbenchOpts.tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... {240, 230, 260, 120}, ... - struct('resizeRows', [1 2 3], ... - 'resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 90))), ... + struct('resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 90))), ... labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... {210, '1x'}, ... - struct('resizeRows', 1, ... - 'resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 90))), ... + struct('resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 90))), ... labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; ui = labkit.ui.app.createShell(struct( ... 'title', 'DIC Strain Postprocess', ... diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+ui/createLayout.m b/apps/dic/dic_preprocess/+dic_preprocess/+ui/createLayout.m index 7b3fa14..bd54420 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+ui/createLayout.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+ui/createLayout.m @@ -13,12 +13,10 @@ workbenchOpts.tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... {240, 210, 330, 170}, ... - struct('resizeRows', [1 2 3], ... - 'resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 80))), ... + struct('resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 80))), ... labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... {150, '1x'}, ... - struct('resizeRows', 1, ... - 'resizeOptions', struct('minTopHeight', 90, 'minBottomHeight', 90))), ... + struct('resizeOptions', struct('minTopHeight', 90, 'minBottomHeight', 90))), ... labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; ui = labkit.ui.app.createShell(struct( ... 'title', 'DIC Image Preprocess', ... diff --git a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m index 7efd3f2..edb58c3 100644 --- a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m +++ b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m @@ -29,11 +29,9 @@ 'rightRowHeight', {{'1x'}}); workbenchOpts.tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [3 1], ... - {250, 260, 145}, ... - struct('resizeRows', [1 2])), ... + {250, 260, 145}), ... labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {240, '1x'}, ... - struct('resizeRows', 1)), ... + {240, '1x'}), ... labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; ui = labkit.ui.app.createShell(struct( ... diff --git a/apps/image_measurement/curvature/+curvature/+ui/appShellOptions.m b/apps/image_measurement/curvature/+curvature/+ui/appShellOptions.m index 14ec261..74bb1a6 100644 --- a/apps/image_measurement/curvature/+curvature/+ui/appShellOptions.m +++ b/apps/image_measurement/curvature/+curvature/+ui/appShellOptions.m @@ -11,10 +11,8 @@ opts.tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [5 1], ... {140, 105, 355, 225, 160}, ... - struct('resizeRows', [1 2 3 4], ... - 'resizeOptions', struct('minTopHeight', 140, 'minBottomHeight', 90))), ... + struct('resizeOptions', struct('minTopHeight', 140, 'minBottomHeight', 90))), ... labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {170, '1x'}, ... - struct('resizeRows', 1)), ... + {170, '1x'}), ... labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; end diff --git a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m index 9246a0f..b92818e 100644 --- a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m +++ b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m @@ -33,11 +33,9 @@ workbenchOpts.tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... {250, 235, 185, 170}, ... - struct('resizeRows', [1 2 3], ... - 'resizeOptions', struct('minTopHeight', 130, 'minBottomHeight', 90))), ... + struct('resizeOptions', struct('minTopHeight', 130, 'minBottomHeight', 90))), ... labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {220, '1x'}, ... - struct('resizeRows', 1)), ... + {220, '1x'}), ... labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; ui = labkit.ui.app.createShell(struct( ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/+export/buildManifest.m b/apps/image_measurement/image_enhance/+image_enhance/+export/buildManifest.m new file mode 100644 index 0000000..c3bf427 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+export/buildManifest.m @@ -0,0 +1,27 @@ +% Expected caller: image_enhance.export.writeOutputs and tests. Input is a +% result struct array from batch export. Output is the stable CSV manifest table. +function T = buildManifest(results) + + results = results(:); + sourceImage = strings(numel(results), 1); + outputImage = strings(numel(results), 1); + status = strings(numel(results), 1); + widthPx = zeros(numel(results), 1); + heightPx = zeros(numel(results), 1); + stepCount = zeros(numel(results), 1); + message = strings(numel(results), 1); + + for k = 1:numel(results) + sourceImage(k) = results(k).sourcePath; + outputImage(k) = results(k).outputPath; + status(k) = results(k).status; + widthPx(k) = results(k).widthPx; + heightPx(k) = results(k).heightPx; + stepCount(k) = results(k).stepCount; + message(k) = results(k).message; + end + + T = table(sourceImage, outputImage, status, widthPx, heightPx, ... + stepCount, message, 'VariableNames', {'SourceImage', 'OutputImage', ... + 'Status', 'Width_px', 'Height_px', 'StepCount', 'Message'}); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+export/writeOutputs.m b/apps/image_measurement/image_enhance/+image_enhance/+export/writeOutputs.m new file mode 100644 index 0000000..256b5b8 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+export/writeOutputs.m @@ -0,0 +1,103 @@ +% Expected caller: labkit_ImageEnhance_app and image_enhance export tests. +% Inputs are loaded image items, ordered enhancement steps, and export options. +% Output includes per-image result structs and the manifest CSV path. +function payload = writeOutputs(items, steps, opts) + + if isempty(items) + error('labkit_ImageEnhance_app:NoImagesLoaded', ... + 'Load images before exporting enhanced outputs.'); + end + if nargin < 3 || isempty(opts) + opts = struct(); + end + outputFolder = optionValue(opts, 'outputFolder', string(pwd)); + outputFormat = optionValue(opts, 'format', 'PNG'); + + if exist(outputFolder, 'dir') ~= 7 + mkdir(outputFolder); + end + + images = cell(numel(items), 1); + for k = 1:numel(items) + images{k} = items(k).image; + end + processed = image_enhance.ops.applyPipeline(images, steps); + + resultTemplate = emptyResult(); + results = repmat(resultTemplate, numel(items), 1); + for k = 1:numel(items) + result = resultTemplate; + result.sourcePath = items(k).path; + result.stepCount = numel(steps); + result.widthPx = size(processed{k}, 2); + result.heightPx = size(processed{k}, 1); + + outputPath = uniqueOutputPath(outputFolder, items(k).path, outputFormat); + result.outputPath = outputPath; + try + imwrite(processed{k}, outputPath); + result.status = "saved"; + result.message = "Saved"; + catch ME + result.status = "failed"; + result.message = string(ME.message); + end + results(k) = result; + end + + manifestPath = uniquePath(fullfile(char(outputFolder), ... + 'image_enhance_manifest.csv')); + writetable(image_enhance.export.buildManifest(results), manifestPath); + + payload = struct(); + payload.results = results; + payload.manifestPath = string(manifestPath); +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isfield(opts, name) && ~isempty(opts.(name)) + value = opts.(name); + end +end + +function result = emptyResult() + result = struct( ... + 'sourcePath', "", ... + 'outputPath', "", ... + 'status', "pending", ... + 'widthPx', 0, ... + 'heightPx', 0, ... + 'stepCount', 0, ... + 'message', ""); +end + +function outputPath = uniqueOutputPath(outputFolder, sourcePath, formatName) + [~, base, ~] = fileparts(char(sourcePath)); + extension = formatExtension(formatName); + outputPath = uniquePath(fullfile(char(outputFolder), ... + sprintf('%s_enhanced%s', base, extension))); + outputPath = string(outputPath); +end + +function extension = formatExtension(formatName) + switch upper(string(formatName)) + case "TIFF" + extension = '.tif'; + case "JPEG" + extension = '.jpg'; + otherwise + extension = '.png'; + end +end + +function path = uniquePath(path) + [folder, base, ext] = fileparts(path); + candidate = fullfile(folder, [base ext]); + index = 1; + while isfile(candidate) + candidate = fullfile(folder, sprintf('%s_%03d%s', base, index, ext)); + index = index + 1; + end + path = candidate; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+io/imageDialogFilter.m b/apps/image_measurement/image_enhance/+image_enhance/+io/imageDialogFilter.m new file mode 100644 index 0000000..a4c3bb5 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+io/imageDialogFilter.m @@ -0,0 +1,9 @@ +% Expected caller: labkit_ImageEnhance_app file dialogs. Output is a uigetfile +% filter spec for image formats supported by the enhancement workflow. +function filterSpec = imageDialogFilter() + + filterSpec = { ... + '*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... + 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'; ... + '*.*', 'All files (*.*)'}; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+io/readImages.m b/apps/image_measurement/image_enhance/+image_enhance/+io/readImages.m new file mode 100644 index 0000000..3bae9dc --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+io/readImages.m @@ -0,0 +1,33 @@ +% Expected caller: labkit_ImageEnhance_app and batch export tests. Input is a +% string vector of image paths. Output is an item struct array with RGB double +% images normalized to [0, 1]. Alpha channels are ignored. +function items = readImages(paths) + + paths = string(paths(:)); + template = image_enhance.state.emptyItem(); + items = repmat(template, numel(paths), 1); + + for k = 1:numel(paths) + imageData = imread(paths(k)); + items(k) = template; + items(k).path = paths(k); + items(k).name = displayName(paths(k)); + items(k).image = normalizeImage(imageData); + end +end + +function name = displayName(path) + [~, base, ext] = fileparts(char(path)); + name = string([base ext]); +end + +function imageData = normalizeImage(imageData) + if ndims(imageData) == 2 + imageData = repmat(imageData, 1, 1, 3); + elseif size(imageData, 3) > 3 + imageData = imageData(:, :, 1:3); + end + + imageData = im2double(imageData); + imageData = min(max(imageData, 0), 1); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+io/selectedImagePaths.m b/apps/image_measurement/image_enhance/+image_enhance/+io/selectedImagePaths.m new file mode 100644 index 0000000..1f83262 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+io/selectedImagePaths.m @@ -0,0 +1,25 @@ +% Expected caller: labkit_ImageEnhance_app and image_enhance IO tests. Inputs +% are uigetfile selected names and folder. Output is a sorted string column of +% absolute file paths. Unsupported extensions raise an app-specific error. +function paths = selectedImagePaths(files, folder) + + folder = string(folder); + if ischar(files) || isstring(files) + files = {char(files)}; + end + + paths = strings(numel(files), 1); + for k = 1:numel(files) + paths(k) = string(fullfile(char(folder), char(files{k}))); + end + paths = sort(paths(:)); + + allowed = image_enhance.io.supportedImageExtensions(); + for k = 1:numel(paths) + [~, ~, ext] = fileparts(char(paths(k))); + if ~any(strcmpi(string(ext), allowed)) + error('labkit_ImageEnhance_app:UnsupportedImageFile', ... + 'Unsupported image file type: %s', char(paths(k))); + end + end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+io/supportedImageExtensions.m b/apps/image_measurement/image_enhance/+image_enhance/+io/supportedImageExtensions.m new file mode 100644 index 0000000..7226565 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+io/supportedImageExtensions.m @@ -0,0 +1,6 @@ +% Expected caller: image_enhance IO helpers and tests. Output is the supported +% lowercase extension list for source images. +function extensions = supportedImageExtensions() + + extensions = [".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"]; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ops/applyPipeline.m b/apps/image_measurement/image_enhance/+image_enhance/+ops/applyPipeline.m new file mode 100644 index 0000000..b1fa6eb --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+ops/applyPipeline.m @@ -0,0 +1,31 @@ +% Expected caller: labkit_ImageEnhance_app, batch export, and tests. Inputs are +% source RGB double images in a cell array and an ordered step array. Output is +% a cell array after applying the same non-destructive history pipeline to each +% image. +function processed = applyPipeline(images, steps) + + images = normalizeImages(images); + steps = steps(:); + processed = images; + + for iStep = 1:numel(steps) + step = steps(iStep); + for iImage = 1:numel(processed) + processed{iImage} = image_enhance.ops.applyStep( ... + processed{iImage}, step, []); + end + end +end + +function images = normalizeImages(images) + if isnumeric(images) + images = {images}; + end + images = images(:); + for k = 1:numel(images) + images{k} = min(max(im2double(images{k}), 0), 1); + if ndims(images{k}) == 2 + images{k} = repmat(images{k}, 1, 1, 3); + end + end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ops/applyStep.m b/apps/image_measurement/image_enhance/+image_enhance/+ops/applyStep.m new file mode 100644 index 0000000..4cbb804 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+ops/applyStep.m @@ -0,0 +1,95 @@ +% Expected caller: image_enhance.ops.applyPipeline and focused tests. Inputs +% are one RGB double source image, a normalized step, and an optional reference +% image for match-reference steps. Output is RGB double image data in [0, 1]. +function outputImage = applyStep(inputImage, step, referenceImage) + + inputImage = normalizeImage(inputImage); + key = normalizeKind(step.kind); + switch key + case 'brightnesscontrast' + outputImage = adjustBrightnessContrast(inputImage, ... + step.amount, step.secondary); + case 'localcontrast' + outputImage = localContrast(inputImage, step.amount, step.secondary); + case 'sharpen' + outputImage = sharpenImage(inputImage, step.amount, step.secondary); + case 'huesaturation' + outputImage = adjustHueSaturation(inputImage, ... + step.amount, step.secondary); + case 'whitebalance' + outputImage = whiteBalance(inputImage, step.amount, step.secondary); + otherwise + error('labkit_ImageEnhance_app:UnknownEnhancementStep', ... + 'Unknown image enhancement step: %s', char(step.kind)); + end + outputImage = min(max(outputImage, 0), 1); +end + +function imageData = normalizeImage(imageData) + imageData = im2double(imageData); + if ndims(imageData) == 2 + imageData = repmat(imageData, 1, 1, 3); + elseif size(imageData, 3) > 3 + imageData = imageData(:, :, 1:3); + end + imageData = min(max(imageData, 0), 1); +end + +function outputImage = adjustBrightnessContrast(inputImage, brightnessPct, contrastPct) + brightness = double(brightnessPct) / 100; + contrastScale = max(0, 1 + double(contrastPct) / 100); + outputImage = (inputImage - 0.5) .* contrastScale + 0.5 + brightness; +end + +function outputImage = localContrast(inputImage, amountPct, radiusPx) + amount = max(0, double(amountPct)) / 100; + radius = max(1, round(double(radiusPx))); + hsvImage = rgb2hsv(inputImage); + valueChannel = hsvImage(:, :, 3); + blurred = boxBlur(valueChannel, 2 * radius + 1); + hsvImage(:, :, 3) = valueChannel + amount .* 1.5 .* (valueChannel - blurred); + outputImage = hsv2rgb(hsvImage); +end + +function outputImage = sharpenImage(inputImage, amountPct, radiusPx) + amount = max(0, double(amountPct)) / 100; + radius = max(0.5, double(radiusPx)); + windowSize = max(3, 2 * round(radius) + 1); + blurred = zeros(size(inputImage)); + for channel = 1:size(inputImage, 3) + blurred(:, :, channel) = boxBlur(inputImage(:, :, channel), windowSize); + end + outputImage = inputImage + amount .* 2.0 .* (inputImage - blurred); +end + +function outputImage = adjustHueSaturation(inputImage, hueDeg, saturationPct) + hsvImage = rgb2hsv(inputImage); + hsvImage(:, :, 1) = mod(hsvImage(:, :, 1) + double(hueDeg) / 360, 1); + hsvImage(:, :, 2) = hsvImage(:, :, 2) .* (1 + double(saturationPct) / 100); + outputImage = hsv2rgb(hsvImage); +end + +function outputImage = whiteBalance(inputImage, strengthPct, temperaturePct) + strength = min(max(double(strengthPct) / 100, 0), 1); + channelMean = squeeze(mean(inputImage, [1 2])); + grayMean = mean(channelMean); + gains = grayMean ./ max(channelMean, eps); + gains = reshape(gains, 1, 1, []); + balanced = inputImage .* gains; + + temperature = double(temperaturePct) / 100; + balanced(:, :, 1) = balanced(:, :, 1) + 0.08 * temperature; + balanced(:, :, 3) = balanced(:, :, 3) - 0.08 * temperature; + outputImage = (1 - strength) .* inputImage + strength .* balanced; +end + +function outputImage = boxBlur(inputImage, windowSize) + windowSize = max(1, round(windowSize)); + kernel = ones(windowSize, windowSize); + outputImage = conv2(inputImage, kernel, 'same') ./ ... + conv2(ones(size(inputImage)), kernel, 'same'); +end + +function key = normalizeKind(kind) + key = lower(regexprep(char(string(kind)), '[^a-zA-Z0-9]', '')); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ops/defaultStepValues.m b/apps/image_measurement/image_enhance/+image_enhance/+ops/defaultStepValues.m new file mode 100644 index 0000000..78d8fb3 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+ops/defaultStepValues.m @@ -0,0 +1,52 @@ +% Expected caller: labkit_ImageEnhance_app controls. Input is a user-facing +% step kind. Output defines the default amount/secondary values and labels. +function values = defaultStepValues(kind) + + key = normalizeKind(kind); + values = struct(); + values.amountLabel = "Amount:"; + values.secondaryLabel = "Secondary:"; + values.amountLimits = [-100 100]; + values.secondaryLimits = [-100 100]; + values.amount = 0; + values.secondary = 0; + values.referenceEnabled = false; + + switch key + case 'brightnesscontrast' + values.amountLabel = "Brightness (%):"; + values.secondaryLabel = "Contrast (%):"; + values.amount = 0; + values.secondary = 15; + case 'localcontrast' + values.amountLabel = "Clarity (%):"; + values.secondaryLabel = "Radius (px):"; + values.amountLimits = [0 100]; + values.secondaryLimits = [1 80]; + values.amount = 30; + values.secondary = 12; + case 'sharpen' + values.amountLabel = "Sharpen (%):"; + values.secondaryLabel = "Radius (px):"; + values.amountLimits = [0 100]; + values.secondaryLimits = [0.5 20]; + values.amount = 35; + values.secondary = 1.5; + case 'huesaturation' + values.amountLabel = "Hue (deg):"; + values.secondaryLabel = "Saturation (%):"; + values.amountLimits = [-180 180]; + values.amount = 0; + values.secondary = 10; + case 'whitebalance' + values.amountLabel = "Strength (%):"; + values.secondaryLabel = "Temp (%):"; + values.amountLimits = [0 100]; + values.amount = 100; + values.secondary = 0; + end +end + +function key = normalizeKind(kind) + key = lower(regexprep(char(string(kind)), '[^a-zA-Z0-9]', '')); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ops/describeStep.m b/apps/image_measurement/image_enhance/+image_enhance/+ops/describeStep.m new file mode 100644 index 0000000..96bab4c --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+ops/describeStep.m @@ -0,0 +1,31 @@ +% Expected caller: labkit_ImageEnhance_app, view helpers, and tests. Input is +% an enhancement step. Output is a concise reproducible history label. +function label = describeStep(step) + + kind = normalizeKind(step.kind); + switch kind + case 'brightnesscontrast' + label = sprintf('Brightness %+g%%, contrast %+g%%', ... + step.amount, step.secondary); + case 'localcontrast' + label = sprintf('Local contrast %+g%%, radius %.1f px', ... + step.amount, max(1, step.secondary)); + case 'sharpen' + label = sprintf('Sharpen %+g%%, radius %.1f px', ... + step.amount, max(0.5, step.secondary)); + case 'huesaturation' + label = sprintf('Hue %+g deg, saturation %+g%%', ... + step.amount, step.secondary); + case 'whitebalance' + label = sprintf('Gray-world white balance %g%%, temp %+g%%', ... + step.amount, step.secondary); + otherwise + label = sprintf('%s %+g %+g', char(step.kind), ... + step.amount, step.secondary); + end + label = string(label); +end + +function key = normalizeKind(kind) + key = lower(regexprep(char(string(kind)), '[^a-zA-Z0-9]', '')); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ops/makeStep.m b/apps/image_measurement/image_enhance/+image_enhance/+ops/makeStep.m new file mode 100644 index 0000000..500f356 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+ops/makeStep.m @@ -0,0 +1,16 @@ +% Expected caller: labkit_ImageEnhance_app and image_enhance tests. Inputs are +% the user-facing step kind, numeric controls, and optional reference image +% index. Output is a normalized step record with a stable display label. +function step = makeStep(kind, amount, secondary, referenceIndex) + + if nargin < 4 + referenceIndex = 0; + end + + step = image_enhance.state.emptyStep(); + step.kind = string(kind); + step.amount = double(amount); + step.secondary = double(secondary); + step.referenceIndex = double(referenceIndex); + step.label = image_enhance.ops.describeStep(step); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+state/emptyItem.m b/apps/image_measurement/image_enhance/+image_enhance/+state/emptyItem.m new file mode 100644 index 0000000..66ac100 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+state/emptyItem.m @@ -0,0 +1,10 @@ +% Expected caller: labkit_ImageEnhance_app and image_enhance package tests. +% Output is one loaded-image record with source path, display name, and RGB +% double image payload ready for deterministic enhancement operations. +function item = emptyItem() + + item = struct( ... + 'path', "", ... + 'name', "", ... + 'image', []); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+state/emptyStep.m b/apps/image_measurement/image_enhance/+image_enhance/+state/emptyStep.m new file mode 100644 index 0000000..631d527 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+state/emptyStep.m @@ -0,0 +1,12 @@ +% Expected caller: labkit_ImageEnhance_app and image_enhance package tests. +% Output is one enhancement-history step. kind names match the app dropdown; +% amount and secondary are numeric controls whose meaning depends on kind. +function step = emptyStep() + + step = struct( ... + 'kind', "", ... + 'amount', 0, ... + 'secondary', 0, ... + 'referenceIndex', 0, ... + 'label', ""); +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+ui/createEditorUi.m b/apps/image_measurement/image_enhance/+image_enhance/+ui/createEditorUi.m new file mode 100644 index 0000000..b624695 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+ui/createEditorUi.m @@ -0,0 +1,195 @@ +% Expected caller: labkit_ImageEnhance_app. Inputs are the tool labels, initial +% export folder, and app callback handles. Output is a struct of UI component +% handles for the image-enhancement editor shell. +function uih = createEditorUi(stepKinds, outputFolder, callbacks) + + workbenchOpts = struct( ... + 'rightTitle', 'Preview', ... + 'rightGridSize', [1 1], ... + 'rightRowHeight', {{'1x'}}); + workbenchOpts.tabs = [ ... + labkit.ui.app.tab('libraryExport', 'Library + Export', [3 1], ... + {250, 185, 150}), ... + labkit.ui.app.tab('toolsHistory', 'Tools + History', [4 1], ... + {90, 300, 245, 125}), ... + labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; + + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Paper Image Enhance', ... + 'position', [80 60 1460 860], ... + 'leftWidth', 470, ... + 'options', workbenchOpts)); + + uih = struct(); + uih.fig = ui.fig; + uih.previewAxes = uiaxes(ui.rightGrid); + title(uih.previewAxes, 'Enhanced Preview'); + labkit.ui.view.draw(uih.previewAxes, 'popout'); + + uih = buildLibrarySection(uih, ui.libraryExportGrid, callbacks, 1); + uih = buildExportSection(uih, ui.libraryExportGrid, outputFolder, callbacks, 2); + uih = buildExportDetailsSection(uih, ui.libraryExportGrid, 3); + uih = buildToolsSection(uih, ui.toolsHistoryGrid, stepKinds, callbacks, 1, 2); + uih = buildHistorySection(uih, ui.toolsHistoryGrid, 3); + uih = buildMetricsSection(uih, ui.toolsHistoryGrid, 4); + + logUi = labkit.ui.view.panel(ui.logGrid, 'log', 1, {'Ready.'}); + uih.txtLog = logUi.textArea; +end + +function uih = buildLibrarySection(uih, parentGrid, callbacks, row) + panel = labkit.ui.view.section(parentGrid, 'Library', row, [4 2], ... + struct('rowHeight', {{'fit', 'fit', 125, 'fit'}}, ... + 'columnWidth', {{'1x', '1x'}})); + grid = panel.grid; + + uih.btnOpenFiles = uibutton(grid, 'Text', 'Open image files', ... + 'ButtonPushedFcn', callbacks.openFiles); + place(uih.btnOpenFiles, 1, 1); + uih.btnClearImages = uibutton(grid, 'Text', 'Clear images', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.clearImages); + place(uih.btnClearImages, 1, 2); + + uih.txtImageSource = labkit.ui.view.form(grid, struct( ... + 'kind', 'readonly', ... + 'value', 'No images loaded')); + place(uih.txtImageSource, 2, [1 2]); + + uih.lbImages = uilistbox(grid, ... + 'Items', {'No images loaded'}, ... + 'ValueChangedFcn', callbacks.imageSelectionChanged); + place(uih.lbImages, 3, [1 2]); + + uih.txtImageStatus = labkit.ui.view.form(grid, struct( ... + 'kind', 'readonly', ... + 'value', 'Images: 0')); + place(uih.txtImageStatus, 4, [1 2]); +end + +function uih = buildToolsSection(uih, parentGrid, stepKinds, callbacks, previewRow, toolboxRow) + previewPanel = labkit.ui.view.section(parentGrid, 'Preview', previewRow, [2 2], ... + struct('rowHeight', {{'fit', 'fit'}}, ... + 'columnWidth', {{135, '1x'}})); + [lblPreviewMode, uih.ddPreviewMode] = labkit.ui.view.form(previewPanel.grid, struct( ... + 'kind', 'dropdown', ... + 'label', 'Mode:', ... + 'items', {{'Enhanced', 'Original', 'Before | After'}}, ... + 'value', 'Enhanced', ... + 'callback', callbacks.previewModeChanged)); + place(lblPreviewMode, 1, 1); + place(uih.ddPreviewMode, 1, 2); + uih.txtToolStatus = labkit.ui.view.form(previewPanel.grid, struct( ... + 'kind', 'readonly', ... + 'value', 'Select an image, choose a tool, then apply it to history.')); + place(uih.txtToolStatus, 2, [1 2]); + + toolPanel = labkit.ui.view.section(parentGrid, 'Toolbox', toolboxRow, [6 2], ... + struct('rowHeight', {{145, 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{135, '1x'}})); + grid = toolPanel.grid; + uih.lbTools = uilistbox(grid, ... + 'Items', stepKinds, ... + 'Value', stepKinds{1}, ... + 'ValueChangedFcn', callbacks.toolChanged); + place(uih.lbTools, 1, [1 2]); + + [uih.lblAmount, uih.edtAmount] = labkit.ui.view.form(grid, struct( ... + 'kind', 'spinner', ... + 'label', 'Brightness (%):', ... + 'value', 0, ... + 'limits', [-100 100], ... + 'step', 1)); + place(uih.lblAmount, 2, 1); + place(uih.edtAmount, 2, 2); + uih.edtAmount.ValueChangedFcn = callbacks.toolSettingChanged; + + [uih.lblSecondary, uih.edtSecondary] = labkit.ui.view.form(grid, struct( ... + 'kind', 'spinner', ... + 'label', 'Contrast (%):', ... + 'value', 15, ... + 'limits', [-100 100], ... + 'step', 1)); + place(uih.lblSecondary, 3, 1); + place(uih.edtSecondary, 3, 2); + uih.edtSecondary.ValueChangedFcn = callbacks.toolSettingChanged; + + uih.btnApplyTool = uibutton(grid, 'Text', 'Apply tool', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.applyTool); + place(uih.btnApplyTool, 4, [1 2]); + uih.btnUndoHistory = uibutton(grid, 'Text', 'Undo history', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.undoHistory); + place(uih.btnUndoHistory, 5, [1 2]); + uih.btnResetHistory = uibutton(grid, 'Text', 'Reset history', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.resetHistory); + place(uih.btnResetHistory, 6, [1 2]); +end + +function uih = buildHistorySection(uih, parentGrid, row) + panel = labkit.ui.view.section(parentGrid, 'Step History', row, [2 1], ... + struct('rowHeight', {{200, 'fit'}})); + uih.historyTable = uitable(panel.grid, ... + 'ColumnName', {'#', 'Step', 'Settings'}, ... + 'Data', image_enhance.view.historyTableData(repmat(image_enhance.state.emptyStep(), 0, 1))); + place(uih.historyTable, 1, 1); + uih.txtHistoryStatus = labkit.ui.view.form(panel.grid, struct( ... + 'kind', 'readonly', ... + 'value', 'History steps: 0')); + place(uih.txtHistoryStatus, 2, 1); + +end + +function uih = buildMetricsSection(uih, parentGrid, row) + panel = labkit.ui.view.section(parentGrid, 'Current Image', row, [1 1], ... + struct('rowHeight', {{95}})); + uih.resultTable = uitable(panel.grid, ... + 'ColumnName', {'Metric', 'Value'}, ... + 'Data', image_enhance.view.resultTableData([], [], 0)); + place(uih.resultTable, 1, 1); +end + +function uih = buildExportSection(uih, parentGrid, outputFolder, callbacks, row) + panel = labkit.ui.view.section(parentGrid, 'Batch Export', row, [3 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{135, '1x'}})); + grid = panel.grid; + + uih.btnChooseOutput = uibutton(grid, 'Text', 'Choose folder', ... + 'ButtonPushedFcn', callbacks.chooseOutputFolder); + place(uih.btnChooseOutput, 1, 1); + uih.txtOutputFolder = labkit.ui.view.form(grid, struct( ... + 'kind', 'readonly', ... + 'value', outputFolder)); + place(uih.txtOutputFolder, 1, 2); + + [lblFormat, uih.ddFormat] = labkit.ui.view.form(grid, struct( ... + 'kind', 'dropdown', ... + 'label', 'Format:', ... + 'items', {{'PNG', 'TIFF', 'JPEG'}}, ... + 'value', 'PNG')); + place(lblFormat, 2, 1); + place(uih.ddFormat, 2, 2); + + uih.btnExport = uibutton(grid, 'Text', 'Export enhanced images', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.exportImages); + place(uih.btnExport, 3, [1 2]); +end + +function uih = buildExportDetailsSection(uih, parentGrid, row) + panel = labkit.ui.view.section(parentGrid, 'Export Details', row, [1 1], ... + struct('rowHeight', {{105}})); + uih.txtDetails = uitextarea(panel.grid, 'Editable', 'off'); + place(uih.txtDetails, 1, 1); + uih.txtDetails.Value = image_enhance.view.detailLines( ... + repmat(image_enhance.state.emptyItem(), 0, 1), 1, ... + repmat(image_enhance.state.emptyStep(), 0, 1), []); +end + +function place(component, row, column) + component.Layout.Row = row; + component.Layout.Column = column; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+view/beforeAfterImage.m b/apps/image_measurement/image_enhance/+image_enhance/+view/beforeAfterImage.m new file mode 100644 index 0000000..1d818a7 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+view/beforeAfterImage.m @@ -0,0 +1,21 @@ +% Expected caller: labkit_ImageEnhance_app preview rendering. Inputs are original +% and enhanced images. Output is a display-only side-by-side RGB preview image +% with a narrow divider; source images are not modified. +function imageOut = beforeAfterImage(original, enhanced) + + original = normalizePreviewImage(original); + enhanced = normalizePreviewImage(enhanced); + if size(original, 1) ~= size(enhanced, 1) || size(original, 2) ~= size(enhanced, 2) + enhanced = imresize(enhanced, [size(original, 1), size(original, 2)]); + end + + divider = ones(size(original, 1), 6, 3); + imageOut = cat(2, original, divider, enhanced); +end + +function imageOut = normalizePreviewImage(imageIn) + imageOut = min(max(im2double(imageIn), 0), 1); + if ndims(imageOut) == 2 + imageOut = repmat(imageOut, 1, 1, 3); + end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+view/detailLines.m b/apps/image_measurement/image_enhance/+image_enhance/+view/detailLines.m new file mode 100644 index 0000000..96429a2 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+view/detailLines.m @@ -0,0 +1,22 @@ +% Expected caller: labkit_ImageEnhance_app summary pane. Inputs are loaded +% items, selection index, history steps, and last batch export payload. +function lines = detailLines(items, currentIndex, steps, lastExport) + + if isempty(items) + lines = {'Load one or more images to begin enhancement.'}; + return; + end + + item = items(currentIndex); + lines = { ... + sprintf('Selected: %s', char(item.name)), ... + sprintf('Images loaded: %d', numel(items)), ... + sprintf('History steps: %d', numel(steps))}; + if ~isempty(steps) + lines{end + 1} = sprintf('Last step: %s', ... + char(image_enhance.ops.describeStep(steps(end)))); + end + if ~isempty(lastExport) && isfield(lastExport, 'manifestPath') + lines{end + 1} = sprintf('Last manifest: %s', char(lastExport.manifestPath)); + end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+view/displayImageNames.m b/apps/image_measurement/image_enhance/+image_enhance/+view/displayImageNames.m new file mode 100644 index 0000000..415dc3b --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+view/displayImageNames.m @@ -0,0 +1,14 @@ +% Expected caller: labkit_ImageEnhance_app listbox/reference controls. Input is +% a loaded item array. Output is a cell array of stable display labels. +function names = displayImageNames(items) + + if isempty(items) + names = {'No images loaded'}; + return; + end + + names = cell(numel(items), 1); + for k = 1:numel(items) + names{k} = sprintf('%d. %s', k, char(items(k).name)); + end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+view/historyTableData.m b/apps/image_measurement/image_enhance/+image_enhance/+view/historyTableData.m new file mode 100644 index 0000000..018fbe3 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+view/historyTableData.m @@ -0,0 +1,17 @@ +% Expected caller: labkit_ImageEnhance_app and tests. Input is the enhancement +% history. Output is cell data for the visible history table. +function data = historyTableData(steps) + + if isempty(steps) + data = cell(0, 3); + return; + end + + steps = steps(:); + data = cell(numel(steps), 3); + for k = 1:numel(steps) + data{k, 1} = k; + data{k, 2} = char(steps(k).kind); + data{k, 3} = char(image_enhance.ops.describeStep(steps(k))); + end +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+view/resultTableData.m b/apps/image_measurement/image_enhance/+image_enhance/+view/resultTableData.m new file mode 100644 index 0000000..2db7a07 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+view/resultTableData.m @@ -0,0 +1,19 @@ +% Expected caller: labkit_ImageEnhance_app and tests. Inputs are the current +% item, current processed image, and history length. Output is metric/value data. +function data = resultTableData(item, processedImage, stepCount) + + if isempty(item) || isempty(processedImage) + data = { ... + 'Images loaded', '0'; ... + 'Current image', '-'; ... + 'Output size', '-'; ... + 'History steps', '0'}; + return; + end + + data = { ... + 'Current image', char(item.name); ... + 'Output size', sprintf('%d x %d px', size(processedImage, 2), size(processedImage, 1)); ... + 'Mean intensity', sprintf('%.4g', mean(processedImage(:))); ... + 'History steps', sprintf('%d', stepCount)}; +end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+view/ternary.m b/apps/image_measurement/image_enhance/+image_enhance/+view/ternary.m new file mode 100644 index 0000000..d0f73e3 --- /dev/null +++ b/apps/image_measurement/image_enhance/+image_enhance/+view/ternary.m @@ -0,0 +1,10 @@ +% Expected caller: labkit_ImageEnhance_app UI state updates. Return trueValue +% when condition is true, otherwise falseValue. +function value = ternary(condition, trueValue, falseValue) + + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m b/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m new file mode 100644 index 0000000..b6d1441 --- /dev/null +++ b/apps/image_measurement/image_enhance/labkit_ImageEnhance_app.m @@ -0,0 +1,382 @@ +function varargout = labkit_ImageEnhance_app(varargin) +%LABKIT_IMAGEENHANCE_APP Image enhancement and color matching app for figures. + + [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... + 'labkit_ImageEnhance_app', varargin, nargout); + if requestHandled + varargout = requestOutputs; + return; + end + if debugLog.enabled + if nargout > 2 + error('labkit_ImageEnhance_app:TooManyOutputs', ... + 'labkit_ImageEnhance_app debug mode returns at most the app figure and debug log.'); + end + elseif nargout > 1 + error('labkit_ImageEnhance_app:TooManyOutputs', ... + 'labkit_ImageEnhance_app returns at most the app figure handle.'); + end + + S = struct(); + S.items = repmat(image_enhance.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.outputFolder = string(pwd); + S.lastExport = []; + S.pendingDirty = false; + + stepKinds = {'Brightness/contrast', 'Local contrast', 'Sharpen', ... + 'Hue/saturation', 'White balance'}; + + callbacks = struct( ... + 'openFiles', @onOpenFiles, ... + 'clearImages', @onClearImages, ... + 'imageSelectionChanged', @onImageSelectionChanged, ... + 'previewModeChanged', @onPreviewModeChanged, ... + 'toolChanged', @onToolChanged, ... + 'toolSettingChanged', @onToolSettingChanged, ... + 'applyTool', @onApplyTool, ... + 'undoHistory', @onUndoHistory, ... + 'resetHistory', @onResetHistory, ... + 'chooseOutputFolder', @onChooseOutputFolder, ... + 'exportImages', @onExportImages); + uih = image_enhance.ui.createEditorUi(stepKinds, char(S.outputFolder), callbacks); + fig = uih.fig; previewAxes = uih.previewAxes; txtLog = uih.txtLog; + btnOpenFiles = uih.btnOpenFiles; btnClearImages = uih.btnClearImages; + lbImages = uih.lbImages; txtImageSource = uih.txtImageSource; + txtImageStatus = uih.txtImageStatus; ddPreviewMode = uih.ddPreviewMode; + lbTools = uih.lbTools; txtToolStatus = uih.txtToolStatus; + lblAmount = uih.lblAmount; edtAmount = uih.edtAmount; + lblSecondary = uih.lblSecondary; edtSecondary = uih.edtSecondary; + btnApplyTool = uih.btnApplyTool; + btnUndoHistory = uih.btnUndoHistory; btnResetHistory = uih.btnResetHistory; + historyTable = uih.historyTable; txtHistoryStatus = uih.txtHistoryStatus; + resultTable = uih.resultTable; btnChooseOutput = uih.btnChooseOutput; + txtOutputFolder = uih.txtOutputFolder; ddFormat = uih.ddFormat; + btnExport = uih.btnExport; txtDetails = uih.txtDetails; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('Image enhance debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + resetPreviewAxes(); + updateToolControls(false); + refreshAll(); + + if nargout >= 1 + varargout{1} = fig; + end + if nargout >= 2 + varargout{2} = debugLog; + end + + function onOpenFiles(~, ~) + [files, folder] = uigetfile(image_enhance.io.imageDialogFilter(), ... + 'Select images to enhance', pwd, 'MultiSelect', 'on'); + if isequal(files, 0) + addLog('Image file selection cancelled.'); + return; + end + + try + paths = image_enhance.io.selectedImagePaths(files, folder); + S.items = image_enhance.io.readImages(paths); + catch ME + showError('Could not load images', ME.message); + return; + end + + S.currentIndex = 1; + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.outputFolder = string(folder); + S.lastExport = []; + txtOutputFolder.Value = char(S.outputFolder); + addLog(sprintf('Loaded %d image(s).', numel(S.items))); + refreshAll(); + end + + function onClearImages(~, ~) + S.items = repmat(image_enhance.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Cleared loaded images and enhancement history.'); + refreshAll(); + end + + function onImageSelectionChanged(~, ~) + if isempty(S.items) + return; + end + + names = image_enhance.view.displayImageNames(S.items); + idx = find(strcmp(names, lbImages.Value), 1); + if isempty(idx) + return; + end + S.currentIndex = idx; + refreshSelection(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + end + + function onPreviewModeChanged(~, ~) + refreshPreview(); + end + + function onToolChanged(~, ~) + updateToolControls(true); + S.pendingDirty = true; + S.lastExport = []; + refreshPreview(); + refreshToolStatus(); + end + + function onToolSettingChanged(~, ~) + updateToolControls(false); + S.pendingDirty = true; + S.lastExport = []; + refreshPreview(); + refreshToolStatus(); + end + + function onApplyTool(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before applying enhancement tools.'); + return; + end + + step = currentToolStep(); + S.steps(end + 1, 1) = step; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Applied tool: %s', char(step.label))); + refreshAll(); + end + + function onUndoHistory(~, ~) + if isempty(S.steps) + return; + end + removed = S.steps(end); + S.steps(end) = []; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Undid history step: %s', char(removed.label))); + refreshAll(); + end + + function onResetHistory(~, ~) + if isempty(S.steps) + return; + end + S.steps = repmat(image_enhance.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Reset enhancement history.'); + refreshAll(); + end + + function onChooseOutputFolder(~, ~) + folder = uigetdir(char(S.outputFolder), 'Select image enhancement export folder'); + if isequal(folder, 0) + addLog('Export folder selection cancelled.'); + return; + end + S.outputFolder = string(folder); + txtOutputFolder.Value = char(S.outputFolder); + refreshDetails(); + end + + function onExportImages(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before exporting enhanced outputs.'); + return; + end + + opts = struct(); + opts.outputFolder = S.outputFolder; + opts.format = ddFormat.Value; + busyOpts = struct(); + busyOpts.title = 'Export enhanced images'; + busyOpts.message = 'Writing enhanced image outputs...'; + busyOpts.controls = exportBusyControls(); + try + S.lastExport = labkit.ui.app.runBusy(fig, ... + @() image_enhance.export.writeOutputs(S.items, S.steps, opts), busyOpts); + catch ME + showError('Export failed', ME.message); + return; + end + + statuses = string({S.lastExport.results.status}); + addLog(sprintf('Exported %d image(s), %d failed. Manifest: %s', ... + sum(statuses == "saved"), sum(statuses == "failed"), ... + char(S.lastExport.manifestPath))); + refreshDetails(); + end + + function controls = exportBusyControls() + controls = {btnOpenFiles, btnClearImages, lbImages, ddPreviewMode, ... + lbTools, edtAmount, edtSecondary, btnApplyTool, ... + btnUndoHistory, btnResetHistory, btnChooseOutput, ddFormat, btnExport}; + end + + function refreshAll() + refreshList(); + updateToolControls(false); + refreshControls(); + refreshSelection(); + refreshHistory(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + refreshToolStatus(); + end + + function refreshList() + if isempty(S.items) + lbImages.Items = {'No images loaded'}; + lbImages.Value = 'No images loaded'; + txtImageSource.Value = 'No images loaded'; + txtImageStatus.Value = 'Images: 0'; + return; + end + + names = image_enhance.view.displayImageNames(S.items); + S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); + lbImages.Items = names; + lbImages.Value = names{S.currentIndex}; + txtImageStatus.Value = sprintf('Images: %d | history steps: %d', ... + numel(S.items), numel(S.steps)); + + end + + function refreshSelection() + if isempty(S.items) + txtImageSource.Value = 'No images loaded'; + return; + end + + txtImageSource.Value = char(S.items(S.currentIndex).path); + end + + function refreshControls() + hasImages = ~isempty(S.items); + hasSteps = ~isempty(S.steps); + btnClearImages.Enable = image_enhance.view.ternary(hasImages, 'on', 'off'); + btnApplyTool.Enable = image_enhance.view.ternary(hasImages, 'on', 'off'); + btnUndoHistory.Enable = image_enhance.view.ternary(hasSteps, 'on', 'off'); + btnResetHistory.Enable = image_enhance.view.ternary(hasSteps, 'on', 'off'); + btnExport.Enable = image_enhance.view.ternary(hasImages, 'on', 'off'); + end + + function refreshPreview() + if isempty(S.items) + resetPreviewAxes(); + return; + end + + original = S.items(S.currentIndex).image; + processed = currentProcessedImages(S.pendingDirty); + enhanced = processed{S.currentIndex}; + + switch ddPreviewMode.Value + case 'Original' + labkit.ui.view.draw(previewAxes, 'image', original, 'Original Preview'); + case 'Before | After' + labkit.ui.view.draw(previewAxes, 'image', ... + image_enhance.view.beforeAfterImage(original, enhanced), 'Before | After'); + otherwise + labkit.ui.view.draw(previewAxes, 'image', enhanced, 'Enhanced Preview'); + end + end + + function refreshMetrics() + if isempty(S.items) + resultTable.Data = image_enhance.view.resultTableData([], [], 0); + return; + end + + processed = currentProcessedImages(false); + resultTable.Data = image_enhance.view.resultTableData( ... + S.items(S.currentIndex), processed{S.currentIndex}, numel(S.steps)); + end + + function refreshHistory() + historyTable.Data = image_enhance.view.historyTableData(S.steps); + txtHistoryStatus.Value = sprintf('History steps: %d', numel(S.steps)); + end + + function refreshDetails() + txtDetails.Value = image_enhance.view.detailLines( ... + S.items, max(S.currentIndex, 1), S.steps, S.lastExport); + end + + function refreshToolStatus() + if isempty(S.items) + txtToolStatus.Value = 'Select an image, choose a tool, then apply it to history.'; + return; + end + + step = currentToolStep(); + if S.pendingDirty + prefix = 'Previewing: '; + else + prefix = 'Ready: '; + end + txtToolStatus.Value = [prefix char(step.label)]; + end + + function processed = currentProcessedImages(includePending) + images = cell(numel(S.items), 1); + for k = 1:numel(S.items) + images{k} = S.items(k).image; + end + + steps = S.steps; + if includePending + steps(end + 1, 1) = currentToolStep(); + end + processed = image_enhance.ops.applyPipeline(images, steps); + end + + function step = currentToolStep() + step = image_enhance.ops.makeStep(lbTools.Value, ... + edtAmount.Value, edtSecondary.Value, 0); + end + + function updateToolControls(resetToDefaults) + values = image_enhance.ops.defaultStepValues(lbTools.Value); + lblAmount.Text = char(values.amountLabel); + lblSecondary.Text = char(values.secondaryLabel); + edtAmount.Limits = values.amountLimits; + edtSecondary.Limits = values.secondaryLimits; + edtAmount.Value = min(max(edtAmount.Value, edtAmount.Limits(1)), edtAmount.Limits(2)); + edtSecondary.Value = min(max(edtSecondary.Value, edtSecondary.Limits(1)), edtSecondary.Limits(2)); + if resetToDefaults + edtAmount.Value = values.amount; + edtSecondary.Value = values.secondary; + end + end + + function resetPreviewAxes() + labkit.ui.view.draw(previewAxes, 'reset', 'Enhanced Preview', true); + end + + function addLog(message) + labkit.ui.view.update(txtLog, 'appendLog', message); + if debugLog.enabled + debugLog.append(message); + end + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end +end diff --git a/apps/image_measurement/image_match/+image_match/+export/buildManifest.m b/apps/image_measurement/image_match/+image_match/+export/buildManifest.m new file mode 100644 index 0000000..2929f47 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+export/buildManifest.m @@ -0,0 +1,27 @@ +% Expected caller: image_match.export.writeOutputs and tests. Input is a +% result struct array from batch export. Output is the stable CSV manifest table. +function T = buildManifest(results) + + results = results(:); + sourceImage = strings(numel(results), 1); + outputImage = strings(numel(results), 1); + status = strings(numel(results), 1); + widthPx = zeros(numel(results), 1); + heightPx = zeros(numel(results), 1); + stepCount = zeros(numel(results), 1); + message = strings(numel(results), 1); + + for k = 1:numel(results) + sourceImage(k) = results(k).sourcePath; + outputImage(k) = results(k).outputPath; + status(k) = results(k).status; + widthPx(k) = results(k).widthPx; + heightPx(k) = results(k).heightPx; + stepCount(k) = results(k).stepCount; + message(k) = results(k).message; + end + + T = table(sourceImage, outputImage, status, widthPx, heightPx, ... + stepCount, message, 'VariableNames', {'SourceImage', 'OutputImage', ... + 'Status', 'Width_px', 'Height_px', 'StepCount', 'Message'}); +end diff --git a/apps/image_measurement/image_match/+image_match/+export/writeOutputs.m b/apps/image_measurement/image_match/+image_match/+export/writeOutputs.m new file mode 100644 index 0000000..4f90cbd --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+export/writeOutputs.m @@ -0,0 +1,103 @@ +% Expected caller: labkit_ImageMatch_app and image_match export tests. +% Inputs are loaded image items, ordered reference-match steps, and export options. +% Output includes per-image result structs and the manifest CSV path. +function payload = writeOutputs(items, steps, opts) + + if isempty(items) + error('labkit_ImageMatch_app:NoImagesLoaded', ... + 'Load images before exporting matched outputs.'); + end + if nargin < 3 || isempty(opts) + opts = struct(); + end + outputFolder = optionValue(opts, 'outputFolder', string(pwd)); + outputFormat = optionValue(opts, 'format', 'PNG'); + + if exist(outputFolder, 'dir') ~= 7 + mkdir(outputFolder); + end + + images = cell(numel(items), 1); + for k = 1:numel(items) + images{k} = items(k).image; + end + processed = image_match.ops.applyPipeline(images, steps); + + resultTemplate = emptyResult(); + results = repmat(resultTemplate, numel(items), 1); + for k = 1:numel(items) + result = resultTemplate; + result.sourcePath = items(k).path; + result.stepCount = numel(steps); + result.widthPx = size(processed{k}, 2); + result.heightPx = size(processed{k}, 1); + + outputPath = uniqueOutputPath(outputFolder, items(k).path, outputFormat); + result.outputPath = outputPath; + try + imwrite(processed{k}, outputPath); + result.status = "saved"; + result.message = "Saved"; + catch ME + result.status = "failed"; + result.message = string(ME.message); + end + results(k) = result; + end + + manifestPath = uniquePath(fullfile(char(outputFolder), ... + 'image_match_manifest.csv')); + writetable(image_match.export.buildManifest(results), manifestPath); + + payload = struct(); + payload.results = results; + payload.manifestPath = string(manifestPath); +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isfield(opts, name) && ~isempty(opts.(name)) + value = opts.(name); + end +end + +function result = emptyResult() + result = struct( ... + 'sourcePath', "", ... + 'outputPath', "", ... + 'status', "pending", ... + 'widthPx', 0, ... + 'heightPx', 0, ... + 'stepCount', 0, ... + 'message', ""); +end + +function outputPath = uniqueOutputPath(outputFolder, sourcePath, formatName) + [~, base, ~] = fileparts(char(sourcePath)); + extension = formatExtension(formatName); + outputPath = uniquePath(fullfile(char(outputFolder), ... + sprintf('%s_matched%s', base, extension))); + outputPath = string(outputPath); +end + +function extension = formatExtension(formatName) + switch upper(string(formatName)) + case "TIFF" + extension = '.tif'; + case "JPEG" + extension = '.jpg'; + otherwise + extension = '.png'; + end +end + +function path = uniquePath(path) + [folder, base, ext] = fileparts(path); + candidate = fullfile(folder, [base ext]); + index = 1; + while isfile(candidate) + candidate = fullfile(folder, sprintf('%s_%03d%s', base, index, ext)); + index = index + 1; + end + path = candidate; +end diff --git a/apps/image_measurement/image_match/+image_match/+io/imageDialogFilter.m b/apps/image_measurement/image_match/+image_match/+io/imageDialogFilter.m new file mode 100644 index 0000000..49c47fa --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+io/imageDialogFilter.m @@ -0,0 +1,9 @@ +% Expected caller: labkit_ImageMatch_app file dialogs. Output is a uigetfile +% filter spec for image formats supported by the reference-match workflow. +function filterSpec = imageDialogFilter() + + filterSpec = { ... + '*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... + 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'; ... + '*.*', 'All files (*.*)'}; +end diff --git a/apps/image_measurement/image_match/+image_match/+io/readImages.m b/apps/image_measurement/image_match/+image_match/+io/readImages.m new file mode 100644 index 0000000..f87ddaa --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+io/readImages.m @@ -0,0 +1,33 @@ +% Expected caller: labkit_ImageMatch_app and batch export tests. Input is a +% string vector of image paths. Output is an item struct array with RGB double +% images normalized to [0, 1]. Alpha channels are ignored. +function items = readImages(paths) + + paths = string(paths(:)); + template = image_match.state.emptyItem(); + items = repmat(template, numel(paths), 1); + + for k = 1:numel(paths) + imageData = imread(paths(k)); + items(k) = template; + items(k).path = paths(k); + items(k).name = displayName(paths(k)); + items(k).image = normalizeImage(imageData); + end +end + +function name = displayName(path) + [~, base, ext] = fileparts(char(path)); + name = string([base ext]); +end + +function imageData = normalizeImage(imageData) + if ndims(imageData) == 2 + imageData = repmat(imageData, 1, 1, 3); + elseif size(imageData, 3) > 3 + imageData = imageData(:, :, 1:3); + end + + imageData = im2double(imageData); + imageData = min(max(imageData, 0), 1); +end diff --git a/apps/image_measurement/image_match/+image_match/+io/selectedImagePaths.m b/apps/image_measurement/image_match/+image_match/+io/selectedImagePaths.m new file mode 100644 index 0000000..2a24efa --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+io/selectedImagePaths.m @@ -0,0 +1,25 @@ +% Expected caller: labkit_ImageMatch_app and image_match IO tests. Inputs +% are uigetfile selected names and folder. Output is a sorted string column of +% absolute file paths. Unsupported extensions raise an app-specific error. +function paths = selectedImagePaths(files, folder) + + folder = string(folder); + if ischar(files) || isstring(files) + files = {char(files)}; + end + + paths = strings(numel(files), 1); + for k = 1:numel(files) + paths(k) = string(fullfile(char(folder), char(files{k}))); + end + paths = sort(paths(:)); + + allowed = image_match.io.supportedImageExtensions(); + for k = 1:numel(paths) + [~, ~, ext] = fileparts(char(paths(k))); + if ~any(strcmpi(string(ext), allowed)) + error('labkit_ImageMatch_app:UnsupportedImageFile', ... + 'Unsupported image file type: %s', char(paths(k))); + end + end +end diff --git a/apps/image_measurement/image_match/+image_match/+io/supportedImageExtensions.m b/apps/image_measurement/image_match/+image_match/+io/supportedImageExtensions.m new file mode 100644 index 0000000..0a1754c --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+io/supportedImageExtensions.m @@ -0,0 +1,6 @@ +% Expected caller: image_match IO helpers and tests. Output is the supported +% lowercase extension list for source images. +function extensions = supportedImageExtensions() + + extensions = [".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"]; +end diff --git a/apps/image_measurement/image_match/+image_match/+ops/applyMatch.m b/apps/image_measurement/image_match/+image_match/+ops/applyMatch.m new file mode 100644 index 0000000..ad166fd --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+ops/applyMatch.m @@ -0,0 +1,168 @@ +% Expected caller: image_match.ops.applyStep and tests. Inputs are a source +% image, a reference image, and a match step with method/strength fields. +% Output is a display-ready RGB double image in [0, 1]. +function outputImage = applyMatch(inputImage, referenceImage, step) + + inputImage = normalizeImage(inputImage); + if isempty(referenceImage) + outputImage = inputImage; + return; + end + + referenceImage = normalizeImage(referenceImage); + strength = clamp01(double(step.amount) / 100); + toneStrength = clamp01(double(step.secondary) / 100); + colorStrength = clamp01(double(step.colorStrength) / 100); + method = normalizeKind(step.matchMethod); + if method == "" + method = "balanced"; + end + + switch method + case "whitebalance" + matched = whiteBalanceMatch(inputImage, referenceImage); + case "toneonly" + matched = labToneMatch(inputImage, referenceImage, 1); + case "labstyle" + matched = labStyleMatch(inputImage, referenceImage, ... + toneStrength, colorStrength); + case "histogram" + matched = labHistogramMatch(inputImage, referenceImage, ... + toneStrength, colorStrength); + otherwise + balanced = whiteBalanceMatch(inputImage, referenceImage); + matched = labStyleMatch(balanced, referenceImage, ... + toneStrength, colorStrength); + end + + outputImage = min(max((1 - strength) .* inputImage + strength .* matched, 0), 1); +end + +function outputImage = whiteBalanceMatch(inputImage, referenceImage) + sourceWhite = robustWhitePoint(inputImage); + referenceWhite = robustWhitePoint(referenceImage); + gains = reshape(referenceWhite ./ max(sourceWhite, eps), 1, 1, 3); + gains = gains ./ mean(gains(:)); + outputImage = min(max(inputImage .* gains, 0), 1); +end + +function whitePoint = robustWhitePoint(imageData) + luminance = mean(imageData, 3); + chromaSpread = max(imageData, [], 3) - min(imageData, [], 3); + brightMask = luminance >= percentileValue(luminance(:), 70); + if any(brightMask(:)) + neutralLimit = percentileValue(chromaSpread(brightMask), 60); + mask = brightMask & chromaSpread <= neutralLimit; + else + mask = true(size(luminance)); + end + if nnz(mask) < 16 + mask = brightMask; + end + if nnz(mask) < 16 + mask = true(size(luminance)); + end + + pixels = reshape(imageData, [], 3); + whitePoint = median(pixels(mask(:), :), 1); + if any(~isfinite(whitePoint)) || any(whitePoint <= eps) + whitePoint = squeeze(mean(imageData, [1 2])).'; + end +end + +function outputImage = labToneMatch(inputImage, referenceImage, toneStrength) + labImage = rgb2lab(inputImage); + referenceLab = rgb2lab(referenceImage); + matchedL = quantileMatch(labImage(:, :, 1), referenceLab(:, :, 1)); + labImage(:, :, 1) = (1 - toneStrength) .* labImage(:, :, 1) + ... + toneStrength .* matchedL; + outputImage = labToRgb(labImage); +end + +function outputImage = labStyleMatch(inputImage, referenceImage, toneStrength, colorStrength) + labImage = rgb2lab(inputImage); + referenceLab = rgb2lab(referenceImage); + matchedL = quantileMatch(labImage(:, :, 1), referenceLab(:, :, 1)); + matchedAb = covarianceMatch(labImage(:, :, 2:3), referenceLab(:, :, 2:3)); + labImage(:, :, 1) = (1 - toneStrength) .* labImage(:, :, 1) + ... + toneStrength .* matchedL; + labImage(:, :, 2:3) = (1 - colorStrength) .* labImage(:, :, 2:3) + ... + colorStrength .* matchedAb; + outputImage = labToRgb(labImage); +end + +function outputImage = labHistogramMatch(inputImage, referenceImage, toneStrength, colorStrength) + labImage = rgb2lab(inputImage); + referenceLab = rgb2lab(referenceImage); + matchedLab = labImage; + matchedLab(:, :, 1) = quantileMatch(labImage(:, :, 1), referenceLab(:, :, 1)); + matchedLab(:, :, 2) = quantileMatch(labImage(:, :, 2), referenceLab(:, :, 2)); + matchedLab(:, :, 3) = quantileMatch(labImage(:, :, 3), referenceLab(:, :, 3)); + labImage(:, :, 1) = (1 - toneStrength) .* labImage(:, :, 1) + ... + toneStrength .* matchedLab(:, :, 1); + labImage(:, :, 2:3) = (1 - colorStrength) .* labImage(:, :, 2:3) + ... + colorStrength .* matchedLab(:, :, 2:3); + outputImage = labToRgb(labImage); +end + +function matched = covarianceMatch(sourceLab, referenceLab) + sourceSize = size(sourceLab); + sourcePixels = reshape(sourceLab, [], sourceSize(3)); + referencePixels = reshape(referenceLab, [], sourceSize(3)); + sourceMean = mean(sourcePixels, 1); + referenceMean = mean(referencePixels, 1); + sourceCov = cov(sourcePixels) + 1e-6 .* eye(sourceSize(3)); + referenceCov = cov(referencePixels) + 1e-6 .* eye(sourceSize(3)); + transform = real(sqrtm(referenceCov)) / real(sqrtm(sourceCov)); + matchedPixels = (sourcePixels - sourceMean) * transform.' + referenceMean; + matched = reshape(matchedPixels, sourceSize); +end + +function matched = quantileMatch(sourceChannel, referenceChannel) + pct = [0 1 5 10 25 50 75 90 95 99 100]; + sourceQ = percentileValues(sourceChannel(:), pct); + referenceQ = percentileValues(referenceChannel(:), pct); + [sourceQ, uniqueIdx] = unique(sourceQ, 'stable'); + referenceQ = referenceQ(uniqueIdx); + if numel(sourceQ) < 2 + matched = sourceChannel; + return; + end + matched = interp1(sourceQ, referenceQ, sourceChannel, 'linear', 'extrap'); +end + +function values = percentileValues(data, pct) + data = sort(double(data(:))); + if isempty(data) + values = zeros(size(pct)); + return; + end + positions = 1 + (numel(data) - 1) .* double(pct) ./ 100; + values = interp1(1:numel(data), data, positions, 'linear'); +end + +function value = percentileValue(data, pct) + value = percentileValues(data, pct); +end + +function imageData = normalizeImage(imageData) + imageData = im2double(imageData); + if ndims(imageData) == 2 + imageData = repmat(imageData, 1, 1, 3); + elseif size(imageData, 3) > 3 + imageData = imageData(:, :, 1:3); + end + imageData = min(max(imageData, 0), 1); +end + +function outputImage = labToRgb(labImage) + outputImage = min(max(lab2rgb(labImage), 0), 1); +end + +function value = clamp01(value) + value = min(max(value, 0), 1); +end + +function key = normalizeKind(kind) + key = string(lower(regexprep(char(string(kind)), '[^a-zA-Z0-9]', ''))); +end diff --git a/apps/image_measurement/image_match/+image_match/+ops/applyPipeline.m b/apps/image_measurement/image_match/+image_match/+ops/applyPipeline.m new file mode 100644 index 0000000..ade5196 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+ops/applyPipeline.m @@ -0,0 +1,33 @@ +% Expected caller: labkit_ImageMatch_app, batch export, and tests. Inputs are +% RGB images in a cell array and an ordered reference-match step array. Output +% is a cell array after applying each match step; references are processed up to +% the previous step for deterministic batch matching. +function processed = applyPipeline(images, steps) + + images = normalizeImages(images); + steps = steps(:); + processed = images; + + for iStep = 1:numel(steps) + step = steps(iStep); + referenceIndex = min(max(1, round(step.referenceIndex)), numel(processed)); + referenceImage = processed{referenceIndex}; + for iImage = 1:numel(processed) + processed{iImage} = image_match.ops.applyStep( ... + processed{iImage}, step, referenceImage); + end + end +end + +function images = normalizeImages(images) + if isnumeric(images) + images = {images}; + end + images = images(:); + for k = 1:numel(images) + images{k} = min(max(im2double(images{k}), 0), 1); + if ndims(images{k}) == 2 + images{k} = repmat(images{k}, 1, 1, 3); + end + end +end diff --git a/apps/image_measurement/image_match/+image_match/+ops/applyStep.m b/apps/image_measurement/image_match/+image_match/+ops/applyStep.m new file mode 100644 index 0000000..3bb6ca2 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+ops/applyStep.m @@ -0,0 +1,7 @@ +% Expected caller: image_match.ops.applyPipeline and focused tests. Inputs are +% one RGB image, one reference-match step, and an optional reference image. +% Output is RGB double image data in [0, 1]. +function outputImage = applyStep(inputImage, step, referenceImage) + + outputImage = image_match.ops.applyMatch(inputImage, referenceImage, step); +end diff --git a/apps/image_measurement/image_match/+image_match/+ops/describeStep.m b/apps/image_measurement/image_match/+image_match/+ops/describeStep.m new file mode 100644 index 0000000..1992bfb --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+ops/describeStep.m @@ -0,0 +1,13 @@ +% Expected caller: labkit_ImageMatch_app, view helpers, and tests. Input is a +% reference-match step. Output is a concise reproducible history label. +function label = describeStep(step) + + method = string(step.matchMethod); + if strlength(method) == 0 + method = "Balanced"; + end + label = sprintf('%s reference #%d, strength %g%%, tone %g%%, color %g%%', ... + char(method), max(1, round(step.referenceIndex)), ... + step.amount, step.secondary, step.colorStrength); + label = string(label); +end diff --git a/apps/image_measurement/image_match/+image_match/+ops/makeStep.m b/apps/image_measurement/image_match/+image_match/+ops/makeStep.m new file mode 100644 index 0000000..3e8ca3b --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+ops/makeStep.m @@ -0,0 +1,27 @@ +% Expected caller: labkit_ImageMatch_app and image_match tests. Inputs are the +% reference index, method, and strength controls. Output is a normalized match +% history step with a stable display label. +function step = makeStep(referenceIndex, method, strength, toneStrength, colorStrength) + + if nargin < 5 + colorStrength = 100; + end + if nargin < 4 + toneStrength = 100; + end + if nargin < 3 + strength = 100; + end + if nargin < 2 + method = "Balanced"; + end + + step = image_match.state.emptyStep(); + step.kind = "Reference match"; + step.amount = double(strength); + step.secondary = double(toneStrength); + step.colorStrength = double(colorStrength); + step.matchMethod = string(method); + step.referenceIndex = double(referenceIndex); + step.label = image_match.ops.describeStep(step); +end diff --git a/apps/image_measurement/image_match/+image_match/+state/emptyItem.m b/apps/image_measurement/image_match/+image_match/+state/emptyItem.m new file mode 100644 index 0000000..f94d8de --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+state/emptyItem.m @@ -0,0 +1,10 @@ +% Expected caller: labkit_ImageMatch_app and image_match package tests. +% Output is one loaded-image record with source path, display name, and RGB +% double image payload ready for deterministic reference-match operations. +function item = emptyItem() + + item = struct( ... + 'path', "", ... + 'name', "", ... + 'image', []); +end diff --git a/apps/image_measurement/image_match/+image_match/+state/emptyStep.m b/apps/image_measurement/image_match/+image_match/+state/emptyStep.m new file mode 100644 index 0000000..6977d28 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+state/emptyStep.m @@ -0,0 +1,14 @@ +% Expected caller: labkit_ImageMatch_app and image_match package tests. +% Output is one reference-match history step. Numeric controls and match +% options describe the selected matching algorithm. +function step = emptyStep() + + step = struct( ... + 'kind', "", ... + 'amount', 0, ... + 'secondary', 0, ... + 'colorStrength', 0, ... + 'matchMethod', "", ... + 'referenceIndex', 0, ... + 'label', ""); +end diff --git a/apps/image_measurement/image_match/+image_match/+ui/createEditorUi.m b/apps/image_measurement/image_match/+image_match/+ui/createEditorUi.m new file mode 100644 index 0000000..cc0407f --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+ui/createEditorUi.m @@ -0,0 +1,216 @@ +% Expected caller: labkit_ImageMatch_app. Inputs are match method labels, +% initial export folder, and app callback handles. Output is a struct of UI +% component handles for the reference-matching editor shell. +function uih = createEditorUi(methods, outputFolder, callbacks) + + workbenchOpts = struct( ... + 'rightTitle', 'Preview', ... + 'rightGridSize', [1 1], ... + 'rightRowHeight', {{'1x'}}); + workbenchOpts.tabs = [ ... + labkit.ui.app.tab('libraryExport', 'Library + Export', [3 1], ... + {250, 185, 150}), ... + labkit.ui.app.tab('matchHistory', 'Match + History', [4 1], ... + {265, 160, 245, 125}), ... + labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; + + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Paper Image Match', ... + 'position', [80 60 1460 860], ... + 'leftWidth', 500, ... + 'options', workbenchOpts)); + + uih = struct(); + uih.fig = ui.fig; + uih.previewAxes = uiaxes(ui.rightGrid); + title(uih.previewAxes, 'Matched Preview'); + labkit.ui.view.draw(uih.previewAxes, 'popout'); + + uih = buildLibrarySection(uih, ui.libraryExportGrid, callbacks, 1); + uih = buildExportSection(uih, ui.libraryExportGrid, outputFolder, callbacks, 2); + uih = buildExportDetailsSection(uih, ui.libraryExportGrid, 3); + uih = buildMatchSection(uih, ui.matchHistoryGrid, methods, callbacks, 1); + uih = buildMatchFlowSection(uih, ui.matchHistoryGrid, methods, 2); + uih = buildHistorySection(uih, ui.matchHistoryGrid, callbacks, 3); + uih = buildMetricsSection(uih, ui.matchHistoryGrid, 4); + + logUi = labkit.ui.view.panel(ui.logGrid, 'log', 1, {'Ready.'}); + uih.txtLog = logUi.textArea; +end + +function uih = buildLibrarySection(uih, parentGrid, callbacks, row) + panel = labkit.ui.view.section(parentGrid, 'Library', row, [4 2], ... + struct('rowHeight', {{'fit', 'fit', 125, 'fit'}}, ... + 'columnWidth', {{'1x', '1x'}})); + grid = panel.grid; + uih.btnOpenFiles = uibutton(grid, 'Text', 'Open image files', ... + 'ButtonPushedFcn', callbacks.openFiles); + place(uih.btnOpenFiles, 1, 1); + uih.btnClearImages = uibutton(grid, 'Text', 'Clear images', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.clearImages); + place(uih.btnClearImages, 1, 2); + uih.txtImageSource = labkit.ui.view.form(grid, struct( ... + 'kind', 'readonly', ... + 'value', 'No images loaded')); + place(uih.txtImageSource, 2, [1 2]); + uih.lbImages = uilistbox(grid, ... + 'Items', {'No images loaded'}, ... + 'ValueChangedFcn', callbacks.imageSelectionChanged); + place(uih.lbImages, 3, [1 2]); + uih.txtImageStatus = labkit.ui.view.form(grid, struct( ... + 'kind', 'readonly', ... + 'value', 'Images: 0')); + place(uih.txtImageStatus, 4, [1 2]); +end + +function uih = buildMatchSection(uih, parentGrid, methods, callbacks, row) + controlsPanel = labkit.ui.view.section(parentGrid, 'Reference Match', row, [7 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{145, '1x'}})); + grid = controlsPanel.grid; + + [lblPreviewMode, uih.ddPreviewMode] = labkit.ui.view.form(grid, struct( ... + 'kind', 'dropdown', ... + 'label', 'Preview:', ... + 'items', {{'Matched', 'Original', 'Before | After'}}, ... + 'value', 'Matched', ... + 'callback', callbacks.previewModeChanged)); + place(lblPreviewMode, 1, 1); + place(uih.ddPreviewMode, 1, 2); + + lblReference = uilabel(grid, 'Text', 'Reference:'); + place(lblReference, 2, 1); + uih.ddReference = uidropdown(grid, ... + 'Items', {'No reference'}, ... + 'Enable', 'off', ... + 'ValueChangedFcn', callbacks.matchSettingChanged); + place(uih.ddReference, 2, 2); + + [lblMethod, uih.ddMethod] = labkit.ui.view.form(grid, struct( ... + 'kind', 'dropdown', ... + 'label', 'Method:', ... + 'items', {methods}, ... + 'value', methods{1}, ... + 'callback', callbacks.matchSettingChanged)); + place(lblMethod, 3, 1); + place(uih.ddMethod, 3, 2); + + [uih.lblStrength, uih.edtStrength] = labkit.ui.view.form(grid, struct( ... + 'kind', 'spinner', ... + 'label', 'Strength (%):', ... + 'value', 100, ... + 'limits', [0 100], ... + 'step', 1)); + place(uih.lblStrength, 4, 1); + place(uih.edtStrength, 4, 2); + uih.edtStrength.ValueChangedFcn = callbacks.matchSettingChanged; + + [uih.lblTone, uih.edtTone] = labkit.ui.view.form(grid, struct( ... + 'kind', 'spinner', ... + 'label', 'Tone (%):', ... + 'value', 100, ... + 'limits', [0 100], ... + 'step', 1)); + place(uih.lblTone, 5, 1); + place(uih.edtTone, 5, 2); + uih.edtTone.ValueChangedFcn = callbacks.matchSettingChanged; + + [uih.lblColor, uih.edtColor] = labkit.ui.view.form(grid, struct( ... + 'kind', 'spinner', ... + 'label', 'Color (%):', ... + 'value', 100, ... + 'limits', [0 100], ... + 'step', 1)); + place(uih.lblColor, 6, 1); + place(uih.edtColor, 6, 2); + uih.edtColor.ValueChangedFcn = callbacks.matchSettingChanged; + + uih.btnApplyMatch = uibutton(grid, 'Text', 'Apply match', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.applyMatch); + place(uih.btnApplyMatch, 7, [1 2]); + +end + +function uih = buildMatchFlowSection(uih, parentGrid, methods, row) + panel = labkit.ui.view.section(parentGrid, 'Match Flow', row, [1 1], ... + struct('rowHeight', {{120}})); + uih.txtMatchFlow = uitextarea(panel.grid, 'Editable', 'off'); + place(uih.txtMatchFlow, 1, 1); + uih.txtMatchFlow.Value = image_match.view.matchFlowLines(methods{1}); +end + +function uih = buildHistorySection(uih, parentGrid, callbacks, row) + panel = labkit.ui.view.section(parentGrid, 'Match History', row, [3 2], ... + struct('rowHeight', {{'fit', 180, 'fit'}}, ... + 'columnWidth', {{'1x', '1x'}})); + grid = panel.grid; + uih.btnUndoHistory = uibutton(grid, 'Text', 'Undo history', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.undoHistory); + uih.btnResetHistory = uibutton(grid, 'Text', 'Reset history', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.resetHistory); + place(uih.btnUndoHistory, 1, 1); + place(uih.btnResetHistory, 1, 2); + uih.historyTable = uitable(grid, ... + 'ColumnName', {'#', 'Step', 'Settings', 'Ref'}, ... + 'Data', image_match.view.historyTableData(repmat(image_match.state.emptyStep(), 0, 1))); + place(uih.historyTable, 2, [1 2]); + uih.txtHistoryStatus = labkit.ui.view.form(grid, struct( ... + 'kind', 'readonly', ... + 'value', 'History steps: 0')); + place(uih.txtHistoryStatus, 3, [1 2]); + +end + +function uih = buildMetricsSection(uih, parentGrid, row) + panel = labkit.ui.view.section(parentGrid, 'Current Image', row, [1 1], ... + struct('rowHeight', {{95}})); + uih.resultTable = uitable(panel.grid, ... + 'ColumnName', {'Metric', 'Value'}, ... + 'Data', image_match.view.resultTableData([], [], 0)); + place(uih.resultTable, 1, 1); +end + +function uih = buildExportSection(uih, parentGrid, outputFolder, callbacks, row) + panel = labkit.ui.view.section(parentGrid, 'Batch Export', row, [3 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{145, '1x'}})); + grid = panel.grid; + uih.btnChooseOutput = uibutton(grid, 'Text', 'Choose folder', ... + 'ButtonPushedFcn', callbacks.chooseOutputFolder); + place(uih.btnChooseOutput, 1, 1); + uih.txtOutputFolder = labkit.ui.view.form(grid, struct( ... + 'kind', 'readonly', ... + 'value', outputFolder)); + place(uih.txtOutputFolder, 1, 2); + [lblFormat, uih.ddFormat] = labkit.ui.view.form(grid, struct( ... + 'kind', 'dropdown', ... + 'label', 'Format:', ... + 'items', {{'PNG', 'TIFF', 'JPEG'}}, ... + 'value', 'PNG')); + place(lblFormat, 2, 1); + place(uih.ddFormat, 2, 2); + uih.btnExport = uibutton(grid, 'Text', 'Export matched images', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.exportImages); + place(uih.btnExport, 3, [1 2]); + +end + +function uih = buildExportDetailsSection(uih, parentGrid, row) + panel = labkit.ui.view.section(parentGrid, 'Export Details', row, [1 1], ... + struct('rowHeight', {{105}})); + uih.txtDetails = uitextarea(panel.grid, 'Editable', 'off'); + place(uih.txtDetails, 1, 1); + uih.txtDetails.Value = image_match.view.detailLines( ... + repmat(image_match.state.emptyItem(), 0, 1), 1, ... + repmat(image_match.state.emptyStep(), 0, 1), []); +end + +function place(component, row, column) + component.Layout.Row = row; + component.Layout.Column = column; +end diff --git a/apps/image_measurement/image_match/+image_match/+view/beforeAfterImage.m b/apps/image_measurement/image_match/+image_match/+view/beforeAfterImage.m new file mode 100644 index 0000000..78a720b --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+view/beforeAfterImage.m @@ -0,0 +1,21 @@ +% Expected caller: labkit_ImageMatch_app preview rendering. Inputs are original +% and matched images. Output is a display-only side-by-side RGB preview image +% with a narrow divider; source images are not modified. +function imageOut = beforeAfterImage(original, matchedImage) + + original = normalizePreviewImage(original); + matchedImage = normalizePreviewImage(matchedImage); + if size(original, 1) ~= size(matchedImage, 1) || size(original, 2) ~= size(matchedImage, 2) + matchedImage = imresize(matchedImage, [size(original, 1), size(original, 2)]); + end + + divider = ones(size(original, 1), 6, 3); + imageOut = cat(2, original, divider, matchedImage); +end + +function imageOut = normalizePreviewImage(imageIn) + imageOut = min(max(im2double(imageIn), 0), 1); + if ndims(imageOut) == 2 + imageOut = repmat(imageOut, 1, 1, 3); + end +end diff --git a/apps/image_measurement/image_match/+image_match/+view/detailLines.m b/apps/image_measurement/image_match/+image_match/+view/detailLines.m new file mode 100644 index 0000000..6adc36a --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+view/detailLines.m @@ -0,0 +1,22 @@ +% Expected caller: labkit_ImageMatch_app summary pane. Inputs are loaded +% items, selection index, history steps, and last batch export payload. +function lines = detailLines(items, currentIndex, steps, lastExport) + + if isempty(items) + lines = {'Load one or more images to begin reference matching.'}; + return; + end + + item = items(currentIndex); + lines = { ... + sprintf('Selected: %s', char(item.name)), ... + sprintf('Images loaded: %d', numel(items)), ... + sprintf('History steps: %d', numel(steps))}; + if ~isempty(steps) + lines{end + 1} = sprintf('Last step: %s', ... + char(image_match.ops.describeStep(steps(end)))); + end + if ~isempty(lastExport) && isfield(lastExport, 'manifestPath') + lines{end + 1} = sprintf('Last manifest: %s', char(lastExport.manifestPath)); + end +end diff --git a/apps/image_measurement/image_match/+image_match/+view/displayImageNames.m b/apps/image_measurement/image_match/+image_match/+view/displayImageNames.m new file mode 100644 index 0000000..d3086eb --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+view/displayImageNames.m @@ -0,0 +1,14 @@ +% Expected caller: labkit_ImageMatch_app listbox/reference controls. Input is +% a loaded item array. Output is a cell array of stable display labels. +function names = displayImageNames(items) + + if isempty(items) + names = {'No images loaded'}; + return; + end + + names = cell(numel(items), 1); + for k = 1:numel(items) + names{k} = sprintf('%d. %s', k, char(items(k).name)); + end +end diff --git a/apps/image_measurement/image_match/+image_match/+view/historyTableData.m b/apps/image_measurement/image_match/+image_match/+view/historyTableData.m new file mode 100644 index 0000000..9189e3f --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+view/historyTableData.m @@ -0,0 +1,22 @@ +% Expected caller: labkit_ImageMatch_app and tests. Input is the reference-match +% history. Output is cell data for the visible history table. +function data = historyTableData(steps) + + if isempty(steps) + data = cell(0, 4); + return; + end + + steps = steps(:); + data = cell(numel(steps), 4); + for k = 1:numel(steps) + data{k, 1} = k; + data{k, 2} = char(steps(k).kind); + data{k, 3} = char(image_match.ops.describeStep(steps(k))); + if steps(k).referenceIndex > 0 + data{k, 4} = sprintf('%d', round(steps(k).referenceIndex)); + else + data{k, 4} = ''; + end + end +end diff --git a/apps/image_measurement/image_match/+image_match/+view/matchFlowLines.m b/apps/image_measurement/image_match/+image_match/+view/matchFlowLines.m new file mode 100644 index 0000000..d2d67ad --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+view/matchFlowLines.m @@ -0,0 +1,43 @@ +% Expected caller: labkit_ImageMatch_app UI refresh. Input is a method label. +% Output is display text describing the selected reference-match pipeline. +function lines = matchFlowLines(method) + + key = lower(regexprep(char(string(method)), '[^a-zA-Z0-9]', '')); + switch key + case 'whitebalance' + lines = { + 'White balance match' + '1. Estimate bright low-chroma points in source and reference.' + '2. Apply RGB diagonal gains to align source white point to reference.' + '3. Blend result by Strength.' + 'Best for microscope background or illumination color shifts.'}; + case 'toneonly' + lines = { + 'Tone only match' + '1. Convert source and reference to Lab.' + '2. Match L* percentiles for brightness and contrast.' + '3. Keep a*/b* color channels unchanged.' + 'Best for exposure and contrast drift without changing sample color.'}; + case 'labstyle' + lines = { + 'Lab style match' + '1. Convert source and reference to Lab.' + '2. Match L* percentiles using Tone strength.' + '3. Match a*/b* mean and covariance using Color strength.' + 'Best for global color style and sample/skin color harmonization.'}; + case 'histogram' + lines = { + 'Histogram match' + '1. Convert source and reference to Lab.' + '2. Match L*, a*, and b* quantiles channel-by-channel.' + '3. Blend tone and color channels separately.' + 'Strongest match; may overfit if images contain different content.'}; + otherwise + lines = { + 'Balanced match' + '1. Align white point to the reference image.' + '2. Match Lab L* brightness and contrast percentiles.' + '3. Match Lab a*/b* color covariance.' + 'Default for figure images with illumination and color drift.'}; + end +end diff --git a/apps/image_measurement/image_match/+image_match/+view/resultTableData.m b/apps/image_measurement/image_match/+image_match/+view/resultTableData.m new file mode 100644 index 0000000..db8bb8a --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+view/resultTableData.m @@ -0,0 +1,19 @@ +% Expected caller: labkit_ImageMatch_app and tests. Inputs are the current +% item, current processed image, and history length. Output is metric/value data. +function data = resultTableData(item, processedImage, stepCount) + + if isempty(item) || isempty(processedImage) + data = { ... + 'Images loaded', '0'; ... + 'Current image', '-'; ... + 'Output size', '-'; ... + 'History steps', '0'}; + return; + end + + data = { ... + 'Current image', char(item.name); ... + 'Output size', sprintf('%d x %d px', size(processedImage, 2), size(processedImage, 1)); ... + 'Mean intensity', sprintf('%.4g', mean(processedImage(:))); ... + 'History steps', sprintf('%d', stepCount)}; +end diff --git a/apps/image_measurement/image_match/+image_match/+view/ternary.m b/apps/image_measurement/image_match/+image_match/+view/ternary.m new file mode 100644 index 0000000..15420b3 --- /dev/null +++ b/apps/image_measurement/image_match/+image_match/+view/ternary.m @@ -0,0 +1,10 @@ +% Expected caller: labkit_ImageMatch_app UI state updates. Return trueValue +% when condition is true, otherwise falseValue. +function value = ternary(condition, trueValue, falseValue) + + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/apps/image_measurement/image_match/labkit_ImageMatch_app.m b/apps/image_measurement/image_match/labkit_ImageMatch_app.m new file mode 100644 index 0000000..b5de2f8 --- /dev/null +++ b/apps/image_measurement/image_match/labkit_ImageMatch_app.m @@ -0,0 +1,355 @@ +function varargout = labkit_ImageMatch_app(varargin) +%LABKIT_IMAGEMATCH_APP Reference image matching app for figure images. + + [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... + 'labkit_ImageMatch_app', varargin, nargout); + if requestHandled + varargout = requestOutputs; + return; + end + if debugLog.enabled + if nargout > 2 + error('labkit_ImageMatch_app:TooManyOutputs', ... + 'labkit_ImageMatch_app debug mode returns at most the app figure and debug log.'); + end + elseif nargout > 1 + error('labkit_ImageMatch_app:TooManyOutputs', ... + 'labkit_ImageMatch_app returns at most the app figure handle.'); + end + + S = struct(); + S.items = repmat(image_match.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.outputFolder = string(pwd); + S.lastExport = []; + S.pendingDirty = false; + + methods = {'Balanced', 'White balance', 'Tone only', 'Lab style', 'Histogram'}; + callbacks = struct( ... + 'openFiles', @onOpenFiles, ... + 'clearImages', @onClearImages, ... + 'imageSelectionChanged', @onImageSelectionChanged, ... + 'previewModeChanged', @onPreviewModeChanged, ... + 'matchSettingChanged', @onMatchSettingChanged, ... + 'applyMatch', @onApplyMatch, ... + 'undoHistory', @onUndoHistory, ... + 'resetHistory', @onResetHistory, ... + 'chooseOutputFolder', @onChooseOutputFolder, ... + 'exportImages', @onExportImages); + uih = image_match.ui.createEditorUi(methods, char(S.outputFolder), callbacks); + fig = uih.fig; previewAxes = uih.previewAxes; txtLog = uih.txtLog; + btnOpenFiles = uih.btnOpenFiles; btnClearImages = uih.btnClearImages; + lbImages = uih.lbImages; txtImageSource = uih.txtImageSource; + txtImageStatus = uih.txtImageStatus; ddPreviewMode = uih.ddPreviewMode; + ddReference = uih.ddReference; ddMethod = uih.ddMethod; + edtStrength = uih.edtStrength; edtTone = uih.edtTone; + edtColor = uih.edtColor; txtMatchFlow = uih.txtMatchFlow; + btnApplyMatch = uih.btnApplyMatch; btnUndoHistory = uih.btnUndoHistory; + btnResetHistory = uih.btnResetHistory; historyTable = uih.historyTable; + txtHistoryStatus = uih.txtHistoryStatus; resultTable = uih.resultTable; + btnChooseOutput = uih.btnChooseOutput; txtOutputFolder = uih.txtOutputFolder; + ddFormat = uih.ddFormat; btnExport = uih.btnExport; txtDetails = uih.txtDetails; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('Image match debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + resetPreviewAxes(); + refreshAll(); + + if nargout >= 1 + varargout{1} = fig; + end + if nargout >= 2 + varargout{2} = debugLog; + end + + function onOpenFiles(~, ~) + [files, folder] = uigetfile(image_match.io.imageDialogFilter(), ... + 'Select images to match', pwd, 'MultiSelect', 'on'); + if isequal(files, 0) + addLog('Image file selection cancelled.'); + return; + end + try + paths = image_match.io.selectedImagePaths(files, folder); + S.items = image_match.io.readImages(paths); + catch ME + showError('Could not load images', ME.message); + return; + end + + S.currentIndex = 1; + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.outputFolder = string(folder); + S.lastExport = []; + txtOutputFolder.Value = char(S.outputFolder); + addLog(sprintf('Loaded %d image(s).', numel(S.items))); + refreshAll(); + end + + function onClearImages(~, ~) + S.items = repmat(image_match.state.emptyItem(), 0, 1); + S.currentIndex = 0; + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Cleared loaded images and match history.'); + refreshAll(); + end + + function onImageSelectionChanged(~, ~) + if isempty(S.items) + return; + end + names = image_match.view.displayImageNames(S.items); + idx = find(strcmp(names, lbImages.Value), 1); + if isempty(idx) + return; + end + S.currentIndex = idx; + refreshSelection(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + end + + function onPreviewModeChanged(~, ~) + refreshPreview(); + end + + function onMatchSettingChanged(~, ~) + S.pendingDirty = true; + S.lastExport = []; + refreshPreview(); + refreshMatchStatus(); + end + + function onApplyMatch(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before applying reference matches.'); + return; + end + step = currentMatchStep(); + S.steps(end + 1, 1) = step; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Applied match: %s', char(step.label))); + refreshAll(); + end + + function onUndoHistory(~, ~) + if isempty(S.steps) + return; + end + removed = S.steps(end); + S.steps(end) = []; + S.pendingDirty = false; + S.lastExport = []; + addLog(sprintf('Undid match step: %s', char(removed.label))); + refreshAll(); + end + + function onResetHistory(~, ~) + if isempty(S.steps) + return; + end + S.steps = repmat(image_match.state.emptyStep(), 0, 1); + S.pendingDirty = false; + S.lastExport = []; + addLog('Reset match history.'); + refreshAll(); + end + + function onChooseOutputFolder(~, ~) + folder = uigetdir(char(S.outputFolder), 'Select image match export folder'); + if isequal(folder, 0) + addLog('Export folder selection cancelled.'); + return; + end + S.outputFolder = string(folder); + txtOutputFolder.Value = char(S.outputFolder); + refreshDetails(); + end + + function onExportImages(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before exporting matched outputs.'); + return; + end + opts = struct(); + opts.outputFolder = S.outputFolder; + opts.format = ddFormat.Value; + busyOpts = struct(); + busyOpts.title = 'Export matched images'; + busyOpts.message = 'Writing matched image outputs...'; + busyOpts.controls = exportBusyControls(); + try + S.lastExport = labkit.ui.app.runBusy(fig, ... + @() image_match.export.writeOutputs(S.items, S.steps, opts), busyOpts); + catch ME + showError('Export failed', ME.message); + return; + end + statuses = string({S.lastExport.results.status}); + addLog(sprintf('Exported %d image(s), %d failed. Manifest: %s', ... + sum(statuses == "saved"), sum(statuses == "failed"), ... + char(S.lastExport.manifestPath))); + refreshDetails(); + end + + function controls = exportBusyControls() + controls = {btnOpenFiles, btnClearImages, lbImages, ddPreviewMode, ... + ddReference, ddMethod, edtStrength, edtTone, edtColor, ... + btnApplyMatch, btnUndoHistory, btnResetHistory, btnChooseOutput, ... + ddFormat, btnExport}; + end + + function refreshAll() + refreshList(); + refreshControls(); + refreshSelection(); + refreshHistory(); + refreshPreview(); + refreshMetrics(); + refreshDetails(); + refreshMatchStatus(); + end + + function refreshList() + if isempty(S.items) + lbImages.Items = {'No images loaded'}; + lbImages.Value = 'No images loaded'; + txtImageSource.Value = 'No images loaded'; + txtImageStatus.Value = 'Images: 0'; + ddReference.Items = {'No reference'}; + ddReference.Value = 'No reference'; + return; + end + names = image_match.view.displayImageNames(S.items); + S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); + lbImages.Items = names; + lbImages.Value = names{S.currentIndex}; + txtImageStatus.Value = sprintf('Images: %d | match steps: %d', ... + numel(S.items), numel(S.steps)); + previousReference = ddReference.Value; + ddReference.Items = names; + if any(strcmp(names, previousReference)) + ddReference.Value = previousReference; + else + ddReference.Value = names{S.currentIndex}; + end + end + + function refreshSelection() + if isempty(S.items) + txtImageSource.Value = 'No images loaded'; + return; + end + txtImageSource.Value = char(S.items(S.currentIndex).path); + end + + function refreshControls() + hasImages = ~isempty(S.items); + hasSteps = ~isempty(S.steps); + btnClearImages.Enable = image_match.view.ternary(hasImages, 'on', 'off'); + ddReference.Enable = image_match.view.ternary(hasImages, 'on', 'off'); + btnApplyMatch.Enable = image_match.view.ternary(hasImages, 'on', 'off'); + btnUndoHistory.Enable = image_match.view.ternary(hasSteps, 'on', 'off'); + btnResetHistory.Enable = image_match.view.ternary(hasSteps, 'on', 'off'); + btnExport.Enable = image_match.view.ternary(hasImages, 'on', 'off'); + end + + function refreshPreview() + if isempty(S.items) + resetPreviewAxes(); + return; + end + original = S.items(S.currentIndex).image; + processed = currentProcessedImages(S.pendingDirty); + matched = processed{S.currentIndex}; + switch ddPreviewMode.Value + case 'Original' + labkit.ui.view.draw(previewAxes, 'image', original, 'Original Preview'); + case 'Before | After' + labkit.ui.view.draw(previewAxes, 'image', ... + image_match.view.beforeAfterImage(original, matched), 'Before | After'); + otherwise + labkit.ui.view.draw(previewAxes, 'image', matched, 'Matched Preview'); + end + end + + function refreshMetrics() + if isempty(S.items) + resultTable.Data = image_match.view.resultTableData([], [], 0); + return; + end + processed = currentProcessedImages(false); + resultTable.Data = image_match.view.resultTableData( ... + S.items(S.currentIndex), processed{S.currentIndex}, numel(S.steps)); + end + + function refreshHistory() + historyTable.Data = image_match.view.historyTableData(S.steps); + txtHistoryStatus.Value = sprintf('History steps: %d', numel(S.steps)); + end + + function refreshDetails() + txtDetails.Value = image_match.view.detailLines( ... + S.items, max(S.currentIndex, 1), S.steps, S.lastExport); + end + + function refreshMatchStatus() + txtMatchFlow.Value = image_match.view.matchFlowLines(ddMethod.Value); + end + + function processed = currentProcessedImages(includePending) + images = cell(numel(S.items), 1); + for k = 1:numel(S.items) + images{k} = S.items(k).image; + end + steps = S.steps; + if includePending + steps(end + 1, 1) = currentMatchStep(); + end + processed = image_match.ops.applyPipeline(images, steps); + end + + function step = currentMatchStep() + step = image_match.ops.makeStep(currentReferenceIndex(), ddMethod.Value, ... + edtStrength.Value, edtTone.Value, edtColor.Value); + end + + function index = currentReferenceIndex() + index = 0; + if isempty(S.items) + return; + end + names = image_match.view.displayImageNames(S.items); + idx = find(strcmp(names, ddReference.Value), 1); + if ~isempty(idx) + index = idx; + elseif S.currentIndex > 0 + index = S.currentIndex; + end + end + + function resetPreviewAxes() + labkit.ui.view.draw(previewAxes, 'reset', 'Matched Preview', true); + end + + function addLog(message) + labkit.ui.view.update(txtLog, 'appendLog', message); + if debugLog.enabled + debugLog.append(message); + end + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+ui/runApp.m b/apps/wearable/ecg_print/+ecg_print/+ui/runApp.m index af59461..4fbcb3e 100644 --- a/apps/wearable/ecg_print/+ecg_print/+ui/runApp.m +++ b/apps/wearable/ecg_print/+ecg_print/+ui/runApp.m @@ -23,11 +23,9 @@ 'rightRowSpacing', 8); opts.tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [6 1], ... - {140, 255, 120, 235, 100, 125}, ... - struct('resizeRows', [1 2 3 4 5])), ... + {140, 255, 120, 235, 100, 125}), ... labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {210, '1x'}, ... - struct('resizeRows', 1)), ... + {210, '1x'}), ... labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; ui = labkit.ui.app.createShell(struct( ... diff --git a/docs/apps.md b/docs/apps.md index 6c54828..a26af43 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -46,6 +46,8 @@ the repository contract. | `labkit_DICPostprocess_app` | active | Ncorr strain overlay, ROI summary, and colorbar export. | Ncorr MAT, reference image, mask | EXX/EYY overlays, summary CSV, colorbar/level table. | | `labkit_CurvatureMeasurement_app` | experimental | Editable image-curve circle fit, calibrated real-unit scale-bar placement, and curve length measurement. | Image | Overlay PNG and curvature/length CSV. | | `labkit_FocusStack_app` | experimental | Microscope focus-stack fusion into one all-in-focus image. | Focus image folder or selected image files | Fused PNG, focus map PNG, summary CSV. | +| `labkit_ImageEnhance_app` | experimental | Stepwise brightness, contrast, clarity, color, and white-balance processing for figure images. | Image files | Enhanced images and processing manifest CSV. | +| `labkit_ImageMatch_app` | experimental | Reference-based white-balance, tone, Lab color-style, and histogram matching for figure images. | Image files | Matched images and processing manifest CSV. | | `labkit_BatchImageCrop_app` | experimental | Batch fixed-size microscope image crops with per-image crop center and rotation. | Microscope image files | Cropped images and crop manifest CSV. | | `labkit_ECGPrint_app` | experimental | ECG waveform preview, ROI filtering, peak/segment SNR, and SNR-over-time display. | MAT timetable or CSV/TSV table | Segment SNR CSV and waveform PNG. | @@ -184,5 +186,7 @@ Interactive file selection, drawing, visual inspection, and full workflow feel a | `labkit_DICPostprocess_app` | Ncorr MAT extraction, EXX/EYY overlays, ROI summary, optical enhancement controls, and strain colorbar levels. | Exports overlays, summary CSV, and colorbar/level files. | | `labkit_CurvatureMeasurement_app` | Curve-point workflow, circle fitting, curvature conversion, curve length measurement, dense-point display, residual annotations, result summaries, and CSV/overlay export schemas. It consumes reusable UI tools for generic anchor editing and scale-bar mechanics. | Exports overlay PNG and curvature/length CSV. | | `labkit_FocusStack_app` | Folder or selected-file focus sequence loading, optional registration to the middle image, preset-guided Laplacian-pyramid focus fusion, user-facing detail/blend controls, and focus-depth preview. | Exports fused PNG, colorized focus map PNG, and per-source focus coverage CSV. | +| `labkit_ImageEnhance_app` | Multi-image figure enhancement, ordered non-destructive step history, undo/reset, brightness/contrast, local contrast, sharpening, hue/saturation, gray-world white balance, and batch export. | Exports enhanced image files plus a manifest CSV with source/output path, status, output size, and step count. | +| `labkit_ImageMatch_app` | Multi-image reference matching with ordered history, undo/reset, balanced matching, white-balance matching, tone-only matching, Lab style matching, histogram matching, and batch export. | Exports matched image files plus a manifest CSV with source/output path, status, output size, and step count. | | `labkit_BatchImageCrop_app` | Selected-file microscope image loading, fixed global crop width/height, per-image rotation, per-image crop-center confirmation on a rotated preview canvas, and exact-pixel crop generation without resizing. | Exports unique cropped image files and a crop manifest CSV with source/output paths, rotation, center, output size, canvas size, and status. | | `labkit_ECGPrint_app` | CSV/MAT import parsing, channel/ROI selection, padded filtering before ROI crop, ECG peak detection, segments, template, and SNR-over-time plots. | Exports per-segment SNR CSV and waveform PNG. Multi-file/class statistics belong in a separate wearable stats app. | diff --git a/docs/architecture.md b/docs/architecture.md index 0a27719..476bdad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,8 @@ labkit_DICPreprocess_app labkit_DICPostprocess_app labkit_CurvatureMeasurement_app labkit_FocusStack_app +labkit_ImageEnhance_app +labkit_ImageMatch_app labkit_BatchImageCrop_app labkit_ECGPrint_app ``` diff --git a/docs/ui.md b/docs/ui.md index 09be33f..c7cca76 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -40,11 +40,10 @@ Custom tabs use `labkit.ui.app.tab`: ```matlab opts.tabs = labkit.ui.app.tab( ... 'filesAnalysis', 'Files + Analysis', [4 1], ... - {180, 220, 260, 140}, ... - struct('resizeRows', [1 2 3])); + {180, 220, 260, 140}); ``` -The shell owns split panes, scrollable tab grids, row resize handles, and the right-side grid. Apps own the controls and axes placed inside returned grids. +The shell owns split panes, scrollable tab grids, row resize handles, and the right-side grid. Multi-row tabs get resize handles between adjacent logical rows by default. Use `struct('resize','none')` only for tabs whose rows should remain fixed. Apps own the controls and axes placed inside returned grids. ## Views And Forms diff --git a/labkit_launcher.m b/labkit_launcher.m index da7b023..2e0025c 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -76,8 +76,7 @@ function initializePath(root) workbenchOpts.tabs = [ ... labkit.ui.app.tab('filesAnalysis', 'Find App', [3 1], ... {125, 245, '1x'}, ... - struct('resizeRows', 2, ... - 'resizeOptions', struct('minTopHeight', 150, 'minBottomHeight', 100))), ... + struct('resizeOptions', struct('minTopHeight', 150, 'minBottomHeight', 100))), ... labkit.ui.app.tab('log', 'Status', [1 1], {'1x'})]; ui = labkit.ui.app.createShell(struct( ... diff --git a/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m b/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m index 8e7a8d3..f995faf 100644 --- a/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m +++ b/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m @@ -19,6 +19,8 @@ function verify_gui_layout_image_measurement() checkCurvatureMeasurementLayout(h); checkFocusStackLayout(h); checkBatchImageCropLayout(h); + checkImageEnhanceLayout(h); + checkImageMatchLayout(h); end function checkCurvatureMeasurementLayout(h) @@ -101,6 +103,58 @@ function checkBatchImageCropLayout(h) 'Batch crop debug launch should mirror trace lines into the visible Log tab.'); end +function checkImageEnhanceLayout(h) + fig = h.launchFigure('labkit_ImageEnhance_app', 'Paper Image Enhance'); + h.assertFigureMinimumSize(fig, 1460, 860); + h.assertComponentCounts(fig, struct('Button', 7, 'DropDown', 2, ... + 'Spinner', 2, 'ListBox', 2, 'Table', 2, 'TextArea', 2, 'Axes', 1)); + h.assertButtonContract(fig, {'Open image files', 'Clear images', ... + 'Apply tool', 'Undo history', 'Reset history', ... + 'Choose folder', 'Export enhanced images'}); + h.assertDropdownGroups(fig, [ ... + h.dropdownGroup({'Enhanced', 'Original', 'Before | After'}, 1), ... + h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1)]); + h.assertTabTitles(fig, {'Library + Export', 'Tools + History', 'Log'}); + h.assertAnyTableColumns(fig, {'Metric', 'Value'}); + h.assertAnyTableColumns(fig, {'#', 'Step', 'Settings'}); + h.assertAxesContract(fig, {h.axesSpec('Enhanced Preview', '', '')}); + + h.closeAllFigures(); + [fig, debug] = labkit_ImageEnhance_app("debug", struct()); + drawnow; + assert(debug.enabled && debug.traceEnabled, ... + 'Image enhance debug launch should return an enabled trace logger.'); + assertAnyTextAreaContains(h, fig, 'Image enhance debug trace enabled', ... + 'Image enhance debug launch should mirror trace lines into the visible Log tab.'); +end + +function checkImageMatchLayout(h) + fig = h.launchFigure('labkit_ImageMatch_app', 'Paper Image Match'); + h.assertFigureMinimumSize(fig, 1460, 860); + h.assertComponentCounts(fig, struct('Button', 7, 'DropDown', 4, ... + 'Spinner', 3, 'ListBox', 1, 'Table', 2, 'TextArea', 3, 'Axes', 1)); + h.assertButtonContract(fig, {'Open image files', 'Clear images', ... + 'Apply match', 'Undo history', 'Reset history', ... + 'Choose folder', 'Export matched images'}); + h.assertDropdownGroups(fig, [ ... + h.dropdownGroup({'Matched', 'Original', 'Before | After'}, 1), ... + h.dropdownGroup({'Balanced', 'White balance', 'Tone only', ... + 'Lab style', 'Histogram'}, 1), ... + h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1)]); + h.assertTabTitles(fig, {'Library + Export', 'Match + History', 'Log'}); + h.assertAnyTableColumns(fig, {'Metric', 'Value'}); + h.assertAnyTableColumns(fig, {'#', 'Step', 'Settings', 'Ref'}); + h.assertAxesContract(fig, {h.axesSpec('Matched Preview', '', '')}); + + h.closeAllFigures(); + [fig, debug] = labkit_ImageMatch_app("debug", struct()); + drawnow; + assert(debug.enabled && debug.traceEnabled, ... + 'Image match debug launch should return an enabled trace logger.'); + assertAnyTextAreaContains(h, fig, 'Image match debug trace enabled', ... + 'Image match debug launch should mirror trace lines into the visible Log tab.'); +end + function assertAnyTextAreaContains(h, fig, needle, message) textAreas = h.findControlsByClass(fig, 'TextArea'); for k = 1:numel(textAreas) diff --git a/tests/gui/structural/apps/smoke/GuiSmokeTest.m b/tests/gui/structural/apps/smoke/GuiSmokeTest.m index 74583ad..840b002 100644 --- a/tests/gui/structural/apps/smoke/GuiSmokeTest.m +++ b/tests/gui/structural/apps/smoke/GuiSmokeTest.m @@ -2,6 +2,11 @@ %GUISMOKETEST Verify LabKit behavior through official MATLAB tests. methods (Test, TestTags = {'GUI', 'Structural', 'Smoke'}) + function test_labkit_launcher_layout(testCase) + setupLabKitTestPath(); + verify_labkit_launcher_layout(); + end + function test_gui_smoke(testCase) setupLabKitTestPath(); verify_gui_smoke(); @@ -9,6 +14,31 @@ function test_gui_smoke(testCase) end end +function verify_labkit_launcher_layout() +%VERIFY_LABKIT_LAUNCHER_LAYOUT Verify root launcher layout and app discovery. + + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); + + apps = labkit_launcher("list"); + assert(istable(apps), 'labkit_launcher list mode should return a table.'); + assert(all(ismember({'Command', 'DisplayName', 'Family'}, apps.Properties.VariableNames)), ... + 'labkit_launcher list mode should expose Command, DisplayName, and Family columns.'); + assert(height(apps) > 0, 'labkit_launcher list mode should find app entry points.'); + + fig = labkit_launcher(); + drawnow; + assert(strcmp(fig.Name, 'LabKit App Launcher'), ... + 'labkit_launcher should return the launcher figure handle.'); + h.assertFigureMinimumSize(fig, 1320, 760); + h.assertTabTitles(fig, {'Find App', 'Status', 'Filter', 'Selected App', 'Actions'}); + h.assertButtonContract(fig, {'Open Selected App', 'Refresh App List'}); + h.assertDropdownGroups(fig, h.dropdownGroup(['All'; unique(apps.Family)], 1)); + h.assertAnyTableColumns(fig, {'Family', 'App', 'Command'}); + h.invokeButton(fig, 'Refresh App List'); +end + function verify_gui_smoke() %TEST_GUI_SMOKE Verify GUI entry points can launch. diff --git a/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m b/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m index 37f2ba2..1285e67 100644 --- a/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m +++ b/tests/gui/structural/labkit/ui/GuiLayoutUiAxesWorkbenchTest.m @@ -111,9 +111,25 @@ function checkCreateAppShellHelper(h) h.assertTabTitles(custom.fig, {'Probe Controls'}); h.assertScrollablePanel(custom.probeScrollPanel, 'Probe Controls tab'); h.assertScrollableGrid(custom.probeGrid, 'Probe Controls grid'); - assert(h.sameStringCell(custom.probeGrid.RowHeight, {'fit', '1x'}), ... + assert(isequal(custom.probeGrid.RowHeight, {'fit', 6, '1x'}), ... 'App shell helper should preserve custom tab specs.'); - assert(isempty(custom.probeResizeHandles), ... - 'Custom tabs without resizeRows should not create resize handles.'); + assert(numel(custom.probeResizeHandles) == 1, ... + 'Custom multi-row tabs should create default row-resize handles.'); + + fixedOpts = struct(); + fixedOpts.rightTitle = 'Fixed'; + fixedOpts.tabs = labkit.ui.app.tab( ... + 'fixed', 'Fixed Controls', [2 1], {'fit', '1x'}, ... + struct('resize', 'none')); + fixed = labkit.ui.app.createShell(struct( ... + 'title', 'labkit_fixed_tab_app_shell_probe', ... + 'position', [40 30 1200 760], ... + 'leftWidth', 330, ... + 'options', fixedOpts)); + cleaner4 = onCleanup(@() delete(fixed.fig)); + assert(h.sameStringCell(fixed.fixedGrid.RowHeight, {'fit', '1x'}), ... + 'Explicit fixed custom tabs should preserve row specs.'); + assert(isempty(fixed.fixedResizeHandles), ... + 'Custom tabs with resize none should not create resize handles.'); end diff --git a/tests/helpers/appEntryManifest.m b/tests/helpers/appEntryManifest.m index e4587be..eff851b 100644 --- a/tests/helpers/appEntryManifest.m +++ b/tests/helpers/appEntryManifest.m @@ -11,6 +11,8 @@ 'labkit_DICPostprocess_app', 'DIC Strain Postprocess'; ... 'labkit_CurvatureMeasurement_app', 'Image Curvature Measurement'; ... 'labkit_FocusStack_app', 'Microscope Focus Stack Fusion'; ... + 'labkit_ImageEnhance_app', 'Paper Image Enhance'; ... + 'labkit_ImageMatch_app', 'Paper Image Match'; ... 'labkit_BatchImageCrop_app', 'Microscope Batch Image Crop'; ... 'labkit_ECGPrint_app', 'ECG Signal Print + SNR Explorer'}; end diff --git a/tests/helpers/architectureTestHelpers.m b/tests/helpers/architectureTestHelpers.m index 03516b1..7909dd0 100644 --- a/tests/helpers/architectureTestHelpers.m +++ b/tests/helpers/architectureTestHelpers.m @@ -167,7 +167,8 @@ function assertImageMeasurementAppBoundary(source, appName) packageName = imageMeasurementPackageForApp(appName); assert(contains(source, [packageName '.']), ... [appName ' should use its app-owned package namespace.']); - allPackageNames = {'batch_crop', 'curvature', 'focus_stack'}; + allPackageNames = {'batch_crop', 'curvature', 'focus_stack', ... + 'image_enhance', 'image_match'}; otherPackageNames = setdiff(allPackageNames, {packageName}); for iPackage = 1:numel(otherPackageNames) assert(~contains(source, [otherPackageNames{iPackage} '.']), ... @@ -189,6 +190,10 @@ function assertImageMeasurementAppBoundary(source, appName) packageName = 'curvature'; case 'labkit_FocusStack_app' packageName = 'focus_stack'; + case 'labkit_ImageEnhance_app' + packageName = 'image_enhance'; + case 'labkit_ImageMatch_app' + packageName = 'image_match'; otherwise error('Unknown image-measurement app entrypoint: %s', appName); end @@ -265,7 +270,9 @@ function assertAppUsesManagedImageInteractions(source, appName) 'labkit_EIS_app', 'labkit_ChronoOverlay_app', ... 'labkit_DICPreprocess_app', 'labkit_DICPostprocess_app', ... 'labkit_CurvatureMeasurement_app', 'labkit_FocusStack_app', ... - 'labkit_BatchImageCrop_app', 'labkit_ECGPrint_app'}; + 'labkit_ImageEnhance_app', 'labkit_ImageMatch_app', ... + 'labkit_BatchImageCrop_app', ... + 'labkit_ECGPrint_app'}; end function words = experimentWorkflowWords() diff --git a/tests/integration/project/AppEntrypointBoundariesTest.m b/tests/integration/project/AppEntrypointBoundariesTest.m index bdc65f5..c650f6a 100644 --- a/tests/integration/project/AppEntrypointBoundariesTest.m +++ b/tests/integration/project/AppEntrypointBoundariesTest.m @@ -80,6 +80,18 @@ function verify_app_entrypoint_boundaries() 'focus_stack_gui('); h.assertImageMeasurementAppBoundary(focusStackSource, 'labkit_FocusStack_app'); + imageEnhanceSource = h.assertAppEntrypoint(root, ... + 'labkit_ImageEnhance_app', ... + 'launchImageEnhanceApp', ... + 'image_enhance_gui('); + h.assertImageMeasurementAppBoundary(imageEnhanceSource, 'labkit_ImageEnhance_app'); + + imageMatchSource = h.assertAppEntrypoint(root, ... + 'labkit_ImageMatch_app', ... + 'launchImageMatchApp', ... + 'image_match_gui('); + h.assertImageMeasurementAppBoundary(imageMatchSource, 'labkit_ImageMatch_app'); + batchCropSource = h.assertAppEntrypoint(root, ... 'labkit_BatchImageCrop_app', ... 'launchBatchImageCropApp', ... diff --git a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m index f4a803a..852a612 100644 --- a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m +++ b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m @@ -138,12 +138,22 @@ function verify_app_owned_workflow_boundaries() 'labkit_FocusStack_app', ... 'launchFocusStackApp', ... 'focus_stack_gui('); + imageEnhanceSource = h.assertAppEntrypoint(root, ... + 'labkit_ImageEnhance_app', ... + 'launchImageEnhanceApp', ... + 'image_enhance_gui('); + imageMatchSource = h.assertAppEntrypoint(root, ... + 'labkit_ImageMatch_app', ... + 'launchImageMatchApp', ... + 'image_match_gui('); batchCropSource = h.assertAppEntrypoint(root, ... 'labkit_BatchImageCrop_app', ... 'launchBatchImageCropApp', ... 'batch_crop_gui('); h.assertImageMeasurementAppBoundary(curvatureSource, 'labkit_CurvatureMeasurement_app'); h.assertImageMeasurementAppBoundary(focusStackSource, 'labkit_FocusStack_app'); + h.assertImageMeasurementAppBoundary(imageEnhanceSource, 'labkit_ImageEnhance_app'); + h.assertImageMeasurementAppBoundary(imageMatchSource, 'labkit_ImageMatch_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/ProjectStructureGuardrailTest.m b/tests/integration/project/ProjectStructureGuardrailTest.m index b14c932..79047ee 100644 --- a/tests/integration/project/ProjectStructureGuardrailTest.m +++ b/tests/integration/project/ProjectStructureGuardrailTest.m @@ -141,6 +141,10 @@ function imageMeasurementAppsUseOwnedPackageNamespaces(testCase) 'curvature', 'curvature', 'labkit_CurvatureMeasurement_app.m'); assertImageMeasurementPackageLayout(testCase, root, ... 'focus_stack', 'focus_stack', 'labkit_FocusStack_app.m'); + assertImageMeasurementPackageLayout(testCase, root, ... + 'image_enhance', 'image_enhance', 'labkit_ImageEnhance_app.m'); + assertImageMeasurementPackageLayout(testCase, root, ... + 'image_match', 'image_match', 'labkit_ImageMatch_app.m'); end function electrochemAppsUseOwnedPackageNamespaces(testCase) @@ -407,6 +411,12 @@ function assertAppFamilyBoundary(h, source, appName) case 'labkit_FocusStack_app' legacy = struct('launchName', 'launchFocusStackApp', ... 'legacyCall', 'focus_stack_gui('); + case 'labkit_ImageEnhance_app' + legacy = struct('launchName', 'launchImageEnhanceApp', ... + 'legacyCall', 'image_enhance_gui('); + case 'labkit_ImageMatch_app' + legacy = struct('launchName', 'launchImageMatchApp', ... + 'legacyCall', 'image_match_gui('); case 'labkit_BatchImageCrop_app' legacy = struct('launchName', 'launchBatchImageCropApp', ... 'legacyCall', 'batch_crop_gui('); diff --git a/tests/integration/project/StartupBoundariesTest.m b/tests/integration/project/StartupBoundariesTest.m index 65abd81..857e06d 100644 --- a/tests/integration/project/StartupBoundariesTest.m +++ b/tests/integration/project/StartupBoundariesTest.m @@ -36,6 +36,10 @@ function verify_startup_boundaries() 'startup_labkit should add nested image measurement app folders to the path.'); assert(pathContains(fullfile(root, 'apps', 'image_measurement', 'focus_stack')), ... 'startup_labkit should add nested image measurement app folders to the path.'); + assert(pathContains(fullfile(root, 'apps', 'image_measurement', 'image_enhance')), ... + 'startup_labkit should add nested image measurement app folders to the path.'); + assert(pathContains(fullfile(root, 'apps', 'image_measurement', 'image_match')), ... + 'startup_labkit should add nested image measurement app folders to the path.'); assert(pathContains(fullfile(root, 'apps', 'image_measurement', 'batch_crop')), ... 'startup_labkit should add nested image measurement app folders to the path.'); assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'curvature', 'private')), ... @@ -48,6 +52,10 @@ function verify_startup_boundaries() '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', 'image_enhance', '+image_enhance')), ... + 'startup_labkit should not expose app-owned package folders as direct path entries.'); + assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'image_match', '+image_match')), ... + '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')), ... diff --git a/tests/unit/apps/image_measurement/ImageEnhanceTest.m b/tests/unit/apps/image_measurement/ImageEnhanceTest.m new file mode 100644 index 0000000..663eb62 --- /dev/null +++ b/tests/unit/apps/image_measurement/ImageEnhanceTest.m @@ -0,0 +1,133 @@ +classdef ImageEnhanceTest < matlab.unittest.TestCase + %IMAGEENHANCETEST Verify LabKit behavior through official MATLAB tests. + + methods (Test, TestTags = {'Unit'}) + function test_imageEnhance(testCase) + setupLabKitTestPath(); + verify_imageEnhance(); + end + end +end + +function verify_imageEnhance() +%TEST_IMAGEENHANCE Verify image enhancement calculations and exports. + + checkBrightnessContrastAndSharpenPipeline(); + checkWhiteBalanceReducesChannelCast(); + checkSelectedFileNormalization(); + checkManifestAndExportContract(); +end + +function checkBrightnessContrastAndSharpenPipeline() + img = syntheticGradientImage(); + steps = [ ... + image_enhance.ops.makeStep('Brightness/contrast', 10, 25, 0); ... + image_enhance.ops.makeStep('Sharpen', 40, 1.5, 0)]; + + processed = image_enhance.ops.applyPipeline({img}, steps); + out = processed{1}; + + assert(isequal(size(out), size(img)), ... + 'Enhancement pipeline should preserve image size.'); + assert(all(out(:) >= 0 & out(:) <= 1), ... + 'Enhancement pipeline should clamp output to display range.'); + assert(mean(out(:)) > mean(img(:)), ... + 'Positive brightness should increase mean image intensity.'); +end + +function checkWhiteBalanceReducesChannelCast() + gray = repmat(linspace(0.2, 0.8, 64), 48, 1); + castImage = cat(3, 0.55 .* gray, 0.8 .* gray, 1.25 .* gray); + beforeSpread = channelMeanSpread(castImage); + + step = image_enhance.ops.makeStep('White balance', 100, 0, 0); + out = image_enhance.ops.applyStep(castImage, step, []); + afterSpread = channelMeanSpread(out); + + assert(afterSpread < beforeSpread * 0.25, ... + 'Gray-world white balance should reduce channel mean spread.'); +end + +function checkSelectedFileNormalization() + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); + + paths = image_enhance.io.selectedImagePaths( ... + {'figure_b.png', 'figure_a.tif'}, folder); + names = fileNames(paths); + assert(isequal(names, {'figure_a.tif'; 'figure_b.png'}), ... + 'Selected enhancement images should be sorted by filename.'); + + assertThrows(@() image_enhance.io.selectedImagePaths('notes.txt', folder), ... + 'labkit_ImageEnhance_app:UnsupportedImageFile', ... + 'Manual image selection should reject unsupported file types.'); +end + +function checkManifestAndExportContract() + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); + + sourcePath = string(fullfile(folder, 'sample.png')); + imwrite(uint8(120 * ones(10, 12, 3)), sourcePath); + imwrite(uint8(255 * ones(5, 5, 3)), fullfile(folder, 'sample_enhanced.png')); + + items = image_enhance.io.readImages(sourcePath); + steps = image_enhance.ops.makeStep('Brightness/contrast', 5, 0, 0); + payload = image_enhance.export.writeOutputs(items, steps, struct( ... + 'outputFolder', string(folder), ... + 'format', 'PNG')); + + assert(endsWith(payload.results(1).outputPath, "sample_enhanced_001.png"), ... + 'Batch export should avoid overwriting existing enhanced outputs.'); + assert(isfile(payload.results(1).outputPath), ... + 'Batch export should write enhanced image output.'); + assert(isfile(payload.manifestPath), ... + 'Batch export should write a manifest CSV.'); + + T = image_enhance.export.buildManifest(payload.results); + assert(isequal(T.Properties.VariableNames, expectedManifestColumns()), ... + 'Image enhancement manifest columns changed.'); + assert(T.StepCount(1) == 1, 'Manifest should preserve step count.'); +end + +function img = syntheticGradientImage() + [x, y] = meshgrid(linspace(0, 1, 72), linspace(0, 1, 48)); + img = cat(3, x, y, 0.5 .* x + 0.5 .* y); +end + +function spread = channelMeanSpread(img) + means = squeeze(mean(img, [1 2])); + spread = max(means) - min(means); +end + +function names = fileNames(paths) + names = cell(numel(paths), 1); + for k = 1:numel(paths) + [~, base, ext] = fileparts(char(paths(k))); + names{k} = [base ext]; + end +end + +function cols = expectedManifestColumns() + cols = {'SourceImage', 'OutputImage', 'Status', 'Width_px', ... + 'Height_px', 'StepCount', 'Message'}; +end + +function assertThrows(fcn, expectedId, message) + try + fcn(); + catch ME + assert(strcmp(ME.identifier, expectedId), ... + 'Expected error %s, got %s.', expectedId, ME.identifier); + return; + end + error(message); +end + +function removeTempFolder(folder) + if exist(folder, 'dir') == 7 + rmdir(folder, 's'); + end +end diff --git a/tests/unit/apps/image_measurement/ImageMatchTest.m b/tests/unit/apps/image_measurement/ImageMatchTest.m new file mode 100644 index 0000000..fabc775 --- /dev/null +++ b/tests/unit/apps/image_measurement/ImageMatchTest.m @@ -0,0 +1,147 @@ +classdef ImageMatchTest < matlab.unittest.TestCase + %IMAGEMATCHTEST Verify LabKit behavior through official MATLAB tests. + + methods (Test, TestTags = {'Unit'}) + function test_imageMatch(testCase) + setupLabKitTestPath(); + verify_imageMatch(); + end + end +end + +function verify_imageMatch() +%TEST_IMAGEMATCH Verify reference image matching calculations and exports. + + checkWhiteBalanceMatchMovesChannelRatiosTowardReference(); + checkToneOnlyMatchMovesBrightnessWithoutChangingColorStrongly(); + checkLabStyleMatchMovesColorTowardReference(); + checkHistogramMatchPreservesDisplayRange(); + checkManifestAndExportContract(); +end + +function checkWhiteBalanceMatchMovesChannelRatiosTowardReference() + base = syntheticGradientImage(); + source = tintImage(base, [0.62 0.86 1.25]); + reference = tintImage(base, [1.18 0.96 0.72]); + beforeDistance = channelRatioDistance(source, reference); + + step = image_match.ops.makeStep(2, 'White balance', 100, 100, 100); + processed = image_match.ops.applyPipeline({source; reference}, step); + afterDistance = channelRatioDistance(processed{1}, reference); + + assert(afterDistance < beforeDistance * 0.55, ... + 'White-balance matching should move source channel ratios toward the reference.'); +end + +function checkToneOnlyMatchMovesBrightnessWithoutChangingColorStrongly() + base = syntheticGradientImage(); + source = 0.38 .* base + 0.10; + reference = min(1, 1.18 .* base + 0.18); + sourceRatio = channelRatios(source); + + step = image_match.ops.makeStep(2, 'Tone only', 100, 100, 0); + processed = image_match.ops.applyPipeline({source; reference}, step); + out = processed{1}; + + assert(mean(out(:)) > mean(source(:)), ... + 'Tone-only matching should move source brightness toward a brighter reference.'); + assert(norm(channelRatios(out) - sourceRatio) < 0.12, ... + 'Tone-only matching should not strongly alter average color ratios.'); +end + +function checkLabStyleMatchMovesColorTowardReference() + [source, reference] = syntheticColorPair(); + beforeDistance = meanChannelDistance(source, reference); + + step = image_match.ops.makeStep(2, 'Lab style', 100, 80, 100); + processed = image_match.ops.applyPipeline({source; reference}, step); + afterDistance = meanChannelDistance(processed{1}, reference); + + assert(afterDistance < beforeDistance * 0.60, ... + 'Lab style matching should move source channel means toward the reference.'); +end + +function checkHistogramMatchPreservesDisplayRange() + [source, reference] = syntheticColorPair(); + step = image_match.ops.makeStep(2, 'Histogram', 75, 100, 100); + processed = image_match.ops.applyPipeline({source; reference}, step); + out = processed{1}; + + assert(isequal(size(out), size(source)), ... + 'Histogram matching should preserve image size.'); + assert(all(out(:) >= 0 & out(:) <= 1), ... + 'Histogram matching should clamp output to display range.'); +end + +function checkManifestAndExportContract() + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); + + sourcePath = string(fullfile(folder, 'sample.png')); + referencePath = string(fullfile(folder, 'reference.png')); + imwrite(uint8(120 * ones(10, 12, 3)), sourcePath); + imwrite(uint8(180 * ones(10, 12, 3)), referencePath); + imwrite(uint8(255 * ones(5, 5, 3)), fullfile(folder, 'sample_matched.png')); + + items = image_match.io.readImages([sourcePath; referencePath]); + steps = image_match.ops.makeStep(2, 'Balanced', 100, 100, 100); + payload = image_match.export.writeOutputs(items, steps, struct( ... + 'outputFolder', string(folder), ... + 'format', 'PNG')); + + assert(endsWith(payload.results(1).outputPath, "sample_matched_001.png"), ... + 'Batch export should avoid overwriting existing matched outputs.'); + assert(isfile(payload.results(1).outputPath), ... + 'Batch export should write matched image output.'); + assert(isfile(payload.manifestPath), ... + 'Batch export should write a manifest CSV.'); + + T = image_match.export.buildManifest(payload.results); + assert(isequal(T.Properties.VariableNames, expectedManifestColumns()), ... + 'Image match manifest columns changed.'); + assert(T.StepCount(1) == 1, 'Manifest should preserve step count.'); +end + +function img = syntheticGradientImage() + [x, y] = meshgrid(linspace(0, 1, 72), linspace(0, 1, 48)); + img = cat(3, x, y, 0.5 .* x + 0.5 .* y); +end + +function [source, reference] = syntheticColorPair() + base = syntheticGradientImage(); + source = cat(3, 0.55 .* base(:, :, 1), 0.70 .* base(:, :, 2), ... + 1.10 .* base(:, :, 3)); + reference = cat(3, 0.80 .* base(:, :, 1) + 0.05, ... + 0.55 .* base(:, :, 2) + 0.10, 0.60 .* base(:, :, 3) + 0.20); +end + +function imageData = tintImage(imageData, gains) + imageData = min(max(imageData .* reshape(gains, 1, 1, 3), 0), 1); +end + +function distance = channelRatioDistance(a, b) + distance = norm(channelRatios(a) - channelRatios(b)); +end + +function ratios = channelRatios(img) + means = squeeze(mean(img, [1 2])).'; + ratios = means ./ max(mean(means), eps); +end + +function distance = meanChannelDistance(a, b) + aMeans = squeeze(mean(a, [1 2])); + bMeans = squeeze(mean(b, [1 2])); + distance = norm(aMeans - bMeans); +end + +function cols = expectedManifestColumns() + cols = {'SourceImage', 'OutputImage', 'Status', 'Width_px', ... + 'Height_px', 'StepCount', 'Message'}; +end + +function removeTempFolder(folder) + if exist(folder, 'dir') == 7 + rmdir(folder, 's'); + end +end From c838635cf924d2d6a9efff9bd6a1e78a72129832 Mon Sep 17 00:00:00 2001 From: Pluze Zhu Date: Thu, 11 Jun 2026 16:09:39 -0500 Subject: [PATCH 2/2] fix: avoid image match toolbox dependency --- .../+image_match/+ops/applyMatch.m | 96 +++++++++++++++++-- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/apps/image_measurement/image_match/+image_match/+ops/applyMatch.m b/apps/image_measurement/image_match/+image_match/+ops/applyMatch.m index ad166fd..2830f17 100644 --- a/apps/image_measurement/image_match/+image_match/+ops/applyMatch.m +++ b/apps/image_measurement/image_match/+image_match/+ops/applyMatch.m @@ -71,8 +71,8 @@ end function outputImage = labToneMatch(inputImage, referenceImage, toneStrength) - labImage = rgb2lab(inputImage); - referenceLab = rgb2lab(referenceImage); + labImage = rgbToLab(inputImage); + referenceLab = rgbToLab(referenceImage); matchedL = quantileMatch(labImage(:, :, 1), referenceLab(:, :, 1)); labImage(:, :, 1) = (1 - toneStrength) .* labImage(:, :, 1) + ... toneStrength .* matchedL; @@ -80,8 +80,8 @@ end function outputImage = labStyleMatch(inputImage, referenceImage, toneStrength, colorStrength) - labImage = rgb2lab(inputImage); - referenceLab = rgb2lab(referenceImage); + labImage = rgbToLab(inputImage); + referenceLab = rgbToLab(referenceImage); matchedL = quantileMatch(labImage(:, :, 1), referenceLab(:, :, 1)); matchedAb = covarianceMatch(labImage(:, :, 2:3), referenceLab(:, :, 2:3)); labImage(:, :, 1) = (1 - toneStrength) .* labImage(:, :, 1) + ... @@ -92,8 +92,8 @@ end function outputImage = labHistogramMatch(inputImage, referenceImage, toneStrength, colorStrength) - labImage = rgb2lab(inputImage); - referenceLab = rgb2lab(referenceImage); + labImage = rgbToLab(inputImage); + referenceLab = rgbToLab(referenceImage); matchedLab = labImage; matchedLab(:, :, 1) = quantileMatch(labImage(:, :, 1), referenceLab(:, :, 1)); matchedLab(:, :, 2) = quantileMatch(labImage(:, :, 2), referenceLab(:, :, 2)); @@ -156,7 +156,89 @@ end function outputImage = labToRgb(labImage) - outputImage = min(max(lab2rgb(labImage), 0), 1); + if exist('lab2rgb', 'file') == 2 + outputImage = min(max(lab2rgb(labImage), 0), 1); + return; + end + + xyzImage = labToXyz(labImage); + outputImage = xyzToRgb(xyzImage); +end + +function labImage = rgbToLab(rgbImage) + if exist('rgb2lab', 'file') == 2 + labImage = rgb2lab(rgbImage); + return; + end + + xyzImage = rgbToXyz(rgbImage); + labImage = xyzToLab(xyzImage); +end + +function xyzImage = rgbToXyz(rgbImage) + rgbImage = min(max(double(rgbImage), 0), 1); + linearRgb = rgbImage; + lowMask = linearRgb <= 0.04045; + linearRgb(lowMask) = linearRgb(lowMask) ./ 12.92; + linearRgb(~lowMask) = ((linearRgb(~lowMask) + 0.055) ./ 1.055) .^ 2.4; + + transform = [ ... + 0.4124564 0.3575761 0.1804375; ... + 0.2126729 0.7151522 0.0721750; ... + 0.0193339 0.1191920 0.9503041]; + pixels = reshape(linearRgb, [], 3) * transform.'; + xyzImage = reshape(pixels, size(linearRgb)); +end + +function rgbImage = xyzToRgb(xyzImage) + transform = [ ... + 3.2404542 -1.5371385 -0.4985314; ... + -0.9692660 1.8760108 0.0415560; ... + 0.0556434 -0.2040259 1.0572252]; + linearPixels = reshape(double(xyzImage), [], 3) * transform.'; + linearRgb = reshape(linearPixels, size(xyzImage)); + linearRgb = min(max(linearRgb, 0), 1); + + rgbImage = linearRgb; + lowMask = rgbImage <= 0.0031308; + rgbImage(lowMask) = 12.92 .* rgbImage(lowMask); + rgbImage(~lowMask) = 1.055 .* (rgbImage(~lowMask) .^ (1 / 2.4)) - 0.055; + rgbImage = min(max(rgbImage, 0), 1); +end + +function labImage = xyzToLab(xyzImage) + white = reshape([0.95047 1.00000 1.08883], 1, 1, 3); + scaled = double(xyzImage) ./ white; + f = labPivotForward(scaled); + + labImage = zeros(size(xyzImage)); + labImage(:, :, 1) = 116 .* f(:, :, 2) - 16; + labImage(:, :, 2) = 500 .* (f(:, :, 1) - f(:, :, 2)); + labImage(:, :, 3) = 200 .* (f(:, :, 2) - f(:, :, 3)); +end + +function xyzImage = labToXyz(labImage) + white = reshape([0.95047 1.00000 1.08883], 1, 1, 3); + fy = (double(labImage(:, :, 1)) + 16) ./ 116; + fx = fy + double(labImage(:, :, 2)) ./ 500; + fz = fy - double(labImage(:, :, 3)) ./ 200; + + scaled = cat(3, labPivotInverse(fx), labPivotInverse(fy), labPivotInverse(fz)); + xyzImage = scaled .* white; +end + +function value = labPivotForward(value) + delta = 6 / 29; + highMask = value > delta ^ 3; + value(highMask) = value(highMask) .^ (1 / 3); + value(~highMask) = value(~highMask) ./ (3 * delta ^ 2) + 4 / 29; +end + +function value = labPivotInverse(value) + delta = 6 / 29; + highMask = value > delta; + value(highMask) = value(highMask) .^ 3; + value(~highMask) = 3 * delta ^ 2 .* (value(~highMask) - 4 / 29); end function value = clamp01(value)