Skip to content

Read masks one pixel high or wide as 2D YX masks - #63

Merged
guijacquemet merged 3 commits into
mainfrom
claude/one-pixel-high-masks
Oct 31, 2026
Merged

guijacquemet merged 3 commits into
mainfrom
claude/one-pixel-high-masks

Conversation

@guijacquemet

@guijacquemet guijacquemet commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Reported by the fiberfeatures agent: read_mask_file refuses a valid TIFF label mask that is 1 pixel high, and the error message is misleading ("has axes 'X'").

Root cause. Two cases, both investigated with tifffile 2023.7.10, 2025.10.16, 2026.3.3, 2026.5.2, 2026.9.9 and 2026.10.15:

  1. A 1×N, N×1 or 1×1 array written plain, shaped {"axes": "YX"}, ImageJ or OME-TIFF. tifffile reports it as YX with shape (1, N) / (N, 1) / (1, 1). OME-NGFF label images name y/x too. The squeeze was this brick's own:

    • masks._mask_header dropped every length-1 axis, Y and X included;
    • both plane readers did the same (_read_tiff_plane, and the OME-Zarr branch of read_mask).

    The effects:

    • find_masks gave mask_not_2d;
    • read_mask_file said "has axes 'X'" (or 'Y', or '');
    • scan_masks raised "is not the 2D plane its header gave (X)";
    • named ZYX/TYX stacks of such planes failed the same way ('ZX', 'TY').
  2. A 1-D array written with tifffile's default metadata ({"shape": [N]}). Here tifffile itself gives the series axes X and no Y. This is the only case where tifffile reports 'X'. The file is a single TIFF page of 1 row × N columns.

Image side. inspect_tiff, named_axes, header_axes, check_declared_axes, and image reading/runs (TIFF directly, TIFF→OME-Zarr conversion, OME-Zarr input) handle 1×N, N×1 and 1×1 images correctly on main, because tifffile keeps Y and X. Guard tests were added. The 1-D case (2) for images is left open for the owner (see Notes).

By module

  • masks:
    • Y and X are kept whatever their length in _mask_header, so also in MaskFile.axes/shape and the manifest's masks entries.
    • One _plane_index helper does the same for both readers. The plane comes back as (1, N), (N, 1) or (1, 1) in the mask's dtype.
    • _tiff_axes: a series without Y or X that is a single 2-D YX page of the same size is read as that page, YX (1, N). This is case 2; it follows decision D-d for a plain 2-D page. Any other series without Y or X stays refused.
    • Masks accepted before are recorded exactly as before.
    • The read_mask_file refusal gives the header's axes and shape and names the unnamed dimension, e.g. has axes 'IYX' (shape (3, 1, 9)): the dimension I (length 3) is neither Y nor X, and its metadata does not name it depth (Z) or time (T). Before, it showed the axes left over after dropping Y or X ('IX').
  • Docs:

Decisions

No new rule. The existing "length-1 axes aside" rule now leaves out Y and X, which are always the axes the header or the TIFF page gives. Nothing is guessed. A dimension the metadata does not name (a plain stack IYX, shaped without axes QYX with Q > 1) is still refused.

Notes for review

  • Open for the owner (not changed here): an image saved as a 1-D array keeps header axes X. header_axes returns 'X' and check_declared_axes accepts 'X' and refuses 'YX'. The folder check suggests a mapping without Y, and the run then refuses it with an accurate missing_xy_axis. Reading it as one row would change the shared D86 header-axes rule (contract §16), so it is recorded in plan §12 question 44 rather than done.
  • tifffile's ImageJ writer cannot store a trailing length-1 X: it writes one page with invalid ImageJ metadata, which tifffile itself reports. Those stack cases are tested with OME-TIFF only.
  • A shaped file without axes and with a length-1 placeholder (YXQ (1, 9, 1)) is read as YX. That was already the rule on main for YXQ (2, 5, 1); only Y/X of length 1 are new.
  • No version bump and no tag.

Tests

  • New tests: 27 (24 regression + 3 guards).
    • The 24 regression tests fail on main (verified by running them against main's masks.py). They cover:
      • 1×9, 9×1 and 1×1 masks via read_mask_file, written plain, shaped YX, ImageJ and OME (12);
      • named Z/T stacks of 1-pixel planes, ImageJ/OME (4);
      • an OME-Zarr label image with y = 1;
      • the refusal message for an unnamed IYX stack;
      • find_masks + read_mask + scan_masks for 1×9, 9×1 and 1×1 (3);
      • a named TYX stack of 1-pixel masks;
      • a 1-D shaped mask via read_mask_file and via find_masks/read_mask (2).
    • The 3 guards cover image-side header and declared axes for 1-pixel images; they pass on main and here.
  • Local results (Python 3.13, .[dev,notebook,zarr], zarr 3.4.2, tifffile 2026.10.15):
    • ruff check .: passed.
    • ruff format --check .: passed.
    • pytest: 1496 passed, 1 skipped. The skip is test_discovery.py:665, which needs file-mode permissions and cannot run as root.
    • The touched mask/axes test files also pass with -W error.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WX6rdMV1r5jUPUR94kxV5a

A valid 2D label mask of 1 x N, N x 1 or 1 x 1 pixels was refused:
read_mask_file said it had the axes 'X' (or 'Y', or ''), and find_masks
gave mask_not_2d. tifffile reports such a mask as YX, with or without
axis metadata (plain, shaped, ImageJ and OME-TIFF; checked with tifffile
2023.7.10 to 2026.10.15), and OME-NGFF names y and x too. The squeeze
was this brick's own: masks._mask_header dropped every length-1 axis,
Y and X included, and the plane readers (_read_tiff_plane and the
OME-Zarr branch of read_mask) did the same.

Y and X are now kept whatever their length, in the header (MaskFile
axes/shape, the manifest's masks entries) and in both readers (one
_plane_index helper), so the plane comes back as (1, N), (N, 1) or
(1, 1); a named Z or T stack of such planes keeps its planes. Nothing
is guessed: Y and X are the header's, and an unnamed dimension (a plain
stack, IYX) is still refused, the message now giving the header's axes
and shape and naming the dimension instead of the axes left after
dropping Y or X. Images were not affected (their axes are tifffile's).

Contract §9, §15 and §16, the user guide and plan §12 question 44 record
it for the next release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WX6rdMV1r5jUPUR94kxV5a
tifffile's default metadata saves a 1D array as {"shape": [N]} and reads
it back with the axes 'X' and no Y (tifffile 2023.7.10 to 2026.10.15),
which is the "axes 'X'" of the fiberfeatures report. The file is one TIFF
page of 1 row and N columns, so a mask series without Y or X stored in
one 2D page is read as that page, YX (1, N), as any plain 2D page is
(decision D-d); any other series without Y or X is still refused.

Plan §12 question 44 records it, and leaves open for the owner whether
an image saved that way (header axes 'X', refused by the run with
missing_xy_axis) should be read as one row too: that would change the
shared header-axes rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WX6rdMV1r5jUPUR94kxV5a
The test opened tifffile.TiffFile without a context manager, which
leaves the file open (a ResourceWarning, and a lock on Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WX6rdMV1r5jUPUR94kxV5a
@guijacquemet
guijacquemet marked this pull request as ready for review October 31, 2026 19:51
@guijacquemet
guijacquemet merged commit a4aace4 into main Oct 31, 2026
7 checks passed
@guijacquemet
guijacquemet deleted the claude/one-pixel-high-masks branch November 1, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants