From 08518e911c0702855aa9761620c1fc3335854602 Mon Sep 17 00:00:00 2001 From: Pluze Zhu Date: Thu, 4 Jun 2026 14:11:56 -0500 Subject: [PATCH 1/2] feat: add microscope focus stack app --- .gitignore | 2 + README.md | 4 +- .../image_measurement/labkit_FocusStack_app.m | 1080 +++++++++++++++++ docs/apps.md | 4 +- docs/architecture.md | 1 + tests/helpers/appEntryManifest.m | 1 + tests/helpers/architectureTestHelpers.m | 3 +- .../image_measurement/test_focusStackFusion.m | 170 +++ .../test_gui_layout_image_measurement.m | 20 + .../project/test_app_entrypoint_boundaries.m | 6 + .../test_app_owned_workflow_boundaries.m | 11 + 11 files changed, 1299 insertions(+), 3 deletions(-) create mode 100644 apps/image_measurement/labkit_FocusStack_app.m create mode 100644 tests/suites/apps/image_measurement/test_focusStackFusion.m diff --git a/.gitignore b/.gitignore index 707368d..86ac49d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store matlab_test.log +matlab_test*.log +photos/ diff --git a/README.md b/README.md index a398cdf..77cf2a6 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 and curvature/radius measurement from image data | +| Image measurement | Interactive curve tracing, curvature/radius measurement, and microscope focus stacking | | Wearable biosignals | ECG preview, filtering, peak detection, segments, templates, and SNR-style measurements | | Reusable foundation | MATLAB UI helpers plus DTA and biosignal facades for app-facing workflows | | Validation | Focused MATLAB suites, architecture guardrails, synthetic fixtures, and GitHub Actions CI | @@ -47,6 +47,7 @@ labkit_DICPostprocess_app % Image measurement labkit_CurvatureMeasurement_app +labkit_FocusStack_app % Wearable biosignal labkit_ECGPrint_app @@ -66,6 +67,7 @@ Then use the app window to load files, inspect plots or results, and export outp | `labkit_DICPreprocess_app` | active | Image registration, paired crop preparation, and ROI mask drawing | Reference/current images | Aligned images, crop PNGs, ROI mask | | `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 for radius and curvature | Image | Overlay PNG and curvature CSV | +| `labkit_FocusStack_app` | experimental | Microscope focus-stack fusion into one all-in-focus image | Focus image folder | Fused PNG, focus map PNG, summary 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/labkit_FocusStack_app.m b/apps/image_measurement/labkit_FocusStack_app.m new file mode 100644 index 0000000..032641d --- /dev/null +++ b/apps/image_measurement/labkit_FocusStack_app.m @@ -0,0 +1,1080 @@ +function varargout = labkit_FocusStack_app(varargin) +%LABKIT_FOCUSSTACK_APP Fuse a focus image stack into one all-in-focus image. + + [requestHandled, requestOutputs, debugLog] = labkit.ui.handleAppRequest( ... + 'labkit_FocusStack_app', varargin, nargout, focusStackAppTestHandlers()); + if requestHandled + varargout = requestOutputs; + return; + end + if debugLog.enabled + if nargout > 2 + error('labkit_FocusStack_app:TooManyOutputs', ... + 'labkit_FocusStack_app debug mode returns at most the app figure and debug log.'); + end + elseif nargout > 1 + error('labkit_FocusStack_app:TooManyOutputs', ... + 'labkit_FocusStack_app returns at most the app figure handle.'); + end + + S = struct(); + S.folder = ""; + S.paths = strings(0, 1); + S.images = {}; + S.alignedImages = {}; + S.registrationLines = {}; + S.result = emptyFocusStackResult(); + + workbenchOpts = struct('rightKind', 'dualPlot', ... + 'rightTitle', 'Focus Stack Preview', ... + 'topPlotTitle', 'Fused all-in-focus image', ... + 'bottomPlotTitle', 'Focus-depth index map', ... + 'showPlotControls', false); + workbenchOpts.tabs = [ ... + labkit.ui.tabSpec('filesAnalysis', 'Files + Analysis', [4 1], ... + {250, 235, 185, 170}, ... + struct('resizeRows', [1 2 3], ... + 'resizeOptions', struct('minTopHeight', 130, 'minBottomHeight', 90))), ... + labkit.ui.tabSpec('summaryResults', 'Summary + Results', [2 1], ... + {220, '1x'}, ... + struct('resizeRows', 1)), ... + labkit.ui.tabSpec('log', 'Log', [1 1], {'1x'})]; + + ui = labkit.ui.createWorkbench( ... + 'Microscope Focus Stack Fusion', [80 60 1440 860], 390, workbenchOpts); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + + filePanel = labkit.ui.createPanelGrid(layFA, 'Images', 1, [4 1], ... + struct('rowHeight', {{'fit', 'fit', 105, 'fit'}}, ... + 'columnWidth', {{'1x'}})); + fileGrid = filePanel.grid; + + btnOpenFolder = uibutton(fileGrid, 'Text', 'Open image folder', ... + 'ButtonPushedFcn', @onOpenFolder); + btnOpenFolder.Layout.Row = 1; + btnOpenFolder.Layout.Column = 1; + + txtFolder = labkit.ui.createReadOnlyTextField(fileGrid, ... + 'Value', 'No folder loaded'); + txtFolder.Layout.Row = 2; + txtFolder.Layout.Column = [1 2]; + + lbImages = uilistbox(fileGrid, 'Items', {'No images loaded'}); + lbImages.Layout.Row = 3; + lbImages.Layout.Column = [1 2]; + + txtStackStatus = labkit.ui.createReadOnlyTextField(fileGrid, ... + 'Value', 'Images: 0'); + txtStackStatus.Layout.Row = 4; + txtStackStatus.Layout.Column = [1 2]; + + analysisPanel = labkit.ui.createPanelGrid(layFA, 'Fusion Options', 2, [5 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{155, '1x'}})); + analysisGrid = analysisPanel.grid; + + chkRegister = uicheckbox(analysisGrid, ... + 'Text', 'Auto-register stack to middle image', ... + 'Value', false); + chkRegister.Layout.Row = 1; + chkRegister.Layout.Column = [1 2]; + + [lblFocusWindow, edtFocusWindow] = labkit.ui.createLabeledSpinner(analysisGrid, ... + 'Focus window (px):', 'Value', 31, 'Limits', [3 99], 'Step', 2); + lblFocusWindow.Layout.Row = 2; + lblFocusWindow.Layout.Column = 1; + edtFocusWindow.Layout.Row = 2; + edtFocusWindow.Layout.Column = 2; + + [lblSmoothRadius, edtSmoothRadius] = labkit.ui.createLabeledSpinner(analysisGrid, ... + 'Mask smoothing (px):', 'Value', 4, 'Limits', [0 50], 'Step', 1); + lblSmoothRadius.Layout.Row = 3; + lblSmoothRadius.Layout.Column = 1; + edtSmoothRadius.Layout.Row = 3; + edtSmoothRadius.Layout.Column = 2; + + [lblMinConfidence, edtMinConfidence] = labkit.ui.createLabeledSpinner(analysisGrid, ... + 'Min confidence:', 'Value', 0.05, 'Limits', [0 1], 'Step', 0.05); + lblMinConfidence.Layout.Row = 4; + lblMinConfidence.Layout.Column = 1; + edtMinConfidence.Layout.Row = 4; + edtMinConfidence.Layout.Column = 2; + + btnRun = uibutton(analysisGrid, 'Text', 'Run focus stack', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onRunFocusStack); + btnRun.Layout.Row = 5; + btnRun.Layout.Column = [1 2]; + + exportPanel = labkit.ui.createPanelGrid(layFA, 'Export', 3, [3 1], ... + struct('rowHeight', {{'fit', 'fit', 'fit'}})); + exportGrid = exportPanel.grid; + + btnExportFused = uibutton(exportGrid, 'Text', 'Export fused PNG', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onExportFused); + btnExportFused.Layout.Row = 1; + btnExportMap = uibutton(exportGrid, 'Text', 'Export focus map PNG', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onExportFocusMap); + btnExportMap.Layout.Row = 2; + btnExportSummary = uibutton(exportGrid, 'Text', 'Export summary CSV', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onExportSummary); + btnExportSummary.Layout.Row = 3; + + labkit.ui.createReadOnlyTextPanel(layFA, 'Workflow Notes', 4, { ... + '1. Load a folder containing one focus sequence for the same microscope field of view.', ... + '2. Run focus stack to fuse sharp details across the image pyramid.', ... + '3. Inspect the fused image and focus-depth map before exporting.', ... + '4. Enable registration only when the stack has whole-image drift and no strong focus breathing.'}); + + resultTable = uitable(laySR, ... + 'ColumnName', {'Metric', 'Value'}, ... + 'Data', initialResultTable()); + resultTable.Layout.Row = 1; + + txtDetails = uitextarea(laySR, 'Editable', 'off'); + txtDetails.Layout.Row = labkit.ui.layoutRow(laySR, 2); + txtDetails.Value = {'Load a focus image folder to begin.'}; + + logUi = labkit.ui.createLogPanel(layLog, 1, {'Ready.'}); + txtLog = logUi.textArea; + + resetPreviewAxes(); + refreshSummary(); + + if nargout >= 1 + varargout{1} = fig; + end + if nargout >= 2 + varargout{2} = debugLog; + end + + function onOpenFolder(~, ~) + folder = uigetdir(pwd, 'Select focus image folder'); + if isequal(folder, 0) + addLog('Image folder selection cancelled.'); + return; + end + loadImageFolder(string(folder)); + end + + function loadImageFolder(folder) + try + paths = findFocusStackImages(folder); + images = readFocusStackImages(paths); + catch ME + showError('Could not load focus stack', ME.message); + return; + end + + S.folder = folder; + S.paths = paths; + S.images = images; + S.alignedImages = {}; + S.registrationLines = {}; + S.result = emptyFocusStackResult(); + + txtFolder.Value = char(folder); + lbImages.Items = displayImageNames(paths); + if ~isempty(lbImages.Items) + lbImages.Value = lbImages.Items{1}; + end + addLog(sprintf('Loaded %d image(s) from folder.', numel(S.images))); + refreshPreview(); + refreshSummary(); + end + + function onRunFocusStack(~, ~) + if numel(S.images) < 2 + showError('Not enough images', 'Load at least two images before running focus stacking.'); + return; + end + + opts = currentFusionOptions(); + try + imagesForFusion = S.images; + S.registrationLines = {}; + if chkRegister.Value + [imagesForFusion, S.registrationLines] = alignFocusStackImages(S.images); + end + S.alignedImages = imagesForFusion; + S.result = computeFocusStack(imagesForFusion, opts); + catch ME + showError('Focus stacking failed', ME.message); + return; + end + + addLog(sprintf('Focus stack complete: %d images fused with %s.', ... + S.result.inputCount, S.result.method)); + for k = 1:numel(S.registrationLines) + addLog(S.registrationLines{k}); + end + refreshPreview(); + refreshSummary(); + end + + function opts = currentFusionOptions() + opts = struct(); + opts.focusWindow = round(edtFocusWindow.Value); + opts.smoothRadius = round(edtSmoothRadius.Value); + opts.minConfidence = edtMinConfidence.Value; + end + + function onExportFused(~, ~) + if ~S.result.ok + showError('No fused image', 'Run focus stack before exporting the fused PNG.'); + return; + end + filepath = chooseSavePath('Export fused PNG', 'focus_stack_fused.png'); + if filepath == "" + addLog('Export fused PNG cancelled.'); + return; + end + try + imwrite(S.result.fused, filepath); + catch ME + showError('Could not export fused PNG', ME.message); + return; + end + addLog(sprintf('Exported fused PNG: %s', filepath)); + end + + function onExportFocusMap(~, ~) + if ~S.result.ok + showError('No focus map', 'Run focus stack before exporting the focus map PNG.'); + return; + end + filepath = chooseSavePath('Export focus map PNG', 'focus_stack_map.png'); + if filepath == "" + addLog('Export focus map PNG cancelled.'); + return; + end + try + imwrite(focusIndexRgb(S.result.focusIndex, S.result.inputCount), filepath); + catch ME + showError('Could not export focus map PNG', ME.message); + return; + end + addLog(sprintf('Exported focus map PNG: %s', filepath)); + end + + function onExportSummary(~, ~) + if ~S.result.ok + showError('No summary', 'Run focus stack before exporting the summary CSV.'); + return; + end + filepath = chooseSavePath('Export summary CSV', 'focus_stack_summary.csv'); + if filepath == "" + addLog('Export summary CSV cancelled.'); + return; + end + try + T = buildFocusStackSummaryTable(S.result, S.paths); + writetable(T, filepath); + catch ME + showError('Could not export summary CSV', ME.message); + return; + end + addLog(sprintf('Exported summary CSV: %s', filepath)); + end + + function filepath = chooseSavePath(titleText, defaultName) + defaultPath = fullfile(defaultSaveFolder(), defaultName); + [fn, fp] = uiputfile({'*.png;*.csv', 'Export files'}, titleText, defaultPath); + if isequal(fn, 0) + filepath = ""; + else + filepath = string(fullfile(fp, fn)); + end + end + + function folder = defaultSaveFolder() + folder = char(S.folder); + if isempty(folder) || exist(folder, 'dir') ~= 7 + folder = pwd; + end + end + + function refreshPreview() + if S.result.ok + labkit.ui.showImageAxes(ui.topAxes, S.result.fused, ... + 'Fused all-in-focus image'); + labkit.ui.showImageAxes(ui.bottomAxes, ... + focusIndexRgb(S.result.focusIndex, S.result.inputCount), ... + 'Focus-depth index map'); + elseif ~isempty(S.images) + labkit.ui.showImageAxes(ui.topAxes, previewImage(S.images{1}), ... + 'First source image'); + labkit.ui.hardResetAxis(ui.bottomAxes, 'Focus-depth index map', true); + else + resetPreviewAxes(); + end + updateControls(); + end + + function refreshSummary() + txtStackStatus.Value = sprintf('Images: %d', numel(S.images)); + if S.result.ok + resultTable.Data = focusStackResultTableData(S.result); + txtDetails.Value = focusStackDetails(S.result, S.paths, S.registrationLines); + elseif numel(S.images) >= 2 + resultTable.Data = initialResultTable(); + txtDetails.Value = { ... + sprintf('Loaded images: %d', numel(S.images)), ... + 'Run focus stack to compute the fused image and focus-depth map.'}; + else + resultTable.Data = initialResultTable(); + txtDetails.Value = {'Load a focus image folder to begin.'}; + end + updateControls(); + end + + function updateControls() + hasStack = numel(S.images) >= 2; + hasResult = S.result.ok; + btnRun.Enable = ternary(hasStack, 'on', 'off'); + btnExportFused.Enable = ternary(hasResult, 'on', 'off'); + btnExportMap.Enable = ternary(hasResult, 'on', 'off'); + btnExportSummary.Enable = ternary(hasResult, 'on', 'off'); + end + + function resetPreviewAxes() + labkit.ui.hardResetAxis(ui.topAxes, 'Fused all-in-focus image', true); + labkit.ui.hardResetAxis(ui.bottomAxes, 'Focus-depth index map', true); + end + + function addLog(message) + labkit.ui.appendLog(txtLog, message); + debugLog.append(message); + end + + function showError(titleText, message) + addLog(sprintf('%s: %s', titleText, message)); + uialert(fig, message, titleText); + end +end + +function handlers = focusStackAppTestHandlers() + handlers = struct( ... + 'command', {'computeFocusStack', 'buildFocusStackSummaryTable', 'findFocusStackImages', 'alignFocusStackImages'}, ... + 'minArgs', {2, 2, 1, 1}, ... + 'maxArgs', {2, 2, 1, 1}, ... + 'maxOutputs', {1, 1, 1, 2}, ... + 'run', {@runComputeFocusStack, @runBuildFocusStackSummaryTable, @runFindFocusStackImages, @runAlignFocusStackImages}); +end + +function outputs = runComputeFocusStack(args) + outputs = {computeFocusStack(args{1}, args{2})}; +end + +function outputs = runBuildFocusStackSummaryTable(args) + outputs = {buildFocusStackSummaryTable(args{1}, string(args{2}))}; +end + +function outputs = runFindFocusStackImages(args) + outputs = {findFocusStackImages(string(args{1}))}; +end + +function outputs = runAlignFocusStackImages(args) + [alignedImages, lines] = alignFocusStackImages(args{1}); + outputs = {alignedImages, lines}; +end + +function paths = findFocusStackImages(folder) + if strlength(string(folder)) == 0 || exist(folder, 'dir') ~= 7 + error('labkit_FocusStack_app:FolderNotFound', ... + 'Focus image folder does not exist.'); + end + + allowedExt = {'.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'}; + entries = dir(folder); + keep = false(numel(entries), 1); + names = cell(numel(entries), 1); + for k = 1:numel(entries) + entry = entries(k); + names{k} = entry.name; + if entry.isdir + continue; + end + [~, ~, ext] = fileparts(entry.name); + keep(k) = any(strcmpi(ext, allowedExt)); + end + + entries = entries(keep); + names = names(keep); + [~, order] = sort(lower(names)); + entries = entries(order); + + paths = strings(numel(entries), 1); + for k = 1:numel(entries) + paths(k) = string(fullfile(folder, entries(k).name)); + end + if numel(paths) < 2 + error('labkit_FocusStack_app:NotEnoughImages', ... + 'Focus stacking requires at least two image files in the selected folder.'); + end +end + +function images = readFocusStackImages(paths) + paths = string(paths(:)); + if numel(paths) < 2 + error('labkit_FocusStack_app:NotEnoughImages', ... + 'Focus stacking requires at least two image files.'); + end + + images = cell(numel(paths), 1); + for k = 1:numel(paths) + if exist(paths(k), 'file') ~= 2 + error('labkit_FocusStack_app:ImageFileNotFound', ... + 'Image file does not exist: %s', char(paths(k))); + end + images{k} = imread(paths(k)); + end +end + +function [alignedImages, lines] = alignFocusStackImages(images) + images = normalizeImageCell(images); + alignedImages = images; + lines = {}; + if numel(images) < 2 + return; + end + + referenceIndex = round((numel(images) + 1) / 2); + reference = images{referenceIndex}; + lines{end+1} = sprintf('Registration reference image: %d.', referenceIndex); %#ok + for k = 1:numel(images) + if k == referenceIndex + continue; + end + try + [alignedImages{k}, method] = alignImageToReference(reference, images{k}); + lines{end+1} = sprintf('Registered image %d using %s.', k, method); %#ok + catch ME + alignedImages{k} = resizeImageToReference(images{k}, size(reference)); + lines{end+1} = sprintf('Image %d registration skipped: %s', k, ME.message); %#ok + end + end +end + +function [alignedImage, method] = alignImageToReference(referenceImage, movingImage) + origClass = class(movingImage); + movingImage = resizeImageToReference(movingImage, size(referenceImage)); + fixedGray = alignmentGray(referenceImage); + movingGray = alignmentGray(movingImage); + + try + tform = imregcorr(movingGray, fixedGray, 'similarity'); + method = 'phase-correlation similarity registration'; + catch similarityErr + try + tform = imregcorr(movingGray, fixedGray, 'rigid'); + method = 'phase-correlation rigid registration'; + catch rigidErr + try + tform = imregcorr(movingGray, fixedGray, 'translation'); + method = 'phase-correlation translation registration'; + catch translationErr + error('labkit_FocusStack_app:RegistrationFailed', ... + 'Similarity failed: %s Rigid failed: %s Translation failed: %s', ... + similarityErr.message, rigidErr.message, translationErr.message); + end + end + end + + fixedRef = imref2d(size(fixedGray)); + alignedImage = imwarp(movingImage, tform, ... + 'OutputView', fixedRef, 'FillValues', backgroundFillValues(movingImage)); + alignedImage = cast(alignedImage, origClass); +end + +function gray = alignmentGray(imageData) + gray = normalizeGray(imageData); + lowpass = boxMean2(gray, 31); + gray = gray - lowpass; + mx = max(abs(gray(:))); + if mx > 0 + gray = gray ./ mx; + end +end + +function fillValues = backgroundFillValues(imageData) + if ndims(imageData) == 2 + border = [imageData(1, :), imageData(end, :), imageData(:, 1).', imageData(:, end).']; + fillValues = median(double(border(:))); + return; + end + + fillValues = zeros(1, size(imageData, 3)); + for c = 1:size(imageData, 3) + channel = imageData(:, :, c); + border = [channel(1, :), channel(end, :), channel(:, 1).', channel(:, end).']; + fillValues(c) = median(double(border(:))); + end +end + +function result = computeFocusStack(images, opts) + if nargin < 2 + opts = struct(); + end + images = normalizeImageCell(images); + if numel(images) < 2 + error('labkit_FocusStack_app:NotEnoughImages', ... + 'Focus stacking requires at least two images.'); + end + + focusWindow = oddWindow(optionValue(opts, 'focusWindow', 31), 3); + smoothRadius = max(0, round(optionValue(opts, 'smoothRadius', 4))); + minConfidence = optionValue(opts, 'minConfidence', 0.05); + validateattributes(minConfidence, {'numeric'}, ... + {'scalar', 'finite', 'nonnegative', '<=', 1}); + + [stack, resizedCount] = stackImagesAsDouble(images); + [heightPx, widthPx, channels, imageCount] = size(stack); + pyramidLevels = max(1, min( ... + max(1, round(optionValue(opts, 'pyramidLevels', 4))), ... + maximumPyramidLevels([heightPx widthPx]))); + + [fused, focusIndex, confidence] = laplacianPyramidFocusFusion( ... + stack, focusWindow, smoothRadius, minConfidence, pyramidLevels); + if channels == 1 + fused = fused(:, :, 1); + end + + coverage = zeros(1, imageCount); + for k = 1:imageCount + coverage(k) = mean(focusIndex(:) == k); + end + + result = emptyFocusStackResult(); + result.ok = true; + result.message = ''; + result.fused = fused; + result.focusIndex = uint16(focusIndex); + result.confidence = confidence; + result.focusCoverage = coverage; + result.inputCount = imageCount; + result.imageHeight = heightPx; + result.imageWidth = widthPx; + result.channelCount = channels; + result.focusWindow = focusWindow; + result.smoothRadius = smoothRadius; + result.minConfidence = minConfidence; + result.meanConfidence = mean(confidence(:)); + result.method = 'Laplacian pyramid focus fusion'; + result.resizedCount = resizedCount; + result.pyramidLevels = pyramidLevels; +end + +function [fused, focusIndex, confidence] = laplacianPyramidFocusFusion(stack, focusWindow, smoothRadius, minConfidence, pyramidLevels) + [heightPx, widthPx, channels, imageCount] = size(stack); + gaussPyramids = cell(imageCount, 1); + lapPyramids = cell(imageCount, 1); + for k = 1:imageCount + [gaussPyramids{k}, lapPyramids{k}] = buildLaplacianPyramid( ... + stack(:, :, :, k), pyramidLevels); + end + + fusedLap = cell(pyramidLevels, 1); + focusIndex = ones(heightPx, widthPx); + confidence = zeros(heightPx, widthPx); + for level = 1:pyramidLevels + levelScores = focusScoreStack(lapPyramids, level, focusWindow); + [levelIndex, bestScore, secondScore] = bestFocusIndex(levelScores); + levelConfidence = focusConfidence(bestScore, secondScore); + if level == 1 + focusIndex = levelIndex; + confidence = levelConfidence; + end + + levelSmooth = max(0, round(smoothRadius / (2 ^ (level - 1)))); + levelWeights = focusWeightsFromScores(levelScores, levelIndex, ... + levelConfidence, minConfidence, levelSmooth); + fusedLap{level} = weightedPyramidLevel(lapPyramids, level, levelWeights, channels); + end + + baseScores = baseFocusScoreStack(gaussPyramids, pyramidLevels + 1); + [baseIndex, baseBest, baseSecond] = bestFocusIndex(baseScores); + baseConfidence = focusConfidence(baseBest, baseSecond); + baseSmooth = max(1, round(smoothRadius / (2 ^ pyramidLevels))); + baseWeights = focusWeightsFromScores(baseScores, baseIndex, ... + baseConfidence, minConfidence, baseSmooth); + fused = weightedPyramidLevel(gaussPyramids, pyramidLevels + 1, baseWeights, channels); + + for level = pyramidLevels:-1:1 + fused = resizeImageToSize(fused, size(fusedLap{level})) + fusedLap{level}; + end + fused = min(max(fused, 0), 1); +end + +function [gaussPyramid, lapPyramid] = buildLaplacianPyramid(imageData, levels) + gaussPyramid = cell(levels + 1, 1); + lapPyramid = cell(levels, 1); + gaussPyramid{1} = imageData; + for level = 1:levels + blurred = gaussianBlurImage(gaussPyramid{level}, 1); + gaussPyramid{level + 1} = imresize(blurred, 0.5, 'bilinear'); + expanded = resizeImageToSize(gaussPyramid{level + 1}, size(gaussPyramid{level})); + lapPyramid{level} = gaussPyramid{level} - expanded; + end +end + +function scoreStack = focusScoreStack(pyramids, level, focusWindow) + imageCount = numel(pyramids); + sample = pyramids{1}{level}; + scoreStack = zeros(size(sample, 1), size(sample, 2), imageCount); + levelWindow = oddWindow(max(3, round(focusWindow / (2 ^ (level - 1)))), 3); + for k = 1:imageCount + scoreStack(:, :, k) = focusDetailEnergy(pyramids{k}{level}, levelWindow); + end +end + +function score = focusDetailEnergy(detailImage, focusWindow) + gray = grayImage(detailImage); + score = boxMean2(gray .^ 2, focusWindow); + score(~isfinite(score)) = 0; + score = max(score, 0); +end + +function scoreStack = baseFocusScoreStack(pyramids, level) + imageCount = numel(pyramids); + sample = pyramids{1}{level}; + scoreStack = zeros(size(sample, 1), size(sample, 2), imageCount); + for k = 1:imageCount + scoreStack(:, :, k) = localVarianceScore(normalizeGray(pyramids{k}{level}), 5); + end +end + +function score = localVarianceScore(gray, windowSize) + meanValue = boxMean2(gray, windowSize); + score = boxMean2(gray .^ 2, windowSize) - meanValue .^ 2; + score(~isfinite(score)) = 0; + score = max(score, 0); +end + +function weights = focusWeightsFromScores(scoreStack, focusIndex, confidence, minConfidence, smoothRadius) + [heightPx, widthPx, imageCount] = size(scoreStack); + weights = zeros(heightPx, widthPx, imageCount); + lowConfidence = confidence < minConfidence; + scoreSum = sum(scoreStack, 3); + zeroScore = scoreSum <= eps; + + for k = 1:imageCount + w = double(focusIndex == k); + if any(lowConfidence(:)) + scoreWeight = scoreStack(:, :, k) ./ max(scoreSum, eps); + w(lowConfidence) = scoreWeight(lowConfidence); + w(lowConfidence & zeroScore) = 1 / imageCount; + end + if smoothRadius > 0 + w = boxMean2(w, 2 * smoothRadius + 1); + end + weights(:, :, k) = w; + end + + weightSum = sum(weights, 3); + zeroWeight = weightSum <= eps; + for k = 1:imageCount + w = weights(:, :, k) ./ max(weightSum, eps); + w(zeroWeight) = 1 / imageCount; + weights(:, :, k) = w; + end +end + +function fusedLevel = weightedPyramidLevel(pyramids, level, weights, channels) + sample = pyramids{1}{level}; + fusedLevel = zeros(size(sample, 1), size(sample, 2), channels); + for k = 1:numel(pyramids) + img = pyramids{k}{level}; + w = weights(:, :, k); + for c = 1:channels + fusedLevel(:, :, c) = fusedLevel(:, :, c) + img(:, :, c) .* w; + end + end +end + +function confidence = focusConfidence(bestScore, secondScore) + confidence = (bestScore - secondScore) ./ max(bestScore, eps); + confidence(~isfinite(confidence)) = 0; + confidence = min(max(confidence, 0), 1); +end + +function [stack, resizedCount] = stackImagesAsDouble(images) + refSize = size(images{1}); + heightPx = refSize(1); + widthPx = refSize(2); + channels = maxImageChannels(images); + imageCount = numel(images); + stack = zeros(heightPx, widthPx, channels, imageCount); + resizedCount = 0; + + for k = 1:imageCount + img = images{k}; + if ~isequal(size(img, 1), heightPx) || ~isequal(size(img, 2), widthPx) + img = resizeImageToReference(img, refSize); + resizedCount = resizedCount + 1; + end + img = convertChannels(im2double(img), channels); + stack(:, :, :, k) = img; + end +end + +function channels = maxImageChannels(images) + channels = 1; + for k = 1:numel(images) + if ndims(images{k}) == 3 && size(images{k}, 3) >= 3 + channels = 3; + return; + end + end +end + +function img = convertChannels(img, channels) + if channels == 1 + if ndims(img) == 3 + img = normalizeGray(img); + end + return; + end + if ndims(img) == 2 || size(img, 3) == 1 + img = repmat(img(:, :, 1), [1 1 3]); + elseif size(img, 3) > 3 + img = img(:, :, 1:3); + end +end + +function [focusIndex, bestScore, secondScore] = bestFocusIndex(scoreStack) + [heightPx, widthPx, imageCount] = size(scoreStack); + bestScore = -inf(heightPx, widthPx); + secondScore = -inf(heightPx, widthPx); + focusIndex = ones(heightPx, widthPx); + for k = 1:imageCount + score = scoreStack(:, :, k); + better = score > bestScore; + secondScore(better) = bestScore(better); + bestScore(better) = score(better); + focusIndex(better) = k; + + notBetter = ~better; + secondScore(notBetter) = max(secondScore(notBetter), score(notBetter)); + end + secondScore(~isfinite(secondScore)) = 0; + bestScore(~isfinite(bestScore)) = 0; +end + +function meanImage = boxMean2(imageData, windowSize) + windowSize = max(1, round(windowSize)); + kernel = ones(windowSize, windowSize); + numerator = conv2(double(imageData), kernel, 'same'); + denominator = conv2(ones(size(imageData)), kernel, 'same'); + meanImage = numerator ./ max(denominator, eps); +end + +function gray = grayImage(imageData) + imageData = double(imageData); + if ndims(imageData) == 3 + if size(imageData, 3) >= 3 + gray = 0.2989 .* imageData(:, :, 1) + ... + 0.5870 .* imageData(:, :, 2) + ... + 0.1140 .* imageData(:, :, 3); + else + gray = imageData(:, :, 1); + end + else + gray = imageData; + end +end + +function gray = normalizeGray(imageData) + if ndims(imageData) == 4 + imageData = imageData(:, :, :, 1); + end + if ndims(imageData) == 3 + if size(imageData, 3) >= 3 + gray = rgb2gray(imageData(:, :, 1:3)); + else + gray = imageData(:, :, 1); + end + else + gray = imageData; + end + gray = im2double(gray); + values = gray(:); + values = values(isfinite(values)); + if isempty(values) + gray(:) = 0; + return; + end + mn = min(values); + mx = max(values); + if mx > mn + gray = (gray - mn) ./ (mx - mn); + else + gray(:) = 0; + end +end + +function imageOut = resizeImageToSize(imageIn, targetSize) + targetRows = targetSize(1); + targetCols = targetSize(2); + if isequal(size(imageIn, 1), targetRows) && isequal(size(imageIn, 2), targetCols) + imageOut = imageIn; + return; + end + imageOut = imresize(imageIn, [targetRows targetCols], 'bilinear'); + if ndims(imageIn) == 3 && size(imageIn, 3) == 1 && ndims(imageOut) == 2 + imageOut = reshape(imageOut, targetRows, targetCols, 1); + end +end + +function imageOut = resizeImageToReference(imageIn, referenceSize) + targetSize = referenceSize(1:2); + if isequal(size(imageIn, 1), targetSize(1)) && isequal(size(imageIn, 2), targetSize(2)) + imageOut = imageIn; + return; + end + imageOut = imresize(imageIn, targetSize); +end + +function imageOut = gaussianBlurImage(imageIn, sigma) + try + imageOut = imgaussfilt(imageIn, sigma, 'Padding', 'replicate'); + return; + catch + end + + radius = max(1, ceil(3 * sigma)); + x = -radius:radius; + kernel = exp(-(x .^ 2) ./ (2 * sigma ^ 2)); + kernel = kernel ./ sum(kernel); + imageOut = zeros(size(imageIn)); + for c = 1:size(imageIn, 3) + tmp = conv2(imageIn(:, :, c), kernel, 'same'); + imageOut(:, :, c) = conv2(tmp, kernel.', 'same'); + end +end + +function levels = maximumPyramidLevels(imageSize) + levels = 1; + rows = imageSize(1); + cols = imageSize(2); + while min(rows, cols) >= 96 && levels < 5 + rows = ceil(rows / 2); + cols = ceil(cols / 2); + levels = levels + 1; + end +end + +function images = normalizeImageCell(images) + if isnumeric(images) + if ndims(images) == 4 + imageCount = size(images, 4); + out = cell(imageCount, 1); + for k = 1:imageCount + out{k} = images(:, :, :, k); + end + images = out; + elseif ndims(images) == 3 + imageCount = size(images, 3); + out = cell(imageCount, 1); + for k = 1:imageCount + out{k} = images(:, :, k); + end + images = out; + else + images = {images}; + end + end + + if ~iscell(images) + error('labkit_FocusStack_app:InvalidImages', ... + 'Images must be provided as a cell array or numeric stack.'); + end + images = images(:); + for k = 1:numel(images) + if ~isnumeric(images{k}) || ndims(images{k}) < 2 + error('labkit_FocusStack_app:InvalidImages', ... + 'Each focus stack image must be a numeric image array.'); + end + end +end + +function T = buildFocusStackSummaryTable(result, paths) + if ~result.ok + error('labkit_FocusStack_app:NoResult', ... + 'A completed focus-stack result is required to build a summary table.'); + end + paths = string(paths(:)); + if numel(paths) ~= result.inputCount + paths = defaultSliceNames(result.inputCount); + end + + imageNames = strings(result.inputCount, 1); + for k = 1:result.inputCount + imageNames(k) = string(displayNameFromPath(paths(k))); + end + + T = table( ... + imageNames, ... + (1:result.inputCount).', ... + result.focusCoverage(:), ... + 100 .* result.focusCoverage(:), ... + repmat(result.meanConfidence, result.inputCount, 1), ... + repmat(string(result.method), result.inputCount, 1), ... + repmat(result.imageHeight, result.inputCount, 1), ... + repmat(result.imageWidth, result.inputCount, 1), ... + repmat(result.focusWindow, result.inputCount, 1), ... + repmat(result.smoothRadius, result.inputCount, 1), ... + repmat(result.minConfidence, result.inputCount, 1), ... + 'VariableNames', {'SourceImage', 'FocusIndex', ... + 'SelectedPixelFraction', 'SelectedPixelPercent', 'MeanConfidence', ... + 'Method', 'FusedHeight_px', 'FusedWidth_px', ... + 'FocusWindow_px', 'MaskSmoothing_px', 'MinConfidence'}); +end + +function names = defaultSliceNames(imageCount) + names = strings(imageCount, 1); + for k = 1:imageCount + names(k) = sprintf('slice_%03d', k); + end +end + +function data = initialResultTable() + data = { ... + 'Input images', '-'; ... + 'Image size', '-'; ... + 'Focus window', '-'; ... + 'Mask smoothing', '-'; ... + 'Mean confidence', '-'; ... + 'Dominant source', '-'}; +end + +function data = focusStackResultTableData(result) + [dominantCoverage, dominantIndex] = max(result.focusCoverage); + data = { ... + 'Input images', sprintf('%d', result.inputCount); ... + 'Image size', sprintf('%d x %d px', result.imageWidth, result.imageHeight); ... + 'Focus window', sprintf('%d px', result.focusWindow); ... + 'Mask smoothing', sprintf('%d px', result.smoothRadius); ... + 'Mean confidence', sprintf('%.4f', result.meanConfidence); ... + 'Dominant source', sprintf('%d (%.1f%%)', dominantIndex, 100 * dominantCoverage)}; +end + +function lines = focusStackDetails(result, paths, registrationLines) + lines = { ... + sprintf('Method: %s', result.method), ... + sprintf('Fused size: %d x %d px, channels: %d', ... + result.imageWidth, result.imageHeight, result.channelCount), ... + sprintf('Images resized to first image: %d', result.resizedCount), ... + sprintf('Minimum confidence fallback: %.3g', result.minConfidence), ... + 'Selected pixel coverage by source:'}; + names = displayImageNamesForDetails(paths, result.inputCount); + for k = 1:result.inputCount + lines{end+1} = sprintf(' %d. %s: %.2f%%', ... + k, names{k}, 100 * result.focusCoverage(k)); %#ok + end + if ~isempty(registrationLines) + lines{end+1} = 'Registration:'; %#ok + lines = [lines, registrationLines(:).']; %#ok + end +end + +function names = displayImageNamesForDetails(paths, count) + paths = string(paths(:)); + names = cell(count, 1); + for k = 1:count + if k <= numel(paths) + names{k} = displayNameFromPath(paths(k)); + else + names{k} = sprintf('slice_%03d', k); + end + end +end + +function names = displayImageNames(paths) + paths = string(paths(:)); + names = cell(numel(paths), 1); + for k = 1:numel(paths) + names{k} = displayNameFromPath(paths(k)); + end +end + +function name = displayNameFromPath(pathValue) + [~, base, ext] = fileparts(char(pathValue)); + name = [base ext]; + if isempty(name) + name = char(pathValue); + end +end + +function rgb = focusIndexRgb(focusIndex, imageCount) + imageCount = max(1, double(imageCount)); + cmap = parula(max(imageCount, 2)); + idx = double(focusIndex); + idx(~isfinite(idx) | idx < 1) = 1; + idx(idx > imageCount) = imageCount; + rgb = zeros(size(idx, 1), size(idx, 2), 3); + for k = 1:imageCount + mask = idx == k; + for c = 1:3 + channel = rgb(:, :, c); + channel(mask) = cmap(k, c); + rgb(:, :, c) = channel; + end + end +end + +function img = previewImage(img) + img = im2double(img); + if ndims(img) == 3 && size(img, 3) > 3 + img = img(:, :, 1:3); + end +end + +function result = emptyFocusStackResult() + result = struct( ... + 'ok', false, ... + 'message', 'No focus-stack result.', ... + 'fused', [], ... + 'focusIndex', [], ... + 'confidence', [], ... + 'focusCoverage', [], ... + 'inputCount', 0, ... + 'imageHeight', 0, ... + 'imageWidth', 0, ... + 'channelCount', 0, ... + 'focusWindow', 0, ... + 'smoothRadius', 0, ... + 'minConfidence', 0, ... + 'meanConfidence', NaN, ... + 'method', '', ... + 'resizedCount', 0); +end + +function value = optionValue(opts, name, defaultValue) + value = defaultValue; + if isstruct(opts) && isfield(opts, name) + value = opts.(name); + end +end + +function window = oddWindow(value, minimum) + validateattributes(value, {'numeric'}, {'scalar', 'finite', 'positive'}); + window = max(minimum, round(value)); + if mod(window, 2) == 0 + window = window + 1; + end +end + +function value = ternary(condition, trueValue, falseValue) + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/docs/apps.md b/docs/apps.md index 4e8d646..20cbe38 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -24,6 +24,7 @@ This adds the repository root, `apps/`, and nested app category folders to the M | `labkit_DICPreprocess_app` | active | Image registration, paired crop preparation, and ROI mask drawing. | Reference/current images | Aligned images, crop PNGs, ROI mask. | | `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 for radius and curvature. | Image | Overlay PNG and curvature CSV. | +| `labkit_FocusStack_app` | experimental | Microscope focus-stack fusion into one all-in-focus image. | Focus image folder | Fused PNG, focus map PNG, summary 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: @@ -41,7 +42,7 @@ Electrochemistry apps live under `apps/electrochem/` and use the DTA facade for DIC apps live under `apps/dic/`. They use the shared GUI shell while keeping registration, crop geometry, Ncorr MAT extraction, strain overlays, summaries, and exports in the owning app files. -Image measurement apps live under `apps/image_measurement/`. They are separate from DIC because their workflows are general image measurements rather than DIC preprocessing or strain postprocessing. +Image measurement apps live under `apps/image_measurement/`. They are separate from DIC because their workflows are general image measurements or image-processing utilities rather than DIC preprocessing or strain postprocessing. Wearable biosignal apps live under `apps/wearable/`. They use the biosignal facade for recording loading, channel extraction, time ROI, filtering, events, segments, templates, and measurements, while the app owns workflow wording, plot layout, import controls, and export choices. @@ -124,4 +125,5 @@ Interactive file selection, drawing, visual inspection, and full workflow feel a | `labkit_DICPreprocess_app` | Registration, repeated crop/align workflow, false-color preview, inline crop ROI, and binary ROI mask drawing. | Exports current image pair, crop PNGs, and white-inside/black-outside ROI masks. | | `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` | Image anchor editing, scale-bar measurement, circle fitting, curvature conversion, dense-point display, and residual annotations. | Exports overlay PNG and curvature CSV. | +| `labkit_FocusStack_app` | Folder-based focus sequence loading, optional registration to the middle image, Laplacian-pyramid focus fusion, smoothed detail-level decision weights, and focus-depth preview. | Exports fused PNG, colorized focus map PNG, and per-source focus coverage CSV. | | `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 a599a5b..3f9c3e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,7 @@ labkit_ChronoOverlay_app labkit_DICPreprocess_app labkit_DICPostprocess_app labkit_CurvatureMeasurement_app +labkit_FocusStack_app labkit_ECGPrint_app ``` diff --git a/tests/helpers/appEntryManifest.m b/tests/helpers/appEntryManifest.m index 8825885..d60497d 100644 --- a/tests/helpers/appEntryManifest.m +++ b/tests/helpers/appEntryManifest.m @@ -10,5 +10,6 @@ 'labkit_DICPreprocess_app', 'DIC Image Preprocess'; ... 'labkit_DICPostprocess_app', 'DIC Strain Postprocess'; ... 'labkit_CurvatureMeasurement_app', 'Image Curvature Measurement'; ... + 'labkit_FocusStack_app', 'Microscope Focus Stack Fusion'; ... 'labkit_ECGPrint_app', 'ECG Signal Print + SNR Explorer'}; end diff --git a/tests/helpers/architectureTestHelpers.m b/tests/helpers/architectureTestHelpers.m index 902ec29..262aebe 100644 --- a/tests/helpers/architectureTestHelpers.m +++ b/tests/helpers/architectureTestHelpers.m @@ -153,7 +153,8 @@ function assertPackageSourcesDoNotContain(packageDir, forbiddenWords, label) words = {'labkit_CIC_app', 'labkit_VTResistance_app', 'labkit_CSC_app', ... 'labkit_EIS_app', 'labkit_ChronoOverlay_app', ... 'labkit_DICPreprocess_app', 'labkit_DICPostprocess_app', ... - 'labkit_CurvatureMeasurement_app', 'labkit_ECGPrint_app'}; + 'labkit_CurvatureMeasurement_app', 'labkit_FocusStack_app', ... + 'labkit_ECGPrint_app'}; end function words = experimentWorkflowWords() diff --git a/tests/suites/apps/image_measurement/test_focusStackFusion.m b/tests/suites/apps/image_measurement/test_focusStackFusion.m new file mode 100644 index 0000000..3b47249 --- /dev/null +++ b/tests/suites/apps/image_measurement/test_focusStackFusion.m @@ -0,0 +1,170 @@ +function test_focusStackFusion() +%TEST_FOCUSSTACKFUSION Verify focus-stack fusion app calculations. + + checkSyntheticFocusSelection(); + checkSummaryTableContract(); + checkFolderDiscovery(); + checkRegistrationImprovesSyntheticDrift(); + checkInvalidInputs(); +end + +function checkSyntheticFocusSelection() + [nearImage, farImage, mid] = syntheticFocusPair(); + opts = struct('focusWindow', 5, 'smoothRadius', 0, 'minConfidence', 0); + + result = labkit_FocusStack_app('__labkit_test__', ... + 'computeFocusStack', {nearImage, farImage}, opts); + + assert(result.ok, 'Focus stack should succeed for a two-image synthetic stack.'); + assert(result.inputCount == 2, 'Input image count changed.'); + assert(result.imageHeight == size(nearImage, 1), 'Fused image height changed.'); + assert(result.imageWidth == size(nearImage, 2), 'Fused image width changed.'); + assert(result.channelCount == 3, 'RGB channel handling changed.'); + assert(abs(sum(result.focusCoverage) - 1) < 1e-12, ... + 'Focus coverage should sum to one.'); + + idx = double(result.focusIndex); + margin = 8; + leftRegion = idx(:, 1:(mid - margin)); + rightRegion = idx(:, (mid + margin):end); + leftNearFraction = mean(leftRegion(:) == 1); + rightFarFraction = mean(rightRegion(:) == 2); + + assert(leftNearFraction > 0.80, ... + 'Left sharp region should mostly select the first image.'); + assert(rightFarFraction > 0.80, ... + 'Right sharp region should mostly select the second image.'); + assert(all(result.fused(:) >= 0 & result.fused(:) <= 1), ... + 'Fused image should stay in displayable double range.'); +end + +function checkSummaryTableContract() + [nearImage, farImage] = syntheticFocusPair(); + result = labkit_FocusStack_app('__labkit_test__', ... + 'computeFocusStack', {nearImage, farImage}, ... + struct('focusWindow', 5, 'smoothRadius', 1, 'minConfidence', 0.05)); + + T = labkit_FocusStack_app('__labkit_test__', ... + 'buildFocusStackSummaryTable', result, ["slice_a.png"; "slice_b.png"]); + + assert(isequal(T.Properties.VariableNames, expectedSummaryColumns()), ... + 'Focus stack summary columns changed.'); + assert(height(T) == 2, 'Summary table should include one row per source image.'); + assert(T.SourceImage(1) == "slice_a.png", ... + 'Summary table should preserve source image display names.'); + assert(T.FocusWindow_px(1) == result.focusWindow, ... + 'Summary table should preserve focus-window option.'); + assert(T.MaskSmoothing_px(1) == result.smoothRadius, ... + 'Summary table should preserve smoothing option.'); +end + +function checkFolderDiscovery() + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); %#ok + + imwrite(uint8(255 * ones(8, 8)), fullfile(folder, 'slice_b.png')); + imwrite(uint8(128 * ones(8, 8)), fullfile(folder, 'slice_a.jpg')); + fid = fopen(fullfile(folder, 'notes.txt'), 'w'); + fprintf(fid, 'not an image fixture'); + fclose(fid); + + paths = labkit_FocusStack_app('__labkit_test__', 'findFocusStackImages', folder); + names = cell(numel(paths), 1); + for k = 1:numel(paths) + [~, base, ext] = fileparts(char(paths(k))); + names{k} = [base ext]; + end + + assert(isequal(names, {'slice_a.jpg'; 'slice_b.png'}), ... + 'Image folder discovery should filter image files and sort by name.'); +end + +function checkRegistrationImprovesSyntheticDrift() + reference = syntheticRegistrationImage(); + moving = imtranslate(reference, [4 -3], 'FillValues', median(reference(:))); + + [aligned, lines] = labkit_FocusStack_app('__labkit_test__', ... + 'alignFocusStackImages', {moving, reference}); + + beforeErr = mean((im2double(moving(:)) - im2double(reference(:))) .^ 2); + afterErr = mean((im2double(aligned{1}(:)) - im2double(reference(:))) .^ 2); + assert(afterErr < beforeErr, ... + 'Automatic registration should reduce synthetic alignment error.'); + assert(contains(strjoin(string(lines), " "), "reference image: 2"), ... + 'Registration should use the middle stack image as reference.'); +end + +function checkInvalidInputs() + assertThrows(@() labkit_FocusStack_app('__labkit_test__', ... + 'computeFocusStack', {zeros(8, 8)}, struct()), ... + 'labkit_FocusStack_app:NotEnoughImages', ... + 'Single-image stacks should be rejected.'); + assertThrows(@() labkit_FocusStack_app('__labkit_test__', ... + 'computeFocusStack', {zeros(8, 8), zeros(8, 8)}, ... + struct('focusWindow', 0)), ... + 'MATLAB:expectedPositive', ... + 'Invalid focus window should be rejected.'); +end + +function [nearImage, farImage, mid] = syntheticFocusPair() + heightPx = 72; + widthPx = 104; + [x, y] = meshgrid(1:widthPx, 1:heightPx); + sharp = 0.5 + 0.25 .* sin(0.75 .* x) + 0.25 .* cos(0.65 .* y); + sharp = min(max(sharp, 0), 1); + blurred = boxBlur(sharp, 13); + + mid = floor(widthPx / 2); + nearMask = false(heightPx, widthPx); + nearMask(:, 1:mid) = true; + + nearGray = blurred; + farGray = blurred; + nearGray(nearMask) = sharp(nearMask); + farGray(~nearMask) = sharp(~nearMask); + + nearImage = cat(3, nearGray, 0.85 .* nearGray, 0.65 .* nearGray); + farImage = cat(3, farGray, 0.85 .* farGray, 0.65 .* farGray); +end + +function imageData = syntheticRegistrationImage() + [x, y] = meshgrid(1:96, 1:72); + base = 0.2 + 0.5 .* exp(-((x - 48) .^ 2 + (y - 36) .^ 2) ./ 300); + ring = abs(sqrt((x - 48) .^ 2 + (y - 36) .^ 2) - 18) < 2; + line = abs(y - 0.55 .* x - 8) < 1.5; + imageData = base; + imageData(ring) = 1; + imageData(line) = 0.85; + imageData = uint8(255 .* min(max(imageData, 0), 1)); +end + +function out = boxBlur(in, windowSize) + kernel = ones(windowSize, windowSize); + out = conv2(in, kernel, 'same') ./ conv2(ones(size(in)), kernel, 'same'); +end + +function columns = expectedSummaryColumns() + columns = {'SourceImage', 'FocusIndex', ... + 'SelectedPixelFraction', 'SelectedPixelPercent', 'MeanConfidence', ... + 'Method', 'FusedHeight_px', 'FusedWidth_px', ... + 'FocusWindow_px', 'MaskSmoothing_px', 'MinConfidence'}; +end + +function assertThrows(fn, expectedIdentifier, label) + try + fn(); + catch ME + assert(strcmp(ME.identifier, expectedIdentifier), ... + '%s Expected %s but caught %s.', ... + label, expectedIdentifier, ME.identifier); + return; + end + error('%s Expected an error with identifier %s.', label, expectedIdentifier); +end + +function removeTempFolder(folder) + if exist(folder, 'dir') == 7 + rmdir(folder, 's'); + end +end diff --git a/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m b/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m index 1c18828..904bebc 100644 --- a/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m +++ b/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m @@ -5,6 +5,11 @@ function test_gui_layout_image_measurement() h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); %#ok + checkCurvatureMeasurementLayout(h); + checkFocusStackLayout(h); +end + +function checkCurvatureMeasurementLayout(h) fig = h.launchFigure('labkit_CurvatureMeasurement_app', 'Image Curvature Measurement'); h.assertFigureMinimumSize(fig, 1420, 860); h.assertComponentCounts(fig, struct('Button', 8, 'CheckBox', 2, ... @@ -20,3 +25,18 @@ function test_gui_layout_image_measurement() h.assertTableColumns(fig, {'Metric', 'Value'}); h.assertAxesContract(fig, {h.axesSpec('Image + Circle Fit', '', '')}); end + +function checkFocusStackLayout(h) + fig = h.launchFigure('labkit_FocusStack_app', 'Microscope Focus Stack Fusion'); + h.assertFigureMinimumSize(fig, 1440, 860); + h.assertComponentCounts(fig, struct('Button', 5, 'CheckBox', 1, ... + 'Spinner', 3, 'ListBox', 1, 'Table', 1, 'TextArea', 3, 'Axes', 2)); + h.assertButtonContract(fig, {'Open image folder', 'Run focus stack', 'Export fused PNG', ... + 'Export focus map PNG', 'Export summary CSV'}); + h.assertCheckboxContract(fig, {'Auto-register stack to middle image'}); + h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); + h.assertTableColumns(fig, {'Metric', 'Value'}); + h.assertAxesContract(fig, { ... + h.axesSpec('Fused all-in-focus image', '', ''), ... + h.axesSpec('Focus-depth index map', '', '')}); +end diff --git a/tests/suites/project/test_app_entrypoint_boundaries.m b/tests/suites/project/test_app_entrypoint_boundaries.m index e10bd19..2d792f5 100644 --- a/tests/suites/project/test_app_entrypoint_boundaries.m +++ b/tests/suites/project/test_app_entrypoint_boundaries.m @@ -63,6 +63,12 @@ function test_app_entrypoint_boundaries() 'curvature_measurement_gui('); h.assertImageMeasurementAppBoundary(curvatureSource, 'labkit_CurvatureMeasurement_app'); + focusStackSource = h.assertSingleFileApp(root, ... + 'labkit_FocusStack_app', ... + 'launchFocusStackApp', ... + 'focus_stack_gui('); + h.assertImageMeasurementAppBoundary(focusStackSource, 'labkit_FocusStack_app'); + ecgPrintSource = h.assertSingleFileApp(root, ... 'labkit_ECGPrint_app', ... 'launchECGPrintApp', ... diff --git a/tests/suites/project/test_app_owned_workflow_boundaries.m b/tests/suites/project/test_app_owned_workflow_boundaries.m index d97e19c..abba48a 100644 --- a/tests/suites/project/test_app_owned_workflow_boundaries.m +++ b/tests/suites/project/test_app_owned_workflow_boundaries.m @@ -169,6 +169,17 @@ function test_app_owned_workflow_boundaries() 'Image curvature fitting should stay local to the curvature measurement app.'); assert(contains(curvatureSource, 'function T = buildCurvatureResultTable'), ... 'Image curvature export table construction should stay local to the app.'); + + focusStackSource = h.assertSingleFileApp(root, ... + 'labkit_FocusStack_app', ... + 'launchFocusStackApp', ... + 'focus_stack_gui('); + assert(contains(focusStackSource, 'function result = computeFocusStack'), ... + 'Focus-stack fusion should stay local to the focus stack app.'); + assert(contains(focusStackSource, 'function T = buildFocusStackSummaryTable'), ... + 'Focus-stack export table construction should stay local to the app.'); + assert(contains(focusStackSource, 'function [alignedImages, lines] = alignFocusStackImages'), ... + 'Focus-stack registration workflow should stay local to the app.'); assert(exist(fullfile(root, '+labkit', '+image_measurement'), 'dir') ~= 7, ... 'Image measurement workflow code should not be promoted to a reusable +labkit package yet.'); From cc4cdf92158776057beeaf236cf37ffb598ba4ae Mon Sep 17 00:00:00 2001 From: Pluze Zhu Date: Thu, 4 Jun 2026 14:25:00 -0500 Subject: [PATCH 2/2] feat: allow manual focus image selection --- README.md | 2 +- .../image_measurement/labkit_FocusStack_app.m | 239 ++++++++++++++++-- docs/apps.md | 4 +- .../image_measurement/test_focusStackFusion.m | 50 +++- .../test_gui_layout_image_measurement.m | 6 +- 5 files changed, 266 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 77cf2a6..0504f5e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Then use the app window to load files, inspect plots or results, and export outp | `labkit_DICPreprocess_app` | active | Image registration, paired crop preparation, and ROI mask drawing | Reference/current images | Aligned images, crop PNGs, ROI mask | | `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 for radius and curvature | Image | Overlay PNG and curvature CSV | -| `labkit_FocusStack_app` | experimental | Microscope focus-stack fusion into one all-in-focus image | Focus image folder | Fused PNG, focus map PNG, summary 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_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/labkit_FocusStack_app.m b/apps/image_measurement/labkit_FocusStack_app.m index 032641d..261689d 100644 --- a/apps/image_measurement/labkit_FocusStack_app.m +++ b/apps/image_measurement/labkit_FocusStack_app.m @@ -47,9 +47,9 @@ laySR = ui.summaryResultsGrid; layLog = ui.logGrid; - filePanel = labkit.ui.createPanelGrid(layFA, 'Images', 1, [4 1], ... + filePanel = labkit.ui.createPanelGrid(layFA, 'Images', 1, [4 2], ... struct('rowHeight', {{'fit', 'fit', 105, 'fit'}}, ... - 'columnWidth', {{'1x'}})); + 'columnWidth', {{'1x', '1x'}})); fileGrid = filePanel.grid; btnOpenFolder = uibutton(fileGrid, 'Text', 'Open image folder', ... @@ -57,8 +57,13 @@ btnOpenFolder.Layout.Row = 1; btnOpenFolder.Layout.Column = 1; + btnOpenFiles = uibutton(fileGrid, 'Text', 'Open image files', ... + 'ButtonPushedFcn', @onOpenFiles); + btnOpenFiles.Layout.Row = 1; + btnOpenFiles.Layout.Column = 2; + txtFolder = labkit.ui.createReadOnlyTextField(fileGrid, ... - 'Value', 'No folder loaded'); + 'Value', 'No images loaded'); txtFolder.Layout.Row = 2; txtFolder.Layout.Column = [1 2]; @@ -127,9 +132,9 @@ btnExportSummary.Layout.Row = 3; labkit.ui.createReadOnlyTextPanel(layFA, 'Workflow Notes', 4, { ... - '1. Load a folder containing one focus sequence for the same microscope field of view.', ... - '2. Run focus stack to fuse sharp details across the image pyramid.', ... - '3. Inspect the fused image and focus-depth map before exporting.', ... + '1. Load a folder or select one or more image files from the same microscope field of view.', ... + '2. Use file selection when a folder contains bad frames that should be excluded.', ... + '3. Run focus stack to fuse sharp details across the image pyramid.', ... '4. Enable registration only when the stack has whole-image drift and no strong focus breathing.'}); resultTable = uitable(laySR, ... @@ -139,7 +144,7 @@ txtDetails = uitextarea(laySR, 'Editable', 'off'); txtDetails.Layout.Row = labkit.ui.layoutRow(laySR, 2); - txtDetails.Value = {'Load a focus image folder to begin.'}; + txtDetails.Value = {'Load a focus image folder or select image files to begin.'}; logUi = labkit.ui.createLogPanel(layLog, 1, {'Ready.'}); txtLog = logUi.textArea; @@ -163,28 +168,58 @@ function onOpenFolder(~, ~) loadImageFolder(string(folder)); end + function onOpenFiles(~, ~) + [files, folder] = uigetfile(focusImageDialogFilter(), ... + 'Select focus image files', pwd, 'MultiSelect', 'on'); + if isequal(files, 0) + addLog('Image file selection cancelled.'); + return; + end + + try + paths = selectedFocusImagePaths(files, folder); + catch ME + showError('Could not select focus images', ME.message); + return; + end + loadImagePaths(paths, string(folder), ... + sprintf('Selected image files from %s', char(folder)), ... + sprintf('Loaded %d selected image file(s).', numel(paths))); + end + function loadImageFolder(folder) try paths = findFocusStackImages(folder); + catch ME + showError('Could not load focus stack', ME.message); + return; + end + loadImagePaths(paths, folder, char(folder), ... + sprintf('Loaded %d image(s) from folder.', numel(paths))); + end + + function loadImagePaths(paths, sourceFolder, sourceDescription, logMessage) + try images = readFocusStackImages(paths); catch ME showError('Could not load focus stack', ME.message); return; end - S.folder = folder; + sourceDescription = string(sourceDescription); S.paths = paths; S.images = images; S.alignedImages = {}; S.registrationLines = {}; S.result = emptyFocusStackResult(); + S.folder = string(sourceFolder); - txtFolder.Value = char(folder); + txtFolder.Value = char(sourceDescription); lbImages.Items = displayImageNames(paths); if ~isempty(lbImages.Items) lbImages.Value = lbImages.Items{1}; end - addLog(sprintf('Loaded %d image(s) from folder.', numel(S.images))); + addLog(logMessage); refreshPreview(); refreshSummary(); end @@ -327,9 +362,14 @@ function refreshSummary() txtDetails.Value = { ... sprintf('Loaded images: %d', numel(S.images)), ... 'Run focus stack to compute the fused image and focus-depth map.'}; + elseif ~isempty(S.images) + resultTable.Data = initialResultTable(); + txtDetails.Value = { ... + sprintf('Loaded images: %d', numel(S.images)), ... + 'Load at least two images before running focus stack.'}; else resultTable.Data = initialResultTable(); - txtDetails.Value = {'Load a focus image folder to begin.'}; + txtDetails.Value = {'Load a focus image folder or select image files to begin.'}; end updateControls(); end @@ -361,11 +401,11 @@ function showError(titleText, message) function handlers = focusStackAppTestHandlers() handlers = struct( ... - 'command', {'computeFocusStack', 'buildFocusStackSummaryTable', 'findFocusStackImages', 'alignFocusStackImages'}, ... - 'minArgs', {2, 2, 1, 1}, ... - 'maxArgs', {2, 2, 1, 1}, ... - 'maxOutputs', {1, 1, 1, 2}, ... - 'run', {@runComputeFocusStack, @runBuildFocusStackSummaryTable, @runFindFocusStackImages, @runAlignFocusStackImages}); + 'command', {'computeFocusStack', 'buildFocusStackSummaryTable', 'findFocusStackImages', 'alignFocusStackImages', 'selectedFocusImagePaths'}, ... + 'minArgs', {2, 2, 1, 1, 2}, ... + 'maxArgs', {2, 2, 1, 1, 2}, ... + 'maxOutputs', {1, 1, 1, 2, 1}, ... + 'run', {@runComputeFocusStack, @runBuildFocusStackSummaryTable, @runFindFocusStackImages, @runAlignFocusStackImages, @runSelectedFocusImagePaths}); end function outputs = runComputeFocusStack(args) @@ -385,47 +425,78 @@ function showError(titleText, message) outputs = {alignedImages, lines}; end +function outputs = runSelectedFocusImagePaths(args) + outputs = {selectedFocusImagePaths(args{1}, args{2})}; +end + +function filter = focusImageDialogFilter() + filter = {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... + 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'}; +end + function paths = findFocusStackImages(folder) if strlength(string(folder)) == 0 || exist(folder, 'dir') ~= 7 error('labkit_FocusStack_app:FolderNotFound', ... 'Focus image folder does not exist.'); end - allowedExt = {'.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'}; entries = dir(folder); keep = false(numel(entries), 1); - names = cell(numel(entries), 1); for k = 1:numel(entries) entry = entries(k); - names{k} = entry.name; if entry.isdir continue; end - [~, ~, ext] = fileparts(entry.name); - keep(k) = any(strcmpi(ext, allowedExt)); + keep(k) = isSupportedFocusImagePath(entry.name); end entries = entries(keep); - names = names(keep); - [~, order] = sort(lower(names)); - entries = entries(order); paths = strings(numel(entries), 1); for k = 1:numel(entries) paths(k) = string(fullfile(folder, entries(k).name)); end + paths = sortFocusStackPathsByName(paths); if numel(paths) < 2 error('labkit_FocusStack_app:NotEnoughImages', ... 'Focus stacking requires at least two image files in the selected folder.'); end end +function paths = selectedFocusImagePaths(files, folder) + 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_FocusStack_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 = sortFocusStackPathsByName(paths); + assertSupportedFocusImagePaths(paths); +end + function images = readFocusStackImages(paths) paths = string(paths(:)); - if numel(paths) < 2 - error('labkit_FocusStack_app:NotEnoughImages', ... - 'Focus stacking requires at least two image files.'); + if isempty(paths) + error('labkit_FocusStack_app:NoImagesSelected', ... + 'Select at least one image file.'); end + assertSupportedFocusImagePaths(paths); images = cell(numel(paths), 1); for k = 1:numel(paths) @@ -437,6 +508,35 @@ function showError(titleText, message) end end +function assertSupportedFocusImagePaths(paths) + for k = 1:numel(paths) + if ~isSupportedFocusImagePath(paths(k)) + error('labkit_FocusStack_app:UnsupportedImageFile', ... + 'Unsupported image file type: %s', char(paths(k))); + end + end +end + +function tf = isSupportedFocusImagePath(pathValue) + [~, ~, ext] = fileparts(char(pathValue)); + tf = any(strcmpi(ext, supportedFocusImageExtensions())); +end + +function extensions = supportedFocusImageExtensions() + extensions = {'.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'}; +end + +function paths = sortFocusStackPathsByName(paths) + paths = string(paths(:)); + names = strings(numel(paths), 1); + for k = 1:numel(paths) + [~, base, ext] = fileparts(char(paths(k))); + names(k) = lower(string([base ext])); + end + [~, order] = sort(names); + paths = paths(order); +end + function [alignedImages, lines] = alignFocusStackImages(images) images = normalizeImageCell(images); alignedImages = images; @@ -468,6 +568,30 @@ function showError(titleText, message) fixedGray = alignmentGray(referenceImage); movingGray = alignmentGray(movingImage); + try + [alignedImage, method] = alignImageWithImregcorr( ... + movingImage, movingGray, fixedGray); + alignedImage = cast(alignedImage, origClass); + return; + catch registrationErr + try + [rowShift, colShift] = estimateTranslationByPhaseCorrelation( ... + fixedGray, movingGray); + alignedImage = translateImageByIntegerShift( ... + movingImage, rowShift, colShift, backgroundFillValues(movingImage)); + alignedImage = cast(alignedImage, origClass); + method = sprintf('FFT translation fallback (row %+d, col %+d)', ... + rowShift, colShift); + return; + catch fallbackErr + error('labkit_FocusStack_app:RegistrationFailed', ... + 'Image registration failed: %s Fallback failed: %s', ... + registrationErr.message, fallbackErr.message); + end + end +end + +function [alignedImage, method] = alignImageWithImregcorr(movingImage, movingGray, fixedGray) try tform = imregcorr(movingGray, fixedGray, 'similarity'); method = 'phase-correlation similarity registration'; @@ -490,7 +614,6 @@ function showError(titleText, message) fixedRef = imref2d(size(fixedGray)); alignedImage = imwarp(movingImage, tform, ... 'OutputView', fixedRef, 'FillValues', backgroundFillValues(movingImage)); - alignedImage = cast(alignedImage, origClass); end function gray = alignmentGray(imageData) @@ -518,6 +641,66 @@ function showError(titleText, message) end end +function [rowShift, colShift] = estimateTranslationByPhaseCorrelation(fixedGray, movingGray) + fixedGray = double(fixedGray); + movingGray = double(movingGray); + fixedGray = fixedGray - mean(fixedGray(:), 'omitnan'); + movingGray = movingGray - mean(movingGray(:), 'omitnan'); + fixedGray(~isfinite(fixedGray)) = 0; + movingGray(~isfinite(movingGray)) = 0; + + crossPower = fft2(fixedGray) .* conj(fft2(movingGray)); + magnitude = abs(crossPower); + normalized = crossPower ./ max(magnitude, eps); + corrMap = real(ifft2(normalized)); + [~, peakIdx] = max(corrMap(:)); + [peakRow, peakCol] = ind2sub(size(corrMap), peakIdx); + + [rows, cols] = size(corrMap); + rowShift = peakRow - 1; + colShift = peakCol - 1; + if rowShift > rows / 2 + rowShift = rowShift - rows; + end + if colShift > cols / 2 + colShift = colShift - cols; + end +end + +function imageOut = translateImageByIntegerShift(imageIn, rowShift, colShift, fillValues) + rowShift = round(rowShift); + colShift = round(colShift); + imageOut = filledImageLike(imageIn, fillValues); + + rows = size(imageIn, 1); + cols = size(imageIn, 2); + dstRows = max(1, 1 + rowShift):min(rows, rows + rowShift); + dstCols = max(1, 1 + colShift):min(cols, cols + colShift); + srcRows = max(1, 1 - rowShift):min(rows, rows - rowShift); + srcCols = max(1, 1 - colShift):min(cols, cols - colShift); + if isempty(dstRows) || isempty(dstCols) || isempty(srcRows) || isempty(srcCols) + return; + end + + if ndims(imageIn) == 2 + imageOut(dstRows, dstCols) = imageIn(srcRows, srcCols); + else + imageOut(dstRows, dstCols, :) = imageIn(srcRows, srcCols, :); + end +end + +function imageOut = filledImageLike(imageIn, fillValues) + imageOut = zeros(size(imageIn), class(imageIn)); + if ndims(imageIn) == 2 + imageOut(:) = cast(fillValues(1), class(imageIn)); + return; + end + + for c = 1:size(imageIn, 3) + imageOut(:, :, c) = cast(fillValues(min(c, numel(fillValues))), class(imageIn)); + end +end + function result = computeFocusStack(images, opts) if nargin < 2 opts = struct(); diff --git a/docs/apps.md b/docs/apps.md index 20cbe38..2e75c17 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -24,7 +24,7 @@ This adds the repository root, `apps/`, and nested app category folders to the M | `labkit_DICPreprocess_app` | active | Image registration, paired crop preparation, and ROI mask drawing. | Reference/current images | Aligned images, crop PNGs, ROI mask. | | `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 for radius and curvature. | Image | Overlay PNG and curvature CSV. | -| `labkit_FocusStack_app` | experimental | Microscope focus-stack fusion into one all-in-focus image. | Focus image folder | Fused PNG, focus map PNG, summary 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_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: @@ -125,5 +125,5 @@ Interactive file selection, drawing, visual inspection, and full workflow feel a | `labkit_DICPreprocess_app` | Registration, repeated crop/align workflow, false-color preview, inline crop ROI, and binary ROI mask drawing. | Exports current image pair, crop PNGs, and white-inside/black-outside ROI masks. | | `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` | Image anchor editing, scale-bar measurement, circle fitting, curvature conversion, dense-point display, and residual annotations. | Exports overlay PNG and curvature CSV. | -| `labkit_FocusStack_app` | Folder-based focus sequence loading, optional registration to the middle image, Laplacian-pyramid focus fusion, smoothed detail-level decision weights, and focus-depth preview. | Exports fused PNG, colorized focus map PNG, and per-source focus coverage CSV. | +| `labkit_FocusStack_app` | Folder or selected-file focus sequence loading, optional registration to the middle image, Laplacian-pyramid focus fusion, smoothed detail-level decision weights, and focus-depth preview. | Exports fused PNG, colorized focus map PNG, and per-source focus coverage CSV. | | `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/tests/suites/apps/image_measurement/test_focusStackFusion.m b/tests/suites/apps/image_measurement/test_focusStackFusion.m index 3b47249..0338fe2 100644 --- a/tests/suites/apps/image_measurement/test_focusStackFusion.m +++ b/tests/suites/apps/image_measurement/test_focusStackFusion.m @@ -4,6 +4,7 @@ function test_focusStackFusion() checkSyntheticFocusSelection(); checkSummaryTableContract(); checkFolderDiscovery(); + checkSelectedFileSelection(); checkRegistrationImprovesSyntheticDrift(); checkInvalidInputs(); end @@ -80,9 +81,31 @@ function checkFolderDiscovery() 'Image folder discovery should filter image files and sort by name.'); end +function checkSelectedFileSelection() + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); %#ok + + paths = labkit_FocusStack_app('__labkit_test__', ... + 'selectedFocusImagePaths', {'frame_b.png', 'frame_a.tif'}, folder); + names = fileNames(paths); + assert(isequal(names, {'frame_a.tif'; 'frame_b.png'}), ... + 'Selected image files should be normalized and sorted by name.'); + + onePath = labkit_FocusStack_app('__labkit_test__', ... + 'selectedFocusImagePaths', 'frame_c.jpg', folder); + assert(numel(onePath) == 1 && endsWith(onePath, "frame_c.jpg"), ... + 'Single-file selection should be accepted for preview before stacking.'); + + assertThrows(@() labkit_FocusStack_app('__labkit_test__', ... + 'selectedFocusImagePaths', 'notes.txt', folder), ... + 'labkit_FocusStack_app:UnsupportedImageFile', ... + 'Manual selection should reject unsupported file types.'); +end + function checkRegistrationImprovesSyntheticDrift() reference = syntheticRegistrationImage(); - moving = imtranslate(reference, [4 -3], 'FillValues', median(reference(:))); + moving = integerTranslateImage(reference, -3, 4, median(reference(:))); [aligned, lines] = labkit_FocusStack_app('__labkit_test__', ... 'alignFocusStackImages', {moving, reference}); @@ -95,6 +118,15 @@ function checkRegistrationImprovesSyntheticDrift() 'Registration should use the middle stack image as reference.'); end +function names = fileNames(paths) + paths = string(paths(:)); + names = cell(numel(paths), 1); + for k = 1:numel(paths) + [~, base, ext] = fileparts(char(paths(k))); + names{k} = [base ext]; + end +end + function checkInvalidInputs() assertThrows(@() labkit_FocusStack_app('__labkit_test__', ... 'computeFocusStack', {zeros(8, 8)}, struct()), ... @@ -144,6 +176,22 @@ function checkInvalidInputs() out = conv2(in, kernel, 'same') ./ conv2(ones(size(in)), kernel, 'same'); end +function out = integerTranslateImage(in, rowShift, colShift, fillValue) + out = zeros(size(in), class(in)); + out(:) = cast(fillValue, class(in)); + + rows = size(in, 1); + cols = size(in, 2); + dstRows = max(1, 1 + rowShift):min(rows, rows + rowShift); + dstCols = max(1, 1 + colShift):min(cols, cols + colShift); + srcRows = max(1, 1 - rowShift):min(rows, rows - rowShift); + srcCols = max(1, 1 - colShift):min(cols, cols - colShift); + if isempty(dstRows) || isempty(dstCols) || isempty(srcRows) || isempty(srcCols) + return; + end + out(dstRows, dstCols, :) = in(srcRows, srcCols, :); +end + function columns = expectedSummaryColumns() columns = {'SourceImage', 'FocusIndex', ... 'SelectedPixelFraction', 'SelectedPixelPercent', 'MeanConfidence', ... diff --git a/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m b/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m index 904bebc..aa52fba 100644 --- a/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m +++ b/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m @@ -29,10 +29,10 @@ function checkCurvatureMeasurementLayout(h) function checkFocusStackLayout(h) fig = h.launchFigure('labkit_FocusStack_app', 'Microscope Focus Stack Fusion'); h.assertFigureMinimumSize(fig, 1440, 860); - h.assertComponentCounts(fig, struct('Button', 5, 'CheckBox', 1, ... + h.assertComponentCounts(fig, struct('Button', 6, 'CheckBox', 1, ... 'Spinner', 3, 'ListBox', 1, 'Table', 1, 'TextArea', 3, 'Axes', 2)); - h.assertButtonContract(fig, {'Open image folder', 'Run focus stack', 'Export fused PNG', ... - 'Export focus map PNG', 'Export summary CSV'}); + h.assertButtonContract(fig, {'Open image folder', 'Open image files', ... + 'Run focus stack', 'Export fused PNG', 'Export focus map PNG', 'Export summary CSV'}); h.assertCheckboxContract(fig, {'Auto-register stack to middle image'}); h.assertTabTitles(fig, {'Files + Analysis', 'Summary + Results', 'Log'}); h.assertTableColumns(fig, {'Metric', 'Value'});