Skip to content
Merged
14 changes: 8 additions & 6 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,8 @@ row (follow-up P3b, normalization plan N43).
| `unsupported_pixel_size_unit` | Warning | An image's metadata give a pixel size or z-step (OME `PhysicalSize{X,Y,Z}`, ImageJ resolution or `spacing`, OME-Zarr level-0 scale) in a length unit that is not one of decision D-a2 (§16), for example OME `in` or ImageJ `inch`. The size is not converted and the axis is treated as uncalibrated (as before); the warning names each image, its axes and the unit as written, so the missing calibration is never silent. A unit without a size, and a plain TIFF's resolution tags (decision D-c), are not reported. The run is not refused. |
| `invalid_physical_calibration` | Warning | An image's metadata give a calibration that cannot be used (issue #94): OME-Zarr `coordinateTransformations` that are not a list or of an unsupported type, more than one scale, a scale stored in a separate file or not one number per axis, or a scale value of X, Y, Z or T that is not a positive finite number; or, from any TIFF or OME-Zarr metadata, a size that is not a positive finite length in micrometres once converted (issue #93: `1e308 m`, `1e-320 pm`); or a size of OME-TIFF (`PhysicalSizeX/Y/Z`, `TimeIncrement`) or ImageJ metadata (`spacing`, `finterval` and the resolution tags, with a unit) that is itself not a positive finite number (`inf`, `NaN`, zero, negative or not a number; review of #133, before silently unknown). The message names each image and what is wrong, with the axis and value. Its sizes are unknown on those axes (never a substituted value) and recorded as uncalibrated; the warning is in the `check` and `run` issues and the report (`discovery_report.json`). An image is not named once every size the malformed calibration leaves unknown (X and Y, and Z or T with more than one plane) is stated in `calibration.csv`; a time step cannot be stated there, so a lost time step of a time series is always named. A calibration that is not given (no transformations, or no scale) is not reported. The run is not refused. |
| `different_channel_counts` | Error | The number of channels differs between images: the `C` dimension, or the RGB samples `S` when there is no `C`; once the axis roles are confirmed, the length of the C axis (1 without one), which replaces the header's count. The images not having the most common number are listed, with each number's image count. Every image of an experiment must have the same channels: split the others into another experiment or exclude them. |
| `different_plane_counts` | Warning | The number of depth slices (`Z`), time points (`T`) or planes along any other dimension except X, Y, C and S differs between images. A missing dimension counts as 1. Channels are compared by `different_channel_counts`. |
| `different_sizes` | Warning | The image width or height differs between images (TIFF and OME-Zarr). The other dimensions are compared by `different_plane_counts`. |
| `different_plane_counts` | Warning | The number of depth slices (`Z`), time points (`T`) or planes along any other dimension except X, Y, C and S differs between images. A missing dimension counts as 1. Channels are compared by `different_channel_counts`. Before the axis roles are confirmed (`check`, preflight) the axes each header names are compared, and a plain stack (its header names no axes, §2) is left out; once they are confirmed (`run`, the notebook's run, `AxisReview.combined_issues`) each image's depth slices and time points under its confirmed roles are compared instead, and the header version is dropped. An image whose confirmed roles contradict its header (an axis error, never read) is compared by its header. Before issue #74 a plain stack was compared by its header letters, so a plain `QYX` stack confirmed as `YXC` gave "planes along Q". |
| `different_sizes` | Warning | The image width or height differs between images (TIFF and OME-Zarr). The other dimensions are compared by `different_plane_counts`. As for `different_plane_counts`, the header's Y and X before the roles are confirmed (a plain stack left out) and the confirmed Y and X after (issue #74: before, a plain `QYX` stack of (Y, X, C) arrays confirmed as `YXC` was compared by its X and channels, and a real difference in height was missed). |

| Rule | Detail |
|---|---|
Expand Down Expand Up @@ -424,7 +424,7 @@ masks/ masks folder, outside the experiment folder
| Warnings | `mask_missing` (images without a mask of a type that other images have: their objects of that type cannot be measured), `mask_without_image` (files matching no image of the experiment), `mask_of_excluded_image` (masks of excluded images, not used), `mask_outside_type_folder` (files directly in the masks folder that are not TIFF masks). |
| Run | `run_experiment(..., masks_folder=..., mask_axes=...)` stops on a mask error and records the masks in the manifest's `masks` key (§9). Without masks, the run and its outputs are unchanged. |

### 15.1 The mask step (`mask_relationships.json`, report version 1)
### 15.1 The mask step (`mask_relationships.json`, report version 2)

The optional mask step reads every image's masks, one image at a time, and describes each
mask type and every ordered pair of types over the whole experiment. Pieces are counted with
Expand Down Expand Up @@ -453,7 +453,7 @@ Each child object's parent is the parent object sharing most of its pixels.

| Relation of child to parent | Rule (default thresholds; the values used are written in the report) |
|---|---|
| `same_objects` | At least 90% of the children and 90% of the parents are matched one to one at an intersection over union of at least 0.5: two segmentations of the same objects. |
| `same_objects` | At least 90% of the children and 90% of the parents are matched one to one at an intersection over union of at least 0.5: two segmentations of the same objects. The matches are a maximum matching of the pairs at or above the threshold, in each plane: each object is matched at most once, and as many objects as possible are matched. Two children that each cover half of one parent are one match, not two (issue #67; before report version 2 every object with any partner at the threshold counted as matched). |
| `grouped_by` | The child is `grouped`, at least 90% of its objects are inside as below, and at least 99% of its pixels carry the label of the parent object under them. |
| `inside` | At least 90% of the child objects have at least half of their pixels in one parent object, and that parent is larger than the child for at least 90% of them. |
| `none` | Otherwise. |
Expand All @@ -465,7 +465,9 @@ Each child object's parent is the parent object sharing most of its pixels.
| `split_between_parents`, `outside_parents` | Children touching parents but with less than half of their pixels in any one; children touching no parent. |
| `parents_with_0_children`, `parents_with_1_child`, `parents_with_2_children`, `parents_with_3_or_more_children` | How many children each parent holds (two nuclei in one cell counts as 2). |
| `child_pixels`, `child_pixels_with_parent_label` | Child pixels; those whose label equals the parent label under them. |
| `children_matched_one_to_one`, `parents_matched_one_to_one` | Objects with a partner at an intersection over union of at least 0.5. |
| `children_matched_one_to_one`, `parents_matched_one_to_one` | Objects matched one to one (the maximum matching above, at an intersection over union of at least 0.5); the two counts are equal. The number of matches does not depend on which of several equally large matchings is found, so it is deterministic. |
| `children_not_matched`, `parents_not_matched` | Objects of each type left without a match. |
| `children_over_iou_with_several_parents`, `parents_over_iou_with_several_children` | Objects with two or more partners at or above the threshold: a split or merged segmentation. Above 0.5 an object can overlap only one partner that much, so these are 0 at a threshold above 0.5. |

**Thresholds.** The numbers above are defaults (`DEFAULT_THRESHOLDS`), and each may be
changed: `inside_pixel_share` (0.5), `inside_object_share` (0.9), `parent_larger_share`
Expand All @@ -486,7 +488,7 @@ The report is UTF-8 JSON (two-space indent, LF), written atomically to the outpu

| Key | Meaning |
|---|---|
| `report_version` | `1`. |
| `report_version` | `2` (since issue #67: the one-to-one matches are a matching, and the four counts of unmatched and several-partner objects were added). |
| `thresholds` | The thresholds used, all seven. |
| `images` | Images scanned (every image with at least one mask). |
| `masks` | Per mask type: `kind`, `images`, `objects` (labels over all images) and `median_pieces_per_label`. |
Expand Down
27 changes: 27 additions & 0 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -1654,3 +1654,30 @@ Raised during review; to be decided before the milestones that need them:
review of #133), and `design` with 0 when the design it printed had `repeat_group_conflict` or
`invalid_experiment_design`, also after writing a change. `run`, `masks`, `calibrate`
and `convert` already did. Contract §2, §9.2, §9.3, §11, §16; user guide §5, §8.

67. How are the one-to-one matches of `same_objects` counted (issue #67)?
*Decided (2 November 2026, implemented for issue #67; the rule is the issue's
acceptance): as a maximum matching of the child and parent objects of each plane whose
intersection over union is at least `same_objects_iou`
(`scipy.sparse.csgraph.maximum_bipartite_matching`, a sparse graph, no K x K matrix).
Each object is matched at most once, so two children halving one parent are one match.
The number of matches is the same for every maximum matching, so the counts do not
depend on label numbers or tie-breaking. Above an intersection over union of 0.5 the
matching is every eligible pair (an object can overlap only one partner that much); at
the default of exactly 0.5 only an object split into exact halves has two eligible
partners, so the default changes nothing for real one-to-one segmentations (the HPMEC
masks give the same report). The evidence adds `children_not_matched`,
`parents_not_matched`, `children_over_iou_with_several_parents` and
`parents_over_iou_with_several_children`; `mask_relationships.json` is report version 2.
Contract §15.1.*
68. Which axes do the size and plane-count warnings use (issue #74)?
*Decided (2 November 2026, implemented for issue #74): the confirmed roles, once they
are confirmed. Before (`check`, preflight), `different_sizes` and
`different_plane_counts` compare the axes each header names, and a plain stack is left
out, since its header's letters are not its roles (design v0.4 §3). After the
confirmation, `AxisReview.combined_issues` (used by `run`, the notebook's run and the
conversion plan) drops the header versions and compares each image under its own
confirmed roles; an image with an axis error is compared by its header, never by the
contradicted roles. Images without confirmed roles that the header names are compared
by their header, as before. The notebook's axis card still shows only the axis review's
own issues; the corrected warnings appear in the run's result. Contract §11.*
8 changes: 5 additions & 3 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,9 @@ Hierarchy (direct parents only):
```

Nucleoli inside nuclei inside cells have the nuclei as their direct parent; two kinds of
mask that segment the same objects are reported as such. These are suggestions from the
mask that segment the same objects are reported as such: at least 90% of the objects of
both are matched one to one, each object matched at most once (two halves of one cell are
one match, not two). These are suggestions from the
pixels, not decisions: you name each kind of mask and check the relationships in the bricks
that use them. The report is written to `mask_relationships.json` in the output folder
(§6). The command takes `--output`, `--exclude` and `--log` like the others, and exits with
Expand Down Expand Up @@ -977,8 +979,8 @@ continues.
| `image_named_as_mask` | Error | An image is named like a mask of another image (`f1_mask.tif` beside `f1.tif`; the endings `_mask`, `_masks`, `_cp_masks`, `_labels`, `_label`, `_seg`, `_segmentation`), and a mask is never analysed as an image. If it is a mask, move it into a masks folder outside the experiment, or exclude it; if it is an image, rename it. |
| `mask_folder_in_experiment` | Warning | A folder inside the experiment is named like a masks folder (`masks`, `ctrl_masks`, `CellLabels`) and holds files named like masks of the images; they would be analysed as images. Move it out of the experiment and give it as the masks folder, or rename it if these are images. |
| `planes_read_in_blocks` | Warning | Planes too large for the memory limit are read in blocks. The results are the same; nothing to do. Raise the limit to read them whole, which is faster. |
| `different_sizes` | Warning | Images have different widths or heights. Usually expected; otherwise check the camera settings. |
| `different_plane_counts` | Warning | Images have different numbers of depth slices, time points or other planes. Images with more planes weigh more in pooled statistics. Check the acquisition if this is not expected. |
| `different_sizes` | Warning | Images have different widths or heights. Usually expected; otherwise check the camera settings. For a plain stack (no axes in its file), the sizes are compared once you confirm its axis roles, with those roles. |
| `different_plane_counts` | Warning | Images have different numbers of depth slices, time points or other planes. Images with more planes weigh more in pooled statistics. Check the acquisition if this is not expected. For a plain stack, compared with its confirmed axis roles, as `different_sizes`. |
| `different_pixel_sizes` | Warning | The physical pixel size or z-step (from the metadata or `calibration.csv`) differs between images by more than 0.1 % (for example different objectives). Images outside the largest group of matching sizes are listed; when sizes only change by small steps from image to image, none is listed. Check the microscope settings if this is not intended. |
| `missing_pixel_size` | Warning | Some images have a pixel size (from the metadata or `calibration.csv`) and others do not, so it cannot be checked; images stated as measured in pixels count as without one, and the message says how many. Export them again with metadata, or state their sizes with `calibrate`, if needed. |
| `unsupported_pixel_size_unit` | Warning | An image gives its pixel size or z-step in a unit that is not converted (for example inches); only pm, Å, nm, µm, mm, cm and m are. The message names each image, axis and unit. Those sizes are not used: the image is treated as uncalibrated on those axes. Export it again with the pixel size in a supported unit, or state it with `calibrate`, if the physical size matters. Not reported for an axis whose size is stated. |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.physical_sizes" = "io"
"morphobricks_preprocessing.length_units" = "pure"
"morphobricks_preprocessing.pixel_size_checks" = "pure"
"morphobricks_preprocessing.size_checks" = "pure"
"morphobricks_preprocessing.ngff_scale" = "pure"
"morphobricks_preprocessing.calibration" = "pure"
"morphobricks_preprocessing.calibration_io" = "io"
Expand Down
19 changes: 16 additions & 3 deletions src/morphobricks_preprocessing/axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .metadata import (
MetadataReport,
TiffMetadata,
dimension_issues,
)
from .validation import ValidationIssue, has_errors

Expand Down Expand Up @@ -65,7 +66,13 @@

# Metadata issues that the axis review checks again against the confirmed mapping.
_SUPERSEDED_METADATA_CODES = frozenset(
{"missing_axes", "inconsistent_dimensions", "different_channel_counts"}
{
"missing_axes",
"inconsistent_dimensions",
"different_channel_counts",
"different_sizes",
"different_plane_counts",
}
)


Expand Down Expand Up @@ -256,10 +263,16 @@ def combined_issues(self, metadata: MetadataReport) -> tuple[ValidationIssue, ..
The review checks the arrangement of dimensions and the numbers of
channels again against the confirmed mapping, so the metadata's
``missing_axes``, ``inconsistent_dimensions`` and
``different_channel_counts`` issues are replaced by the review's own.
``different_channel_counts`` issues are replaced by the review's own. The
``different_sizes`` and ``different_plane_counts`` warnings are computed again with
each image's confirmed roles (issue #74; :func:`.metadata.dimension_issues`), except
for an image whose roles contradict its header (:attr:`axis_error_files`, never read),
which is measured by the axes its header names.
"""
kept = [i for i in metadata.issues if i.code not in _SUPERSEDED_METADATA_CODES]
merged = [*kept, *self.issues]
refused = set(self.axis_error_files)
roles = {p: m.roles for p, m in self.file_mappings.items() if p not in refused}
merged = [*kept, *dimension_issues(metadata.files, roles), *self.issues]
return tuple(sorted(merged, key=lambda issue: not issue.is_error))


Expand Down
Loading
Loading