Skip to content

Calibration: unit catalog, unknown is a value, field states, _calibration request (QuPath #15) - #28

Merged
guijacquemet merged 3 commits into
mainfrom
claude/phase2-qupath-calibration
Oct 13, 2026
Merged

guijacquemet merged 3 commits into
mainfrom
claude/phase2-qupath-calibration

Conversation

@guijacquemet

@guijacquemet guijacquemet commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Phase 2, theme 3 (calibration) for the QuPath host: docs/SPEC_WRONG_SCIENCE.md section 2 of LabConstrictor-Tools and wrong_science.calibration of conformance.json (CAL-01..20, CAL-T1..T9; CAL-09 and CAL-T7 are the QuPath-specific cases). Fixes #15. The Napari reference is LabConstrictor-napari #53.

Stacked on #27

This PR is stacked on the unmerged coordinate-frame PR #27 (base branch claude/phase2-qupath-coordinate-frame); it contains its commits. A PR stacked on an unmerged parent does not reach main when the parent is squash-merged: after #27 is merged, retarget this PR to main (do not merge it stacked). The calibration commits themselves are 820ae4a and 6161775; the rest is #27.

What changes

  • Units come from the conformance catalog (LcUnits): loaded from the installed labconstrictor-tools (python -m labconstrictor_tools.conformance --path, run with the interpreter of a registered app; LC_CONFORMANCE overrides it for tests). The script keeps no unit list. Symbols (um, nm, micro-sign forms, Angstrom signs, ...) are exact, case-sensitive text; words are case-insensitive after trimming; anything else is an unknown unit; U+FFFD + m is refused with the damaged sentence. If the catalog cannot be loaded every calibration is unknown and the sentence says why (the number alone is never evidence).
  • Unknown is a value. A bare number with QuPath's px, an unknown or non-length unit (unit 's' is not a length), a damaged unit, or pixels that are not square (X and Y differ by more than 1e-3 relative to the larger, pixels are not square) give an unknown calibration: the field is emptied, the tooltip says not calibrated: 'image' has no pixel size, the warning is in the status line. Within the tolerance the field takes the mean of X and Y; it is never filled from one axis.
  • Sources. The image open in QuPath and project images: PixelCalibration (unit and number) through the catalog. A file input: its own TIFF tags, read without decoding pixels (classic and BigTIFF, both byte orders): OME PhysicalSize or an ImageJ unit= first; the resolution tags only for INCH/CENTIMETER and not when they look like 72 or 96 dpi (28.35/37.80 per cm); NONE is unknown; other file formats through QuPath's reader. QuPath-specific trap: QuPath's own TIFF reader turns a 72 dpi tag into 352.78 um per pixel with hasPixelSizeMicrons() true (the test prints it). For a local TIFF whose calibration was not entered in QuPath (server metadata equal to the reader's original) the TIFF rules apply too; a calibration the person entered in QuPath (metadata differs from the original) is explicit and kept (CAL-09).
  • Field states (2.2). The linked PixelSizeOf field is a text box with a button "Use the image's calibration", state unset / auto / user kept as a flag set by the person's edit events (typed or pasted text, and a key event that leaves the text unchanged), never inferred by comparing numbers; a programmatic write never makes it user. The box follows the chosen image, the typed file (a file wins over the open image), and the open image (listener on QuPath's image property) until the person enters a value. A source that is unknown clears an auto field (Do not retain stale PixelSizeOf values when QuPath image calibration changes #15: the previous image's value never stays); an empty box takes the image's calibration again when it loses the focus or a run starts. The declared default is never a stand-in (the form no longer starts at 0.65).
  • Run (2.3). A linked parameter that is not Optional and has no value blocks the run with 'pixel_size' needs a value: 'image' has no pixel size. Enter it in um.; an Optional[float] that is unset sends null; text that is not a number, 0 or a negative number is refused by name. Every field that is not the person's own is read again at the click (a calibration edited in QuPath after choosing the image is used).
  • _calibration (reserved input, 2.3.4): per axis (x, y), value in um, unit, source image / file / user and the raw number and unit text, frozen with the values at the click. Tools never see it; the run record (run.json, inputs) keeps it (the installed Tools worker does not copy it itself, so the host does it, as Napari).
  • Removed: the old "fill from X and say pixel size differs: Y .., X .." behaviour, the "keep the value when the file has none" behaviour, autoPixel (value comparison).

Issues

Tests

tests/gui_test_calibration_body.groovy (142 checks, tests/run_gui_ci.sh, README). Cases and catalog are read from the conformance file. Every case goes through the real form (file input, project images, the image open in QuPath) and checks the request the host would send (readFormValues + prepareInputs: the manifest value and _calibration), the field text, the tooltip and the blocked-run sentence; the transitions also run the real worker (test tools Pixel size required, with a declared default 1.0, and Pixel size optional in tests/frame_app) and read run.json. Images are written by tests/calibration_fixtures.py (tifffile, with the Python of the registered frame app): OME files keep the unit text exactly, so CAL-01..07, 15..20 and all 49 unit_match_refused names run as files; ImageJ files give CAL-08 and CAL-10; TIFF tags give CAL-10..14 (also BigTIFF and big-endian); CAL-09 sets the open image's PixelCalibration (true with 1.0, false). CAL-T1..T9 are driven with real key events typed into the box (T9: typing the value the box already shows). The field state machine is also tested on its own.

  • Sabotage checks (production code broken in a copy): 25 mutants (px read as um, case-insensitive symbols, case-sensitive words, untrimmed words, no 72/96 dpi rule, wider dpi tolerance, loose square tolerance, field from X, declared default as stand-in, no _calibration, programmatic write counted as an edit, typed value not a flag, user value not kept, previous value kept when unknown, OME ignored, ImageJ unit ignored, damaged sign accepted, Optional blocked, QuPath's 72 dpi reading trusted, calibration entered in QuPath ignored, no re-read at the click, source always image, raw text dropped, file losing to the image, resolution NONE trusted). All caught. One (the user-value guard inside LcLink.sourceChanged) was first missed because the caller has the same guard (the mutant was equivalent at behaviour level); I added tests of the state machine on its own and it is now caught.

Existing tests changed (superseded by the spec)

tests/gui_test_parity_pixel_body.groovy (QP-8), each change marked was: in the code:

  • pixel_field_starts_at_its_default -> pixel_field_starts_unset_not_at_its_declared_default (CAL-T8: a default is no stand-in).
  • a_file_without_calibration_leaves_the_value -> a_file_without_calibration_clears_the_field (CAL-T5: another image's value must not stay).
  • typed_value_is_not_overwritten_by_*: the value is now typed with key events; writing through dlg.setters is not an edit (2.2). the_other_pixel_field_was_not_touched: the other field is unset, not at its default 0.325.
  • anisotropic_pixels_are_said (old sentence pixel size differs: Y 0.25 µm, X 0.5 µm - the tool takes one value and gets X) -> pixels are not square; anisotropic_pixels_send_x -> anisotropic_pixels_leave_the_field_unset (CAL-06, 2.1.5).
  • unreadable_file_is_said_and_value_kept -> ..._and_the_field_is_unset.
    Unchanged and passing: file header fills the field, another file refills, centimetre unit, fresh form follows the file, square pixels say nothing, unreadable file logged, a path still being typed is silent. tests/gui_test_body.groovy (NucleiSky, not run in CI) was not edited; its Q3 expects 0.65 / 0.325 from ImageJ-unit files, which this rule still gives, but it was not run.

Spec gaps and conservative choices

  1. Catalog source in the host: the spec says hosts load the catalog; the QuPath script cannot import Python, so it asks the registered app's interpreter once (first app that answers) or LC_CONFORMANCE. An app whose labconstrictor-tools has no conformance module gives unknown calibrations until updated (stated in the status).
  2. "Required" means not Optional (as Napari): a linked float with a declared default is required in the spec's sense (CAL-T8), although the manifest marks it required: false.
  3. Clearing a text box: an empty box is unset at once but re-derived only on focus loss, the button, a source change or Run, so typing "0.3" over a selected value is not undone half-way. Non-numeric text is the person's value and is refused at the run by name ('Pixel size': 'abc' is not a number).
  4. OME size without a unit text is unknown (the OME default of um is not used); a TIFF without a ResolutionUnit tag is read as INCH (the TIFF default) and then the 72/96 rule applies; if either axis looks like a default the file is unknown. Same as Napari.
  5. Unit text not in the catalog (inch, km, case variants such as UM) gets the is not a length warning; the spec only says unknown. pixel, px, empty, none, dimensionless are unknown without a warning.
  6. Source labels: a file input is source: file, a project image or the open image image, a typed value user.
  7. The Min(...) declared on a linked field is not enforced beyond "greater than 0".
  8. A project image is opened to read its calibration each time the choice changes (and again at Run), then closed.
  9. QuPath's own calibration of non-TIFF formats (.czi, .svs, ...) is trusted as it is (through the catalog); only local TIFFs get the tag rules.

Not verified

  • CI is Linux only (QuPath 0.7.0 on Xvfb); Windows and macOS were not run (the TIFF reader, the interpreter call for the catalog and the key-event typing were not tried there). Other QuPath versions not tried.
  • Not tried: non-TIFF vendor formats with real calibration (czi, svs, ...), pyramidal OME-TIFF written by QuPath, a user-edited calibration saved in a project and reopened, copy-as-command with an unset field (the line simply has no pixel size, like Napari's CLI), a human at a real display.
  • tests/gui_test_body.groovy (CellTracksColab, NucleiSky) is skipped in CI and was not run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y2SN8SuEakTdiWy3F2WUqN

guijacquemet and others added 3 commits October 13, 2026 14:50
…tion request (SPEC_WRONG_SCIENCE section 2)

- units come from the conformance catalog of the installed labconstrictor-tools (symbols exact, words case-insensitive after trimming, 72/96 dpi unknown, damaged micro sign refused)
- a linked PixelSizeOf field is unset / auto / user (a flag set by the person's edits); an unknown source clears it; the declared default is never a stand-in
- a required linked field that is unset blocks the run with the spec sentence; an Optional one sends null
- anisotropic pixels (tolerance 1e-3) and non-length units are unknown with a warning
- the request carries the per-axis reserved input _calibration (x and y); the run record keeps it
- TIFF files are read by their own tags (OME / ImageJ unit first, resolution tags only when not a default dpi); QuPath's own 72 dpi reading of such a TIFF is not trusted unless the calibration was entered in QuPath
- tests/gui_test_calibration_body.groovy runs CAL-01..20 and CAL-T1..T9 through the form, the request and real worker runs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2SN8SuEakTdiWy3F2WUqN
…chine on its own, the run re-reads an edited calibration

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2SN8SuEakTdiWy3F2WUqN
@guijacquemet
guijacquemet marked this pull request as ready for review October 13, 2026 15:22
@guijacquemet
guijacquemet changed the base branch from claude/phase2-qupath-coordinate-frame to main October 13, 2026 16:00
@guijacquemet
guijacquemet merged commit 681ca6f into main Oct 13, 2026
2 checks passed
@guijacquemet
guijacquemet deleted the claude/phase2-qupath-calibration branch October 14, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant