From 28ff8edba2d76d3b6275b41919ed0834ff5f3582 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 12:19:03 -0500 Subject: [PATCH 01/15] ci: make MATLAB failures traceable --- .github/workflows/matlab-tests.yml | 11 +- docs/testing.md | 12 ++ scripts/summarize_junit.py | 43 +++++- tests/AGENTS.md | 5 + .../build/BuildTaskEfficiencyGuardrailTest.m | 18 +++ .../build/BuildTaskFrameworkGuardrailTest.m | 8 ++ .../ci/CiValidationPolicyGuardrailTest.m | 5 + tests/cases/gui/apps/AppLaunchGuiTest.m | 2 +- .../GuiLayoutChronoOverlayTest.m | 2 +- .../apps/electrochem/cic/GuiLayoutCicTest.m | 2 +- .../apps/electrochem/csc/GuiLayoutCscTest.m | 2 +- .../apps/electrochem/eis/GuiLayoutEisTest.m | 2 +- .../vt_resistance/GuiLayoutVtResistanceTest.m | 2 +- .../batch_crop/GuiLayoutBatchCropTest.m | 2 +- .../curvature/GuiLayoutCurvatureTest.m | 2 +- .../flir_thermal/GuiLayoutFlirThermalTest.m | 2 +- .../focus_stack/GuiLayoutFocusStackTest.m | 2 +- .../image_enhance/GuiLayoutImageEnhanceTest.m | 2 +- .../image_match/GuiLayoutImageMatchTest.m | 2 +- .../figure_studio/GuiLayoutFigureStudioTest.m | 2 +- .../GuiLayoutNerveResponseAnalysisTest.m | 2 +- .../GuiLayoutResponseReviewStatsTest.m | 2 +- .../rhs_preview/GuiLayoutRhsPreviewTest.m | 2 +- .../ecg_print/GuiLayoutEcgPrintTest.m | 2 +- .../ui/AnchorEditorGestureTest.m | 2 +- .../ui/GuiLayoutUiAnchorCurveEditorTest.m | 2 +- .../ui/GuiLayoutUiAppRuntimeTest.m | 2 +- .../ui/GuiLayoutUiAxesWorkbenchTest.m | 2 +- .../ui/GuiLayoutUiBasicControlsTest.m | 2 +- .../ui/GuiLayoutUiBusyStateTest.m | 2 +- .../ui/GuiLayoutUiDebugTraceTest.m | 2 +- .../ui/GuiLayoutUiDeclarativeAppTest.m | 2 +- .../ui/GuiLayoutUiImageAxesRuntimeTest.m | 2 +- .../ui/GuiLayoutUiPlotHelpersTest.m | 2 +- .../ui/GuiLayoutUiScaleBarPanelTest.m | 2 +- .../ui/GuiLayoutUiScaleBarToolTest.m | 2 +- .../labkit_framework/ui/RuntimeGestureTest.m | 2 +- .../labkit_framework/ui/ScaleBarGestureTest.m | 2 +- .../launcher/LauncherCodeAnalyzerTest.m | 2 +- .../gui/project/launcher/LauncherGuiTest.m | 2 +- .../project/launcher/LauncherPackagingTest.m | 2 +- .../project/launcher/LauncherProfilerTest.m | 2 +- tests/runLabKitTests.m | 3 +- tests/runner/labkitArtifactPaths.m | 5 +- tests/runner/labkitProgressPlugin.m | 122 +++++++++++++++++- 45 files changed, 259 insertions(+), 43 deletions(-) diff --git a/.github/workflows/matlab-tests.yml b/.github/workflows/matlab-tests.yml index c1d4f3dd..bf4663ea 100644 --- a/.github/workflows/matlab-tests.yml +++ b/.github/workflows/matlab-tests.yml @@ -43,6 +43,7 @@ jobs: run: mkdir -p artifacts/logs/headless - name: Run headless tests + timeout-minutes: 20 uses: matlab-actions/run-build@v3 with: tasks: headless @@ -63,13 +64,15 @@ jobs: python scripts/summarize_junit.py "$junit" \ --run-name "$run_name" \ --html "artifacts/test-results/${run_name}/html/index.html" \ - --log "$log_path" + --log "$log_path" \ + --active-test "artifacts/logs/${run_name}/active-test.json" done if [ "$found" -eq 0 ]; then python scripts/summarize_junit.py artifacts/test-results/headless/junit.xml \ --run-name headless \ --html artifacts/test-results/headless/html/index.html \ - --log artifacts/logs/headless/matlab.log + --log artifacts/logs/headless/matlab.log \ + --active-test artifacts/logs/headless/active-test.json fi - name: Upload headless artifacts @@ -107,6 +110,7 @@ jobs: run: mkdir -p artifacts/logs/coverage - name: Run coverage report + timeout-minutes: 30 uses: matlab-actions/run-build@v3 with: tasks: coverage @@ -120,6 +124,7 @@ jobs: --run-name coverage --html artifacts/test-results/coverage/html/index.html --log artifacts/logs/coverage/matlab.log + --active-test artifacts/logs/coverage/active-test.json - name: Upload coverage artifacts if: always() @@ -157,6 +162,7 @@ jobs: run: mkdir -p artifacts/logs/gui - name: Run GUI tests + timeout-minutes: 20 uses: matlab-actions/run-build@v3 with: tasks: gui @@ -170,6 +176,7 @@ jobs: --run-name gui --html artifacts/test-results/gui/html/index.html --log artifacts/logs/gui/matlab.log + --active-test artifacts/logs/gui/active-test.json - name: Upload GUI artifacts if: always() diff --git a/docs/testing.md b/docs/testing.md index ad6b6875..9484f3d5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -26,6 +26,13 @@ If MATLAB exits before printing a build-task banner such as MATLAB launcher or runtime access failure before diagnosing source or test failures. +Every official run writes `test-progress.jsonl` and `active-test.json` under +its `artifacts/logs//` folder. The first file records suite, test, +and long-test heartbeat events; the second records the last active test and +elapsed time. CI gives the MATLAB execution step a shorter timeout than the +containing job so the always-run summary and artifact upload steps can still +publish these diagnostics when a test stalls before JUnit is complete. + ## Build Tasks Use MATLAB build tasks for the stable official entry points: @@ -234,6 +241,11 @@ Automated GUI tests check: - reusable tool lifecycle - hidden synthetic app workflows +Hidden GUI tests use `matlab.unittest.TestCase` while still constructing real +figures and controls. `matlab.uitest.TestCase` is reserved for visible UI +automation because its display driver emits `ViewReady` callback errors for +hidden figures on CI runners. + App GUI layout tests should express user-facing and app-facing contracts: expected command buttons, dropdown choices, tabs, table columns, axes, callback wiring, and debug trace behavior. They should not assert raw MATLAB component diff --git a/scripts/summarize_junit.py b/scripts/summarize_junit.py index 39c270e2..1bee2733 100644 --- a/scripts/summarize_junit.py +++ b/scripts/summarize_junit.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse +import json import os import re import sys @@ -21,7 +22,8 @@ def main() -> int: args = parse_args() - summary_path = Path(os.environ.get("GITHUB_STEP_SUMMARY", "")) + summary_value = os.environ.get("GITHUB_STEP_SUMMARY", "") + summary_path = Path(summary_value) if summary_value else None junit_path = Path(args.junit_xml) run_name = args.run_name @@ -35,6 +37,7 @@ def main() -> int: artifact_lines(args), "", ] + lines += active_test_lines(args.active_test) lines += log_tail_summary(args.log, args.summary_log_tail_lines) write_summary(summary_path, lines) print_annotation("warning", f"{run_name} report missing", message) @@ -93,6 +96,7 @@ def main() -> int: ) lines.append("") lines += failure_detail_lines(failed_cases, args.max_failure_details) + lines += active_test_lines(args.active_test) lines += log_tail_summary(args.log, args.summary_log_tail_lines) for case in failed_cases[: args.max_annotations]: @@ -123,6 +127,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--run-name", required=True, help="Display name in GitHub summaries.") parser.add_argument("--html", default="", help="Path to the MATLAB HTML test report.") parser.add_argument("--log", default="", help="Path to the MATLAB log file.") + parser.add_argument( + "--active-test", + default="", + help="Path to the runner's active-test JSON diagnostic.", + ) parser.add_argument("--max-failures", type=int, default=20) parser.add_argument("--max-annotations", type=int, default=20) parser.add_argument("--max-failure-details", type=int, default=5) @@ -190,10 +199,38 @@ def artifact_lines(args: argparse.Namespace) -> str: ) if args.log: rows.append(f"- MATLAB log: `{args.log}`") + if args.active_test: + rows.append(f"- Active-test diagnostic: `{args.active_test}`") rows.append(f"- JUnit XML: `{args.junit_xml}`") return "\n".join(rows) +def active_test_lines(active_test_path: str) -> list[str]: + if not active_test_path: + return [] + path = Path(active_test_path) + if not path.is_file(): + return [f"> Active-test diagnostic not found: `{path}`", ""] + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"> Could not read active-test diagnostic `{path}`: {exc}", ""] + + test_name = str(payload.get("test", "unknown")) + state = str(payload.get("event", "unknown")) + elapsed = to_float(payload.get("testElapsedSeconds")) + timestamp = str(payload.get("timestamp", "unknown")) + return [ + "#### Last active test", + "", + f"- Test: `{markdown_escape(test_name)}`", + f"- State: `{markdown_escape(state)}`", + f"- Test elapsed at last update: `{elapsed:.2f}s`", + f"- Last update: `{markdown_escape(timestamp)}`", + "", + ] + + def github_artifacts_url() -> str: server = os.environ.get("GITHUB_SERVER_URL", "") repo = os.environ.get("GITHUB_REPOSITORY", "") @@ -374,8 +411,8 @@ def print_annotation(level: str, title: str, message: str) -> None: print(f"::{level} title={escape_command(title)}::{escape_command(message)}") -def write_summary(path: Path, lines: Iterable[str]) -> None: - if not str(path): +def write_summary(path: Path | None, lines: Iterable[str]) -> None: + if path is None: return with path.open("a", encoding="utf-8") as handle: handle.write("\n".join(lines)) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index fece0fd4..63e82b40 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -163,6 +163,11 @@ Tests mirror source ownership. Do not create a parallel runner framework unless Hidden mode must still create real MATLAB figures and controls rather than mock GUI objects; visible or minimized GUI mode is for observing the same automated checks during local diagnosis. +- Hidden GUI tests that invoke semantic callbacks or inspect real controls must + inherit from `matlab.unittest.TestCase`. Use `matlab.uitest.TestCase` only + for a visible automation workflow that actually calls its press, choose, + drag, scroll, or type methods; its display driver is incompatible with + hidden CI figures. ## Fixture and Hygiene Rules diff --git a/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m b/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m index 41f918f9..8ece7bf5 100644 --- a/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m +++ b/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m @@ -157,6 +157,24 @@ function testCasePathsUseExplicitOwners(testCase) testCase.verifyEmpty(blocked, ... "Test paths should use explicit owners: apps, labkit_framework, or project."); end + + function hiddenGuiTestsAvoidUiAutomationDriver(testCase) + root = setupLabKitTestPath(); + files = trackedTestCaseFiles(root); + files = files(contains(files, "/tests/cases/gui/")); + offenders = strings(1, 0); + for k = 1:numel(files) + source = string(fileread(fullfile(root, extractAfter(files(k), 1)))); + if contains(source, "< matlab.uitest.TestCase") + offenders(end + 1) = files(k); + end + end + testCase.verifyEmpty(offenders, ... + ["Hidden GUI tests must use matlab.unittest.TestCase. " + ... + "matlab.uitest.TestCase installs a display-only automation driver " + ... + "that emits ViewReady callback errors for hidden figures. Findings: " + ... + strjoin(offenders, ", ")]); + end end end diff --git a/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m b/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m index 465e9465..429b99bc 100644 --- a/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m +++ b/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m @@ -76,6 +76,14 @@ function focusedRunnerCanSkipHtmlReport(testCase) testCase.verifyTrue(isfile(output.artifacts.junitXml), ... "Focused runner should still write JUnit when HTML is disabled."); + testCase.verifyTrue(isfile(output.artifacts.testProgress), ... + "Focused runner should write machine-readable test progress."); + testCase.verifyTrue(isfile(output.artifacts.activeTest), ... + "Focused runner should preserve the last active-test state."); + progressText = string(fileread(output.artifacts.testProgress)); + testCase.verifyTrue(contains(progressText, '"event":"test_start"') && ... + contains(progressText, '"event":"test_done"'), ... + "Progress artifacts should identify test start and completion events."); testCase.verifyFalse(isfile(fullfile(output.artifacts.testHtml, "index.html")), ... "HtmlReport=false should skip the HTML report index."); testCase.verifyTrue(startsWith(string(output.artifacts.root), string(artifactsRoot)), ... diff --git a/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m b/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m index 6b9d873a..48364c0c 100644 --- a/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m +++ b/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m @@ -140,6 +140,11 @@ function ciMatlabJobsHaveTimeouts(testCase) job = extractWorkflowJob(workflow, jobNames(k)); testCase.verifyTrue(contains(job, "timeout-minutes:"), ... "CI MATLAB job should have an explicit timeout: " + jobNames(k)); + testCase.verifyGreaterThanOrEqual(count(job, "timeout-minutes:"), 2, ... + "CI MATLAB execution should time out before its job so diagnostics can upload: " + ... + jobNames(k)); + testCase.verifyTrue(contains(job, "--active-test"), ... + "CI summaries should report the last active test: " + jobNames(k)); end end diff --git a/tests/cases/gui/apps/AppLaunchGuiTest.m b/tests/cases/gui/apps/AppLaunchGuiTest.m index 68938aac..60a763c3 100644 --- a/tests/cases/gui/apps/AppLaunchGuiTest.m +++ b/tests/cases/gui/apps/AppLaunchGuiTest.m @@ -1,4 +1,4 @@ -classdef AppLaunchGuiTest < matlab.uitest.TestCase +classdef AppLaunchGuiTest < matlab.unittest.TestCase %APPLAUNCHGUITEST Guard supported apps against missing dedicated GUI tests. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m b/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m index 24be773b..76466606 100644 --- a/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m +++ b/tests/cases/gui/apps/electrochem/chrono_overlay/GuiLayoutChronoOverlayTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutChronoOverlayTest < matlab.uitest.TestCase +classdef GuiLayoutChronoOverlayTest < matlab.unittest.TestCase %GUILAYOUTCHRONOOVERLAYTEST Verify chrono overlay GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m b/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m index 1e271035..69b51e00 100644 --- a/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m +++ b/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutCicTest < matlab.uitest.TestCase +classdef GuiLayoutCicTest < matlab.unittest.TestCase %GUILAYOUTCICTEST Verify CIC GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m b/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m index 7cac47ac..fd59528e 100644 --- a/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m +++ b/tests/cases/gui/apps/electrochem/csc/GuiLayoutCscTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutCscTest < matlab.uitest.TestCase +classdef GuiLayoutCscTest < matlab.unittest.TestCase %GUILAYOUTCSCTEST Verify CSC GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m b/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m index 51751e21..73ee5e10 100644 --- a/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m +++ b/tests/cases/gui/apps/electrochem/eis/GuiLayoutEisTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutEisTest < matlab.uitest.TestCase +classdef GuiLayoutEisTest < matlab.unittest.TestCase %GUILAYOUTEISTEST Verify EIS GUI layout and workflow contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m b/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m index b9c556c0..f9ef3317 100644 --- a/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m +++ b/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutVtResistanceTest < matlab.uitest.TestCase +classdef GuiLayoutVtResistanceTest < matlab.unittest.TestCase %GUILAYOUTVTRESISTANCETEST Verify VT resistance GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m b/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m index 006057ff..3e2de019 100644 --- a/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m +++ b/tests/cases/gui/apps/image_measurement/batch_crop/GuiLayoutBatchCropTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutBatchCropTest < matlab.uitest.TestCase +classdef GuiLayoutBatchCropTest < matlab.unittest.TestCase %GUILAYOUTBATCHCROPTEST Verify batch crop GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m b/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m index 50a48a82..2931dd8a 100644 --- a/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m +++ b/tests/cases/gui/apps/image_measurement/curvature/GuiLayoutCurvatureTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutCurvatureTest < matlab.uitest.TestCase +classdef GuiLayoutCurvatureTest < matlab.unittest.TestCase %GUILAYOUTCURVATURETEST Verify curvature measurement GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m b/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m index 8c0c88a7..393d9b78 100644 --- a/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m +++ b/tests/cases/gui/apps/image_measurement/flir_thermal/GuiLayoutFlirThermalTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutFlirThermalTest < matlab.uitest.TestCase +classdef GuiLayoutFlirThermalTest < matlab.unittest.TestCase %GUILAYOUTFLIRTHERMALTEST Verify FLIR thermal GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m b/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m index cd582c86..f8fa7b61 100644 --- a/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m +++ b/tests/cases/gui/apps/image_measurement/focus_stack/GuiLayoutFocusStackTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutFocusStackTest < matlab.uitest.TestCase +classdef GuiLayoutFocusStackTest < matlab.unittest.TestCase %GUILAYOUTFOCUSSTACKTEST Verify focus stack GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m b/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m index c6b84820..97222256 100644 --- a/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m +++ b/tests/cases/gui/apps/image_measurement/image_enhance/GuiLayoutImageEnhanceTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutImageEnhanceTest < matlab.uitest.TestCase +classdef GuiLayoutImageEnhanceTest < matlab.unittest.TestCase %GUILAYOUTIMAGEENHANCETEST Verify image enhance GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m b/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m index 9bb60c83..d0b700a9 100644 --- a/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m +++ b/tests/cases/gui/apps/image_measurement/image_match/GuiLayoutImageMatchTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutImageMatchTest < matlab.uitest.TestCase +classdef GuiLayoutImageMatchTest < matlab.unittest.TestCase %GUILAYOUTIMAGEMATCHTEST Verify image match GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m b/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m index 7df49dbe..08f43f4d 100644 --- a/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m +++ b/tests/cases/gui/apps/labkit_core/figure_studio/GuiLayoutFigureStudioTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutFigureStudioTest < matlab.uitest.TestCase +classdef GuiLayoutFigureStudioTest < matlab.unittest.TestCase %GUILAYOUTFIGURESTUDIOTEST Verify Figure Studio launch and controls. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m b/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m index e55b6458..9ed156e6 100644 --- a/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m +++ b/tests/cases/gui/apps/neurophysiology/nerve_response_analysis/GuiLayoutNerveResponseAnalysisTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutNerveResponseAnalysisTest < matlab.uitest.TestCase +classdef GuiLayoutNerveResponseAnalysisTest < matlab.unittest.TestCase %GUILAYOUTNERVERESPONSEANALYSISTEST Verify nerve-response GUI workflow. methods (Test, TestTags = {'GUI', 'Workflow'}) diff --git a/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m b/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m index 77180d6d..59f3066e 100644 --- a/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m +++ b/tests/cases/gui/apps/neurophysiology/response_review_stats/GuiLayoutResponseReviewStatsTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutResponseReviewStatsTest < matlab.uitest.TestCase +classdef GuiLayoutResponseReviewStatsTest < matlab.unittest.TestCase %GUILAYOUTRESPONSEREVIEWSTATSTEST Verify response-review GUI workflow. methods (Test, TestTags = {'GUI', 'Workflow'}) diff --git a/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m b/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m index 848efc9b..d317175d 100644 --- a/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m +++ b/tests/cases/gui/apps/neurophysiology/rhs_preview/GuiLayoutRhsPreviewTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutRhsPreviewTest < matlab.uitest.TestCase +classdef GuiLayoutRhsPreviewTest < matlab.unittest.TestCase %GUILAYOUTRHSPREVIEWTEST Verify RHS Preview GUI workflow contracts. methods (Test, TestTags = {'GUI', 'Workflow'}) diff --git a/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m b/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m index 7164fa97..861128c4 100644 --- a/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m +++ b/tests/cases/gui/apps/wearable/ecg_print/GuiLayoutEcgPrintTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutEcgPrintTest < matlab.uitest.TestCase +classdef GuiLayoutEcgPrintTest < matlab.unittest.TestCase %GUILAYOUTECGPRINTTEST Verify ECG Print GUI layout contracts. methods (Test, TestTags = {'GUI', 'Structural', 'Workflow'}) diff --git a/tests/cases/gui/labkit_framework/ui/AnchorEditorGestureTest.m b/tests/cases/gui/labkit_framework/ui/AnchorEditorGestureTest.m index c4023549..13ac7097 100644 --- a/tests/cases/gui/labkit_framework/ui/AnchorEditorGestureTest.m +++ b/tests/cases/gui/labkit_framework/ui/AnchorEditorGestureTest.m @@ -1,4 +1,4 @@ -classdef AnchorEditorGestureTest < matlab.uitest.TestCase +classdef AnchorEditorGestureTest < matlab.unittest.TestCase %ANCHOREDITORGESTURETEST Gesture-level anchor editor operation coverage. methods (Test, TestTags = {'GUI', 'Gesture'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m index 3efdb9b9..ee64007c 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiAnchorCurveEditorTest < matlab.uitest.TestCase +classdef GuiLayoutUiAnchorCurveEditorTest < matlab.unittest.TestCase %GUILAYOUTUIANCHORCURVEEDITORTEST Verify LabKit behavior through official MATLAB tests. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAppRuntimeTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAppRuntimeTest.m index f3c96c97..d1538586 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAppRuntimeTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAppRuntimeTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiAppRuntimeTest < matlab.uitest.TestCase +classdef GuiLayoutUiAppRuntimeTest < matlab.unittest.TestCase %GUILAYOUTUIAPPRUNTIMETEST Verify declarative app runtime contracts. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAxesWorkbenchTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAxesWorkbenchTest.m index e1aa0e73..c385c948 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAxesWorkbenchTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAxesWorkbenchTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiAxesWorkbenchTest < matlab.uitest.TestCase +classdef GuiLayoutUiAxesWorkbenchTest < matlab.unittest.TestCase %GUILAYOUTUIAXESWORKBENCHTEST Verify UI 5 shell and axes behavior. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBasicControlsTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBasicControlsTest.m index 96a29a5b..e9f4453d 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBasicControlsTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBasicControlsTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiBasicControlsTest < matlab.uitest.TestCase +classdef GuiLayoutUiBasicControlsTest < matlab.unittest.TestCase %GUILAYOUTUIBASICCONTROLSTEST Verify UI 5 control and plot helper contracts. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBusyStateTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBusyStateTest.m index 45d42cb6..7275828c 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBusyStateTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiBusyStateTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiBusyStateTest < matlab.uitest.TestCase +classdef GuiLayoutUiBusyStateTest < matlab.unittest.TestCase %GUILAYOUTUIBUSYSTATETEST Verify LabKit behavior through official MATLAB tests. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDebugTraceTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDebugTraceTest.m index 77d0fad9..bb5be77a 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDebugTraceTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDebugTraceTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiDebugTraceTest < matlab.uitest.TestCase +classdef GuiLayoutUiDebugTraceTest < matlab.unittest.TestCase %GUILAYOUTUIDEBUGTRACETEST Verify LabKit behavior through official MATLAB tests. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDeclarativeAppTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDeclarativeAppTest.m index 342b79cb..ef33366f 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDeclarativeAppTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiDeclarativeAppTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiDeclarativeAppTest < matlab.uitest.TestCase +classdef GuiLayoutUiDeclarativeAppTest < matlab.unittest.TestCase %GUILAYOUTUIDECLARATIVEAPPTEST Verify UI 5 app builder contracts. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiImageAxesRuntimeTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiImageAxesRuntimeTest.m index b525f98f..f68dd947 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiImageAxesRuntimeTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiImageAxesRuntimeTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiImageAxesRuntimeTest < matlab.uitest.TestCase +classdef GuiLayoutUiImageAxesRuntimeTest < matlab.unittest.TestCase %GUILAYOUTUIIMAGEAXESRUNTIMETEST Verify LabKit behavior through official MATLAB tests. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiPlotHelpersTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiPlotHelpersTest.m index 377ec613..539b9ee0 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiPlotHelpersTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiPlotHelpersTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiPlotHelpersTest < matlab.uitest.TestCase +classdef GuiLayoutUiPlotHelpersTest < matlab.unittest.TestCase %GUILAYOUTUIPLOTHELPERSTEST Verify UI 5 plot helper contracts. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarPanelTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarPanelTest.m index 24c81e76..0bd0113a 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarPanelTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarPanelTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiScaleBarPanelTest < matlab.uitest.TestCase +classdef GuiLayoutUiScaleBarPanelTest < matlab.unittest.TestCase %GUILAYOUTUISCALEBARPANELTEST Verify LabKit behavior through official MATLAB tests. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarToolTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarToolTest.m index ebe45b5d..57a52eb6 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarToolTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiScaleBarToolTest.m @@ -1,4 +1,4 @@ -classdef GuiLayoutUiScaleBarToolTest < matlab.uitest.TestCase +classdef GuiLayoutUiScaleBarToolTest < matlab.unittest.TestCase %GUILAYOUTUISCALEBARTOOLTEST Verify LabKit behavior through official MATLAB tests. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/labkit_framework/ui/RuntimeGestureTest.m b/tests/cases/gui/labkit_framework/ui/RuntimeGestureTest.m index fc0e0878..f7044d6a 100644 --- a/tests/cases/gui/labkit_framework/ui/RuntimeGestureTest.m +++ b/tests/cases/gui/labkit_framework/ui/RuntimeGestureTest.m @@ -1,4 +1,4 @@ -classdef RuntimeGestureTest < matlab.uitest.TestCase +classdef RuntimeGestureTest < matlab.unittest.TestCase %RUNTIMEGESTURETEST Gesture-level checks for image axes runtime ownership. methods (Test, TestTags = {'GUI', 'Gesture'}) diff --git a/tests/cases/gui/labkit_framework/ui/ScaleBarGestureTest.m b/tests/cases/gui/labkit_framework/ui/ScaleBarGestureTest.m index 73d3d573..f9efaaad 100644 --- a/tests/cases/gui/labkit_framework/ui/ScaleBarGestureTest.m +++ b/tests/cases/gui/labkit_framework/ui/ScaleBarGestureTest.m @@ -1,4 +1,4 @@ -classdef ScaleBarGestureTest < matlab.uitest.TestCase +classdef ScaleBarGestureTest < matlab.unittest.TestCase %SCALEBARGESTURETEST Gesture-level scale-bar lifecycle coverage. methods (Test, TestTags = {'GUI', 'Gesture'}) diff --git a/tests/cases/gui/project/launcher/LauncherCodeAnalyzerTest.m b/tests/cases/gui/project/launcher/LauncherCodeAnalyzerTest.m index d5afff2b..f23c3c32 100644 --- a/tests/cases/gui/project/launcher/LauncherCodeAnalyzerTest.m +++ b/tests/cases/gui/project/launcher/LauncherCodeAnalyzerTest.m @@ -1,4 +1,4 @@ -classdef LauncherCodeAnalyzerTest < matlab.uitest.TestCase +classdef LauncherCodeAnalyzerTest < matlab.unittest.TestCase %LAUNCHERCODEANALYZERTEST Verify launcher Code Analyzer action. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/project/launcher/LauncherGuiTest.m b/tests/cases/gui/project/launcher/LauncherGuiTest.m index d9f079fb..955f6e80 100644 --- a/tests/cases/gui/project/launcher/LauncherGuiTest.m +++ b/tests/cases/gui/project/launcher/LauncherGuiTest.m @@ -1,4 +1,4 @@ -classdef LauncherGuiTest < matlab.uitest.TestCase +classdef LauncherGuiTest < matlab.unittest.TestCase %LAUNCHERGUITEST Verify the root launcher without launching every app. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/project/launcher/LauncherPackagingTest.m b/tests/cases/gui/project/launcher/LauncherPackagingTest.m index 14273101..34d3ec6f 100644 --- a/tests/cases/gui/project/launcher/LauncherPackagingTest.m +++ b/tests/cases/gui/project/launcher/LauncherPackagingTest.m @@ -1,4 +1,4 @@ -classdef LauncherPackagingTest < matlab.uitest.TestCase +classdef LauncherPackagingTest < matlab.unittest.TestCase %LAUNCHERPACKAGINGTEST Verify launcher multi-app package wiring. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/cases/gui/project/launcher/LauncherProfilerTest.m b/tests/cases/gui/project/launcher/LauncherProfilerTest.m index bfda4735..ffe9ee9a 100644 --- a/tests/cases/gui/project/launcher/LauncherProfilerTest.m +++ b/tests/cases/gui/project/launcher/LauncherProfilerTest.m @@ -1,4 +1,4 @@ -classdef LauncherProfilerTest < matlab.uitest.TestCase +classdef LauncherProfilerTest < matlab.unittest.TestCase %LAUNCHERPROFILERTEST Verify launcher-managed app profiling. methods (Test, TestTags = {'GUI', 'Structural'}) diff --git a/tests/runLabKitTests.m b/tests/runLabKitTests.m index 04fe7289..3dff1ac5 100644 --- a/tests/runLabKitTests.m +++ b/tests/runLabKitTests.m @@ -57,10 +57,11 @@ "RunName", opts.RunName, ... "Create", false); ensureDirectory(paths.testResults); + ensureDirectory(paths.logs); runner = matlab.unittest.TestRunner.withTextOutput( ... "OutputDetail", opts.OutputDetail, ... "LoggingLevel", opts.LoggingLevel); - runner.addPlugin(labkitProgressPlugin); + runner.addPlugin(labkitProgressPlugin(paths.logs)); runner.addPlugin(matlab.unittest.plugins.XMLPlugin.producingJUnitFormat( ... paths.junitXml)); if opts.HtmlReport diff --git a/tests/runner/labkitArtifactPaths.m b/tests/runner/labkitArtifactPaths.m index 85ffddd3..b7e53266 100644 --- a/tests/runner/labkitArtifactPaths.m +++ b/tests/runner/labkitArtifactPaths.m @@ -7,7 +7,8 @@ % Create logical flag that creates directories when true % % Output fields include JUnit XML, HTML test results, coverage, MATLAB log, -% app debug trace, GUI trace, and GUI snapshot locations. +% machine-readable test progress, app debug trace, GUI trace, and GUI +% snapshot locations. p = inputParser; p.addParameter("Root", defaultArtifactRoot(), @(v) ischar(v) || isstring(v)); @@ -30,6 +31,8 @@ paths.coverageHtml = fullfile(paths.coverage, "html"); paths.logs = artifactPath(artifactRoot, "logs", runName); paths.matlabLog = fullfile(paths.logs, "matlab.log"); + paths.testProgress = fullfile(paths.logs, "test-progress.jsonl"); + paths.activeTest = fullfile(paths.logs, "active-test.json"); paths.debug = artifactPath(artifactRoot, "debug", runName); paths.gui = artifactPath(artifactRoot, "gui", runName); paths.guiTrace = fullfile(paths.gui, "trace"); diff --git a/tests/runner/labkitProgressPlugin.m b/tests/runner/labkitProgressPlugin.m index 6d5099fe..6a689e4e 100644 --- a/tests/runner/labkitProgressPlugin.m +++ b/tests/runner/labkitProgressPlugin.m @@ -9,6 +9,21 @@ StartedTests = 0 CompletedTests = 0 SuiteTimer = [] + TestTimer = [] + ActiveTestName = "" + ProgressFile = "" + ActiveTestFile = "" + HeartbeatTimer = [] + end + + methods + function plugin = labkitProgressPlugin(logFolder) + if nargin < 1 || strlength(string(logFolder)) == 0 + return; + end + plugin.ProgressFile = fullfile(string(logFolder), "test-progress.jsonl"); + plugin.ActiveTestFile = fullfile(string(logFolder), "active-test.json"); + end end methods (Access = protected) @@ -19,7 +34,12 @@ function runTestSuite(plugin, pluginData) plugin.SuiteTimer = tic; fprintf("LabKit test progress: 0/%d elapsed=00:00:00 eta=unknown\n", ... plugin.TotalTests); + plugin.recordEvent("suite_start", "", 0); + plugin.startHeartbeat(); + cleanup = onCleanup(@() plugin.stopHeartbeat()); runTestSuite@matlab.unittest.plugins.TestRunnerPlugin(plugin, pluginData); + plugin.recordEvent("suite_done", "", toc(plugin.SuiteTimer)); + clear cleanup end function runTest(plugin, pluginData) @@ -27,15 +47,24 @@ function runTest(plugin, pluginData) testIndex = plugin.StartedTests; testName = string(pluginData.Name); testTimer = tic; + plugin.ActiveTestName = testName; + plugin.TestTimer = testTimer; + plugin.recordEvent("test_start", testName, 0); + plugin.writeActiveTest("running", testName, 0); fprintf("START [%d/%d %5.1f%% elapsed=%s eta=%s] %s\n", ... testIndex, plugin.TotalTests, plugin.percentStarted(), ... plugin.elapsedText(), plugin.etaText(), testName); runTest@matlab.unittest.plugins.TestRunnerPlugin(plugin, pluginData); plugin.CompletedTests = plugin.CompletedTests + 1; + testElapsed = toc(testTimer); fprintf("DONE [%d/%d %5.1f%% elapsed=%s eta=%s +%s] %s\n", ... plugin.CompletedTests, plugin.TotalTests, plugin.percentComplete(), ... plugin.elapsedText(), plugin.etaText(), ... - labkitProgressPlugin.formatSeconds(toc(testTimer)), testName); + labkitProgressPlugin.formatSeconds(testElapsed), testName); + plugin.recordEvent("test_done", testName, testElapsed); + plugin.writeActiveTest("completed", testName, testElapsed); + plugin.ActiveTestName = ""; + plugin.TestTimer = []; end end @@ -78,6 +107,97 @@ function runTest(plugin, pluginData) value = 100 * double(plugin.CompletedTests) / double(plugin.TotalTests); end end + + function startHeartbeat(plugin) + plugin.stopHeartbeat(); + try + plugin.HeartbeatTimer = timer( ... + "ExecutionMode", "fixedSpacing", ... + "Period", 30, ... + "BusyMode", "drop", ... + "TimerFcn", @(~, ~) plugin.emitHeartbeat()); + start(plugin.HeartbeatTimer); + catch + plugin.HeartbeatTimer = []; + end + end + + function stopHeartbeat(plugin) + timerObj = plugin.HeartbeatTimer; + plugin.HeartbeatTimer = []; + if isempty(timerObj) + return; + end + try + if isvalid(timerObj) + stop(timerObj); + delete(timerObj); + end + catch + end + end + + function emitHeartbeat(plugin) + if strlength(plugin.ActiveTestName) == 0 || isempty(plugin.TestTimer) + return; + end + elapsed = toc(plugin.TestTimer); + fprintf("HEARTBEAT [%d/%d elapsed=%s test_elapsed=%s] %s\n", ... + plugin.StartedTests, plugin.TotalTests, plugin.elapsedText(), ... + labkitProgressPlugin.formatSeconds(elapsed), plugin.ActiveTestName); + plugin.recordEvent("heartbeat", plugin.ActiveTestName, elapsed); + plugin.writeActiveTest("running", plugin.ActiveTestName, elapsed); + end + + function recordEvent(plugin, eventName, testName, testElapsed) + if strlength(plugin.ProgressFile) == 0 + return; + end + payload = plugin.progressPayload(eventName, testName, testElapsed); + plugin.writeJson(plugin.ProgressFile, payload, "a"); + end + + function writeActiveTest(plugin, status, testName, testElapsed) + if strlength(plugin.ActiveTestFile) == 0 + return; + end + payload = plugin.progressPayload(status, testName, testElapsed); + plugin.writeJson(plugin.ActiveTestFile, payload, "w"); + end + + function payload = progressPayload(plugin, eventName, testName, testElapsed) + payload = struct( ... + "timestamp", char(datetime("now", "TimeZone", "UTC", ... + "Format", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")), ... + "event", char(string(eventName)), ... + "test", char(string(testName)), ... + "started", plugin.StartedTests, ... + "completed", plugin.CompletedTests, ... + "total", plugin.TotalTests, ... + "suiteElapsedSeconds", plugin.suiteElapsedSeconds(), ... + "testElapsedSeconds", double(testElapsed)); + end + + function elapsed = suiteElapsedSeconds(plugin) + if isempty(plugin.SuiteTimer) + elapsed = 0; + else + elapsed = toc(plugin.SuiteTimer); + end + end + + function writeJson(~, filepath, payload, mode) + try + fid = fopen(filepath, mode); + if fid < 0 + return; + end + cleanup = onCleanup(@() fclose(fid)); + fprintf(fid, "%s\n", jsonencode(payload)); + clear cleanup + catch + end + end end methods (Static, Access = private) From 37bd7fd5f1d973d8932cdb8bb6fabd269110074b Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 13:27:46 -0500 Subject: [PATCH 02/15] feat: enforce base MATLAB compatibility --- +labkit/+ui/+interaction/rectangleEditor.m | 321 ++++++++++++++++++ .../+dic_preprocess/+analysisRun/cropImage.m | 24 ++ .../+analysisRun/maskFromCurve.m | 8 +- .../+userInterface/clearCropRoi.m | 6 +- .../+userInterface/selectRigidPointPairs.m | 238 +++++++++++++ .../+userInterface/startCropRoi.m | 18 +- .../+dic_preprocess/definitionActions.m | 24 +- .../+cropGeometry/cropScaledImage.m | 5 - .../+batch_crop/+userInterface/drawPreview.m | 21 +- .../+userInterface/updateWorkbenchFromState.m | 5 +- .../+batch_crop/definitionActions.m | 39 ++- .../+analysisRun/computeCurvatureFit.m | 21 +- .../+focus_stack/+analysisRun/alignImages.m | 51 +-- .../+image_enhance/+analysisRun/applyStep.m | 69 +--- .../+image_enhance/definitionActions.m | 37 +- .../+image_match/+analysisRun/applyMatch.m | 10 - buildfile.m | 15 + docs/testing.md | 12 +- .../generate_image_app_workflow_assets.m | 26 +- docs/ui.md | 4 +- .../build/BuildTaskFrameworkGuardrailTest.m | 13 +- .../hygiene/ToolboxDependencyGuardrailTest.m | 88 +++-- .../packages/PackagePublicSurfaceTest.m | 2 +- .../GuiLayoutDicPreprocessTest.m | 44 +++ .../ui/GuiLayoutUiRectangleEditorTest.m | 58 ++++ .../unit/apps/dic/DicPreprocessOpsTest.m | 23 ++ 26 files changed, 919 insertions(+), 263 deletions(-) create mode 100644 +labkit/+ui/+interaction/rectangleEditor.m create mode 100644 apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropImage.m create mode 100644 apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m create mode 100644 tests/cases/gui/labkit_framework/ui/GuiLayoutUiRectangleEditorTest.m diff --git a/+labkit/+ui/+interaction/rectangleEditor.m b/+labkit/+ui/+interaction/rectangleEditor.m new file mode 100644 index 00000000..3732465c --- /dev/null +++ b/+labkit/+ui/+interaction/rectangleEditor.m @@ -0,0 +1,321 @@ +function editor = rectangleEditor(runtime, imageSize, position, opts) +%RECTANGLEEDITOR Create a draggable and resizable rectangular image overlay. +% +% Usage: +% runtime = labkit.ui.interaction.runtime(ax); +% editor = labkit.ui.interaction.rectangleEditor(runtime, size(image), ... +% [20 20 80 60], struct('onMoved', @storePosition)); +% +% Inputs: +% runtime - interaction runtime returned by labkit.ui.interaction.runtime. +% imageSize - [height width] or full image size used to constrain the ROI. +% position - [x y width height] rectangle in image coordinates. +% opts - optional struct. +% +% Options: +% fixedAspectRatio - logical, default false. +% color - RGB edge/handle color, default [1 1 0]. +% lineWidth - positive scalar, default 1.5. +% lineStyle - MATLAB line style, default '-'. +% minimumSize - [width height], default [1 1]. +% movable - logical, default true. +% resizable - logical, default true. +% onMoving - callback(position) during pointer movement, default []. +% onMoved - callback(position) after pointer release, default []. +% onBackgroundDown - callback(src,event) for background clicks, default []. +% +% Returned editor API: +% getPosition(), setPosition(position), setImageSize(imageSize), +% setBounds([xmin xmax ymin ymax]), setBackground(handle), +% setActive(tf), activateIfAvailable(), isValid(), graphics(), refresh(), +% and delete(). +% +% The editor uses ordinary MATLAB graphics and the LabKit interaction runtime; +% it does not require Image Processing Toolbox ROI objects. + + if nargin < 4 + opts = struct(); + end + assert(isstruct(runtime) && isfield(runtime, 'axes') && ... + isa(runtime.axes, 'function_handle') && ... + isfield(runtime, 'createSession') && ... + isa(runtime.createSession, 'function_handle'), ... + 'First input must be a labkit.ui.interaction.runtime result.'); + + state = struct(); + state.ax = runtime.axes(); + state.imageSize = normalizeImageSize(imageSize); + state.bounds = boundsFromImageSize(state.imageSize); + state.fixedAspectRatio = logical(optionValue(opts, 'fixedAspectRatio', false)); + state.minimumSize = normalizeMinimumSize(optionValue(opts, 'minimumSize', [1 1])); + state.position = constrainPosition(position, state.bounds, [], false, ... + state.minimumSize); + state.aspectRatio = state.position(3) ./ state.position(4); + state.color = optionValue(opts, 'color', [1 1 0]); + state.lineWidth = optionValue(opts, 'lineWidth', 1.5); + state.lineStyle = optionValue(opts, 'lineStyle', '-'); + state.movable = logical(optionValue(opts, 'movable', true)); + state.resizable = logical(optionValue(opts, 'resizable', true)); + state.onMoving = optionValue(opts, 'onMoving', []); + state.onMoved = optionValue(opts, 'onMoved', []); + state.onBackgroundDown = optionValue(opts, 'onBackgroundDown', []); + state.box = []; + state.cornerHandles = gobjects(0); + state.dragMode = ""; + state.dragCorner = 0; + state.dragStartPoint = [0 0]; + state.dragStartPosition = state.position; + state.session = runtime.createSession(struct( ... + 'name', 'rectangleEditor', ... + 'onPointerDown', @onPointerDown, ... + 'installScrollWheel', false)); + + editor = struct( ... + 'getPosition', @getPosition, ... + 'setPosition', @setPosition, ... + 'setImageSize', @setImageSize, ... + 'setBounds', @setBounds, ... + 'setBackground', @setBackground, ... + 'setActive', @setActive, ... + 'activateIfAvailable', @activateIfAvailable, ... + 'isValid', @isValid, ... + 'graphics', @editorGraphics, ... + 'refresh', @refresh, ... + 'delete', @deleteEditor); + + refresh(); + setActive(true); + + function value = getPosition() + value = state.position; + end + + function setPosition(value) + state.position = constrainPosition(value, state.bounds, ... + state.aspectRatio, state.fixedAspectRatio, state.minimumSize); + refresh(); + end + + function setImageSize(value) + state.imageSize = normalizeImageSize(value); + state.bounds = boundsFromImageSize(state.imageSize); + setPosition(state.position); + end + + function setBounds(value) + value = double(value(:).'); + assert(numel(value) == 4 && all(isfinite(value)) && ... + value(2) > value(1) && value(4) > value(3), ... + 'bounds must be finite increasing [xmin xmax ymin ymax] values.'); + state.bounds = value; + setPosition(state.position); + end + + function setBackground(value) + state.session.setBackground(value); + end + + function setActive(value) + if logical(value) + state.session.activate(); + else + state.session.deactivate(); + end + end + + function activateIfAvailable() + state.session.activateIfAvailable(); + end + + function tf = isValid() + expectedCorners = 4 .* double(state.resizable); + tf = isValidGraphic(state.box) && ... + numel(state.cornerHandles) == expectedCorners && ... + all(isgraphics(state.cornerHandles)); + end + + function handles = editorGraphics() + handles = [state.box state.cornerHandles]; + handles = handles(isgraphics(handles)); + end + + function refresh() + ensureGraphics(); + if ~isValidGraphic(state.box) + return; + end + state.box.Position = state.position; + corners = rectangleCorners(state.position); + for iCorner = 1:numel(state.cornerHandles) + state.cornerHandles(iCorner).XData = corners(iCorner, 1); + state.cornerHandles(iCorner).YData = corners(iCorner, 2); + end + state.session.setGraphics(editorGraphics()); + state.session.refresh(); + end + + function deleteEditor() + state.session.delete(); + deleteGraphics(state.cornerHandles); + deleteGraphics(state.box); + state.cornerHandles = gobjects(0); + state.box = []; + end + + function ensureGraphics() + if ~isValidGraphic(state.ax) + return; + end + if ~isValidGraphic(state.box) + state.box = rectangle(state.ax, 'Position', state.position, ... + 'EdgeColor', state.color, 'LineWidth', state.lineWidth, ... + 'LineStyle', state.lineStyle); + end + if ~state.resizable + deleteGraphics(state.cornerHandles); + state.cornerHandles = gobjects(0); + elseif numel(state.cornerHandles) ~= 4 || ... + ~all(isgraphics(state.cornerHandles)) + deleteGraphics(state.cornerHandles); + state.cornerHandles = gobjects(1, 4); + for iCorner = 1:4 + state.cornerHandles(iCorner) = line(state.ax, NaN, NaN, ... + 'LineStyle', 'none', 'Marker', 's', 'MarkerSize', 7, ... + 'MarkerEdgeColor', state.color, ... + 'MarkerFaceColor', state.color); + end + end + end + + function onPointerDown(src, event) + if ~isValid() + return; + end + state.dragCorner = find(state.cornerHandles == src, 1); + if ~isempty(state.dragCorner) + state.dragMode = "resize"; + elseif isequal(src, state.box) && state.movable + state.dragCorner = 0; + state.dragMode = "move"; + else + invokePointerCallback(state.onBackgroundDown, src, event); + return; + end + state.dragStartPoint = axesPoint(state.ax); + state.dragStartPosition = state.position; + state.session.captureDrag(@onDrag, @onRelease); + end + + function onDrag(~, ~) + point = axesPoint(state.ax); + if state.dragMode == "move" + candidate = state.dragStartPosition; + candidate(1:2) = candidate(1:2) + point - state.dragStartPoint; + else + candidate = resizedPosition(state.dragStartPosition, ... + state.dragCorner, point, state.fixedAspectRatio, state.aspectRatio); + end + state.position = constrainPosition(candidate, state.bounds, ... + state.aspectRatio, state.fixedAspectRatio, state.minimumSize); + refresh(); + invokeCallback(state.onMoving, state.position); + end + + function onRelease(~, ~) + state.dragMode = ""; + state.dragCorner = 0; + invokeCallback(state.onMoved, state.position); + end +end + +function value = optionValue(options, name, fallback) + value = fallback; + if isfield(options, name) && ~isempty(options.(name)) + value = options.(name); + end +end + +function imageSize = normalizeImageSize(value) + value = double(value(:).'); + assert(numel(value) >= 2 && all(isfinite(value(1:2))) && ... + all(value(1:2) >= 1), ... + 'imageSize must provide finite positive height and width values.'); + imageSize = value(1:2); +end + +function bounds = boundsFromImageSize(imageSize) + bounds = [1 imageSize(2) 1 imageSize(1)]; +end + +function value = normalizeMinimumSize(value) + value = double(value(:).'); + assert(numel(value) == 2 && all(isfinite(value)) && all(value > 0), ... + 'minimumSize must be a finite positive [width height] vector.'); +end + +function point = axesPoint(ax) + current = double(ax.CurrentPoint); + point = current(1, 1:2); +end + +function corners = rectangleCorners(position) + x = position(1); + y = position(2); + x2 = x + position(3); + y2 = y + position(4); + corners = [x y; x2 y; x2 y2; x y2]; +end + +function position = resizedPosition(startPosition, corner, point, fixedAspect, aspectRatio) + corners = rectangleCorners(startPosition); + oppositeCorner = mod(corner + 1, 4) + 1; + opposite = corners(oppositeCorner, :); + dragged = point; + if fixedAspect + signs = sign(dragged - opposite); + signs(signs == 0) = 1; + height = max(abs(dragged(2) - opposite(2)), ... + abs(dragged(1) - opposite(1)) ./ aspectRatio); + dragged = opposite + signs .* [height .* aspectRatio, height]; + end + position = [min(opposite, dragged), abs(dragged - opposite)]; +end + +function position = constrainPosition(value, bounds, aspectRatio, fixedAspect, minimumSize) + value = double(value(:).'); + assert(numel(value) == 4 && all(isfinite(value)), ... + 'position must be a finite [x y width height] vector.'); + maxSize = [bounds(2) - bounds(1), bounds(4) - bounds(3)]; + extent = max(value(3:4), minimumSize); + if fixedAspect && ~isempty(aspectRatio) + height = max(extent(2), extent(1) ./ aspectRatio); + extent = [height .* aspectRatio, height]; + end + extent = extent .* min([1, maxSize(1) ./ extent(1), ... + maxSize(2) ./ extent(2)]); + origin = min(max(value(1:2), bounds([1 3])), bounds([2 4]) - extent); + position = [origin extent]; +end + +function invokeCallback(callback, position) + if ~isempty(callback) + callback(position); + end +end + +function invokePointerCallback(callback, source, event) + if ~isempty(callback) + callback(source, event); + end +end + +function deleteGraphics(handles) + handles = handles(isgraphics(handles)); + if ~isempty(handles) + delete(handles); + end +end + +function tf = isValidGraphic(handle) + tf = ~isempty(handle) && isgraphics(handle); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropImage.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropImage.m new file mode 100644 index 00000000..3bdd9f00 --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cropImage.m @@ -0,0 +1,24 @@ +% Expected caller: DIC preprocess crop callback and direct unit tests. Inputs +% are one image and an imcrop-style [x y width height] rectangle. Output is +% the inclusive pixel crop clamped to image bounds. Side effects: none. +function cropped = cropImage(imageData, rect) +%CROPIMAGE Crop an image using base-MATLAB indexing. + +% Rectangle width and height follow MATLAB imcrop's inclusive endpoint +% convention, so [x y 10 10] selects 11-by-11 pixels when fully in bounds. + + rect = double(rect); + if numel(rect) ~= 4 || any(~isfinite(rect)) + error('labkit_DICPreprocess_app:InvalidCropRectangle', ... + 'Crop rectangle must contain four finite values.'); + end + firstCol = max(1, round(rect(1))); + firstRow = max(1, round(rect(2))); + lastCol = min(size(imageData, 2), round(rect(1) + rect(3))); + lastRow = min(size(imageData, 1), round(rect(2) + rect(4))); + if lastCol < firstCol || lastRow < firstRow + error('labkit_DICPreprocess_app:EmptyCropRectangle', ... + 'Crop rectangle does not overlap the image.'); + end + cropped = imageData(firstRow:lastRow, firstCol:lastCol, :); +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/maskFromCurve.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/maskFromCurve.m index 5ff4f0a8..d254a0e9 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/maskFromCurve.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/maskFromCurve.m @@ -11,11 +11,7 @@ mask = uint8(false(H, W)); return; end - if exist('poly2mask', 'file') == 2 - inside = poly2mask(curve(:, 1), curve(:, 2), H, W); - else - [x, y] = meshgrid(1:W, 1:H); - inside = inpolygon(x, y, curve(:, 1), curve(:, 2)); - end + [x, y] = meshgrid(1:W, 1:H); + inside = inpolygon(x, y, curve(:, 1), curve(:, 2)); mask = uint8(inside) .* uint8(255); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/clearCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/clearCropRoi.m index 36cb7979..0320042f 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/clearCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/clearCropRoi.m @@ -7,6 +7,10 @@ function clearCropRoi(listeners, topRoi, bottomRoi) for iListener = 1:numel(listeners) dic_preprocess.userInterface.deleteIfValid(listeners{iListener}); end - dic_preprocess.userInterface.deleteIfValid(topRoi); + if isstruct(topRoi) && isfield(topRoi, 'delete') + topRoi.delete(); + else + dic_preprocess.userInterface.deleteIfValid(topRoi); + end dic_preprocess.userInterface.deleteIfValid(bottomRoi); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m new file mode 100644 index 00000000..690beedd --- /dev/null +++ b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m @@ -0,0 +1,238 @@ +% Expected caller: DIC preprocess manual-alignment action. Inputs are moving +% and fixed images. Outputs are matching N-by-2 [x y] point arrays. Side +% effects: opens a modal point-pair editor until the user accepts or cancels. + +function [movingPoints, fixedPoints] = selectRigidPointPairs(movingImage, fixedImage) +%SELECTRIGIDPOINTPAIRS Select draggable matching points without toolboxes. + + movingPoints = zeros(0, 2); + fixedPoints = zeros(0, 2); + pendingMoving = []; + accepted = false; + dragSide = ""; + dragIndex = []; + + fig = uifigure('Name', 'DIC Manual Alignment', ... + 'Position', [100 100 1120 680], ... + 'CloseRequestFcn', @cancelSelection); + cleanup = onCleanup(@() closeIfValid(fig)); + root = uigridlayout(fig, [3 2], ... + 'RowHeight', {34, '1x', 42}, ... + 'ColumnWidth', {'1x', '1x'}, ... + 'Padding', [10 10 10 10]); + status = uilabel(root, ... + 'Text', 'Click a point in the moving image.', ... + 'HorizontalAlignment', 'center', ... + 'FontWeight', 'bold'); + status.Layout.Row = 1; + status.Layout.Column = [1 2]; + movingAxes = uiaxes(root); + movingAxes.Layout.Row = 2; + movingAxes.Layout.Column = 1; + fixedAxes = uiaxes(root); + fixedAxes.Layout.Row = 2; + fixedAxes.Layout.Column = 2; + movingBackground = drawImage(movingAxes, movingImage, 'Moving image'); + fixedBackground = drawImage(fixedAxes, fixedImage, 'Fixed image'); + + buttonGrid = uigridlayout(root, [1 4], ... + 'ColumnWidth', {'1x', 110, 110, 110}, ... + 'Padding', [0 0 0 0]); + buttonGrid.Layout.Row = 3; + buttonGrid.Layout.Column = [1 2]; + countLabel = uilabel(buttonGrid, 'Text', 'Point pairs: 0'); + undoButton = uibutton(buttonGrid, 'Text', 'Undo last', ... + 'Enable', 'off', 'ButtonPushedFcn', @undoLast); + cancelButton = uibutton(buttonGrid, 'Text', 'Cancel', ... + 'ButtonPushedFcn', @cancelSelection); + acceptButton = uibutton(buttonGrid, 'Text', 'Accept pairs', ... + 'Enable', 'off', 'ButtonPushedFcn', @acceptSelection); + + movingRuntime = labkit.ui.interaction.runtime(movingAxes, struct('figure', fig)); + fixedRuntime = labkit.ui.interaction.runtime(fixedAxes, struct('figure', fig)); + movingSession = movingRuntime.createSession(struct( ... + 'name', 'dicMovingControlPoints', ... + 'onPointerDown', @(src, event) onPointerDown("moving", src, event), ... + 'installScrollWheel', false)); + fixedSession = fixedRuntime.createSession(struct( ... + 'name', 'dicFixedControlPoints', ... + 'onPointerDown', @(src, event) onPointerDown("fixed", src, event), ... + 'installScrollWheel', false)); + movingSession.setBackground(movingBackground); + fixedSession.setBackground(fixedBackground); + movingSession.activate(); + fixedSession.activate(); + redrawPoints(); + + uiwait(fig); + if ~accepted + movingPoints = zeros(0, 2); + fixedPoints = zeros(0, 2); + end + clear cleanup + + function onPointerDown(side, source, ~) + if isgraphics(source) && isprop(source, 'UserData') && ... + isstruct(source.UserData) && isfield(source.UserData, 'pointIndex') + dragSide = side; + dragIndex = source.UserData.pointIndex; + activeSession = sessionForSide(side); + activeSession.captureDrag(@onDrag, @onDragReleased); + return; + end + + point = currentPoint(side); + if side == "moving" && isempty(pendingMoving) + pendingMoving = point; + status.Text = 'Click the matching point in the fixed image.'; + elseif side == "fixed" && ~isempty(pendingMoving) + movingPoints(end + 1, :) = pendingMoving; + fixedPoints(end + 1, :) = point; + pendingMoving = []; + status.Text = 'Pair added. Click another point in the moving image.'; + else + status.Text = char("Next expected click: " + expectedSide() + " image."); + end + redrawPoints(); + end + + function onDrag(~, ~) + if isempty(dragIndex) + return; + end + point = currentPoint(dragSide); + if dragIndex == 0 + pendingMoving = point; + elseif dragSide == "moving" + movingPoints(dragIndex, :) = point; + else + fixedPoints(dragIndex, :) = point; + end + redrawPoints(); + end + + function onDragReleased(~, ~) + onDrag([], []); + dragSide = ""; + dragIndex = []; + end + + function undoLast(~, ~) + if ~isempty(pendingMoving) + pendingMoving = []; + elseif ~isempty(movingPoints) + movingPoints(end, :) = []; + fixedPoints(end, :) = []; + end + status.Text = 'Click a point in the moving image.'; + redrawPoints(); + end + + function acceptSelection(~, ~) + if size(movingPoints, 1) < 2 + return; + end + accepted = true; + uiresume(fig); + end + + function cancelSelection(~, ~) + accepted = false; + if isvalid(fig) + uiresume(fig); + end + end + + function redrawPoints() + movingGraphics = drawPointSet(movingAxes, movingPoints, "moving"); + if ~isempty(pendingMoving) + pendingGraphic = drawPoint(movingAxes, pendingMoving, ... + size(movingPoints, 1) + 1, "moving", 0, [1 0.85 0]); + movingGraphics = [movingGraphics; pendingGraphic]; + end + fixedGraphics = drawPointSet(fixedAxes, fixedPoints, "fixed"); + movingSession.setGraphics(movingGraphics); + fixedSession.setGraphics(fixedGraphics); + movingSession.refresh(); + fixedSession.refresh(); + countLabel.Text = sprintf('Point pairs: %d', size(movingPoints, 1)); + undoButton.Enable = enabledText(~isempty(pendingMoving) || ~isempty(movingPoints)); + acceptButton.Enable = enabledText(size(movingPoints, 1) >= 2); + end + + function point = currentPoint(side) + if side == "moving" + ax = movingAxes; + imageSize = size(movingImage); + else + ax = fixedAxes; + imageSize = size(fixedImage); + end + value = double(ax.CurrentPoint); + point = value(1, 1:2); + point(1) = min(max(point(1), 1), imageSize(2)); + point(2) = min(max(point(2), 1), imageSize(1)); + end + + function session = sessionForSide(side) + if side == "moving" + session = movingSession; + else + session = fixedSession; + end + end + + function side = expectedSide() + side = "moving"; + if ~isempty(pendingMoving) + side = "fixed"; + end + end +end + +function background = drawImage(ax, imageData, titleText) + if ndims(imageData) == 2 + background = imagesc(ax, double(imageData)); + colormap(ax, gray(256)); + else + background = image(ax, labkit.image.toRgbDouble(imageData)); + end + axis(ax, 'image'); + ax.YDir = 'reverse'; + ax.Title.String = titleText; + ax.XLabel.String = 'x (px)'; + ax.YLabel.String = 'y (px)'; +end + +function graphics = drawPointSet(ax, points, side) + delete(findobj(ax, 'Tag', 'dicControlPoint')); + graphics = gobjects(0); + for iPoint = 1:size(points, 1) + graphics(end + 1, 1) = drawPoint(ax, points(iPoint, :), ... + iPoint, side, iPoint, [0 0.85 1]); + end +end + +function graphic = drawPoint(ax, point, labelIndex, side, pointIndex, color) + graphic = line(ax, point(1), point(2), ... + 'LineStyle', 'none', 'Marker', '+', 'MarkerSize', 12, ... + 'LineWidth', 2, 'Color', color, 'Tag', 'dicControlPoint', ... + 'UserData', struct('side', side, 'pointIndex', pointIndex)); + text(ax, point(1) + 4, point(2), string(labelIndex), ... + 'Color', color, 'FontWeight', 'bold', ... + 'HitTest', 'off', 'PickableParts', 'none', ... + 'Tag', 'dicControlPoint'); +end + +function value = enabledText(condition) + value = 'off'; + if condition + value = 'on'; + end +end + +function closeIfValid(fig) + if ~isempty(fig) && isvalid(fig) + delete(fig); + end +end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m index 31dc1365..f93d1954 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m @@ -2,7 +2,7 @@ % pair, square rectangle, and ROI movement callback. Output is ROI handles and % listeners. Side effects: draws images and crop graphics in the preview axes. -function cropUi = startCropRoi(ui, referenceImage, movingImage, rect, movedFcn) +function cropUi = startCropRoi(ui, runtime, referenceImage, movingImage, rect, movedFcn) %STARTCROPROI Draw the DIC preprocess crop ROI controls. dic_preprocess.userInterface.drawPreview(ui, dic_preprocess.userInterface.previewRequest( ... @@ -11,17 +11,17 @@ 'referenceImage', [], ... 'movingImage', [], ... 'maskImage', []), 'Current pair')); - cropUi.top = drawrectangle(ui.topAxes, ... - 'Position', rect, ... - 'FixedAspectRatio', true, ... - 'Color', [1 0.85 0], ... - 'LineWidth', 1.5); + cropUi.top = labkit.ui.interaction.rectangleEditor(runtime, ... + size(referenceImage), rect, struct( ... + 'fixedAspectRatio', true, ... + 'color', [1 0.85 0], ... + 'lineWidth', 1.5, ... + 'onMoving', movedFcn, ... + 'onMoved', movedFcn)); cropUi.bottom = rectangle(ui.bottomAxes, ... 'Position', rect, ... 'EdgeColor', [1 0.85 0], ... 'LineWidth', 1.5, ... 'LineStyle', '--'); - cropUi.listeners = { ... - addlistener(cropUi.top, 'MovingROI', movedFcn), ... - addlistener(cropUi.top, 'ROIMoved', movedFcn)}; + cropUi.listeners = {}; end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m b/apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m index 9f373dde..15e01be0 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/definitionActions.m @@ -208,7 +208,9 @@ function onAlign(~, ~) end addLog('Opening point selector. Choose matching points, then accept.'); - [movingPoints, fixedPoints] = cpselect(S.currentMovingImage, S.currentReferenceImage, 'Wait', true); + [movingPoints, fixedPoints] = ... + dic_preprocess.userInterface.selectRigidPointPairs( ... + S.currentMovingImage, S.currentReferenceImage); if size(movingPoints, 1) < 2 labkit.ui.runtime.showAlert(fig, 'Rigid registration requires at least two point pairs.', 'Not enough points'); addLog('Alignment cancelled: fewer than two point pairs.'); @@ -269,7 +271,7 @@ function onStartCropRoi(~, ~) S.cropMoving = []; rect = dic_preprocess.analysisRun.defaultSquareRect(size(S.currentReferenceImage)); S.cropRect = rect; - cropUi = dic_preprocess.userInterface.startCropRoi(ui, ... + cropUi = dic_preprocess.userInterface.startCropRoi(ui, imageRuntime, ... S.currentReferenceImage, S.currentMovingImage, rect, @onCropRoiMoved); S.cropRoiTop = cropUi.top; S.cropRoiBottom = cropUi.bottom; @@ -282,16 +284,19 @@ function onStartCropRoi(~, ~) end function onApplyCropRoi(~, ~) - if isempty(S.cropRoiTop) || ~isvalid(S.cropRoiTop) + if isempty(S.cropRoiTop) || ~S.cropRoiTop.isValid() labkit.ui.runtime.showAlert(fig, 'Start a crop ROI before applying the crop.', 'No active ROI'); return; end - rect = dic_preprocess.analysisRun.squareRectInsideImage(S.cropRoiTop.Position, size(S.currentReferenceImage)); + rect = dic_preprocess.analysisRun.squareRectInsideImage( ... + S.cropRoiTop.getPosition(), size(S.currentReferenceImage)); pushHistory('crop'); S.cropRect = rect; - S.currentReferenceImage = imcrop(S.currentReferenceImage, rect); - S.currentMovingImage = imcrop(S.currentMovingImage, rect); + S.currentReferenceImage = dic_preprocess.analysisRun.cropImage( ... + S.currentReferenceImage, rect); + S.currentMovingImage = dic_preprocess.analysisRun.cropImage( ... + S.currentMovingImage, rect); S.cropReference = S.currentReferenceImage; S.cropMoving = S.currentMovingImage; clearDerivedStateAndMaskEditor(); @@ -311,12 +316,7 @@ function onCancelCropRoi(~, ~) refreshPreview(); end - function onCropRoiMoved(~, evt) - if isprop(evt, 'CurrentPosition') - pos = evt.CurrentPosition; - else - pos = S.cropRoiTop.Position; - end + function onCropRoiMoved(pos) rect = dic_preprocess.analysisRun.squareRectInsideImage(pos, size(S.currentReferenceImage)); S.cropRect = rect; if ~isempty(S.cropRoiBottom) && isvalid(S.cropRoiBottom) diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropScaledImage.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropScaledImage.m index f90db935..04604395 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropScaledImage.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropScaledImage.m @@ -35,11 +35,6 @@ end function out = resizeImage(imageData, outputSize) - if exist('imresize', 'file') == 2 - out = imresize(imageData, outputSize, 'bicubic'); - return; - end - inHeight = size(imageData, 1); inWidth = size(imageData, 2); y = linspace(1, inHeight, outputSize(1)); diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/drawPreview.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/drawPreview.m index a2b7c04b..eb379147 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/drawPreview.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/drawPreview.m @@ -13,23 +13,26 @@ "axis", "crop", ... "options", struct("xData", render.xData, "yData", render.yData)); hold(previewAxes, 'on'); - [hRect, hLineX, hLineY] = drawCropOverlay(previewAxes, geometry, ... + [position, hLineX, hLineY] = drawCropOverlay(previewAxes, geometry, ... placement, item.centerXY, cropSize); hold(previewAxes, 'off'); tools.scaleTool.setBackground(hImage); tools.scaleTool.setImageSize(size(item.image)); tools.scaleTool.refresh(); - tools.cropSession.setBackground(hImage); - tools.cropSession.setGraphics([hRect, hLineX, hLineY]); - tools.cropSession.activateIfAvailable(); + tools.cropEditor.setBounds([placement.xData, placement.yData]); + tools.cropEditor.setPosition(position); + tools.cropEditor.setBackground(hImage); + tools.cropEditor.activateIfAvailable(); + cropGraphics = tools.cropEditor.graphics(); + hRect = cropGraphics(1); batch_crop.userInterface.restorePreviewView(previewAxes, viewState, geometry, placement); handles = struct('image', hImage, 'rect', hRect, ... 'centerX', hLineX, 'centerY', hLineY); end -function [hRect, hLineX, hLineY] = drawCropOverlay(previewAxes, geometry, ... +function [position, hLineX, hLineY] = drawCropOverlay(previewAxes, geometry, ... placement, centerXY, cropSize) previewScale = batch_crop.cropGeometry.geometryScale(geometry); cropWidth = max(1, double(cropSize(1)) * previewScale); @@ -39,18 +42,14 @@ colStart = round(canvasCenterXY(1) - (cropWidth - 1) / 2); rowStart = round(canvasCenterXY(2) - (cropHeight - 1) / 2); position = [colStart - 0.5, rowStart - 0.5, cropWidth, cropHeight]; - hRect = rectangle(previewAxes, 'Position', position, ... - 'EdgeColor', [1 0.84 0], ... - 'LineWidth', 1.5, ... - 'LineStyle', '-'); hLineX = plot(previewAxes, ... [canvasCenterXY(1) - 16, canvasCenterXY(1) + 16], ... [canvasCenterXY(2), canvasCenterXY(2)], ... 'Color', [0 0.85 1], ... - 'LineWidth', 1.25); + 'LineWidth', 1.25, 'HitTest', 'off', 'PickableParts', 'none'); hLineY = plot(previewAxes, ... [canvasCenterXY(1), canvasCenterXY(1)], ... [canvasCenterXY(2) - 16, canvasCenterXY(2) + 16], ... 'Color', [0 0.85 1], ... - 'LineWidth', 1.25); + 'LineWidth', 1.25, 'HitTest', 'off', 'PickableParts', 'none'); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/updateWorkbenchFromState.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/updateWorkbenchFromState.m index 9f4a0f6e..f1b076ff 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/updateWorkbenchFromState.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/updateWorkbenchFromState.m @@ -110,8 +110,7 @@ function renderPreview(state, ui) if ~hasCurrentImage(state) || ~hasTools(state) resetPreviewAxes(ui); if hasTools(state) - state.tools.cropSession.setBackground([]); - state.tools.cropSession.setGraphics([]); + state.tools.cropEditor.setBackground([]); state.tools.scaleTool.setBackground([]); state.tools.scaleTool.setImageSize([]); end @@ -121,7 +120,7 @@ function renderPreview(state, ui) placement = batch_crop.userInterface.previewPlacement(geometry); item = state.items(state.currentIndex); tools = struct('scaleTool', state.tools.scaleTool, ... - 'cropSession', state.tools.cropSession); + 'cropEditor', state.tools.cropEditor); batch_crop.userInterface.drawPreview(ui, state.tools.previewAxes, geometry, ... placement, item, currentCropSize(state, ui), tools, ... state.previewView); diff --git a/apps/image_measurement/batch_crop/+batch_crop/definitionActions.m b/apps/image_measurement/batch_crop/+batch_crop/definitionActions.m index 6205b13d..76c40b9e 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/definitionActions.m +++ b/apps/image_measurement/batch_crop/+batch_crop/definitionActions.m @@ -25,6 +25,7 @@ "exportSettingChanged", @onExportSettingChanged, ... "chooseOutputFolder", @onChooseOutputFolder, ... "previewPointerDown", @onPreviewPointerDown, ... + "cropRectangleMoved", @onCropRectangleMoved, ... "exportCrops", @onExportCrops); end @@ -35,10 +36,12 @@ previewAxes = ui.controls.preview.primaryAxes; imageRuntime = labkit.ui.interaction.runtime(previewAxes, ... struct('figure', fig, 'onTrace', traceFcn(debugLog))); - cropSession = imageRuntime.createSession(struct( ... - 'name', 'batchCropCenter', ... - 'onPointerDown', @(~, ~) services.dispatch("previewPointerDown"), ... - 'installScrollWheel', false)); + cropEditor = labkit.ui.interaction.rectangleEditor(imageRuntime, ... + [2 2], [1 1 1 1], struct( ... + 'resizable', false, ... + 'onMoved', @(position) services.dispatch( ... + "cropRectangleMoved", struct('position', position)), ... + 'onBackgroundDown', @(~, ~) services.dispatch("previewPointerDown"))); scaleTool = labkit.ui.interaction.scaleBar(ui.controls.scaleBarHost.grid, 1, ... imageRuntime, struct( ... 'title', 'Current Image Scale', ... @@ -50,7 +53,7 @@ 'onTrace', traceFcn(debugLog))); state.tools = struct('previewAxes', previewAxes, ... 'imageRuntime', imageRuntime, ... - 'cropSession', cropSession, ... + 'cropEditor', cropEditor, ... 'scaleTool', scaleTool); if strlength(state.outputFolder) == 0 state.outputFolder = string(labkit.ui.runtime.defaultDialogFolder("output")); @@ -319,7 +322,7 @@ function state = onScaleReferenceEditChanged(state, ~, services) if hasTools(state) && state.tools.scaleTool.isReferenceEditActive() - state.tools.cropSession.deactivate(); + state.tools.cropEditor.setActive(false); return; end state.previewView = capturePreviewView(state, services); @@ -364,6 +367,30 @@ state.currentIndex, centerXY(1), centerXY(2))); end +function state = onCropRectangleMoved(state, payload, services) + [state, ok] = ensureCurrentReady(state, services); + if ~ok || ~hasTools(state) || ~isfield(payload.event, 'position') + return; + end + state.previewView = capturePreviewView(state, services); + position = double(payload.event.position); + placement = batch_crop.userInterface.previewPlacement( ... + currentGeometry(state, services)); + canvasXY = [position(1) + position(3) ./ 2, ... + position(2) + position(4) ./ 2] - placement.offset; + centerXY = batch_crop.cropGeometry.canvasToOriginal( ... + currentGeometry(state, services), canvasXY); + centerXY = adjustedCropCenter(state, services, centerXY); + state.items(state.currentIndex).centerXY = centerXY; + state.items(state.currentIndex).centerSet = true; + labkit.ui.control.setValue(services.ui, "centerX", centerXY(1)); + labkit.ui.control.setValue(services.ui, "centerY", centerXY(2)); + state = batch_crop.appState.clearExportState(state); + addLog(services, sprintf( ... + 'Dragged crop center for image %d: x=%.1f, y=%.1f.', ... + state.currentIndex, centerXY(1), centerXY(2))); +end + function state = onExportCrops(state, ~, services) if isempty(state.items) showError(services, 'No images loaded', ... diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m b/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m index d96e7972..6cfa6c10 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m @@ -146,24 +146,9 @@ p0 = [xc0; yc0; R0]; residual = @(p) sqrt((x - p(1)).^2 + (y - p(2)).^2) - abs(p(3)); - useLSQ = exist('lsqnonlin', 'file') == 2; - if useLSQ - try - opts = optimoptions('lsqnonlin', ... - 'Display', 'off', ... - 'MaxFunctionEvaluations', 2e4, ... - 'MaxIterations', 2e4); - p = lsqnonlin(residual, p0, [], [], opts); - catch - useLSQ = false; - end - end - - if ~useLSQ - f = @(p) sum(residual(p).^2); - opts = optimset('Display', 'off', 'MaxFunEvals', 2e4, 'MaxIter', 2e4); - p = fminsearch(f, p0, opts); - end + f = @(p) sum(residual(p).^2); + opts = optimset('Display', 'off', 'MaxFunEvals', 2e4, 'MaxIter', 2e4); + p = fminsearch(f, p0, opts); xc = p(1); yc = p(2); diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m index 98df5c45..5235bc37 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/alignImages.m @@ -45,51 +45,18 @@ movingGray = alignmentGray(movingImage); try - [alignedImage, method] = alignImageWithImregcorr( ... - movingImage, movingGray, fixedGray); + [rowShift, colShift] = estimateTranslationByPhaseCorrelation( ... + fixedGray, movingGray); + alignedImage = translateImageByIntegerShift( ... + movingImage, rowShift, colShift, backgroundFillValues(movingImage)); alignedImage = cast(alignedImage, origClass); - return; + method = sprintf('FFT translation (row %+d, col %+d)', ... + rowShift, colShift); 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'; - 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 + error('labkit_FocusStack_app:RegistrationFailed', ... + 'Base-MATLAB phase-correlation registration failed: %s', ... + registrationErr.message); end - - fixedRef = imref2d(size(fixedGray)); - alignedImage = imwarp(movingImage, tform, ... - 'OutputView', fixedRef, 'FillValues', backgroundFillValues(movingImage)); end function gray = alignmentGray(imageData) diff --git a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m index a6983835..ca333b40 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m @@ -36,45 +36,8 @@ strength = clamp01(double(step.amount) / 100); targetWhite = min(max(double(step.secondary) / 100, 0.70), 0.98); backgroundMask = backgroundMaskFromContext(inputImage, context, requireRoi); - - if exist('rgb2lab', 'file') ~= 2 || exist('lab2rgb', 'file') ~= 2 - outputImage = protectedRgbFallback(inputImage, strength, targetWhite, backgroundMask); - return; - end - - labImage = rgb2lab(inputImage); - sourceL = labImage(:, :, 1) ./ 100; - hsvImage = rgb2hsv(inputImage); - sat = hsvImage(:, :, 2); - stats = luminanceStats(sourceL); - - contrastScale = min(1.18, max(0.92, 0.30 ./ max(stats.contrast, 0.08))); - tonedL = (sourceL - stats.mid) .* contrastScale + stats.mid; - backgroundLevel = weightedMean(tonedL, backgroundMask); - maxLift = 0.22 + 0.23 .* double(requireRoi); - sourceBlend = 0.28 - 0.20 .* double(requireRoi); - lift = min(maxLift, max(0, targetWhite - backgroundLevel)); - brightMask = smoothstep(0.18, 0.74, sourceL); - saturationGuard = 1 - 0.42 .* smoothstep(0.18, 0.58, sat); - tonedL = tonedL + lift .* (0.35 + 0.65 .* brightMask) .* saturationGuard; - tonedL = min(max((1 - sourceBlend) .* tonedL + sourceBlend .* sourceL, 0), 1); - - detail = tonedL - labkit.image.meanFilter2(tonedL, 3); - tonedL = tonedL + (0.08 + 0.05 .* double(stats.contrast < 0.24)) .* detail; - shadowMask = smoothstep(0.24, 0.08, sourceL); - highlightMask = smoothstep(0.88, 0.99, tonedL); - tonedL = tonedL .* (1 - 0.55 .* shadowMask) + sourceL .* (0.55 .* shadowMask); - tonedL = tonedL .* (1 - 0.40 .* highlightMask) + min(tonedL, 0.965) .* (0.40 .* highlightMask); - tonedL = min(max(tonedL, 0.015), 0.99); - - labOut = labImage; - labOut(:, :, 1) = ((1 - strength) .* sourceL + strength .* tonedL) .* 100; - labOut = correctBackgroundLabCast(labOut, backgroundMask, strength); - outputImage = lab2rgb(labOut, 'OutputType', 'double'); - finishLift = 0.08 + 0.14 .* double(requireRoi); - outputImage = finishBackgroundLuma(outputImage, inputImage, ... - backgroundMask, targetWhite, strength, finishLift); - outputImage = min(max(outputImage, 0), 1); + outputImage = protectedRgbFallback( ... + inputImage, strength, targetWhite, backgroundMask); end function roi = roiFromContext(context) @@ -128,19 +91,6 @@ mask = min(max(labkit.image.meanFilter2(mask, 7), 0), 1); end -function labImage = correctBackgroundLabCast(labImage, backgroundMask, strength) - if nnz(backgroundMask > 0.35) < 16 - return; - end - meanA = weightedMean(labImage(:, :, 2), backgroundMask); - meanB = weightedMean(labImage(:, :, 3), backgroundMask); - shiftA = min(2.2, max(-2.2, -meanA)); - shiftB = min(2.2, max(-2.2, -meanB)); - correction = 0.55 .* strength .* backgroundMask; - labImage(:, :, 2) = labImage(:, :, 2) + correction .* shiftA; - labImage(:, :, 3) = labImage(:, :, 3) + correction .* shiftB; -end - function outputImage = protectedRgbFallback(inputImage, strength, targetWhite, backgroundMask) lumaImage = luma(inputImage); stats = luminanceStats(lumaImage); @@ -152,21 +102,6 @@ outputImage = (1 - strength) .* inputImage + strength .* (inputImage .* ratio); end -function outputImage = finishBackgroundLuma(outputImage, inputImage, backgroundMask, targetWhite, strength, maxLift) - lumaImage = luma(outputImage); - backgroundLevel = weightedMean(lumaImage, backgroundMask); - lift = min(maxLift, max(0, targetWhite - backgroundLevel)); - if lift <= 0 - return; - end - hsvImage = rgb2hsv(inputImage); - sourceLuma = luma(inputImage); - saturationGuard = 1 - 0.58 .* smoothstep(0.18, 0.58, hsvImage(:, :, 2)); - brightMask = smoothstep(0.18, 0.74, sourceLuma); - correction = strength .* lift .* (0.35 + 0.65 .* brightMask) .* saturationGuard; - outputImage = outputImage + repmat(correction, 1, 1, 3); -end - function stats = luminanceStats(value) values = sort(double(value(isfinite(value)))); stats = struct('low', 0, 'mid', 0, 'high', 0, 'contrast', 0); diff --git a/apps/image_measurement/image_enhance/+image_enhance/definitionActions.m b/apps/image_measurement/image_enhance/+image_enhance/definitionActions.m index d8507aec..ba801423 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/definitionActions.m +++ b/apps/image_measurement/image_enhance/+image_enhance/definitionActions.m @@ -8,6 +8,7 @@ ui = []; fig = []; debugLog = []; + imageRuntime = []; actions = struct( ... 'startup', @onStartup, ... 'sourceImagesChosen', @dispatchSourceImagesChosen, ... @@ -29,6 +30,8 @@ ui = services.ui; fig = services.figure; debugLog = services.debug; + imageRuntime = labkit.ui.interaction.runtime( ... + ui.controls.preview.primaryAxes, struct('figure', fig)); if debugLog.enabled debugLog.trace('Image enhance debug trace enabled.'); debugLog.instrumentFigure(fig); @@ -543,11 +546,8 @@ function onSetWhiteRoi(~, ~) if image_enhance.userInterface.whiteRoiHelpers("hasRoi", S.items(currentSelectionIndex())) position = S.items(currentSelectionIndex()).whiteRoi .* currentPreviewScale(); end - S.whiteRoiHandle = drawrectangle(ui.controls.preview.primaryAxes, ... - 'Position', position, 'Color', [1 1 1], 'StripeColor', [0 0 0]); - S.whiteRoiListener = addlistener(S.whiteRoiHandle, 'ROIMoved', ... - @(~, event) storeWhiteRoi(event.CurrentPosition)); - storeWhiteRoi(S.whiteRoiHandle.Position); + S.whiteRoiHandle = createWhiteRoiEditor(position); + storeWhiteRoi(S.whiteRoiHandle.getPosition()); end function availability = currentToolAvailability() availability = image_enhance.userInterface.toolAvailability( ... @@ -568,23 +568,26 @@ function refreshWhiteRoiOverlay() clearWhiteRoiOverlay(); return; end - if isempty(S.whiteRoiHandle) || ~isvalid(S.whiteRoiHandle) - S.whiteRoiHandle = drawrectangle(ui.controls.preview.primaryAxes, ... - 'Position', S.items(currentSelectionIndex()).whiteRoi .* currentPreviewScale(), ... - 'Color', [1 1 1], 'StripeColor', [0 0 0]); - S.whiteRoiListener = addlistener(S.whiteRoiHandle, 'ROIMoved', ... - @(~, event) storeWhiteRoi(event.CurrentPosition)); + if isempty(S.whiteRoiHandle) || ~S.whiteRoiHandle.isValid() + if ~isempty(S.whiteRoiHandle) && isstruct(S.whiteRoiHandle) + S.whiteRoiHandle.delete(); + end + S.whiteRoiHandle = createWhiteRoiEditor( ... + S.items(currentSelectionIndex()).whiteRoi .* currentPreviewScale()); end end function clearWhiteRoiOverlay() - if ~isempty(S.whiteRoiListener) - delete(S.whiteRoiListener); - S.whiteRoiListener = []; - end - if ~isempty(S.whiteRoiHandle) && isvalid(S.whiteRoiHandle) - delete(S.whiteRoiHandle); + if ~isempty(S.whiteRoiHandle) && isstruct(S.whiteRoiHandle) + S.whiteRoiHandle.delete(); end S.whiteRoiHandle = []; + S.whiteRoiListener = []; + end + function editor = createWhiteRoiEditor(position) + editor = labkit.ui.interaction.rectangleEditor(imageRuntime, ... + size(currentPreviewSourceImage()), position, struct( ... + 'color', [1 1 1], ... + 'onMoved', @storeWhiteRoi)); end function updateToolControls(resetToDefaults) values = image_enhance.analysisRun.defaultStepValues( ... diff --git a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m index 44b38ab4..f1286aa1 100644 --- a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m +++ b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m @@ -279,21 +279,11 @@ end function outputImage = labToRgb(labImage) - if exist('lab2rgb', 'file') == 2 - outputImage = min(max(lab2rgb(labImage), 0), 1); - return; - end - xyzImage = labToXyz(labImage); outputImage = xyzToRgb(xyzImage); end function labImage = rgbToLab(rgbImage) - if exist('rgb2lab', 'file') == 2 - labImage = rgb2lab(rgbImage); - return; - end - xyzImage = rgbToXyz(rgbImage); labImage = xyzToLab(xyzImage); end diff --git a/buildfile.m b/buildfile.m index d785dfc1..1111df0d 100644 --- a/buildfile.m +++ b/buildfile.m @@ -4,6 +4,7 @@ % User-facing commands: % buildtool changed conservative validation routed from the git diff % buildtool changedFast faster local iteration routed from the git diff +% buildtool baseMatlab verify source workflows require only base MATLAB % buildtool headless full non-GUI validation % buildtool gui full automated GUI validation with hidden figures % buildtool coverage coverage report for manual or scheduled runs @@ -31,6 +32,14 @@ function changedFastTask(~) runCatalogTask("changedFast"); end +function baseMatlabTask(~) + previous = getenv("LABKIT_VERIFY_TOOLBOX_PRODUCTS"); + setenv("LABKIT_VERIFY_TOOLBOX_PRODUCTS", "1"); + cleanup = onCleanup(@() setenv("LABKIT_VERIFY_TOOLBOX_PRODUCTS", previous)); + runCatalogTask("baseMatlab"); + clear cleanup +end + function headlessTask(~) runCatalogTask("headless"); end @@ -51,6 +60,7 @@ function listTasksTask(~) catalog = [ ... taskSpec("changed", "Run conservative changed-file validation.", "Plan", "changed", "HtmlReport", false), ... taskSpec("changedFast", "Run fast changed-file validation for local iteration.", "Plan", "changedFast", "HtmlReport", false), ... + taskSpec("baseMatlab", "Verify source workflows require only base MATLAB.", "Suites", "project/hygiene", "Tests", "ToolboxDependencyGuardrailTest", "HtmlReport", false), ... taskSpec("headless", "Run the full non-GUI validation set.", "IncludeGui", false), ... taskSpec("gui", "Run noninteractive GUI launch, layout, and gesture checks.", "Suites", "gui", "IncludeGui", true, "GuiMode", "hidden"), ... taskSpec("coverage", "Run official tests with coverage artifacts.", "Tags", ["Unit", "Integration"], "IncludeCoverage", true), ... @@ -62,6 +72,7 @@ function listTasksTask(~) p.FunctionName = "taskSpec"; p.addParameter("RunTests", true, @isLogicalScalar); p.addParameter("Suites", strings(1, 0), @isStringLikeList); + p.addParameter("Tests", strings(1, 0), @isStringLikeList); p.addParameter("Plan", "", @isTextScalar); p.addParameter("Tags", strings(1, 0), @isStringLikeList); p.addParameter("IncludeGui", [], @isEmptyOrLogicalScalar); @@ -79,6 +90,7 @@ function listTasksTask(~) "Visibility", string(p.Results.Visibility), ... "RunTests", runTests, ... "Suites", normalizeTextList(p.Results.Suites), ... + "Tests", normalizeTextList(p.Results.Tests), ... "Plan", string(p.Results.Plan), ... "Tags", normalizeTextList(p.Results.Tags), ... "IncludeGui", normalizeOptionalLogical(p.Results.IncludeGui), ... @@ -117,6 +129,9 @@ function runCatalogTask(runName) if ~isempty(spec.Suites) args = [args, {"Suites", spec.Suites}]; end + if ~isempty(spec.Tests) + args = [args, {"Tests", spec.Tests}]; + end if strlength(spec.Plan) > 0 args = [args, {"Plan", spec.Plan}]; end diff --git a/docs/testing.md b/docs/testing.md index 9484f3d5..0ed67c6d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -40,6 +40,7 @@ Use MATLAB build tasks for the stable official entry points: ```bash buildtool changed buildtool changedFast +buildtool baseMatlab buildtool headless buildtool gui buildtool coverage @@ -50,6 +51,7 @@ buildtool listTasks | --- | --- | | `changedFast` | Tight local iteration from the current diff; substitutes representative GUI coverage for expensive broad GUI scopes. | | `changed` | Conservative pre-handoff validation from the current diff. | +| `baseMatlab` | Explicit compatibility gate: static toolbox-call scan, MATLAB product-ownership analysis, and representative workflows with toolbox helpers shadowed. | | `headless` | Full non-GUI validation. | | `gui` | Full automated GUI validation with hidden figures. | @@ -71,6 +73,7 @@ Common choices: | Tight local iteration on one known component | Focused `runLabKitTests("Suites", ...)` | | Coherent local checkpoint while files are still changing | `buildtool changedFast` | | Before commit, PR, or handoff | `buildtool changed` | +| Verify the repository on a machine that has toolboxes installed | `buildtool baseMatlab` | | Full broad non-GUI validation | `buildtool headless` | | Full automated GUI validation | `buildtool gui` | | Coverage report | `buildtool coverage` | @@ -89,10 +92,11 @@ source to the public repository. For temporary local checks, the sentinel file. Toolbox compatibility guardrails protect the base-MATLAB user path. They -reject unguarded hard calls to common non-base MATLAB toolbox helpers under -`apps/` and `+labkit/`, and run representative workflows with those helper -names shadowed on the MATLAB path so local machines with toolboxes still -exercise fallback paths. +reject non-base calls under `apps/` and `+labkit/`, verify MATLAB's dependency +analysis resolves source only to the `MATLAB` product, and run representative +workflows with known toolbox helper names shadowed on the MATLAB path. App +workflows use the same base-MATLAB implementation whether or not optional +toolboxes happen to be installed. Private workspaces under `private_apps/` are separate Git repositories, so the public `changed` and `changedFast` tasks do not discover their diffs. Validate a diff --git a/docs/tools/generate_image_app_workflow_assets.m b/docs/tools/generate_image_app_workflow_assets.m index 08b306e4..6c54b8eb 100644 --- a/docs/tools/generate_image_app_workflow_assets.m +++ b/docs/tools/generate_image_app_workflow_assets.m @@ -261,11 +261,11 @@ function exportPairImage(leftImage, rightImage, outputPath, leftTitle, rightTitl "Position", [100 100 1200 520]); tiledlayout(fig, 1, 2, "Padding", "compact", ... "TileSpacing", "compact"); - nexttile; - imshow(leftImage); + ax = nexttile; + displayImage(ax, leftImage); title(leftTitle, "FontWeight", "bold"); - nexttile; - imshow(rightImage); + ax = nexttile; + displayImage(ax, rightImage); title(rightTitle, "FontWeight", "bold"); exportgraphics(fig, outputPath, "Resolution", 180); close(fig); @@ -277,19 +277,25 @@ function exportTriptychImage(firstImage, secondImage, thirdImage, outputPath, .. "Position", [100 100 1440 520]); tiledlayout(fig, 1, 3, "Padding", "compact", ... "TileSpacing", "compact"); - nexttile; - imshow(firstImage); + ax = nexttile; + displayImage(ax, firstImage); title(firstTitle, "FontWeight", "bold"); - nexttile; - imshow(secondImage); + ax = nexttile; + displayImage(ax, secondImage); title(secondTitle, "FontWeight", "bold"); - nexttile; - imshow(thirdImage); + ax = nexttile; + displayImage(ax, thirdImage); title(thirdTitle, "FontWeight", "bold"); exportgraphics(fig, outputPath, "Resolution", 180); close(fig); end +function displayImage(ax, imageData) + image(ax, imageData); + axis(ax, "image"); + axis(ax, "off"); +end + function marked = drawCropBox(imageData, centerXY, widthPx, heightPx) marked = labkit.image.toDouble(imageData); x1 = max(1, round(centerXY(1) - widthPx / 2)); diff --git a/docs/ui.md b/docs/ui.md index 7f5da8f3..08a0d959 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -8,7 +8,7 @@ | `labkit.ui.layout` | UI 5 data-only workbench layouts. | `workbench`, `workspace`, `tab`, `section`, `group`, `field`, `rangeField`, `panner`, `action`, `filePanel`, `toolPanel`, `previewArea`, `resultTable`, `logPanel`, `statusPanel`, `usagePanel`. | | `labkit.ui.control` | Semantic registry updates, file-panel values, list selections, numeric limits, enable state, and log appends. | `setValue`, `getValue`, `getFiles`, `setFileSelection`, `setEnabled`, `setLimits`, `appendLog`, `setListItems`, `setListSelection`, `fileLabels`, `filePaths`, `fileIndices`. | | `labkit.ui.plot` | Preview axes lookup, plot clearing, image drawing, fitted limits, canvas framing, empty-state messages, and data/axes coordinate conversion. | `getAxes`, `clear`, `clearPreview`, `reset`, `image`, `fit`, `fitCanvas`, `dataToFraction`, `fractionToData`, `offsetData`, `clampData`, `message`. | -| `labkit.ui.interaction` | Reusable composed preview tools and pointer/scroll interaction runtime. | `runtime`, `anchorEditor`, `scaleBar`, `scaleBarCalibration`, `enablePopout`, `popout`, `zoomAtPoint`. | +| `labkit.ui.interaction` | Reusable composed preview tools and pointer/scroll interaction runtime. | `runtime`, `anchorEditor`, `rectangleEditor`, `scaleBar`, `scaleBarCalibration`, `enablePopout`, `popout`, `zoomAtPoint`. | | `labkit.ui.debug` | Debug launch context, visible trace, callback instrumentation, and crash reports. | `context`. | The root `labkit.ui.*` flat helper surface has been removed. Apps should call the facade that owns the behavior they need. Private implementation details live under each facade's `private/` folder. @@ -464,7 +464,7 @@ when a tool has stricter data limits. Generic plots zoom both axes by default; time-labeled x-axes zoom only the horizontal axis unless `"ZoomAxes"` is provided explicitly. -Use `labkit.ui.interaction.anchorEditor(runtime, imageSize, opts)` for generic anchor editing. Use `labkit.ui.interaction.scaleBar(parent, row, runtime, opts)` for calibration controls, reference-pixel editing, unit normalization, final scale-bar placement, and overlay drawing. Apps can persist `tool.calibration()` per image and restore it with `tool.setCalibration(cal)`. Apps still own image loading, redraw order, scientific calculations, result summaries, alerts, logs, and exports. +Use `labkit.ui.interaction.anchorEditor(runtime, imageSize, opts)` for generic anchor editing. Use `labkit.ui.interaction.rectangleEditor(runtime, imageSize, position, opts)` for a toolbox-free draggable and resizable rectangular overlay. Use `labkit.ui.interaction.scaleBar(parent, row, runtime, opts)` for calibration controls, reference-pixel editing, unit normalization, final scale-bar placement, and overlay drawing. Apps can persist `tool.calibration()` per image and restore it with `tool.setCalibration(cal)`. Apps still own image loading, redraw order, scientific calculations, result summaries, alerts, logs, and exports. `labkit.ui.interaction.scaleBarCalibration(referencePixels, referenceLength, unitName, opts)` is the GUI-free calibration struct helper used by apps and app-private calculations. diff --git a/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m b/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m index 429b99bc..4d53f43e 100644 --- a/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m +++ b/tests/cases/contract/project/build/BuildTaskFrameworkGuardrailTest.m @@ -264,7 +264,7 @@ function buildTaskCatalogStaysCompactAndDiscoveryDriven(testCase) root = setupLabKitTestPath(); catalog = extractBuildfileCatalog(root); - expectedTasks = ["changed", "changedFast", "headless", "gui", ... + expectedTasks = ["changed", "changedFast", "baseMatlab", "headless", "gui", ... "coverage", "listTasks"]; publicTasks = catalog.Name(catalog.Visibility == "public").'; testCase.verifyEqual(publicTasks, expectedTasks, ... @@ -440,6 +440,7 @@ function verifyTaskSubset(testCase, tasks, catalogNames, label) function args = taskSpecArguments(line) args = {}; args = appendStringListArgument(args, line, "Suites"); + args = appendStringListArgument(args, line, "Tests"); args = appendStringListArgument(args, line, "Plan"); args = appendStringListArgument(args, line, "Tags"); args = appendStringListArgument(args, line, "GuiMode"); @@ -455,10 +456,18 @@ function verifyTaskSubset(testCase, tasks, catalogNames, label) end suites = lower(taskSpecStringValues(spec, "Suites")); + tests = taskSpecStringValues(spec, "Tests"); tags = taskSpecStringValues(spec, "Tags"); suiteOk = isempty(suites) || all(ismember(suites, knownSuiteTargets(root))); + testOk = isempty(tests) || taskSelectorsExist(root, suites, tests); tagOk = isempty(tags) || all(ismember(tags, knownRunnerTags())); - tf = suiteOk && tagOk; + tf = suiteOk && testOk && tagOk; +end + +function tf = taskSelectorsExist(root, suites, tests) + output = listLabKitTestsQuietly( ... + "Suites", suites, "Tests", tests, "RunName", "task_spec_probe"); + tf = output.count > 0; end function tf = taskSpecUsesKnownPlan(spec) diff --git a/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m b/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m index f2c24c4b..c1ab8ad4 100644 --- a/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m +++ b/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m @@ -8,12 +8,23 @@ function appAndFacadeSourceAvoidsHardToolboxDependencies(testCase) actual = collectHardToolboxCalls(root, files); testCase.verifyEmpty(actual, ... ['apps, LabKit facades, and maintainer scripts must not hard-depend on non-base ' ... - 'MATLAB toolbox helpers. Use LabKit primitives, app-local ' ... - 'base-MATLAB implementations, or explicit optional ' ... - 'toolbox paths with fallback. Findings: ' ... + 'MATLAB toolbox helpers. Use LabKit primitives or app-local ' ... + 'base-MATLAB implementations so every installation follows ' ... + 'the same execution path. Findings: ' ... strjoin(cellstr(actual), ', ')]); end + function sourceDependenciesResolveToBaseMatlab(testCase) + testCase.assumeEqual(string(getenv("LABKIT_VERIFY_TOOLBOX_PRODUCTS")), "1", ... + "Product ownership scan runs through buildtool baseMatlab."); + root = setupLabKitTestPath(); + files = trackedSourceFiles(root); + findings = dependencyProductFindings(root, files); + testCase.verifyEmpty(findings, ... + ["Each source ownership domain must resolve only to base MATLAB. " + ... + "Findings: " + strjoin(findings, ", ")]); + end + function hardDependencyPatternCatchesUnguardedCalls(testCase) root = tempname; files = ["apps/example/run.m"; "+labkit/+image/helper.m"]; @@ -32,6 +43,7 @@ function hardDependencyPatternCatchesUnguardedCalls(testCase) testCase.verifyEqual(actual(:), [ "apps/example/run.m:1" "apps/example/run.m:2" + "apps/example/run.m:4" "+labkit/+image/helper.m:1" ]); end @@ -206,44 +218,11 @@ function smokeImageMeasurementApps() if startsWith(strtrim(line), "%") || isempty(regexp(char(line), pattern, 'once')) continue; end - if iLine > 1 - previousLine = string(lines(iLine - 1)); - else - previousLine = ""; - end - if isAllowedOptionalToolboxCall(file, line, previousLine) - continue; - end findings(end + 1) = file + ":" + string(iLine); end end end -function tf = isAllowedOptionalToolboxCall(file, line, previousLine) - line = string(line); - previousLine = string(previousLine); - tf = false; - if contains(line, "exist('") || contains(line, 'exist("') || ... - contains(previousLine, "exist('") || contains(previousLine, 'exist("') - tf = true; - return; - end - if contains(file, "apps/image_measurement/focus_stack/") && ... - any(contains(line, ["imregcorr(", "imref2d(", "imwarp("])) - tf = true; - return; - end - if contains(file, "apps/image_measurement/batch_crop/") && ... - contains(line, "imresize(") - tf = true; - return; - end - if contains(file, "apps/image_measurement/curvature/") && ... - any(contains(line, ["optimoptions(", "lsqnonlin("])) - tf = true; - end -end - function path = slashPath(path) path = replace(string(path), "\", "/"); end @@ -267,7 +246,9 @@ function writeToolboxShadowFunctions(folder) function names = guardedToolboxFunctionNames() names = [ ... "imresize", "imgaussfilt", "imregcorr", "imwarp", "imref2d", ... - "rgb2gray", "im2double", "affine2d", "procrustes", ... + "drawrectangle", "poly2mask", "imshow", "cpselect", ... + "rgb2gray", "rgb2lab", "lab2rgb", "im2double", "imcrop", ... + "affine2d", "procrustes", ... "bwlabel", "regionprops", "graythresh", "imbinarize", "medfilt2", ... "fspecial", "imfilter", ... "fitcsvm", "fitcecoc", "fitcknn", "fitctree", "fitlm", "fitnlm", ... @@ -276,6 +257,39 @@ function writeToolboxShadowFunctions(folder) "parpool", "gpuArray", "optimoptions", "fmincon", "lsqcurvefit", "lsqnonlin"]; end +function findings = dependencyProductFindings(root, files) + groups = [ ... + "+labkit" + "apps/dic/dic_preprocess" + "apps/dic/dic_postprocess" + "apps/electrochem" + "apps/image_measurement" + "apps/labkit_core" + "apps/neurophysiology" + "apps/wearable" + "docs/tools"]; + findings = strings(1, 0); + assigned = false(size(files)); + for iGroup = 1:numel(groups) + inGroup = startsWith(files, groups(iGroup) + "/"); + assigned = assigned | inGroup; + if ~any(inGroup) + continue; + end + absoluteFiles = fullfile(root, files(inGroup)); + [~, products] = matlab.codetools.requiredFilesAndProducts( ... + cellstr(absoluteFiles)); + certain = logical([products.Certain]); + productNames = string({products.Name}); + nonBase = productNames(certain & productNames ~= "MATLAB"); + for iProduct = 1:numel(nonBase) + findings(end + 1) = groups(iGroup) + ": " + nonBase(iProduct); + end + end + assert(all(assigned), ... + 'Every tracked source file must belong to a product-ownership scan group.'); +end + function cleanupShadowFolder(folder) if contains(path, folder) rmpath(folder); diff --git a/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m b/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m index fa9cb117..911ffc38 100644 --- a/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m +++ b/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m @@ -87,7 +87,7 @@ function verify_package_public_surface() assertPrivatePackageHasMFiles(fullfile(root, '+labkit', '+ui', '+layout', 'private'), ... 'UI layout private implementation'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+interaction'), ... - {'anchorEditor.m', 'enablePopout.m', ... + {'anchorEditor.m', 'rectangleEditor.m', 'enablePopout.m', ... 'popout.m', 'runtime.m', 'scaleBar.m', 'scaleBarCalibration.m', ... 'zoomAtPoint.m'}, ... 'UI interaction facade'); diff --git a/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m b/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m index be5d81bf..8ef02b15 100644 --- a/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m +++ b/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m @@ -51,6 +51,50 @@ function dic_preprocess_workflow_loads_and_auto_aligns_pair(testCase) testCase.verifyGreaterThan(numel(ui.controls.previewAxes.axesById.current.Children), 0, ... 'DIC preprocess workflow should draw the current preview.'); end + + function manualPointPairSelectorCancelsWithoutToolbox(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); + cancelTimer = timer('StartDelay', 0.5, ... + 'TimerFcn', @(~, ~) cancelPointPairDialog()); + timerCleanup = onCleanup(@() deleteTimer(cancelTimer)); + start(cancelTimer); + + [movingPoints, fixedPoints] = ... + dic_preprocess.userInterface.selectRigidPointPairs( ... + zeros(20, 24), zeros(20, 24)); + + testCase.verifyEmpty(movingPoints, ... + 'Cancelled manual alignment should return no moving points.'); + testCase.verifyEmpty(fixedPoints, ... + 'Cancelled manual alignment should return no fixed points.'); + clear timerCleanup cleanup + end + end +end + +function cancelPointPairDialog() + figures = findall(groot, 'Type', 'figure', 'Name', 'DIC Manual Alignment'); + if isempty(figures) + return; + end + controls = findall(figures(1), '-property', 'Text'); + for iControl = 1:numel(controls) + if isprop(controls(iControl), 'ButtonPushedFcn') && ... + string(controls(iControl).Text) == "Cancel" + callback = controls(iControl).ButtonPushedFcn; + callback(controls(iControl), struct()); + return; + end + end +end + +function deleteTimer(timerHandle) + if isvalid(timerHandle) + stop(timerHandle); + delete(timerHandle); end end diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRectangleEditorTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRectangleEditorTest.m new file mode 100644 index 00000000..e71e2f38 --- /dev/null +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiRectangleEditorTest.m @@ -0,0 +1,58 @@ +classdef GuiLayoutUiRectangleEditorTest < matlab.unittest.TestCase + %GUILAYOUTUIRECTANGLEEDITORTEST Verify the base-MATLAB rectangle editor. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function rectangleEditorOwnsMovableGraphics(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); + + fig = uifigure('Visible', 'off', ... + 'Name', 'labkit_rectangle_editor_probe'); + figureCleanup = onCleanup(@() delete(fig)); + ax = uiaxes(fig); + background = image(ax, zeros(40, 60, 3, 'uint8')); + axis(ax, 'image'); + runtime = labkit.ui.interaction.runtime(ax, struct('figure', fig)); + editor = labkit.ui.interaction.rectangleEditor(runtime, ... + [40 60 3], [10 8 20 12], struct('fixedAspectRatio', true)); + editorCleanup = onCleanup(@() editor.delete()); + editor.setBackground(background); + + assert(editor.isValid(), ... + 'Rectangle editor should create base-MATLAB graphics.'); + graphics = editor.graphics(); + assert(numel(graphics) == 5 && all(arrayfun( ... + @(handle) strcmp(get(handle, 'HitTest'), 'on'), graphics)), ... + 'Resizable editor should expose one box and four interactive corners.'); + + editor.setPosition([-5 -3 100 30]); + position = editor.getPosition(); + assert(position(1) >= 1 && position(2) >= 1 && ... + position(1) + position(3) <= 60 && ... + position(2) + position(4) <= 40, ... + 'Rectangle editor should constrain geometry to image bounds.'); + assert(abs(position(3) ./ position(4) - 20 ./ 12) < 1e-12, ... + 'Fixed-aspect editor should preserve its initial aspect ratio.'); + + editor.setBounds([5 45 7 37]); + editor.setPosition([1 1 10 6]); + position = editor.getPosition(); + assert(position(1) >= 5 && position(2) >= 7, ... + 'Explicit coordinate bounds should constrain offset previews.'); + + fixedSize = labkit.ui.interaction.rectangleEditor(runtime, ... + [40 60], [12 10 18 14], struct('resizable', false)); + fixedCleanup = onCleanup(@() fixedSize.delete()); + assert(numel(fixedSize.graphics()) == 1, ... + 'Non-resizable editor should expose only the draggable box.'); + fixedSize.setActive(false); + fixedGraphics = fixedSize.graphics(); + assert(strcmp(fixedGraphics.HitTest, 'off'), ... + 'Inactive rectangle graphics should not intercept pointer events.'); + + clear fixedCleanup editorCleanup figureCleanup cleanup + end + end +end diff --git a/tests/cases/unit/apps/dic/DicPreprocessOpsTest.m b/tests/cases/unit/apps/dic/DicPreprocessOpsTest.m index b9bec873..ebc5ef96 100644 --- a/tests/cases/unit/apps/dic/DicPreprocessOpsTest.m +++ b/tests/cases/unit/apps/dic/DicPreprocessOpsTest.m @@ -12,6 +12,18 @@ function maskFromCurveHandlesEmptyCurve(testCase) testCase.verifyEqual(mask, zeros(4, 5, 'uint8')); end + + function maskFromCurveRasterizesPolygonWithBaseMatlab(testCase) + setupLabKitTestPath(); + curve = [2 2; 5 2; 5 4; 2 4; 2 2]; + + mask = dic_preprocess.analysisRun.maskFromCurve(curve, [6 7]); + + testCase.verifyClass(mask, 'uint8'); + testCase.verifyEqual(mask(3, 3), uint8(255)); + testCase.verifyEqual(mask(1, 1), uint8(0)); + end + function straightLineBoundaryClosesAndClampsPoints(testCase) setupLabKitTestPath(); @@ -108,6 +120,17 @@ function squareCropGeometryStaysInsideImage(testCase) testCase.verifyLessThanOrEqual(clampedRect(2) + clampedRect(4), 100); end + function cropImageUsesInclusiveImcropRectangleContract(testCase) + setupLabKitTestPath(); + imageData = reshape(uint8(1:60), 5, 6, 2); + + cropped = dic_preprocess.analysisRun.cropImage( ... + imageData, [2 2 3 2]); + + testCase.verifySize(cropped, [3 4 2]); + testCase.verifyEqual(cropped, imageData(2:4, 2:5, :)); + end + function falseColorAndMaskPreviewsPreserveImageShape(testCase) setupLabKitTestPath(); From 67ea22860d3182b1d7051949b68716069807abf3 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 13:50:11 -0500 Subject: [PATCH 03/15] fix: keep electrochem batch analysis consistent --- CHANGELOG.md | 31 ++++++- .../cic/+cic/+analysisRun/computeCIC.m | 18 +++- .../cic/+cic/+analysisRun/recomputeItems.m | 8 ++ .../cic/+cic/+resultFiles/buildResultsTable.m | 10 +- .../cic/+cic/+resultFiles/writeResultsCSV.m | 92 ++----------------- .../+userInterface/buildWorkbenchLayout.m | 13 ++- apps/electrochem/cic/+cic/definitionActions.m | 46 ++++++---- apps/electrochem/cic/+cic/version.m | 4 +- .../+analysisRun/recomputeItems.m | 9 ++ .../+userInterface/buildWorkbenchLayout.m | 1 + .../+vt_resistance/definitionActions.m | 31 ++++--- .../vt_resistance/+vt_resistance/version.m | 4 +- docs/apps.md | 7 ++ .../apps/electrochem/cic/GuiLayoutCicTest.m | 26 ++++++ .../vt_resistance/GuiLayoutVtResistanceTest.m | 8 ++ .../unit/apps/electrochem/CicExportTest.m | 10 +- .../unit/apps/electrochem/ComputeCICTest.m | 17 ++++ .../electrochem/ComputeVTResistanceTest.m | 10 ++ 18 files changed, 214 insertions(+), 131 deletions(-) create mode 100644 apps/electrochem/cic/+cic/+analysisRun/recomputeItems.m create mode 100644 apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7fd6fb..23357a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,32 @@ Commits and PRs belong in `Evidence`, not in the navigation structure. ## Unreleased -No pending project entries. +### Pending - Consistent electrochemistry batch analysis + +Affected versions: +- `labkit_CIC_app` `1.3.7 -> 1.3.8` +- `labkit_VTResistance_app` `1.3.7 -> 1.3.8` + +What changed: +- CIC and VT Resistance now recompute every loaded file under one shared set + of analysis controls, including a final recomputation before CSV export. +- CIC labels delay values in microseconds, rejects sampling outside the + recorded time range instead of extrapolating, and exports the area and delay + used for each result. +- Added family-level regression coverage for whole-batch recomputation; CSC, + EIS, and Chrono Overlay remain safe because they calculate from current + settings at export or do not cache derived batch analysis. + +Why it matters: +- Batch CSV rows can no longer silently mix stale and current area, delay, + pulse-detection, resistance-window, or voltage-mode settings. + +Compatibility: +- CIC CSV adds trailing `Area_cm2` and `Delay_us` columns. Existing columns + retain their names and order. + +Evidence: +- Current development branch; final mainline commit pending. Template for branch work before the final mainline commit is known: @@ -90,10 +115,10 @@ Audited against `main` metadata on 2026-07-13. | `labkit.biosignal` | `1.0.0` | Facade | `+labkit/+biosignal/version.m` | | `labkit_FigureStudio_app` | `0.1.5` | LabKit Core | `apps/labkit_core/figure_studio/+figure_studio/version.m` | | `labkit_ChronoOverlay_app` | `1.3.5` | Electrochem | `apps/electrochem/chrono_overlay/+chrono_overlay/version.m` | -| `labkit_CIC_app` | `1.3.7` | Electrochem | `apps/electrochem/cic/+cic/version.m` | +| `labkit_CIC_app` | `1.3.8` | Electrochem | `apps/electrochem/cic/+cic/version.m` | | `labkit_CSC_app` | `1.3.9` | Electrochem | `apps/electrochem/csc/+csc/version.m` | | `labkit_EIS_app` | `1.3.4` | Electrochem | `apps/electrochem/eis/+eis/version.m` | -| `labkit_VTResistance_app` | `1.3.7` | Electrochem | `apps/electrochem/vt_resistance/+vt_resistance/version.m` | +| `labkit_VTResistance_app` | `1.3.8` | Electrochem | `apps/electrochem/vt_resistance/+vt_resistance/version.m` | | `labkit_DICPreprocess_app` | `1.3.6` | DIC | `apps/dic/dic_preprocess/+dic_preprocess/version.m` | | `labkit_DICPostprocess_app` | `1.3.5` | DIC | `apps/dic/dic_postprocess/+dic_postprocess/version.m` | | `labkit_BatchImageCrop_app` | `1.6.7` | Image Measurement | `apps/image_measurement/batch_crop/+batch_crop/version.m` | diff --git a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m index 17840029..eaafd0c9 100644 --- a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m +++ b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m @@ -70,6 +70,11 @@ V = computeVoltageTransientMetrics(t, Vf, pulse, A.delay_s); A = mergeStructs(A, V); + if ~V.ok + A.message = V.message; + A.logOnFailure = true; + return; + end Q = computeInjectedCharge(t, Im, pulse, A.usedMeasuredCurrent); A = mergeStructs(A, Q); @@ -175,6 +180,14 @@ V = struct(); V.t_emc = pulse.cath_end + delay_s; V.t_ema = pulse.anod_end + delay_s; + V.ok = V.t_emc >= min(t) && V.t_emc <= max(t) && ... + V.t_ema >= min(t) && V.t_ema <= max(t); + if ~V.ok + V.message = sprintf(['Sample delay %.6g us places Emc or Ema outside ' ... + 'the recorded time range.'], 1e6 * delay_s); + return; + end + V.message = 'OK'; V.emc_idx = nearestIndex(t, V.t_emc); V.ema_idx = nearestIndex(t, V.t_ema); V.Emc = interp1Safe(t, Vf, V.t_emc); @@ -220,13 +233,14 @@ end function v = interp1Safe(x, y, xq) - if numel(x) < 2 || any(~isfinite([x(:); y(:)])) + if numel(x) < 2 || any(~isfinite([x(:); y(:)])) || ... + xq < min(x) || xq > max(x) v = NaN; return; end try - v = interp1(x, y, xq, 'linear', 'extrap'); + v = interp1(x, y, xq, 'linear'); catch idx = nearestIndex(x, xq); v = y(idx); diff --git a/apps/electrochem/cic/+cic/+analysisRun/recomputeItems.m b/apps/electrochem/cic/+cic/+analysisRun/recomputeItems.m new file mode 100644 index 00000000..7759e818 --- /dev/null +++ b/apps/electrochem/cic/+cic/+analysisRun/recomputeItems.m @@ -0,0 +1,8 @@ +% Expected caller: CIC app actions and tests. Inputs are loaded DTA item +% structs and one shared CIC option struct. Output contains every item +% recomputed with exactly those options. Side effects are none. +function items = recomputeItems(items, opts) + for index = 1:numel(items) + items(index).analysis = cic.analysisRun.computeCIC(items(index), opts); + end +end diff --git a/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m b/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m index 792c5df6..a6be2e31 100644 --- a/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m +++ b/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m @@ -22,6 +22,8 @@ CICt = NaN(numel(items), 1); safe = zeros(numel(items), 1); detection = cell(numel(items), 1); + area_cm2 = NaN(numel(items), 1); + delay_us = NaN(numel(items), 1); for i = 1:numel(items) item = items(i); @@ -43,11 +45,15 @@ CICt(i) = scale * A.CICt_mCcm2; safe(i) = A.safe; detection{i} = A.detectMode; + area_cm2(i) = A.area_cm2; + delay_us(i) = 1e6 * A.delay_s; end - T = table(file, amp_A, Emc_V, Ema_V, Qc_C, Qa_C, Qt_C, CICc, CICa, CICt, safe, detection, ... + T = table(file, amp_A, Emc_V, Ema_V, Qc_C, Qa_C, Qt_C, CICc, CICa, CICt, ... + safe, detection, area_cm2, delay_us, ... 'VariableNames', {'File', 'Amp_A', 'Emc_V', 'Ema_V', 'Qc_C', 'Qa_C', 'Qt_C', ... - ['CICc_' unitSuffix], ['CICa_' unitSuffix], ['CICt_' unitSuffix], 'Safe', 'Detection'}); + ['CICc_' unitSuffix], ['CICa_' unitSuffix], ['CICt_' unitSuffix], ... + 'Safe', 'Detection', 'Area_cm2', 'Delay_us'}); end function [scale, unitSuffix] = displayScaleSuffix(unitLabel) diff --git a/apps/electrochem/cic/+cic/+resultFiles/writeResultsCSV.m b/apps/electrochem/cic/+cic/+resultFiles/writeResultsCSV.m index 46d88e71..230f84de 100644 --- a/apps/electrochem/cic/+cic/+resultFiles/writeResultsCSV.m +++ b/apps/electrochem/cic/+cic/+resultFiles/writeResultsCSV.m @@ -24,17 +24,20 @@ cleaner = onCleanup(@() fclose(fid)); try - T = buildResultsTable(items, unitLabel); + T = cic.resultFiles.buildResultsTable(items, unitLabel); names = T.Properties.VariableNames; - fprintf(fid, 'File,Amp_A,Emc_V,Ema_V,Qc_C,Qa_C,Qt_C,%s,%s,%s,Safe,Detection\n', ... + fprintf(fid, ['File,Amp_A,Emc_V,Ema_V,Qc_C,Qa_C,Qt_C,%s,%s,%s,' ... + 'Safe,Detection,Area_cm2,Delay_us\n'], ... names{8}, names{9}, names{10}); for i = 1:height(T) if strcmp(T.Detection{i}, 'failed') - fprintf(fid, '"%s",,,,,,,,,,0,"failed"\n', T.File{i}); + fprintf(fid, '"%s",,,,,,,,,,0,"failed",,\n', T.File{i}); else - fprintf(fid, '"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%d,"%s"\n', ... + fprintf(fid, ['"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,' ... + '%.12g,%.12g,%.12g,%d,"%s",%.12g,%.12g\n'], ... T.File{i}, T.Amp_A(i), T.Emc_V(i), T.Ema_V(i), T.Qc_C(i), T.Qa_C(i), T.Qt_C(i), ... - T.(names{8})(i), T.(names{9})(i), T.(names{10})(i), T.Safe(i), T.Detection{i}); + T.(names{8})(i), T.(names{9})(i), T.(names{10})(i), T.Safe(i), ... + T.Detection{i}, T.Area_cm2(i), T.Delay_us(i)); end end catch ME @@ -45,82 +48,3 @@ end end end - -function T = buildResultsTable(items, unitLabel) -%BUILDRESULTSTABLE Build legacy CIC CSV result table. - - if nargin < 2 - unitLabel = 'mC/cm^2'; - end - [scale, unitSuffix] = displayScaleSuffix(unitLabel); - - file = cell(numel(items), 1); - amp_A = NaN(numel(items), 1); - Emc_V = NaN(numel(items), 1); - Ema_V = NaN(numel(items), 1); - Qc_C = NaN(numel(items), 1); - Qa_C = NaN(numel(items), 1); - Qt_C = NaN(numel(items), 1); - CICc = NaN(numel(items), 1); - CICa = NaN(numel(items), 1); - CICt = NaN(numel(items), 1); - safe = zeros(numel(items), 1); - detection = cell(numel(items), 1); - - for i = 1:numel(items) - item = items(i); - file{i} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - detection{i} = 'failed'; - continue; - end - - amp_A(i) = A.ampEstimate_A; - Emc_V(i) = A.Emc; - Ema_V(i) = A.Ema; - Qc_C(i) = A.Qc_C; - Qa_C(i) = A.Qa_C; - Qt_C(i) = A.Qt_C; - CICc(i) = scale * A.CICc_mCcm2; - CICa(i) = scale * A.CICa_mCcm2; - CICt(i) = scale * A.CICt_mCcm2; - safe(i) = A.safe; - detection{i} = A.detectMode; - end - - T = table(file, amp_A, Emc_V, Ema_V, Qc_C, Qa_C, Qt_C, CICc, CICa, CICt, safe, detection, ... - 'VariableNames', {'File', 'Amp_A', 'Emc_V', 'Ema_V', 'Qc_C', 'Qa_C', 'Qt_C', ... - ['CICc_' unitSuffix], ['CICa_' unitSuffix], ['CICt_' unitSuffix], 'Safe', 'Detection'}); -end - -function [scale, unitSuffix] = displayScaleSuffix(unitLabel) - [scale, unitLabel] = displayScale(unitLabel); - unitSuffix = regexprep(unitLabel, '[\^/]', ''); -end - -function [scale, unitLabel] = displayScale(unitLabel) - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end -end - -function name = itemName(item) - if isfield(item, 'name') - name = item.name; - else - name = ''; - end -end - -function A = itemAnalysis(item) - if isfield(item, 'analysis') - A = item.analysis; - else - A = []; - end -end diff --git a/apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m b/apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m index e0226a91..5a1d1918 100644 --- a/apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m +++ b/apps/electrochem/cic/+cic/+userInterface/buildWorkbenchLayout.m @@ -78,17 +78,19 @@ [-10 10], 0.01, callbacks), ... analysisPanner("anodLimit", "Anodic limit (V):", 0.8, ... [-10 10], 0.01, callbacks), ... - analysisPanner("delayUs", "Sample delay after pulse end:", 10, ... + analysisPanner("delayUs", "Delay after pulse end (us):", 10, ... [0 10000000], 1, callbacks), ... labkit.ui.layout.field("area", "Area override (cm^2):", ... "kind", "text", ... "value", "", ... - "onChange", callbackValue(callbacks, "analyzeCurrentFile")), ... + "debounceMs", 0, ... + "onChange", callbackValue(callbacks, "analysisChanged")), ... labkit.ui.layout.field("pulseMode", "Pulse detection:", ... "kind", "dropdown", ... "items", pulseModes, ... "value", pulseModes{1}, ... - "onChange", callbackValue(callbacks, "analyzeCurrentFile")), ... + "debounceMs", 0, ... + "onChange", callbackValue(callbacks, "analysisChanged")), ... labkit.ui.layout.field("cicMode", "CIC summary mode:", ... "kind", "dropdown", ... "items", cicModes, ... @@ -103,7 +105,8 @@ "Use measured Im integration for charge (recommended)", ... "kind", "checkbox", ... "value", true, ... - "onChange", callbackValue(callbacks, "analyzeCurrentFile"))}); + "debounceMs", 0, ... + "onChange", callbackValue(callbacks, "analysisChanged"))}); end function section = plotDisplaySection(callbacks) @@ -171,7 +174,7 @@ "limits", limits, ... "step", step, ... "valueDisplayFormat", "%.6g", ... - "onChange", callbackValue(callbacks, "analyzeCurrentFile")); + "onChange", callbackValue(callbacks, "analysisChanged")); end function layoutNode = axisChoice(id, labelText, items, value, callbacks) diff --git a/apps/electrochem/cic/+cic/definitionActions.m b/apps/electrochem/cic/+cic/definitionActions.m index cd7ad1ac..45a9d22f 100644 --- a/apps/electrochem/cic/+cic/definitionActions.m +++ b/apps/electrochem/cic/+cic/definitionActions.m @@ -10,7 +10,7 @@ "exportResults", @onExportResults, ... "fileSelectionChanged", @onFileSelectionChanged, ... "presetChanged", @onPresetChanged, ... - "analyzeCurrentFile", @onAnalyzeCurrentFile, ... + "analysisChanged", @onAnalyzeAllFiles, ... "refreshResultsSummary", @onRefreshOnly, ... "refreshCICUnitDisplays", @onRefreshOnly, ... "refreshPlots", @onRefreshOnly); @@ -41,7 +41,7 @@ labkit.ui.control.setValue(services.ui, "cathLimit", -0.9); labkit.ui.control.setValue(services.ui, "anodLimit", 0.6); end - state = analyzeCurrentFile(state, services); + state = analyzeAllFiles(state, services); end function state = onOpenFilesChosen(state, payload, services) @@ -103,28 +103,22 @@ end end -function state = onAnalyzeCurrentFile(state, ~, services) - state = analyzeCurrentFile(state, services); +function state = onAnalyzeAllFiles(state, ~, services) + state = analyzeAllFiles(state, services); end -function state = analyzeCurrentFile(state, services) - if isempty(state.items) || isempty(state.current) || ... - state.current < 1 || state.current > numel(state.items) +function state = analyzeAllFiles(state, services) + if isempty(state.items) return; end - state.items(state.current) = analyzeItem(state.items(state.current), services); + opts = analysisOptions(services.ui); + state.items = cic.analysisRun.recomputeItems(state.items, opts); + addLog(services, sprintf('Reanalyzed %d loaded file(s) with shared analysis settings.', ... + numel(state.items))); end function item = analyzeItem(item, services) - ui = services.ui; - opts = struct(); - opts.delay_s = ui.controls.delayUs.valueHandle.Value * 1e-6; - opts.cathLimit = ui.controls.cathLimit.valueHandle.Value; - opts.anodLimit = ui.controls.anodLimit.valueHandle.Value; - opts.areaOverride = ui.controls.area.valueHandle.Value; - opts.pulseMode = ui.controls.pulseMode.valueHandle.Value; - opts.usedMeasuredCurrent = ui.controls.useMeasuredCurrent.valueHandle.Value; - + opts = analysisOptions(services.ui); analysis = cic.analysisRun.computeCIC(item, opts); item.analysis = analysis; if analysis.ok @@ -135,6 +129,23 @@ end end +function opts = analysisOptions(ui) + opts = struct(); + opts.delay_s = finiteScalar(ui.controls.delayUs.valueHandle.Value, 10) * 1e-6; + opts.cathLimit = finiteScalar(ui.controls.cathLimit.valueHandle.Value, -0.6); + opts.anodLimit = finiteScalar(ui.controls.anodLimit.valueHandle.Value, 0.8); + opts.areaOverride = ui.controls.area.valueHandle.Value; + opts.pulseMode = ui.controls.pulseMode.valueHandle.Value; + opts.usedMeasuredCurrent = ui.controls.useMeasuredCurrent.valueHandle.Value; +end + +function value = finiteScalar(value, fallback) + value = double(value); + if isempty(value) || ~isscalar(value) || ~isfinite(value) + value = fallback; + end +end + function state = onFileSelectionChanged(state, ~, services) files = labkit.ui.control.getValue(services.ui, 'files'); paths = labkit.ui.control.filePaths(files); @@ -185,6 +196,7 @@ 'No results to export.', 'Export'); return; end + state = analyzeAllFiles(state, services); [out, cancelled] = labkit.ui.runtime.promptOutputFile( ... 'cic_results.csv', 'Save results CSV', 'cic_results.csv'); if cancelled diff --git a/apps/electrochem/cic/+cic/version.m b/apps/electrochem/cic/+cic/version.m index a6032351..804db0f3 100644 --- a/apps/electrochem/cic/+cic/version.m +++ b/apps/electrochem/cic/+cic/version.m @@ -8,6 +8,6 @@ "name", "labkit_CIC_app", ... "displayName", "CIC", ... "family", "Electrochem", ... - "version", "1.3.7", ... - "updated", "2026-07-06"); + "version", "1.3.8", ... + "updated", "2026-07-13"); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m new file mode 100644 index 00000000..2c99359e --- /dev/null +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/recomputeItems.m @@ -0,0 +1,9 @@ +% Expected caller: VT resistance app actions and tests. Inputs are loaded DTA +% item structs and one shared resistance option struct. Output contains every +% item recomputed with exactly those options. Side effects are none. +function items = recomputeItems(items, opts) + for index = 1:numel(items) + items(index).analysis = ... + vt_resistance.analysisRun.computeResistance(items(index), opts); + end +end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m index a22fddb0..343e5b6d 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/buildWorkbenchLayout.m @@ -138,6 +138,7 @@ "kind", "dropdown", ... "items", items, ... "value", value, ... + "debounceMs", 0, ... "onChange", callbackValue(callbacks, "analysisChanged")); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m b/apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m index e4221a79..bd735d35 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/definitionActions.m @@ -10,7 +10,7 @@ "clearAll", @onClearAll, ... "exportResults", @onExportResults, ... "fileSelectionChanged", @onFileSelectionChanged, ... - "analysisChanged", @onAnalyzeCurrentFile, ... + "analysisChanged", @onAnalyzeAllFiles, ... "refreshPlots", @onRefreshOnly); end @@ -88,21 +88,22 @@ end end -function state = onAnalyzeCurrentFile(state, ~, services) - if isempty(state.items) || isempty(state.current) || ... - state.current < 1 || state.current > numel(state.items) +function state = onAnalyzeAllFiles(state, ~, services) + state = analyzeAllFiles(state, services); +end + +function state = analyzeAllFiles(state, services) + if isempty(state.items) return; end - state.items(state.current) = analyzeItem(state.items(state.current), services); + opts = analysisOptions(services.ui); + state.items = vt_resistance.analysisRun.recomputeItems(state.items, opts); + addLog(services, sprintf('Reanalyzed %d loaded file(s) with shared analysis settings.', ... + numel(state.items))); end function item = analyzeItem(item, services) - ui = services.ui; - opts = struct(); - opts.windowMode = ui.controls.steadyWindow.valueHandle.Value; - opts.voltageMode = ui.controls.voltageMode.valueHandle.Value; - opts.pulseMode = ui.controls.pulseMode.valueHandle.Value; - + opts = analysisOptions(services.ui); analysis = vt_resistance.analysisRun.computeResistance(item, opts); if analysis.ok addLog(services, sprintf('%s: Rc=%.6g ohm, Ra=%.6g ohm, Ravg=%.6g ohm', ... @@ -114,6 +115,13 @@ item.analysis = analysis; end +function opts = analysisOptions(ui) + opts = struct(); + opts.windowMode = ui.controls.steadyWindow.valueHandle.Value; + opts.voltageMode = ui.controls.voltageMode.valueHandle.Value; + opts.pulseMode = ui.controls.pulseMode.valueHandle.Value; +end + function state = onFileSelectionChanged(state, ~, services) files = labkit.ui.control.getValue(services.ui, 'files'); paths = labkit.ui.control.filePaths(files); @@ -163,6 +171,7 @@ 'No results to export.', 'Export'); return; end + state = analyzeAllFiles(state, services); [out, cancelled] = labkit.ui.runtime.promptOutputFile( ... 'vt_steady_resistance_results.csv', 'Save results CSV', ... 'vt_steady_resistance_results.csv'); diff --git a/apps/electrochem/vt_resistance/+vt_resistance/version.m b/apps/electrochem/vt_resistance/+vt_resistance/version.m index e41aadde..11850f8a 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/version.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/version.m @@ -8,6 +8,6 @@ "name", "labkit_VTResistance_app", ... "displayName", "VT Resistance", ... "family", "Electrochem", ... - "version", "1.3.7", ... - "updated", "2026-07-06"); + "version", "1.3.8", ... + "updated", "2026-07-13"); end diff --git a/docs/apps.md b/docs/apps.md index 9109be93..0b80ad83 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -118,6 +118,13 @@ choose an older release, tag, or commit through `Versions`. | `labkit_BatchImageCrop_app` | Image measurement | Fixed-size batch microscope crops with edge-continuous padding, rotation, duplicate crop tasks, responsive downsampled preview rendering, and optional per-image physical scale normalization with independent crop and calibration units. | Microscope images, optional scale calibration per image | Cropped same-size images and crop manifest CSV. | | `labkit_FLIRThermal_app` | Image measurement | FLIR radiometric JPEG/RJPEG thermal postprocessing with per-image display ranges, range-bound presets, linear/log/adjustable-gamma color mapping, clean heatmap rendering, and scale bars. | FLIR radiometric image files | Thermal image exports, colorbar PNGs, and manifest CSV. | | `labkit_ECGPrint_app` | Wearable biosignal | 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. | + +Electrochemistry analysis controls above a batch result table apply to the +whole loaded batch. CIC and VT Resistance recompute every loaded file when a +shared analysis setting changes and again immediately before export. CIC delay +values are microseconds after each pulse end; delays outside the recorded time +range fail instead of extrapolating. CIC CSV exports include `Area_cm2` and +`Delay_us` so the normalization and sampling settings remain auditable. | `labkit_RHSPreview_app` | Neurophysiology | Intan RHS header inspection, stacked waveform preview, ROI zooming, channel protocol drafting, and manual folder filtering. | RHS file, RHS folder, and optional protocol JSON | Header summary, preview window, channel protocol JSON, and filter record JSON. | | `labkit_NerveResponseAnalysis_app` | Neurophysiology | Filter-record-driven event train detection, differential response derivation, common-mode correction, and CAP metrics. | Filter record JSON and recommended protocol JSON | Analysis JSON with events, trains, metrics, and issues. | | `labkit_ResponseReviewStats_app` | Neurophysiology | Immediate metric loading, aligned response segment review, and descriptive statistics from analysis metrics or legacy segment CSV. | Analysis JSON or segment CSV | Metrics CSV and summary table. | diff --git a/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m b/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m index 69b51e00..558d15be 100644 --- a/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m +++ b/tests/cases/gui/apps/electrochem/cic/GuiLayoutCicTest.m @@ -55,6 +55,20 @@ function cic_workflow_loads_analyzes_and_plots_chrono(testCase) testCase.verifyTrue(contains(driver.fileSelection('files'), ... 'chrono_chronopot_current_pulse_1ms.DTA'), ... 'CIC append should select the newly added chrono file.'); + beforeAreaChange = driver.tableData('results'); + ui = driver.registry(); + ui.controls.area.valueHandle.Value = '2'; + h.invokeCallback(ui.controls.area.valueHandle, 'ValueChangedFcn'); + [updated, detail] = h.waitForCondition(fig, ... + @() allRowsAreHalf(driver.tableData('results'), beforeAreaChange), 5); + testCase.verifyTrue(updated, h.waitDiagnostic(detail, ... + 'operation', 'CIC whole-batch area recomputation')); + afterAreaChange = driver.tableData('results'); + for row = 1:2 + testCase.verifyEqual(afterAreaChange{row, 7}, ... + 0.5 * beforeAreaChange{row, 7}, 'RelTol', 1e-12, ... + 'A shared CIC area change should recompute every loaded file.'); + end testCase.verifyFalse(isequal(topAxes.XLim, [-1 0]), ... 'CIC append redraw should replace stale manual X limits.'); testCase.verifyFalse(isequal(topAxes.YLim, [-0.01 0.01]), ... @@ -74,6 +88,14 @@ function cic_workflow_loads_analyzes_and_plots_chrono(testCase) end end +function tf = allRowsAreHalf(actual, previous) + tf = size(actual, 1) == 2 && size(previous, 1) == 2; + for row = 1:2 + tf = tf && abs(actual{row, 7} - 0.5 * previous{row, 7}) <= ... + 1e-12 * max(1, abs(previous{row, 7})); + end +end + function assertCicLayout(h, fig) h.assertStandardWorkbenchLayout(fig); h.assertButtonContract(fig, {'Add DTA files', 'Remove selected', ... @@ -95,6 +117,10 @@ function assertCicLayout(h, fig) h.dropdownGroup({'Time (s)', 'Sample #'}, 2), ... h.dropdownGroup({'VT: Vf vs time', 'IT: Im vs time'}, 2)]); h.assertDropdownCallbacksPresent(fig); + labels = string(get(findall(fig, '-property', 'Text'), 'Text')); + testLabel = "Delay after pulse end (us):"; + assert(any(labels == testLabel), ... + 'CIC delay control should state that its value is measured in microseconds.'); end function assertExtremaLabelsAreReadable(ax) diff --git a/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m b/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m index f9ef3317..9ff78363 100644 --- a/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m +++ b/tests/cases/gui/apps/electrochem/vt_resistance/GuiLayoutVtResistanceTest.m @@ -46,6 +46,14 @@ function vt_resistance_workflow_loads_analyzes_and_plots_chrono(testCase) testCase.verifyTrue(contains(driver.fileSelection('files'), ... 'chrono_chronopot_current_pulse_1ms.DTA'), ... 'VT resistance append should select the newly added chrono file.'); + ui = driver.registry(); + ui.controls.voltageMode.valueHandle.Value = 'Raw Vf/I'; + h.invokeCallback(ui.controls.voltageMode.valueHandle, 'ValueChangedFcn'); + [updated, detail] = h.waitForCondition(fig, ... + @() any(contains(string(driver.logValue('appLog')), ... + 'Reanalyzed 2 loaded file(s) with shared analysis settings.')), 5); + testCase.verifyTrue(updated, h.waitDiagnostic(detail, ... + 'operation', 'VT resistance whole-batch recomputation')); end end end diff --git a/tests/cases/unit/apps/electrochem/CicExportTest.m b/tests/cases/unit/apps/electrochem/CicExportTest.m index 06ed55ca..19dd350e 100644 --- a/tests/cases/unit/apps/electrochem/CicExportTest.m +++ b/tests/cases/unit/apps/electrochem/CicExportTest.m @@ -35,13 +35,16 @@ function verify_cicExport() T = buildCICResultsTable(items, 'mC/cm^2'); expectedNames = {'File', 'Amp_A', 'Emc_V', 'Ema_V', 'Qc_C', 'Qa_C', 'Qt_C', ... - 'CICc_mCcm2', 'CICa_mCcm2', 'CICt_mCcm2', 'Safe', 'Detection'}; + 'CICc_mCcm2', 'CICa_mCcm2', 'CICt_mCcm2', 'Safe', 'Detection', ... + 'Area_cm2', 'Delay_us'}; assert(isequal(T.Properties.VariableNames, expectedNames), ... 'CIC export table headers should preserve stable mC CSV names.'); assertClose(T.Amp_A(1), item.analysis.ampEstimate_A, 1e-15, 'CIC amp export value'); assertClose(T.CICc_mCcm2(1), item.analysis.CICc_mCcm2, 1e-15, 'CIC mC cathodic value'); assert(T.Safe(1) == item.analysis.safe, 'CIC safe flag should be preserved.'); assert(strcmp(T.Detection{1}, item.analysis.detectMode), 'CIC detection mode should be preserved.'); + assertClose(T.Area_cm2(1), item.analysis.area_cm2, 1e-15, 'CIC area audit value'); + assertClose(T.Delay_us(1), 10, 1e-12, 'CIC delay audit value'); assert(isnan(T.Amp_A(2)) && isnan(T.CICt_mCcm2(2)), 'Failed CIC rows should use NaN numeric values.'); assert(T.Safe(2) == 0, 'Failed CIC rows should preserve stable Safe=0.'); assert(strcmp(T.Detection{2}, 'failed'), 'Failed CIC rows should preserve stable failed detection label.'); @@ -63,11 +66,12 @@ function verify_cicExport() cleaner = onCleanup(@() deleteIfExists(tmp)); writeCICResultsCSV(items, tmp, 'mC/cm^2'); txt = fileread(tmp); - header = 'File,Amp_A,Emc_V,Ema_V,Qc_C,Qa_C,Qt_C,CICc_mCcm2,CICa_mCcm2,CICt_mCcm2,Safe,Detection'; + header = ['File,Amp_A,Emc_V,Ema_V,Qc_C,Qa_C,Qt_C,CICc_mCcm2,' ... + 'CICa_mCcm2,CICt_mCcm2,Safe,Detection,Area_cm2,Delay_us']; assert(startsWith(string(txt), header), 'CIC CSV header should preserve stable spelling and order.'); assert(contains(string(txt), '"chrono "cic".DTA"'), 'CIC CSV should preserve stable unescaped quoted file text.'); assert(contains(string(txt), '"metadata-current"'), 'CIC CSV should preserve detection field.'); - assert(contains(string(txt), '"failed "file".DTA",,,,,,,,,,0,"failed"'), ... + assert(contains(string(txt), '"failed "file".DTA",,,,,,,,,,0,"failed",,'), ... 'CIC CSV failed rows should preserve stable empty fields and failed marker.'); end diff --git a/tests/cases/unit/apps/electrochem/ComputeCICTest.m b/tests/cases/unit/apps/electrochem/ComputeCICTest.m index 2e8dd95d..070c47d7 100644 --- a/tests/cases/unit/apps/electrochem/ComputeCICTest.m +++ b/tests/cases/unit/apps/electrochem/ComputeCICTest.m @@ -70,6 +70,23 @@ function verify_computeCIC() assert(D.cathOK && D.anodOK && D.safe, 'Relaxed water window should be safe.'); assert(strcmp(D.limitSide, 'safe'), 'Safe status text should match stable wording.'); + batch = [item item]; + batch(1).analysis = struct('ok', false); + batch(2).analysis = struct('ok', false); + batch = cic.analysisRun.recomputeItems(batch, opts); + analyses = [batch.analysis]; + assert(all([analyses.ok]), ... + 'Shared CIC settings should recompute every loaded item.'); + assert(all(abs([analyses.area_cm2] - 2) < 1e-15), ... + 'Every recomputed CIC item should use the same area override.'); + + opts.delay_s = 1e6; + outside = computeCIC(item, opts); + assert(~outside.ok, ... + 'A delay outside the recorded time range should fail instead of extrapolating.'); + assert(contains(outside.message, 'outside the recorded time range'), ... + 'Out-of-range CIC delay failures should explain the invalid sampling window.'); + bad = struct('meta', struct(), 'tables', struct([])); E = computeCIC(bad, struct()); assert(~E.ok, 'Missing curve should fail.'); diff --git a/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m b/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m index bf372d60..5aa66b46 100644 --- a/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m +++ b/tests/cases/unit/apps/electrochem/ComputeVTResistanceTest.m @@ -58,6 +58,16 @@ function verify_computeVTResistance() assertClose(C.Ra_abs_ohm, 100, 1e-10, 'Raw-mode anodic resistance'); assertClose(C.Ravg_abs_ohm, 100, 1e-10, 'Raw-mode average resistance'); + batch = [item item]; + batch(1).analysis = struct('ok', false); + batch(2).analysis = struct('ok', false); + batch = vt_resistance.analysisRun.recomputeItems(batch, opts); + analyses = [batch.analysis]; + assert(all([analyses.ok]), ... + 'Shared VT resistance settings should recompute every loaded item.'); + assert(all(strcmp({analyses.voltageMode}, opts.voltageMode)), ... + 'Every recomputed resistance item should use the same voltage mode.'); + bad = struct('meta', struct(), 'tables', struct([])); D = computeVTResistance(bad, struct()); assert(~D.ok, 'Missing curve should fail.'); From f7737f35fc94967902819092f851efe2c995b623 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 13:52:32 -0500 Subject: [PATCH 04/15] perf: target shared test helper consumers --- docs/testing.md | 4 +- .../build/BuildTaskEfficiencyGuardrailTest.m | 33 +++++++++++++ .../labkitValidationPlanForChangedPaths.m | 49 +++++++++++++++++-- 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 0ed67c6d..5d06f113 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -212,7 +212,9 @@ test class names, not extra ownership paths. `tests/shared/` intentionally keeps ordinary MATLAB helper functions as one-function files because those helpers are called directly by tests. Prefer a plain function file there over a larger registry object unless repeated call -patterns justify a grouped API. +patterns justify a grouped API. Changed-file validation resolves direct test +consumers of a shared helper and reruns those test classes instead of expanding +every shared GUI helper change to the full GUI suite. Build tasks are the supported human and CI entry points. The lower-level runner is an implementation detail used by the buildfile. diff --git a/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m b/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m index 8ece7bf5..9edca02c 100644 --- a/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m +++ b/tests/cases/contract/project/build/BuildTaskEfficiencyGuardrailTest.m @@ -146,6 +146,34 @@ function changedValidationPlanRoutesFrameworkGuiTestsByOwner(testCase) "LabKit framework GUI test changes should rerun the owning framework GUI suite."); end + function changedValidationPlanTargetsSharedHelperConsumers(testCase) + root = setupLabKitTestPath(); + + guiSteps = labkitValidationPlanForChangedPaths(root, ... + "tests/shared/guiTestHelpers.m"); + guiSignatures = validationStepSignatures(guiSteps); + guiSelectors = split(validationStepTestSelectors(guiSteps), ","); + testCase.verifyEqual(guiSignatures, "gui|true", ... + "Shared GUI helpers should route to direct GUI consumers."); + testCase.verifyGreaterThan(numel(guiSelectors), 1, ... + "Shared GUI helper routing should retain multiple consumers."); + testCase.verifyLessThan(numel(guiSelectors), ... + officialGuiClassCount(root), ... + "Shared GUI helper routing should avoid the full GUI suite."); + testCase.verifyTrue(any(guiSelectors == "GuiLayoutDicPreprocessTest"), ... + "Shared GUI helper routing should include real app consumers."); + + workflowSteps = labkitValidationPlanForChangedPaths(root, ... + "tests/shared/labkitWorkflowDriver.m"); + workflowSelectors = split( ... + validationStepTestSelectors(workflowSteps), ","); + testCase.verifyLessThan(numel(workflowSelectors), ... + numel(guiSelectors), ... + "Workflow-driver changes should run only direct consumers."); + testCase.verifyTrue(any(workflowSelectors == "GuiLayoutBatchCropTest"), ... + "Workflow-driver routing should include app workflow consumers."); + end + function testCasePathsUseExplicitOwners(testCase) root = setupLabKitTestPath(); caseFiles = trackedTestCaseFiles(root); @@ -229,3 +257,8 @@ function verifySelectorsMatchTests(testCase, selectors, suites) files = files(exists); files = "/" + replace(files, filesep, "/"); end + +function count = officialGuiClassCount(root) + entries = dir(fullfile(root, "tests", "cases", "gui", "**", "*.m")); + count = numel(entries); +end diff --git a/tests/runner/labkitValidationPlanForChangedPaths.m b/tests/runner/labkitValidationPlanForChangedPaths.m index 974035f2..6230eb58 100644 --- a/tests/runner/labkitValidationPlanForChangedPaths.m +++ b/tests/runner/labkitValidationPlanForChangedPaths.m @@ -173,7 +173,7 @@ elseif numel(parts) >= 2 && parts(2) == "runner" steps = fullNonGuiStep(); elseif numel(parts) >= 2 && parts(2) == "shared" - steps = sharedTestPathSteps(parts); + steps = sharedTestPathSteps(root, parts); else steps = planStep("project", "project", false, ... "Reason", "test support or policy file changed"); @@ -252,12 +252,25 @@ end end -function steps = sharedTestPathSteps(parts) +function steps = sharedTestPathSteps(root, parts) filename = ""; if ~isempty(parts) filename = lower(parts(end)); end - if contains(filename, "launcher") + consumers = sharedHelperConsumers(root, filename); + if ~isempty(consumers.guiTests) || ~isempty(consumers.nonGuiTests) + steps = emptyPlanSteps(); + if ~isempty(consumers.nonGuiTests) + steps(end + 1) = planStep("shared_consumers", strings(1, 0), false, ... + "Tests", consumers.nonGuiTests, ... + "Reason", "shared test helper change reruns direct non-GUI consumers"); + end + if ~isempty(consumers.guiTests) + steps(end + 1) = planStep("gui_shared_consumers", "gui", true, ... + "Tests", consumers.guiTests, ... + "Reason", "shared test helper change reruns direct GUI consumers"); + end + elseif contains(filename, "launcher") steps = planStep("gui_project_launcher", "gui/project/launcher", true, ... "Reason", "shared launcher test helper changed"); elseif contains(filename, "gui") || contains(filename, "uispec") || ... @@ -269,6 +282,36 @@ end end +function consumers = sharedHelperConsumers(root, filename) + consumers = struct( ... + "guiTests", strings(1, 0), ... + "nonGuiTests", strings(1, 0)); + [~, helperName] = fileparts(char(filename)); + if strlength(string(helperName)) == 0 + return; + end + casesRoot = fullfile(root, "tests", "cases"); + entries = dir(fullfile(casesRoot, "**", "*.m")); + callPattern = ['(^|[^A-Za-z0-9_])' ... + regexptranslate('escape', helperName) '\s*\(']; + for iFile = 1:numel(entries) + filepath = fullfile(entries(iFile).folder, entries(iFile).name); + source = fileread(filepath); + if isempty(regexpi(source, callPattern, 'once')) + continue; + end + selector = string(erase(entries(iFile).name, ".m")); + relativePath = replace(string(filepath), "\", "/"); + if contains(relativePath, "/tests/cases/gui/") + consumers.guiTests(end + 1) = selector; + else + consumers.nonGuiTests(end + 1) = selector; + end + end + consumers.guiTests = unique(consumers.guiTests, "stable"); + consumers.nonGuiTests = unique(consumers.nonGuiTests, "stable"); +end + function tf = isHeadlessRoutingPath(path) path = string(path); tf = ismember(path, [ ... From e3f71c2d0a41f60ad4e714bccfd69642c5aadd53 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 14:05:01 -0500 Subject: [PATCH 05/15] refactor: align image conversion APIs with MATLAB --- +labkit/+image/adjustBrightnessContrast.m | 5 +- +labkit/+image/adjustHueSaturation.m | 6 ++- +labkit/+image/ensureRgb.m | 31 +++++++++++ +labkit/+image/grayWorldWhiteBalance.m | 5 +- +labkit/+image/im2double.m | 47 +++++++++++++++++ +labkit/+image/localContrast.m | 5 +- +labkit/+image/readFiles.m | 3 +- +labkit/+image/rgb2gray.m | 38 ++++++++++++++ +labkit/+image/sharpen.m | 5 +- +labkit/+image/toDouble.m | 31 ----------- +labkit/+image/toLuma.m | 34 ------------- +labkit/+image/toRgbDouble.m | 25 --------- +labkit/+image/version.m | 2 +- +labkit/AGENTS.md | 6 +++ CHANGELOG.md | 51 +++++++++++++++---- .../+dic_postprocess/+analysisRun/imageMask.m | 6 +-- .../+dic_postprocess/requirements.m | 3 +- .../+dic_postprocess/version.m | 2 +- .../+analysisRun/autoAlignMovingToReference.m | 21 ++------ .../+analysisRun/makeFalseColorOverlay.m | 21 ++------ .../+userInterface/selectRigidPointPairs.m | 3 +- .../+dic_preprocess/requirements.m | 3 +- .../dic_preprocess/+dic_preprocess/version.m | 2 +- .../batch_crop/+batch_crop/requirements.m | 2 +- .../batch_crop/+batch_crop/version.m | 4 +- .../curvature/+curvature/requirements.m | 2 +- .../curvature/+curvature/version.m | 4 +- .../flir_thermal/+flir_thermal/requirements.m | 2 +- .../flir_thermal/+flir_thermal/version.m | 4 +- .../+analysisRun/computeFocusStack.m | 8 ++- .../+focus_stack/+analysisRun/normalizeGray.m | 7 ++- .../+userInterface/previewImage.m | 6 +-- .../focus_stack/+focus_stack/requirements.m | 2 +- .../focus_stack/+focus_stack/version.m | 2 +- .../+analysisRun/applyPipeline.m | 3 +- .../+image_enhance/+analysisRun/applyStep.m | 5 +- .../+userInterface/beforeAfterImage.m | 2 +- .../+userInterface/previewImage.m | 3 +- .../+image_enhance/requirements.m | 2 +- .../image_enhance/+image_enhance/version.m | 2 +- .../+image_match/+analysisRun/applyMatch.m | 15 +++--- .../+image_match/+analysisRun/applyPipeline.m | 3 +- .../+userInterface/beforeAfterImage.m | 2 +- .../+userInterface/previewImage.m | 3 +- .../image_match/+image_match/requirements.m | 2 +- .../image_match/+image_match/version.m | 2 +- docs/image.md | 25 ++++----- .../generate_image_app_workflow_assets.m | 10 ++-- .../hygiene/ToolboxDependencyGuardrailTest.m | 18 ++++--- .../packages/PackagePublicSurfaceTest.m | 5 +- .../image_measurement/FocusStackFusionTest.m | 8 +-- .../apps/image_measurement/ImageEnhanceTest.m | 8 +-- .../apps/image_measurement/ImageMatchTest.m | 4 +- .../image/LabKitImageFacadeTest.m | 24 ++++++--- 54 files changed, 304 insertions(+), 240 deletions(-) create mode 100644 +labkit/+image/ensureRgb.m create mode 100644 +labkit/+image/im2double.m create mode 100644 +labkit/+image/rgb2gray.m delete mode 100644 +labkit/+image/toDouble.m delete mode 100644 +labkit/+image/toLuma.m delete mode 100644 +labkit/+image/toRgbDouble.m diff --git a/+labkit/+image/adjustBrightnessContrast.m b/+labkit/+image/adjustBrightnessContrast.m index 4b50da9d..a20b2201 100644 --- a/+labkit/+image/adjustBrightnessContrast.m +++ b/+labkit/+image/adjustBrightnessContrast.m @@ -5,14 +5,15 @@ % imageOut = labkit.image.adjustBrightnessContrast(imageIn, brightnessPct, contrastPct) % % Inputs: -% imageIn - numeric image data, normalized internally with toRgbDouble. +% imageIn - numeric image data, normalized internally to RGB double. % brightnessPct - brightness offset in percent of full scale. % contrastPct - contrast scale delta in percent around midpoint 0.5. % % Outputs: % imageOut - RGB double image clamped to [0, 1]. - imageIn = labkit.image.toRgbDouble(imageIn); + imageIn = labkit.image.ensureRgb(labkit.image.im2double(imageIn)); + imageIn = min(max(imageIn, 0), 1); brightness = double(brightnessPct) / 100; contrastScale = max(0, 1 + double(contrastPct) / 100); imageOut = (imageIn - 0.5) .* contrastScale + 0.5 + brightness; diff --git a/+labkit/+image/adjustHueSaturation.m b/+labkit/+image/adjustHueSaturation.m index 72cff3bc..422d5069 100644 --- a/+labkit/+image/adjustHueSaturation.m +++ b/+labkit/+image/adjustHueSaturation.m @@ -5,14 +5,16 @@ % imageOut = labkit.image.adjustHueSaturation(imageIn, hueDeg, saturationPct) % % Inputs: -% imageIn - numeric image data, normalized internally with toRgbDouble. +% imageIn - numeric image data, normalized internally to RGB double. % hueDeg - hue rotation in degrees. % saturationPct - saturation scale delta in percent. % % Outputs: % imageOut - RGB double image clamped to [0, 1]. - hsvImage = rgb2hsv(labkit.image.toRgbDouble(imageIn)); + imageIn = labkit.image.ensureRgb(labkit.image.im2double(imageIn)); + imageIn = min(max(imageIn, 0), 1); + hsvImage = rgb2hsv(imageIn); hsvImage(:, :, 1) = mod(hsvImage(:, :, 1) + double(hueDeg) / 360, 1); hsvImage(:, :, 2) = hsvImage(:, :, 2) .* (1 + double(saturationPct) / 100); hsvImage(:, :, 2) = min(max(hsvImage(:, :, 2), 0), 1); diff --git a/+labkit/+image/ensureRgb.m b/+labkit/+image/ensureRgb.m new file mode 100644 index 00000000..78b88d84 --- /dev/null +++ b/+labkit/+image/ensureRgb.m @@ -0,0 +1,31 @@ +function rgb = ensureRgb(imageData) +%ENSURERGB Return image data with exactly three color channels. +% +% App-facing contract: +% rgb = labkit.image.ensureRgb(imageData) +% +% Inputs: +% imageData - numeric or logical M-by-N grayscale, M-by-N-by-1 grayscale, +% M-by-N-by-3 RGB, or M-by-N-by-C data with C greater than three. +% +% Outputs: +% rgb - data with the same class and first two dimensions as imageData. +% Grayscale is replicated across three channels and channels after the +% first three are dropped. Values and numeric class are not changed. + + narginchk(1, 1); + validateattributes(imageData, {'numeric', 'logical'}, {'nonsparse'}, ... + mfilename, 'imageData'); + if isempty(imageData) + rgb = imageData; + return; + end + if ismatrix(imageData) || size(imageData, 3) == 1 + rgb = repmat(imageData(:, :, 1), 1, 1, 3); + elseif size(imageData, 3) >= 3 + rgb = imageData(:, :, 1:3); + else + error('labkit:image:ensureRgb:InvalidChannelCount', ... + 'Image data must have one, three, or more than three channels.'); + end +end diff --git a/+labkit/+image/grayWorldWhiteBalance.m b/+labkit/+image/grayWorldWhiteBalance.m index 801911b8..7e4f6805 100644 --- a/+labkit/+image/grayWorldWhiteBalance.m +++ b/+labkit/+image/grayWorldWhiteBalance.m @@ -5,14 +5,15 @@ % imageOut = labkit.image.grayWorldWhiteBalance(imageIn, strengthPct, temperaturePct) % % Inputs: -% imageIn - numeric image data, normalized internally with toRgbDouble. +% imageIn - numeric image data, normalized internally to RGB double. % strengthPct - blend strength from 0 to 100. % temperaturePct - optional warm/cool red-blue offset in percent. % % Outputs: % imageOut - RGB double image clamped to [0, 1]. - imageIn = labkit.image.toRgbDouble(imageIn); + imageIn = labkit.image.ensureRgb(labkit.image.im2double(imageIn)); + imageIn = min(max(imageIn, 0), 1); strength = min(max(double(strengthPct) / 100, 0), 1); temperature = double(temperaturePct) / 100; channelMean = squeeze(mean(imageIn, [1 2])); diff --git a/+labkit/+image/im2double.m b/+labkit/+image/im2double.m new file mode 100644 index 00000000..1e7ccdee --- /dev/null +++ b/+labkit/+image/im2double.m @@ -0,0 +1,47 @@ +function imageOut = im2double(imageIn, imageType) +%IM2DOUBLE Convert image data to double using MATLAB's im2double contract. +% +% App-facing contract: +% imageOut = labkit.image.im2double(imageIn) +% imageOut = labkit.image.im2double(indexedImage, "indexed") +% +% Inputs: +% imageIn - logical, uint8, uint16, int16, single, or double image data. +% imageType - optional "indexed" flag. For uint8 and uint16 indexed data, +% one is added after conversion to preserve MATLAB's one-based double +% colormap indices. +% +% Outputs: +% imageOut - double image data with the same class scaling and indexed-image +% offset as MATLAB im2double. Empty input returns an empty double array. + + narginchk(1, 2); + indexed = false; + if nargin == 2 + indexed = strcmpi(string(imageType), "indexed"); + if ~isscalar(indexed) || ~indexed + error('labkit:image:im2double:InvalidImageType', ... + 'The optional image type must be "indexed".'); + end + end + + if isa(imageIn, 'double') + imageOut = imageIn; + elseif islogical(imageIn) || isfloat(imageIn) + imageOut = double(imageIn); + elseif isa(imageIn, 'uint8') + imageOut = double(imageIn) ./ double(intmax('uint8')); + elseif isa(imageIn, 'uint16') + imageOut = double(imageIn) ./ double(intmax('uint16')); + elseif isa(imageIn, 'int16') + imageOut = (double(imageIn) - double(intmin('int16'))) ./ ... + (double(intmax('int16')) - double(intmin('int16'))); + else + error('labkit:image:im2double:UnsupportedClass', ... + 'Unsupported image class: %s.', class(imageIn)); + end + + if indexed && (isa(imageIn, 'uint8') || isa(imageIn, 'uint16')) + imageOut = imageOut .* double(intmax(class(imageIn))) + 1; + end +end diff --git a/+labkit/+image/localContrast.m b/+labkit/+image/localContrast.m index b91b30cb..5d8603d7 100644 --- a/+labkit/+image/localContrast.m +++ b/+labkit/+image/localContrast.m @@ -5,14 +5,15 @@ % imageOut = labkit.image.localContrast(imageIn, amountPct, radiusPx) % % Inputs: -% imageIn - numeric image data, normalized internally with toRgbDouble. +% imageIn - numeric image data, normalized internally to RGB double. % amountPct - nonnegative effect strength in percent. % radiusPx - local neighborhood radius in pixels. % % Outputs: % imageOut - RGB double image clamped to [0, 1]. - imageIn = labkit.image.toRgbDouble(imageIn); + imageIn = labkit.image.ensureRgb(labkit.image.im2double(imageIn)); + imageIn = min(max(imageIn, 0), 1); amount = max(0, double(amountPct)) / 100; radius = max(1, round(double(radiusPx))); hsvImage = rgb2hsv(imageIn); diff --git a/+labkit/+image/readFiles.m b/+labkit/+image/readFiles.m index d7f9f418..5909dad4 100644 --- a/+labkit/+image/readFiles.m +++ b/+labkit/+image/readFiles.m @@ -43,7 +43,8 @@ reportProgress(opts.progressFcn, "beforeRead", k, numel(paths), path); imageData = imread(char(path)); if opts.Normalize - imageData = labkit.image.toRgbDouble(imageData); + imageData = labkit.image.ensureRgb(labkit.image.im2double(imageData)); + imageData = min(max(imageData, 0), 1); end records(k) = struct( ... 'path', path, ... diff --git a/+labkit/+image/rgb2gray.m b/+labkit/+image/rgb2gray.m new file mode 100644 index 00000000..e72c0587 --- /dev/null +++ b/+labkit/+image/rgb2gray.m @@ -0,0 +1,38 @@ +function gray = rgb2gray(rgb) +%RGB2GRAY Convert RGB data using MATLAB's rgb2gray call contract. +% +% App-facing contract: +% grayImage = labkit.image.rgb2gray(rgbImage) +% grayMap = labkit.image.rgb2gray(rgbColorMap) +% +% Inputs: +% rgb - M-by-N-by-3 RGB image or M-by-3 colormap of class uint8, uint16, +% int16, single, or double. +% +% Outputs: +% gray - grayscale image or colormap with the same numeric class as rgb. +% Conversion uses the Rec.601 luma weights used by MATLAB rgb2gray. + + narginchk(1, 1); + validateattributes(rgb, {'uint8', 'uint16', 'int16', 'single', 'double'}, ... + {'real', 'nonsparse'}, mfilename, 'rgb'); + isColorMap = ismatrix(rgb) && size(rgb, 2) == 3; + isRgbImage = ndims(rgb) == 3 && size(rgb, 3) == 3; + if ~(isColorMap || isRgbImage) + error('labkit:image:rgb2gray:InvalidShape', ... + 'Input must be an M-by-N-by-3 RGB image or an M-by-3 colormap.'); + end + + % ITU-R BT.601 luma coefficients are the documented MATLAB rgb2gray + % transform; naming the source keeps these scientific constants auditable. + rec601LumaWeights = [0.2989 0.5870 0.1140]; + if isColorMap + converted = double(rgb) * rec601LumaWeights(:); + converted = repmat(converted, 1, 3); + else + converted = rec601LumaWeights(1) .* double(rgb(:, :, 1)) + ... + rec601LumaWeights(2) .* double(rgb(:, :, 2)) + ... + rec601LumaWeights(3) .* double(rgb(:, :, 3)); + end + gray = cast(converted, 'like', rgb); +end diff --git a/+labkit/+image/sharpen.m b/+labkit/+image/sharpen.m index c206f798..55b7443c 100644 --- a/+labkit/+image/sharpen.m +++ b/+labkit/+image/sharpen.m @@ -5,14 +5,15 @@ % imageOut = labkit.image.sharpen(imageIn, amountPct, radiusPx) % % Inputs: -% imageIn - numeric image data, normalized internally with toRgbDouble. +% imageIn - numeric image data, normalized internally to RGB double. % amountPct - nonnegative effect strength in percent. % radiusPx - blur radius in pixels. % % Outputs: % imageOut - RGB double image clamped to [0, 1]. - imageIn = labkit.image.toRgbDouble(imageIn); + imageIn = labkit.image.ensureRgb(labkit.image.im2double(imageIn)); + imageIn = min(max(imageIn, 0), 1); amount = max(0, double(amountPct)) / 100; radius = max(0.5, double(radiusPx)); windowSize = max(3, 2 * round(radius) + 1); diff --git a/+labkit/+image/toDouble.m b/+labkit/+image/toDouble.m deleted file mode 100644 index c8968496..00000000 --- a/+labkit/+image/toDouble.m +++ /dev/null @@ -1,31 +0,0 @@ -function imageOut = toDouble(imageIn) -%TODOUBLE Convert image data to double without Image Processing Toolbox. -% -% App-facing contract: -% imageOut = labkit.image.toDouble(imageIn) -% -% Inputs: -% imageIn - numeric or logical image data. -% -% Outputs: -% imageOut - double image data using MATLAB im2double-compatible image -% class scaling for uint8, uint16, int16, logical, single, and double. -% Empty input returns []. - - if isempty(imageIn) - imageOut = []; - elseif isfloat(imageIn) - imageOut = double(imageIn); - elseif islogical(imageIn) - imageOut = double(imageIn); - elseif isa(imageIn, 'uint8') - imageOut = double(imageIn) ./ double(intmax('uint8')); - elseif isa(imageIn, 'uint16') - imageOut = double(imageIn) ./ double(intmax('uint16')); - elseif isa(imageIn, 'int16') - imageOut = (double(imageIn) - double(intmin('int16'))) ./ ... - (double(intmax('int16')) - double(intmin('int16'))); - else - imageOut = double(imageIn); - end -end diff --git a/+labkit/+image/toLuma.m b/+labkit/+image/toLuma.m deleted file mode 100644 index ded26dc1..00000000 --- a/+labkit/+image/toLuma.m +++ /dev/null @@ -1,34 +0,0 @@ -function gray = toLuma(imageIn) -%TOLUMA Convert image data to a luminance plane without toolbox dependencies. -% -% App-facing contract: -% gray = labkit.image.toLuma(imageIn) -% -% Inputs: -% imageIn - numeric grayscale or color image data. Integer images are -% normalized to double through labkit.image.toDouble. Color channels -% beyond RGB are ignored. -% -% Outputs: -% gray - double luminance plane. Grayscale inputs are returned as -% labkit.image.toDouble output. RGB inputs use the Rec.601 luma weights -% used by MATLAB rgb2gray: [0.2989 0.5870 0.1140]. - - if isempty(imageIn) - gray = []; - return; - end - imageIn = labkit.image.toDouble(imageIn); - if ndims(imageIn) == 2 || size(imageIn, 3) == 1 - gray = imageIn(:, :, 1); - return; - end - - % Rec.601 luma coefficients match MATLAB rgb2gray while avoiding the - % Image Processing Toolbox dependency. - rec601LumaWeights = [0.2989 0.5870 0.1140]; - rgb = imageIn(:, :, 1:3); - gray = rec601LumaWeights(1) .* rgb(:, :, 1) + ... - rec601LumaWeights(2) .* rgb(:, :, 2) + ... - rec601LumaWeights(3) .* rgb(:, :, 3); -end diff --git a/+labkit/+image/toRgbDouble.m b/+labkit/+image/toRgbDouble.m deleted file mode 100644 index 59e75bd5..00000000 --- a/+labkit/+image/toRgbDouble.m +++ /dev/null @@ -1,25 +0,0 @@ -function imageOut = toRgbDouble(imageIn) -%TORGBDOUBLE Normalize image data to RGB double in [0, 1]. -% -% App-facing contract: -% imageOut = labkit.image.toRgbDouble(imageIn) -% -% Inputs: -% imageIn - numeric image data. Grayscale images are expanded to RGB, RGB -% images are preserved, and channels beyond RGB are ignored. -% -% Outputs: -% imageOut - double image data clamped to [0, 1]. Empty input returns []. - - if isempty(imageIn) - imageOut = []; - return; - end - imageOut = labkit.image.toDouble(imageIn); - if ndims(imageOut) == 2 - imageOut = repmat(imageOut, 1, 1, 3); - elseif size(imageOut, 3) > 3 - imageOut = imageOut(:, :, 1:3); - end - imageOut = min(max(imageOut, 0), 1); -end diff --git a/+labkit/+image/version.m b/+labkit/+image/version.m index 0d181bc7..e16d7a58 100644 --- a/+labkit/+image/version.m +++ b/+labkit/+image/version.m @@ -12,6 +12,6 @@ % compatible contract ranges implemented by this code, contract status, % and a short maintainer note. - info = labkit.contract.versionInfo("image", "1.2.0", ">=1.0 <2", ... + info = labkit.contract.versionInfo("image", "2.0.0", ">=2.0 <3", ... "stable", "GUI-free image file input, basic processing, and preview-budget helpers for responsive image apps."); end diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index a3853438..45e4536f 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -17,6 +17,12 @@ - Public API growth must be conservative. - New public helpers must be domain-neutral, independently testable, useful beyond one workflow, and clearer as an API than as app-local code. +- When replacing a MATLAB toolbox function with a base-MATLAB implementation, + use the MATLAB function name and preserve its documented call contract under + the owning `labkit.*` namespace. Keep orthogonal shaping or validation steps + in explicitly named helpers; do not bundle them into convenience APIs such + as `toRgbDouble` that silently combine conversion, channel changes, and + clipping. - Do not encode experiment-specific units, thresholds, result columns, plot wording, or export schemas in reusable helpers. - Do not add public `+labkit/+analysis`, `+data`, `+io`, or `+util` app-facing surfaces. - `labkit.dta` stays GUI-free and app-free. diff --git a/CHANGELOG.md b/CHANGELOG.md index 23357a07..1604d56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,39 @@ Compatibility: Evidence: - Current development branch; final mainline commit pending. +### Pending - MATLAB-compatible image conversion API + +Affected versions: +- `labkit.image` `1.2.0 -> 2.0.0` +- `labkit_DICPreprocess_app` `1.3.6 -> 1.3.7` +- `labkit_DICPostprocess_app` `1.3.5 -> 1.3.6` +- `labkit_BatchImageCrop_app` `1.6.7 -> 1.6.8` +- `labkit_CurvatureMeasurement_app` `1.3.4 -> 1.3.5` +- `labkit_FLIRThermal_app` `1.2.8 -> 1.2.9` +- `labkit_FocusStack_app` `1.4.8 -> 1.4.9` +- `labkit_ImageEnhance_app` `1.5.7 -> 1.5.8` +- `labkit_ImageMatch_app` `1.5.7 -> 1.5.8` + +What changed: +- Replaced the LabKit-specific `toDouble`, `toLuma`, and `toRgbDouble` surface + with MATLAB-contract-compatible `labkit.image.im2double` and + `labkit.image.rgb2gray` functions plus the shape-only `ensureRgb` helper. +- Image pipelines now state class conversion, RGB shaping, and clipping as + separate operations, and Rec.601 coefficients have one documented owner. +- DIC now declares its existing dependency on the image facade. + +Why it matters: +- Base-MATLAB users can apply familiar MATLAB image conversion contracts + without learning a composite LabKit normalization API or accepting hidden + channel and value changes. + +Compatibility: +- This is a major image-facade change. Callers must replace the removed helper + names with the explicit compatible conversion and shaping operations. + +Evidence: +- Current development branch; final mainline commit pending. + Template for branch work before the final mainline commit is known: ```markdown @@ -109,7 +142,7 @@ Audited against `main` metadata on 2026-07-13. | `labkit_launcher` | `1.3.0` | Launcher | `labkit_launcher.m` | | `labkit.ui` | `5.0.4` | Facade | `+labkit/+ui/version.m` | | `labkit.dta` | `2.0.0` | Facade | `+labkit/+dta/version.m` | -| `labkit.image` | `1.2.0` | Facade | `+labkit/+image/version.m` | +| `labkit.image` | `2.0.0` | Facade | `+labkit/+image/version.m` | | `labkit.thermal` | `1.0.0` | Facade | `+labkit/+thermal/version.m` | | `labkit.rhs` | `1.0.0` | Facade | `+labkit/+rhs/version.m` | | `labkit.biosignal` | `1.0.0` | Facade | `+labkit/+biosignal/version.m` | @@ -119,14 +152,14 @@ Audited against `main` metadata on 2026-07-13. | `labkit_CSC_app` | `1.3.9` | Electrochem | `apps/electrochem/csc/+csc/version.m` | | `labkit_EIS_app` | `1.3.4` | Electrochem | `apps/electrochem/eis/+eis/version.m` | | `labkit_VTResistance_app` | `1.3.8` | Electrochem | `apps/electrochem/vt_resistance/+vt_resistance/version.m` | -| `labkit_DICPreprocess_app` | `1.3.6` | DIC | `apps/dic/dic_preprocess/+dic_preprocess/version.m` | -| `labkit_DICPostprocess_app` | `1.3.5` | DIC | `apps/dic/dic_postprocess/+dic_postprocess/version.m` | -| `labkit_BatchImageCrop_app` | `1.6.7` | Image Measurement | `apps/image_measurement/batch_crop/+batch_crop/version.m` | -| `labkit_CurvatureMeasurement_app` | `1.3.4` | Image Measurement | `apps/image_measurement/curvature/+curvature/version.m` | -| `labkit_FLIRThermal_app` | `1.2.8` | Image Measurement | `apps/image_measurement/flir_thermal/+flir_thermal/version.m` | -| `labkit_FocusStack_app` | `1.4.8` | Image Measurement | `apps/image_measurement/focus_stack/+focus_stack/version.m` | -| `labkit_ImageEnhance_app` | `1.5.7` | Image Measurement | `apps/image_measurement/image_enhance/+image_enhance/version.m` | -| `labkit_ImageMatch_app` | `1.5.7` | Image Measurement | `apps/image_measurement/image_match/+image_match/version.m` | +| `labkit_DICPreprocess_app` | `1.3.7` | DIC | `apps/dic/dic_preprocess/+dic_preprocess/version.m` | +| `labkit_DICPostprocess_app` | `1.3.6` | DIC | `apps/dic/dic_postprocess/+dic_postprocess/version.m` | +| `labkit_BatchImageCrop_app` | `1.6.8` | Image Measurement | `apps/image_measurement/batch_crop/+batch_crop/version.m` | +| `labkit_CurvatureMeasurement_app` | `1.3.5` | Image Measurement | `apps/image_measurement/curvature/+curvature/version.m` | +| `labkit_FLIRThermal_app` | `1.2.9` | Image Measurement | `apps/image_measurement/flir_thermal/+flir_thermal/version.m` | +| `labkit_FocusStack_app` | `1.4.9` | Image Measurement | `apps/image_measurement/focus_stack/+focus_stack/version.m` | +| `labkit_ImageEnhance_app` | `1.5.8` | Image Measurement | `apps/image_measurement/image_enhance/+image_enhance/version.m` | +| `labkit_ImageMatch_app` | `1.5.8` | Image Measurement | `apps/image_measurement/image_match/+image_match/version.m` | | `labkit_RHSPreview_app` | `1.3.4` | Neurophysiology | `apps/neurophysiology/rhs_preview/+rhs_preview/version.m` | | `labkit_NerveResponseAnalysis_app` | `1.3.4` | Neurophysiology | `apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/version.m` | | `labkit_ResponseReviewStats_app` | `1.3.4` | Neurophysiology | `apps/neurophysiology/response_review_stats/+response_review_stats/version.m` | diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/imageMask.m b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/imageMask.m index 11d973ed..b5c47123 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/imageMask.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/imageMask.m @@ -2,12 +2,12 @@ % Inputs are mask image data and target size. Output is resized logical mask. % Side effects: none. function mask = imageMask(maskImage, targetSize) - maskImage = labkit.image.toDouble(maskImage); + maskImage = labkit.image.im2double(maskImage); if ndims(maskImage) == 3 - maskImage = labkit.image.toLuma(maskImage); + maskImage = labkit.image.rgb2gray(labkit.image.ensureRgb(maskImage)); end % Preserve the legacy uint8 mask contract of "pixel value > 128" after - % normalizing through labkit.image.toDouble. + % normalizing through labkit.image.im2double. normalizedBinaryMaskThreshold = double(128) / double(intmax('uint8')); mask = maskImage > normalizedBinaryMaskThreshold; mask = dic_postprocess.analysisRun.resizeNearest(mask, targetSize); diff --git a/apps/dic/dic_postprocess/+dic_postprocess/requirements.m b/apps/dic/dic_postprocess/+dic_postprocess/requirements.m index d8e7af96..d74c0c75 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/requirements.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/requirements.m @@ -3,5 +3,6 @@ function req = requirements() - req = labkit.contract.requirements("ui", ">=5 <6"); + req = labkit.contract.requirements("ui", ">=5 <6", ... + "image", ">=2.0 <3"); end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/version.m b/apps/dic/dic_postprocess/+dic_postprocess/version.m index dc6e21eb..1bf3d328 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/version.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/version.m @@ -8,6 +8,6 @@ "name", "labkit_DICPostprocess_app", ... "displayName", "DIC Postprocess", ... "family", "DIC", ... - "version", "1.3.5", ... + "version", "1.3.6", ... "updated", "2026-07-13"); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m index ef0c253c..40a7b7da 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/autoAlignMovingToReference.m @@ -19,11 +19,11 @@ function gray = normalizeGray(imageData) if ndims(imageData) == 3 - gray = labkit.image.toLuma(imageData); + rgb = labkit.image.ensureRgb(labkit.image.im2double(imageData)); + gray = labkit.image.rgb2gray(rgb); else - gray = imageData; + gray = labkit.image.im2double(imageData); end - gray = localIm2double(gray); values = gray(:); values = values(~isnan(values)); if isempty(values) @@ -36,21 +36,6 @@ end end -function imageOut = localIm2double(imageIn) - if isfloat(imageIn) - imageOut = double(imageIn); - elseif isa(imageIn, 'uint8') - imageOut = double(imageIn) ./ double(intmax('uint8')); - elseif isa(imageIn, 'uint16') - imageOut = double(imageIn) ./ double(intmax('uint16')); - elseif isa(imageIn, 'int16') - imageOut = (double(imageIn) - double(intmin('int16'))) ./ ... - double(intmax('int16') - intmin('int16')); - else - imageOut = double(imageIn); - end -end - function [rowShift, colShift] = estimateTranslation(fixedGray, movingGray) targetSize = size(fixedGray); movingGray = resizeToMatch(movingGray, targetSize); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/makeFalseColorOverlay.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/makeFalseColorOverlay.m index 68119c52..870d3b14 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/makeFalseColorOverlay.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/makeFalseColorOverlay.m @@ -34,11 +34,11 @@ function gray = normalizeGray(imageData) if ndims(imageData) == 3 - gray = labkit.image.toLuma(imageData); + rgb = labkit.image.ensureRgb(labkit.image.im2double(imageData)); + gray = labkit.image.rgb2gray(rgb); else - gray = imageData; + gray = labkit.image.im2double(imageData); end - gray = localIm2double(gray); values = gray(:); values = values(~isnan(values)); if isempty(values) @@ -50,18 +50,3 @@ gray = (gray - mn) ./ (mx - mn); end end - -function imageOut = localIm2double(imageIn) - if isfloat(imageIn) - imageOut = double(imageIn); - elseif isa(imageIn, 'uint8') - imageOut = double(imageIn) ./ double(intmax('uint8')); - elseif isa(imageIn, 'uint16') - imageOut = double(imageIn) ./ double(intmax('uint16')); - elseif isa(imageIn, 'int16') - imageOut = (double(imageIn) - double(intmin('int16'))) ./ ... - double(intmax('int16') - intmin('int16')); - else - imageOut = double(imageIn); - end -end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m index 690beedd..1a7da90c 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m @@ -195,7 +195,8 @@ function redrawPoints() background = imagesc(ax, double(imageData)); colormap(ax, gray(256)); else - background = image(ax, labkit.image.toRgbDouble(imageData)); + rgb = labkit.image.ensureRgb(labkit.image.im2double(imageData)); + background = image(ax, min(max(rgb, 0), 1)); end axis(ax, 'image'); ax.YDir = 'reverse'; diff --git a/apps/dic/dic_preprocess/+dic_preprocess/requirements.m b/apps/dic/dic_preprocess/+dic_preprocess/requirements.m index d8e7af96..d74c0c75 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/requirements.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/requirements.m @@ -3,5 +3,6 @@ function req = requirements() - req = labkit.contract.requirements("ui", ">=5 <6"); + req = labkit.contract.requirements("ui", ">=5 <6", ... + "image", ">=2.0 <3"); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/version.m b/apps/dic/dic_preprocess/+dic_preprocess/version.m index 6e0f08b3..24715970 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/version.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/version.m @@ -8,6 +8,6 @@ "name", "labkit_DICPreprocess_app", ... "displayName", "DIC Preprocess", ... "family", "DIC", ... - "version", "1.3.6", ... + "version", "1.3.7", ... "updated", "2026-07-13"); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/requirements.m b/apps/image_measurement/batch_crop/+batch_crop/requirements.m index c6c89f64..d74c0c75 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/requirements.m +++ b/apps/image_measurement/batch_crop/+batch_crop/requirements.m @@ -4,5 +4,5 @@ function req = requirements() req = labkit.contract.requirements("ui", ">=5 <6", ... - "image", ">=1.0 <2"); + "image", ">=2.0 <3"); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/version.m b/apps/image_measurement/batch_crop/+batch_crop/version.m index 216fca9b..d3b835c9 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/version.m +++ b/apps/image_measurement/batch_crop/+batch_crop/version.m @@ -8,6 +8,6 @@ "name", "labkit_BatchImageCrop_app", ... "displayName", "Batch Image Crop", ... "family", "Image Measurement", ... - "version", "1.6.7", ... - "updated", "2026-07-07"); + "version", "1.6.8", ... + "updated", "2026-07-13"); end diff --git a/apps/image_measurement/curvature/+curvature/requirements.m b/apps/image_measurement/curvature/+curvature/requirements.m index c6c89f64..d74c0c75 100644 --- a/apps/image_measurement/curvature/+curvature/requirements.m +++ b/apps/image_measurement/curvature/+curvature/requirements.m @@ -4,5 +4,5 @@ function req = requirements() req = labkit.contract.requirements("ui", ">=5 <6", ... - "image", ">=1.0 <2"); + "image", ">=2.0 <3"); end diff --git a/apps/image_measurement/curvature/+curvature/version.m b/apps/image_measurement/curvature/+curvature/version.m index 1f2c40c9..4bc5643f 100644 --- a/apps/image_measurement/curvature/+curvature/version.m +++ b/apps/image_measurement/curvature/+curvature/version.m @@ -8,6 +8,6 @@ "name", "labkit_CurvatureMeasurement_app", ... "displayName", "Curvature Measurement", ... "family", "Image Measurement", ... - "version", "1.3.4", ... - "updated", "2026-07-06"); + "version", "1.3.5", ... + "updated", "2026-07-13"); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m b/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m index 514bfb20..2aa9c4a8 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m @@ -4,6 +4,6 @@ function req = requirements() req = labkit.contract.requirements("ui", ">=5 <6", ... - "image", ">=1.0 <2", ... + "image", ">=2.0 <3", ... "thermal", ">=1.0 <2"); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/version.m b/apps/image_measurement/flir_thermal/+flir_thermal/version.m index ed2dc5cb..7498d510 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/version.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/version.m @@ -8,6 +8,6 @@ "name", "labkit_FLIRThermal_app", ... "displayName", "FLIR Thermal Postprocess", ... "family", "Image Measurement", ... - "version", "1.2.8", ... - "updated", "2026-07-06"); + "version", "1.2.9", ... + "updated", "2026-07-13"); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m index cc936dac..5351a702 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/computeFocusStack.m @@ -216,7 +216,7 @@ img = focus_stack.analysisRun.resizeImageToReference(img, refSize); resizedCount = resizedCount + 1; end - img = convertChannels(labkit.image.toDouble(img), channels); + img = convertChannels(labkit.image.im2double(img), channels); stack(:, :, :, k) = img; end end @@ -265,7 +265,11 @@ end function gray = grayImage(imageData) - gray = labkit.image.toLuma(imageData); + if ismatrix(imageData) || size(imageData, 3) == 1 + gray = imageData(:, :, 1); + else + gray = labkit.image.rgb2gray(labkit.image.ensureRgb(imageData)); + end end function imageOut = gaussianBlurImage(imageIn, sigma) diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/normalizeGray.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/normalizeGray.m index 3e506909..09bf647d 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/normalizeGray.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/normalizeGray.m @@ -17,7 +17,12 @@ if ndims(imageData) == 4 imageData = imageData(:, :, :, 1); end - gray = labkit.image.toLuma(imageData); + imageData = labkit.image.im2double(imageData); + if ismatrix(imageData) || size(imageData, 3) == 1 + gray = imageData(:, :, 1); + else + gray = labkit.image.rgb2gray(labkit.image.ensureRgb(imageData)); + end values = gray(:); values = values(isfinite(values)); if isempty(values) diff --git a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/previewImage.m b/apps/image_measurement/focus_stack/+focus_stack/+userInterface/previewImage.m index 82941b27..f0b27086 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+userInterface/previewImage.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+userInterface/previewImage.m @@ -4,8 +4,6 @@ function img = previewImage(img) %PREVIEWIMAGE Normalize an image for focus-stack preview display. - img = labkit.image.toRgbDouble(img); - if ndims(img) == 3 && size(img, 3) > 3 - img = img(:, :, 1:3); - end + img = labkit.image.ensureRgb(labkit.image.im2double(img)); + img = min(max(img, 0), 1); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/requirements.m b/apps/image_measurement/focus_stack/+focus_stack/requirements.m index c6c89f64..d74c0c75 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/requirements.m +++ b/apps/image_measurement/focus_stack/+focus_stack/requirements.m @@ -4,5 +4,5 @@ function req = requirements() req = labkit.contract.requirements("ui", ">=5 <6", ... - "image", ">=1.0 <2"); + "image", ">=2.0 <3"); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/version.m b/apps/image_measurement/focus_stack/+focus_stack/version.m index 2861fbec..2ac933d3 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/version.m +++ b/apps/image_measurement/focus_stack/+focus_stack/version.m @@ -8,6 +8,6 @@ "name", "labkit_FocusStack_app", ... "displayName", "Focus Stack", ... "family", "Image Measurement", ... - "version", "1.4.8", ... + "version", "1.4.9", ... "updated", "2026-07-13"); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m index 17cc57c7..5d4e158f 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyPipeline.m @@ -26,6 +26,7 @@ end images = images(:); for k = 1:numel(images) - images{k} = labkit.image.toRgbDouble(images{k}); + images{k} = labkit.image.ensureRgb(labkit.image.im2double(images{k})); + images{k} = min(max(images{k}, 0), 1); end end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m index ca333b40..17b8c7a4 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+analysisRun/applyStep.m @@ -3,7 +3,8 @@ % image for match-reference steps. Output is RGB double image data in [0, 1]. function outputImage = applyStep(inputImage, step, referenceImage) - inputImage = labkit.image.toRgbDouble(inputImage); + inputImage = labkit.image.ensureRgb(labkit.image.im2double(inputImage)); + inputImage = min(max(inputImage, 0), 1); key = normalizeKind(step.kind); switch key case 'brightnesscontrast' @@ -137,7 +138,7 @@ end function gray = luma(imageData) - gray = labkit.image.toLuma(imageData); + gray = labkit.image.rgb2gray(imageData); end function y = smoothstep(edge0, edge1, x) diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/beforeAfterImage.m b/apps/image_measurement/image_enhance/+image_enhance/+userInterface/beforeAfterImage.m index af117385..53a24939 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/beforeAfterImage.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+userInterface/beforeAfterImage.m @@ -14,7 +14,7 @@ end function imageOut = normalizePreviewImage(imageIn) - imageOut = min(max(labkit.image.toDouble(imageIn), 0), 1); + imageOut = min(max(labkit.image.im2double(imageIn), 0), 1); if ndims(imageOut) == 2 imageOut = repmat(imageOut, 1, 1, 3); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/previewImage.m b/apps/image_measurement/image_enhance/+image_enhance/+userInterface/previewImage.m index 73bcdd64..08802db1 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+userInterface/previewImage.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+userInterface/previewImage.m @@ -8,7 +8,8 @@ if nargin < 2 || isempty(maxHeight) maxHeight = 1500; end - imageOut = labkit.image.toRgbDouble(imageIn); + imageOut = labkit.image.ensureRgb(labkit.image.im2double(imageIn)); + imageOut = min(max(imageOut, 0), 1); [imageOut, scale] = labkit.image.resizeToFit(imageOut, ... "MaxHeight", maxHeight); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/requirements.m b/apps/image_measurement/image_enhance/+image_enhance/requirements.m index c6c89f64..d74c0c75 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/requirements.m +++ b/apps/image_measurement/image_enhance/+image_enhance/requirements.m @@ -4,5 +4,5 @@ function req = requirements() req = labkit.contract.requirements("ui", ">=5 <6", ... - "image", ">=1.0 <2"); + "image", ">=2.0 <3"); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/version.m b/apps/image_measurement/image_enhance/+image_enhance/version.m index f1a1d4cf..d70092c5 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/version.m +++ b/apps/image_measurement/image_enhance/+image_enhance/version.m @@ -8,6 +8,6 @@ "name", "labkit_ImageEnhance_app", ... "displayName", "Image Enhance", ... "family", "Image Measurement", ... - "version", "1.5.7", ... + "version", "1.5.8", ... "updated", "2026-07-13"); end diff --git a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m index f1286aa1..b920907d 100644 --- a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m +++ b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m @@ -3,13 +3,15 @@ % Output is a display-ready RGB double image in [0, 1]. function outputImage = applyMatch(inputImage, referenceImage, step) - inputImage = labkit.image.toRgbDouble(inputImage); + inputImage = labkit.image.ensureRgb(labkit.image.im2double(inputImage)); + inputImage = min(max(inputImage, 0), 1); if isempty(referenceImage) outputImage = inputImage; return; end - referenceImage = labkit.image.toRgbDouble(referenceImage); + referenceImage = labkit.image.ensureRgb(labkit.image.im2double(referenceImage)); + referenceImage = min(max(referenceImage, 0), 1); strength = clamp01(double(step.amount) / 100); toneStrength = clamp01(double(step.secondary) / 100); colorStrength = clamp01(double(step.colorStrength) / 100); @@ -265,17 +267,12 @@ end function imageData = normalizeImage(imageData) - imageData = labkit.image.toDouble(imageData); - if ndims(imageData) == 2 - imageData = repmat(imageData, 1, 1, 3); - elseif size(imageData, 3) > 3 - imageData = imageData(:, :, 1:3); - end + imageData = labkit.image.ensureRgb(labkit.image.im2double(imageData)); imageData = min(max(imageData, 0), 1); end function gray = luma(imageData) - gray = labkit.image.toLuma(imageData); + gray = labkit.image.rgb2gray(imageData); end function outputImage = labToRgb(labImage) diff --git a/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m b/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m index 63bec86b..914fc251 100644 --- a/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m +++ b/apps/image_measurement/image_match/+image_match/+analysisRun/applyPipeline.m @@ -35,5 +35,6 @@ if isempty(imageData) return; end - imageData = labkit.image.toRgbDouble(imageData); + imageData = labkit.image.ensureRgb(labkit.image.im2double(imageData)); + imageData = min(max(imageData, 0), 1); end diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/beforeAfterImage.m b/apps/image_measurement/image_match/+image_match/+userInterface/beforeAfterImage.m index 4a0c91fb..dc8075f2 100644 --- a/apps/image_measurement/image_match/+image_match/+userInterface/beforeAfterImage.m +++ b/apps/image_measurement/image_match/+image_match/+userInterface/beforeAfterImage.m @@ -14,7 +14,7 @@ end function imageOut = normalizePreviewImage(imageIn) - imageOut = min(max(labkit.image.toDouble(imageIn), 0), 1); + imageOut = min(max(labkit.image.im2double(imageIn), 0), 1); if ndims(imageOut) == 2 imageOut = repmat(imageOut, 1, 1, 3); end diff --git a/apps/image_measurement/image_match/+image_match/+userInterface/previewImage.m b/apps/image_measurement/image_match/+image_match/+userInterface/previewImage.m index c89c9e21..d481cae5 100644 --- a/apps/image_measurement/image_match/+image_match/+userInterface/previewImage.m +++ b/apps/image_measurement/image_match/+image_match/+userInterface/previewImage.m @@ -7,6 +7,7 @@ if nargin < 2 || isempty(maxHeight) maxHeight = 1500; end - imageOut = labkit.image.toRgbDouble(imageIn); + imageOut = labkit.image.ensureRgb(labkit.image.im2double(imageIn)); + imageOut = min(max(imageOut, 0), 1); imageOut = labkit.image.resizeToFit(imageOut, "MaxHeight", maxHeight); end diff --git a/apps/image_measurement/image_match/+image_match/requirements.m b/apps/image_measurement/image_match/+image_match/requirements.m index c6c89f64..d74c0c75 100644 --- a/apps/image_measurement/image_match/+image_match/requirements.m +++ b/apps/image_measurement/image_match/+image_match/requirements.m @@ -4,5 +4,5 @@ function req = requirements() req = labkit.contract.requirements("ui", ">=5 <6", ... - "image", ">=1.0 <2"); + "image", ">=2.0 <3"); end diff --git a/apps/image_measurement/image_match/+image_match/version.m b/apps/image_measurement/image_match/+image_match/version.m index 332e07e3..12320536 100644 --- a/apps/image_measurement/image_match/+image_match/version.m +++ b/apps/image_measurement/image_match/+image_match/version.m @@ -8,6 +8,6 @@ "name", "labkit_ImageMatch_app", ... "displayName", "Image Match", ... "family", "Image Measurement", ... - "version", "1.5.7", ... + "version", "1.5.8", ... "updated", "2026-07-13"); end diff --git a/docs/image.md b/docs/image.md index cc443cdf..b455f419 100644 --- a/docs/image.md +++ b/docs/image.md @@ -15,9 +15,10 @@ filter = labkit.image.fileDialogFilter("IncludeAll", true); paths = labkit.image.normalizePaths(rawPaths); records = labkit.image.readFiles(paths); -source = labkit.image.toDouble(records(1).image); -preview = labkit.image.toRgbDouble(source); -luma = labkit.image.toLuma(preview); +source = labkit.image.im2double(records(1).image); +preview = labkit.image.ensureRgb(source); +preview = min(max(preview, 0), 1); +luma = labkit.image.rgb2gray(preview); [preview, scale] = labkit.image.resizeToFit(preview, "MaxHeight", 1500); [preview, budget] = labkit.image.previewBudget(preview, "MaxPixels", 1.2e6); @@ -32,16 +33,16 @@ labkit.image.writeFile(enhanced, outputPath); ## Normalization Helpers -`labkit.image.toDouble` is the base-MATLAB replacement for `im2double` for -LabKit-supported image classes. Use it when code would otherwise call -`im2double`. +`labkit.image.im2double` follows the MATLAB `im2double` call contract for +supported numeric image classes, including the optional `"indexed"` mode. -`labkit.image.toLuma` is the base-MATLAB replacement for `rgb2gray`-style -luminance conversion. Use it when code needs a grayscale analysis plane. +`labkit.image.rgb2gray` follows the MATLAB `rgb2gray` call contract for RGB +images and colormaps while using the documented Rec.601 luma transform. -`labkit.image.toRgbDouble` is LabKit display glue, not a MATLAB API clone. It -normalizes with `toDouble`, expands grayscale images to RGB, drops channels -beyond RGB, and clamps to `[0, 1]` for previews and RGB enhancement pipelines. +`labkit.image.ensureRgb` changes channel shape only. It expands grayscale data +to three channels or drops channels after RGB without changing class or sample +values. Callers that need display-ready RGB data explicitly compose +`im2double`, `ensureRgb`, and `[0, 1]` clamping as shown above. ## Ownership @@ -50,7 +51,7 @@ The facade may own: - supported source-image extension lists and file-dialog filters - path normalization and display names - `imread`/`imwrite` wrappers that normalize app-facing edge behavior -- base-MATLAB image normalization, RGB double conversion, preview-size fitting, +- MATLAB-compatible image conversion, explicit RGB shaping, preview-size fitting, and edge-normalized mean filtering - display-pixel budget helpers for responsive previews while preserving a documented integer coordinate scale diff --git a/docs/tools/generate_image_app_workflow_assets.m b/docs/tools/generate_image_app_workflow_assets.m index 6c54b8eb..86d89da5 100644 --- a/docs/tools/generate_image_app_workflow_assets.m +++ b/docs/tools/generate_image_app_workflow_assets.m @@ -112,7 +112,7 @@ function runImageEnhanceWorkflow(inputs, assetDir, exportRoot) image_enhance.analysisRun.makeStep("Sharpen", 22, 1.5, 0)]; opts = struct("outputFolder", string(outputFolder), "format", "PNG"); payload = image_enhance.resultFiles.writeOutputs(items, steps, opts); - processed = labkit.image.toDouble(imread(char(payload.results(1).outputPath))); + processed = labkit.image.im2double(imread(char(payload.results(1).outputPath))); exportPairImage(items(1).image, processed, ... fullfile(assetDir, "workflow_image_enhance_before_after.png"), ... @@ -129,7 +129,7 @@ function runImageMatchWorkflow(inputs, assetDir, exportRoot) steps = image_match.analysisRun.makeStep("Balanced", 85, 70, 80); opts = struct("outputFolder", string(outputFolder), "format", "PNG"); payload = image_match.resultFiles.writeOutputs(items, reference, steps, opts); - matched = labkit.image.toDouble(imread(char(payload.results(1).outputPath))); + matched = labkit.image.im2double(imread(char(payload.results(1).outputPath))); exportTriptychImage(reference(1).image, items(1).image, matched, ... fullfile(assetDir, ... @@ -154,8 +154,8 @@ function runBatchCropWorkflow(inputs, assetDir, exportRoot) "cropHeight", 320, ... "paddingPercent", 18); payload = batch_crop.resultFiles.writeOutputs(items, opts); - cropped = labkit.image.toDouble(imread(char(payload.results(1).outputPath))); - source = labkit.image.toDouble(imread(inputs.cropSource)); + cropped = labkit.image.im2double(imread(char(payload.results(1).outputPath))); + source = labkit.image.im2double(imread(inputs.cropSource)); marked = drawCropBox(source, items(1).centerXY, ... opts.cropWidth, opts.cropHeight); @@ -297,7 +297,7 @@ function displayImage(ax, imageData) end function marked = drawCropBox(imageData, centerXY, widthPx, heightPx) - marked = labkit.image.toDouble(imageData); + marked = labkit.image.im2double(imageData); x1 = max(1, round(centerXY(1) - widthPx / 2)); x2 = min(size(marked, 2), round(centerXY(1) + widthPx / 2)); y1 = max(1, round(centerXY(2) - heightPx / 2)); diff --git a/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m b/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m index c1ab8ad4..890305dc 100644 --- a/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m +++ b/tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m @@ -74,7 +74,7 @@ function rec601LumaWeightsStayCentralized(testCase) files = trackedMatlabFiles(root); findings = collectRec601LumaWeightCopies(root, files); testCase.verifyEmpty(findings, ... - ['Rec.601 luma weights should be owned by labkit.image.toLuma ' ... + ['Rec.601 luma weights should be owned by labkit.image.rgb2gray ' ... 'instead of copied into app or test code. Findings: ' ... strjoin(cellstr(findings), ', ')]); end @@ -83,13 +83,13 @@ function rec601LumaWeightsStayCentralized(testCase) function smokeLabkitImageFacade() imageData = uint8(repmat(reshape(0:15, 4, 4), 1, 1, 3)); - rgb = labkit.image.toRgbDouble(imageData); - luma = labkit.image.toLuma(imageData); + rgb = labkit.image.ensureRgb(labkit.image.im2double(imageData)); + luma = labkit.image.rgb2gray(rgb); resized = labkit.image.resizeToFit(rgb, "MaxHeight", 2); assert(isequal(size(rgb), [4 4 3]), ... - 'labkit.image.toRgbDouble should return RGB double image data.'); + 'Explicit image conversion and RGB shaping should return RGB double data.'); assert(isequal(size(luma), [4 4]), ... - 'labkit.image.toLuma should return one luminance plane.'); + 'labkit.image.rgb2gray should return one luminance plane.'); assert(size(resized, 1) == 2, ... 'labkit.image.resizeToFit should use the base-MATLAB resize path.'); end @@ -185,7 +185,7 @@ function smokeImageMeasurementApps() weightPatterns = ["0.2989", "0.5870", "0.1140"]; for k = 1:numel(files) file = slashPath(files(k)); - if file == "+labkit/+image/toLuma.m" || ... + if file == "+labkit/+image/rgb2gray.m" || ... file == "tests/cases/contract/project/hygiene/ToolboxDependencyGuardrailTest.m" continue; end @@ -206,7 +206,7 @@ function smokeImageMeasurementApps() function findings = collectHardToolboxCallsFromContents(~, files, contents) findings = strings(1, 0); names = guardedToolboxFunctionNames(); - pattern = ['(^|[^\w])(' char(strjoin(names, "|")) ')\s*\(']; + pattern = ['(^|[^\w.])(' char(strjoin(names, "|")) ')\s*\(']; for k = 1:numel(files) file = slashPath(files(k)); if ~isKey(contents, char(files(k))) @@ -215,7 +215,9 @@ function smokeImageMeasurementApps() lines = contents(char(files(k))); for iLine = 1:numel(lines) line = string(lines(iLine)); - if startsWith(strtrim(line), "%") || isempty(regexp(char(line), pattern, 'once')) + trimmed = strtrim(line); + if startsWith(trimmed, "%") || startsWith(trimmed, "function") || ... + isempty(regexp(char(line), pattern, 'once')) continue; end findings(end + 1) = file + ":" + string(iLine); diff --git a/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m b/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m index 911ffc38..f39e905d 100644 --- a/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m +++ b/tests/cases/contract/project/packages/PackagePublicSurfaceTest.m @@ -122,10 +122,9 @@ function verify_package_public_surface() {'adjustBrightnessContrast.m', 'adjustHueSaturation.m', ... 'assertSupportedPaths.m', 'displayName.m', 'fileDialogFilter.m', ... 'grayWorldWhiteBalance.m', 'isSupportedPath.m', 'localContrast.m', ... - 'meanFilter2.m', 'normalizePaths.m', 'previewBudget.m', ... + 'ensureRgb.m', 'im2double.m', 'meanFilter2.m', 'normalizePaths.m', 'previewBudget.m', ... 'readFiles.m', 'resizeToFit.m', ... - 'sharpen.m', 'supportedExtensions.m', 'toDouble.m', 'toLuma.m', ... - 'toRgbDouble.m', ... + 'rgb2gray.m', 'sharpen.m', 'supportedExtensions.m', ... 'version.m', 'writeFile.m'}, ... 'Public reusable +labkit image facade'); h.assertTopLevelMFiles(fullfile(root, '+labkit', '+thermal'), ... diff --git a/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m b/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m index 265bc8f2..26fe5ee1 100644 --- a/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m +++ b/tests/cases/unit/apps/image_measurement/FocusStackFusionTest.m @@ -114,10 +114,10 @@ function checkRegistrationImprovesSyntheticDrift() [aligned, lines] = focus_stack.analysisRun.alignImages({moving, reference}); - beforeErr = mean((labkit.image.toDouble(moving(:)) - ... - labkit.image.toDouble(reference(:))) .^ 2); - afterErr = mean((labkit.image.toDouble(aligned{1}(:)) - ... - labkit.image.toDouble(reference(:))) .^ 2); + beforeErr = mean((labkit.image.im2double(moving(:)) - ... + labkit.image.im2double(reference(:))) .^ 2); + afterErr = mean((labkit.image.im2double(aligned{1}(:)) - ... + labkit.image.im2double(reference(:))) .^ 2); assert(afterErr < beforeErr, ... 'Automatic registration should reduce synthetic alignment error.'); assert(contains(strjoin(string(lines), " "), "reference image: 2"), ... diff --git a/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m b/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m index a400a56a..bcacbaa1 100644 --- a/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m +++ b/tests/cases/unit/apps/image_measurement/ImageEnhanceTest.m @@ -267,7 +267,7 @@ function checkManifestAndExportContract() 'Batch export should avoid overwriting existing enhanced outputs.'); assert(isfile(payload.results(1).outputPath), ... 'Batch export should write enhanced image output.'); - written = labkit.image.toDouble(imread(payload.results(1).outputPath)); + written = labkit.image.im2double(imread(payload.results(1).outputPath)); assert(isequal(size(written), [10 12 3]), ... 'Batch export should process and write full-size enhanced images.'); assert(isfile(payload.manifestPath), ... @@ -303,8 +303,8 @@ function checkPerImageExportSteps() T = image_enhance.resultFiles.buildManifest(payload.results); assert(all(T.StepCount == [1; 1]), ... 'Per-image enhancement exports should report each image history length.'); - firstWritten = labkit.image.toDouble(imread(payload.results(1).outputPath)); - secondWritten = labkit.image.toDouble(imread(payload.results(2).outputPath)); + firstWritten = labkit.image.im2double(imread(payload.results(1).outputPath)); + secondWritten = labkit.image.im2double(imread(payload.results(2).outputPath)); assert(mean(firstWritten(:)) > mean(items(1).image(:)) && ... mean(secondWritten(:)) < mean(items(2).image(:)), ... 'Per-image enhancement exports should apply each image-specific history.'); @@ -372,7 +372,7 @@ function checkExportTaskBuildsStateDrivenInputs() end function gray = testLuma(imageIn) - gray = labkit.image.toLuma(imageIn); + gray = labkit.image.rgb2gray(imageIn); end function checkPreviewImageDownsamplesLargeInputs() diff --git a/tests/cases/unit/apps/image_measurement/ImageMatchTest.m b/tests/cases/unit/apps/image_measurement/ImageMatchTest.m index c72dd249..925cc320 100644 --- a/tests/cases/unit/apps/image_measurement/ImageMatchTest.m +++ b/tests/cases/unit/apps/image_measurement/ImageMatchTest.m @@ -185,7 +185,7 @@ function checkManifestAndExportContract() 'Batch export should avoid overwriting existing matched outputs.'); assert(isfile(payload.results(1).outputPath), ... 'Batch export should write matched image output.'); - written = labkit.image.toDouble(imread(payload.results(1).outputPath)); + written = labkit.image.im2double(imread(payload.results(1).outputPath)); assert(isequal(size(written), [10 12 3]), ... 'Batch export should process and write full-size matched images.'); assert(isfile(payload.manifestPath), ... @@ -243,7 +243,7 @@ function checkPreviewImageDownsamplesLargeInputs() end function gray = testLuma(imageIn) - gray = labkit.image.toLuma(imageIn); + gray = labkit.image.rgb2gray(imageIn); end function img = syntheticGradientImage() diff --git a/tests/cases/unit/labkit_framework/image/LabKitImageFacadeTest.m b/tests/cases/unit/labkit_framework/image/LabKitImageFacadeTest.m index 61c2bb64..817eb932 100644 --- a/tests/cases/unit/labkit_framework/image/LabKitImageFacadeTest.m +++ b/tests/cases/unit/labkit_framework/image/LabKitImageFacadeTest.m @@ -62,13 +62,14 @@ function imageFacadeProcessesAndResizesImages(testCase) image(:, :, 2) = repmat(linspace(0.1, 0.9, 20), 12, 1); image(:, :, 3) = 0.55; - normalized = labkit.image.toDouble(uint8([0 255])); - signedNormalized = labkit.image.toDouble(int16([-32768 0 32767])); - logicalNormalized = labkit.image.toDouble([false true]); - rgb = labkit.image.toRgbDouble(uint8(100 * ones(4, 5))); - redLuma = mean(labkit.image.toLuma(cat(3, ones(2), zeros(2), zeros(2))), "all"); - greenLuma = mean(labkit.image.toLuma(cat(3, zeros(2), ones(2), zeros(2))), "all"); - blueLuma = mean(labkit.image.toLuma(cat(3, zeros(2), zeros(2), ones(2))), "all"); + normalized = labkit.image.im2double(uint8([0 255])); + signedNormalized = labkit.image.im2double(int16([-32768 0 32767])); + logicalNormalized = labkit.image.im2double([false true]); + rgb = labkit.image.ensureRgb( ... + labkit.image.im2double(uint8(100 * ones(4, 5)))); + redLuma = mean(labkit.image.rgb2gray(cat(3, ones(2), zeros(2), zeros(2))), "all"); + greenLuma = mean(labkit.image.rgb2gray(cat(3, zeros(2), ones(2), zeros(2))), "all"); + blueLuma = mean(labkit.image.rgb2gray(cat(3, zeros(2), zeros(2), ones(2))), "all"); [preview, scale] = labkit.image.resizeToFit(image, "MaxHeight", 6); [budgetPreview, budgetInfo] = labkit.image.previewBudget(image, ... "MaxPixels", 40, ... @@ -88,6 +89,13 @@ function imageFacadeProcessesAndResizesImages(testCase) testCase.verifyEqual(signedNormalized, expectedSigned, "AbsTol", 1e-12); testCase.verifyEqual(logicalNormalized, [0 1]); testCase.verifyEqual(size(rgb), [4 5 3]); + testCase.verifyEqual(labkit.image.im2double(uint8([0 1]), "indexed"), [1 2]); + grayUint8 = labkit.image.rgb2gray(uint8(cat(3, ... + 255 * ones(2), zeros(2), zeros(2)))); + testCase.verifyClass(grayUint8, "uint8"); + testCase.verifyEqual(grayUint8, uint8(76 * ones(2))); + grayMap = labkit.image.rgb2gray([1 0 0; 0 1 0]); + testCase.verifyEqual(size(grayMap), [2 3]); testCase.verifyTrue(greenLuma > redLuma && redLuma > blueLuma); testCase.verifyEqual(size(preview), [6 10 3]); testCase.verifyEqual(scale, 0.5, "AbsTol", 1e-12); @@ -114,7 +122,7 @@ function imageFacadeWritesImageFiles(testCase) labkit.image.writeFile(0.5 .* ones(4, 5, 3), filepath); testCase.verifyTrue(isfile(filepath)); - written = labkit.image.toDouble(imread(filepath)); + written = labkit.image.im2double(imread(filepath)); testCase.verifyEqual(size(written), [4 5 3]); end From 125338c0f9aa050b1c2ab26aada821dffad18e38 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 14:55:32 -0500 Subject: [PATCH 06/15] refactor: govern scientific calculation constants --- .../+biosignal/private/detectEcgPeaksImpl.m | 5 +- +labkit/+biosignal/private/inferTableTime.m | 22 ++++-- +labkit/+biosignal/version.m | 2 +- +labkit/+dta/private/pulsesFromCurrent.m | 6 +- +labkit/+dta/version.m | 2 +- +labkit/+image/previewBudget.m | 10 ++- +labkit/+image/rgb2gray.m | 2 +- +labkit/+rhs/readWindow.m | 25 ++++-- +labkit/+rhs/version.m | 2 +- .../+interaction/private/addOrInsertAnchor.m | 23 ++++-- AGENTS.md | 12 +++ .../+userInterface/startCropRoi.m | 4 +- .../+chrono_overlay/+userInterface/plotVTIT.m | 4 +- .../chrono_overlay/+chrono_overlay/version.m | 2 +- .../cic/+cic/+analysisRun/computeCIC.m | 17 +++-- .../cic/+cic/+resultFiles/buildResultsTable.m | 12 +-- .../addPaperStyleITAnnotations.m | 15 ++-- .../addPaperStyleVTAnnotations.m | 10 ++- .../+cic/+userInterface/buildBatchTableData.m | 9 +-- .../+cic/+userInterface/buildCurrentSummary.m | 19 ++--- .../cic/+cic/+userInterface/displayUnit.m | 4 +- .../+cic/+userInterface/formatChargeDensity.m | 9 +-- apps/electrochem/cic/+cic/definitionActions.m | 5 +- .../csc/+csc/+analysisRun/chargeDensity.m | 14 ++++ .../csc/+csc/+analysisRun/computeCSC.m | 6 +- .../csc/+csc/+resultFiles/buildResultsTable.m | 6 +- .../+csc/+userInterface/formatChargeAndCSC.m | 2 +- apps/electrochem/csc/+csc/version.m | 2 +- .../addResistanceITAnnotations.m | 6 +- .../+userInterface/formatDurationUs.m | 4 +- .../+batch_crop/+appState/currentGeometry.m | 5 +- .../+cropGeometry/pixelsPerUnitForUnit.m | 14 +++- .../+cropGeometry/prepareCropCanvas.m | 5 +- .../+batch_crop/+cropGeometry/rotateCanvas.m | 5 +- .../+userInterface/previewRenderData.m | 11 ++- .../+analysisRun/computeCurvatureFit.m | 14 +++- .../+analysisRun/computeCurveLength.m | 4 +- .../+analysisRun/curvePointTolerance.m | 10 +++ .../+image_match/+analysisRun/applyMatch.m | 66 +++++++++++----- .../+analysisRun/detectEventTrains.m | 10 ++- .../+nerve_response_analysis/version.m | 2 +- .../+analysisRun/alignSegments.m | 5 +- .../+response_review_stats/version.m | 2 +- .../hygiene/MagicNumberGovernanceTest.m | 76 +++++++++++++++++++ .../RectangleInteractionGovernanceTest.m | 71 +++++++++++++++++ 45 files changed, 430 insertions(+), 131 deletions(-) create mode 100644 apps/electrochem/csc/+csc/+analysisRun/chargeDensity.m create mode 100644 apps/image_measurement/curvature/+curvature/+analysisRun/curvePointTolerance.m create mode 100644 tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m create mode 100644 tests/cases/contract/project/hygiene/RectangleInteractionGovernanceTest.m diff --git a/+labkit/+biosignal/private/detectEcgPeaksImpl.m b/+labkit/+biosignal/private/detectEcgPeaksImpl.m index 36ef4f3a..affb6289 100644 --- a/+labkit/+biosignal/private/detectEcgPeaksImpl.m +++ b/+labkit/+biosignal/private/detectEcgPeaksImpl.m @@ -339,7 +339,10 @@ function validateSignal(signal) return; end med = median(x, 'omitnan'); - sigma = 1.4826 * median(abs(x - med), 'omitnan'); + % Constant: 1.4826 makes median absolute deviation consistent with the + % standard deviation of a Gaussian distribution. + gaussianMadScale = 1.4826; + sigma = gaussianMadScale * median(abs(x - med), 'omitnan'); if ~isfinite(sigma) || sigma <= 0 sigma = std(x, 'omitnan'); end diff --git a/+labkit/+biosignal/private/inferTableTime.m b/+labkit/+biosignal/private/inferTableTime.m index 6a209646..b40559b1 100644 --- a/+labkit/+biosignal/private/inferTableTime.m +++ b/+labkit/+biosignal/private/inferTableTime.m @@ -189,6 +189,11 @@ end function scale = unitScale(explicitUnit, name) + % Constant: SI prefixes convert milliseconds, microseconds, and + % nanoseconds to the facade's canonical seconds unit. + millisecondsToSeconds = 1e-3; + microsecondsToSeconds = 1e-6; + nanosecondsToSeconds = 1e-9; if ~isempty(explicitUnit) unit = lower(string(explicitUnit)); elseif strcmpi(char(name), 'I0') @@ -201,11 +206,11 @@ case {"s", "sec", "secs", "second", "seconds"} scale = 1; case {"ms", "msec", "millisecond", "milliseconds"} - scale = 1e-3; + scale = millisecondsToSeconds; case {"us", "usec", "microsecond", "microseconds"} - scale = 1e-6; + scale = microsecondsToSeconds; case {"ns", "nsec", "nanosecond", "nanoseconds"} - scale = 1e-9; + scale = nanosecondsToSeconds; case {"samples", "sample", "index", "sample_index"} scale = 1; otherwise @@ -229,13 +234,18 @@ end function label = unitLabel(scale) + % Constant: SI prefixes identify the source time unit after conversion + % to the facade's canonical seconds unit. + millisecondsToSeconds = 1e-3; + microsecondsToSeconds = 1e-6; + nanosecondsToSeconds = 1e-9; if scale == 1 label = "seconds"; - elseif scale == 1e-3 + elseif scale == millisecondsToSeconds label = "milliseconds"; - elseif scale == 1e-6 + elseif scale == microsecondsToSeconds label = "microseconds"; - elseif scale == 1e-9 + elseif scale == nanosecondsToSeconds label = "nanoseconds"; else label = "custom"; diff --git a/+labkit/+biosignal/version.m b/+labkit/+biosignal/version.m index 167c2207..196c006a 100644 --- a/+labkit/+biosignal/version.m +++ b/+labkit/+biosignal/version.m @@ -12,6 +12,6 @@ % compatible contract ranges implemented by this code, contract status, % and a short maintainer note. - info = labkit.contract.versionInfo("biosignal", "1.0.0", ">=1.0 <2", ... + info = labkit.contract.versionInfo("biosignal", "1.0.1", ">=1.0 <2", ... "stable", "Biosignal recording, filtering, event, segmentation, and ECG facade contract."); end diff --git a/+labkit/+dta/private/pulsesFromCurrent.m b/+labkit/+dta/private/pulsesFromCurrent.m index 7330da1b..671cfad7 100644 --- a/+labkit/+dta/private/pulsesFromCurrent.m +++ b/+labkit/+dta/private/pulsesFromCurrent.m @@ -25,7 +25,11 @@ ok = false; Iabs = abs(Im); - thr = max(1e-12, 0.25 * max(Iabs)); + % Constant: the 1e-12 A floor avoids a zero detector threshold for + % numerically quiet traces; 25 percent selects dominant pulse segments. + currentFloorA = 1e-12; + dominantPulseFraction = 0.25; + thr = max(currentFloorA, dominantPulseFraction * max(Iabs)); cathMask = Im <= -thr; anodMask = Im >= thr; diff --git a/+labkit/+dta/version.m b/+labkit/+dta/version.m index 2eb6310a..68f04882 100644 --- a/+labkit/+dta/version.m +++ b/+labkit/+dta/version.m @@ -12,6 +12,6 @@ % compatible contract ranges implemented by this code, contract status, % and a short maintainer note. - info = labkit.contract.versionInfo("dta", "2.0.0", ">=2.0 <3", ... + info = labkit.contract.versionInfo("dta", "2.0.1", ">=2.0 <3", ... "stable", "DTA parser, file item, pulse, and curve facade contract."); end diff --git a/+labkit/+image/previewBudget.m b/+labkit/+image/previewBudget.m index f52b16d0..c420005e 100644 --- a/+labkit/+image/previewBudget.m +++ b/+labkit/+image/previewBudget.m @@ -22,7 +22,7 @@ opts = parseOptions(varargin); validateImageData(imageData); - maxPixels = positiveScalar(opts.MaxPixels, 1.2e6); + maxPixels = positiveScalar(opts.MaxPixels, defaultMaxPixels()); expansion = positiveScalar(opts.Expansion, 1); estimatedPixels = double(size(imageData, 1)) * ... @@ -39,7 +39,7 @@ end function opts = parseOptions(args) - opts = struct('MaxPixels', 1.2e6, 'Expansion', 1); + opts = struct('MaxPixels', defaultMaxPixels(), 'Expansion', 1); if isempty(args) return; end @@ -57,6 +57,12 @@ end end +function value = defaultMaxPixels() + % Constant: 1.2 megapixels balances interactive preview responsiveness + % with enough spatial detail for image measurement workflows. + value = 1.2e6; +end + function validateImageData(imageData) if isempty(imageData) || ~(isnumeric(imageData) || islogical(imageData)) || ndims(imageData) > 3 error('labkit:image:InvalidImageData', ... diff --git a/+labkit/+image/rgb2gray.m b/+labkit/+image/rgb2gray.m index e72c0587..3c7655ad 100644 --- a/+labkit/+image/rgb2gray.m +++ b/+labkit/+image/rgb2gray.m @@ -23,7 +23,7 @@ 'Input must be an M-by-N-by-3 RGB image or an M-by-3 colormap.'); end - % ITU-R BT.601 luma coefficients are the documented MATLAB rgb2gray + % Constant: ITU-R BT.601 luma coefficients are the documented MATLAB rgb2gray % transform; naming the source keeps these scientific constants auditable. rec601LumaWeights = [0.2989 0.5870 0.1140]; if isColorMap diff --git a/+labkit/+rhs/readWindow.m b/+labkit/+rhs/readWindow.m index 7bff2edb..5ce451f0 100644 --- a/+labkit/+rhs/readWindow.m +++ b/+labkit/+rhs/readWindow.m @@ -248,6 +248,14 @@ end function values = readBlockFamily(fid, info, spec, channelIdx) + % Constant: Intan RHS data-file conversion gains and unsigned offsets + % convert stored ADC codes to the physical units documented by the format. + amplifierMicrovoltsPerBit = 0.195; + amplifierCodeOffset = 32768; + dcMillivoltsPerBit = -0.01923; + dcCodeOffset = 512; + boardVoltsPerBit = 312.5e-6; + boardCodeOffset = 32768; nAmp = numel(info.channelFamilies.amplifier); nAdc = numel(info.channelFamilies.boardAdc); nDac = numel(info.channelFamilies.boardDac); @@ -259,12 +267,14 @@ if nAmp > 0 ampRaw = readUint16Matrix(fid, nAmp, spb); if spec.family == "amplifier" - values = 0.195 .* (ampRaw(channelIdx, :) - 32768); + values = amplifierMicrovoltsPerBit .* ... + (ampRaw(channelIdx, :) - amplifierCodeOffset); end if info.dcAmplifierSaved dcRaw = readUint16Matrix(fid, nAmp, spb); if spec.family == "dcAmplifier" - values = -0.01923 .* (dcRaw(channelIdx, :) - 512); + values = dcMillivoltsPerBit .* ... + (dcRaw(channelIdx, :) - dcCodeOffset); end end stimRaw = readUint16Matrix(fid, nAmp, spb); @@ -276,13 +286,15 @@ if nAdc > 0 adcRaw = readUint16Matrix(fid, nAdc, spb); if spec.family == "boardAdc" - values = 312.5e-6 .* (adcRaw(channelIdx, :) - 32768); + values = boardVoltsPerBit .* ... + (adcRaw(channelIdx, :) - boardCodeOffset); end end if nDac > 0 dacRaw = readUint16Matrix(fid, nDac, spb); if spec.family == "boardDac" - values = 312.5e-6 .* (dacRaw(channelIdx, :) - 32768); + values = boardVoltsPerBit .* ... + (dacRaw(channelIdx, :) - boardCodeOffset); end end if nDigIn > 0 @@ -316,7 +328,10 @@ polarity = raw >= 2^8; raw = raw - polarity .* 2^8; polarity = 1 - 2 .* polarity; - values = stepSize .* raw .* polarity ./ 1.0e-6; + % Constant: one microampere converts stimulation amplitude from amperes + % to the RHS facade's documented microampere output unit. + amperesPerMicroampere = 1.0e-6; + values = stepSize .* raw .* polarity ./ amperesPerMicroampere; end function values = decodeDigital(raw, channels, channelIdx) diff --git a/+labkit/+rhs/version.m b/+labkit/+rhs/version.m index 0db10e9c..d7d6b242 100644 --- a/+labkit/+rhs/version.m +++ b/+labkit/+rhs/version.m @@ -12,6 +12,6 @@ % compatible contract ranges implemented by this code, contract status, % and a short maintainer note. - info = labkit.contract.versionInfo("rhs", "1.0.0", ">=1.0 <2", ... + info = labkit.contract.versionInfo("rhs", "1.0.1", ">=1.0 <2", ... "stable", "RHS discovery, metadata, indexing, and waveform-window facade contract."); end diff --git a/+labkit/+ui/+interaction/private/addOrInsertAnchor.m b/+labkit/+ui/+interaction/private/addOrInsertAnchor.m index 079026da..67eb5b47 100644 --- a/+labkit/+ui/+interaction/private/addOrInsertAnchor.m +++ b/+labkit/+ui/+interaction/private/addOrInsertAnchor.m @@ -172,11 +172,13 @@ end function tf = segmentsIntersect(a, b, c, d) - tol = 1e-9; + % Constant: 1e-9 coordinate units absorbs floating-point orientation + % noise without changing visible anchor-curve intersections. + intersectionTolerance = 1e-9; if max(min(a(1), b(1)), min(c(1), d(1))) > ... - min(max(a(1), b(1)), max(c(1), d(1))) + tol || ... + min(max(a(1), b(1)), max(c(1), d(1))) + intersectionTolerance || ... max(min(a(2), b(2)), min(c(2), d(2))) > ... - min(max(a(2), b(2)), max(c(2), d(2))) + tol + min(max(a(2), b(2)), max(c(2), d(2))) + intersectionTolerance tf = false; return; end @@ -186,11 +188,16 @@ o3 = orient2d(c, d, a); o4 = orient2d(c, d, b); - tf = (oppositeSigns(o1, o2, tol) && oppositeSigns(o3, o4, tol)) || ... - (abs(o1) <= tol && pointOnSegment(c, a, b, tol)) || ... - (abs(o2) <= tol && pointOnSegment(d, a, b, tol)) || ... - (abs(o3) <= tol && pointOnSegment(a, c, d, tol)) || ... - (abs(o4) <= tol && pointOnSegment(b, c, d, tol)); + tf = (oppositeSigns(o1, o2, intersectionTolerance) && ... + oppositeSigns(o3, o4, intersectionTolerance)) || ... + (abs(o1) <= intersectionTolerance && ... + pointOnSegment(c, a, b, intersectionTolerance)) || ... + (abs(o2) <= intersectionTolerance && ... + pointOnSegment(d, a, b, intersectionTolerance)) || ... + (abs(o3) <= intersectionTolerance && ... + pointOnSegment(a, c, d, intersectionTolerance)) || ... + (abs(o4) <= intersectionTolerance && ... + pointOnSegment(b, c, d, intersectionTolerance)); end function value = orient2d(a, b, c) diff --git a/AGENTS.md b/AGENTS.md index f089018e..08840e67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,18 @@ debt for the touched area. - Folder/path scalars must not be reshaped with `(:)`, because char paths become one element per character. Use `string(folder)` for one selected folder/path and reserve `paths(:)` for values already known to be string arrays or cell arrays of paths. - UI numeric control values must be sanitized to finite scalars before they are assigned into app state, step structs, or task structs. Do not write `step.amount = double(amount)` or similar directly from callback values; use a small scalar-normalization helper with a fallback. - User-visible UI text that also acts as a state enum, branch key, dropdown value, action label, or test contract must have one app-local source of truth, such as a `+userInterface/*Labels.m`, `+userInterface/*Choices.m`, or workflow-owned `*Items.m` helper. Do not repeat those literals in runners, view helpers, or tests; callers and tests should reference the helper. One-off section labels that are only displayed in `+userInterface/buildWorkbenchLayout.m` may stay inline. +- Interactive image rectangles must use `labkit.ui.interaction.rectangleEditor` + or an interaction-runtime drag session. Direct `rectangle(...)` calls in apps + are only for non-pickable display/mirror overlays; they must disable hit + testing so users are not presented with a rectangle that looks interactive + but cannot be dragged. +- Nontrivial numeric constants in production calculations must have a semantic + variable name and a nearby `% Constant:` comment that states the standard, + physical conversion, numerical-stability purpose, or empirical policy behind + the value. Structural indices and dimensions, explicit UI geometry/colors, + version metadata, and synthetic test/debug fixture values are not calculation + magic numbers. Keep one owner for standardized coefficient sets and unit + conversions instead of copying their literals across apps. Default principle: diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m index f93d1954..d259b8c7 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/startCropRoi.m @@ -22,6 +22,8 @@ 'Position', rect, ... 'EdgeColor', [1 0.85 0], ... 'LineWidth', 1.5, ... - 'LineStyle', '--'); + 'LineStyle', '--', ... + 'HitTest', 'off', ... + 'PickableParts', 'none'); cropUi.listeners = {}; end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/plotVTIT.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/plotVTIT.m index 6e9cb0fb..a16a1591 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/plotVTIT.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+userInterface/plotVTIT.m @@ -100,9 +100,11 @@ function plotVTIT(axV, axI, items, opts) end function x = chooseX(item, mode) + % Constant: 1000 converts seconds to milliseconds for the selected axis. + millisecondsPerSecond = 1e3; switch mode case 'Time (ms)' - x = 1e3 * chronoAlignedTime(item); + x = millisecondsPerSecond * chronoAlignedTime(item); case 'Sample #' x = samplePoint(item); otherwise diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/version.m b/apps/electrochem/chrono_overlay/+chrono_overlay/version.m index b3d2d375..6b39fada 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/version.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/version.m @@ -8,6 +8,6 @@ "name", "labkit_ChronoOverlay_app", ... "displayName", "Chrono Overlay", ... "family", "Electrochem", ... - "version", "1.3.5", ... + "version", "1.3.6", ... "updated", "2026-07-06"); end diff --git a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m index eaafd0c9..13a6b814 100644 --- a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m +++ b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m @@ -84,9 +84,11 @@ end if isfinite(A.area_cm2) && A.area_cm2 > 0 - A.CICc_mCcm2 = 1e3 * A.Qc_C / A.area_cm2; - A.CICa_mCcm2 = 1e3 * A.Qa_C / A.area_cm2; - A.CICt_mCcm2 = 1e3 * A.Qt_C / A.area_cm2; + % Constant: 1000 converts coulombs to millicoulombs for CIC density. + millicoulombsPerCoulomb = 1e3; + A.CICc_mCcm2 = millicoulombsPerCoulomb * A.Qc_C / A.area_cm2; + A.CICa_mCcm2 = millicoulombsPerCoulomb * A.Qa_C / A.area_cm2; + A.CICt_mCcm2 = millicoulombsPerCoulomb * A.Qt_C / A.area_cm2; else A.CICc_mCcm2 = NaN; A.CICa_mCcm2 = NaN; @@ -102,7 +104,10 @@ function opts = fillCICOptions(opts) if ~isfield(opts, 'delay_s') - opts.delay_s = 10e-6; + % Constant: 10 microseconds is the app's default post-pulse sampling + % delay for estimating maximum polarization voltage. + defaultDelaySeconds = 10e-6; + opts.delay_s = defaultDelaySeconds; end if ~isfield(opts, 'cathLimit') opts.cathLimit = -0.6; @@ -183,8 +188,10 @@ V.ok = V.t_emc >= min(t) && V.t_emc <= max(t) && ... V.t_ema >= min(t) && V.t_ema <= max(t); if ~V.ok + % Constant: one million converts seconds to microseconds for UI text. + microsecondsPerSecond = 1e6; V.message = sprintf(['Sample delay %.6g us places Emc or Ema outside ' ... - 'the recorded time range.'], 1e6 * delay_s); + 'the recorded time range.'], microsecondsPerSecond * delay_s); return; end V.message = 'OK'; diff --git a/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m b/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m index a6be2e31..8fc79c37 100644 --- a/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m +++ b/apps/electrochem/cic/+cic/+resultFiles/buildResultsTable.m @@ -46,7 +46,9 @@ safe(i) = A.safe; detection{i} = A.detectMode; area_cm2(i) = A.area_cm2; - delay_us(i) = 1e6 * A.delay_s; + % Constant: one million converts seconds to microseconds for export. + microsecondsPerSecond = 1e6; + delay_us(i) = microsecondsPerSecond * A.delay_s; end T = table(file, amp_A, Emc_V, Ema_V, Qc_C, Qa_C, Qt_C, CICc, CICa, CICt, ... @@ -62,13 +64,7 @@ end function [scale, unitLabel] = displayScale(unitLabel) - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end + [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); end function name = itemName(item) diff --git a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleITAnnotations.m b/apps/electrochem/cic/+cic/+userInterface/addPaperStyleITAnnotations.m index 813094cc..ff55e4cb 100644 --- a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleITAnnotations.m +++ b/apps/electrochem/cic/+cic/+userInterface/addPaperStyleITAnnotations.m @@ -2,13 +2,18 @@ % annotation helper. Side effects are limited to annotating the supplied axes. function addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) + % Constant: SI display conversions express current in mA, pulse duration + % in ms, and interpulse duration in us while analysis remains in A/s. + milliampsPerAmp = 1e3; + millisecondsPerSecond = 1e3; + microsecondsPerSecond = 1e6; plot(ax, emcX, interp1Safe(chooseX(A,xChoice), A.Im, emcX), 'o', 'MarkerFaceColor',[0.1 0.7 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); plot(ax, emaX, interp1Safe(chooseX(A,xChoice), A.Im, emaX), 'o', 'MarkerFaceColor',[0.95 0.8 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); plot(ax, [cathStartX cathEndX], [A.Ic_est_A A.Ic_est_A], '--', 'Color',[0.1 0.45 0.8], 'LineWidth',1.3); plot(ax, [anodStartX anodEndX], [A.Ia_est_A A.Ia_est_A], '--', 'Color',[0.85 0.45 0.1], 'LineWidth',1.3); - text(ax, cathEndX, A.Ic_est_A, sprintf(' ic = %.3f mA', 1e3*A.Ic_est_A), 'Color',[0.1 0.35 0.75], 'VerticalAlignment','bottom'); - text(ax, anodEndX, A.Ia_est_A, sprintf(' ia = %.3f mA', 1e3*A.Ia_est_A), 'Color',[0.7 0.32 0.05], 'VerticalAlignment','top'); + text(ax, cathEndX, A.Ic_est_A, sprintf(' ic = %.3f mA', milliampsPerAmp*A.Ic_est_A), 'Color',[0.1 0.35 0.75], 'VerticalAlignment','bottom'); + text(ax, anodEndX, A.Ia_est_A, sprintf(' ia = %.3f mA', milliampsPerAmp*A.Ia_est_A), 'Color',[0.7 0.32 0.05], 'VerticalAlignment','top'); labelPulseCharge(ax, cathStartX, cathEndX, A.Qc_C, 'Qc'); labelPulseCharge(ax, anodStartX, anodEndX, A.Qa_C, 'Qa'); @@ -17,10 +22,10 @@ function addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodSt dy = yl(2) - yl(1); yTop = yl(2) - 0.08*dy; yMid = yl(2) - 0.16*dy; - drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); - drawDurationBracket(ax, anodStartX, anodEndX, yTop, sprintf('ta = %.3f ms', 1e3*A.ta_s)); + drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', millisecondsPerSecond*A.tc_s)); + drawDurationBracket(ax, anodStartX, anodEndX, yTop, sprintf('ta = %.3f ms', millisecondsPerSecond*A.ta_s)); if A.tip_s > 0 && anodStartX > cathEndX - drawDurationBracket(ax, cathEndX, anodStartX, yMid, sprintf('tip = %.1f us', 1e6*A.tip_s)); + drawDurationBracket(ax, cathEndX, anodStartX, yMid, sprintf('tip = %.1f us', microsecondsPerSecond*A.tip_s)); end end diff --git a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleVTAnnotations.m b/apps/electrochem/cic/+cic/+userInterface/addPaperStyleVTAnnotations.m index 6ca2fa8e..e88732de 100644 --- a/apps/electrochem/cic/+cic/+userInterface/addPaperStyleVTAnnotations.m +++ b/apps/electrochem/cic/+cic/+userInterface/addPaperStyleVTAnnotations.m @@ -2,6 +2,10 @@ % annotation helper. Side effects are limited to annotating the supplied axes. function addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) + % Constant: SI display conversions express pulse duration in ms and + % interpulse duration in us while analysis remains in seconds. + millisecondsPerSecond = 1e3; + microsecondsPerSecond = 1e6; yl = ylim(ax); dy = yl(2) - yl(1); yTop = yl(2) - 0.07*dy; @@ -54,10 +58,10 @@ function addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodSt drawExtremaLabel(ax, emaX, A.Ema, sprintf('Ema = %.4f V', A.Ema), ... [0.6 0.4 0], 'right', -0.04); - drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); - drawDurationBracket(ax, anodStartX, anodEndX, yTop - 0.06*dy, sprintf('ta = %.3f ms', 1e3*A.ta_s)); + drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', millisecondsPerSecond*A.tc_s)); + drawDurationBracket(ax, anodStartX, anodEndX, yTop - 0.06*dy, sprintf('ta = %.3f ms', millisecondsPerSecond*A.ta_s)); if A.tip_s > 0 && anodStartX > cathEndX - drawDurationBracket(ax, cathEndX, anodStartX, yLow, sprintf('tip = %.1f us', 1e6*A.tip_s)); + drawDurationBracket(ax, cathEndX, anodStartX, yLow, sprintf('tip = %.1f us', microsecondsPerSecond*A.tip_s)); end yline(ax, yMid, ':', 'Color',[0.8 0.8 0.8], 'HandleVisibility','off'); end diff --git a/apps/electrochem/cic/+cic/+userInterface/buildBatchTableData.m b/apps/electrochem/cic/+cic/+userInterface/buildBatchTableData.m index 002a4281..b777fcec 100644 --- a/apps/electrochem/cic/+cic/+userInterface/buildBatchTableData.m +++ b/apps/electrochem/cic/+cic/+userInterface/buildBatchTableData.m @@ -55,14 +55,7 @@ end function [scale, unitLabel] = displayScale(unitLabel) - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - unitLabel = 'uC/cm^2'; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end + [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); end function txt = ternary(cond, a, b) diff --git a/apps/electrochem/cic/+cic/+userInterface/buildCurrentSummary.m b/apps/electrochem/cic/+cic/+userInterface/buildCurrentSummary.m index cb2d32d8..eb081ee6 100644 --- a/apps/electrochem/cic/+cic/+userInterface/buildCurrentSummary.m +++ b/apps/electrochem/cic/+cic/+userInterface/buildCurrentSummary.m @@ -36,10 +36,14 @@ end summary.detect = sprintf('%s | %s', A.detectMode, A.detectMsg); - summary.delay = sprintf('%.3f us', 1e6 * A.delay_s); + % Constant: one million converts seconds to microseconds for display. + microsecondsPerSecond = 1e6; + summary.delay = sprintf('%.3f us', microsecondsPerSecond * A.delay_s); summary.area = formatMaybeNumText(A.area_cm2, '%.8g cm^2'); - summary.emc = sprintf('%.6f V @ %.6fus', A.Emc, 1e6 * A.t_emc); - summary.ema = sprintf('%.6f V @ %.6fus', A.Ema, 1e6 * A.t_ema); + summary.emc = sprintf('%.6f V @ %.6fus', A.Emc, ... + microsecondsPerSecond * A.t_emc); + summary.ema = sprintf('%.6f V @ %.6fus', A.Ema, ... + microsecondsPerSecond * A.t_ema); summary.qc = formatChargeDensityText(A.Qc_C, A.CICc_mCcm2, unitLabel); summary.qa = formatChargeDensityText(A.Qa_C, A.CICa_mCcm2, unitLabel); summary.qt = formatChargeDensityText(A.Qt_C, A.CICt_mCcm2, unitLabel); @@ -164,12 +168,5 @@ end function [scale, unitLabel] = displayScale(unitLabel) - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - unitLabel = 'uC/cm^2'; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end + [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); end diff --git a/apps/electrochem/cic/+cic/+userInterface/displayUnit.m b/apps/electrochem/cic/+cic/+userInterface/displayUnit.m index 1e1d16cc..bd4cfc73 100644 --- a/apps/electrochem/cic/+cic/+userInterface/displayUnit.m +++ b/apps/electrochem/cic/+cic/+userInterface/displayUnit.m @@ -11,7 +11,9 @@ switch unitLabel case 'uC/cm^2' - scale = 1e3; + % Constant: 1000 converts mC/cm^2 to uC/cm^2 for display. + microcoulombsPerMillicoulomb = 1e3; + scale = microcoulombsPerMillicoulomb; unitLabel = 'uC/cm^2'; otherwise scale = 1; diff --git a/apps/electrochem/cic/+cic/+userInterface/formatChargeDensity.m b/apps/electrochem/cic/+cic/+userInterface/formatChargeDensity.m index e8920cf0..752b1226 100644 --- a/apps/electrochem/cic/+cic/+userInterface/formatChargeDensity.m +++ b/apps/electrochem/cic/+cic/+userInterface/formatChargeDensity.m @@ -12,12 +12,5 @@ end function [scale, unitLabel] = displayScale(unitLabel) - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - unitLabel = 'uC/cm^2'; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end + [scale, unitLabel] = cic.userInterface.displayUnit(unitLabel); end diff --git a/apps/electrochem/cic/+cic/definitionActions.m b/apps/electrochem/cic/+cic/definitionActions.m index 45a9d22f..52053264 100644 --- a/apps/electrochem/cic/+cic/definitionActions.m +++ b/apps/electrochem/cic/+cic/definitionActions.m @@ -131,7 +131,10 @@ function opts = analysisOptions(ui) opts = struct(); - opts.delay_s = finiteScalar(ui.controls.delayUs.valueHandle.Value, 10) * 1e-6; + % Constant: one microsecond is 1e-6 seconds; UI delay is entered in us. + secondsPerMicrosecond = 1e-6; + opts.delay_s = finiteScalar(ui.controls.delayUs.valueHandle.Value, 10) * ... + secondsPerMicrosecond; opts.cathLimit = finiteScalar(ui.controls.cathLimit.valueHandle.Value, -0.6); opts.anodLimit = finiteScalar(ui.controls.anodLimit.valueHandle.Value, 0.8); opts.areaOverride = ui.controls.area.valueHandle.Value; diff --git a/apps/electrochem/csc/+csc/+analysisRun/chargeDensity.m b/apps/electrochem/csc/+csc/+analysisRun/chargeDensity.m new file mode 100644 index 00000000..c4d29bca --- /dev/null +++ b/apps/electrochem/csc/+csc/+analysisRun/chargeDensity.m @@ -0,0 +1,14 @@ +% App-owned CSC unit helper. Expected callers are CSC analysis, display, and +% export paths. Inputs are charge in C and electrode area in cm^2. Output is +% charge density in mC/cm^2 or NaN for invalid area. No side effects. +function value = chargeDensity(chargeC, areaCm2) +%CHARGEDENSITY Convert charge and electrode area to CSC density. + + if isfinite(areaCm2) && areaCm2 > 0 + % Constant: 1000 converts coulombs to millicoulombs for CSC density. + millicoulombsPerCoulomb = 1e3; + value = millicoulombsPerCoulomb * chargeC / areaCm2; + else + value = NaN; + end +end diff --git a/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m b/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m index c3c8b27f..649d7544 100644 --- a/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m +++ b/apps/electrochem/csc/+csc/+analysisRun/computeCSC.m @@ -93,9 +93,9 @@ end if isfinite(A.area_cm2) && A.area_cm2 > 0 - A.Qct_mC_cm2 = 1e3 * A.Qct / A.area_cm2; - A.Qcv_mC_cm2 = 1e3 * A.Qcv / A.area_cm2; - A.diff_mC_cm2 = 1e3 * A.diff_C / A.area_cm2; + A.Qct_mC_cm2 = csc.analysisRun.chargeDensity(A.Qct, A.area_cm2); + A.Qcv_mC_cm2 = csc.analysisRun.chargeDensity(A.Qcv, A.area_cm2); + A.diff_mC_cm2 = csc.analysisRun.chargeDensity(A.diff_C, A.area_cm2); else A.Qct_mC_cm2 = NaN; A.Qcv_mC_cm2 = NaN; diff --git a/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m b/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m index 9b2887cf..4222e73b 100644 --- a/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m +++ b/apps/electrochem/csc/+csc/+resultFiles/buildResultsTable.m @@ -182,11 +182,7 @@ end function value = normalizeCharge(charge_C, area_cm2) - if isfinite(area_cm2) && area_cm2 > 0 - value = 1e3 * charge_C / area_cm2; - else - value = NaN; - end + value = csc.analysisRun.chargeDensity(charge_C, area_cm2); end function value = relativePct(diff_C, qct_C, qcv_C) diff --git a/apps/electrochem/csc/+csc/+userInterface/formatChargeAndCSC.m b/apps/electrochem/csc/+csc/+userInterface/formatChargeAndCSC.m index 9ced7f57..f0fe6ec8 100644 --- a/apps/electrochem/csc/+csc/+userInterface/formatChargeAndCSC.m +++ b/apps/electrochem/csc/+csc/+userInterface/formatChargeAndCSC.m @@ -12,7 +12,7 @@ if isnan(area_cm2) || area_cm2 <= 0 text = sprintf('%.12e C', charge_C); else - csc_mC_cm2 = 1e3 * charge_C / area_cm2; + csc_mC_cm2 = csc.analysisRun.chargeDensity(charge_C, area_cm2); text = sprintf('%.12e C | %.12e mC/cm^2', charge_C, csc_mC_cm2); end end diff --git a/apps/electrochem/csc/+csc/version.m b/apps/electrochem/csc/+csc/version.m index bbc4148b..0d8ae448 100644 --- a/apps/electrochem/csc/+csc/version.m +++ b/apps/electrochem/csc/+csc/version.m @@ -8,6 +8,6 @@ "name", "labkit_CSC_app", ... "displayName", "CSC", ... "family", "Electrochem", ... - "version", "1.3.9", ... + "version", "1.3.10", ... "updated", "2026-07-06"); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceITAnnotations.m b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceITAnnotations.m index 288475dd..12e79ea3 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceITAnnotations.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/addResistanceITAnnotations.m @@ -3,6 +3,8 @@ function addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... cathStartX, cathEndX, anodStartX, anodEndX) + % Constant: 1000 converts amperes to milliamperes for annotation text. + milliampsPerAmp = 1e3; drawLevelSegment(ax, cSteadyStartX, cSteadyEndX, A.Ic_est_A, [0.10 0.35 0.80], '--'); drawLevelSegment(ax, aSteadyStartX, aSteadyEndX, A.Ia_est_A, [0.80 0.35 0.10], '--'); @@ -11,9 +13,9 @@ function addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadySt plot(ax, aSteadyEndX, A.Ia_est_A, 'o', 'MarkerFaceColor',[0.80 0.35 0.10], ... 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - text(ax, cSteadyEndX, A.Ic_est_A, sprintf(' Cath current = %.3f mA', 1e3 * A.Ic_est_A), ... + text(ax, cSteadyEndX, A.Ic_est_A, sprintf(' Cath current = %.3f mA', milliampsPerAmp * A.Ic_est_A), ... 'Color',[0.10 0.35 0.80], 'VerticalAlignment','bottom', 'Interpreter','tex'); - text(ax, aSteadyEndX, A.Ia_est_A, sprintf(' Anod current = %.3f mA', 1e3 * A.Ia_est_A), ... + text(ax, aSteadyEndX, A.Ia_est_A, sprintf(' Anod current = %.3f mA', milliampsPerAmp * A.Ia_est_A), ... 'Color',[0.80 0.35 0.10], 'VerticalAlignment','top', 'Interpreter','tex'); yl = ylim(ax); diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/formatDurationUs.m b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/formatDurationUs.m index 82408f1e..d2f93c08 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/formatDurationUs.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+userInterface/formatDurationUs.m @@ -5,6 +5,8 @@ if ~isscalar(dt_s) || ~isfinite(dt_s) || dt_s < 0 txt = '-'; else - txt = sprintf('%.3f us', 1e6 * dt_s); + % Constant: one million converts seconds to microseconds for display. + microsecondsPerSecond = 1e6; + txt = sprintf('%.3f us', microsecondsPerSecond * dt_s); end end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+appState/currentGeometry.m b/apps/image_measurement/batch_crop/+batch_crop/+appState/currentGeometry.m index 13728e6c..d19f52f6 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+appState/currentGeometry.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+appState/currentGeometry.m @@ -7,9 +7,12 @@ geometry = cache.geometry; return; end + % Constant: 1.2 megapixels balances draggable crop responsiveness with + % enough preview detail to place the crop accurately. + previewCanvasPixels = 1.2e6; geometry = batch_crop.cropGeometry.prepareCropCanvas(item.image, struct( ... 'angleDeg', item.angleDeg, ... 'paddingPercent', paddingPercent, ... - 'maxCanvasPixels', 1.2e6)); + 'maxCanvasPixels', previewCanvasPixels)); cache = struct('valid', true, 'key', key, 'geometry', geometry); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/pixelsPerUnitForUnit.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/pixelsPerUnitForUnit.m index 6067a629..280859b0 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/pixelsPerUnitForUnit.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/pixelsPerUnitForUnit.m @@ -18,17 +18,23 @@ end function value = metersPerUnit(unitName) + % Constant: SI prefix factors convert supported physical length units + % to meters before calibration densities are compared. + centimetersToMeters = 1e-2; + millimetersToMeters = 1e-3; + micrometersToMeters = 1e-6; + nanometersToMeters = 1e-9; switch string(unitName) case "m" value = 1; case "cm" - value = 1e-2; + value = centimetersToMeters; case "mm" - value = 1e-3; + value = millimetersToMeters; case "um" - value = 1e-6; + value = micrometersToMeters; case "nm" - value = 1e-9; + value = nanometersToMeters; otherwise error('labkit_BatchImageCrop_app:InvalidScaleUnit', ... 'Unsupported physical scale unit: %s.', char(string(unitName))); diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/prepareCropCanvas.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/prepareCropCanvas.m index 26093d86..439e0627 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/prepareCropCanvas.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/prepareCropCanvas.m @@ -73,7 +73,10 @@ end function tf = isIdentityRotation(angleDeg) - tf = abs(mod(double(angleDeg), 360)) < 1e-12; + % Constant: this degree tolerance absorbs floating-point roundoff when + % users enter rotations equivalent to a full turn. + identityAngleToleranceDeg = 1e-12; + tf = abs(mod(double(angleDeg), 360)) < identityAngleToleranceDeg; end function value = optionValue(opts, name, defaultValue) diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m index d8aa6b78..909c877f 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotateCanvas.m @@ -11,7 +11,10 @@ fillValue = 0; end - if abs(mod(double(angleDeg), 360)) < 1e-12 + % Constant: this degree tolerance absorbs floating-point roundoff when + % users enter rotations equivalent to a full turn. + identityAngleToleranceDeg = 1e-12; + if abs(mod(double(angleDeg), 360)) < identityAngleToleranceDeg canvas = imageData; mask = true(size(imageData, 1), size(imageData, 2)); info = struct( ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewRenderData.m b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewRenderData.m index dde8cbb8..c6cd5319 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewRenderData.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+userInterface/previewRenderData.m @@ -9,9 +9,10 @@ opts = struct(); end - maxPreviewPixels = double(optionValue(opts, 'MaxPreviewPixels', 1.2e6)); + maxPreviewPixels = double(optionValue(opts, 'MaxPreviewPixels', ... + defaultPreviewPixels())); if ~isfinite(maxPreviewPixels) || maxPreviewPixels < 1 - maxPreviewPixels = 1.2e6; + maxPreviewPixels = defaultPreviewPixels(); end [canvas, info] = labkit.image.previewBudget(geometry.canvas, ... @@ -24,6 +25,12 @@ 'scaleFactor', info.scaleFactor); end +function value = defaultPreviewPixels() + % Constant: 1.2 megapixels balances draggable preview responsiveness + % with sufficient crop-placement detail. + value = 1.2e6; +end + function value = optionValue(opts, name, defaultValue) value = defaultValue; if isstruct(opts) && isfield(opts, name) diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m b/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m index 6cfa6c10..9c5d7871 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurvatureFit.m @@ -18,7 +18,9 @@ fit = curvature.appState.emptyFitResult(); xPix = xPix(:); yPix = yPix(:); - [xPix, yPix] = curvature.analysisRun.removeDuplicateNeighbors(xPix, yPix, 1e-9); + pointTolerancePx = curvature.analysisRun.curvePointTolerance(); + [xPix, yPix] = curvature.analysisRun.removeDuplicateNeighbors( ... + xPix, yPix, pointTolerancePx); if numel(xPix) < 3 error('labkit_CurvatureMeasurement_app:NotEnoughPoints', ... @@ -46,7 +48,8 @@ fitPathX = fitPathX(:); fitPathY = fitPathY(:); if numel(fitPathX) == numel(fitPathY) - [fitPathX, fitPathY] = curvature.analysisRun.removeDuplicateNeighbors(fitPathX, fitPathY, 1e-9); + [fitPathX, fitPathY] = curvature.analysisRun.removeDuplicateNeighbors( ... + fitPathX, fitPathY, pointTolerancePx); if numel(fitPathX) >= 3 fitSourceX = fitPathX; fitSourceY = fitPathY; @@ -147,7 +150,12 @@ residual = @(p) sqrt((x - p(1)).^2 + (y - p(2)).^2) - abs(p(3)); f = @(p) sum(residual(p).^2); - opts = optimset('Display', 'off', 'MaxFunEvals', 2e4, 'MaxIter', 2e4); + % Constant: 20,000 evaluations/iterations retain the legacy convergence + % budget for difficult hand-traced circle fits. + optimizerIterationLimit = 2e4; + opts = optimset('Display', 'off', ... + 'MaxFunEvals', optimizerIterationLimit, ... + 'MaxIter', optimizerIterationLimit); p = fminsearch(f, p0, opts); xc = p(1); diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurveLength.m b/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurveLength.m index 2f867575..d1d79427 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurveLength.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/computeCurveLength.m @@ -18,7 +18,9 @@ lengthResult = curvature.appState.emptyLengthResult(); xPix = xPix(:); yPix = yPix(:); - [xPix, yPix] = curvature.analysisRun.removeDuplicateNeighbors(xPix, yPix, 1e-9); + pointTolerancePx = curvature.analysisRun.curvePointTolerance(); + [xPix, yPix] = curvature.analysisRun.removeDuplicateNeighbors( ... + xPix, yPix, pointTolerancePx); if numel(xPix) < 2 error('labkit_CurvatureMeasurement_app:NotEnoughLengthPoints', ... diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/curvePointTolerance.m b/apps/image_measurement/curvature/+curvature/+analysisRun/curvePointTolerance.m new file mode 100644 index 00000000..a0b93626 --- /dev/null +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/curvePointTolerance.m @@ -0,0 +1,10 @@ +% App-owned numeric policy helper. Expected callers are curvature fitting and +% curve-length analysis. Output is the pixel-distance tolerance used to merge +% adjacent duplicate points. No side effects. +function tolerancePx = curvePointTolerance() +%CURVEPOINTTOLERANCE Return the shared duplicate-point tolerance in pixels. + + % Constant: 1e-9 pixels removes only floating-point duplicate coordinates + % while preserving every physically distinct traced point. + tolerancePx = 1e-9; +end diff --git a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m index b920907d..21c98807 100644 --- a/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m +++ b/apps/image_measurement/image_match/+image_match/+analysisRun/applyMatch.m @@ -232,8 +232,11 @@ referencePixels = reshape(referenceLab, [], sourceSize(3)); sourceMean = mean(sourcePixels, 1); referenceMean = mean(referencePixels, 1); - sourceCov = cov(sourcePixels) + 1e-6 .* eye(sourceSize(3)); - referenceCov = cov(referencePixels) + 1e-6 .* eye(sourceSize(3)); + % Constant: this small diagonal term regularizes singular covariance + % matrices from flat or nearly single-color images. + covarianceRegularization = 1e-6; + sourceCov = cov(sourcePixels) + covarianceRegularization .* eye(sourceSize(3)); + referenceCov = cov(referencePixels) + covarianceRegularization .* eye(sourceSize(3)); transform = real(sqrtm(referenceCov)) / real(sqrtm(sourceCov)); matchedPixels = (sourcePixels - sourceMean) * transform.' + referenceMean; matched = reshape(matchedPixels, sourceSize); @@ -286,38 +289,35 @@ end function xyzImage = rgbToXyz(rgbImage) + constants = cieColorConstants(); rgbImage = min(max(double(rgbImage), 0), 1); linearRgb = rgbImage; - lowMask = linearRgb <= 0.04045; - linearRgb(lowMask) = linearRgb(lowMask) ./ 12.92; - linearRgb(~lowMask) = ((linearRgb(~lowMask) + 0.055) ./ 1.055) .^ 2.4; + lowMask = linearRgb <= constants.srgbDecodeThreshold; + linearRgb(lowMask) = linearRgb(lowMask) ./ constants.srgbLinearScale; + linearRgb(~lowMask) = ((linearRgb(~lowMask) + constants.srgbOffset) ./ ... + constants.srgbSlope) .^ constants.srgbGamma; - transform = [ ... - 0.4124564 0.3575761 0.1804375; ... - 0.2126729 0.7151522 0.0721750; ... - 0.0193339 0.1191920 0.9503041]; - pixels = reshape(linearRgb, [], 3) * transform.'; + pixels = reshape(linearRgb, [], 3) * constants.rgbToXyz.'; xyzImage = reshape(pixels, size(linearRgb)); end function rgbImage = xyzToRgb(xyzImage) - transform = [ ... - 3.2404542 -1.5371385 -0.4985314; ... - -0.9692660 1.8760108 0.0415560; ... - 0.0556434 -0.2040259 1.0572252]; - linearPixels = reshape(double(xyzImage), [], 3) * transform.'; + constants = cieColorConstants(); + linearPixels = reshape(double(xyzImage), [], 3) * constants.xyzToRgb.'; linearRgb = reshape(linearPixels, size(xyzImage)); linearRgb = min(max(linearRgb, 0), 1); rgbImage = linearRgb; - lowMask = rgbImage <= 0.0031308; - rgbImage(lowMask) = 12.92 .* rgbImage(lowMask); - rgbImage(~lowMask) = 1.055 .* (rgbImage(~lowMask) .^ (1 / 2.4)) - 0.055; + lowMask = rgbImage <= constants.srgbEncodeThreshold; + rgbImage(lowMask) = constants.srgbLinearScale .* rgbImage(lowMask); + rgbImage(~lowMask) = constants.srgbSlope .* ... + (rgbImage(~lowMask) .^ (1 / constants.srgbGamma)) - constants.srgbOffset; rgbImage = min(max(rgbImage, 0), 1); end function labImage = xyzToLab(xyzImage) - white = reshape([0.95047 1.00000 1.08883], 1, 1, 3); + constants = cieColorConstants(); + white = reshape(constants.d65WhitePoint, 1, 1, 3); scaled = double(xyzImage) ./ white; f = labPivotForward(scaled); @@ -328,7 +328,8 @@ end function xyzImage = labToXyz(labImage) - white = reshape([0.95047 1.00000 1.08883], 1, 1, 3); + constants = cieColorConstants(); + white = reshape(constants.d65WhitePoint, 1, 1, 3); fy = (double(labImage(:, :, 1)) + 16) ./ 116; fx = fy + double(labImage(:, :, 2)) ./ 500; fz = fy - double(labImage(:, :, 3)) ./ 200; @@ -344,6 +345,31 @@ value(~highMask) = value(~highMask) ./ (3 * delta ^ 2) + 4 / 29; end +function constants = cieColorConstants() + % Constant: IEC 61966-2-1 sRGB transfer values, the IEC/CIE sRGB-D65 + % conversion matrices, and the CIE D65 2-degree reference white define + % the app's toolbox-free RGB/XYZ/Lab conversion contract. + constants = struct( ... + 'srgbDecodeThreshold', 0.04045, ... + 'srgbEncodeThreshold', 0.0031308, ... + 'srgbLinearScale', 12.92, ... + 'srgbOffset', 0.055, ... + 'srgbSlope', 1.055, ... + 'srgbGamma', 2.4); + % Constant: IEC/CIE sRGB-D65 forward conversion matrix. + constants.rgbToXyz = [ ... + 0.4124564 0.3575761 0.1804375; ... + 0.2126729 0.7151522 0.0721750; ... + 0.0193339 0.1191920 0.9503041]; + % Constant: IEC/CIE sRGB-D65 inverse conversion matrix. + constants.xyzToRgb = [ ... + 3.2404542 -1.5371385 -0.4985314; ... + -0.9692660 1.8760108 0.0415560; ... + 0.0556434 -0.2040259 1.0572252]; + % Constant: CIE D65 2-degree normalized reference white. + constants.d65WhitePoint = [0.95047 1.00000 1.08883]; +end + function value = labPivotInverse(value) delta = 6 / 29; highMask = value > delta; diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/detectEventTrains.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/detectEventTrains.m index ec387702..11e0896c 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/detectEventTrains.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/detectEventTrains.m @@ -44,6 +44,9 @@ "train", struct())); source = firstAnalogSource(eventDetection); + % Constant: shifting detected stimulation events 0.5 ms earlier aligns + % derivative peaks with the protocol's physical stimulus onset. + defaultStimShiftSec = -0.0005; detector = struct( ... "sourceId", string(fieldOrDefault(opts, "sourceId", ... fieldOrDefault(source, "id", "analog_derivative"))), ... @@ -62,7 +65,7 @@ "requireIsolation", logical(fieldOrDefault(train, ... "requireIsolation", false)), ... "stimShiftSec", double(fieldOrDefault(train, "stimShiftSec", ... - fieldOrDefault(opts, "stimShiftSec", -0.0005)))); + fieldOrDefault(opts, "stimShiftSec", defaultStimShiftSec)))); end function source = firstAnalogSource(eventDetection) @@ -115,7 +118,10 @@ return; end center = median(x); - value = median(abs(x - center)) / 0.6745; + % Constant: 0.6745 is the Gaussian median absolute deviation at one + % standard deviation and converts MAD to a robust sigma estimate. + gaussianMadQuantile = 0.6745; + value = median(abs(x - center)) / gaussianMadQuantile; if value == 0 || ~isfinite(value) value = std(x); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/version.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/version.m index 18f4f1c3..4661ec2f 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/version.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/version.m @@ -8,6 +8,6 @@ "name", "labkit_NerveResponseAnalysis_app", ... "displayName", "Nerve Response Analysis", ... "family", "Neurophysiology", ... - "version", "1.3.4", ... + "version", "1.3.5", ... "updated", "2026-07-06"); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/alignSegments.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/alignSegments.m index 5688ff06..69d5e20f 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/alignSegments.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/alignSegments.m @@ -15,7 +15,10 @@ dt = double(fieldOrDefault(opts, "sampleIntervalSec", estimateDt(segments))); if ~isfinite(dt) || dt <= 0 - dt = 1e-4; + % Constant: 100 microseconds provides a 10 kHz fallback grid when + % segment timestamps cannot supply a valid sample interval. + fallbackSampleIntervalSec = 1e-4; + dt = fallbackSampleIntervalSec; end windowSec = fieldOrDefault(opts, "windowSec", []); if isempty(windowSec) diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/version.m b/apps/neurophysiology/response_review_stats/+response_review_stats/version.m index 9f50c37e..4df2d10c 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/version.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/version.m @@ -8,6 +8,6 @@ "name", "labkit_ResponseReviewStats_app", ... "displayName", "Response Review Stats", ... "family", "Neurophysiology", ... - "version", "1.3.4", ... + "version", "1.3.5", ... "updated", "2026-07-06"); end diff --git a/tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m b/tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m new file mode 100644 index 00000000..c935adcb --- /dev/null +++ b/tests/cases/contract/project/hygiene/MagicNumberGovernanceTest.m @@ -0,0 +1,76 @@ +classdef MagicNumberGovernanceTest < matlab.unittest.TestCase + %MAGICNUMBERGOVERNANCETEST Guard explanations for calculation constants. + + methods (Test, TestTags = {'Integration', 'Style'}) + function productionCalculationConstantsAreExplained(testCase) + root = setupLabKitTestPath(); + files = calculationSourceFiles(root); + findings = unexplainedConstantFindings(root, files); + testCase.verifyEmpty(findings, ... + ['Nontrivial calculation constants require a nearby ' ... + '"% Constant:" comment naming their source or purpose. ' ... + 'Findings: ' strjoin(cellstr(findings), ', ')]); + end + + function scannerRejectsUnexplainedHighPrecisionConstants(testCase) + lines = [ + "gain = 0.123456 .* signal;" + "% Constant: published device conversion gain." + "converted = 0.654321 .* signal;" + ]; + findings = unexplainedLines("example.m", lines); + testCase.verifyEqual(findings, "example.m:1"); + end + end +end + +function files = calculationSourceFiles(root) + [status, output] = system(sprintf([ ... + 'git -C "%s" ls-files --cached --others --exclude-standard ' ... + '+labkit apps'], root)); + assert(status == 0, 'Could not list tracked calculation source files.'); + files = string(splitlines(strtrim(output))); + files = files(endsWith(files, ".m")); + slashFiles = replace(files, "\", "/"); + productionSource = startsWith(slashFiles, "+labkit/") | ... + startsWith(slashFiles, "apps/"); + excludedSource = contains(slashFiles, "/+debug/") | ... + endsWith(slashFiles, "/buildWorkbenchLayout.m") | ... + endsWith(slashFiles, "/version.m") | ... + endsWith(slashFiles, "/requirements.m"); + files = files(productionSource & ~excludedSource); +end + +function findings = unexplainedConstantFindings(root, files) + findings = strings(1, 0); + for index = 1:numel(files) + filepath = fullfile(root, char(files(index))); + findings = [findings unexplainedLines(files(index), readlines(filepath))]; + end +end + +function findings = unexplainedLines(file, lines) + findings = strings(1, 0); + pattern = ['(? Date: Mon, 13 Jul 2026 14:55:46 -0500 Subject: [PATCH 07/15] feat: expose thermal calibration provenance --- .../private/readFlirRadiometricJpeg.m | 33 +++- +labkit/+thermal/rawToTemperatureC.m | 143 +++++++++++++++--- +labkit/+thermal/readFile.m | 3 +- +labkit/+thermal/version.m | 4 +- .../+userInterface/calibrationStatus.m | 35 +++++ .../+userInterface/detailLines.m | 2 + .../+userInterface/filePanelEntries.m | 6 + .../flir_thermal/+flir_thermal/requirements.m | 2 +- .../flir_thermal/+flir_thermal/version.m | 2 +- docs/thermal.md | 25 ++- .../apps/image_measurement/FlirThermalTest.m | 29 +++- .../thermal/ThermalFacadeTest.m | 19 ++- 12 files changed, 263 insertions(+), 40 deletions(-) create mode 100644 apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/calibrationStatus.m diff --git a/+labkit/+thermal/private/readFlirRadiometricJpeg.m b/+labkit/+thermal/private/readFlirRadiometricJpeg.m index be158487..fc7b3ede 100644 --- a/+labkit/+thermal/private/readFlirRadiometricJpeg.m +++ b/+labkit/+thermal/private/readFlirRadiometricJpeg.m @@ -19,7 +19,8 @@ end [raw, rawByteOrder] = decodeRawThermalImage(rawBytes, cameraInfo); - [temperatureC, units, message] = convertTemperature(raw, cameraInfo, opts); + [temperatureC, units, message, conversion] = ... + convertTemperature(raw, cameraInfo, opts); [~, base, ext] = fileparts(char(path)); metadata = struct(); @@ -27,6 +28,7 @@ metadata.rawImageType = embeddedImageType(rawBytes); metadata.rawByteOrder = rawByteOrder; metadata.calibration = cameraInfo; + metadata.temperatureConversion = conversion; metadata.records = directory; record = struct( ... @@ -276,18 +278,27 @@ function cleanupTempFile(filepath) info.PlanckR2 = readF32le(bytes, base + hex2dec('30c')); end -function [temperatureC, units, message] = convertTemperature(raw, cameraInfo, opts) +function [temperatureC, units, message, conversion] = ... + convertTemperature(raw, cameraInfo, opts) units = "raw"; message = "Raw thermal signal only; Planck calibration is incomplete."; temperatureC = NaN(size(raw)); + conversion = unavailableConversion(opts.TemperatureCorrection, message); if isempty(fieldnames(cameraInfo)) return; end try - temperatureC = labkit.thermal.rawToTemperatureC(raw, cameraInfo, ... + [temperatureC, conversion] = labkit.thermal.rawToTemperatureC( ... + raw, cameraInfo, ... struct('Correction', opts.TemperatureCorrection)); units = "C"; - message = "Temperature converted from FLIR raw signal using embedded calibration."; + if conversion.usedDefaults + message = "Temperature converted using embedded FLIR calibration; " + ... + "warning: default correction parameters used for " + ... + strjoin(conversion.defaultedFields, ", ") + "."; + else + message = "Temperature converted from FLIR raw signal using embedded calibration."; + end catch ME if ~strcmp(ME.identifier, 'labkit:thermal:MissingCalibration') rethrow(ME); @@ -295,6 +306,16 @@ function cleanupTempFile(filepath) end end +function conversion = unavailableConversion(correction, message) + conversion = struct( ... + 'available', false, ... + 'correction', string(correction), ... + 'usedDefaults', false, ... + 'defaultedFields', strings(0, 1), ... + 'parameterSources', struct(), ... + 'message', string(message)); +end + function value = readU16be(bytes, startIndex) value = double(bytes(startIndex)) * 256 + double(bytes(startIndex + 1)); end @@ -330,6 +351,8 @@ function cleanupTempFile(filepath) function value = kelvinToCelsius(value) if isfinite(value) - value = value - 273.15; + % Constant: 273.15 is the exact Celsius-to-Kelvin zero-point offset. + kelvinOffsetC = 273.15; + value = value - kelvinOffsetC; end end diff --git a/+labkit/+thermal/rawToTemperatureC.m b/+labkit/+thermal/rawToTemperatureC.m index c4d3624a..757abccb 100644 --- a/+labkit/+thermal/rawToTemperatureC.m +++ b/+labkit/+thermal/rawToTemperatureC.m @@ -1,9 +1,10 @@ -function temperatureC = rawToTemperatureC(raw, calibration, opts) +function [temperatureC, diagnostics] = rawToTemperatureC(raw, calibration, opts) %RAWTOTEMPERATUREC Convert FLIR raw sensor values to Celsius. % % App-facing contract: % temperatureC = labkit.thermal.rawToTemperatureC(raw, calibration) % temperatureC = labkit.thermal.rawToTemperatureC(raw, calibration, opts) +% [temperatureC, diagnostics] = labkit.thermal.rawToTemperatureC(...) % % Inputs: % raw - numeric raw sensor matrix. @@ -19,6 +20,11 @@ % Outputs: % temperatureC - double matrix in degrees Celsius. Invalid conversion pixels % become NaN. +% diagnostics - scalar struct with correction, usedDefaults, +% defaultedFields, parameterSources, and message. parameterSources maps +% environmental field names to "calibration" or "default". Basic mode +% does not consume environmental parameters and therefore reports no +% defaults. if nargin < 3 || isempty(opts) opts = struct(); @@ -32,10 +38,12 @@ raw = double(raw); if mode == "environment" - rawObject = environmentCorrectedRaw(raw, calibration); + [rawObject, diagnostics] = environmentCorrectedRaw(raw, calibration); else rawObject = raw; + diagnostics = conversionDiagnostics(mode, strings(0, 1), struct()); end + diagnostics.correction = mode; temperatureC = planckTemperature(rawObject, calibration); end @@ -50,28 +58,28 @@ function assertPlanckCalibration(calibration) end end -function rawObject = environmentCorrectedRaw(raw, calibration) - E = scalarField(calibration, 'Emissivity', 1); - OD = scalarField(calibration, 'ObjectDistanceM', 1); - RTemp = scalarField(calibration, 'ReflectedApparentTemperatureC', 20); - ATemp = scalarField(calibration, 'AtmosphericTemperatureC', 20); - IRWTemp = scalarField(calibration, 'IRWindowTemperatureC', RTemp); - IRT = scalarField(calibration, 'IRWindowTransmission', 1); - RH = scalarField(calibration, 'RelativeHumidity', 0.5); +function [rawObject, diagnostics] = environmentCorrectedRaw(raw, calibration) + [parameters, diagnostics] = environmentParameters(calibration); + E = parameters.Emissivity; + OD = parameters.ObjectDistanceM; + RTemp = parameters.ReflectedApparentTemperatureC; + ATemp = parameters.AtmosphericTemperatureC; + IRWTemp = parameters.IRWindowTemperatureC; + IRT = parameters.IRWindowTransmission; + RH = parameters.RelativeHumidity; if RH > 2 RH = RH / 100; end - ATA1 = scalarField(calibration, 'AtmosphericTransAlpha1', 0.006569); - ATA2 = scalarField(calibration, 'AtmosphericTransAlpha2', 0.01262); - ATB1 = scalarField(calibration, 'AtmosphericTransBeta1', -0.002276); - ATB2 = scalarField(calibration, 'AtmosphericTransBeta2', -0.00667); - ATX = scalarField(calibration, 'AtmosphericTransX', 1.9); - - E = clampPositive(E, 1); - IRT = clampPositive(IRT, 1); - OD = max(0, double(OD)); - h2o = RH .* exp(1.5587 + 0.06939 .* ATemp - ... - 0.00027816 .* ATemp.^2 + 0.00000068455 .* ATemp.^3); + ATA1 = parameters.AtmosphericTransAlpha1; + ATA2 = parameters.AtmosphericTransAlpha2; + ATB1 = parameters.AtmosphericTransBeta1; + ATB2 = parameters.AtmosphericTransBeta2; + ATX = parameters.AtmosphericTransX; + % Constant: FLIR's water-vapor polynomial estimates atmospheric water + % content from relative humidity and atmospheric temperature in Celsius. + vaporPolynomial = [1.5587 0.06939 -0.00027816 0.00000068455]; + h2o = RH .* exp(vaporPolynomial(1) + vaporPolynomial(2) .* ATemp + ... + vaporPolynomial(3) .* ATemp.^2 + vaporPolynomial(4) .* ATemp.^3); h2o = max(0, h2o); tau1 = atmosphericTau(OD / 2, h2o, ATA1, ATA2, ATB1, ATB2, ATX); tau2 = atmosphericTau(OD / 2, h2o, ATA1, ATA2, ATB1, ATB2, ATX); @@ -94,6 +102,78 @@ function assertPlanckCalibration(calibration) (1 - tau2) ./ E ./ tau1 ./ IRT ./ tau2 .* rawAtm2; end +function [parameters, diagnostics] = environmentParameters(calibration) + % Constant: these are the documented environmental fallback settings + % used only when the supplied calibration omits or invalidates a field. + defaults = struct( ... + 'Emissivity', 1, ... + 'ObjectDistanceM', 1, ... + 'ReflectedApparentTemperatureC', 20, ... + 'AtmosphericTemperatureC', 20, ... + 'IRWindowTemperatureC', NaN, ... + 'IRWindowTransmission', 1, ... + 'RelativeHumidity', 0.5, ... + 'AtmosphericTransAlpha1', defaultAtmosphericCoefficient('alpha1'), ... + 'AtmosphericTransAlpha2', defaultAtmosphericCoefficient('alpha2'), ... + 'AtmosphericTransBeta1', defaultAtmosphericCoefficient('beta1'), ... + 'AtmosphericTransBeta2', defaultAtmosphericCoefficient('beta2'), ... + 'AtmosphericTransX', 1.9); + fields = string(fieldnames(defaults)); + parameters = struct(); + sources = struct(); + defaultedFields = strings(0, 1); + for k = 1:numel(fields) + field = fields(k); + fallback = defaults.(field); + if field == "IRWindowTemperatureC" && ~isfinite(fallback) + fallback = fieldValue(parameters, 'ReflectedApparentTemperatureC', 20); + end + [value, usedDefault] = scalarField(calibration, field, fallback); + if any(field == ["Emissivity", "IRWindowTransmission"]) && value <= 0 + value = fallback; + usedDefault = true; + elseif field == "ObjectDistanceM" + value = max(0, value); + end + parameters.(field) = value; + if usedDefault + sources.(field) = "default"; + defaultedFields(end + 1, 1) = field; + else + sources.(field) = "calibration"; + end + end + diagnostics = conversionDiagnostics("environment", defaultedFields, sources); +end + +function value = defaultAtmosphericCoefficient(name) + % Constant: FLIR radiometric atmospheric transmission coefficients used + % when a file or caller does not provide camera-specific values. + coefficients = struct( ... + 'alpha1', 0.006569, ... + 'alpha2', 0.01262, ... + 'beta1', -0.002276, ... + 'beta2', -0.00667); + value = coefficients.(char(name)); +end + +function diagnostics = conversionDiagnostics(correction, defaultedFields, sources) + usedDefaults = ~isempty(defaultedFields); + if usedDefaults + message = "Temperature correction used defaults for: " + ... + strjoin(defaultedFields, ", ") + "."; + else + message = "Temperature correction used supplied calibration parameters."; + end + diagnostics = struct( ... + 'available', true, ... + 'correction', string(correction), ... + 'usedDefaults', usedDefaults, ... + 'defaultedFields', defaultedFields, ... + 'parameterSources', sources, ... + 'message', message); +end + function tau = atmosphericTau(distancePart, h2o, alpha1, alpha2, beta1, beta2, x) distanceRoot = sqrt(max(0, distancePart)); h2oRoot = sqrt(max(0, h2o)); @@ -102,7 +182,9 @@ function assertPlanckCalibration(calibration) end function raw = rawFromTemperatureC(temperatureC, calibration) - kelvin = double(temperatureC) + 273.15; + % Constant: 273.15 is the exact Celsius-to-Kelvin zero-point offset. + kelvinOffsetC = 273.15; + kelvin = double(temperatureC) + kelvinOffsetC; raw = calibration.PlanckR1 ./ ... (calibration.PlanckR2 .* (exp(calibration.PlanckB ./ kelvin) - ... calibration.PlanckF)) - calibration.PlanckO; @@ -111,15 +193,19 @@ function assertPlanckCalibration(calibration) function temperatureC = planckTemperature(rawObject, calibration) denominator = calibration.PlanckR2 .* (rawObject + calibration.PlanckO); argument = calibration.PlanckR1 ./ denominator + calibration.PlanckF; - temperatureC = calibration.PlanckB ./ log(argument) - 273.15; + % Constant: 273.15 is the exact Celsius-to-Kelvin zero-point offset. + kelvinOffsetC = 273.15; + temperatureC = calibration.PlanckB ./ log(argument) - kelvinOffsetC; temperatureC(~isfinite(temperatureC) | argument <= 0 | denominator <= 0) = NaN; end -function value = scalarField(calibration, field, defaultValue) +function [value, usedDefault] = scalarField(calibration, field, defaultValue) value = defaultValue; - if isfield(calibration, field) && ~isempty(calibration.(field)) && ... + usedDefault = true; + if isfield(calibration, field) && isscalar(calibration.(field)) && ... isfinite(double(calibration.(field))) value = double(calibration.(field)); + usedDefault = false; end end @@ -130,6 +216,13 @@ function assertPlanckCalibration(calibration) end end +function value = fieldValue(S, field, defaultValue) + value = defaultValue; + if isfield(S, field) + value = S.(field); + end +end + function value = optionValue(opts, name, defaultValue) value = defaultValue; if isstruct(opts) && isfield(opts, name) diff --git a/+labkit/+thermal/readFile.m b/+labkit/+thermal/readFile.m index 6828b89f..3bd54515 100644 --- a/+labkit/+thermal/readFile.m +++ b/+labkit/+thermal/readFile.m @@ -20,7 +20,8 @@ % Outputs: % record - struct with path, name, format, raw, temperatureC, units, % metadata, and message fields. temperatureC is NaN when the file has raw -% data but lacks enough calibration constants. +% data but lacks enough calibration constants. metadata.temperatureConversion +% records correction mode, parameter sources, and any defaulted fields. if nargin < 2 || isempty(opts) opts = struct(); diff --git a/+labkit/+thermal/version.m b/+labkit/+thermal/version.m index bc83d48e..50964166 100644 --- a/+labkit/+thermal/version.m +++ b/+labkit/+thermal/version.m @@ -11,6 +11,6 @@ % info - plain struct describing the current labkit.thermal contract, % compatible contract range, stability, and maintainer note. - info = labkit.contract.versionInfo("thermal", "1.0.0", ">=1.0 <2", ... - "experimental", "GUI-free thermal image facade for FLIR radiometric JPEG reads, raw sensor matrices, temperature conversion, and display rendering."); + info = labkit.contract.versionInfo("thermal", "1.1.0", ">=1.0 <2", ... + "experimental", "GUI-free thermal image facade for FLIR radiometric JPEG reads, raw sensor matrices, provenance-aware temperature conversion, and display rendering."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/calibrationStatus.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/calibrationStatus.m new file mode 100644 index 00000000..af4abe12 --- /dev/null +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/calibrationStatus.m @@ -0,0 +1,35 @@ +% App-owned calibration presentation helper. Expected callers are FLIR file +% list and detail-panel renderers. Input is one loaded app item. Output is a +% scalar status struct with severity, shortText, and detailText. No side effects. +function status = calibrationStatus(item) +%CALIBRATIONSTATUS Describe thermal conversion provenance for the user. + + status = struct( ... + 'severity', "unavailable", ... + 'shortText', "temperature unavailable", ... + 'detailText', "Temperature conversion unavailable; displaying raw signal."); + if ~isfield(item, 'metadata') || ... + ~isfield(item.metadata, 'temperatureConversion') + return; + end + conversion = item.metadata.temperatureConversion; + if ~isfield(conversion, 'available') || ~logical(conversion.available) + return; + end + if isfield(conversion, 'usedDefaults') && logical(conversion.usedDefaults) + fields = string(conversion.defaultedFields); + status.severity = "warning"; + status.shortText = "calibration defaults used"; + status.detailText = "Warning: default thermal correction parameters used: " + ... + strjoin(fields, ", ") + ". Absolute temperatures may be less accurate."; + return; + end + correction = string(conversion.correction); + status.severity = "calibrated"; + status.shortText = "embedded calibration"; + if correction == "planck-basic" + status.detailText = "Calibration: embedded Planck parameters; environmental correction disabled."; + else + status.detailText = "Calibration: embedded FLIR parameters; no correction defaults used."; + end +end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/detailLines.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/detailLines.m index b03ca685..f0930964 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/detailLines.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/detailLines.m @@ -13,6 +13,7 @@ item = items(currentIndex); range = double(item.displayRange(:)).'; labels = flir_thermal.userInterface.rangeControlLabels(); + calibration = flir_thermal.userInterface.calibrationStatus(item); lines = { sprintf('Loaded files: %d', numel(items)) sprintf('Current file: %s', char(item.name)) @@ -28,6 +29,7 @@ sprintf('Temperature differences: %s', differenceSummary(item)) sprintf('Reader: %s', metadataText(item, 'reader')) sprintf('Raw byte order: %s', metadataText(item, 'rawByteOrder')) + char(calibration.detailText) sprintf('Message: %s', char(string(item.message))) ['Output folder: ' char(string(outputFolder))]}; end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/filePanelEntries.m b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/filePanelEntries.m index c61035f2..0d6ab621 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/filePanelEntries.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+userInterface/filePanelEntries.m @@ -11,5 +11,11 @@ else entries(k).status = "needs range"; end + calibration = flir_thermal.userInterface.calibrationStatus(items(k)); + if calibration.severity == "warning" + entries(k).status = entries(k).status + "; calibration defaults used"; + elseif calibration.severity == "unavailable" + entries(k).status = entries(k).status + "; temperature unavailable"; + end end end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m b/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m index 2aa9c4a8..56417f20 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/requirements.m @@ -5,5 +5,5 @@ req = labkit.contract.requirements("ui", ">=5 <6", ... "image", ">=2.0 <3", ... - "thermal", ">=1.0 <2"); + "thermal", ">=1.1 <2"); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/version.m b/apps/image_measurement/flir_thermal/+flir_thermal/version.m index 7498d510..f9131085 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/version.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/version.m @@ -8,6 +8,6 @@ "name", "labkit_FLIRThermal_app", ... "displayName", "FLIR Thermal Postprocess", ... "family", "Image Measurement", ... - "version", "1.2.9", ... + "version", "1.3.0", ... "updated", "2026-07-13"); end diff --git a/docs/thermal.md b/docs/thermal.md index 9d06f7fa..ffc2ae8e 100644 --- a/docs/thermal.md +++ b/docs/thermal.md @@ -36,7 +36,8 @@ embedded FFF RawThermalImage record. `readFile` returns a struct with: `NaN` values when conversion is unavailable - `units`: `"C"` or `"raw"` - `metadata`: reader name, raw image type, byte-order normalization, embedded - calibration fields, and parsed FFF records + calibration fields, parsed FFF records, and `temperatureConversion` + diagnostics describing correction mode and parameter provenance - `message`: short conversion status text `rawToTemperatureC` supports `"environment"` correction, which uses emissivity, @@ -44,6 +45,28 @@ distance, reflected/atmospheric/window temperatures, humidity, transmission, and Planck constants when available. `"planck-basic"` applies only the embedded Planck constants. +Call `rawToTemperatureC` with two outputs to inspect conversion provenance: + +```matlab +[temperatureC, diagnostics] = labkit.thermal.rawToTemperatureC( ... + raw, calibration); +``` + +`diagnostics.usedDefaults` indicates that one or more environmental fields were +missing or invalid, `defaultedFields` names them, and `parameterSources` maps +each environmental field to `"calibration"` or `"default"`. Records returned +by `readFile` expose the same struct as +`record.metadata.temperatureConversion`. Planck constants are mandatory and +never receive generic fallback values. + +Environment mode falls back to defined FLIR-model settings only when necessary, +including emissivity `1`, object distance `1 m`, reflected and atmospheric +temperature `20 C`, relative humidity `0.5`, and the standard atmospheric +transmission coefficients. These values make conversion possible but do not +establish measurement accuracy for the photographed material or environment. +Apps should visibly warn users whenever defaults were used. The FLIR Thermal +app shows that warning in both the file status and detail panel. + `renderImage` is the reusable facade renderer for linear thermal palette mapping over a selected numeric range. App-level display modes such as log or gamma color mapping belong to the owning app because they are workflow and UI diff --git a/tests/cases/unit/apps/image_measurement/FlirThermalTest.m b/tests/cases/unit/apps/image_measurement/FlirThermalTest.m index 00b2fdc1..61bc0f5d 100644 --- a/tests/cases/unit/apps/image_measurement/FlirThermalTest.m +++ b/tests/cases/unit/apps/image_measurement/FlirThermalTest.m @@ -48,6 +48,8 @@ function flirThermalAppReadsSummarizesAndExports(testCase) testCase.verifyTrue(any(strcmp(string(rows(:, 1)), "Range status"))); testCase.verifyTrue(any(contains(string(details), "Current file: synthetic_flir.jpg"))); testCase.verifyTrue(any(contains(string(details), "Range status: needs range"))); + testCase.verifyTrue(any(contains(string(details), ... + "no correction defaults used"))); testCase.verifyEqual(payload.results.status, "saved"); testCase.verifyEqual(payload.results.colorMapping, "Gamma"); testCase.verifyEqual(payload.results.gammaValue, 1.6, "AbsTol", 1e-12); @@ -115,11 +117,34 @@ function flirThermalRawItemsFallbackAndRangeStatus(testCase) testCase.verifyEqual(units, "raw"); testCase.verifyEqual(label, "Raw thermal signal"); testCase.verifyTrue(any(contains(string(rows(:, 2)), "1 to 4 raw"))); - testCase.verifyEqual(entries.status, "needs range"); + testCase.verifyEqual(entries.status, ... + "needs range; temperature unavailable"); item.rangeAdjusted = true; entries = flir_thermal.userInterface.filePanelEntries(item); - testCase.verifyEqual(entries.status, "range set"); + testCase.verifyEqual(entries.status, ... + "range set; temperature unavailable"); + end + + function flirThermalWarnsWhenCorrectionUsesDefaults(testCase) + setupLabKitTestPath(); + folder = tempname; + mkdir(folder); + cleanup = onCleanup(@() removeTempFolder(folder)); + sourcePath = fullfile(folder, "defaulted_calibration.jpg"); + writeSyntheticFlirRjpegFixture(sourcePath, struct("emissivity", 0)); + + items = flir_thermal.sourceFiles.readImages(sourcePath); + details = flir_thermal.userInterface.detailLines(items, 1, folder); + entries = flir_thermal.userInterface.filePanelEntries(items); + + conversion = items.metadata.temperatureConversion; + testCase.verifyTrue(conversion.usedDefaults); + testCase.verifyTrue(any(conversion.defaultedFields == "Emissivity")); + testCase.verifyTrue(any(contains(string(details), ... + "Warning: default thermal correction parameters used: Emissivity"))); + testCase.verifyEqual(entries.status, ... + "needs range; calibration defaults used"); end function flirThermalRangeControlBoundsPresets(testCase) diff --git a/tests/cases/unit/labkit_framework/thermal/ThermalFacadeTest.m b/tests/cases/unit/labkit_framework/thermal/ThermalFacadeTest.m index 995931dd..27eed43c 100644 --- a/tests/cases/unit/labkit_framework/thermal/ThermalFacadeTest.m +++ b/tests/cases/unit/labkit_framework/thermal/ThermalFacadeTest.m @@ -23,6 +23,12 @@ function thermalFacadeReadsSyntheticFlirRjpeg(testCase) testCase.verifyEqual(records.metadata.rawByteOrder, "native"); testCase.verifyEqual(records.metadata.calibration.ImageWidth, 3); testCase.verifyEqual(records.metadata.calibration.ImageHeight, 2); + conversion = records.metadata.temperatureConversion; + testCase.verifyTrue(conversion.available); + testCase.verifyFalse(conversion.usedDefaults); + testCase.verifyEmpty(conversion.defaultedFields); + testCase.verifyEqual(conversion.parameterSources.Emissivity, ... + "calibration"); testCase.verifyEqual(records.units, "C"); testCase.verifyTrue(all(isfinite(records.temperatureC), "all")); testCase.verifyEqual(size(records.temperatureC), size(fixture.raw)); @@ -89,14 +95,23 @@ function rawToTemperatureSupportsBasicAndEnvironmentModes(testCase) cleanup = onCleanup(@() deleteIfExists(calibrationPath)); calibration = writeSyntheticFlirRjpegFixture(calibrationPath).calibration; - basic = labkit.thermal.rawToTemperatureC(raw, calibration, ... + [basic, basicDiagnostics] = labkit.thermal.rawToTemperatureC(raw, calibration, ... struct("Correction", "planck-basic")); - corrected = labkit.thermal.rawToTemperatureC(raw, calibration, ... + [corrected, correctedDiagnostics] = labkit.thermal.rawToTemperatureC(raw, calibration, ... struct("Correction", "environment")); + incomplete = rmfield(calibration, "Emissivity"); + [~, fallbackDiagnostics] = labkit.thermal.rawToTemperatureC( ... + raw, incomplete, struct("Correction", "environment")); testCase.verifyTrue(all(isfinite(basic), "all")); testCase.verifyTrue(all(isfinite(corrected), "all")); testCase.verifyEqual(size(basic), size(raw)); + testCase.verifyFalse(basicDiagnostics.usedDefaults); + testCase.verifyFalse(correctedDiagnostics.usedDefaults); + testCase.verifyTrue(fallbackDiagnostics.usedDefaults); + testCase.verifyEqual(fallbackDiagnostics.defaultedFields, "Emissivity"); + testCase.verifyEqual( ... + fallbackDiagnostics.parameterSources.Emissivity, "default"); testCase.verifyError(@() labkit.thermal.rawToTemperatureC(raw, ... rmfield(calibration, "PlanckR2")), ... "labkit:thermal:MissingCalibration"); From 392a073eb9c71679e2363a08e2045637d3f9ca7b Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 14:56:06 -0500 Subject: [PATCH 08/15] feat: unify DIC rigid point interactions --- +labkit/+ui/+interaction/anchorEditor.m | 44 +++- +labkit/+ui/version.m | 4 +- .../+userInterface/selectRigidPointPairs.m | 224 +++++++----------- .../dic_preprocess/+dic_preprocess/version.m | 2 +- docs/ui.md | 14 +- .../ui/GuiLayoutUiAnchorCurveEditorTest.m | 7 + 6 files changed, 150 insertions(+), 145 deletions(-) diff --git a/+labkit/+ui/+interaction/anchorEditor.m b/+labkit/+ui/+interaction/anchorEditor.m index 66877073..6d717a9c 100644 --- a/+labkit/+ui/+interaction/anchorEditor.m +++ b/+labkit/+ui/+interaction/anchorEditor.m @@ -13,6 +13,9 @@ % opts - optional struct. % % Options: +% mode - "curve" (default) or "points". Point mode uses a single blank +% click to append an anchor, draws no connecting path, and leaves +% deletion to the caller's explicit undo/clear controls. % closed - logical, default false; close preview path for ROI boundaries. % style - "Curve" (default) or "Straight lines". % installScrollWheel - logical, default true; temporarily use editor zoom @@ -32,6 +35,8 @@ % Interaction: % Double-click blank space to add/insert anchors, drag anchors to move, % double-click anchors to delete, and use scroll wheel to zoom when enabled. +% In point mode, single-click blank space to append anchors and drag anchors +% to refine them; double-click does not delete an anchor. % Open paths extend endpoints for natural tracing, but clicks close to an % existing visible segment insert correction anchors. Endpoint extensions % that would self-intersect the visible path are treated as insertions. @@ -53,6 +58,7 @@ state.ax = runtime.axes(); state.fig = runtime.figure(); state.imageSize = imageSize; + state.mode = editorMode(optionValue(opts, 'mode', 'curve')); state.closed = optionValue(opts, 'closed', false); state.style = string(optionValue(opts, 'style', 'Curve')); state.installScrollWheel = optionValue(opts, 'installScrollWheel', true); @@ -143,8 +149,7 @@ function undoLast() function insertPoint(point) trace(sprintf('insertPoint %.6g %.6g', point(1), point(2))); - state.points = addOrInsertAnchor(state.points, point, state.ax, ... - state.imageSize, state.style, state.closed, state.maxPoints); + state.points = addPoint(state.points, point); refresh(); notifyChanged('add point'); end @@ -202,6 +207,10 @@ function refresh() end function curve = curvePointsForCurrentState() + if state.mode == "points" + curve = zeros(0, 2); + return; + end curve = anchorCurvePoints(state.points, state.imageSize, state.style, state.closed); end @@ -238,7 +247,8 @@ function onAxesClicked(~, ~) end idx = nearestAnchor(x, y); - if strcmp(state.fig.SelectionType, 'open') + selectionType = string(state.fig.SelectionType); + if selectionType == "open" && state.mode == "curve" if ~isempty(idx) trace(sprintf('onAxesClicked double-delete idx=%d', idx)); state.points(idx, :) = []; @@ -246,14 +256,21 @@ function onAxesClicked(~, ~) notifyChanged('delete point'); else trace(sprintf('onAxesClicked double-add x=%.6g y=%.6g', x, y)); - state.points = addOrInsertAnchor(state.points, [x y], state.ax, ... - state.imageSize, state.style, state.closed, state.maxPoints); + state.points = addPoint(state.points, [x y]); refresh(); notifyChanged('add point'); end return; end + if isempty(idx) && state.mode == "points" && selectionType == "normal" + trace(sprintf('onAxesClicked single-add x=%.6g y=%.6g', x, y)); + state.points = addPoint(state.points, [x y]); + refresh(); + notifyChanged('add point'); + return; + end + if ~isempty(idx) trace(sprintf('onAxesClicked drag idx=%d', idx)); state.dragIndex = idx; @@ -345,6 +362,17 @@ function ensureGraphics() end end + function points = addPoint(points, point) + if state.mode == "points" + if size(points, 1) < state.maxPoints + points(end + 1, :) = double(point(1:2)); + end + return; + end + points = addOrInsertAnchor(points, point, state.ax, ... + state.imageSize, state.style, state.closed, state.maxPoints); + end + function applyStoredView() if isempty(state.imageSize) || ~isvalid(state.ax) return; @@ -414,6 +442,12 @@ function trace(message) end end +function mode = editorMode(value) + mode = lower(string(value)); + assert(isscalar(mode) && any(mode == ["curve", "points"]), ... + 'Anchor editor mode must be "curve" or "points".'); +end + function points = normalizePoints(points) if isempty(points) points = zeros(0, 2); diff --git a/+labkit/+ui/version.m b/+labkit/+ui/version.m index b9dd4a8c..21fc249e 100644 --- a/+labkit/+ui/version.m +++ b/+labkit/+ui/version.m @@ -12,6 +12,6 @@ % compatible contract ranges implemented by this code, contract status, % and a short maintainer note. - info = labkit.contract.versionInfo("ui", "5.0.4", ">=5 <6", ... - "stable", "UI 5 runtime/layout/control/plot/interaction/debug contract with declarative app definitions, data-only workbench layouts, semantic control updates, framework-owned plot clearing and limit fitting, plot coordinate conversion helpers, reusable image preview rendering, interaction tools, debug artifacts, hidden-test-safe alerts, default close guards, state snapshot save/load, and app version title formatting."); + info = labkit.contract.versionInfo("ui", "5.1.0", ">=5 <6", ... + "stable", "UI 5 runtime/layout/control/plot/interaction/debug contract with declarative app definitions, data-only workbench layouts, semantic control updates, framework-owned plot clearing and limit fitting, plot coordinate conversion helpers, reusable image preview rendering, curve and discrete-point anchor editing, interaction tools, debug artifacts, hidden-test-safe alerts, default close guards, state snapshot save/load, and app version title formatting."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m index 1a7da90c..52fb3bff 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m @@ -7,129 +7,115 @@ movingPoints = zeros(0, 2); fixedPoints = zeros(0, 2); - pendingMoving = []; accepted = false; - dragSide = ""; - dragIndex = []; + updatingEditors = false; + editorsReady = false; fig = uifigure('Name', 'DIC Manual Alignment', ... 'Position', [100 100 1120 680], ... 'CloseRequestFcn', @cancelSelection); cleanup = onCleanup(@() closeIfValid(fig)); - root = uigridlayout(fig, [3 2], ... - 'RowHeight', {34, '1x', 42}, ... - 'ColumnWidth', {'1x', '1x'}, ... - 'Padding', [10 10 10 10]); - status = uilabel(root, ... - 'Text', 'Click a point in the moving image.', ... + status = uilabel(fig, ... + 'Text', ['Single-click a feature in the moving image; ' ... + 'drag any existing anchor to refine it.'], ... 'HorizontalAlignment', 'center', ... - 'FontWeight', 'bold'); - status.Layout.Row = 1; - status.Layout.Column = [1 2]; - movingAxes = uiaxes(root); - movingAxes.Layout.Row = 2; - movingAxes.Layout.Column = 1; - fixedAxes = uiaxes(root); - fixedAxes.Layout.Row = 2; - fixedAxes.Layout.Column = 2; + 'FontWeight', 'bold', ... + 'Position', [20 638 1080 28]); + movingAxes = uiaxes(fig, 'Position', [25 100 525 525]); + fixedAxes = uiaxes(fig, 'Position', [570 100 525 525]); movingBackground = drawImage(movingAxes, movingImage, 'Moving image'); fixedBackground = drawImage(fixedAxes, fixedImage, 'Fixed image'); - buttonGrid = uigridlayout(root, [1 4], ... - 'ColumnWidth', {'1x', 110, 110, 110}, ... - 'Padding', [0 0 0 0]); - buttonGrid.Layout.Row = 3; - buttonGrid.Layout.Column = [1 2]; - countLabel = uilabel(buttonGrid, 'Text', 'Point pairs: 0'); - undoButton = uibutton(buttonGrid, 'Text', 'Undo last', ... + countLabel = uilabel(fig, 'Text', 'Point pairs: 0', ... + 'Position', [25 35 650 30]); + undoButton = uibutton(fig, 'Text', 'Undo last', ... + 'Position', [720 32 110 34], ... 'Enable', 'off', 'ButtonPushedFcn', @undoLast); - cancelButton = uibutton(buttonGrid, 'Text', 'Cancel', ... + cancelButton = uibutton(fig, 'Text', 'Cancel', ... + 'Position', [845 32 110 34], ... 'ButtonPushedFcn', @cancelSelection); - acceptButton = uibutton(buttonGrid, 'Text', 'Accept pairs', ... + acceptButton = uibutton(fig, 'Text', 'Accept pairs', ... + 'Position', [970 32 110 34], ... 'Enable', 'off', 'ButtonPushedFcn', @acceptSelection); movingRuntime = labkit.ui.interaction.runtime(movingAxes, struct('figure', fig)); fixedRuntime = labkit.ui.interaction.runtime(fixedAxes, struct('figure', fig)); - movingSession = movingRuntime.createSession(struct( ... - 'name', 'dicMovingControlPoints', ... - 'onPointerDown', @(src, event) onPointerDown("moving", src, event), ... - 'installScrollWheel', false)); - fixedSession = fixedRuntime.createSession(struct( ... - 'name', 'dicFixedControlPoints', ... - 'onPointerDown', @(src, event) onPointerDown("fixed", src, event), ... - 'installScrollWheel', false)); - movingSession.setBackground(movingBackground); - fixedSession.setBackground(fixedBackground); - movingSession.activate(); - fixedSession.activate(); - redrawPoints(); + pointOptions = struct('mode', 'points', 'installScrollWheel', false); + movingOptions = pointOptions; + movingOptions.onChanged = @onMovingChanged; + fixedOptions = pointOptions; + fixedOptions.onChanged = @onFixedChanged; + movingEditor = labkit.ui.interaction.anchorEditor( ... + movingRuntime, size(movingImage), movingOptions); + fixedEditor = labkit.ui.interaction.anchorEditor( ... + fixedRuntime, size(fixedImage), fixedOptions); + movingEditor.setBackground(movingBackground); + fixedEditor.setBackground(fixedBackground); + movingEditor.start(movingPoints); + fixedEditor.start(fixedPoints); + editorsReady = true; + refreshPointDisplay(); uiwait(fig); + movingEditor.delete(); + fixedEditor.delete(); if ~accepted movingPoints = zeros(0, 2); fixedPoints = zeros(0, 2); end clear cleanup - function onPointerDown(side, source, ~) - if isgraphics(source) && isprop(source, 'UserData') && ... - isstruct(source.UserData) && isfield(source.UserData, 'pointIndex') - dragSide = side; - dragIndex = source.UserData.pointIndex; - activeSession = sessionForSide(side); - activeSession.captureDrag(@onDrag, @onDragReleased); + function onMovingChanged(points, reason) + if ~editorsReady || updatingEditors return; end - - point = currentPoint(side); - if side == "moving" && isempty(pendingMoving) - pendingMoving = point; - status.Text = 'Click the matching point in the fixed image.'; - elseif side == "fixed" && ~isempty(pendingMoving) - movingPoints(end + 1, :) = pendingMoving; - fixedPoints(end + 1, :) = point; - pendingMoving = []; - status.Text = 'Pair added. Click another point in the moving image.'; - else - status.Text = char("Next expected click: " + expectedSide() + " image."); + if string(reason) == "add point" && ... + size(movingPoints, 1) ~= size(fixedPoints, 1) + restoreEditorPoints(movingEditor, movingPoints); + status.Text = 'Place the matching point in the fixed image first.'; + return; end - redrawPoints(); + movingPoints = points; + if size(movingPoints, 1) > size(fixedPoints, 1) + status.Text = 'Now single-click the matching point in the fixed image.'; + end + refreshPointDisplay(); end - function onDrag(~, ~) - if isempty(dragIndex) + function onFixedChanged(points, reason) + if ~editorsReady || updatingEditors + return; + end + if string(reason) == "add point" && ... + size(movingPoints, 1) ~= size(fixedPoints, 1) + 1 + restoreEditorPoints(fixedEditor, fixedPoints); + status.Text = 'Start the next pair in the moving image.'; return; end - point = currentPoint(dragSide); - if dragIndex == 0 - pendingMoving = point; - elseif dragSide == "moving" - movingPoints(dragIndex, :) = point; - else - fixedPoints(dragIndex, :) = point; + fixedPoints = points; + if size(movingPoints, 1) == size(fixedPoints, 1) + status.Text = ['Pair added. Single-click another moving feature, ' ... + 'or drag any anchor to refine it.']; end - redrawPoints(); - end - - function onDragReleased(~, ~) - onDrag([], []); - dragSide = ""; - dragIndex = []; + refreshPointDisplay(); end function undoLast(~, ~) - if ~isempty(pendingMoving) - pendingMoving = []; + if size(movingPoints, 1) > size(fixedPoints, 1) + movingPoints(end, :) = []; elseif ~isempty(movingPoints) movingPoints(end, :) = []; fixedPoints(end, :) = []; end - status.Text = 'Click a point in the moving image.'; - redrawPoints(); + updateEditorPoints(); + status.Text = ['Single-click a feature in the moving image; ' ... + 'drag any existing anchor to refine it.']; + refreshPointDisplay(); end function acceptSelection(~, ~) - if size(movingPoints, 1) < 2 + if size(movingPoints, 1) < 2 || ... + size(movingPoints, 1) ~= size(fixedPoints, 1) return; end accepted = true; @@ -143,50 +129,26 @@ function cancelSelection(~, ~) end end - function redrawPoints() - movingGraphics = drawPointSet(movingAxes, movingPoints, "moving"); - if ~isempty(pendingMoving) - pendingGraphic = drawPoint(movingAxes, pendingMoving, ... - size(movingPoints, 1) + 1, "moving", 0, [1 0.85 0]); - movingGraphics = [movingGraphics; pendingGraphic]; - end - fixedGraphics = drawPointSet(fixedAxes, fixedPoints, "fixed"); - movingSession.setGraphics(movingGraphics); - fixedSession.setGraphics(fixedGraphics); - movingSession.refresh(); - fixedSession.refresh(); + function refreshPointDisplay() + drawPointLabels(movingAxes, movingPoints); + drawPointLabels(fixedAxes, fixedPoints); countLabel.Text = sprintf('Point pairs: %d', size(movingPoints, 1)); - undoButton.Enable = enabledText(~isempty(pendingMoving) || ~isempty(movingPoints)); - acceptButton.Enable = enabledText(size(movingPoints, 1) >= 2); - end - - function point = currentPoint(side) - if side == "moving" - ax = movingAxes; - imageSize = size(movingImage); - else - ax = fixedAxes; - imageSize = size(fixedImage); - end - value = double(ax.CurrentPoint); - point = value(1, 1:2); - point(1) = min(max(point(1), 1), imageSize(2)); - point(2) = min(max(point(2), 1), imageSize(1)); + undoButton.Enable = enabledText(~isempty(movingPoints)); + acceptButton.Enable = enabledText(size(movingPoints, 1) >= 2 && ... + size(movingPoints, 1) == size(fixedPoints, 1)); end - function session = sessionForSide(side) - if side == "moving" - session = movingSession; - else - session = fixedSession; - end + function restoreEditorPoints(editor, points) + updatingEditors = true; + editor.setPoints(points); + updatingEditors = false; end - function side = expectedSide() - side = "moving"; - if ~isempty(pendingMoving) - side = "fixed"; - end + function updateEditorPoints() + updatingEditors = true; + movingEditor.setPoints(movingPoints); + fixedEditor.setPoints(fixedPoints); + updatingEditors = false; end end @@ -205,26 +167,16 @@ function redrawPoints() ax.YLabel.String = 'y (px)'; end -function graphics = drawPointSet(ax, points, side) - delete(findobj(ax, 'Tag', 'dicControlPoint')); - graphics = gobjects(0); +function drawPointLabels(ax, points) + delete(findobj(ax, 'Tag', 'dicControlPointLabel')); for iPoint = 1:size(points, 1) - graphics(end + 1, 1) = drawPoint(ax, points(iPoint, :), ... - iPoint, side, iPoint, [0 0.85 1]); + text(ax, points(iPoint, 1) + 4, points(iPoint, 2), string(iPoint), ... + 'Color', [0 0.85 1], 'FontWeight', 'bold', ... + 'HitTest', 'off', 'PickableParts', 'none', ... + 'Tag', 'dicControlPointLabel'); end end -function graphic = drawPoint(ax, point, labelIndex, side, pointIndex, color) - graphic = line(ax, point(1), point(2), ... - 'LineStyle', 'none', 'Marker', '+', 'MarkerSize', 12, ... - 'LineWidth', 2, 'Color', color, 'Tag', 'dicControlPoint', ... - 'UserData', struct('side', side, 'pointIndex', pointIndex)); - text(ax, point(1) + 4, point(2), string(labelIndex), ... - 'Color', color, 'FontWeight', 'bold', ... - 'HitTest', 'off', 'PickableParts', 'none', ... - 'Tag', 'dicControlPoint'); -end - function value = enabledText(condition) value = 'off'; if condition diff --git a/apps/dic/dic_preprocess/+dic_preprocess/version.m b/apps/dic/dic_preprocess/+dic_preprocess/version.m index 24715970..fc50a2c2 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/version.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/version.m @@ -8,6 +8,6 @@ "name", "labkit_DICPreprocess_app", ... "displayName", "DIC Preprocess", ... "family", "DIC", ... - "version", "1.3.7", ... + "version", "1.4.0", ... "updated", "2026-07-13"); end diff --git a/docs/ui.md b/docs/ui.md index 08a0d959..9402a44c 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -464,7 +464,19 @@ when a tool has stricter data limits. Generic plots zoom both axes by default; time-labeled x-axes zoom only the horizontal axis unless `"ZoomAxes"` is provided explicitly. -Use `labkit.ui.interaction.anchorEditor(runtime, imageSize, opts)` for generic anchor editing. Use `labkit.ui.interaction.rectangleEditor(runtime, imageSize, position, opts)` for a toolbox-free draggable and resizable rectangular overlay. Use `labkit.ui.interaction.scaleBar(parent, row, runtime, opts)` for calibration controls, reference-pixel editing, unit normalization, final scale-bar placement, and overlay drawing. Apps can persist `tool.calibration()` per image and restore it with `tool.setCalibration(cal)`. Apps still own image loading, redraw order, scientific calculations, result summaries, alerts, logs, and exports. +Use `labkit.ui.interaction.anchorEditor(runtime, imageSize, opts)` for generic +anchor editing. Its default `mode="curve"` uses double-click insertion and a +visible path for boundaries and traced curves. `mode="points"` uses the +single-click placement and drag-to-refine behavior suited to ordered feature +points and ROI centers, without drawing a connecting path or assigning +double-click deletion. Use `labkit.ui.interaction.rectangleEditor(runtime, +imageSize, position, opts)` for a toolbox-free draggable and resizable +rectangular overlay. Use `labkit.ui.interaction.scaleBar(parent, row, runtime, +opts)` for calibration controls, reference-pixel editing, unit normalization, +final scale-bar placement, and overlay drawing. Apps can persist +`tool.calibration()` per image and restore it with `tool.setCalibration(cal)`. +Apps still own image loading, redraw order, scientific calculations, result +summaries, alerts, logs, and exports. `labkit.ui.interaction.scaleBarCalibration(referencePixels, referenceLength, unitName, opts)` is the GUI-free calibration struct helper used by apps and app-private calculations. diff --git a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m index ee64007c..40b1f5c5 100644 --- a/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m +++ b/tests/cases/gui/labkit_framework/ui/GuiLayoutUiAnchorCurveEditorTest.m @@ -67,6 +67,13 @@ function verify_gui_layout_ui_anchor_curve_editor() points = openEditor.getPoints(); assert(isequal(points(2, :), [25 20]), ... 'Open anchor editor should insert points that are close to an existing segment.'); + pointEditor = labkit.ui.interaction.anchorEditor(runtime, [40 60 3], ... + struct('mode', 'points', 'installScrollWheel', false)); + pointEditor.start([10 10; 40 30]); + pointEditor.insertPoint([25 20]); + points = pointEditor.getPoints(); + assert(isequal(points(end, :), [25 20]) && isempty(pointEditor.curvePoints()), ... + 'Point-mode anchors should append discrete points without a connecting path.'); spiralEditor = labkit.ui.interaction.anchorEditor(runtime, [60 70 3], ... struct('closed', false, 'style', 'Straight lines')); spiralEditor.start([20 20; 55 20; 55 55; 35 55; 35 35; 48 35]); From 2c9b879204e2066b82621ef0d86b1ca8060bbaa6 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 14:56:13 -0500 Subject: [PATCH 09/15] test: preserve launcher validation ownership --- tests/runner/labkitValidationPlanForChangedPaths.m | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/runner/labkitValidationPlanForChangedPaths.m b/tests/runner/labkitValidationPlanForChangedPaths.m index 6230eb58..8385b6eb 100644 --- a/tests/runner/labkitValidationPlanForChangedPaths.m +++ b/tests/runner/labkitValidationPlanForChangedPaths.m @@ -258,7 +258,10 @@ filename = lower(parts(end)); end consumers = sharedHelperConsumers(root, filename); - if ~isempty(consumers.guiTests) || ~isempty(consumers.nonGuiTests) + if contains(filename, "launcher") + steps = planStep("gui_project_launcher", "gui/project/launcher", true, ... + "Reason", "shared launcher test helper changed"); + elseif ~isempty(consumers.guiTests) || ~isempty(consumers.nonGuiTests) steps = emptyPlanSteps(); if ~isempty(consumers.nonGuiTests) steps(end + 1) = planStep("shared_consumers", strings(1, 0), false, ... @@ -270,9 +273,6 @@ "Tests", consumers.guiTests, ... "Reason", "shared test helper change reruns direct GUI consumers"); end - elseif contains(filename, "launcher") - steps = planStep("gui_project_launcher", "gui/project/launcher", true, ... - "Reason", "shared launcher test helper changed"); elseif contains(filename, "gui") || contains(filename, "uispec") || ... contains(filename, "snapshot") steps = planStep("gui", "gui", true, ... From 26daafbeaa28452a0d5d8db493ef8449ddd981d6 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 14:56:19 -0500 Subject: [PATCH 10/15] test: enforce mainline version baselines --- docs/release.md | 9 +++ .../release/VersionChangeGuardrailTest.m | 69 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/release.md b/docs/release.md index 3144bd6f..cc499813 100644 --- a/docs/release.md +++ b/docs/release.md @@ -13,6 +13,15 @@ compatibility, choose a major version. If the implementation changes a user workflow without intentionally breaking compatibility, describe it in the release notes so launcher users can choose or roll back versions deliberately. +On a development branch, choose each component's final version directly from +the merge base with `origin/main`, not from intermediate branch commits. A +branch may edit version metadata while work is evolving, but its merge-ready +state must be exactly one semantic-version step from the mainline baseline: +the next patch, the next minor with patch zero, or the next major with minor +and patch zero. `CHANGELOG.md` affected-version lines must record that direct +`main baseline -> branch final` transition. This prevents temporary branch +versions from accumulating into artificial public version jumps. + ## Tags Use `vX.Y.Z` for new release tags, for example `v2.2.0`. diff --git a/tests/cases/contract/project/release/VersionChangeGuardrailTest.m b/tests/cases/contract/project/release/VersionChangeGuardrailTest.m index 170a940e..84630b1b 100644 --- a/tests/cases/contract/project/release/VersionChangeGuardrailTest.m +++ b/tests/cases/contract/project/release/VersionChangeGuardrailTest.m @@ -55,6 +55,16 @@ function mainAndFinalBranchChecksRequireVersionBumps(testCase) testCase.verifyTrue(shouldEnforceVersionBumps(strictChangeSet), ... "Final branch cleanup can opt into aggregate version checks before squash or handoff."); end + + function finalVersionsUseOneStepFromMainlineBaseline(testCase) + testCase.verifyTrue(isSingleSemverStep("1.2.8", "1.2.9")); + testCase.verifyTrue(isSingleSemverStep("1.2.8", "1.3.0")); + testCase.verifyTrue(isSingleSemverStep("1.2.8", "2.0.0")); + testCase.verifyFalse(isSingleSemverStep("1.2.8", "1.2.10")); + testCase.verifyFalse(isSingleSemverStep("1.2.8", "1.3.1")); + testCase.verifyFalse(isSingleSemverStep("1.2.8", "1.4.0")); + testCase.verifyFalse(isSingleSemverStep("1.2.8", "2.1.0")); + end end end @@ -87,6 +97,7 @@ function mainAndFinalBranchChecksRequireVersionBumps(testCase) for k = 1:numel(artifacts) artifact = artifacts(k); currentVersion = versionInWorkingTree(root, artifact.versionPath); + baseVersion = versionInGit(root, changeSet.baseRef, artifact.versionPath); if strlength(currentVersion) == 0 continue; end @@ -96,6 +107,16 @@ function mainAndFinalBranchChecksRequireVersionBumps(testCase) issues(end+1) = artifact.label + " " + currentVersion + ... " missing lookup row"; end + if strlength(baseVersion) > 0 + component = changelogComponentName(root, artifact); + transition = "`" + baseVersion + " -> " + currentVersion + "`"; + hasTransition = any(contains(changelogLines, "`" + component + "`") & ... + contains(changelogLines, transition)); + if ~hasTransition + issues(end+1) = component + " missing direct baseline transition " + ... + baseVersion + " -> " + currentVersion; + end + end end issues = unique(issues, "stable"); end @@ -129,6 +150,10 @@ function mainAndFinalBranchChecksRequireVersionBumps(testCase) elseif strlength(baseVersion) > 0 && compareSemver(currentVersion, baseVersion) <= 0 issues(end+1) = artifact.label + " " + currentVersion + ... " must be greater than " + baseVersion; + elseif strlength(baseVersion) > 0 && ... + ~isSingleSemverStep(baseVersion, currentVersion) + issues(end+1) = artifact.label + " " + currentVersion + ... + " must be one semver step from baseline " + baseVersion; end end issues = unique(issues, "stable"); @@ -147,10 +172,20 @@ function mainAndFinalBranchChecksRequireVersionBumps(testCase) end changeSet.branchName = gitCurrentBranch(root); - paths = [gitChangedPaths(root, "HEAD"), gitUntrackedPaths(root)]; - if isempty(paths) && gitRefExists(root, "HEAD^") + dirtyPaths = [gitChangedPaths(root, "HEAD"), gitUntrackedPaths(root)]; + if shouldEnforceVersionBumps(changeSet) && ... + changeSet.branchName ~= "main" && gitRefExists(root, "origin/main") changeSet.baseRef = versionBaselineRef(root, changeSet); paths = gitChangedPaths(root, changeSet.baseRef); + paths = [paths, gitUntrackedPaths(root)]; + elseif ~isempty(dirtyPaths) + changeSet.baseRef = "HEAD"; + paths = dirtyPaths; + elseif gitRefExists(root, "HEAD^") + changeSet.baseRef = versionBaselineRef(root, changeSet); + paths = gitChangedPaths(root, changeSet.baseRef); + else + paths = strings(1, 0); end changeSet.paths = unique(paths, "stable"); end @@ -251,6 +286,22 @@ function mainAndFinalBranchChecksRequireVersionBumps(testCase) version = versionInText(string(fileread(filepath))); end +function component = changelogComponentName(root, artifact) + if startsWith(artifact.label, "labkit.") || artifact.label == "labkit_launcher" + component = artifact.label; + return; + end + parts = [{char(root)}; cellstr(split(artifact.versionPath, "/"))]; + source = string(fileread(fullfile(parts{:}))); + name = regexp(source, ... + '["'']name["'']\s*,\s*["'']([^"'']+)["'']', "tokens", "once"); + if isempty(name) + component = artifact.label; + else + component = string(name{1}); + end +end + function version = versionInGit(root, ref, relPath) command = gitCommand(root, "show " + ... shellDoubleQuote(validateGitRef(ref) + ":" + normalizePath(relPath))); @@ -296,6 +347,20 @@ function mainAndFinalBranchChecksRequireVersionBumps(testCase) end end +function tf = isSingleSemverStep(baseVersion, currentVersion) + if ~isSemver(baseVersion) || ~isSemver(currentVersion) + tf = false; + return; + end + base = sscanf(char(baseVersion), '%d.%d.%d').'; + current = sscanf(char(currentVersion), '%d.%d.%d').'; + nextPatch = [base(1) base(2) base(3) + 1]; + nextMinor = [base(1) base(2) + 1 0]; + nextMajor = [base(1) + 1 0 0]; + tf = isequal(current, nextPatch) || isequal(current, nextMinor) || ... + isequal(current, nextMajor); +end + function tf = isGitCheckout(root) command = gitCommand(root, "rev-parse --is-inside-work-tree"); [status, output] = system(char(command)); From e93913226cedc967ba522a0f22821b0797c853cb Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 15:01:31 -0500 Subject: [PATCH 11/15] docs: establish structured change history --- AGENTS.md | 15 +- CHANGELOG.md | 2371 ++++++++++++++--- docs/release.md | 47 +- tests/AGENTS.md | 9 +- .../project/release/ChangelogGuardrailTest.m | 46 +- tools/release/parseLabKitChangelog.m | 225 ++ 6 files changed, 2283 insertions(+), 430 deletions(-) create mode 100644 tools/release/parseLabKitChangelog.m diff --git a/AGENTS.md b/AGENTS.md index 08840e67..8406de39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -299,13 +299,14 @@ the same change with the affected versions, what changed, why it matters, compatibility notes when relevant, optional direction notes, and evidence. Organize entries by coherent user-facing or maintainer-facing evolution, not by raw tag rows, commit rows, or issue lists. Release tags are public anchors -inside affected versions and evidence; commits are evidence. For branch or PR -work before the final mainline SHA is known, stage the entry under -`Unreleased`; for direct-main work with a decided version or any finalized -entry, write it directly under `Version History`. During release preparation or -changelog audit, move finalized entries out of `Unreleased`, remove stale -pending drafts, and include the mainline commit SHA when it is known. Do not -reduce changelog entries to raw commit-log dumps. +inside affected versions and evidence; commits are evidence. Every entry uses +the schema-v1 `labkit-change` metadata block and required narrative sections +under `Structured Change Records`. Use one stable Change ID on branches and +main; do not add delivery-status sections such as Unreleased or Pending. +Branch evidence may name checkpoint commits or a PR, then a later audit may +add the mainline SHA without moving the record. Validate the file with +`tools/release/parseLabKitChangelog.m`. Do not reduce entries to raw commit-log +dumps. For new releases, use `vX.Y.Z` tags, for example `v2.2.0`. Do not rename or delete already published historical tags only to normalize naming; preserve diff --git a/CHANGELOG.md b/CHANGELOG.md index 1604d56f..7ba0278f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,11 @@ Commits and PRs belong in `Evidence`, not in the navigation structure. - Start with `Current Version Lookup` when you only need to identify the version and metadata file for a launcher, facade, or app. -- Use `Unreleased` for branch, pull-request, and release-preparation work - before the final mainline commit or tag evidence is known. Move finalized - direct-main work into `Version History`. -- Use `Version History` as the main reading path for project evolution. Entries - are ordered by date and grouped by reader-facing theme: a release-line entry - summarizes a public tag, while a feature or maintenance entry summarizes one - coherent improvement direction. -- Read `Affected versions` as the index from an evolution entry to release - tags, launcher versions, facade versions, and app versions. +- Search `Structured Change Records` by Change ID, component, date, or title + when investigating why behavior exists. Branch records use the same schema + as mainline records; their location in Git already supplies delivery state. +- Read each record's metadata block for machine routing, then its narrative for + context, rationale, impact, migration, validation, evidence, and limitations. - Record meaningful behavior, compatibility, workflow, validation, diagnostics, and public facade changes. Do not dump raw git logs. @@ -30,146 +26,461 @@ Commits and PRs belong in `Evidence`, not in the navigation structure. - `Current Version Lookup` is a state table. Keep it current with metadata files, but do not use it to explain history. -- `Unreleased` is a staging area. Use it for pending evolution entries until - the final mainline commit and, when applicable, release tag are known; do not - leave version-finalized entries there. -- `Version History` is the narrative timeline. Prefer one entry per coherent - user-facing or maintainer-facing evolution: a release, a facade migration, an - app workflow improvement, a validation/release-system improvement, or a - compatibility change. -- Release entries may roll up several related improvements when the tag is the - useful reader anchor. Non-release entries should be based on the capability, - workflow, or maintenance direction they explain, even when they also list - version bumps. -- Every entry should answer four questions: what changed, why it mattered, what - compatibility or upgrade risk exists, and what evidence proves the entry. -- Optional direction or follow-up notes are allowed when they help future - maintainers and agents understand where the project is moving. Keep them - short and concrete. - -## Unreleased - -### Pending - Consistent electrochemistry batch analysis - -Affected versions: -- `labkit_CIC_app` `1.3.7 -> 1.3.8` -- `labkit_VTResistance_app` `1.3.7 -> 1.3.8` - -What changed: -- CIC and VT Resistance now recompute every loaded file under one shared set - of analysis controls, including a final recomputation before CSV export. -- CIC labels delay values in microseconds, rejects sampling outside the - recorded time range instead of extrapolating, and exports the area and delay - used for each result. -- Added family-level regression coverage for whole-batch recomputation; CSC, - EIS, and Chrono Overlay remain safe because they calculate from current - settings at export or do not cache derived batch analysis. - -Why it matters: -- Batch CSV rows can no longer silently mix stale and current area, delay, - pulse-detection, resistance-window, or voltage-mode settings. - -Compatibility: -- CIC CSV adds trailing `Area_cm2` and `Delay_us` columns. Existing columns - retain their names and order. - -Evidence: -- Current development branch; final mainline commit pending. - -### Pending - MATLAB-compatible image conversion API - -Affected versions: -- `labkit.image` `1.2.0 -> 2.0.0` -- `labkit_DICPreprocess_app` `1.3.6 -> 1.3.7` -- `labkit_DICPostprocess_app` `1.3.5 -> 1.3.6` -- `labkit_BatchImageCrop_app` `1.6.7 -> 1.6.8` -- `labkit_CurvatureMeasurement_app` `1.3.4 -> 1.3.5` -- `labkit_FLIRThermal_app` `1.2.8 -> 1.2.9` -- `labkit_FocusStack_app` `1.4.8 -> 1.4.9` -- `labkit_ImageEnhance_app` `1.5.7 -> 1.5.8` -- `labkit_ImageMatch_app` `1.5.7 -> 1.5.8` - -What changed: -- Replaced the LabKit-specific `toDouble`, `toLuma`, and `toRgbDouble` surface - with MATLAB-contract-compatible `labkit.image.im2double` and - `labkit.image.rgb2gray` functions plus the shape-only `ensureRgb` helper. -- Image pipelines now state class conversion, RGB shaping, and clipping as - separate operations, and Rec.601 coefficients have one documented owner. -- DIC now declares its existing dependency on the image facade. - -Why it matters: -- Base-MATLAB users can apply familiar MATLAB image conversion contracts - without learning a composite LabKit normalization API or accepting hidden - channel and value changes. - -Compatibility: -- This is a major image-facade change. Callers must replace the removed helper - names with the explicit compatible conversion and shaping operations. - -Evidence: -- Current development branch; final mainline commit pending. - -Template for branch work before the final mainline commit is known: - -```markdown -### Pending - Short user-facing title - -Affected versions: -- `component` `old -> new` - -What changed: -- Plain-language change summary. - -Why it matters: -- User or maintainer reason this version exists. - -Compatibility: -- Migration or rollback note, or `No known manual migration.` - -Direction: -- Optional short note about the improvement direction or follow-up boundary. - -Evidence: -- PR, branch, or pending commit. +- A structured record has one stable Change ID and one ISO date. It may list + versioned `component` transitions, unversioned repository `scope` values, or + both. Component transitions always compare directly with the mainline + baseline rather than another commit on the development branch. +- `type` uses the repository's Conventional Commit vocabulary. + `compatibility` is `compatible`, `additive`, or `breaking`. +- Narrative sections are required because metadata alone cannot explain the + scientific, workflow, or maintenance decision that Git history obscures. +- `tools/release/parseLabKitChangelog.m` parses schema-v1 records and rejects + duplicate IDs, malformed dates or versions, unknown metadata, missing + narrative sections, and records out of reverse chronological order. +- A branch does not need a separate pending state. The same record is useful + before and after merge; evidence can name tests, a PR, a release tag, source + anchors, or the stable Change ID used to locate the carrying commit. + +## Structured Change Records + +### Structured evolution records + +```labkit-change +schema: 1 +id: LK-20260713-structured-evolution-records +date: 2026-07-13 +type: docs +compatibility: additive +scope: repository changelog and release governance ``` -## Current Version Lookup +#### Context -Audited against `main` metadata on 2026-07-13. +The previous `Unreleased` and `Pending` model mixed delivery state with +historical meaning and required records to be moved after merge. Its prose was +useful to humans but had no reliable machine contract. -| Component | Current version | Family | Metadata location | -|---|---:|---|---| -| `labkit_launcher` | `1.3.0` | Launcher | `labkit_launcher.m` | -| `labkit.ui` | `5.0.4` | Facade | `+labkit/+ui/version.m` | -| `labkit.dta` | `2.0.0` | Facade | `+labkit/+dta/version.m` | -| `labkit.image` | `2.0.0` | Facade | `+labkit/+image/version.m` | -| `labkit.thermal` | `1.0.0` | Facade | `+labkit/+thermal/version.m` | -| `labkit.rhs` | `1.0.0` | Facade | `+labkit/+rhs/version.m` | -| `labkit.biosignal` | `1.0.0` | Facade | `+labkit/+biosignal/version.m` | -| `labkit_FigureStudio_app` | `0.1.5` | LabKit Core | `apps/labkit_core/figure_studio/+figure_studio/version.m` | -| `labkit_ChronoOverlay_app` | `1.3.5` | Electrochem | `apps/electrochem/chrono_overlay/+chrono_overlay/version.m` | -| `labkit_CIC_app` | `1.3.8` | Electrochem | `apps/electrochem/cic/+cic/version.m` | -| `labkit_CSC_app` | `1.3.9` | Electrochem | `apps/electrochem/csc/+csc/version.m` | -| `labkit_EIS_app` | `1.3.4` | Electrochem | `apps/electrochem/eis/+eis/version.m` | -| `labkit_VTResistance_app` | `1.3.8` | Electrochem | `apps/electrochem/vt_resistance/+vt_resistance/version.m` | -| `labkit_DICPreprocess_app` | `1.3.7` | DIC | `apps/dic/dic_preprocess/+dic_preprocess/version.m` | -| `labkit_DICPostprocess_app` | `1.3.6` | DIC | `apps/dic/dic_postprocess/+dic_postprocess/version.m` | -| `labkit_BatchImageCrop_app` | `1.6.8` | Image Measurement | `apps/image_measurement/batch_crop/+batch_crop/version.m` | -| `labkit_CurvatureMeasurement_app` | `1.3.5` | Image Measurement | `apps/image_measurement/curvature/+curvature/version.m` | -| `labkit_FLIRThermal_app` | `1.2.9` | Image Measurement | `apps/image_measurement/flir_thermal/+flir_thermal/version.m` | -| `labkit_FocusStack_app` | `1.4.9` | Image Measurement | `apps/image_measurement/focus_stack/+focus_stack/version.m` | -| `labkit_ImageEnhance_app` | `1.5.8` | Image Measurement | `apps/image_measurement/image_enhance/+image_enhance/version.m` | -| `labkit_ImageMatch_app` | `1.5.8` | Image Measurement | `apps/image_measurement/image_match/+image_match/version.m` | -| `labkit_RHSPreview_app` | `1.3.4` | Neurophysiology | `apps/neurophysiology/rhs_preview/+rhs_preview/version.m` | -| `labkit_NerveResponseAnalysis_app` | `1.3.4` | Neurophysiology | `apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/version.m` | -| `labkit_ResponseReviewStats_app` | `1.3.4` | Neurophysiology | `apps/neurophysiology/response_review_stats/+response_review_stats/version.m` | -| `labkit_ECGPrint_app` | `1.3.5` | Wearable | `apps/wearable/ecg_print/+ecg_print/version.m` | +#### Decision and rationale + +Use stable, status-free records with a small parseable metadata header and +required explanatory sections. Git branches, PRs, tags, and evidence identify +delivery state without duplicating it in the changelog. + +#### Changes + +- Added schema-v1 metadata for Change ID, date, type, compatibility, component + version transitions, and unversioned repository scopes. +- Added a base-MATLAB parser and release guardrail for schema integrity. +- Normalized every historical entry into the same schema-v1 record contract. + +#### User and data impact + +Users and maintainers can search one durable ID and obtain the reason, impact, +compatibility, validation, and evidence for a change without reconstructing it +from commit messages. No scientific data or runtime behavior changes. + +#### Compatibility and migration + +Existing links to `CHANGELOG.md` remain valid. Automation that searched the old +`Unreleased` or `Version History` headings must use the parser and stable +Change IDs. + +#### Validation + +`ChangelogGuardrailTest` parses every schema-v1 record and checks current +version lookup coverage and release-policy documentation. + +#### Evidence + +The parser lives at `tools/release/parseLabKitChangelog.m`; the normalized +historical baseline was checked against the dated mainline log and release tags. + +#### Known limitations and follow-up + +Some older records do not name exact test commands because that evidence was +not recorded at the time; they state that limitation instead of inventing it. + +### Single-click DIC rigid point matching + +```labkit-change +schema: 1 +id: LK-20260713-dic-rigid-point-editor +date: 2026-07-13 +type: feat +compatibility: additive +component: `labkit.ui` | `5.0.4 -> 5.1.0` +component: `labkit_DICPreprocess_app` | `1.3.6 -> 1.4.0` +``` + +#### Context + +DIC manual rigid matching had draggable points but maintained a separate +pointer implementation and required a less consistent placement workflow than +the ROI-center anchors used by Imager Reconstruction. + +#### Decision and rationale + +Extend the existing app-neutral anchor editor with a discrete point mode, then +keep moving/fixed pair order, numbering, minimum pair count, and rigid-fit +policy inside DIC. + +#### Changes + +- Added `mode="points"`: one blank click appends a point, dragging refines it, + no connecting curve is drawn, and deletion remains under explicit controls. +- Migrated the DIC modal to two shared point-mode editors while preserving + ordered moving/fixed pairs, labels, undo, cancel, and acceptance rules. +- Retained toolbox-free image display and rigid alignment behavior. + +#### User and data impact + +Feature placement now follows the same direct click-and-drag model as Imager +ROI anchors. Point coordinates and the resulting rigid transform keep their +existing N-by-2 pixel-coordinate contract. + +#### Compatibility and migration + +The default anchor-editor curve mode is unchanged. DIC exports and transform +math are unchanged; this is an additive interaction improvement. + +#### Validation + +UI anchor-editor tests cover discrete point append and no-path behavior. The +DIC GUI workflow covers toolbox-free modal cancellation and app launch wiring. + +#### Evidence + +Primary sources are `labkit.ui.interaction.anchorEditor` and +`dic_preprocess.userInterface.selectRigidPointPairs`; branch checkpoint +`392a073e` carries the implementation before the final squash merge. + +#### Known limitations and follow-up + +Automated hidden-GUI tests cannot judge pointing ergonomics; final interaction +feel still requires a short manual placement-and-drag check. + +### MATLAB-compatible image conversion API + +```labkit-change +schema: 1 +id: LK-20260713-matlab-compatible-image-conversion +date: 2026-07-13 +type: refactor +compatibility: breaking +component: `labkit.image` | `1.2.0 -> 2.0.0` +component: `labkit_DICPostprocess_app` | `1.3.5 -> 1.3.6` +component: `labkit_BatchImageCrop_app` | `1.6.7 -> 1.6.8` +component: `labkit_CurvatureMeasurement_app` | `1.3.4 -> 1.3.5` +component: `labkit_FocusStack_app` | `1.4.8 -> 1.4.9` +component: `labkit_ImageEnhance_app` | `1.5.7 -> 1.5.8` +component: `labkit_ImageMatch_app` | `1.5.7 -> 1.5.8` +``` + +#### Context + +The `toDouble`, `toLuma`, and `toRgbDouble` names combined class conversion, +channel shaping, and clipping in ways that differed from familiar MATLAB APIs. + +#### Decision and rationale + +Use MATLAB-compatible names and call contracts for replacement functions, and +keep orthogonal RGB shaping explicit so users do not need to learn a composite +LabKit normalization rule. + +#### Changes + +- Added base-MATLAB `labkit.image.im2double` and `labkit.image.rgb2gray`. +- Kept channel shaping in `ensureRgb` and made clipping explicit at call sites. +- Removed the ambiguous conversion helpers and centralized Rec.601 ownership. + +#### User and data impact + +Base-MATLAB users receive familiar image conversion behavior without hidden +toolbox requirements. Existing app image results retain their intended ranges +and channel shapes through explicit pipelines. + +#### Compatibility and migration + +This is a breaking facade rename. External callers replace removed helper names +with `im2double`, `rgb2gray`, and `ensureRgb` as separately needed. + +#### Validation + +Image facade, downstream app, toolbox-shadow, and base-MATLAB ownership tests +cover class conversion, luma values, and representative workflows. + +#### Evidence + +The API contracts are documented in `docs/image.md`; branch checkpoint +`e3f71c2d` carries the migration before the final squash merge. + +#### Known limitations and follow-up + +The compatibility layer intentionally covers the LabKit-used MATLAB contracts, +not every Image Processing Toolbox function. + +### Traceable FLIR temperature calibration + +```labkit-change +schema: 1 +id: LK-20260713-flir-calibration-provenance +date: 2026-07-13 +type: feat +compatibility: additive +component: `labkit.thermal` | `1.0.0 -> 1.1.0` +component: `labkit_FLIRThermal_app` | `1.2.8 -> 1.3.0` +``` + +#### Context + +A successful Celsius conversion did not reveal whether emissivity and +environmental values came from the file or from model defaults, which could +make an absolute temperature appear more certain than its metadata justified. + +#### Decision and rationale + +Preserve conversion provenance with the thermal record and show fallback use +in the app, rather than silently treating every parameter as measured. + +#### Changes + +- Added optional conversion diagnostics with correction mode, defaulted fields, + parameter sources, and fallback status. +- Stored diagnostics in thermal record metadata and surfaced warnings in FLIR + file status and details. +- Documented embedded calibration requirements and environmental fallbacks. + +#### User and data impact + +Users can distinguish radiometric values based entirely on embedded metadata +from values affected by fallback assumptions before interpreting temperatures. + +#### Compatibility and migration + +Existing one-output conversion calls remain valid. The diagnostics output and +record metadata are additive; app workflows need no migration. -## Version History +#### Validation -### 2026-07-13 - Base-MATLAB image compatibility +Thermal facade and FLIR app tests cover metadata sources, fallback reporting, +and one-output compatibility. + +#### Evidence + +The model and provenance contract are documented in `docs/thermal.md`; branch +checkpoint `7391e293` carries the implementation before the final squash merge. + +#### Known limitations and follow-up + +Diagnostics describe source and fallback use; they do not estimate a physical +uncertainty interval for a particular camera, surface, or environment. + +### Consistent electrochemistry batch analysis + +```labkit-change +schema: 1 +id: LK-20260713-electrochem-batch-consistency +date: 2026-07-13 +type: fix +compatibility: additive +component: `labkit_CIC_app` | `1.3.7 -> 1.3.8` +component: `labkit_VTResistance_app` | `1.3.7 -> 1.3.8` +``` + +#### Context + +CIC and VT Resistance could retain per-file derived values from different +control settings, and CIC could sample outside the recorded time range while +displaying a delay without units. + +#### Decision and rationale + +Treat analysis controls as one batch contract and recompute every file before +display/export so rows cannot silently mix stale and current parameters. + +#### Changes + +- Recompute all loaded files when shared controls change and once more before + CSV export. +- Label CIC delay in microseconds, reject out-of-range sampling, and export the + area and delay used for each result. +- Added family regression coverage and audited sibling electrochem apps. + +#### User and data impact + +Batch rows now represent one consistent area, delay, pulse-detection, +resistance-window, and voltage-mode configuration. Invalid delay choices fail +instead of extrapolating a misleading value. + +#### Compatibility and migration + +CIC CSV adds trailing `Area_cm2` and `Delay_us` columns. Existing columns keep +their names and order; VT Resistance exports remain schema-compatible. + +#### Validation + +Electrochem unit and GUI tests cover batch recomputation, display units, +out-of-range handling, and export-time refresh. + +#### Evidence + +The guarded calculations and export builders are app-owned; branch checkpoint +`67ea2286` carries the fix before the final squash merge. + +#### Known limitations and follow-up + +The fix enforces internal batch consistency but does not choose scientifically +appropriate area, delay, or resistance windows for the user. + +### Managed scientific and conversion constants + +```labkit-change +schema: 1 +id: LK-20260713-managed-calculation-constants +date: 2026-07-13 +type: refactor +compatibility: compatible +component: `labkit.dta` | `2.0.0 -> 2.0.1` +component: `labkit.rhs` | `1.0.0 -> 1.0.1` +component: `labkit.biosignal` | `1.0.0 -> 1.0.1` +component: `labkit_ChronoOverlay_app` | `1.3.5 -> 1.3.6` +component: `labkit_CSC_app` | `1.3.9 -> 1.3.10` +component: `labkit_NerveResponseAnalysis_app` | `1.3.4 -> 1.3.5` +component: `labkit_ResponseReviewStats_app` | `1.3.4 -> 1.3.5` +``` + +#### Context + +Scientific coefficients, device-format gains, unit conversions, and numeric +tolerances were sometimes left as unexplained literals or repeated across +callers, making origin and change impact difficult to audit. + +#### Decision and rationale + +Give each semantic calculation constant one named owner and a nearby source or +purpose comment, while exempting ordinary indices and explicit UI geometry +that do not encode scientific meaning. + +#### Changes + +- Named and documented Rec.601, sRGB/CIE, DTA/RHS gains, SI conversions, + tolerances, and empirical policies across facades and apps. +- Centralized repeated CSC charge-density, CIC display-unit, and curvature + tolerance contracts. +- Added repository-wide magic-number and rectangle-interaction guardrails. + +#### User and data impact + +Calculation results are preserved, but future changes now expose the constant's +meaning and provenance instead of silently editing an unexplained literal. + +#### Compatibility and migration + +No user migration is required. Public function call contracts and output +schemas are unchanged by this record. + +#### Validation + +`MagicNumberGovernanceTest`, rectangle governance, facade tests, and affected +app tests cover centralized ownership and preserved numeric behavior. + +#### Evidence + +Source comments use the `Constant:` marker and guardrail diagnostics name the +unmanaged file and line. Branch checkpoint `125338c0` carries the governance +work before the final squash merge. + +#### Known limitations and follow-up + +The scanner targets semantically suspicious precision and notation; review is +still required for simple integers or short decimals whose meaning is hidden. + +### Traceable and base-MATLAB CI validation + +```labkit-change +schema: 1 +id: LK-20260713-traceable-base-matlab-ci +date: 2026-07-13 +type: ci +compatibility: compatible +scope: GitHub Actions and MATLAB validation routing +``` + +#### Context + +CI failures and stalls could end without enough information to identify the +active test, and ordinary success on a toolbox-rich development machine did +not prove that base-MATLAB users could run representative workflows. + +#### Decision and rationale + +Make the existing official runner publish progress and active-test state, and +add a distinct compatibility gate that combines static calls, product +ownership, and toolbox-shadowed behavior. + +#### Changes + +- Added per-test progress, heartbeat, active-test, timeout-summary, and artifact + publication behavior to CI. +- Added `buildtool baseMatlab` and representative toolbox-shadow workflows. +- Improved changed-file routing to target direct consumers while retaining + explicit owners such as the launcher GUI suite. + +#### User and data impact + +Base-MATLAB compatibility is now an explicit supported path, and failed or +stalled CI runs provide enough state to identify the last active test. Runtime +scientific outputs are not changed by this record. + +#### Compatibility and migration + +Existing build tasks remain available. Maintainers can add `baseMatlab` to +local validation without installing or uninstalling toolboxes. + +#### Validation + +CI policy, build-task framework, changed-routing, toolbox dependency, and +representative workflow tests guard the new behavior. + +#### Evidence + +The command matrix is in `docs/testing.md`; CI uploads official runner logs and +active-test artifacts. Branch checkpoints `28ff8edb`, `37bd7fd5`, and +`2c9b8792` carry the implementation before the final squash merge. + +#### Known limitations and follow-up + +Shadow tests cover known dependency risks and cannot simulate every licensed +toolbox combination; MATLAB product-ownership analysis remains the broad check. + +### Base-MATLAB image compatibility + +```labkit-change +schema: 1 +id: LK-20260713-base-matlab-image-compatibility +date: 2026-07-13 +type: feat +compatibility: compatible +component: `labkit.image` | `1.1.0 -> 1.2.0` +component: `labkit_DICPreprocess_app` | `1.3.5 -> 1.3.6` +component: `labkit_DICPostprocess_app` | `1.3.4 -> 1.3.5` +component: `labkit_FocusStack_app` | `1.4.7 -> 1.4.8` +component: `labkit_ImageEnhance_app` | `1.5.6 -> 1.5.7` +component: `labkit_ImageMatch_app` | `1.5.6 -> 1.5.7` +``` + +#### Context + +- CI now protects the base-MATLAB user path instead of passing only on + machines that happen to have Image Processing Toolbox installed. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.image` `1.1.0 -> 1.2.0` - `labkit_DICPreprocess_app` `1.3.5 -> 1.3.6` - `labkit_DICPostprocess_app` `1.3.4 -> 1.3.5` @@ -177,7 +488,6 @@ Affected versions: - `labkit_ImageEnhance_app` `1.5.6 -> 1.5.7` - `labkit_ImageMatch_app` `1.5.6 -> 1.5.7` -What changed: - Added `labkit.image.toDouble` and `labkit.image.toLuma`, and replaced hard Image Processing Toolbox calls in shared image facade code and image-app workflow paths with base-MATLAB implementations. @@ -191,48 +501,115 @@ What changed: helper calls under `apps/` and `+labkit/`, while still allowing explicit optional toolbox paths with fallbacks. -Why it matters: +#### User and data impact + - CI now protects the base-MATLAB user path instead of passing only on machines that happen to have Image Processing Toolbox installed. -Compatibility: +#### Compatibility and migration + - Existing app workflows and exported schemas are preserved. Optional toolbox acceleration paths remain allowed only when a base-MATLAB fallback is present. -Evidence: -- Mainline commit recorded by this change. +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + +- Mainline commit `bcd5f51f`. + +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Preview-area per-axis wheel zoom + +```labkit-change +schema: 1 +id: LK-20260709-preview-area-per-axis-wheel-zoom +date: 2026-07-09 +type: feat +compatibility: compatible +component: `labkit.ui` | `5.0.3 -> 5.0.4` +``` + +#### Context + +- App-owned side panels such as color scales and histograms can remain compact + and stable without disabling useful wheel interaction. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. -### 2026-07-09 - Preview-area per-axis wheel zoom +#### Changes -Affected versions: - `labkit.ui` `5.0.3 -> 5.0.4` -What changed: - Added a `scrollZoomAxes` preview-area layout option so apps can declare whether each preview axis should mouse-wheel zoom in `xy`, `x`, or `y`. - Preview-area side axes can now remain horizontally stable while still allowing app-selected vertical wheel zoom. -Why it matters: +#### User and data impact + - App-owned side panels such as color scales and histograms can remain compact and stable without disabling useful wheel interaction. -Compatibility: +#### Compatibility and migration + - Existing preview areas keep default `xy` wheel zoom unless they opt into another per-axis setting. -Evidence: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Mainline commit `3c143eb`. -### 2026-07-09 - Default LabKit close protection +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Default LabKit close protection + +```labkit-change +schema: 1 +id: LK-20260709-default-labkit-close-protection +date: 2026-07-09 +type: fix +compatibility: compatible +component: `labkit.ui` | `5.0.2 -> 5.0.3` +component: `labkit_FocusStack_app` | `1.4.6 -> 1.4.7` +component: `labkit_ImageEnhance_app` | `1.5.5 -> 1.5.6` +component: `labkit_ImageMatch_app` | `1.5.5 -> 1.5.6` +``` + +#### Context + +- Public and private apps get a baseline close-safety prompt from the framework, + without app-owned dirty-state close logic. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `5.0.2 -> 5.0.3` - `labkit_FocusStack_app` `1.4.6 -> 1.4.7` - `labkit_ImageEnhance_app` `1.5.5 -> 1.5.6` - `labkit_ImageMatch_app` `1.5.5 -> 1.5.6` -What changed: - LabKit runtime figures now show an in-window confirmation prompt before any framework-owned app window closes, even when the app has not marked itself dirty. @@ -241,25 +618,57 @@ What changed: - Repeating or holding the app close shortcut while the in-window prompt is active confirms the close. -Why it matters: +#### User and data impact + - Public and private apps get a baseline close-safety prompt from the framework, without app-owned dirty-state close logic. -Compatibility: +#### Compatibility and migration + - Closing LabKit apps now requires one confirmation step by default. App code that calls `labkit.ui.runtime.setCloseGuard` must remove that call; close confirmation is framework-owned. -Evidence: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Mainline commit `0c9f472`. -### 2026-07-09 - Multi-app launcher packages +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Multi-app launcher packages + +```labkit-change +schema: 1 +id: LK-20260709-multi-app-launcher-packages +date: 2026-07-09 +type: feat +compatibility: compatible +component: `labkit_launcher` | `1.2.7 -> 1.3.0` +``` + +#### Context + +- Related LabKit apps can be distributed together without shipping unrelated + apps or manually combining separate packages. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_launcher` `1.2.7 -> 1.3.0` - Project deployment tooling, multi-app bundle support. -What changed: - Added an independent `Package` checkbox column to the launcher app table so users can choose multiple apps without changing the row selected for Open or Debug. @@ -268,23 +677,56 @@ What changed: - Kept single-app package names, result fields, and manifest schema compatible when only one app is supplied to `packageLabKitApp`. -Why it matters: +#### User and data impact + - Related LabKit apps can be distributed together without shipping unrelated apps or manually combining separate packages. -Compatibility: +#### Compatibility and migration + - Existing direct calls that package one app continue to produce the original single-app package contract. -Evidence: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Mainline commit `8a23a52`. -### 2026-07-08 - Runtime-only P-code app packages +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Runtime-only P-code app packages + +```labkit-change +schema: 1 +id: LK-20260708-runtime-only-p-code-app-packages +date: 2026-07-08 +type: refactor +compatibility: compatible +scope: historical project evolution +``` + +#### Context + +- P-code distributions no longer expose or depend on launcher behavior that is + source-checkout oriented, including launcher version/date metadata and + follow-on packaging actions. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - Project deployment tooling, no component version change. -What changed: - `Package P-code` now creates a runtime-only single-app package instead of shipping a P-coded LabKit launcher and launcher maintenance tools. - P-code package manifests and README instructions point users to the direct @@ -292,25 +734,58 @@ What changed: - P-code packaging no longer requires `labkit_launcher.m` or `labkit_launcher.p` to exist in the package root being used as the runtime source. -Why it matters: +#### User and data impact + - P-code distributions no longer expose or depend on launcher behavior that is source-checkout oriented, including launcher version/date metadata and follow-on packaging actions. -Compatibility: +#### Compatibility and migration + - Users of P-code packages should run `run_` from the unzipped package instead of `labkit_launcher`. Source packages still include and support the launcher. -Evidence: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Mainline commit `75f63f1`. -### 2026-07-08 - Release validation gate and GUI CI hardening +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Release validation gate and GUI CI hardening + +```labkit-change +schema: 1 +id: LK-20260708-release-validation-gate-and-gui-ci-hardening +date: 2026-07-08 +type: ci +compatibility: compatible +scope: historical project evolution +``` + +#### Context + +- Maintainers get a concrete pre-publication release signal that covers all + supported automated test projects, and GUI CI should fail on contract drift + rather than platform layout rounding. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - Project validation workflow, no component version change. -What changed: - Release candidate tags now run the full MATLAB test workflow gate before publication: headless tests, coverage, GUI tests, and a release summary gate. - GUI layout tests now assert structural grid contracts instead of @@ -318,20 +793,69 @@ What changed: - Shared GUI test idle waiting allows slower CI display backends more time to finish registered UI work. -Why it matters: +#### User and data impact + - Maintainers get a concrete pre-publication release signal that covers all supported automated test projects, and GUI CI should fail on contract drift rather than platform layout rounding. -Compatibility: +#### Compatibility and migration + - No known manual migration. -Evidence: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Mainline commit `f359518`. -### 2026-07-07 - Debug workflows, launcher tools, and changelog governance +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Debug workflows, launcher tools, and changelog governance + +```labkit-change +schema: 1 +id: LK-20260707-debug-workflows-launcher-tools-and-changelog-governance +date: 2026-07-07 +type: feat +compatibility: compatible +component: `labkit_launcher` | `1.2.4 -> 1.2.7` +component: `labkit.ui` | `5.0.1 -> 5.0.2` +component: `labkit_FigureStudio_app` | `0.1.4 -> 0.1.5` +component: `labkit_DICPreprocess_app` | `1.3.4 -> 1.3.5` +component: `labkit_BatchImageCrop_app` | `1.6.6 -> 1.6.7` +component: `labkit_FocusStack_app` | `1.4.5 -> 1.4.6` +``` + +#### Context + +- The debug sample workflows can be exercised without false crash reports or + disabled-looking app paths when the required user action is folder loading, + ROI anchor completion, or crop-center confirmation. +- Code Analyzer cleanup can be reviewed from an interactive local HTML report + without making the launcher own a growing maintenance workflow. +- A single lab workflow can be distributed into a fixed production or offline + deployment step without shipping unrelated apps, tests, docs, or repository + metadata. +- Developers can keep private LabKit apps next to a public checkout, use the + ordinary launcher to open them, and push that workspace to a separate private + repository. +- Maintainers and agents can understand project direction from the changelog + without reconstructing intent from raw git history. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - Release tag `v3.1.0` - `labkit_launcher` `1.2.4 -> 1.2.7` - `labkit.ui` `5.0.1 -> 5.0.2` @@ -340,7 +864,6 @@ Affected versions: - `labkit_BatchImageCrop_app` `1.6.6 -> 1.6.7` - `labkit_FocusStack_app` `1.4.5 -> 1.4.6` -What changed: - DIC Preprocess ROI mask export now reads the live ROI editor anchors when building a mask, so preview/save do not misreport a drawn ROI as empty when editor state is newer than the app state snapshot. @@ -366,7 +889,8 @@ What changed: reader-facing evolution entries, with release tags and commits kept as anchors and evidence rather than the primary structure. -Why it matters: +#### User and data impact + - The debug sample workflows can be exercised without false crash reports or disabled-looking app paths when the required user action is folder loading, ROI anchor completion, or crop-center confirmation. @@ -381,7 +905,8 @@ Why it matters: - Maintainers and agents can understand project direction from the changelog without reconstructing intent from raw git history. -Compatibility: +#### Compatibility and migration + - DIC ROI editing still uses double-click to add anchors; no interaction-mode migration is required. - Existing file-panel image selection remains available in Focus Stack. @@ -392,19 +917,75 @@ Compatibility: require MATLAB to run the generated `.p` files. - Public apps, public releases, and public CI remain scoped to `apps/`. -Direction: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + +- PR #34 squash merge and release tag `v3.1.0`. + +#### Known limitations and follow-up + - Keep debug fixes moving into shared callback and editor contracts when the failure pattern is reusable, but keep app-specific workflow decisions in the owning app. - Keep changelog entries organized around evolution themes and release lines, not raw tag rows or issue lists. -Evidence: -- PR #34 squash merge and release tag `v3.1.0`. +### UI 5 facade redesign, app migration, and plot refresh + +```labkit-change +schema: 1 +id: LK-20260706-ui-5-facade-redesign-app-migration-and-plot-refresh +date: 2026-07-06 +type: refactor +compatibility: breaking +component: `labkit_launcher` | `1.2.3 -> 1.2.4` +component: `labkit.ui` | `4.2.0 -> 5.0.0` +component: `labkit_FigureStudio_app` | `0.1.0 -> 0.1.4` +component: `labkit_ChronoOverlay_app` | `1.3.3 -> 1.3.5` +component: `labkit_CIC_app` | `1.3.5 -> 1.3.7` +component: `labkit_CSC_app` | `1.3.7 -> 1.3.9` +component: `labkit_EIS_app` | `1.3.3 -> 1.3.4` +component: `labkit_VTResistance_app` | `1.3.5 -> 1.3.7` +component: `labkit_DICPreprocess_app` | `1.3.3 -> 1.3.4` +component: `labkit_DICPostprocess_app` | `1.3.3 -> 1.3.4` +component: `labkit_BatchImageCrop_app` | `1.6.5 -> 1.6.6` +component: `labkit_CurvatureMeasurement_app` | `1.3.3 -> 1.3.4` +component: `labkit_FLIRThermal_app` | `1.2.7 -> 1.2.8` +component: `labkit_FocusStack_app` | `1.4.4 -> 1.4.5` +component: `labkit_ImageEnhance_app` | `1.5.4 -> 1.5.5` +component: `labkit_ImageMatch_app` | `1.5.4 -> 1.5.5` +component: `labkit_RHSPreview_app` | `1.3.3 -> 1.3.4` +component: `labkit_NerveResponseAnalysis_app` | `1.3.3 -> 1.3.4` +component: `labkit_ResponseReviewStats_app` | `1.3.3 -> 1.3.4` +component: `labkit_ECGPrint_app` | `1.3.4 -> 1.3.5` +``` + +#### Context + +- App authors now use a smaller set of responsibility-named UI packages instead + of learning old mixed app/spec/view/tool/diag buckets. +- Shared plot-area mechanics live in the framework, so app code can focus on + domain plotting while the framework handles stale axes state, fitted ranges, + empty previews, coordinate offsets, and registered preview utilities. +- Electrochem apps now match the active file selection after add/remove/clear + workflows, and CIC keeps the critical Emc/Ema readout visible on dense plots. +- Users can distinguish long launcher work from a frozen MATLAB session. +- Multi-plot apps expose utility actions in a clearer, less repetitive flow. +- Figure cleanup and data/script export move into a dedicated reusable workflow + instead of crowding every popout plot window. -### 2026-07-06 - UI 5 facade redesign, app migration, and plot refresh +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_launcher` `1.2.3 -> 1.2.4` - `labkit.ui` `4.2.0 -> 5.0.0` - `labkit_FigureStudio_app` `0.1.0 -> 0.1.4` @@ -426,7 +1007,6 @@ Affected versions: - `labkit_ResponseReviewStats_app` `1.3.3 -> 1.3.4` - `labkit_ECGPrint_app` `1.3.4 -> 1.3.5` -What changed: - Reorganized the UI facade into `labkit.ui.runtime`, `layout`, `control`, `plot`, `interaction`, and `debug` so app authors can find lifecycle, data-only layout, control update, plot-area, pointer/tooling, and diagnostic @@ -470,7 +1050,8 @@ What changed: axes use the framework-owned preview grid policy instead of app-owned row/column layout code. -Why it matters: +#### User and data impact + - App authors now use a smaller set of responsibility-named UI packages instead of learning old mixed app/spec/view/tool/diag buckets. - Shared plot-area mechanics live in the framework, so app code can focus on @@ -483,499 +1064,1477 @@ Why it matters: - Figure cleanup and data/script export move into a dedicated reusable workflow instead of crowding every popout plot window. -Compatibility: -- Breaking UI facade migration: app code must use the UI 5 package paths and - require `labkit.ui >=5 <6`. +#### Compatibility and migration + +- Breaking UI facade migration: app code must use the UI 5 package paths and + require `labkit.ui >=5 <6`. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + +- Main UI 5 squash commit. + +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### UI utility snapshots and popout tools + +```labkit-change +schema: 1 +id: LK-20260704-ui-utility-snapshots-and-popout-tools +date: 2026-07-04 +type: feat +compatibility: compatible +component: `labkit.ui` | `4.1.0 -> 4.2.0` +``` + +#### Context + +- Users can preserve UI state and move plot outputs out of the GUI with less + manual work. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes + +- `labkit.ui` `4.1.0 -> 4.2.0` + +- Added UI state snapshot save/load APIs. +- Added workbench utility controls. +- Improved axes popout export and copy tools. + +#### User and data impact + +- Users can preserve UI state and move plot outputs out of the GUI with less + manual work. + +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + +- Main commit `0155cd12`. + +#### Known limitations and follow-up -Evidence: -- Main UI 5 squash commit. +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. -### 2026-07-04 - UI utility snapshots and popout tools +### FLIR display tuning -Affected versions: -- `labkit.ui` `4.1.0 -> 4.2.0` +```labkit-change +schema: 1 +id: LK-20260703-flir-display-tuning +date: 2026-07-03 +type: feat +compatibility: compatible +component: `labkit_FLIRThermal_app` | `1.2.4 -> 1.2.7` +component: `labkit_CSC_app` | `1.3.6 -> 1.3.7` +``` -What changed: -- Added UI state snapshot save/load APIs. -- Added workbench utility controls. -- Improved axes popout export and copy tools. +#### Context -Why it matters: -- Users can preserve UI state and move plot outputs out of the GUI with less - manual work. +- CSC exports became clearer for downstream analysis, and FLIR display tuning + no longer requires code edits. -Evidence: -- Main commit `0155cd12`. +#### Decision and rationale -### 2026-07-03 - FLIR display tuning +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_FLIRThermal_app` `1.2.4 -> 1.2.7` - `labkit_CSC_app` `1.3.6 -> 1.3.7` -What changed: - Refined CSC CV export. - Added FLIR gamma color mapping and made gamma adjustable. -Why it matters: +#### User and data impact + - CSC exports became clearer for downstream analysis, and FLIR display tuning no longer requires code edits. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `ee5b8f79`, `65dbf5ae`, and `f076561e`. -### 2026-07-03 - CSC export and viewport policy +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### CSC export and viewport policy + +```labkit-change +schema: 1 +id: LK-20260703-csc-export-and-viewport-policy +date: 2026-07-03 +type: feat +compatibility: compatible +component: `labkit.ui` | `4.0.0 -> 4.1.0` +``` + +#### Context + +- Users can export more complete CSC cycle data, and app layouts share the same + viewport assumptions. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `4.0.0 -> 4.1.0` - All supported apps received aligned patch bumps. -What changed: - Added CSC all-cycle export. - Added viewport policy support and aligned app contracts with the UI 4.x line. -Why it matters: +#### User and data impact + - Users can export more complete CSC cycle data, and app layouts share the same viewport assumptions. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `a69829c6`. -### 2026-07-03 - UI groups migration +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### UI groups migration + +```labkit-change +schema: 1 +id: LK-20260703-ui-groups-migration +date: 2026-07-03 +type: refactor +compatibility: compatible +component: `labkit.ui` | `3.4.5 -> 4.0.0` +``` + +#### Context + +- This is the point where app action layout became a grouped UI contract instead + of a looser action-list convention. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.4.5 -> 4.0.0` - All supported apps received patch bumps. -What changed: - Replaced action groups with UI groups. - Moved the reusable UI contract into the 4.x line. -Why it matters: +#### User and data impact + - This is the point where app action layout became a grouped UI contract instead of a looser action-list convention. -Compatibility: +#### Compatibility and migration + - App workflow definitions had to align with the new grouped UI contract. -Evidence: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `e81243a3`. -### 2026-07-03 - App file-selection and electrochem control fixes +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### App file-selection and electrochem control fixes + +```labkit-change +schema: 1 +id: LK-20260703-app-file-selection-and-electrochem-control-fixes +date: 2026-07-03 +type: fix +compatibility: compatible +scope: historical project evolution +``` + +#### Context + +- Multi-file workflows stopped losing appended selections, and electrochem app + controls became less misleading. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - CIC, CSC, VT Resistance, Batch Crop, FLIR Thermal, Focus Stack, Image Enhance, and Image Match patch bumped for appended file selections. - CIC, CSC, and VT Resistance patch bumped again for manual plot-control removal. -What changed: - Preserved appended file selections. - Removed electrochem manual plot controls that no longer matched the workflow. -Why it matters: +#### User and data impact + - Multi-file workflows stopped losing appended selections, and electrochem app controls became less misleading. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `6348185e` and `674d5d4b`. -### 2026-07-03 - Declarative app runtime +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Declarative app runtime + +```labkit-change +schema: 1 +id: LK-20260703-declarative-app-runtime +date: 2026-07-03 +type: refactor +compatibility: compatible +component: `labkit.ui` | `3.4.4 -> 3.4.5` +``` + +#### Context + +- Maintainers can reason about app wiring through workflow definitions instead + of hand-following callback construction. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.4.4 -> 3.4.5` - All supported apps received patch bumps. -What changed: - Migrated apps to declarative workflow runtime. -Why it matters: +#### User and data impact + - Maintainers can reason about app wiring through workflow definitions instead of hand-following callback construction. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `568b3e9b`. -### 2026-07-02 - Startup responsiveness +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Startup responsiveness + +```labkit-change +schema: 1 +id: LK-20260702-startup-responsiveness +date: 2026-07-02 +type: perf +compatibility: compatible +component: `labkit_launcher` | `1.2.2 -> 1.2.3` +component: `labkit.ui` | `3.4.2 -> 3.4.4` +``` + +#### Context + +- Users see responsive windows sooner instead of waiting on discovery and setup + work before the GUI appears. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_launcher` `1.2.2 -> 1.2.3` - `labkit.ui` `3.4.2 -> 3.4.4` -What changed: - Painted launcher and app windows earlier. - Deferred launcher app discovery and lazy preview scroll setup. -Why it matters: +#### User and data impact + - Users see responsive windows sooner instead of waiting on discovery and setup work before the GUI appears. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `7d4ef11e`. -### 2026-07-02 - Profiling and validation speedups +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Profiling and validation speedups + +```labkit-change +schema: 1 +id: LK-20260702-profiling-and-validation-speedups +date: 2026-07-02 +type: ci +compatibility: compatible +component: `labkit_launcher` | `1.2.0 -> 1.2.2` +component: `labkit.ui` | `3.4.0 -> 3.4.2` +component: `labkit_BatchImageCrop_app` | `1.6.0 -> 1.6.1` +component: `labkit_ECGPrint_app` | `1.3.0 -> 1.3.1` +``` + +#### Context + +- Maintainers get faster diagnosis and faster validation without changing app + behavior. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_launcher` `1.2.0 -> 1.2.2` - `labkit.ui` `3.4.0 -> 3.4.2` - `labkit_BatchImageCrop_app` `1.6.0 -> 1.6.1` - `labkit_ECGPrint_app` `1.3.0 -> 1.3.1` -What changed: - Added LabKit profiling and build-managed test routing to the launcher. - Reduced GUI profiling overhead and deferred Batch Crop image reads until preview/export. - Compressed validation runtime with bounded GUI waits. -Why it matters: +#### User and data impact + - Maintainers get faster diagnosis and faster validation without changing app behavior. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `c07dfc0a`, `74025fee`, `eadcca82`, `25912c54`, and `fcfc36d8`. -### 2026-07-01 - Launcher code-analysis export +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Launcher code-analysis export + +```labkit-change +schema: 1 +id: LK-20260701-launcher-code-analysis-export +date: 2026-07-01 +type: feat +compatibility: compatible +component: `labkit_launcher` | `1.1.6 -> 1.2.0` +``` + +#### Context + +- Maintainers can inspect launcher code issues through the workbench tooling + without a separate manual MATLAB setup. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_launcher` `1.1.6 -> 1.2.0` -What changed: - Exported launcher Code Analyzer issues natively. -Why it matters: +#### User and data impact + - Maintainers can inspect launcher code issues through the workbench tooling without a separate manual MATLAB setup. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `8fd3ddff`. -### 2026-07-01 - Debug sample packs +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Debug sample packs + +```labkit-change +schema: 1 +id: LK-20260701-debug-sample-packs +date: 2026-07-01 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.3.1 -> 3.4.0` +``` + +#### Context + +- Reproducing app failures became a maintained workflow instead of an ad hoc + collection of local files. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.3.1 -> 3.4.0` - All supported apps moved into the `1.3.x`, `1.4.x`, `1.5.x`, or `1.6.x` debug-sample-pack lines. -What changed: - Added app-owned debug sample packs. - Added debug artifact sample and output folders. -Why it matters: +#### User and data impact + - Reproducing app failures became a maintained workflow instead of an ad hoc collection of local files. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `279befbc`. -### 2026-07-01 - Image app workflow improvements +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Image app workflow improvements + +```labkit-change +schema: 1 +id: LK-20260701-image-app-workflow-improvements +date: 2026-07-01 +type: feat +compatibility: compatible +component: `labkit.image` | `1.0.0 -> 1.1.0` +component: `labkit.ui` | `3.2.10 -> 3.3.1` +component: `labkit_launcher` | `1.1.5 -> 1.1.6` +``` + +#### Context + +- Large image workflows became more predictable and less likely to spend time on + unnecessary preview work. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.image` `1.0.0 -> 1.1.0` - `labkit.ui` `3.2.10 -> 3.3.1` - Batch Crop `1.4.0 -> 1.5.1` - FLIR Thermal `1.0.0 -> 1.1.2` - `labkit_launcher` `1.1.5 -> 1.1.6` -What changed: - Added preview-budget helpers. - Improved image app range and preview controls. - Improved image measurement workflows. -Why it matters: +#### User and data impact + - Large image workflows became more predictable and less likely to spend time on unnecessary preview work. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `15a798ba` and `70bfcfd4`. -### 2026-07-01 - Thermal facade and FLIR app +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Thermal facade and FLIR app + +```labkit-change +schema: 1 +id: LK-20260701-thermal-facade-and-flir-app +date: 2026-07-01 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.2.9 -> 3.2.10` +``` + +#### Context + +- Thermal image parsing and rendering became a reusable LabKit contract instead + of app-local logic. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.thermal` `1.0.0` - `labkit.ui` `3.2.9 -> 3.2.10` - `labkit_FLIRThermal_app` `1.0.0` -What changed: - Added the thermal facade. - Added the FLIR Thermal Postprocess app. -Why it matters: +#### User and data impact + - Thermal image parsing and rendering became a reusable LabKit contract instead of app-local logic. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `977c9457`. -### 2026-07-01 - Launcher update reliability +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Launcher update reliability + +```labkit-change +schema: 1 +id: LK-20260701-launcher-update-reliability +date: 2026-07-01 +type: fix +compatibility: compatible +component: `labkit_launcher` | `1.1.3 -> 1.1.5` +``` + +#### Context + +- Updating the self-contained launcher became less fragile. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_launcher` `1.1.3 -> 1.1.5` -What changed: - Sped up launcher zip updates. - Simplified launcher zip replacement. -Why it matters: +#### User and data impact + - Updating the self-contained launcher became less fragile. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `ebf86cf2` and `becf9391`. -### 2026-06-30 - Shared image facade +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Shared image facade + +```labkit-change +schema: 1 +id: LK-20260630-shared-image-facade +date: 2026-06-30 +type: feat +compatibility: compatible +scope: historical project evolution +``` + +#### Context + +- Image app behavior became more consistent, and reusable image IO stopped + living inside individual GUI workflows. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.image` `1.0.0` - Batch Crop, Curvature, Focus Stack, Image Enhance, and Image Match advanced within their image-facade adoption lines. -What changed: - Added a GUI-free image facade for file input, display normalization, basic processing, and preview support. - Adopted that facade across image-measurement apps. -Why it matters: +#### User and data impact + - Image app behavior became more consistent, and reusable image IO stopped living inside individual GUI workflows. -Evidence: -- Main commit `7023e87e`. +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + +- Main commit `7023e87e`. + +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Migration helper cleanup + +```labkit-change +schema: 1 +id: LK-20260630-migration-helper-cleanup +date: 2026-06-30 +type: refactor +compatibility: compatible +scope: historical project evolution +``` + +#### Context + +- Maintainers no longer need to route through temporary migration helpers to + understand these workflows. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes + +- DIC Post, Batch Crop, and RHS Preview patch bumped. + +- Retired migration helper debt. +- Consolidated RHS preview window bounds, Batch Crop scale state, and Image + Enhance export helpers. + +#### User and data impact + +- Maintainers no longer need to route through temporary migration helpers to + understand these workflows. + +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + +- Main commits `7f73b71b`, `e3349af6`, `733fb951`, `98a2b02c`, and `391540a7`. + +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### App alerts through UI facade -### 2026-06-30 - Migration helper cleanup +```labkit-change +schema: 1 +id: LK-20260630-app-alerts-through-ui-facade +date: 2026-06-30 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.2.7 -> 3.2.8` +``` -Affected versions: -- DIC Post, Batch Crop, and RHS Preview patch bumped. +#### Context -What changed: -- Retired migration helper debt. -- Consolidated RHS preview window bounds, Batch Crop scale state, and Image - Enhance export helpers. +- App error reporting became testable without each app inventing its own alert + mechanics. -Why it matters: -- Maintainers no longer need to route through temporary migration helpers to - understand these workflows. +#### Decision and rationale -Evidence: -- Main commits `7f73b71b`, `e3349af6`, `733fb951`, `98a2b02c`, and `391540a7`. +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. -### 2026-06-30 - App alerts through UI facade +#### Changes -Affected versions: - `labkit.ui` `3.2.7 -> 3.2.8` - DIC, electrochem, image-measurement, and ECG apps patch bumped where alert routing changed. -What changed: - Routed app alerts through hidden-test-safe `labkit.ui.app.showAlert`. -Why it matters: +#### User and data impact + - App error reporting became testable without each app inventing its own alert mechanics. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `8d7c83b1`. -### 2026-06-30 - Close guards and caught-exception diagnostics +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Close guards and caught-exception diagnostics + +```labkit-change +schema: 1 +id: LK-20260630-close-guards-and-caught-exception-diagnostics +date: 2026-06-30 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.2.6 -> 3.2.7` +``` + +#### Context + +- Crashes and interrupted workflows leave better evidence for maintainers, and + users get safer close behavior around incomplete image workflows. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.2.6 -> 3.2.7` - DIC, Batch Crop, Curvature, Focus Stack, Image Match, neurophysiology apps, and ECG Print patch bumped for diagnostics or close-guard work. -What changed: - Reported caught app-runner exceptions through framework debug diagnostics. - Promoted file-entry index helpers. - Connected dirty/incomplete workflow state to close guards. -Why it matters: +#### User and data impact + - Crashes and interrupted workflows leave better evidence for maintainers, and users get safer close behavior around incomplete image workflows. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `c0028a81` and `a81853ef`. -### 2026-06-30 - Output folder prompts +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Output folder prompts + +```labkit-change +schema: 1 +id: LK-20260630-output-folder-prompts +date: 2026-06-30 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.2.5 -> 3.2.6` +``` + +#### Context + +- Apps gained consistent output-folder behavior without hard-coding dialog + mechanics into each workflow. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.2.5 -> 3.2.6` - DIC apps, Batch Crop, Focus Stack, Image Enhance/Match, Nerve Response, and Response Review patch bumped. -What changed: - Added `promptOutputFolder`. - Migrated output-folder prompts with chooser injection and safe defaults. -Why it matters: +#### User and data impact + - Apps gained consistent output-folder behavior without hard-coding dialog mechanics into each workflow. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `c5055b98`. -### 2026-06-30 - File-panel layout stabilization +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### File-panel layout stabilization + +```labkit-change +schema: 1 +id: LK-20260630-file-panel-layout-stabilization +date: 2026-06-30 +type: fix +compatibility: compatible +component: `labkit.ui` | `3.2.3 -> 3.2.5` +``` + +#### Context + +- File-heavy app workflows became easier to scan and less layout-fragile. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.2.3 -> 3.2.5` -What changed: - Stabilized and compacted single file-panel layout. -Why it matters: +#### User and data impact + - File-heavy app workflows became easier to scan and less layout-fragile. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `7f8df1cd` and `02b2f1b6`. -### 2026-06-29 - Tool-panel hosts and image app fixes +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Tool-panel hosts and image app fixes + +```labkit-change +schema: 1 +id: LK-20260629-tool-panel-hosts-and-image-app-fixes +date: 2026-06-29 +type: fix +compatibility: compatible +component: `labkit.ui` | `3.2.0 -> 3.2.3` +``` + +#### Context + +- Reusable tools gained a real layout host, and image app reports/ROI controls + became less surprising. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.2.0 -> 3.2.3` - Batch Crop, Curvature, Image Enhance, and Image Match patch bumped where layouts or image-app behavior changed. -What changed: - Hardened file-panel entry normalization and deterministic ID regeneration. - Fixed output-size reporting and White ROI responsiveness. - Added semantic `toolPanel` hosts. -Why it matters: +#### User and data impact + - Reusable tools gained a real layout host, and image app reports/ROI controls became less surprising. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `f2189aef`, `77084fbe`, and `871739cd`. -### 2026-06-29 - UI diagnostics and release v3.0.0 +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### UI diagnostics and release v3.0.0 + +```labkit-change +schema: 1 +id: LK-20260629-ui-diagnostics-and-release-v3-0-0 +date: 2026-06-29 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.1.3 -> 3.2.0` +component: `labkit_launcher` | `1.1.2 -> 1.1.3` +``` + +#### Context + +- Maintainers got better evidence when app callbacks failed, and users got a + clearer release line to roll back to. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - Release tag `v3.0.0` - `labkit.ui` `3.1.3 -> 3.2.0` - `labkit_launcher` `1.1.2 -> 1.1.3` -What changed: - Improved UI diagnostics and validation documentation. - Published the v3.0.0 release line around UI diagnostics, validation docs, and duplicate CI avoidance. -Why it matters: +#### User and data impact + - Maintainers got better evidence when app callbacks failed, and users got a clearer release line to roll back to. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `21eff4dc` and release tag commit `349a7549`. -### 2026-06-29 - Protected image enhancement workflows +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Protected image enhancement workflows + +```labkit-change +schema: 1 +id: LK-20260629-protected-image-enhancement-workflows +date: 2026-06-29 +type: feat +compatibility: compatible +scope: historical project evolution +``` + +#### Context + +- The image enhancement apps gained a more deliberate workflow boundary before + later image-facade adoption. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - Image Enhance `1.2.2 -> 1.3.0` - Image Match `1.2.1 -> 1.3.0` -What changed: - Added protected image enhancement workflows. -Why it matters: +#### User and data impact + - The image enhancement apps gained a more deliberate workflow boundary before later image-facade adoption. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `1768dd57`. -### 2026-06-28 - App diagnostics and hardened UI workflows +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### App diagnostics and hardened UI workflows + +```labkit-change +schema: 1 +id: LK-20260628-app-diagnostics-and-hardened-ui-workflows +date: 2026-06-28 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.1.0 -> 3.1.3` +``` + +#### Context + +- Maintainers get structured failure evidence instead of relying on screenshots + or vague crash reports. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.1.0 -> 3.1.3` - Batch Crop, Focus Stack, Image Enhance/Match, neurophysiology apps, and the launcher patch bumped where runtime behavior changed. -What changed: - Hardened LabKit UI workflows. - Added crash reports, active-operation reports, caught-error reports, and stall diagnostics. -Why it matters: +#### User and data impact + - Maintainers get structured failure evidence instead of relying on screenshots or vague crash reports. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `e966457b` and `f5bc6f98`. -### 2026-06-28 - Batch Crop file workflow feedback +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Batch Crop file workflow feedback + +```labkit-change +schema: 1 +id: LK-20260628-batch-crop-file-workflow-feedback +date: 2026-06-28 +type: feat +compatibility: compatible +component: `labkit.ui` | `3.0.1 -> 3.1.0` +``` + +#### Context + +- Users can see which selected file a preview or result belongs to. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.ui` `3.0.1 -> 3.1.0` - Batch Crop `1.2.0 -> 1.3.0` -What changed: - Added selected-file title context. - Improved Batch Crop file workflow feedback. -Why it matters: +#### User and data impact + - Users can see which selected file a preview or result belongs to. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `61e8edd3`. -### 2026-06-25 to 2026-06-26 - Launcher manager and stale callback fix +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Launcher manager and stale callback fix + +```labkit-change +schema: 1 +id: LK-20260626-launcher-manager-and-stale-callback-fix +date: 2026-06-26 +type: fix +compatibility: compatible +component: `labkit_launcher` | `1.0.0 -> 1.1.1` +component: `labkit.ui` | `3.0.0 -> 3.0.1` +``` + +#### Context + +- Users gained a deliberate path to choose recent releases, tags, or main + commits, and image interactions stopped carrying stale callback state. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit_launcher` `1.0.0 -> 1.1.1` - `labkit.ui` `3.0.0 -> 3.0.1` -What changed: - Added the launcher version manager and managed-manifest requirement. - Released stale image drag callbacks. -Why it matters: +#### User and data impact + - Users gained a deliberate path to choose recent releases, tags, or main commits, and image interactions stopped carrying stale callback state. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `fe8654c9`, `ef89cf77`, and `3d23b7f1`. -### 2026-06-24 - File-panel migration +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### File-panel migration + +```labkit-change +schema: 1 +id: LK-20260624-file-panel-migration +date: 2026-06-24 +type: refactor +compatibility: breaking +component: `labkit.dta` | `1.0.0 -> 2.0.0` +component: `labkit.ui` | `2.2.1 -> 3.0.0` +``` + +#### Context + +- File selection became a shared UI workflow instead of app-specific task-input + plumbing. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.dta` `1.0.0 -> 2.0.0` - `labkit.ui` `2.2.1 -> 3.0.0` - All supported apps moved from `1.0.x` into the `1.2.0` workflow line. -What changed: - Replaced task inputs with file panels. - Removed the old DTA session helper surface. -Why it matters: +#### User and data impact + - File selection became a shared UI workflow instead of app-specific task-input plumbing. -Compatibility: +#### Compatibility and migration + - This was a breaking workflow migration. Older app code expecting task inputs or the removed DTA session helpers needed migration. -Evidence: +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `b145c904`. -### 2026-06-23 - Version metadata baseline +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Version metadata baseline + +```labkit-change +schema: 1 +id: LK-20260623-version-metadata-baseline +date: 2026-06-23 +type: feat +compatibility: compatible +component: `labkit.ui` | `2.1.0 -> 2.2.0` +``` + +#### Context + +- This is the first point where app and launcher versions became first-class + user-facing metadata. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - Release tag `v2.4.0` - `labkit_launcher` `1.0.0` - All supported apps `1.0.0` - `labkit.ui` `2.1.0 -> 2.2.0` -What changed: - Added app and launcher version metadata. - Added versioned titles, lightweight version requests, launcher catalog version display, and version guardrails. -Why it matters: +#### User and data impact + - This is the first point where app and launcher versions became first-class user-facing metadata. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commit `d70c2607`. -### 2026-06-23 - Facade contract baseline and release validation hardening +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Facade contract baseline and release validation hardening + +```labkit-change +schema: 1 +id: LK-20260623-facade-contract-baseline-and-release-validation-hardening +date: 2026-06-23 +type: ci +compatibility: compatible +component: `labkit.ui` | `2.0.0 -> 2.2.1` +``` + +#### Context + +- Reusable facades gained explicit compatibility contracts before the later + app-version and launcher-version work. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - `labkit.biosignal` `1.0.0` - `labkit.dta` `1.0.0` - `labkit.rhs` `1.0.0` @@ -983,25 +2542,59 @@ Affected versions: - DIC Pre/Post and Curvature `1.0.0 -> 1.0.1` - Release tags `v2.4.1` and `v2.4.2` -What changed: - Added facade contract metadata and requirement checks. - Hardened app lifecycle and release validation contracts. - Routed MATLAB CI shards through build tasks. -Why it matters: +#### User and data impact + - Reusable facades gained explicit compatibility contracts before the later app-version and launcher-version work. -Evidence: +#### Compatibility and migration + +No manual migration was recorded for this historical change. + +#### Validation + +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. + +#### Evidence + - Main commits `a25b79f9`, `3673e548`, `49d9f41b`, and `7e39b558`. -### Before component versions - 2026-05-28 to 2026-06-23 +#### Known limitations and follow-up + +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. + +### Initial app workbench foundation + +```labkit-change +schema: 1 +id: LK-20260528-initial-app-workbench-foundation +date: 2026-05-28 +type: feat +compatibility: compatible +scope: historical project evolution +``` + +#### Context + +- This is the period where LabKit changed from loose scripts into an app + workbench with a small reusable foundation. + +#### Decision and rationale + +Treat this as one coherent evolution record because the listed versions and +evidence changed together to address the stated user or maintainer need. + +#### Changes -Affected versions: - Release tags `v1.0`, `v2.0`, legacy `2.1`, `v2.2.0`, `v2.3.0`, `v2.3.1`, `v2.3.2`, and `v2.3.3`. -What changed: - Imported legacy MATLAB code and split it into app entry points. - Extracted DTA parsers, electrochem calculations, DIC workflows, image measurement workflows, biosignal support, and ECG workflows. @@ -1010,44 +2603,62 @@ What changed: logging, launcher/project metadata, release updater support, and reproducible release-asset rules. -Why it matters: +#### User and data impact + - This is the period where LabKit changed from loose scripts into an app workbench with a small reusable foundation. -Compatibility: +#### Compatibility and migration + - Component/app version files did not exist yet, so this era is tracked by release tags, commit ranges, and workflow milestones rather than per-app version numbers. -Evidence: -- Main history from `5973bde0` through `a7e7dfb1`. -- Release tags: `v1.0`, `v2.0`, `2.1`, `v2.2.0`, `v2.3.0`, `v2.3.1`, - `v2.3.2`, `v2.3.3`. - -## Maintenance Template +#### Validation -Use this format for new versioned changes: +Historical test commands were not recorded consistently. The carrying +mainline commits and release tags below are the authoritative evidence; +current guardrails protect the surviving contracts. -```markdown -### YYYY-MM-DD - Short user-facing title +#### Evidence -Affected versions: -- `component` `old -> new` +- Main history from `5973bde0` through `a7e7dfb1`. +- Release tags: `v1.0`, `v2.0`, `2.1`, `v2.2.0`, `v2.3.0`, `v2.3.1`, + `v2.3.2`, `v2.3.3`. -What changed: -- Plain-language change summary. +#### Known limitations and follow-up -Why it matters: -- User or maintainer reason this version exists. +This normalized baseline preserves the historical intent; consult the evidence for commit-level implementation details. -Compatibility: -- Migration or rollback note, when relevant. +## Current Version Lookup -Evidence: -- Main commit `sha`. -``` +Audited against working-tree metadata on 2026-07-13; version transitions use +the `origin/main` merge-base values. -For branch work before the final mainline SHA is known, place the entry in -`Unreleased` with PR or branch evidence. During release preparation or a -changelog audit, move the finalized entry into `Version History` with the -mainline commit SHA. +| Component | Current version | Family | Metadata location | +|---|---:|---|---| +| `labkit_launcher` | `1.3.0` | Launcher | `labkit_launcher.m` | +| `labkit.ui` | `5.1.0` | Facade | `+labkit/+ui/version.m` | +| `labkit.dta` | `2.0.1` | Facade | `+labkit/+dta/version.m` | +| `labkit.image` | `2.0.0` | Facade | `+labkit/+image/version.m` | +| `labkit.thermal` | `1.1.0` | Facade | `+labkit/+thermal/version.m` | +| `labkit.rhs` | `1.0.1` | Facade | `+labkit/+rhs/version.m` | +| `labkit.biosignal` | `1.0.1` | Facade | `+labkit/+biosignal/version.m` | +| `labkit_FigureStudio_app` | `0.1.5` | LabKit Core | `apps/labkit_core/figure_studio/+figure_studio/version.m` | +| `labkit_ChronoOverlay_app` | `1.3.6` | Electrochem | `apps/electrochem/chrono_overlay/+chrono_overlay/version.m` | +| `labkit_CIC_app` | `1.3.8` | Electrochem | `apps/electrochem/cic/+cic/version.m` | +| `labkit_CSC_app` | `1.3.10` | Electrochem | `apps/electrochem/csc/+csc/version.m` | +| `labkit_EIS_app` | `1.3.4` | Electrochem | `apps/electrochem/eis/+eis/version.m` | +| `labkit_VTResistance_app` | `1.3.8` | Electrochem | `apps/electrochem/vt_resistance/+vt_resistance/version.m` | +| `labkit_DICPreprocess_app` | `1.4.0` | DIC | `apps/dic/dic_preprocess/+dic_preprocess/version.m` | +| `labkit_DICPostprocess_app` | `1.3.6` | DIC | `apps/dic/dic_postprocess/+dic_postprocess/version.m` | +| `labkit_BatchImageCrop_app` | `1.6.8` | Image Measurement | `apps/image_measurement/batch_crop/+batch_crop/version.m` | +| `labkit_CurvatureMeasurement_app` | `1.3.5` | Image Measurement | `apps/image_measurement/curvature/+curvature/version.m` | +| `labkit_FLIRThermal_app` | `1.3.0` | Image Measurement | `apps/image_measurement/flir_thermal/+flir_thermal/version.m` | +| `labkit_FocusStack_app` | `1.4.9` | Image Measurement | `apps/image_measurement/focus_stack/+focus_stack/version.m` | +| `labkit_ImageEnhance_app` | `1.5.8` | Image Measurement | `apps/image_measurement/image_enhance/+image_enhance/version.m` | +| `labkit_ImageMatch_app` | `1.5.8` | Image Measurement | `apps/image_measurement/image_match/+image_match/version.m` | +| `labkit_RHSPreview_app` | `1.3.4` | Neurophysiology | `apps/neurophysiology/rhs_preview/+rhs_preview/version.m` | +| `labkit_NerveResponseAnalysis_app` | `1.3.5` | Neurophysiology | `apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/version.m` | +| `labkit_ResponseReviewStats_app` | `1.3.5` | Neurophysiology | `apps/neurophysiology/response_review_stats/+response_review_stats/version.m` | +| `labkit_ECGPrint_app` | `1.3.5` | Wearable | `apps/wearable/ecg_print/+ecg_print/version.m` | diff --git a/docs/release.md b/docs/release.md index cc499813..bb4cf70c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -80,34 +80,31 @@ release notes summarize one public release, while the changelog explains how LabKit changed over time, why each iteration exists, which release tag or component versions carry it, and where the audit evidence lives. -The format combines common open-source practices: - -- Keep a top `Unreleased` section for branch, pull-request, and - release-preparation work before the final mainline commit or tag evidence is - known, following the Keep a Changelog pattern. Do not leave direct-main work - or version-finalized entries there. -- Keep a current version lookup so users can quickly map each app, facade, and - launcher to its metadata file. -- Keep one `Version History` reading path. Entries are user-facing evolution - entries, not raw tag rows or commit-log rows. Use release-line entries when a - public tag is the useful reader anchor, and use feature or maintenance - entries when the capability, workflow, compatibility, or project direction is - the useful reader anchor. -- Entries should lead with affected versions, then explain what changed, - why it matters, compatibility notes when relevant, optional direction notes, - and evidence. -- Keep release notes shorter and user-focused, similar to Django and VS Code - release pages. +The changelog has one format for current and historical records: + +- Keep every entry under `Structured Change Records` with a stable Change ID, + ISO date, Conventional Commit type, compatibility value, and either exact + component version transitions or an unversioned repository scope. +- Keep the required narrative sections for context, decision and rationale, + changes, user and data impact, compatibility and migration, validation, + evidence, and known limitations or follow-up. +- Do not add `Unreleased`, `Pending`, or another delivery-status hierarchy. + Git branches, PRs, mainline commits, and release tags already express + delivery state. A branch record can cite checkpoint commits or a PR and keep + the same Change ID after merge. +- Keep the current version lookup synchronized with every launcher, facade, and + app metadata file. Development-branch transitions compare directly with the + merge base from `origin/main`, not with intermediate branch versions. +- Parse and validate the complete history with + `addpath("tools/release"); parseLabKitChangelog()`. Do not maintain a second + unstructured history or duplicate the parser grammar in another document. When a change bumps `labkit_launcher.m`, a `+labkit/**/version.m` facade, or an `apps/**/version.m` app metadata file, add a changelog entry in the same -change. Before the final mainline SHA is known, add it under `Unreleased` with -PR or branch evidence. For direct-main work with a decided version, write the -entry directly under `Version History`. During release preparation or a -changelog audit, move finalized entries out of `Unreleased`, remove stale -pending drafts, and add the mainline commit SHA when it is known. Do not write -the entry as a raw commit-log dump; explain the maintainer intent and user -impact that are not obvious from blame history. +change. Record the direct mainline-to-final version transition and evidence +available at that time. Do not write the entry as a raw commit-log dump; +explain the context, decision, user or data impact, migration risk, validation, +and limitations that are not obvious from blame history. Before tagging a release that adds, renames, or removes release-blocking guardrail tests, verify that the buildfile CI shard tasks still discover the diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 63e82b40..de16b1b4 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -65,10 +65,11 @@ Tests mirror source ownership. Do not create a parallel runner framework unless time; before squash, PR handoff, or direct `main` push, choose the next version from the latest `main` version file and make the aggregate bump once. The same aggregate version bump must be recorded in `CHANGELOG.md` with user - impact and evidence. Branch work may use the `Unreleased` section until the - final mainline commit is known; direct `main` work or any finalized version - entry belongs under `Version History`, and stale pending drafts should be - moved or removed during changelog audits. + impact and evidence. Branch and mainline records use the same schema-v1 + `labkit-change` block and stable Change ID under `Structured Change Records`; + do not create Unreleased or Pending lifecycle sections. Changelog guardrails + should parse records through `tools/release/parseLabKitChangelog.m` instead + of duplicating the file grammar in tests. - Boundary tests may require app-owned logic to stay under the owning app tree, but should not require GUI-free helpers to remain inside the public app entry-point file or assert exact app-private helper file lists. - App-owned workflow packages need direct unit coverage for non-UI functions; GUI structural tests only prove launch/layout wiring. diff --git a/tests/cases/contract/project/release/ChangelogGuardrailTest.m b/tests/cases/contract/project/release/ChangelogGuardrailTest.m index 32753410..f4aa648c 100644 --- a/tests/cases/contract/project/release/ChangelogGuardrailTest.m +++ b/tests/cases/contract/project/release/ChangelogGuardrailTest.m @@ -9,10 +9,9 @@ function changelogKeepsRequiredSections(testCase) requiredSections = [ ... "# LabKit MATLAB Workbench Changelog", ... "## How To Use This File", ... - "## Unreleased", ... - "## Current Version Lookup", ... - "## Version History", ... - "## Maintenance Template" ... + "## Changelog Model", ... + "## Structured Change Records", ... + "## Current Version Lookup" ... ]; for k = 1:numel(requiredSections) testCase.verifyTrue(contains(changelog, requiredSections(k)), ... @@ -20,22 +19,41 @@ function changelogKeepsRequiredSections(testCase) end requiredPhrases = [ ... - "`Version History` as the main reading path", ... "The primary unit is a user-facing evolution entry", ... "Release tags are public anchors", ... "Commits and PRs belong in `Evidence`", ... "Do not dump raw git logs", ... - "Why it matters:", ... - "Compatibility:", ... - "Direction:", ... - "Evidence:", ... - "Audited against `main`" ... + "stable Change ID", ... + "does not need a separate pending state", ... + "#### Decision and rationale", ... + "#### User and data impact", ... + "#### Known limitations and follow-up" ... ]; for k = 1:numel(requiredPhrases) testCase.verifyTrue(contains(changelog, requiredPhrases(k)), ... "CHANGELOG.md should keep maintenance contract phrase: " + ... requiredPhrases(k)); end + + testCase.verifyFalse(contains(changelog, "## Unreleased") || ... + contains(changelog, "### Pending"), ... + "Delivery state belongs to Git and must not split changelog history."); + end + + function structuredHistoryIsMachineParseable(testCase) + root = setupLabKitTestPath(); + toolFolder = fullfile(root, "tools", "release"); + addpath(toolFolder); + cleanup = onCleanup(@() rmpath(toolFolder)); + + records = parseLabKitChangelog(fullfile(root, "CHANGELOG.md")); + + testCase.verifyGreaterThanOrEqual(numel(records), 40, ... + "The normalized baseline should include the complete recorded history."); + testCase.verifyEqual(numel(unique(string({records.id}))), numel(records)); + testCase.verifyTrue(all(string({records.schema}) == "1")); + testCase.verifyTrue(all([records.sourceLine] > 0)); + clear cleanup end function currentLookupMatchesVersionMetadata(testCase) @@ -66,11 +84,11 @@ function releaseDocsDefineChangelogContract(testCase) requiredReleasePhrases = [ ... "`CHANGELOG.md` is the user-facing version map", ... "project evolution map", ... - "user-facing evolution", ... - "top `Unreleased` section", ... + "one format for current and historical records", ... + "stable Change ID", ... "current version lookup", ... - "one `Version History` reading path", ... - "why it matters", ... + "parseLabKitChangelog", ... + "decision and rationale", ... "When a change bumps `labkit_launcher.m`" ... ]; for k = 1:numel(requiredReleasePhrases) diff --git a/tools/release/parseLabKitChangelog.m b/tools/release/parseLabKitChangelog.m new file mode 100644 index 00000000..c0c39fb6 --- /dev/null +++ b/tools/release/parseLabKitChangelog.m @@ -0,0 +1,225 @@ +function records = parseLabKitChangelog(filepath) +%PARSELABKITCHANGELOG Parse and validate structured LabKit change records. +% +% Usage: +% records = parseLabKitChangelog(); +% records = parseLabKitChangelog("CHANGELOG.md"); +% +% Inputs: +% filepath - optional changelog path. Defaults to the repository +% CHANGELOG.md associated with this tool. +% +% Outputs: +% records - struct array with title, id, date, type, compatibility, +% components, scopes, sections, and sourceLine fields. Component +% entries contain name, fromVersion, and toVersion; scopes name +% unversioned repository surfaces such as CI or documentation. +% +% The parser reads every entry under `## Structured Change Records`; the +% changelog intentionally has no parallel unstructured history section. + + if nargin < 1 || strlength(string(filepath)) == 0 + root = fileparts(fileparts(fileparts(mfilename('fullpath')))); + filepath = fullfile(root, "CHANGELOG.md"); + end + lines = splitlines(string(fileread(filepath))); + [firstLine, lastLine] = recordSectionBounds(lines); + headingLines = find(startsWith(lines, "### ") & ... + (1:numel(lines)).' >= firstLine & (1:numel(lines)).' <= lastLine); + records = repmat(emptyRecord(), 1, 0); + for k = 1:numel(headingLines) + recordEnd = lastLine; + if k < numel(headingLines) + recordEnd = headingLines(k + 1) - 1; + end + records(end + 1) = parseRecord(lines, headingLines(k), recordEnd); + end + validateRecordSet(records); +end + +function [firstLine, lastLine] = recordSectionBounds(lines) + startLine = find(lines == "## Structured Change Records", 1); + endLine = find(lines == "## Current Version Lookup", 1); + if isempty(startLine) || isempty(endLine) || endLine <= startLine + error('LabKit:Changelog:MissingRecordSection', ... + 'CHANGELOG.md must place Structured Change Records before Current Version Lookup.'); + end + firstLine = startLine + 1; + lastLine = endLine - 1; +end + +function record = parseRecord(lines, headingLine, recordEnd) + record = emptyRecord(); + record.title = extractAfter(lines(headingLine), "### "); + record.sourceLine = headingLine; + cursor = nextContentLine(lines, headingLine + 1, recordEnd); + if cursor > recordEnd || lines(cursor) ~= "```labkit-change" + invalidRecord(record, 'must start with a ```labkit-change metadata block'); + end + cursor = cursor + 1; + while cursor <= recordEnd && lines(cursor) ~= "```" + line = strtrim(lines(cursor)); + if strlength(line) > 0 + [key, value] = metadataPair(line, record); + switch key + case "schema" + record.schema = uniqueScalar(record.schema, value, key, record); + case "id" + record.id = uniqueScalar(record.id, value, key, record); + case "date" + record.date = uniqueScalar(record.date, value, key, record); + case "type" + record.type = uniqueScalar(record.type, value, key, record); + case "compatibility" + record.compatibility = uniqueScalar( ... + record.compatibility, value, key, record); + case "component" + record.components(end + 1) = componentTransition(value, record); + case "scope" + record.scopes(end + 1) = value; + otherwise + invalidRecord(record, 'contains unknown metadata key ' + key); + end + end + cursor = cursor + 1; + end + if cursor > recordEnd + invalidRecord(record, 'has an unterminated metadata block'); + end + record.sections = parseSections(lines, cursor + 1, recordEnd, record); + validateRecord(record); +end + +function [key, value] = metadataPair(line, record) + delimiter = strfind(line, ":"); + if isempty(delimiter) + invalidRecord(record, 'contains metadata without a key delimiter'); + end + key = strtrim(extractBefore(line, delimiter(1))); + value = strtrim(extractAfter(line, delimiter(1))); +end + +function value = uniqueScalar(existing, value, key, record) + if strlength(existing) > 0 + invalidRecord(record, 'repeats metadata key ' + key); + end +end + +function component = componentTransition(value, record) + tokens = regexp(value, ... + '^`([^`]+)`\s*\|\s*`(\d+\.\d+\.\d+)\s*->\s*(\d+\.\d+\.\d+)`$', ... + 'tokens', 'once'); + if isempty(tokens) + invalidRecord(record, ... + 'has an invalid component transition; expected `name` | `X.Y.Z -> X.Y.Z`'); + end + component = struct( ... + "name", string(tokens{1}), ... + "fromVersion", string(tokens{2}), ... + "toVersion", string(tokens{3})); +end + +function sections = parseSections(lines, firstLine, lastLine, record) + sectionNames = [ ... + "Context", "Decision and rationale", "Changes", ... + "User and data impact", "Compatibility and migration", ... + "Validation", "Evidence", "Known limitations and follow-up"]; + fieldNames = [ ... + "context", "decisionAndRationale", "changes", ... + "userAndDataImpact", "compatibilityAndMigration", ... + "validation", "evidence", "knownLimitationsAndFollowUp"]; + sections = cell2struct(cellstr(repmat("", size(fieldNames))), ... + cellstr(fieldNames), 2); + headings = find(startsWith(lines(firstLine:lastLine), "#### ")) + firstLine - 1; + for k = 1:numel(headings) + name = extractAfter(lines(headings(k)), "#### "); + index = find(sectionNames == name, 1); + if isempty(index) + invalidRecord(record, 'contains unknown narrative section ' + name); + end + contentEnd = lastLine; + if k < numel(headings) + contentEnd = headings(k + 1) - 1; + end + content = strtrim(strjoin(lines(headings(k) + 1:contentEnd), newline)); + sections.(fieldNames(index)) = content; + end +end + +function validateRecord(record) + if record.schema ~= "1" + invalidRecord(record, 'uses an unsupported or missing schema version'); + end + if isempty(regexp(record.id, '^LK-\d{8}-[a-z0-9-]+$', 'once')) + invalidRecord(record, 'has an invalid or missing Change ID'); + end + if isempty(regexp(record.date, '^\d{4}-\d{2}-\d{2}$', 'once')) + invalidRecord(record, 'has an invalid or missing ISO date'); + end + try + datetime(record.date, 'InputFormat', 'yyyy-MM-dd'); + catch + invalidRecord(record, 'has a calendar-invalid date'); + end + legalTypes = ["feat", "fix", "perf", "refactor", "test", "ci", "docs", "chore"]; + if ~any(record.type == legalTypes) + invalidRecord(record, 'has an invalid or missing change type'); + end + if ~any(record.compatibility == ["compatible", "additive", "breaking"]) + invalidRecord(record, 'has an invalid or missing compatibility value'); + end + if isempty(record.components) && isempty(record.scopes) + invalidRecord(record, ... + 'must declare at least one component transition or repository scope'); + end + fields = string(fieldnames(record.sections)); + for k = 1:numel(fields) + if strlength(strtrim(record.sections.(fields(k)))) == 0 + invalidRecord(record, 'is missing narrative section ' + fields(k)); + end + end +end + +function validateRecordSet(records) + if isempty(records) + error('LabKit:Changelog:NoStructuredRecords', ... + 'CHANGELOG.md must contain at least one structured change record.'); + end + ids = string({records.id}); + if numel(unique(ids)) ~= numel(ids) + error('LabKit:Changelog:DuplicateId', ... + 'Structured changelog Change IDs must be unique.'); + end + dates = datetime(string({records.date}), 'InputFormat', 'yyyy-MM-dd'); + if any(diff(dates) > days(0)) + error('LabKit:Changelog:DateOrder', ... + 'Structured changelog records must use reverse chronological date order.'); + end +end + +function line = nextContentLine(lines, line, lastLine) + while line <= lastLine && strlength(strtrim(lines(line))) == 0 + line = line + 1; + end +end + +function record = emptyRecord() + record = struct( ... + "title", "", ... + "schema", "", ... + "id", "", ... + "date", "", ... + "type", "", ... + "compatibility", "", ... + "components", repmat(struct( ... + "name", "", "fromVersion", "", "toVersion", ""), 1, 0), ... + "scopes", strings(1, 0), ... + "sections", struct(), ... + "sourceLine", 0); +end + +function invalidRecord(record, detail) + error('LabKit:Changelog:InvalidRecord', ... + 'Structured change record "%s" at line %d %s.', ... + record.title, record.sourceLine, detail); +end From 878ac201de775b1ce61aa4082d1f2ddd2092b536 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 15:19:59 -0500 Subject: [PATCH 12/15] feat: expose complete app version history --- AGENTS.md | 2 + CHANGELOG.md | 323 ++++++++++++++++-- docs/release.md | 8 +- labkit_launcher.m | 226 +++++++++++- .../project/release/ChangelogGuardrailTest.m | 60 +++- .../gui/project/launcher/LauncherGuiTest.m | 70 +++- tools/release/parseLabKitChangelog.m | 64 +++- 7 files changed, 701 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8406de39..dd85375a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -297,6 +297,8 @@ agents. When a change bumps `labkit_launcher.m`, a `+labkit/**/version.m` facade, or an `apps/**/version.m` app metadata file, update `CHANGELOG.md` in the same change with the affected versions, what changed, why it matters, compatibility notes when relevant, optional direction notes, and evidence. +When adding a newly versioned component, record its first version as an +`introduced` event and preserve one continuous chain through current metadata. Organize entries by coherent user-facing or maintainer-facing evolution, not by raw tag rows, commit rows, or issue lists. Release tags are public anchors inside affected versions and evidence; commits are evidence. Every entry uses diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba0278f..de08fbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,10 @@ Commits and PRs belong in `Evidence`, not in the navigation structure. - `Current Version Lookup` is a state table. Keep it current with metadata files, but do not use it to explain history. - A structured record has one stable Change ID and one ISO date. It may list - versioned `component` transitions, unversioned repository `scope` values, or - both. Component transitions always compare directly with the mainline - baseline rather than another commit on the development branch. + versioned `introduced` events, later `component` transitions, unversioned + repository `scope` values, or a combination. Historical events follow the + committed version chain; current branch transitions compare directly with + the mainline baseline rather than intermediate development commits. - `type` uses the repository's Conventional Commit vocabulary. `compatibility` is `compatible`, `additive`, or `breaking`. - Narrative sections are required because metadata alone cannot explain the @@ -43,6 +44,69 @@ Commits and PRs belong in `Evidence`, not in the navigation structure. ## Structured Change Records +### Launcher app version history + +```labkit-change +schema: 1 +id: LK-20260713-launcher-app-version-history +date: 2026-07-13 +type: feat +compatibility: additive +component: `labkit_launcher` | `1.3.0 -> 1.4.0` +``` + +#### Context + +The structured changelog could be parsed by maintainers, but launcher users +could not inspect the history of the selected app. Earlier normalization also +attached broad release descriptions without recording every app version event, +which made histories such as CIC appear to begin years of versions too late. + +#### Decision and rationale + +Expose component-filtered history in the launcher and make exact component +events the durable lookup key. A user should be able to move from the current +app catalog directly to its evolution record without reading Git history or +understanding changelog internals. + +#### Changes + +- Added `labkit_launcher("history", appCommand)` for programmatic history + lookup and a Version History viewer for the selected launcher app. +- Added explicit component introduction events and reconstructed every tracked + launcher, facade, and app version transition from `origin/main` history. +- Added continuous-history validation so missing introductions, gaps, and + current-version mismatches fail the release guardrail. + +#### User and data impact + +Launcher users can inspect dated version transitions, compatibility, rationale, +impact, and evidence for the selected app. App calculations and user data are +unchanged. + +#### Compatibility and migration + +The launcher command and button are additive. Existing list, version, package, +maintenance, and app-launch workflows remain available. + +#### Validation + +`LauncherGuiTest` covers programmatic filtering and the hidden GUI viewer; +`ChangelogGuardrailTest` validates complete version chains against current +metadata. + +#### Evidence + +The carrying commit is located by Change ID +`LK-20260713-launcher-app-version-history`; historical component events were +reconstructed from version-file changes on `origin/main`. + +#### Known limitations and follow-up + +History begins when version metadata was first tracked for each component. +Behavior before that point can only be inferred from older source commits and +is not assigned invented version numbers. + ### Structured evolution records ```labkit-change @@ -69,7 +133,7 @@ delivery state without duplicating it in the changelog. #### Changes - Added schema-v1 metadata for Change ID, date, type, compatibility, component - version transitions, and unversioned repository scopes. + introductions and version transitions, and unversioned repository scopes. - Added a base-MATLAB parser and release guardrail for schema integrity. - Normalized every historical entry into the same schema-v1 record contract. @@ -462,8 +526,8 @@ date: 2026-07-13 type: feat compatibility: compatible component: `labkit.image` | `1.1.0 -> 1.2.0` -component: `labkit_DICPreprocess_app` | `1.3.5 -> 1.3.6` component: `labkit_DICPostprocess_app` | `1.3.4 -> 1.3.5` +component: `labkit_DICPreprocess_app` | `1.3.5 -> 1.3.6` component: `labkit_FocusStack_app` | `1.4.7 -> 1.4.8` component: `labkit_ImageEnhance_app` | `1.5.6 -> 1.5.7` component: `labkit_ImageMatch_app` | `1.5.6 -> 1.5.7` @@ -825,12 +889,15 @@ id: LK-20260707-debug-workflows-launcher-tools-and-changelog-governance date: 2026-07-07 type: feat compatibility: compatible -component: `labkit_launcher` | `1.2.4 -> 1.2.7` +component: `labkit_launcher` | `1.2.4 -> 1.2.5` +component: `labkit_launcher` | `1.2.5 -> 1.2.6` +component: `labkit_launcher` | `1.2.6 -> 1.2.7` +component: `labkit.ui` | `5.0.0 -> 5.0.1` component: `labkit.ui` | `5.0.1 -> 5.0.2` -component: `labkit_FigureStudio_app` | `0.1.4 -> 0.1.5` component: `labkit_DICPreprocess_app` | `1.3.4 -> 1.3.5` component: `labkit_BatchImageCrop_app` | `1.6.6 -> 1.6.7` component: `labkit_FocusStack_app` | `1.4.5 -> 1.4.6` +component: `labkit_FigureStudio_app` | `0.1.4 -> 0.1.5` ``` #### Context @@ -944,24 +1011,28 @@ date: 2026-07-06 type: refactor compatibility: breaking component: `labkit_launcher` | `1.2.3 -> 1.2.4` -component: `labkit.ui` | `4.2.0 -> 5.0.0` -component: `labkit_FigureStudio_app` | `0.1.0 -> 0.1.4` +component: `labkit.ui` | `4.2.0 -> 4.2.1` +component: `labkit.ui` | `4.2.1 -> 5.0.0` +component: `labkit_DICPostprocess_app` | `1.3.3 -> 1.3.4` +component: `labkit_DICPreprocess_app` | `1.3.3 -> 1.3.4` component: `labkit_ChronoOverlay_app` | `1.3.3 -> 1.3.5` component: `labkit_CIC_app` | `1.3.5 -> 1.3.7` component: `labkit_CSC_app` | `1.3.7 -> 1.3.9` component: `labkit_EIS_app` | `1.3.3 -> 1.3.4` component: `labkit_VTResistance_app` | `1.3.5 -> 1.3.7` -component: `labkit_DICPreprocess_app` | `1.3.3 -> 1.3.4` -component: `labkit_DICPostprocess_app` | `1.3.3 -> 1.3.4` component: `labkit_BatchImageCrop_app` | `1.6.5 -> 1.6.6` component: `labkit_CurvatureMeasurement_app` | `1.3.3 -> 1.3.4` component: `labkit_FLIRThermal_app` | `1.2.7 -> 1.2.8` component: `labkit_FocusStack_app` | `1.4.4 -> 1.4.5` component: `labkit_ImageEnhance_app` | `1.5.4 -> 1.5.5` component: `labkit_ImageMatch_app` | `1.5.4 -> 1.5.5` -component: `labkit_RHSPreview_app` | `1.3.3 -> 1.3.4` +introduced: `labkit_FigureStudio_app` | `0.1.0` +component: `labkit_FigureStudio_app` | `0.1.0 -> 0.1.1` +component: `labkit_FigureStudio_app` | `0.1.1 -> 0.1.2` +component: `labkit_FigureStudio_app` | `0.1.2 -> 0.1.4` component: `labkit_NerveResponseAnalysis_app` | `1.3.3 -> 1.3.4` component: `labkit_ResponseReviewStats_app` | `1.3.3 -> 1.3.4` +component: `labkit_RHSPreview_app` | `1.3.3 -> 1.3.4` component: `labkit_ECGPrint_app` | `1.3.4 -> 1.3.5` ``` @@ -1143,8 +1214,10 @@ id: LK-20260703-flir-display-tuning date: 2026-07-03 type: feat compatibility: compatible -component: `labkit_FLIRThermal_app` | `1.2.4 -> 1.2.7` component: `labkit_CSC_app` | `1.3.6 -> 1.3.7` +component: `labkit_FLIRThermal_app` | `1.2.4 -> 1.2.5` +component: `labkit_FLIRThermal_app` | `1.2.5 -> 1.2.6` +component: `labkit_FLIRThermal_app` | `1.2.6 -> 1.2.7` ``` #### Context @@ -1197,6 +1270,23 @@ date: 2026-07-03 type: feat compatibility: compatible component: `labkit.ui` | `4.0.0 -> 4.1.0` +component: `labkit_DICPostprocess_app` | `1.3.2 -> 1.3.3` +component: `labkit_DICPreprocess_app` | `1.3.2 -> 1.3.3` +component: `labkit_ChronoOverlay_app` | `1.3.2 -> 1.3.3` +component: `labkit_CIC_app` | `1.3.4 -> 1.3.5` +component: `labkit_CSC_app` | `1.3.4 -> 1.3.6` +component: `labkit_EIS_app` | `1.3.2 -> 1.3.3` +component: `labkit_VTResistance_app` | `1.3.4 -> 1.3.5` +component: `labkit_BatchImageCrop_app` | `1.6.4 -> 1.6.5` +component: `labkit_CurvatureMeasurement_app` | `1.3.2 -> 1.3.3` +component: `labkit_FLIRThermal_app` | `1.2.3 -> 1.2.4` +component: `labkit_FocusStack_app` | `1.4.3 -> 1.4.4` +component: `labkit_ImageEnhance_app` | `1.5.3 -> 1.5.4` +component: `labkit_ImageMatch_app` | `1.5.3 -> 1.5.4` +component: `labkit_NerveResponseAnalysis_app` | `1.3.2 -> 1.3.3` +component: `labkit_ResponseReviewStats_app` | `1.3.2 -> 1.3.3` +component: `labkit_RHSPreview_app` | `1.3.2 -> 1.3.3` +component: `labkit_ECGPrint_app` | `1.3.3 -> 1.3.4` ``` #### Context @@ -1249,6 +1339,23 @@ date: 2026-07-03 type: refactor compatibility: compatible component: `labkit.ui` | `3.4.5 -> 4.0.0` +component: `labkit_DICPostprocess_app` | `1.3.1 -> 1.3.2` +component: `labkit_DICPreprocess_app` | `1.3.1 -> 1.3.2` +component: `labkit_ChronoOverlay_app` | `1.3.1 -> 1.3.2` +component: `labkit_CIC_app` | `1.3.3 -> 1.3.4` +component: `labkit_CSC_app` | `1.3.3 -> 1.3.4` +component: `labkit_EIS_app` | `1.3.1 -> 1.3.2` +component: `labkit_VTResistance_app` | `1.3.3 -> 1.3.4` +component: `labkit_BatchImageCrop_app` | `1.6.3 -> 1.6.4` +component: `labkit_CurvatureMeasurement_app` | `1.3.1 -> 1.3.2` +component: `labkit_FLIRThermal_app` | `1.2.2 -> 1.2.3` +component: `labkit_FocusStack_app` | `1.4.2 -> 1.4.3` +component: `labkit_ImageEnhance_app` | `1.5.2 -> 1.5.3` +component: `labkit_ImageMatch_app` | `1.5.2 -> 1.5.3` +component: `labkit_NerveResponseAnalysis_app` | `1.3.1 -> 1.3.2` +component: `labkit_ResponseReviewStats_app` | `1.3.1 -> 1.3.2` +component: `labkit_RHSPreview_app` | `1.3.1 -> 1.3.2` +component: `labkit_ECGPrint_app` | `1.3.2 -> 1.3.3` ``` #### Context @@ -1300,6 +1407,17 @@ id: LK-20260703-app-file-selection-and-electrochem-control-fixes date: 2026-07-03 type: fix compatibility: compatible +component: `labkit_CIC_app` | `1.3.1 -> 1.3.2` +component: `labkit_CIC_app` | `1.3.2 -> 1.3.3` +component: `labkit_CSC_app` | `1.3.1 -> 1.3.2` +component: `labkit_CSC_app` | `1.3.2 -> 1.3.3` +component: `labkit_VTResistance_app` | `1.3.1 -> 1.3.2` +component: `labkit_VTResistance_app` | `1.3.2 -> 1.3.3` +component: `labkit_BatchImageCrop_app` | `1.6.2 -> 1.6.3` +component: `labkit_FLIRThermal_app` | `1.2.1 -> 1.2.2` +component: `labkit_FocusStack_app` | `1.4.1 -> 1.4.2` +component: `labkit_ImageEnhance_app` | `1.5.1 -> 1.5.2` +component: `labkit_ImageMatch_app` | `1.5.1 -> 1.5.2` scope: historical project evolution ``` @@ -1355,6 +1473,23 @@ date: 2026-07-03 type: refactor compatibility: compatible component: `labkit.ui` | `3.4.4 -> 3.4.5` +component: `labkit_DICPostprocess_app` | `1.3.0 -> 1.3.1` +component: `labkit_DICPreprocess_app` | `1.3.0 -> 1.3.1` +component: `labkit_ChronoOverlay_app` | `1.3.0 -> 1.3.1` +component: `labkit_CIC_app` | `1.3.0 -> 1.3.1` +component: `labkit_CSC_app` | `1.3.0 -> 1.3.1` +component: `labkit_EIS_app` | `1.3.0 -> 1.3.1` +component: `labkit_VTResistance_app` | `1.3.0 -> 1.3.1` +component: `labkit_BatchImageCrop_app` | `1.6.1 -> 1.6.2` +component: `labkit_CurvatureMeasurement_app` | `1.3.0 -> 1.3.1` +component: `labkit_FLIRThermal_app` | `1.2.0 -> 1.2.1` +component: `labkit_FocusStack_app` | `1.4.0 -> 1.4.1` +component: `labkit_ImageEnhance_app` | `1.5.0 -> 1.5.1` +component: `labkit_ImageMatch_app` | `1.5.0 -> 1.5.1` +component: `labkit_NerveResponseAnalysis_app` | `1.3.0 -> 1.3.1` +component: `labkit_ResponseReviewStats_app` | `1.3.0 -> 1.3.1` +component: `labkit_RHSPreview_app` | `1.3.0 -> 1.3.1` +component: `labkit_ECGPrint_app` | `1.3.1 -> 1.3.2` ``` #### Context @@ -1458,8 +1593,10 @@ id: LK-20260702-profiling-and-validation-speedups date: 2026-07-02 type: ci compatibility: compatible -component: `labkit_launcher` | `1.2.0 -> 1.2.2` -component: `labkit.ui` | `3.4.0 -> 3.4.2` +component: `labkit_launcher` | `1.2.0 -> 1.2.1` +component: `labkit_launcher` | `1.2.1 -> 1.2.2` +component: `labkit.ui` | `3.4.0 -> 3.4.1` +component: `labkit.ui` | `3.4.1 -> 3.4.2` component: `labkit_BatchImageCrop_app` | `1.6.0 -> 1.6.1` component: `labkit_ECGPrint_app` | `1.3.0 -> 1.3.1` ``` @@ -1568,6 +1705,23 @@ date: 2026-07-01 type: feat compatibility: compatible component: `labkit.ui` | `3.3.1 -> 3.4.0` +component: `labkit_DICPostprocess_app` | `1.2.4 -> 1.3.0` +component: `labkit_DICPreprocess_app` | `1.2.2 -> 1.3.0` +component: `labkit_ChronoOverlay_app` | `1.2.1 -> 1.3.0` +component: `labkit_CIC_app` | `1.2.1 -> 1.3.0` +component: `labkit_CSC_app` | `1.2.1 -> 1.3.0` +component: `labkit_EIS_app` | `1.2.1 -> 1.3.0` +component: `labkit_VTResistance_app` | `1.2.1 -> 1.3.0` +component: `labkit_BatchImageCrop_app` | `1.5.1 -> 1.6.0` +component: `labkit_CurvatureMeasurement_app` | `1.2.4 -> 1.3.0` +component: `labkit_FLIRThermal_app` | `1.1.2 -> 1.2.0` +component: `labkit_FocusStack_app` | `1.3.0 -> 1.4.0` +component: `labkit_ImageEnhance_app` | `1.4.1 -> 1.5.0` +component: `labkit_ImageMatch_app` | `1.4.1 -> 1.5.0` +component: `labkit_NerveResponseAnalysis_app` | `1.2.4 -> 1.3.0` +component: `labkit_ResponseReviewStats_app` | `1.2.3 -> 1.3.0` +component: `labkit_RHSPreview_app` | `1.2.4 -> 1.3.0` +component: `labkit_ECGPrint_app` | `1.2.2 -> 1.3.0` ``` #### Context @@ -1620,9 +1774,16 @@ id: LK-20260701-image-app-workflow-improvements date: 2026-07-01 type: feat compatibility: compatible -component: `labkit.image` | `1.0.0 -> 1.1.0` -component: `labkit.ui` | `3.2.10 -> 3.3.1` component: `labkit_launcher` | `1.1.5 -> 1.1.6` +component: `labkit.image` | `1.0.0 -> 1.1.0` +component: `labkit.ui` | `3.2.10 -> 3.3.0` +component: `labkit.ui` | `3.3.0 -> 3.3.1` +component: `labkit_BatchImageCrop_app` | `1.4.0 -> 1.5.0` +component: `labkit_BatchImageCrop_app` | `1.5.0 -> 1.5.1` +component: `labkit_FLIRThermal_app` | `1.0.0 -> 1.1.0` +component: `labkit_FLIRThermal_app` | `1.1.0 -> 1.1.2` +component: `labkit_ImageEnhance_app` | `1.4.0 -> 1.4.1` +component: `labkit_ImageMatch_app` | `1.4.0 -> 1.4.1` ``` #### Context @@ -1678,7 +1839,9 @@ id: LK-20260701-thermal-facade-and-flir-app date: 2026-07-01 type: feat compatibility: compatible +introduced: `labkit.thermal` | `1.0.0` component: `labkit.ui` | `3.2.9 -> 3.2.10` +introduced: `labkit_FLIRThermal_app` | `1.0.0` ``` #### Context @@ -1731,7 +1894,8 @@ id: LK-20260701-launcher-update-reliability date: 2026-07-01 type: fix compatibility: compatible -component: `labkit_launcher` | `1.1.3 -> 1.1.5` +component: `labkit_launcher` | `1.1.3 -> 1.1.4` +component: `labkit_launcher` | `1.1.4 -> 1.1.5` ``` #### Context @@ -1780,6 +1944,12 @@ id: LK-20260630-shared-image-facade date: 2026-06-30 type: feat compatibility: compatible +introduced: `labkit.image` | `1.0.0` +component: `labkit_BatchImageCrop_app` | `1.3.9 -> 1.4.0` +component: `labkit_CurvatureMeasurement_app` | `1.2.3 -> 1.2.4` +component: `labkit_FocusStack_app` | `1.2.5 -> 1.3.0` +component: `labkit_ImageEnhance_app` | `1.3.5 -> 1.4.0` +component: `labkit_ImageMatch_app` | `1.3.5 -> 1.4.0` scope: historical project evolution ``` @@ -1834,6 +2004,13 @@ id: LK-20260630-migration-helper-cleanup date: 2026-06-30 type: refactor compatibility: compatible +component: `labkit.ui` | `3.2.8 -> 3.2.9` +component: `labkit_DICPostprocess_app` | `1.2.3 -> 1.2.4` +component: `labkit_BatchImageCrop_app` | `1.3.7 -> 1.3.8` +component: `labkit_BatchImageCrop_app` | `1.3.8 -> 1.3.9` +component: `labkit_ImageEnhance_app` | `1.3.4 -> 1.3.5` +component: `labkit_RHSPreview_app` | `1.2.2 -> 1.2.3` +component: `labkit_RHSPreview_app` | `1.2.3 -> 1.2.4` scope: historical project evolution ``` @@ -1887,6 +2064,19 @@ date: 2026-06-30 type: feat compatibility: compatible component: `labkit.ui` | `3.2.7 -> 3.2.8` +component: `labkit_DICPostprocess_app` | `1.2.2 -> 1.2.3` +component: `labkit_DICPreprocess_app` | `1.2.1 -> 1.2.2` +component: `labkit_ChronoOverlay_app` | `1.2.0 -> 1.2.1` +component: `labkit_CIC_app` | `1.2.0 -> 1.2.1` +component: `labkit_CSC_app` | `1.2.0 -> 1.2.1` +component: `labkit_EIS_app` | `1.2.0 -> 1.2.1` +component: `labkit_VTResistance_app` | `1.2.0 -> 1.2.1` +component: `labkit_BatchImageCrop_app` | `1.3.6 -> 1.3.7` +component: `labkit_CurvatureMeasurement_app` | `1.2.2 -> 1.2.3` +component: `labkit_FocusStack_app` | `1.2.4 -> 1.2.5` +component: `labkit_ImageEnhance_app` | `1.3.3 -> 1.3.4` +component: `labkit_ImageMatch_app` | `1.3.4 -> 1.3.5` +component: `labkit_ECGPrint_app` | `1.2.1 -> 1.2.2` ``` #### Context @@ -1939,6 +2129,19 @@ date: 2026-06-30 type: feat compatibility: compatible component: `labkit.ui` | `3.2.6 -> 3.2.7` +component: `labkit_DICPostprocess_app` | `1.2.1 -> 1.2.2` +component: `labkit_BatchImageCrop_app` | `1.3.4 -> 1.3.5` +component: `labkit_BatchImageCrop_app` | `1.3.5 -> 1.3.6` +component: `labkit_CurvatureMeasurement_app` | `1.2.1 -> 1.2.2` +component: `labkit_FocusStack_app` | `1.2.2 -> 1.2.3` +component: `labkit_FocusStack_app` | `1.2.3 -> 1.2.4` +component: `labkit_ImageEnhance_app` | `1.3.2 -> 1.3.3` +component: `labkit_ImageMatch_app` | `1.3.2 -> 1.3.3` +component: `labkit_ImageMatch_app` | `1.3.3 -> 1.3.4` +component: `labkit_NerveResponseAnalysis_app` | `1.2.3 -> 1.2.4` +component: `labkit_ResponseReviewStats_app` | `1.2.2 -> 1.2.3` +component: `labkit_RHSPreview_app` | `1.2.1 -> 1.2.2` +component: `labkit_ECGPrint_app` | `1.2.0 -> 1.2.1` ``` #### Context @@ -1993,6 +2196,14 @@ date: 2026-06-30 type: feat compatibility: compatible component: `labkit.ui` | `3.2.5 -> 3.2.6` +component: `labkit_DICPostprocess_app` | `1.2.0 -> 1.2.1` +component: `labkit_DICPreprocess_app` | `1.2.0 -> 1.2.1` +component: `labkit_BatchImageCrop_app` | `1.3.3 -> 1.3.4` +component: `labkit_FocusStack_app` | `1.2.1 -> 1.2.2` +component: `labkit_ImageEnhance_app` | `1.3.1 -> 1.3.2` +component: `labkit_ImageMatch_app` | `1.3.1 -> 1.3.2` +component: `labkit_NerveResponseAnalysis_app` | `1.2.1 -> 1.2.3` +component: `labkit_ResponseReviewStats_app` | `1.2.1 -> 1.2.2` ``` #### Context @@ -2045,7 +2256,8 @@ id: LK-20260630-file-panel-layout-stabilization date: 2026-06-30 type: fix compatibility: compatible -component: `labkit.ui` | `3.2.3 -> 3.2.5` +component: `labkit.ui` | `3.2.3 -> 3.2.4` +component: `labkit.ui` | `3.2.4 -> 3.2.5` ``` #### Context @@ -2093,7 +2305,12 @@ id: LK-20260629-tool-panel-hosts-and-image-app-fixes date: 2026-06-29 type: fix compatibility: compatible -component: `labkit.ui` | `3.2.0 -> 3.2.3` +component: `labkit.ui` | `3.2.0 -> 3.2.2` +component: `labkit.ui` | `3.2.2 -> 3.2.3` +component: `labkit_BatchImageCrop_app` | `1.3.2 -> 1.3.3` +component: `labkit_CurvatureMeasurement_app` | `1.2.0 -> 1.2.1` +component: `labkit_ImageEnhance_app` | `1.3.0 -> 1.3.1` +component: `labkit_ImageMatch_app` | `1.3.0 -> 1.3.1` ``` #### Context @@ -2147,8 +2364,8 @@ id: LK-20260629-ui-diagnostics-and-release-v3-0-0 date: 2026-06-29 type: feat compatibility: compatible -component: `labkit.ui` | `3.1.3 -> 3.2.0` component: `labkit_launcher` | `1.1.2 -> 1.1.3` +component: `labkit.ui` | `3.1.3 -> 3.2.0` ``` #### Context @@ -2202,6 +2419,8 @@ id: LK-20260629-protected-image-enhancement-workflows date: 2026-06-29 type: feat compatibility: compatible +component: `labkit_ImageEnhance_app` | `1.2.2 -> 1.3.0` +component: `labkit_ImageMatch_app` | `1.2.1 -> 1.3.0` scope: historical project evolution ``` @@ -2253,7 +2472,18 @@ id: LK-20260628-app-diagnostics-and-hardened-ui-workflows date: 2026-06-28 type: feat compatibility: compatible -component: `labkit.ui` | `3.1.0 -> 3.1.3` +component: `labkit_launcher` | `1.1.1 -> 1.1.2` +component: `labkit.ui` | `3.1.0 -> 3.1.2` +component: `labkit.ui` | `3.1.2 -> 3.1.3` +component: `labkit_BatchImageCrop_app` | `1.3.0 -> 1.3.1` +component: `labkit_BatchImageCrop_app` | `1.3.1 -> 1.3.2` +component: `labkit_FocusStack_app` | `1.2.0 -> 1.2.1` +component: `labkit_ImageEnhance_app` | `1.2.0 -> 1.2.1` +component: `labkit_ImageEnhance_app` | `1.2.1 -> 1.2.2` +component: `labkit_ImageMatch_app` | `1.2.0 -> 1.2.1` +component: `labkit_NerveResponseAnalysis_app` | `1.2.0 -> 1.2.1` +component: `labkit_ResponseReviewStats_app` | `1.2.0 -> 1.2.1` +component: `labkit_RHSPreview_app` | `1.2.0 -> 1.2.1` ``` #### Context @@ -2308,6 +2538,7 @@ date: 2026-06-28 type: feat compatibility: compatible component: `labkit.ui` | `3.0.1 -> 3.1.0` +component: `labkit_BatchImageCrop_app` | `1.2.0 -> 1.3.0` ``` #### Context @@ -2357,7 +2588,8 @@ id: LK-20260626-launcher-manager-and-stale-callback-fix date: 2026-06-26 type: fix compatibility: compatible -component: `labkit_launcher` | `1.0.0 -> 1.1.1` +component: `labkit_launcher` | `1.0.0 -> 1.1.0` +component: `labkit_launcher` | `1.1.0 -> 1.1.1` component: `labkit.ui` | `3.0.0 -> 3.0.1` ``` @@ -2412,6 +2644,22 @@ type: refactor compatibility: breaking component: `labkit.dta` | `1.0.0 -> 2.0.0` component: `labkit.ui` | `2.2.1 -> 3.0.0` +component: `labkit_DICPostprocess_app` | `1.0.1 -> 1.2.0` +component: `labkit_DICPreprocess_app` | `1.0.1 -> 1.2.0` +component: `labkit_ChronoOverlay_app` | `1.0.0 -> 1.2.0` +component: `labkit_CIC_app` | `1.0.0 -> 1.2.0` +component: `labkit_CSC_app` | `1.0.0 -> 1.2.0` +component: `labkit_EIS_app` | `1.0.0 -> 1.2.0` +component: `labkit_VTResistance_app` | `1.0.0 -> 1.2.0` +component: `labkit_BatchImageCrop_app` | `1.0.0 -> 1.2.0` +component: `labkit_CurvatureMeasurement_app` | `1.0.1 -> 1.2.0` +component: `labkit_FocusStack_app` | `1.0.0 -> 1.2.0` +component: `labkit_ImageEnhance_app` | `1.0.0 -> 1.2.0` +component: `labkit_ImageMatch_app` | `1.0.0 -> 1.2.0` +component: `labkit_NerveResponseAnalysis_app` | `1.0.0 -> 1.2.0` +component: `labkit_ResponseReviewStats_app` | `1.0.0 -> 1.2.0` +component: `labkit_RHSPreview_app` | `1.0.0 -> 1.2.0` +component: `labkit_ECGPrint_app` | `1.0.0 -> 1.2.0` ``` #### Context @@ -2465,7 +2713,24 @@ id: LK-20260623-version-metadata-baseline date: 2026-06-23 type: feat compatibility: compatible +introduced: `labkit_launcher` | `1.0.0` component: `labkit.ui` | `2.1.0 -> 2.2.0` +introduced: `labkit_DICPostprocess_app` | `1.0.0` +introduced: `labkit_DICPreprocess_app` | `1.0.0` +introduced: `labkit_ChronoOverlay_app` | `1.0.0` +introduced: `labkit_CIC_app` | `1.0.0` +introduced: `labkit_CSC_app` | `1.0.0` +introduced: `labkit_EIS_app` | `1.0.0` +introduced: `labkit_VTResistance_app` | `1.0.0` +introduced: `labkit_BatchImageCrop_app` | `1.0.0` +introduced: `labkit_CurvatureMeasurement_app` | `1.0.0` +introduced: `labkit_FocusStack_app` | `1.0.0` +introduced: `labkit_ImageEnhance_app` | `1.0.0` +introduced: `labkit_ImageMatch_app` | `1.0.0` +introduced: `labkit_NerveResponseAnalysis_app` | `1.0.0` +introduced: `labkit_ResponseReviewStats_app` | `1.0.0` +introduced: `labkit_RHSPreview_app` | `1.0.0` +introduced: `labkit_ECGPrint_app` | `1.0.0` ``` #### Context @@ -2520,7 +2785,15 @@ id: LK-20260623-facade-contract-baseline-and-release-validation-hardening date: 2026-06-23 type: ci compatibility: compatible -component: `labkit.ui` | `2.0.0 -> 2.2.1` +introduced: `labkit.biosignal` | `1.0.0` +introduced: `labkit.dta` | `1.0.0` +introduced: `labkit.rhs` | `1.0.0` +introduced: `labkit.ui` | `2.0.0` +component: `labkit.ui` | `2.0.0 -> 2.1.0` +component: `labkit.ui` | `2.2.0 -> 2.2.1` +component: `labkit_DICPostprocess_app` | `1.0.0 -> 1.0.1` +component: `labkit_DICPreprocess_app` | `1.0.0 -> 1.0.1` +component: `labkit_CurvatureMeasurement_app` | `1.0.0 -> 1.0.1` ``` #### Context @@ -2637,7 +2910,7 @@ the `origin/main` merge-base values. | Component | Current version | Family | Metadata location | |---|---:|---|---| -| `labkit_launcher` | `1.3.0` | Launcher | `labkit_launcher.m` | +| `labkit_launcher` | `1.4.0` | Launcher | `labkit_launcher.m` | | `labkit.ui` | `5.1.0` | Facade | `+labkit/+ui/version.m` | | `labkit.dta` | `2.0.1` | Facade | `+labkit/+dta/version.m` | | `labkit.image` | `2.0.0` | Facade | `+labkit/+image/version.m` | diff --git a/docs/release.md b/docs/release.md index bb4cf70c..6192499c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -83,8 +83,9 @@ component versions carry it, and where the audit evidence lives. The changelog has one format for current and historical records: - Keep every entry under `Structured Change Records` with a stable Change ID, - ISO date, Conventional Commit type, compatibility value, and either exact - component version transitions or an unversioned repository scope. + ISO date, Conventional Commit type, compatibility value, and exact component + introduction or version-transition events, an unversioned repository scope, + or both. - Keep the required narrative sections for context, decision and rationale, changes, user and data impact, compatibility and migration, validation, evidence, and known limitations or follow-up. @@ -95,6 +96,9 @@ The changelog has one format for current and historical records: - Keep the current version lookup synchronized with every launcher, facade, and app metadata file. Development-branch transitions compare directly with the merge base from `origin/main`, not with intermediate branch versions. +- Keep one `introduced` event and a continuous transition chain for every + versioned component. Do not invent versions for source history that predates + the component's first tracked metadata. - Parse and validate the complete history with `addpath("tools/release"); parseLabKitChangelog()`. Do not maintain a second unstructured history or duplicate the parser grammar in another document. diff --git a/labkit_launcher.m b/labkit_launcher.m index 9a7ed805..717688fa 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -5,6 +5,7 @@ % labkit_launcher % apps = labkit_launcher("list") % info = labkit_launcher("version") +% records = labkit_launcher("history", appCommand) % % This launcher intentionally avoids dependencies on other LabKit .m files so % it can still restore a damaged zip install when packages, apps, or scripts @@ -15,6 +16,7 @@ % Section: Public entrypoint and version % Section: Path setup % Section: Main launcher window +% Section: App version history window % Section: Version manager window % Section: Version manager support % Section: Table selection and display helpers @@ -30,7 +32,7 @@ % Section: Update install file operations % Section: Shared filesystem and path helpers - mode = parseMode(varargin); + [mode, modeArgs] = parseMode(varargin); if mode == "version" varargout = {launcherVersion()}; return; @@ -42,6 +44,11 @@ varargout = {appCatalogTable(apps)}; return; end + if mode == "history" + root = fileparts(mfilename('fullpath')); + varargout = {launcherAppHistory(root, modeArgs(1))}; + return; + end if nargout > 1 error('labkit_launcher:TooManyOutputs', ... 'labkit_launcher returns at most the launcher figure handle.'); @@ -57,16 +64,33 @@ %% Section: Public entrypoint and version -function mode = parseMode(args) +function [mode, modeArgs] = parseMode(args) mode = "gui"; + modeArgs = strings(1, 0); if isempty(args) return; end + first = string(args{1}); + if numel(args) == 2 && isscalar(first) && strcmpi(first, "history") + command = string(args{2}); + if ~isscalar(command) || strlength(strtrim(command)) == 0 + error('labkit_launcher:InvalidInput', ... + 'History mode requires one nonempty app command.'); + end + mode = "history"; + modeArgs = command; + return; + end if numel(args) ~= 1 error('labkit_launcher:InvalidInput', ... - 'labkit_launcher accepts no inputs or the string "list" or "version".'); + ['labkit_launcher accepts no inputs, "list", "version", or ' ... + '"history" plus one app command.']); end value = string(args{1}); + if ~isscalar(value) + error('labkit_launcher:InvalidInput', ... + 'labkit_launcher mode must be a string scalar.'); + end if strlength(strtrim(value)) == 0 error('labkit_launcher:InvalidInput', ... 'Unsupported labkit_launcher mode: empty string.'); @@ -87,8 +111,8 @@ info = struct( ... "name", "labkit_launcher", ... "displayName", "LabKit App Launcher", ... - "version", "1.3.0", ... - "updated", "2026-07-09"); + "version", "1.4.0", ... + "updated", "2026-07-13"); end function titleText = launcherVersionTitle() @@ -149,8 +173,8 @@ function launchFigureStudioFromAxes(root, ax) rightPanel.Layout.Row = 1; rightPanel.Layout.Column = 3; - controlsGrid = uigridlayout(leftPanel, [8 1]); - controlsGrid.RowHeight = {34, 34, 34, 34, 34, 34, 34, '1x'}; + controlsGrid = uigridlayout(leftPanel, [9 1]); + controlsGrid.RowHeight = {34, 34, 34, 34, 34, 34, 34, 34, '1x'}; controlsGrid.Padding = [6 6 6 6]; controlsGrid.RowSpacing = 6; @@ -188,8 +212,13 @@ function launchFigureStudioFromAxes(root, ax) 'ButtonPushedFcn', @onLaunchSelected); btnDebug = uibutton(controlsGrid, 'Text', 'Open Debug', ... 'ButtonPushedFcn', @onLaunchSelectedDebug); + btnHistory = uibutton(controlsGrid, 'Text', 'Version History', ... + 'ButtonPushedFcn', @onOpenSelectedHistory); + if isprop(btnHistory, 'Tooltip') + btnHistory.Tooltip = 'Show structured changelog records for the selected app.'; + end packageGrid = uigridlayout(controlsGrid, [1 2]); - packageGrid.Layout.Row = 5; + packageGrid.Layout.Row = 6; packageGrid.Layout.Column = 1; packageGrid.ColumnWidth = {'1x', '1x'}; packageGrid.RowHeight = {'1x'}; @@ -206,7 +235,7 @@ function launchFigureStudioFromAxes(root, ax) btnClean = uibutton(controlsGrid, 'Text', 'Clean Artifacts', ... 'ButtonPushedFcn', @onCleanArtifacts); maintenanceGrid = uigridlayout(controlsGrid, [1 2]); - maintenanceGrid.Layout.Row = 7; + maintenanceGrid.Layout.Row = 8; maintenanceGrid.Layout.Column = 1; maintenanceGrid.ColumnWidth = {'1x', '1x'}; maintenanceGrid.RowHeight = {'1x'}; @@ -340,6 +369,29 @@ function onLaunchSelectedDebug(varargin) launchSelectedApp(true); end + function onOpenSelectedHistory(varargin) + if isempty(state.visibleApps) + setStatus('No app is available for version history.'); + return; + end + row = min(max(state.selectedRow, 1), numel(state.visibleApps)); + app = state.visibleApps(row); + try + records = launcherAppHistory(root, app.command); + if isempty(records) + setStatus(sprintf('No structured version history found for %s.', ... + app.command)); + return; + end + openAppHistoryViewer(fig, app, records); + setStatus(sprintf('Opened %d version history record(s) for %s.', ... + numel(records), app.command)); + catch err + setStatus(sprintf('Version history unavailable for %s: %s', ... + app.command, err.message)); + end + end + function onArmPerformanceProfile(varargin) if state.actionBusy return; @@ -650,6 +702,7 @@ function setLaunchEnabled(enabled) stateValue = matlab.lang.OnOffSwitchState(enabled); btnOpen.Enable = stateValue; btnDebug.Enable = stateValue; + btnHistory.Enable = stateValue; btnProfile.Enable = matlab.lang.OnOffSwitchState(enabled && state.tools.profile); btnPackage.Enable = matlab.lang.OnOffSwitchState(enabled && state.tools.package); btnPackagePcode.Enable = matlab.lang.OnOffSwitchState(enabled && state.tools.package); @@ -674,6 +727,7 @@ function setLauncherControlsEnabled(enabled) btnVersions.Enable = stateValue; btnRefresh.Enable = stateValue; btnClean.Enable = stateValue; + btnHistory.Enable = stateValue; if enabled setLaunchEnabled(~isempty(state.visibleApps)); else @@ -742,6 +796,160 @@ function initializeAppPath(app) addPathIfMissing(app.folder, '-end'); end +%% Section: App version history window + +function records = launcherAppHistory(root, appCommand) + changelogFile = fullfile(root, 'CHANGELOG.md'); + if exist(changelogFile, 'file') ~= 2 + error('labkit_launcher:ChangelogUnavailable', ... + 'CHANGELOG.md is missing. Update or repair the LabKit install.'); + end + parserFile = matlabCodeFile(fullfile(root, 'tools', 'release', ... + 'parseLabKitChangelog')); + if strlength(string(parserFile)) == 0 + error('labkit_launcher:ChangelogParserUnavailable', ... + ['Changelog parser is missing. Restore tools/release or update ' ... + 'the LabKit install.']); + end + parserFolder = fileparts(parserFile); + addedParserPath = ~pathContains(parserFolder); + addPathIfMissing(parserFolder); + if addedParserPath + parserPathCleanup = onCleanup(@() rmpath(parserFolder)); + end + + records = parseLabKitChangelog(changelogFile); + command = string(appCommand); + keep = false(1, numel(records)); + for k = 1:numel(records) + components = records(k).components; + if ~isempty(components) + keep(k) = any(string({components.name}) == command); + end + end + records = records(keep); + clear parserPathCleanup; +end + +function viewer = openAppHistoryViewer(parentFig, app, records) + % A stable desktop-sized surface keeps five table columns and narrative + % details readable without resizing when records change. + viewerArgs = {'Name', sprintf('%s Version History', app.displayName), ... + 'Position', centeredChildPosition(parentFig, [980 640]), ... + 'Color', [0.97 0.98 0.99]}; + if launcherGuiTestMode() == "hidden" + viewerArgs = [viewerArgs, {'Visible', 'off'}]; + end + viewer = uifigure(viewerArgs{:}); + applyLauncherGuiTestMode(viewer); + grid = uigridlayout(viewer, [4 1]); + % Fixed header, table, and command rows leave remaining height to details. + grid.RowHeight = {32, 230, '1x', 34}; + grid.Padding = [8 8 8 8]; + grid.RowSpacing = 6; + + uilabel(grid, 'Text', sprintf('%s | current version %s | %d record(s)', ... + app.displayName, app.version, numel(records)), ... + 'FontWeight', 'bold', 'FontSize', 14); + historyTable = uitable(grid, ... + 'ColumnName', {'Date', 'Version change', 'Type', 'Compatibility', 'Change'}, ... + 'ColumnEditable', false(1, 5), 'RowName', {}, ... + 'Data', appHistoryRows(records, app.command)); + historyTable.ColumnWidth = {100, 150, 90, 110, '1x'}; + details = uitextarea(grid, 'Editable', 'off'); + uibutton(grid, 'Text', 'Close', ... + 'ButtonPushedFcn', @(~, ~) close(viewer)); + configureTable(historyTable, @onHistorySelection, @onHistorySelection); + selectTableRow(historyTable, 1, records); + showRecord(1); + + function onHistorySelection(~, event) + row = eventRow(event); + if ~isnan(row) + showRecord(row); + end + end + + function showRecord(row) + row = min(max(round(row), 1), numel(records)); + details.Value = cellstr(historyRecordDetails(records(row), app.command)); + end +end + +function rows = appHistoryRows(records, appCommand) + rows = cell(numel(records), 5); + for k = 1:numel(records) + rows{k, 1} = char(records(k).date); + rows{k, 2} = char(appHistoryTransition(records(k), appCommand)); + rows{k, 3} = char(records(k).type); + rows{k, 4} = char(records(k).compatibility); + rows{k, 5} = char(records(k).title); + end +end + +function transition = appHistoryTransition(record, appCommand) + transition = ""; + components = record.components; + if isempty(components) + return; + end + indices = find(string({components.name}) == string(appCommand)); + values = strings(1, numel(indices)); + for k = 1:numel(indices) + component = components(indices(k)); + if component.kind == "introduced" + values(k) = "Introduced at " + component.toVersion; + else + values(k) = component.fromVersion + " -> " + component.toVersion; + end + end + transition = strjoin(values, ", "); +end + +function lines = historyRecordDetails(record, appCommand) + transition = appHistoryTransition(record, appCommand); + sections = record.sections; + lines = [ ... + record.title + "Change ID: " + record.id + "Date: " + record.date + "Version: " + transition + "Type: " + record.type + " | Compatibility: " + record.compatibility + "" + "Context" + string(sections.context) + "" + "Decision and rationale" + string(sections.decisionAndRationale) + "" + "Changes" + string(sections.changes) + "" + "User and data impact" + string(sections.userAndDataImpact) + "" + "Compatibility and migration" + string(sections.compatibilityAndMigration) + "" + "Validation" + string(sections.validation) + "" + "Evidence" + string(sections.evidence) + "" + "Known limitations and follow-up" + string(sections.knownLimitationsAndFollowUp)]; + lines = splitlines(strjoin(lines, newline)); +end + +function position = centeredChildPosition(parentFig, childSize) + parentPosition = parentFig.Position; + position = [ ... + parentPosition(1) + max(20, (parentPosition(3) - childSize(1)) / 2), ... + parentPosition(2) + max(20, (parentPosition(4) - childSize(2)) / 2), ... + childSize]; +end + %% Section: Version manager window function manager = openVersionManager(parentFig, root, refreshCallback, statusCallback) diff --git a/tests/cases/contract/project/release/ChangelogGuardrailTest.m b/tests/cases/contract/project/release/ChangelogGuardrailTest.m index f4aa648c..07eeec72 100644 --- a/tests/cases/contract/project/release/ChangelogGuardrailTest.m +++ b/tests/cases/contract/project/release/ChangelogGuardrailTest.m @@ -76,6 +76,43 @@ function currentLookupMatchesVersionMetadata(testCase) 'file with its current version: ' strjoin(cellstr(missing), ', ')]); end + function structuredHistoryCoversEveryVersionedComponent(testCase) + root = setupLabKitTestPath(); + toolFolder = fullfile(root, "tools", "release"); + addpath(toolFolder); + cleanup = onCleanup(@() rmpath(toolFolder)); + history = parseLabKitChangelog(fullfile(root, "CHANGELOG.md")); + events = [history.components]; + metadata = collectVersionMetadataRecords(root); + + missing = strings(1, 0); + for k = 1:numel(metadata) + record = metadata(k); + componentEvents = events(string({events.name}) == record.component); + introductions = componentEvents( ... + string({componentEvents.kind}) == "introduced"); + transitions = componentEvents( ... + string({componentEvents.kind}) == "transition"); + if numel(introductions) ~= 1 + missing(end + 1) = record.component + " introduction"; + continue; + end + fromVersions = string({transitions.fromVersion}); + toVersions = [introductions.toVersion, ... + string({transitions.toVersion})]; + terminal = toVersions(~ismember(toVersions, fromVersions)); + if numel(terminal) ~= 1 || terminal ~= record.version + missing(end + 1) = record.component + " terminal " + ... + strjoin(terminal, ",") + " != " + record.version; + end + end + + testCase.verifyEmpty(missing, ... + "Every versioned component needs a continuous history ending at metadata: " + ... + strjoin(missing, "; ")); + clear cleanup + end + function releaseDocsDefineChangelogContract(testCase) root = setupLabKitTestPath(); releaseDoc = string(fileread(fullfile(root, "docs", "release.md"))); @@ -88,6 +125,7 @@ function releaseDocsDefineChangelogContract(testCase) "stable Change ID", ... "current version lookup", ... "parseLabKitChangelog", ... + "continuous transition chain", ... "decision and rationale", ... "When a change bumps `labkit_launcher.m`" ... ]; @@ -107,7 +145,7 @@ function releaseDocsDefineChangelogContract(testCase) files = [string(fullfile(root, "labkit_launcher.m")), ... collectFiles(fullfile(root, "+labkit"), "version.m"), ... collectFiles(fullfile(root, "apps"), "version.m")]; - records = repmat(struct("path", "", "version", ""), 1, 0); + records = repmat(struct("path", "", "component", "", "version", ""), 1, 0); for k = 1:numel(files) filepath = files(k); rel = string(relativePath(root, filepath)); @@ -115,7 +153,25 @@ function releaseDocsDefineChangelogContract(testCase) if strlength(version) == 0 continue; end - records(end+1) = struct("path", rel, "version", version); + component = componentInText(string(fileread(filepath)), rel); + records(end+1) = struct( ... + "path", rel, "component", component, "version", version); + end +end + +function component = componentInText(text, path) + if path == "labkit_launcher.m" + component = "labkit_launcher"; + return; + end + value = regexp(text, '["'']name["'']\s*,\s*["'']([^"'']+)["'']', ... + "tokens", "once"); + if isempty(value) + value = regexp(text, 'versionInfo\(\s*["'']([^"'']+)["'']', ... + "tokens", "once"); + component = "labkit." + string(value{1}); + else + component = string(value{1}); end end diff --git a/tests/cases/gui/project/launcher/LauncherGuiTest.m b/tests/cases/gui/project/launcher/LauncherGuiTest.m index 955f6e80..e2b770d3 100644 --- a/tests/cases/gui/project/launcher/LauncherGuiTest.m +++ b/tests/cases/gui/project/launcher/LauncherGuiTest.m @@ -28,6 +28,52 @@ function launcher_list_mode_discovers_apps(testCase) 'labkit_launcher list mode should discover app entry points.'); end + function launcher_history_mode_returns_structured_app_records(testCase) + setupLabKitTestPath(); + + records = labkit_launcher("history", "labkit_DICPreprocess_app"); + + testCase.verifyNotEmpty(records); + testCase.verifyTrue(all(string({records.schema}) == "1")); + testCase.verifyTrue(any(string({records.id}) == ... + "LK-20260713-dic-rigid-point-editor")); + transitions = strings(1, 0); + for k = 1:numel(records) + components = records(k).components; + index = find(string({components.name}) == ... + "labkit_DICPreprocess_app", 1); + if ~isempty(index) + transitions(end + 1) = components(index).fromVersion + ... + " -> " + components(index).toVersion; + end + end + testCase.verifyTrue(any(transitions == "1.3.6 -> 1.4.0")); + end + + function launcher_opens_selected_app_version_history(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); + + fig = labkit_launcher(); + drawnow; + h.invokeButton(fig, 'Version History'); + drawnow; + + viewers = findall(groot, 'Type', 'figure', '-regexp', ... + 'Name', 'Version History$'); + testCase.verifyNotEmpty(viewers, ... + 'Version History should open for the selected launcher app.'); + tables = findall(viewers(1), 'Type', 'uitable'); + textAreas = findall(viewers(1), 'Type', 'uitextarea'); + testCase.verifyNotEmpty(tables); + testCase.verifyGreaterThan(size(tables(1).Data, 1), 0); + testCase.verifyTrue(any(contains(string(textAreas(1).Value), ... + 'Change ID:'))); + clear cleanup + end + function launcher_list_mode_discovers_local_private_apps(testCase) root = setupLabKitTestPath(); @@ -251,7 +297,7 @@ function launcher_runs_when_only_launcher_file_exists(testCase) testCase.verifyEqual(string(fig.Name), ... info.displayName + " v" + info.version + " (" + info.updated + ")"); h.assertButtonContract(fig, {'Latest', 'Release', 'Versions', ... - 'Refresh App List'}); + 'Refresh App List', 'Version History'}); assertNoLauncherTabs(fig); assertInfoContains(fig, "Use GitHub Update to repair"); clear cleanupFigures; @@ -364,7 +410,8 @@ function verify_launcher_layout() assertLauncherTableDensity(fig); h.assertButtonContract(fig, {'Open Selected App', 'Open Debug', ... 'Latest', 'Release', 'Versions', 'Run Code Analyzer', 'Profile Next App', ... - 'Package Checked', 'Checked P-code', 'Clean Artifacts', 'Refresh App List'}); + 'Version History', 'Package Checked', 'Checked P-code', ... + 'Clean Artifacts', 'Refresh App List'}); assertUpdateButtonRow(fig); assertLauncherActionRows(fig); assertMaintenanceButtonRow(fig); @@ -520,26 +567,27 @@ function assertUpdateButtonRow(fig) function assertLauncherActionRows(fig) buttons = arrayfun(@(text) findLauncherButton(fig, text), ... - ["Refresh App List", "Open Selected App", "Open Debug", ... + ["Refresh App List", "Open Selected App", "Open Debug", "Version History", ... "Clean Artifacts", "Package Checked", "Checked P-code"]); controlsGrid = buttons(1).Parent; assert(isequal(controlsGrid, buttons(2).Parent) && ... isequal(controlsGrid, buttons(3).Parent) && ... - isequal(controlsGrid, buttons(4).Parent), ... + isequal(controlsGrid, buttons(4).Parent) && ... + isequal(controlsGrid, buttons(5).Parent), ... 'Primary launcher actions should remain in the main controls grid.'); - rows = arrayfun(@(button) button.Layout.Row, buttons(1:4)); - assert(isequal(rows, [2 3 4 6]), ... + rows = arrayfun(@(button) button.Layout.Row, buttons(1:5)); + assert(isequal(rows, [2 3 4 5 7]), ... 'Primary launcher actions should keep their compact vertical order.'); - packageGrid = buttons(5).Parent; - assert(isequal(packageGrid, buttons(6).Parent), ... + packageGrid = buttons(6).Parent; + assert(isequal(packageGrid, buttons(7).Parent), ... 'Package actions should share one compact row.'); assert(isequal(packageGrid.Parent, controlsGrid) && ... - isequal(packageGrid.Layout.Row, 5), ... - 'Package actions should sit between debug and clean actions.'); + isequal(packageGrid.Layout.Row, 6), ... + 'Package actions should sit between history and clean actions.'); columns = packageGrid.ColumnWidth; assert(numel(columns) == 2 && all(strcmp(string(columns), "1x")), ... 'Package row should split package actions evenly.'); - columns = arrayfun(@(button) button.Layout.Column, buttons(5:6)); + columns = arrayfun(@(button) button.Layout.Column, buttons(6:7)); assert(isequal(columns, [1 2]), ... 'Package actions should keep source packaging before P-code packaging.'); end diff --git a/tools/release/parseLabKitChangelog.m b/tools/release/parseLabKitChangelog.m index c0c39fb6..5dd6b91e 100644 --- a/tools/release/parseLabKitChangelog.m +++ b/tools/release/parseLabKitChangelog.m @@ -12,8 +12,8 @@ % Outputs: % records - struct array with title, id, date, type, compatibility, % components, scopes, sections, and sourceLine fields. Component -% entries contain name, fromVersion, and toVersion; scopes name -% unversioned repository surfaces such as CI or documentation. +% entries contain name, kind, fromVersion, and toVersion; scopes +% name unversioned repository surfaces such as CI or documentation. % % The parser reads every entry under `## Structured Change Records`; the % changelog intentionally has no parallel unstructured history section. @@ -75,6 +75,8 @@ record.compatibility, value, key, record); case "component" record.components(end + 1) = componentTransition(value, record); + case "introduced" + record.components(end + 1) = introducedComponent(value, record); case "scope" record.scopes(end + 1) = value; otherwise @@ -115,10 +117,25 @@ end component = struct( ... "name", string(tokens{1}), ... + "kind", "transition", ... "fromVersion", string(tokens{2}), ... "toVersion", string(tokens{3})); end +function component = introducedComponent(value, record) + tokens = regexp(value, '^`([^`]+)`\s*\|\s*`(\d+\.\d+\.\d+)`$', ... + 'tokens', 'once'); + if isempty(tokens) + invalidRecord(record, ... + 'has invalid introduced metadata; expected `name` | `X.Y.Z`'); + end + component = struct( ... + "name", string(tokens{1}), ... + "kind", "introduced", ... + "fromVersion", "", ... + "toVersion", string(tokens{2})); +end + function sections = parseSections(lines, firstLine, lastLine, record) sectionNames = [ ... "Context", "Decision and rationale", "Changes", ... @@ -195,6 +212,46 @@ function validateRecordSet(records) error('LabKit:Changelog:DateOrder', ... 'Structured changelog records must use reverse chronological date order.'); end + validateComponentHistories(records); +end + +function validateComponentHistories(records) + components = [records.components]; + if isempty(components) + return; + end + names = unique(string({components.name}), "stable"); + for k = 1:numel(names) + name = names(k); + events = components(string({components.name}) == name); + introductions = events(string({events.kind}) == "introduced"); + transitions = events(string({events.kind}) == "transition"); + if numel(introductions) ~= 1 + error('LabKit:Changelog:ComponentIntroduction', ... + 'Component %s must have exactly one introduced event.', name); + end + fromVersions = string({transitions.fromVersion}); + if numel(unique(fromVersions)) ~= numel(fromVersions) + error('LabKit:Changelog:ComponentBranch', ... + 'Component %s has multiple transitions from the same version.', name); + end + + current = introductions.toVersion; + consumed = false(size(transitions)); + while true + next = find(~consumed & fromVersions == current); + if isempty(next) + break; + end + consumed(next) = true; + current = transitions(next).toVersion; + end + if any(~consumed) + error('LabKit:Changelog:ComponentHistoryGap', ... + 'Component %s has a disconnected, cyclic, or out-of-sequence version transition.', ... + name); + end + end end function line = nextContentLine(lines, line, lastLine) @@ -212,7 +269,8 @@ function validateRecordSet(records) "type", "", ... "compatibility", "", ... "components", repmat(struct( ... - "name", "", "fromVersion", "", "toVersion", ""), 1, 0), ... + "name", "", "kind", "", "fromVersion", "", ... + "toVersion", ""), 1, 0), ... "scopes", strings(1, 0), ... "sections", struct(), ... "sourceLine", 0); From b8de2edf39683d2f40e3265af80e67fa8490450a Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 15:27:34 -0500 Subject: [PATCH 13/15] test: split launcher catalog coverage --- .../project/launcher/LauncherCatalogGuiTest.m | 77 +++++++++++++++++++ .../gui/project/launcher/LauncherGuiTest.m | 72 ----------------- 2 files changed, 77 insertions(+), 72 deletions(-) create mode 100644 tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m diff --git a/tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m b/tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m new file mode 100644 index 00000000..b767871e --- /dev/null +++ b/tests/cases/gui/project/launcher/LauncherCatalogGuiTest.m @@ -0,0 +1,77 @@ +classdef LauncherCatalogGuiTest < matlab.unittest.TestCase + %LAUNCHERCATALOGGUITEST Verify launcher catalog and version history access. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function launcher_list_mode_discovers_apps(testCase) + setupLabKitTestPath(); + + apps = labkit_launcher("list"); + info = labkit_launcher("version"); + + testCase.verifyTrue(istable(apps), ... + 'labkit_launcher list mode should return a table.'); + testCase.verifyEqual(info.name, "labkit_launcher"); + testCase.verifyMatches(info.version, "^\d+\.\d+\.\d+$"); + testCase.verifyMatches(info.updated, "^\d{4}-\d{2}-\d{2}$"); + testCase.verifyTrue(all(ismember( ... + ["Command", "DisplayName", "Family", "Visibility", "Folder", ... + "RelativePath", "Description", "Version", "Updated"], ... + string(apps.Properties.VariableNames))), ... + 'labkit_launcher list mode should return the app catalog columns.'); + testCase.verifyTrue(all(ismember(apps.Visibility, ["public", "private"])), ... + 'Launcher app catalog visibility should be either public or private.'); + testCase.verifyTrue(any(apps.Visibility == "public"), ... + 'Default checkout app catalog should include public app entries.'); + testCase.verifyTrue(all(strlength(apps.Version) > 0 & strlength(apps.Updated) > 0), ... + 'labkit_launcher list mode should expose app version and update dates.'); + testCase.verifyGreaterThan(height(apps), 0, ... + 'labkit_launcher list mode should discover app entry points.'); + end + + function launcher_history_mode_returns_structured_app_records(testCase) + setupLabKitTestPath(); + + records = labkit_launcher("history", "labkit_DICPreprocess_app"); + + testCase.verifyNotEmpty(records); + testCase.verifyTrue(all(string({records.schema}) == "1")); + testCase.verifyTrue(any(string({records.id}) == ... + "LK-20260713-dic-rigid-point-editor")); + transitions = strings(1, 0); + for k = 1:numel(records) + components = records(k).components; + index = find(string({components.name}) == ... + "labkit_DICPreprocess_app", 1); + if ~isempty(index) + transitions(end + 1) = components(index).fromVersion + ... + " -> " + components(index).toVersion; + end + end + testCase.verifyTrue(any(transitions == "1.3.6 -> 1.4.0")); + end + + function launcher_opens_selected_app_version_history(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); + + fig = labkit_launcher(); + drawnow; + h.invokeButton(fig, 'Version History'); + drawnow; + + viewers = findall(groot, 'Type', 'figure', '-regexp', ... + 'Name', 'Version History$'); + testCase.verifyNotEmpty(viewers, ... + 'Version History should open for the selected launcher app.'); + tables = findall(viewers(1), 'Type', 'uitable'); + textAreas = findall(viewers(1), 'Type', 'uitextarea'); + testCase.verifyNotEmpty(tables); + testCase.verifyGreaterThan(size(tables(1).Data, 1), 0); + testCase.verifyTrue(any(contains(string(textAreas(1).Value), ... + 'Change ID:'))); + clear cleanup + end + end +end diff --git a/tests/cases/gui/project/launcher/LauncherGuiTest.m b/tests/cases/gui/project/launcher/LauncherGuiTest.m index e2b770d3..13b906b4 100644 --- a/tests/cases/gui/project/launcher/LauncherGuiTest.m +++ b/tests/cases/gui/project/launcher/LauncherGuiTest.m @@ -2,78 +2,6 @@ %LAUNCHERGUITEST Verify the root launcher without launching every app. methods (Test, TestTags = {'GUI', 'Structural'}) - function launcher_list_mode_discovers_apps(testCase) - setupLabKitTestPath(); - - apps = labkit_launcher("list"); - info = labkit_launcher("version"); - - testCase.verifyTrue(istable(apps), ... - 'labkit_launcher list mode should return a table.'); - testCase.verifyEqual(info.name, "labkit_launcher"); - testCase.verifyMatches(info.version, "^\d+\.\d+\.\d+$"); - testCase.verifyMatches(info.updated, "^\d{4}-\d{2}-\d{2}$"); - testCase.verifyTrue(all(ismember( ... - ["Command", "DisplayName", "Family", "Visibility", "Folder", ... - "RelativePath", "Description", "Version", "Updated"], ... - string(apps.Properties.VariableNames))), ... - 'labkit_launcher list mode should return the app catalog columns.'); - testCase.verifyTrue(all(ismember(apps.Visibility, ["public", "private"])), ... - 'Launcher app catalog visibility should be either public or private.'); - testCase.verifyTrue(any(apps.Visibility == "public"), ... - 'Default checkout app catalog should include public app entries.'); - testCase.verifyTrue(all(strlength(apps.Version) > 0 & strlength(apps.Updated) > 0), ... - 'labkit_launcher list mode should expose app version and update dates.'); - testCase.verifyGreaterThan(height(apps), 0, ... - 'labkit_launcher list mode should discover app entry points.'); - end - - function launcher_history_mode_returns_structured_app_records(testCase) - setupLabKitTestPath(); - - records = labkit_launcher("history", "labkit_DICPreprocess_app"); - - testCase.verifyNotEmpty(records); - testCase.verifyTrue(all(string({records.schema}) == "1")); - testCase.verifyTrue(any(string({records.id}) == ... - "LK-20260713-dic-rigid-point-editor")); - transitions = strings(1, 0); - for k = 1:numel(records) - components = records(k).components; - index = find(string({components.name}) == ... - "labkit_DICPreprocess_app", 1); - if ~isempty(index) - transitions(end + 1) = components(index).fromVersion + ... - " -> " + components(index).toVersion; - end - end - testCase.verifyTrue(any(transitions == "1.3.6 -> 1.4.0")); - end - - function launcher_opens_selected_app_version_history(testCase) - setupLabKitTestPath(); - h = guiTestHelpers(); - h.assertUifigureAvailable(); - cleanup = onCleanup(@() h.closeAllFigures()); - - fig = labkit_launcher(); - drawnow; - h.invokeButton(fig, 'Version History'); - drawnow; - - viewers = findall(groot, 'Type', 'figure', '-regexp', ... - 'Name', 'Version History$'); - testCase.verifyNotEmpty(viewers, ... - 'Version History should open for the selected launcher app.'); - tables = findall(viewers(1), 'Type', 'uitable'); - textAreas = findall(viewers(1), 'Type', 'uitextarea'); - testCase.verifyNotEmpty(tables); - testCase.verifyGreaterThan(size(tables(1).Data, 1), 0); - testCase.verifyTrue(any(contains(string(textAreas(1).Value), ... - 'Change ID:'))); - clear cleanup - end - function launcher_list_mode_discovers_local_private_apps(testCase) root = setupLabKitTestPath(); From ea0d1a669a8a5b5ace1131a83c73073be33644c6 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 15:39:33 -0500 Subject: [PATCH 14/15] fix: accept assumption-filtered test results --- CHANGELOG.md | 2 ++ docs/testing.md | 3 +++ .../project/ci/CiValidationPolicyGuardrailTest.m | 11 +++++++++++ tests/runLabKitTests.m | 2 +- tests/runner/labkitOfficialResultsHaveFailures.m | 9 +++++++++ 5 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/runner/labkitOfficialResultsHaveFailures.m diff --git a/CHANGELOG.md b/CHANGELOG.md index de08fbfb..f492a33c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -489,6 +489,8 @@ ownership, and toolbox-shadowed behavior. - Added `buildtool baseMatlab` and representative toolbox-shadow workflows. - Improved changed-file routing to target direct consumers while retaining explicit owners such as the launcher GUI suite. +- Corrected result aggregation so assumption-filtered tests remain visible as + skipped without being misreported as shard failures. #### User and data impact diff --git a/docs/testing.md b/docs/testing.md index 5d06f113..462f37be 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -156,6 +156,9 @@ iteration loop. Main-branch push and pull-request CI runs the public `headless` build task. The buildfile probes the selected test count and may run deterministic zero-based internal shards when the broad non-GUI suite is large enough. +Assumption-filtered tests remain visible as skipped or incomplete but do not +fail their shard. Actual test failures still fail the owning shard and retain +the progress, JUnit, HTML, and worker-log evidence used for diagnosis. Feature-branch pushes do not run the same MATLAB workflow until a pull request targets `main`, which avoids duplicate branch-push and PR runs for the same commit. Release candidate tag pushes matching `vX.Y.Z` run the full release diff --git a/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m b/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m index 48364c0c..dc91f40e 100644 --- a/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m +++ b/tests/cases/contract/project/ci/CiValidationPolicyGuardrailTest.m @@ -85,6 +85,17 @@ function runnerRejectsUnmatchedTestSelectors(testCase) "LabKit:Tests:UnmatchedTestSelector"); end + function runnerDoesNotTreatAssumptionSkipsAsFailures(testCase) + setupLabKitTestPath(); + skipped = struct("Passed", false, "Failed", false, "Incomplete", true); + failed = struct("Passed", false, "Failed", true, "Incomplete", true); + + testCase.verifyFalse(labkitOfficialResultsHaveFailures(skipped), ... + "A filtered assumption is incomplete but should not fail its shard."); + testCase.verifyTrue(labkitOfficialResultsHaveFailures(failed), ... + "A genuine failed result must still fail its shard."); + end + function ciTriggersAvoidDuplicateBranchPrRuns(testCase) root = setupLabKitTestPath(); workflowPath = fullfile(root, ".github", "workflows", ... diff --git a/tests/runLabKitTests.m b/tests/runLabKitTests.m index 3dff1ac5..7d2d041f 100644 --- a/tests/runLabKitTests.m +++ b/tests/runLabKitTests.m @@ -89,7 +89,7 @@ end officialResults = runner.run(suite); - if ~isempty(officialResults) && ~all([officialResults.Passed]) + if labkitOfficialResultsHaveFailures(officialResults) error("LabKit:Tests:OfficialFailure", ... "One or more official matlab.unittest tests failed."); end diff --git a/tests/runner/labkitOfficialResultsHaveFailures.m b/tests/runner/labkitOfficialResultsHaveFailures.m new file mode 100644 index 00000000..a47458d2 --- /dev/null +++ b/tests/runner/labkitOfficialResultsHaveFailures.m @@ -0,0 +1,9 @@ +function tf = labkitOfficialResultsHaveFailures(results) +%LABKITOFFICIALRESULTSHAVEFAILURES Distinguish failures from assumption skips. +% +% Called by runLabKitTests after matlab.unittest execution. The input is a +% TestResult array, or a struct-shaped test double with a logical Failed field. +% Returns true only when at least one result is an actual test failure. + + tf = ~isempty(results) && any([results.Failed]); +end From 25e625270224baf7966eab0e856f1e630449bc1e Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Mon, 13 Jul 2026 15:57:28 -0500 Subject: [PATCH 15/15] test: stabilize DIC selector cancellation --- .../+userInterface/selectRigidPointPairs.m | 25 ++++++++++++++++--- .../GuiLayoutDicPreprocessTest.m | 24 ++++++++---------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m index 52fb3bff..bed2258d 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+userInterface/selectRigidPointPairs.m @@ -1,10 +1,17 @@ % Expected caller: DIC preprocess manual-alignment action. Inputs are moving -% and fixed images. Outputs are matching N-by-2 [x y] point arrays. Side -% effects: opens a modal point-pair editor until the user accepts or cancels. +% and fixed images. An optional options.onReady callback receives the completed +% editor figure immediately before the modal wait. Outputs are matching N-by-2 +% [x y] point arrays. Side effects: opens a modal point-pair editor until the +% user accepts or cancels. -function [movingPoints, fixedPoints] = selectRigidPointPairs(movingImage, fixedImage) +function [movingPoints, fixedPoints] = selectRigidPointPairs( ... + movingImage, fixedImage, options) %SELECTRIGIDPOINTPAIRS Select draggable matching points without toolboxes. + if nargin < 3 + options = struct(); + end + movingPoints = zeros(0, 2); fixedPoints = zeros(0, 2); accepted = false; @@ -55,6 +62,7 @@ fixedEditor.start(fixedPoints); editorsReady = true; refreshPointDisplay(); + notifyReady(options, fig); uiwait(fig); movingEditor.delete(); @@ -152,6 +160,17 @@ function updateEditorPoints() end end +function notifyReady(options, fig) + if ~isfield(options, 'onReady') || isempty(options.onReady) + return; + end + if ~isa(options.onReady, 'function_handle') + error('LabKit:DIC:InvalidPointSelectorReadyCallback', ... + 'options.onReady must be a function handle.'); + end + options.onReady(fig); +end + function background = drawImage(ax, imageData, titleText) if ndims(imageData) == 2 background = imagesc(ax, double(imageData)); diff --git a/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m b/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m index 8ef02b15..c159bbba 100644 --- a/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m +++ b/tests/cases/gui/apps/dic/dic_preprocess/GuiLayoutDicPreprocessTest.m @@ -57,14 +57,17 @@ function manualPointPairSelectorCancelsWithoutToolbox(testCase) h = guiTestHelpers(); h.assertUifigureAvailable(); cleanup = onCleanup(@() h.closeAllFigures()); - cancelTimer = timer('StartDelay', 0.5, ... - 'TimerFcn', @(~, ~) cancelPointPairDialog()); + % Retry for up to 10 seconds because editor construction time + % varies across local and hosted MATLAB graphics runtimes. + cancelTimer = timer('ExecutionMode', 'fixedSpacing', ... + 'StartDelay', 0.25, 'Period', 0.25, 'TasksToExecute', 40, ... + 'TimerFcn', @cancelPointPairDialog); timerCleanup = onCleanup(@() deleteTimer(cancelTimer)); - start(cancelTimer); [movingPoints, fixedPoints] = ... dic_preprocess.userInterface.selectRigidPointPairs( ... - zeros(20, 24), zeros(20, 24)); + zeros(20, 24), zeros(20, 24), ... + struct('onReady', @(~) start(cancelTimer))); testCase.verifyEmpty(movingPoints, ... 'Cancelled manual alignment should return no moving points.'); @@ -75,20 +78,13 @@ function manualPointPairSelectorCancelsWithoutToolbox(testCase) end end -function cancelPointPairDialog() +function cancelPointPairDialog(timerHandle, ~) figures = findall(groot, 'Type', 'figure', 'Name', 'DIC Manual Alignment'); if isempty(figures) return; end - controls = findall(figures(1), '-property', 'Text'); - for iControl = 1:numel(controls) - if isprop(controls(iControl), 'ButtonPushedFcn') && ... - string(controls(iControl).Text) == "Cancel" - callback = controls(iControl).ButtonPushedFcn; - callback(controls(iControl), struct()); - return; - end - end + close(figures(1)); + stop(timerHandle); end function deleteTimer(timerHandle)