diff --git a/README.md b/README.md index 74b727c..2e31d8d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Workflow-specific calculations, plot choices, summaries, and exports stay in the | --- | --- | | Electrochemistry | Gamry DTA review, chrono overlays, CIC, CSC, VT resistance, and EIS export workflows | | DIC | Image registration, paired crop preparation, ROI masks, Ncorr strain overlays, and summary export | -| Image measurement | Interactive curve tracing, calibrated scale/length measurement, curvature/radius measurement, and microscope focus stacking | +| Image measurement | Interactive curve tracing, calibrated scale/length measurement, curvature/radius measurement, microscope focus stacking, and fixed-size batch image cropping | | Wearable biosignals | ECG preview, filtering, peak detection, segments, templates, and SNR-style measurements | | Reusable foundation | Layered MATLAB UI foundation plus DTA and biosignal facades for app-facing workflows | | Validation | Focused MATLAB build tasks, architecture guardrails, synthetic fixtures, and GitHub Actions CI | @@ -48,6 +48,7 @@ labkit_DICPostprocess_app % Image measurement labkit_CurvatureMeasurement_app labkit_FocusStack_app +labkit_BatchImageCrop_app % Wearable biosignal labkit_ECGPrint_app @@ -68,6 +69,7 @@ Then use the app window to load files, inspect plots or results, and export outp | `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_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 | Status labels: diff --git a/apps/image_measurement/batch_crop/batchImageCropWorkflow.m b/apps/image_measurement/batch_crop/batchImageCropWorkflow.m new file mode 100644 index 0000000..56da1ed --- /dev/null +++ b/apps/image_measurement/batch_crop/batchImageCropWorkflow.m @@ -0,0 +1,22 @@ +function varargout = batchImageCropWorkflow(command, varargin) +%BATCHIMAGECROPWORKFLOW Dispatch app-owned batch crop helpers. +% Expected caller: batch-crop app tests and migration-time workflow checks. +% Inputs are a workflow command plus command-specific arguments. Outputs match +% the selected app-private helper. Export commands have file side effects. + + switch string(command) + case "cropImage" + varargout{1} = batchCropImage(varargin{1}, varargin{2}); + case "buildBatchCropManifest" + varargout{1} = buildBatchCropManifest(varargin{1}); + case "selectedBatchCropImagePaths" + varargout{1} = selectedBatchCropImagePaths(varargin{1}, varargin{2}); + case "writeBatchCropOutputs" + varargout{1} = writeBatchCropOutputs(varargin{1}, varargin{2}); + case "batchCropImageDialogFilter" + varargout{1} = batchCropImageDialogFilter(); + otherwise + error('labkit:BatchImageCrop:UnknownWorkflowCommand', ... + 'Unknown batch image crop workflow helper command: %s.', command); + end +end diff --git a/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m new file mode 100644 index 0000000..df6268e --- /dev/null +++ b/apps/image_measurement/batch_crop/labkit_BatchImageCrop_app.m @@ -0,0 +1,474 @@ +function varargout = labkit_BatchImageCrop_app(varargin) +%LABKIT_BATCHIMAGECROP_APP Batch crop microscope images at fixed pixel size. + + [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... + 'labkit_BatchImageCrop_app', varargin, nargout); + if requestHandled + varargout = requestOutputs; + return; + end + if debugLog.enabled + if nargout > 2 + error('labkit_BatchImageCrop_app:TooManyOutputs', ... + 'labkit_BatchImageCrop_app debug mode returns at most the app figure and debug log.'); + end + elseif nargout > 1 + error('labkit_BatchImageCrop_app:TooManyOutputs', ... + 'labkit_BatchImageCrop_app returns at most the app figure handle.'); + end + + S = struct(); + S.items = repmat(emptyBatchCropItem(), 0, 1); + S.currentIndex = 0; + S.outputFolder = string(pwd); + S.lastExport = []; + + workbenchOpts = struct( ... + 'rightKind', 'custom', ... + 'rightTitle', 'Crop Preview', ... + 'rightGridSize', [1 1], ... + 'rightRowHeight', {{'1x'}}, ... + 'showPlotControls', false); + workbenchOpts.tabs = [ ... + labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [3 1], ... + {250, 260, 145}, ... + struct('resizeRows', [1 2])), ... + labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... + {240, '1x'}, ... + struct('resizeRows', 1)), ... + labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; + + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Microscope Batch Image Crop', ... + 'position', [80 60 1440 860], ... + 'leftWidth', 400, ... + 'options', workbenchOpts)); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + + ui.previewAxes = uiaxes(ui.rightGrid); + ui.previewAxes.Layout.Row = 1; + title(ui.previewAxes, 'Rotated preview + fixed crop'); + labkit.ui.view.draw(ui.previewAxes, 'popout'); + imageRuntime = labkit.ui.tool.createRuntime(ui.previewAxes, ... + struct('figure', fig, 'onTrace', debugLog.trace)); + cropSession = imageRuntime.createSession(struct( ... + 'name', 'batchCropCenter', ... + 'onPointerDown', @onPreviewPointerDown, ... + 'installScrollWheel', false)); + + callbacks = struct( ... + 'onOpenFiles', @onOpenFiles, ... + 'onClearImages', @onClearImages, ... + 'onImageSelectionChanged', @onImageSelectionChanged, ... + 'onPreviousImage', @onPreviousImage, ... + 'onNextImage', @onNextImage, ... + 'onCropGeometryChanged', @onCropGeometryChanged, ... + 'onRotationChanged', @onRotationChanged, ... + 'onFillModeChanged', @onFillModeChanged, ... + 'onCenterChanged', @onCenterChanged, ... + 'onUseCanvasCenter', @onUseCanvasCenter, ... + 'onExportSettingChanged', @onExportSettingChanged, ... + 'onChooseOutputFolder', @onChooseOutputFolder, ... + 'onExportCrops', @onExportCrops); + controls = createBatchCropControls(layFA, laySR, layLog, ... + S.outputFolder, callbacks); + btnOpenFiles = controls.btnOpenFiles; + btnClearImages = controls.btnClearImages; + txtImageSource = controls.txtImageSource; + lbImages = controls.lbImages; + btnPrevious = controls.btnPrevious; + btnNext = controls.btnNext; + txtImageStatus = controls.txtImageStatus; + edtCropWidth = controls.edtCropWidth; + edtCropHeight = controls.edtCropHeight; + edtRotation = controls.edtRotation; + ddFillMode = controls.ddFillMode; + edtCenterX = controls.edtCenterX; + edtCenterY = controls.edtCenterY; + btnUseCanvasCenter = controls.btnUseCanvasCenter; + ddFormat = controls.ddFormat; + txtOutputFolder = controls.txtOutputFolder; + btnChooseOutput = controls.btnChooseOutput; + btnExport = controls.btnExport; + resultTable = controls.resultTable; + txtDetails = controls.txtDetails; + txtLog = controls.txtLog; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('Batch image crop 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(batchCropImageDialogFilter(), ... + 'Select microscope images', pwd, 'MultiSelect', 'on'); + if isequal(files, 0) + addLog('Image file selection cancelled.'); + return; + end + + try + paths = selectedBatchCropImagePaths(files, folder); + items = readCropItems(paths); + catch ME + showError('Could not load images', ME.message); + return; + end + + S.items = items; + S.currentIndex = 1; + S.lastExport = []; + addLog(sprintf('Loaded %d image(s).', numel(S.items))); + refreshAll(); + end + + function onClearImages(~, ~) + S.items = repmat(emptyBatchCropItem(), 0, 1); + S.currentIndex = 0; + S.lastExport = []; + addLog('Cleared loaded images.'); + refreshAll(); + end + + function onImageSelectionChanged(~, ~) + if isempty(S.items) + return; + end + items = batchCropListboxItems(S.items); + idx = find(strcmp(items, lbImages.Value), 1); + if isempty(idx) + return; + end + S.currentIndex = idx; + refreshAll(); + end + + function onPreviousImage(~, ~) + if isempty(S.items) + return; + end + S.currentIndex = max(1, S.currentIndex - 1); + refreshAll(); + end + + function onNextImage(~, ~) + if isempty(S.items) + return; + end + S.currentIndex = min(numel(S.items), S.currentIndex + 1); + refreshAll(); + end + + function onCropGeometryChanged(~, ~) + edtCropWidth.Value = round(max(1, edtCropWidth.Value)); + edtCropHeight.Value = round(max(1, edtCropHeight.Value)); + refreshPreview(); + refreshSummary(); + end + + function onRotationChanged(~, ~) + if ~hasCurrentImage() + return; + end + S.items(S.currentIndex).angleDeg = edtRotation.Value; + ensureCurrentCenter(); + addLog(sprintf('Updated rotation for image %d: %.3g deg.', ... + S.currentIndex, S.items(S.currentIndex).angleDeg)); + refreshAll(); + end + + function onFillModeChanged(~, ~) + refreshPreview(); + refreshSummary(); + end + + function onCenterChanged(~, ~) + if ~hasCurrentImage() + return; + end + S.items(S.currentIndex).centerXY = [edtCenterX.Value, edtCenterY.Value]; + S.items(S.currentIndex).centerSet = true; + addLog(sprintf('Set crop center for image %d: x=%.1f, y=%.1f.', ... + S.currentIndex, edtCenterX.Value, edtCenterY.Value)); + refreshAll(); + end + + function onUseCanvasCenter(~, ~) + if ~hasCurrentImage() + return; + end + [canvas, ~] = currentCanvas(); + S.items(S.currentIndex).centerXY = [(size(canvas, 2) + 1) / 2, ... + (size(canvas, 1) + 1) / 2]; + S.items(S.currentIndex).centerSet = true; + addLog(sprintf('Set image %d crop center to rotated canvas center.', ... + S.currentIndex)); + refreshAll(); + end + + function onExportSettingChanged(~, ~) + refreshSummary(); + end + + function onChooseOutputFolder(~, ~) + folder = uigetdir(char(S.outputFolder), 'Select crop export folder'); + if isequal(folder, 0) + addLog('Export folder selection cancelled.'); + return; + end + S.outputFolder = string(folder); + txtOutputFolder.Value = char(S.outputFolder); + refreshSummary(); + end + + function onPreviewPointerDown(~, ~) + if ~hasCurrentImage() + return; + end + [canvas, ~] = currentCanvas(); + pt = ui.previewAxes.CurrentPoint; + x = min(max(pt(1, 1), 1), size(canvas, 2)); + y = min(max(pt(1, 2), 1), size(canvas, 1)); + S.items(S.currentIndex).centerXY = [x, y]; + S.items(S.currentIndex).centerSet = true; + addLog(sprintf('Picked crop center for image %d: x=%.1f, y=%.1f.', ... + S.currentIndex, x, y)); + refreshAll(); + end + + function onExportCrops(~, ~) + if isempty(S.items) + showError('No images loaded', 'Load images before exporting crops.'); + return; + end + if ~all([S.items.centerSet]) + showError('Crop centers missing', ... + 'Set or confirm the crop center for every loaded image before exporting.'); + return; + end + + opts = currentExportOptions(); + busyOpts = struct(); + busyOpts.title = 'Export crops'; + busyOpts.message = 'Writing cropped microscope images...'; + busyOpts.controls = [btnOpenFiles, btnClearImages, btnExport, ... + btnChooseOutput, btnPrevious, btnNext, btnUseCanvasCenter]; + try + payload = labkit.ui.app.runBusy(fig, ... + @() writeBatchCropOutputs(S.items, opts), busyOpts); + catch ME + showError('Export failed', ME.message); + return; + end + + S.lastExport = payload; + statuses = string({payload.results.status}); + savedCount = sum(statuses == "saved"); + failedCount = sum(statuses == "failed"); + addLog(sprintf('Exported %d crop(s), %d failed. Manifest: %s', ... + savedCount, failedCount, char(payload.manifestPath))); + refreshSummary(); + if failedCount > 0 + showError('Some crops failed', ... + sprintf('%d image(s) failed. See the manifest for details.', failedCount)); + end + end + + function refreshAll() + refreshList(); + refreshControls(); + refreshPreview(); + refreshSummary(); + 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 + + items = batchCropListboxItems(S.items); + lbImages.Items = items; + S.currentIndex = min(max(S.currentIndex, 1), numel(S.items)); + lbImages.Value = items{S.currentIndex}; + txtImageSource.Value = char(S.items(S.currentIndex).path); + txtImageStatus.Value = sprintf('Images: %d | confirmed centers: %d', ... + numel(S.items), countConfirmedCenters()); + end + + function refreshControls() + hasImage = hasCurrentImage(); + enabled = ternary(hasImage, 'on', 'off'); + btnClearImages.Enable = enabled; + btnPrevious.Enable = ternary(hasImage && S.currentIndex > 1, 'on', 'off'); + btnNext.Enable = ternary(hasImage && S.currentIndex < numel(S.items), 'on', 'off'); + edtRotation.Enable = enabled; + edtCenterX.Enable = enabled; + edtCenterY.Enable = enabled; + btnUseCanvasCenter.Enable = enabled; + + if hasImage + ensureCurrentCenter(); + item = S.items(S.currentIndex); + [canvas, ~] = currentCanvas(); + edtRotation.Value = item.angleDeg; + edtCenterX.Limits = [1, max(1, size(canvas, 2))]; + edtCenterY.Limits = [1, max(1, size(canvas, 1))]; + edtCenterX.Value = item.centerXY(1); + edtCenterY.Value = item.centerXY(2); + else + edtRotation.Value = 0; + edtCenterX.Limits = [1, Inf]; + edtCenterY.Limits = [1, Inf]; + edtCenterX.Value = 1; + edtCenterY.Value = 1; + end + + btnExport.Enable = ternary(hasImage && all([S.items.centerSet]), 'on', 'off'); + end + + function refreshPreview() + if ~hasCurrentImage() + resetPreviewAxes(); + cropSession.setBackground([]); + cropSession.setGraphics([]); + return; + end + + ensureCurrentCenter(); + [canvas, ~] = currentCanvas(); + hImage = labkit.ui.view.draw(ui.previewAxes, 'image', canvas, ... + 'Rotated preview + fixed crop'); + hold(ui.previewAxes, 'on'); + item = S.items(S.currentIndex); + cropWidth = currentCropWidth(); + cropHeight = currentCropHeight(); + position = batchCropRectanglePosition(item.centerXY, cropWidth, cropHeight); + hRect = rectangle(ui.previewAxes, 'Position', position, ... + 'EdgeColor', [1 0.84 0], ... + 'LineWidth', 1.5, ... + 'LineStyle', '-'); + hLineX = plot(ui.previewAxes, ... + [item.centerXY(1) - 16, item.centerXY(1) + 16], ... + [item.centerXY(2), item.centerXY(2)], ... + 'Color', [0 0.85 1], ... + 'LineWidth', 1.25); + hLineY = plot(ui.previewAxes, ... + [item.centerXY(1), item.centerXY(1)], ... + [item.centerXY(2) - 16, item.centerXY(2) + 16], ... + 'Color', [0 0.85 1], ... + 'LineWidth', 1.25); + hold(ui.previewAxes, 'off'); + axis(ui.previewAxes, 'image'); + cropSession.setBackground(hImage); + cropSession.setGraphics([hRect, hLineX, hLineY]); + cropSession.activate(); + end + + function refreshSummary() + if hasCurrentImage() + [canvas, ~] = currentCanvas(); + canvasSize = [size(canvas, 2), size(canvas, 1)]; + else + canvasSize = [0, 0]; + end + resultTable.Data = batchCropSummaryTableData(S, S.currentIndex, ... + canvasSize, currentCropWidth(), currentCropHeight(), ddFormat.Value); + txtDetails.Value = batchCropDetailLines(S, S.currentIndex, ... + currentCropWidth(), currentCropHeight(), ddFillMode.Value); + end + + function resetPreviewAxes() + labkit.ui.view.draw(ui.previewAxes, 'reset', ... + 'Rotated preview + fixed crop', true); + end + + function opts = currentExportOptions() + opts = struct(); + opts.outputFolder = S.outputFolder; + opts.format = ddFormat.Value; + opts.cropWidth = currentCropWidth(); + opts.cropHeight = currentCropHeight(); + opts.fillMode = ddFillMode.Value; + end + + function width = currentCropWidth() + width = max(1, round(double(edtCropWidth.Value))); + end + + function height = currentCropHeight() + height = max(1, round(double(edtCropHeight.Value))); + end + + function [canvas, mask] = currentCanvas() + item = S.items(S.currentIndex); + fillValue = batchCropPreviewFillValue(item.image, ddFillMode.Value); + [canvas, mask] = rotateImageCanvas(item.image, item.angleDeg, fillValue); + end + + function ensureCurrentCenter() + if ~hasCurrentImage() + return; + end + [canvas, ~] = currentCanvas(); + item = S.items(S.currentIndex); + if isempty(item.centerXY) || any(~isfinite(item.centerXY)) + item.centerXY = [(size(canvas, 2) + 1) / 2, (size(canvas, 1) + 1) / 2]; + end + item.centerXY(1) = min(max(item.centerXY(1), 1), size(canvas, 2)); + item.centerXY(2) = min(max(item.centerXY(2), 1), size(canvas, 1)); + S.items(S.currentIndex) = item; + end + + function tf = hasCurrentImage() + tf = ~isempty(S.items) && S.currentIndex >= 1 && S.currentIndex <= numel(S.items); + end + + function count = countConfirmedCenters() + if isempty(S.items) + count = 0; + else + count = sum([S.items.centerSet]); + end + end + + function items = readCropItems(paths) + items = readBatchCropItems(paths); + 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 + + function value = ternary(condition, trueValue, falseValue) + if condition + value = trueValue; + else + value = falseValue; + end + end +end diff --git a/apps/image_measurement/batch_crop/private/batchCropDetailLines.m b/apps/image_measurement/batch_crop/private/batchCropDetailLines.m new file mode 100644 index 0000000..b667ba6 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/batchCropDetailLines.m @@ -0,0 +1,32 @@ +% App-owned detail view helper. Expected caller: batch-crop app refreshSummary. +% Inputs are app state, current index, crop size, and fill mode. Output is a +% cell vector of text lines and has no side effects. +function lines = batchCropDetailLines(state, currentIndex, cropWidth, cropHeight, fillMode) +%BATCHCROPDETAILLINES Build detail text for the selected crop item. + + if isempty(state.items) || currentIndex < 1 || currentIndex > numel(state.items) + lines = {'No images loaded.'}; + return; + end + + item = state.items(currentIndex); + stateText = ternary(item.centerSet, 'confirmed', 'needs confirmation'); + lines = { ... + sprintf('Image %d of %d: %s', currentIndex, numel(state.items), ... + displayNameFromPath(item.path)), ... + sprintf('Crop center: x %.1f, y %.1f (%s)', ... + item.centerXY(1), item.centerXY(2), stateText), ... + sprintf('Output size: %d x %d px; rotation: %.3g deg; fill: %s', ... + cropWidth, cropHeight, item.angleDeg, char(fillMode))}; + if ~isempty(state.lastExport) + lines{end+1} = sprintf('Last manifest: %s', char(state.lastExport.manifestPath)); + end +end + +function value = ternary(condition, trueValue, falseValue) + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/apps/image_measurement/batch_crop/private/batchCropImage.m b/apps/image_measurement/batch_crop/private/batchCropImage.m new file mode 100644 index 0000000..6f3c49f --- /dev/null +++ b/apps/image_measurement/batch_crop/private/batchCropImage.m @@ -0,0 +1,92 @@ +% App-owned microscope image crop helper. Expected caller: batch-crop app +% callbacks and workflow tests. Inputs are an image array and crop options. +% Output is a result struct with the cropped image and crop metadata. +function result = batchCropImage(imageData, opts) +%BATCHCROPIMAGE Rotate an image canvas and crop a fixed pixel rectangle. +% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. +% Inputs are an image array and opts with cropWidth, cropHeight, angleDeg, +% centerXY, fillMode, or fillValue. Output preserves image class and returns +% exactly cropHeight-by-cropWidth pixels, padding when the crop crosses canvas +% bounds. This helper does not resize the image and has no file side effects. + + if nargin < 2 + opts = struct(); + end + validateImageData(imageData); + + cropWidth = requiredPositiveInteger(opts, 'cropWidth'); + cropHeight = requiredPositiveInteger(opts, 'cropHeight'); + angleDeg = double(optionValue(opts, 'angleDeg', 0)); + fillValue = fillValueForImage(imageData, opts); + + [canvas, mask] = rotateImageCanvas(imageData, angleDeg, fillValue); + centerXY = optionValue(opts, 'centerXY', []); + if isempty(centerXY) || numel(centerXY) ~= 2 || any(~isfinite(double(centerXY))) + centerXY = [(size(canvas, 2) + 1) / 2, (size(canvas, 1) + 1) / 2]; + else + centerXY = double(centerXY(:)).'; + end + + cropped = cropCanvasFixedSize(canvas, centerXY, [cropWidth, cropHeight], fillValue); + + result = emptyBatchCropResult(); + result.ok = true; + result.status = "cropped"; + result.image = cropped; + result.rotationDeg = angleDeg; + result.centerX = centerXY(1); + result.centerY = centerXY(2); + result.cropWidth = cropWidth; + result.cropHeight = cropHeight; + result.canvasWidth = size(canvas, 2); + result.canvasHeight = size(canvas, 1); + result.message = "OK"; +end + +function validateImageData(imageData) + if isempty(imageData) || ~(isnumeric(imageData) || islogical(imageData)) || ndims(imageData) > 3 + error('labkit_BatchImageCrop_app:InvalidImage', ... + 'Image data must be a nonempty numeric or logical 2-D or 3-D image array.'); + end +end + +function value = requiredPositiveInteger(opts, name) + raw = optionValue(opts, name, []); + if isempty(raw) || ~isscalar(raw) || ~isfinite(double(raw)) || double(raw) < 1 + error('labkit_BatchImageCrop_app:InvalidCropSize', ... + 'Crop %s must be a positive pixel count.', name); + end + value = max(1, round(double(raw))); +end + +function value = fillValueForImage(imageData, opts) + if isfield(opts, 'fillValue') && ~isempty(opts.fillValue) + value = double(opts.fillValue(1)); + return; + end + + mode = lower(char(string(optionValue(opts, 'fillMode', 'Black')))); + if strcmp(mode, 'white') + if islogical(imageData) + value = 1; + elseif isinteger(imageData) + value = double(intmax(class(imageData))); + else + maxPixel = max(double(imageData(:))); + if maxPixel > 1 + value = 255; + else + value = 1; + end + end + else + value = 0; + end +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end diff --git a/apps/image_measurement/batch_crop/private/batchCropImageDialogFilter.m b/apps/image_measurement/batch_crop/private/batchCropImageDialogFilter.m new file mode 100644 index 0000000..ef6d70a --- /dev/null +++ b/apps/image_measurement/batch_crop/private/batchCropImageDialogFilter.m @@ -0,0 +1,8 @@ +% App-owned image dialog filter helper. Expected caller: batch-crop app open +% callback. Output is a uigetfile filter cell array and has no side effects. +function filter = batchCropImageDialogFilter() +%BATCHCROPIMAGEDIALOGFILTER Return supported image file dialog filters. + + filter = {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... + 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'}; +end diff --git a/apps/image_measurement/batch_crop/private/batchCropListboxItems.m b/apps/image_measurement/batch_crop/private/batchCropListboxItems.m new file mode 100644 index 0000000..70fae8f --- /dev/null +++ b/apps/image_measurement/batch_crop/private/batchCropListboxItems.m @@ -0,0 +1,21 @@ +% App-owned list display helper. Expected caller: batch-crop app refreshList. +% Input is an item struct vector. Output is listbox display text and has no +% side effects. +function items = batchCropListboxItems(cropItems) +%BATCHCROPLISTBOXITEMS Build listbox labels for loaded crop items. + + items = cell(numel(cropItems), 1); + for k = 1:numel(cropItems) + marker = ternary(cropItems(k).centerSet, 'set', 'needs center'); + items{k} = sprintf('%02d %s [%s]', k, ... + displayNameFromPath(cropItems(k).path), marker); + end +end + +function value = ternary(condition, trueValue, falseValue) + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/apps/image_measurement/batch_crop/private/batchCropPreviewFillValue.m b/apps/image_measurement/batch_crop/private/batchCropPreviewFillValue.m new file mode 100644 index 0000000..e021222 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/batchCropPreviewFillValue.m @@ -0,0 +1,23 @@ +% App-owned preview fill helper. Expected caller: batch-crop app preview +% rendering. Inputs are image data and fill mode. Output is a scalar fill +% value compatible with rotateImageCanvas. +function value = batchCropPreviewFillValue(imageData, fillMode) +%BATCHCROPPREVIEWFILLVALUE Resolve black/white preview fill value. + + if strcmp(char(string(fillMode)), 'White') + if islogical(imageData) + value = 1; + elseif isinteger(imageData) + value = double(intmax(class(imageData))); + else + maxPixel = max(double(imageData(:))); + if maxPixel > 1 + value = 255; + else + value = 1; + end + end + else + value = 0; + end +end diff --git a/apps/image_measurement/batch_crop/private/batchCropRectanglePosition.m b/apps/image_measurement/batch_crop/private/batchCropRectanglePosition.m new file mode 100644 index 0000000..9489ed9 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/batchCropRectanglePosition.m @@ -0,0 +1,10 @@ +% App-owned overlay geometry helper. Expected caller: batch-crop app preview +% rendering. Inputs are center and crop size in pixels. Output is a MATLAB +% rectangle Position vector and has no side effects. +function position = batchCropRectanglePosition(centerXY, cropWidth, cropHeight) +%BATCHCROPRECTANGLEPOSITION Return rectangle overlay position for a crop box. + + colStart = round(centerXY(1) - (cropWidth - 1) / 2); + rowStart = round(centerXY(2) - (cropHeight - 1) / 2); + position = [colStart - 0.5, rowStart - 0.5, cropWidth, cropHeight]; +end diff --git a/apps/image_measurement/batch_crop/private/batchCropSummaryTableData.m b/apps/image_measurement/batch_crop/private/batchCropSummaryTableData.m new file mode 100644 index 0000000..00b9e08 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/batchCropSummaryTableData.m @@ -0,0 +1,42 @@ +% App-owned summary view helper. Expected caller: batch-crop app refreshSummary. +% Inputs are app state, current index, canvas size, crop size, and output format. +% Output is metric/value cell data and has no side effects. +function data = batchCropSummaryTableData(state, currentIndex, canvasSize, cropWidth, cropHeight, outputFormat) +%BATCHCROPSUMMARYTABLEDATA Build the batch crop summary table cell data. + + if isempty(state.items) || currentIndex < 1 || currentIndex > numel(state.items) + data = { ... + 'Images loaded', '0'; ... + 'Crop size', sprintf('%d x %d px', cropWidth, cropHeight); ... + 'Aspect ratio', aspectRatioText(cropWidth, cropHeight); ... + 'Confirmed centers', '0'; ... + 'Output folder', char(state.outputFolder)}; + return; + end + + item = state.items(currentIndex); + data = { ... + 'Images loaded', sprintf('%d', numel(state.items)); ... + 'Current image', displayNameFromPath(item.path); ... + 'Crop size', sprintf('%d x %d px', cropWidth, cropHeight); ... + 'Aspect ratio', aspectRatioText(cropWidth, cropHeight); ... + 'Rotation', sprintf('%.3g deg', item.angleDeg); ... + 'Center', sprintf('x %.1f, y %.1f', item.centerXY(1), item.centerXY(2)); ... + 'Rotated canvas', sprintf('%d x %d px', canvasSize(1), canvasSize(2)); ... + 'Confirmed centers', sprintf('%d / %d', countConfirmedCenters(state.items), numel(state.items)); ... + 'Output format', char(outputFormat); ... + 'Output folder', char(state.outputFolder)}; +end + +function text = aspectRatioText(width, height) + g = gcd(width, height); + text = sprintf('%d:%d', width / g, height / g); +end + +function count = countConfirmedCenters(items) + if isempty(items) + count = 0; + else + count = sum([items.centerSet]); + end +end diff --git a/apps/image_measurement/batch_crop/private/buildBatchCropManifest.m b/apps/image_measurement/batch_crop/private/buildBatchCropManifest.m new file mode 100644 index 0000000..3b05f9c --- /dev/null +++ b/apps/image_measurement/batch_crop/private/buildBatchCropManifest.m @@ -0,0 +1,61 @@ +% App-owned export manifest helper. Expected caller: batch-crop app export +% callback and workflow tests. Input is a result struct vector. Output is a +% table suitable for CSV export and has no file side effects. +function T = buildBatchCropManifest(results) +%BUILDBATCHCROPMANIFEST Build a per-image crop/export manifest table. +% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. +% Input is a struct vector returned by batchCropImage or writeBatchCropOutputs. +% Output columns describe source/output files, crop geometry, and status. + + if isempty(results) + T = table(strings(0, 1), strings(0, 1), strings(0, 1), ... + zeros(0, 1), zeros(0, 1), zeros(0, 1), zeros(0, 1), ... + zeros(0, 1), zeros(0, 1), zeros(0, 1), strings(0, 1), ... + 'VariableNames', manifestColumns()); + return; + end + + n = numel(results); + sourceImage = strings(n, 1); + outputImage = strings(n, 1); + status = strings(n, 1); + rotationDeg = zeros(n, 1); + centerX = zeros(n, 1); + centerY = zeros(n, 1); + cropWidth = zeros(n, 1); + cropHeight = zeros(n, 1); + canvasWidth = zeros(n, 1); + canvasHeight = zeros(n, 1); + message = strings(n, 1); + + for k = 1:n + sourceImage(k) = string(fieldOr(results(k), 'sourcePath', "")); + outputImage(k) = string(fieldOr(results(k), 'outputPath', "")); + status(k) = string(fieldOr(results(k), 'status', "")); + rotationDeg(k) = double(fieldOr(results(k), 'rotationDeg', NaN)); + centerX(k) = double(fieldOr(results(k), 'centerX', NaN)); + centerY(k) = double(fieldOr(results(k), 'centerY', NaN)); + cropWidth(k) = double(fieldOr(results(k), 'cropWidth', NaN)); + cropHeight(k) = double(fieldOr(results(k), 'cropHeight', NaN)); + canvasWidth(k) = double(fieldOr(results(k), 'canvasWidth', NaN)); + canvasHeight(k) = double(fieldOr(results(k), 'canvasHeight', NaN)); + message(k) = string(fieldOr(results(k), 'message', "")); + end + + T = table(sourceImage, outputImage, status, rotationDeg, centerX, centerY, ... + cropWidth, cropHeight, canvasWidth, canvasHeight, message, ... + 'VariableNames', manifestColumns()); +end + +function names = manifestColumns() + names = {'SourceImage', 'OutputImage', 'Status', 'RotationDeg', ... + 'CenterX_px', 'CenterY_px', 'CropWidth_px', 'CropHeight_px', ... + 'CanvasWidth_px', 'CanvasHeight_px', 'Message'}; +end + +function value = fieldOr(s, name, defaultValue) + value = defaultValue; + if isstruct(s) && isfield(s, name) && ~isempty(s.(name)) + value = s.(name); + end +end diff --git a/apps/image_measurement/batch_crop/private/createBatchCropControls.m b/apps/image_measurement/batch_crop/private/createBatchCropControls.m new file mode 100644 index 0000000..e059ea3 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/createBatchCropControls.m @@ -0,0 +1,146 @@ +% App-owned GUI construction helper. Expected caller: +% labkit_BatchImageCrop_app during startup. Inputs are shell tab grids, +% initial output folder, and callback handles. Output is a struct of UI +% handles. This helper creates controls only and has no file side effects. +function controls = createBatchCropControls(layFA, laySR, layLog, initialOutputFolder, callbacks) +%CREATEBATCHCROPCONTROLS Create controls for the batch image crop app. + + filePanel = labkit.ui.view.section(layFA, 'Images', 1, [5 2], ... + struct('rowHeight', {{'fit', 'fit', 105, 'fit', 'fit'}}, ... + 'columnWidth', {{'1x', '1x'}})); + fileGrid = filePanel.grid; + + controls.btnOpenFiles = uibutton(fileGrid, 'Text', 'Open image files', ... + 'ButtonPushedFcn', callbacks.onOpenFiles); + controls.btnOpenFiles.Layout.Row = 1; + controls.btnOpenFiles.Layout.Column = 1; + + controls.btnClearImages = uibutton(fileGrid, 'Text', 'Clear images', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.onClearImages); + controls.btnClearImages.Layout.Row = 1; + controls.btnClearImages.Layout.Column = 2; + + controls.txtImageSource = labkit.ui.view.form(fileGrid, 'readonly', ... + 'Value', 'No images loaded'); + controls.txtImageSource.Layout.Row = 2; + controls.txtImageSource.Layout.Column = [1 2]; + + controls.lbImages = uilistbox(fileGrid, ... + 'Items', {'No images loaded'}, ... + 'ValueChangedFcn', callbacks.onImageSelectionChanged); + controls.lbImages.Layout.Row = 3; + controls.lbImages.Layout.Column = [1 2]; + + controls.btnPrevious = uibutton(fileGrid, 'Text', 'Previous image', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.onPreviousImage); + controls.btnPrevious.Layout.Row = 4; + controls.btnPrevious.Layout.Column = 1; + + controls.btnNext = uibutton(fileGrid, 'Text', 'Next image', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.onNextImage); + controls.btnNext.Layout.Row = 4; + controls.btnNext.Layout.Column = 2; + + controls.txtImageStatus = labkit.ui.view.form(fileGrid, 'readonly', ... + 'Value', 'Images: 0'); + controls.txtImageStatus.Layout.Row = 5; + controls.txtImageStatus.Layout.Column = [1 2]; + + cropRows = repmat({'fit'}, 1, 7); + cropPanel = labkit.ui.view.section(layFA, 'Crop Geometry', 2, [7 2], ... + struct('rowHeight', {cropRows}, 'columnWidth', {{145, '1x'}})); + cropGrid = cropPanel.grid; + + [lblWidth, controls.edtCropWidth] = labkit.ui.view.form(cropGrid, 'spinner', ... + 'Width (px):', 'Value', 1024, 'Limits', [1 Inf], 'Step', 1, ... + 'ValueChangedFcn', callbacks.onCropGeometryChanged); + placeLabeled(lblWidth, controls.edtCropWidth, 1); + + [lblHeight, controls.edtCropHeight] = labkit.ui.view.form(cropGrid, 'spinner', ... + 'Height (px):', 'Value', 1024, 'Limits', [1 Inf], 'Step', 1, ... + 'ValueChangedFcn', callbacks.onCropGeometryChanged); + placeLabeled(lblHeight, controls.edtCropHeight, 2); + + [lblRotation, controls.edtRotation] = labkit.ui.view.form(cropGrid, 'spinner', ... + 'Rotation (deg):', 'Value', 0, 'Limits', [-180 180], 'Step', 0.5, ... + 'ValueChangedFcn', callbacks.onRotationChanged); + placeLabeled(lblRotation, controls.edtRotation, 3); + + [lblFill, controls.ddFillMode] = labkit.ui.view.form(cropGrid, 'dropdown', ... + 'Fill:', ... + 'Items', {'Black', 'White'}, ... + 'Value', 'Black', ... + 'ValueChangedFcn', callbacks.onFillModeChanged); + placeLabeled(lblFill, controls.ddFillMode, 4); + + [lblCenterX, controls.edtCenterX] = labkit.ui.view.form(cropGrid, 'spinner', ... + 'Center X:', 'Value', 1, 'Limits', [1 Inf], 'Step', 1, ... + 'Enable', 'off', ... + 'ValueChangedFcn', callbacks.onCenterChanged); + placeLabeled(lblCenterX, controls.edtCenterX, 5); + + [lblCenterY, controls.edtCenterY] = labkit.ui.view.form(cropGrid, 'spinner', ... + 'Center Y:', 'Value', 1, 'Limits', [1 Inf], 'Step', 1, ... + 'Enable', 'off', ... + 'ValueChangedFcn', callbacks.onCenterChanged); + placeLabeled(lblCenterY, controls.edtCenterY, 6); + + controls.btnUseCanvasCenter = uibutton(cropGrid, 'Text', 'Use canvas center', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.onUseCanvasCenter); + controls.btnUseCanvasCenter.Layout.Row = 7; + controls.btnUseCanvasCenter.Layout.Column = [1 2]; + + exportRows = repmat({'fit'}, 1, 4); + exportPanel = labkit.ui.view.section(layFA, 'Export', 3, [4 2], ... + struct('rowHeight', {exportRows}, 'columnWidth', {{145, '1x'}})); + exportGrid = exportPanel.grid; + + [lblFormat, controls.ddFormat] = labkit.ui.view.form(exportGrid, 'dropdown', ... + 'Format:', ... + 'Items', {'PNG', 'TIFF', 'JPEG'}, ... + 'Value', 'PNG', ... + 'ValueChangedFcn', callbacks.onExportSettingChanged); + lblFormat.Layout.Row = 1; + lblFormat.Layout.Column = 1; + controls.ddFormat.Layout.Row = 1; + controls.ddFormat.Layout.Column = 2; + + controls.txtOutputFolder = labkit.ui.view.form(exportGrid, 'readonly', ... + 'Value', char(initialOutputFolder)); + controls.txtOutputFolder.Layout.Row = 2; + controls.txtOutputFolder.Layout.Column = [1 2]; + + controls.btnChooseOutput = uibutton(exportGrid, 'Text', 'Choose export folder', ... + 'ButtonPushedFcn', callbacks.onChooseOutputFolder); + controls.btnChooseOutput.Layout.Row = 3; + controls.btnChooseOutput.Layout.Column = [1 2]; + + controls.btnExport = uibutton(exportGrid, 'Text', 'Export cropped images', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.onExportCrops); + controls.btnExport.Layout.Row = 4; + controls.btnExport.Layout.Column = [1 2]; + + controls.resultTable = uitable(laySR, ... + 'ColumnName', {'Metric', 'Value'}, ... + 'Data', {'Images loaded', '0'}); + controls.resultTable.Layout.Row = 1; + + controls.txtDetails = uitextarea(laySR, 'Editable', 'off'); + labkit.ui.view.place(controls.txtDetails, laySR, 2); + controls.txtDetails.Value = {'Load microscope images to begin.'}; + + logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); + controls.txtLog = logUi.textArea; +end + +function placeLabeled(labelHandle, controlHandle, row) + labelHandle.Layout.Row = row; + labelHandle.Layout.Column = 1; + controlHandle.Layout.Row = row; + controlHandle.Layout.Column = 2; +end diff --git a/apps/image_measurement/batch_crop/private/cropCanvasFixedSize.m b/apps/image_measurement/batch_crop/private/cropCanvasFixedSize.m new file mode 100644 index 0000000..ac0f33b --- /dev/null +++ b/apps/image_measurement/batch_crop/private/cropCanvasFixedSize.m @@ -0,0 +1,51 @@ +% App-owned fixed-size crop helper. Expected caller: batchCropImage. Inputs +% are a rotated canvas, center coordinates in canvas pixels, crop size, and +% scalar fill value. Output preserves class and pads out-of-bounds regions. +function cropped = cropCanvasFixedSize(canvas, centerXY, cropSize, fillValue) +%CROPCANVASFIXEDSIZE Crop a fixed pixel rectangle from a canvas. +% Expected caller: batchCropImage. Inputs are a 2-D or 3-D image canvas, +% centerXY as [x y], cropSize as [width height], and a scalar fill value. +% Output is exactly height-by-width pixels and has no file side effects. + + width = max(1, round(double(cropSize(1)))); + height = max(1, round(double(cropSize(2)))); + centerXY = double(centerXY(:)).'; + fillValue = castFillValue(fillValue, canvas); + + rowStart = round(centerXY(2) - (height - 1) / 2); + colStart = round(centerXY(1) - (width - 1) / 2); + rowEnd = rowStart + height - 1; + colEnd = colStart + width - 1; + + canvasHeight = size(canvas, 1); + canvasWidth = size(canvas, 2); + srcRowStart = max(1, rowStart); + srcRowEnd = min(canvasHeight, rowEnd); + srcColStart = max(1, colStart); + srcColEnd = min(canvasWidth, colEnd); + + if ndims(canvas) == 2 + cropped = repmat(fillValue, height, width); + else + cropped = repmat(fillValue, height, width, size(canvas, 3)); + end + + if srcRowEnd < srcRowStart || srcColEnd < srcColStart + return; + end + + dstRowStart = srcRowStart - rowStart + 1; + dstColStart = srcColStart - colStart + 1; + dstRowEnd = dstRowStart + (srcRowEnd - srcRowStart); + dstColEnd = dstColStart + (srcColEnd - srcColStart); + cropped(dstRowStart:dstRowEnd, dstColStart:dstColEnd, :) = ... + canvas(srcRowStart:srcRowEnd, srcColStart:srcColEnd, :); +end + +function value = castFillValue(fillValue, imageData) + if islogical(imageData) + value = logical(fillValue); + else + value = cast(fillValue, class(imageData)); + end +end diff --git a/apps/image_measurement/batch_crop/private/displayNameFromPath.m b/apps/image_measurement/batch_crop/private/displayNameFromPath.m new file mode 100644 index 0000000..e8d2d8b --- /dev/null +++ b/apps/image_measurement/batch_crop/private/displayNameFromPath.m @@ -0,0 +1,12 @@ +% App-private image measurement display helper. Expected caller: batch-crop +% app callbacks and selected-file normalization. Input is a path value. Output +% is a filename-like display string and has no side effects. +function name = displayNameFromPath(pathValue) +%DISPLAYNAMEFROMPATH Return the app display name for a source image path. + + [~, base, ext] = fileparts(char(pathValue)); + name = [base ext]; + if isempty(name) + name = char(pathValue); + end +end diff --git a/apps/image_measurement/batch_crop/private/emptyBatchCropItem.m b/apps/image_measurement/batch_crop/private/emptyBatchCropItem.m new file mode 100644 index 0000000..e676300 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/emptyBatchCropItem.m @@ -0,0 +1,13 @@ +% App-owned state factory. Expected caller: batch-crop app startup and reset +% callbacks. Output is a scalar item struct for one loaded image and has no +% side effects. +function item = emptyBatchCropItem() +%EMPTYBATCHCROPITEM Return an empty loaded-image crop item. + + item = struct( ... + 'path', "", ... + 'image', [], ... + 'angleDeg', 0, ... + 'centerXY', [NaN, NaN], ... + 'centerSet', false); +end diff --git a/apps/image_measurement/batch_crop/private/emptyBatchCropResult.m b/apps/image_measurement/batch_crop/private/emptyBatchCropResult.m new file mode 100644 index 0000000..59bea05 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/emptyBatchCropResult.m @@ -0,0 +1,23 @@ +% App-owned result factory. Expected caller: batch-crop calculation/export +% helpers. Output is a scalar result struct with stable fields and no side +% effects. +function result = emptyBatchCropResult() +%EMPTYBATCHCROPRESULT Return an empty batch crop result struct. +% Expected caller: batchCropImage and writeBatchCropOutputs. Output fields are +% stable so result arrays can be preallocated before crop/export work. + + result = struct( ... + 'ok', false, ... + 'status', "", ... + 'image', [], ... + 'sourcePath', "", ... + 'outputPath', "", ... + 'rotationDeg', NaN, ... + 'centerX', NaN, ... + 'centerY', NaN, ... + 'cropWidth', NaN, ... + 'cropHeight', NaN, ... + 'canvasWidth', NaN, ... + 'canvasHeight', NaN, ... + 'message', ""); +end diff --git a/apps/image_measurement/batch_crop/private/readBatchCropItems.m b/apps/image_measurement/batch_crop/private/readBatchCropItems.m new file mode 100644 index 0000000..65d8b43 --- /dev/null +++ b/apps/image_measurement/batch_crop/private/readBatchCropItems.m @@ -0,0 +1,16 @@ +% App-owned image loading helper. Expected caller: batch-crop app open-files +% callback. Input is a string vector of image paths. Output is an item struct +% vector with images loaded through imread. +function items = readBatchCropItems(paths) +%READBATCHCROPITEMS Load selected image paths into crop item structs. + + items = repmat(emptyBatchCropItem(), numel(paths), 1); + for k = 1:numel(paths) + img = imread(paths(k)); + items(k).path = string(paths(k)); + items(k).image = img; + items(k).angleDeg = 0; + items(k).centerXY = [NaN, NaN]; + items(k).centerSet = false; + end +end diff --git a/apps/image_measurement/batch_crop/private/rotateImageCanvas.m b/apps/image_measurement/batch_crop/private/rotateImageCanvas.m new file mode 100644 index 0000000..5651a8d --- /dev/null +++ b/apps/image_measurement/batch_crop/private/rotateImageCanvas.m @@ -0,0 +1,86 @@ +% App-owned image rotation helper. Expected caller: batchCropImage. Inputs are +% image data, angle in degrees, and fill value. Output is a loose rotated +% canvas with background filled consistently for grayscale/RGB images. +function [canvas, mask] = rotateImageCanvas(imageData, angleDeg, fillValue) +%ROTATEIMAGECANVAS Rotate an image without resizing its pixel scale. +% Expected caller: batchCropImage. The output canvas may be larger than the +% input when angleDeg is nonzero. The implementation uses base MATLAB +% interpolation so CI does not require Image Processing Toolbox. + + if nargin < 3 + fillValue = 0; + end + + if abs(mod(double(angleDeg), 360)) < 1e-12 + canvas = imageData; + mask = true(size(imageData, 1), size(imageData, 2)); + return; + end + + [xInput, yInput, mask] = looseRotationGrid(size(imageData, 1), ... + size(imageData, 2), angleDeg); + canvas = interpolateImage(imageData, xInput, yInput, mask, fillValue); +end + +function [xInput, yInput, mask] = looseRotationGrid(height, width, angleDeg) + cx = (width + 1) / 2; + cy = (height + 1) / 2; + theta = deg2rad(double(angleDeg)); + c = cos(theta); + s = sin(theta); + + corners = [1, width, width, 1; 1, 1, height, height]; + centeredCorners = corners - [cx; cy]; + rotatedCorners = [c, -s; s, c] * centeredCorners; + minX = floor(min(rotatedCorners(1, :))); + maxX = ceil(max(rotatedCorners(1, :))); + minY = floor(min(rotatedCorners(2, :))); + maxY = ceil(max(rotatedCorners(2, :))); + + [xRot, yRot] = meshgrid(minX:maxX, minY:maxY); + xCentered = c .* xRot + s .* yRot; + yCentered = -s .* xRot + c .* yRot; + xInput = xCentered + cx; + yInput = yCentered + cy; + mask = xInput >= 1 & xInput <= width & yInput >= 1 & yInput <= height; +end + +function canvas = interpolateImage(imageData, xInput, yInput, mask, fillValue) + outHeight = size(xInput, 1); + outWidth = size(xInput, 2); + if ndims(imageData) == 2 + canvas = interpolatePlane(imageData, xInput, yInput, mask, fillValue); + else + canvas = repmat(castFillValue(fillValue, imageData), ... + outHeight, outWidth, size(imageData, 3)); + for channel = 1:size(imageData, 3) + canvas(:, :, channel) = interpolatePlane(imageData(:, :, channel), ... + xInput, yInput, mask, fillValue); + end + end +end + +function plane = interpolatePlane(inputPlane, xInput, yInput, mask, fillValue) + interpolated = interp2(double(inputPlane), xInput, yInput, 'linear', NaN); + interpolated(~mask | isnan(interpolated)) = double(fillValue); + + if islogical(inputPlane) + plane = interpolated >= 0.5; + elseif isinteger(inputPlane) + className = class(inputPlane); + minValue = double(intmin(className)); + maxValue = double(intmax(className)); + interpolated = min(max(round(interpolated), minValue), maxValue); + plane = cast(interpolated, className); + else + plane = cast(interpolated, class(inputPlane)); + end +end + +function value = castFillValue(fillValue, imageData) + if islogical(imageData) + value = logical(fillValue); + else + value = cast(fillValue, class(imageData)); + end +end diff --git a/apps/image_measurement/batch_crop/private/selectedBatchCropImagePaths.m b/apps/image_measurement/batch_crop/private/selectedBatchCropImagePaths.m new file mode 100644 index 0000000..340fded --- /dev/null +++ b/apps/image_measurement/batch_crop/private/selectedBatchCropImagePaths.m @@ -0,0 +1,58 @@ +% App-owned selected-file normalization helper. Expected caller: +% labkit_BatchImageCrop_app and batchImageCropWorkflow. Inputs are raw +% uigetfile values. Output is a sorted string column and has no file effects. +function paths = selectedBatchCropImagePaths(files, folder) +%SELECTEDBATCHCROPIMAGEPATHS Normalize manually selected image paths. +% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. Inputs +% are raw uigetfile file/folder values. Output validates image extensions and +% sorts by display filename. + + if isequal(files, 0) || isequal(folder, 0) + paths = strings(0, 1); + return; + end + + if iscell(files) + names = string(files(:)); + else + names = string(files); + names = names(:); + end + names = names(strlength(names) > 0); + if isempty(names) + error('labkit_BatchImageCrop_app:NoImagesSelected', ... + 'Select at least one image file.'); + end + + folder = string(folder); + paths = strings(numel(names), 1); + for k = 1:numel(names) + paths(k) = string(fullfile(folder, names(k))); + end + paths = sortBatchCropPathsByName(paths); + assertSupportedBatchCropImagePaths(paths); +end + +function paths = sortBatchCropPathsByName(paths) + names = strings(numel(paths), 1); + for k = 1:numel(paths) + names(k) = lower(string(displayNameFromPath(paths(k)))); + end + [~, order] = sort(names); + paths = paths(order); +end + +function assertSupportedBatchCropImagePaths(paths) + extensions = supportedBatchCropImageExtensions(); + for k = 1:numel(paths) + [~, ~, ext] = fileparts(char(paths(k))); + if ~any(strcmpi(ext, extensions)) + error('labkit_BatchImageCrop_app:UnsupportedImageFile', ... + 'Unsupported image file type: %s', char(paths(k))); + end + end +end + +function extensions = supportedBatchCropImageExtensions() + extensions = {'.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'}; +end diff --git a/apps/image_measurement/batch_crop/private/writeBatchCropOutputs.m b/apps/image_measurement/batch_crop/private/writeBatchCropOutputs.m new file mode 100644 index 0000000..fa49bff --- /dev/null +++ b/apps/image_measurement/batch_crop/private/writeBatchCropOutputs.m @@ -0,0 +1,107 @@ +% App-owned batch crop export helper. Expected caller: batch-crop app export +% callback and workflow tests. Inputs are crop items and export options. This +% helper writes cropped images and a CSV manifest. +function payload = writeBatchCropOutputs(items, opts) +%WRITEBATCHCROPOUTPUTS Write cropped images and a manifest CSV. +% Expected caller: labkit_BatchImageCrop_app and batchImageCropWorkflow. Items +% must contain path, image, angleDeg, and centerXY fields. Options contain +% outputFolder, format, cropWidth, cropHeight, and fillMode/fillValue. + + if nargin < 2 + opts = struct(); + end + if isempty(items) + error('labkit_BatchImageCrop_app:NoImagesLoaded', ... + 'Load at least one image before exporting crops.'); + end + + outputFolder = string(optionValue(opts, 'outputFolder', "")); + if strlength(outputFolder) == 0 || ~isfolder(outputFolder) + error('labkit_BatchImageCrop_app:InvalidOutputFolder', ... + 'Select an existing output folder before exporting crops.'); + end + + outputFormat = normalizeOutputFormat(optionValue(opts, 'format', 'PNG')); + results = repmat(emptyBatchCropResult(), numel(items), 1); + reservedPaths = strings(0, 1); + for k = 1:numel(items) + result = emptyBatchCropResult(); + result.sourcePath = string(items(k).path); + try + cropOpts = opts; + cropOpts.angleDeg = items(k).angleDeg; + cropOpts.centerXY = items(k).centerXY; + crop = batchCropImage(items(k).image, cropOpts); + outputPath = uniqueBatchCropOutputPath(outputFolder, ... + string(items(k).path), outputFormat.extension, reservedPaths, "_crop"); + reservedPaths(end+1, 1) = outputPath; %#ok + imwrite(crop.image, char(outputPath)); + + result = crop; + result.image = []; + result.sourcePath = string(items(k).path); + result.outputPath = outputPath; + result.status = "saved"; + result.message = "Saved"; + catch ME + result.status = "failed"; + result.message = string(ME.message); + end + results(k) = result; + end + + manifest = buildBatchCropManifest(results); + manifestPath = uniqueBatchCropOutputPath(outputFolder, ... + "batch_crop_manifest.csv", ".csv", reservedPaths, ""); + writetable(manifest, char(manifestPath)); + + payload = struct(); + payload.results = results; + payload.manifest = manifest; + payload.manifestPath = manifestPath; + payload.outputFolder = outputFolder; +end + +function outputFormat = normalizeOutputFormat(formatValue) + label = upper(char(string(formatValue))); + switch label + case 'PNG' + outputFormat = struct('label', 'PNG', 'extension', ".png"); + case {'TIFF', 'TIF'} + outputFormat = struct('label', 'TIFF', 'extension', ".tif"); + case {'JPEG', 'JPG'} + outputFormat = struct('label', 'JPEG', 'extension', ".jpg"); + otherwise + error('labkit_BatchImageCrop_app:UnsupportedOutputFormat', ... + 'Unsupported crop output format: %s.', char(string(formatValue))); + end +end + +function path = uniqueBatchCropOutputPath(outputFolder, sourcePath, extension, reservedPaths, suffix) + if nargin < 5 + suffix = ""; + end + [~, base, ext] = fileparts(char(sourcePath)); + if strlength(string(extension)) == 0 + extension = string(ext); + end + if isempty(base) + base = 'batch_crop'; + end + base = matlab.lang.makeValidName(base); + candidate = string(fullfile(outputFolder, [base char(suffix) char(extension)])); + index = 1; + while isfile(candidate) || any(reservedPaths == candidate) + candidate = string(fullfile(outputFolder, ... + sprintf('%s%s_%03d%s', base, char(suffix), index, char(extension)))); + index = index + 1; + end + path = candidate; +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end diff --git a/docs/apps.md b/docs/apps.md index 345fa53..8248099 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -25,6 +25,7 @@ This adds the repository root, `apps/`, and nested app category folders to the M | `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_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. | Status labels: @@ -132,4 +133,5 @@ 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_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 8196689..f0776e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,7 @@ labkit_DICPreprocess_app labkit_DICPostprocess_app labkit_CurvatureMeasurement_app labkit_FocusStack_app +labkit_BatchImageCrop_app labkit_ECGPrint_app ``` diff --git a/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m b/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m index 0c410af..eac0ff9 100644 --- a/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m +++ b/tests/gui/structural/apps/image_measurement/GuiLayoutImageMeasurementTest.m @@ -18,6 +18,7 @@ function verify_gui_layout_image_measurement() checkCurvatureMeasurementLayout(h); checkFocusStackLayout(h); + checkBatchImageCropLayout(h); end function checkCurvatureMeasurementLayout(h) @@ -76,6 +77,30 @@ function checkFocusStackLayout(h) h.axesSpec('Focus-depth index map', '', '')}); end +function checkBatchImageCropLayout(h) + fig = h.launchFigure('labkit_BatchImageCrop_app', 'Microscope Batch Image Crop'); + h.assertFigureMinimumSize(fig, 1440, 860); + h.assertComponentCounts(fig, struct('Button', 7, 'DropDown', 2, ... + 'Spinner', 5, 'ListBox', 1, 'Table', 1, 'TextArea', 2, 'Axes', 1)); + h.assertButtonContract(fig, {'Open image files', 'Clear images', ... + 'Previous image', 'Next image', 'Use canvas center', ... + 'Choose export folder', 'Export cropped images'}); + h.assertDropdownGroups(fig, [ ... + h.dropdownGroup({'Black', 'White'}, 1), ... + h.dropdownGroup({'PNG', 'TIFF', 'JPEG'}, 1)]); + h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); + h.assertTableColumns(fig, {'Metric', 'Value'}); + h.assertAxesContract(fig, {h.axesSpec('Rotated preview + fixed crop', '', '')}); + + h.closeAllFigures(); + [fig, debug] = labkit_BatchImageCrop_app("debug", struct()); + drawnow; + assert(debug.enabled && debug.traceEnabled, ... + 'Batch crop debug launch should return an enabled trace logger.'); + assertAnyTextAreaContains(h, fig, 'Batch image crop debug trace enabled', ... + 'Batch crop 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/helpers/appEntryManifest.m b/tests/helpers/appEntryManifest.m index d60497d..e4587be 100644 --- a/tests/helpers/appEntryManifest.m +++ b/tests/helpers/appEntryManifest.m @@ -11,5 +11,6 @@ 'labkit_DICPostprocess_app', 'DIC Strain Postprocess'; ... 'labkit_CurvatureMeasurement_app', 'Image Curvature Measurement'; ... 'labkit_FocusStack_app', 'Microscope Focus Stack Fusion'; ... + '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 30130ef..d65dd47 100644 --- a/tests/helpers/architectureTestHelpers.m +++ b/tests/helpers/architectureTestHelpers.m @@ -219,7 +219,7 @@ function assertAppUsesManagedImageInteractions(source, appName) 'labkit_EIS_app', 'labkit_ChronoOverlay_app', ... 'labkit_DICPreprocess_app', 'labkit_DICPostprocess_app', ... 'labkit_CurvatureMeasurement_app', 'labkit_FocusStack_app', ... - 'labkit_ECGPrint_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 4ad6795..bdc65f5 100644 --- a/tests/integration/project/AppEntrypointBoundariesTest.m +++ b/tests/integration/project/AppEntrypointBoundariesTest.m @@ -80,6 +80,12 @@ function verify_app_entrypoint_boundaries() 'focus_stack_gui('); h.assertImageMeasurementAppBoundary(focusStackSource, 'labkit_FocusStack_app'); + batchCropSource = h.assertAppEntrypoint(root, ... + 'labkit_BatchImageCrop_app', ... + 'launchBatchImageCropApp', ... + 'batch_crop_gui('); + h.assertImageMeasurementAppBoundary(batchCropSource, 'labkit_BatchImageCrop_app'); + ecgPrintSource = h.assertAppEntrypoint(root, ... 'labkit_ECGPrint_app', ... 'launchECGPrintApp', ... diff --git a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m index 1359efa..3caa42a 100644 --- a/tests/integration/project/AppOwnedWorkflowBoundariesTest.m +++ b/tests/integration/project/AppOwnedWorkflowBoundariesTest.m @@ -135,6 +135,11 @@ function verify_app_owned_workflow_boundaries() 'labkit_FocusStack_app', ... 'launchFocusStackApp', ... 'focus_stack_gui('); + batchCropSource = h.assertAppEntrypoint(root, ... + 'labkit_BatchImageCrop_app', ... + 'launchBatchImageCropApp', ... + 'batch_crop_gui('); + 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 8d24a9c..548527e 100644 --- a/tests/integration/project/ProjectStructureGuardrailTest.m +++ b/tests/integration/project/ProjectStructureGuardrailTest.m @@ -178,7 +178,8 @@ function assertAppFamilyBoundary(h, source, appName) h.assertDTAFacadeUsage(source, appName, 'chrono', true); elseif contains(appName, 'DIC') h.assertDICAppBoundary(source, appName); - elseif contains(appName, 'CurvatureMeasurement') || contains(appName, 'FocusStack') + elseif contains(appName, 'CurvatureMeasurement') || contains(appName, 'FocusStack') || ... + contains(appName, 'BatchImageCrop') h.assertImageMeasurementAppBoundary(source, appName); elseif contains(appName, 'ECGPrint') h.assertWearableAppBoundary(source, appName); @@ -214,6 +215,9 @@ function assertAppFamilyBoundary(h, source, appName) case 'labkit_FocusStack_app' legacy = struct('launchName', 'launchFocusStackApp', ... 'legacyCall', 'focus_stack_gui('); + case 'labkit_BatchImageCrop_app' + legacy = struct('launchName', 'launchBatchImageCropApp', ... + 'legacyCall', 'batch_crop_gui('); case 'labkit_ECGPrint_app' legacy = struct('launchName', 'launchECGPrintApp', ... 'legacyCall', 'wearable_ecg_print_gui('); diff --git a/tests/integration/project/StartupBoundariesTest.m b/tests/integration/project/StartupBoundariesTest.m index 461d130..034f01f 100644 --- a/tests/integration/project/StartupBoundariesTest.m +++ b/tests/integration/project/StartupBoundariesTest.m @@ -28,10 +28,14 @@ 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', 'batch_crop')), ... + 'startup_labkit should add nested image measurement app folders to the path.'); assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'curvature', 'private')), ... 'startup_labkit should not expose app-private helper folders as public path entries.'); assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'focus_stack', 'private')), ... 'startup_labkit should not expose app-private helper folders as public path entries.'); + assert(~pathContains(fullfile(root, 'apps', 'image_measurement', 'batch_crop', 'private')), ... + 'startup_labkit should not expose app-private helper folders as public path entries.'); assert(pathContains(fullfile(root, 'apps', 'wearable')), ... 'startup_labkit should add wearable app category folders to the path.'); diff --git a/tests/unit/apps/image_measurement/BatchImageCropTest.m b/tests/unit/apps/image_measurement/BatchImageCropTest.m new file mode 100644 index 0000000..750a006 --- /dev/null +++ b/tests/unit/apps/image_measurement/BatchImageCropTest.m @@ -0,0 +1,164 @@ +classdef BatchImageCropTest < matlab.unittest.TestCase + %BATCHIMAGECROPTEST Verify LabKit behavior through official MATLAB tests. + + methods (Test, TestTags = {'Unit'}) + function test_batchImageCrop(testCase) + setupLabKitTestPath(); + verify_batchImageCrop(); + end + end +end + +function verify_batchImageCrop() +%TEST_BATCHIMAGECROP Verify batch microscope crop calculations and exports. + + checkFixedPixelCropPreservesClassAndSize(); + checkOutOfBoundsCropPadsWithFill(); + checkRotatedCropKeepsRequestedSize(); + checkSelectedFileNormalization(); + checkManifestContract(); + checkExportWritesUniqueOutputs(); +end + +function checkFixedPixelCropPreservesClassAndSize() + img = uint8(reshape(1:100, 10, 10)); + result = batchImageCropWorkflow("cropImage", img, struct( ... + 'cropWidth', 4, ... + 'cropHeight', 3, ... + 'centerXY', [5, 6], ... + 'angleDeg', 0, ... + 'fillMode', 'Black')); + + assert(result.ok, 'Crop should succeed.'); + assert(isa(result.image, 'uint8'), 'Crop should preserve image class.'); + assert(isequal(size(result.image), [3 4]), ... + 'Crop output size should be exactly height-by-width pixels.'); + assert(result.cropWidth == 4 && result.cropHeight == 3, ... + 'Crop metadata should preserve requested size.'); +end + +function checkOutOfBoundsCropPadsWithFill() + img = uint8(10 * ones(5, 5)); + result = batchImageCropWorkflow("cropImage", img, struct( ... + 'cropWidth', 4, ... + 'cropHeight', 4, ... + 'centerXY', [1, 1], ... + 'angleDeg', 0, ... + 'fillMode', 'White')); + + assert(isequal(size(result.image), [4 4]), ... + 'Out-of-bounds crops should still use the requested output size.'); + assert(result.image(1, 1) == intmax('uint8'), ... + 'Out-of-bounds crop area should use the selected white fill.'); + assert(result.image(end, end) == 10, ... + 'In-bounds crop area should preserve source pixels.'); +end + +function checkRotatedCropKeepsRequestedSize() + img = uint8(zeros(8, 12, 3)); + img(:, 4:8, 1) = 200; + result = batchImageCropWorkflow("cropImage", img, struct( ... + 'cropWidth', 6, ... + 'cropHeight', 5, ... + 'angleDeg', 35, ... + 'fillMode', 'Black')); + + assert(isequal(size(result.image), [5 6 3]), ... + 'Rotated crop output size should remain fixed.'); + assert(result.canvasWidth > size(img, 2) || result.canvasHeight > size(img, 1), ... + 'Loose rotation should report the expanded rotated canvas size.'); +end + +function checkSelectedFileNormalization() + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); %#ok + + paths = batchImageCropWorkflow( ... + "selectedBatchCropImagePaths", {'frame_b.png', 'frame_a.tif'}, folder); + names = fileNames(paths); + assert(isequal(names, {'frame_a.tif'; 'frame_b.png'}), ... + 'Selected batch crop images should be sorted by filename.'); + + assertThrows(@() batchImageCropWorkflow( ... + "selectedBatchCropImagePaths", 'notes.txt', folder), ... + 'labkit_BatchImageCrop_app:UnsupportedImageFile', ... + 'Manual selection should reject unsupported file types.'); +end + +function checkManifestContract() + result = batchImageCropWorkflow("cropImage", uint8(ones(5, 6)), struct( ... + 'cropWidth', 3, ... + 'cropHeight', 4, ... + 'centerXY', [3, 3], ... + 'angleDeg', 0)); + result.sourcePath = "source.png"; + result.outputPath = "source_crop.png"; + result.status = "saved"; + result.message = "Saved"; + + T = batchImageCropWorkflow("buildBatchCropManifest", result); + assert(isequal(T.Properties.VariableNames, expectedManifestColumns()), ... + 'Batch crop manifest columns changed.'); + assert(height(T) == 1, 'Manifest should include one row per crop result.'); + assert(T.CropWidth_px(1) == 3 && T.CropHeight_px(1) == 4, ... + 'Manifest should preserve fixed crop size metadata.'); +end + +function checkExportWritesUniqueOutputs() + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); %#ok + imwrite(uint8(zeros(4, 4)), fullfile(folder, 'sample_crop.png')); + + item = struct( ... + 'path', string(fullfile(folder, 'sample.png')), ... + 'image', uint8(20 * ones(6, 6)), ... + 'angleDeg', 0, ... + 'centerXY', [3, 3], ... + 'centerSet', true); + + payload = batchImageCropWorkflow("writeBatchCropOutputs", item, struct( ... + 'outputFolder', string(folder), ... + 'format', 'PNG', ... + 'cropWidth', 4, ... + 'cropHeight', 4, ... + 'fillMode', 'Black')); + + outputPath = payload.results(1).outputPath; + assert(endsWith(outputPath, "sample_crop_001.png"), ... + 'Batch export should avoid overwriting existing crop outputs.'); + assert(isfile(outputPath), 'Batch export should write cropped image output.'); + assert(isfile(payload.manifestPath), 'Batch export should write a manifest CSV.'); +end + +function cols = expectedManifestColumns() + cols = {'SourceImage', 'OutputImage', 'Status', 'RotationDeg', ... + 'CenterX_px', 'CenterY_px', 'CropWidth_px', 'CropHeight_px', ... + 'CanvasWidth_px', 'CanvasHeight_px', 'Message'}; +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 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