patchworks 1.0.0__tar.gz → 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {patchworks-1.0.0 → patchworks-1.1.1}/PKG-INFO +3 -3
  2. {patchworks-1.0.0 → patchworks-1.1.1}/pyproject.toml +5 -3
  3. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_merge.py +17 -4
  4. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/plugins/napari.py +38 -0
  5. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/plugins/ome_zarr.py +26 -3
  6. {patchworks-1.0.0 → patchworks-1.1.1}/tests/test_core.py +56 -0
  7. {patchworks-1.0.0 → patchworks-1.1.1}/tests/test_napari.py +36 -0
  8. {patchworks-1.0.0 → patchworks-1.1.1}/tests/test_ome_zarr.py +28 -0
  9. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/scripts/merge.py +3 -1
  10. {patchworks-1.0.0 → patchworks-1.1.1}/.github/workflows/docs.yml +0 -0
  11. {patchworks-1.0.0 → patchworks-1.1.1}/.github/workflows/lint.yml +0 -0
  12. {patchworks-1.0.0 → patchworks-1.1.1}/.github/workflows/release.yml +0 -0
  13. {patchworks-1.0.0 → patchworks-1.1.1}/.gitignore +0 -0
  14. {patchworks-1.0.0 → patchworks-1.1.1}/.markdownlint-cli2.yaml +0 -0
  15. {patchworks-1.0.0 → patchworks-1.1.1}/LICENSE +0 -0
  16. {patchworks-1.0.0 → patchworks-1.1.1}/README.md +0 -0
  17. {patchworks-1.0.0 → patchworks-1.1.1}/cliff.toml +0 -0
  18. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/chunks.md +0 -0
  19. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/cluster.md +0 -0
  20. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/io.md +0 -0
  21. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/merge_tile_labels.md +0 -0
  22. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/plugins/cellpose.md +0 -0
  23. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/plugins/napari.md +0 -0
  24. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/plugins/ome_zarr.md +0 -0
  25. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/relabel.md +0 -0
  26. {patchworks-1.0.0 → patchworks-1.1.1}/docs/api/tile_process.md +0 -0
  27. {patchworks-1.0.0 → patchworks-1.1.1}/docs/assets/logo.png +0 -0
  28. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/cellpose_2d.md +0 -0
  29. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/cellpose_2d.py +0 -0
  30. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/cellpose_3d.md +0 -0
  31. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/cellpose_3d.py +0 -0
  32. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/custom.md +0 -0
  33. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/custom_method.py +0 -0
  34. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/standalone_merge.md +0 -0
  35. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/stardist.md +0 -0
  36. {patchworks-1.0.0 → patchworks-1.1.1}/docs/examples/stardist_2d.py +0 -0
  37. {patchworks-1.0.0 → patchworks-1.1.1}/docs/getting_started.md +0 -0
  38. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/gpu_distributed.md +0 -0
  39. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/merging.md +0 -0
  40. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/ome_zarr_napari.md +0 -0
  41. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/performance.md +0 -0
  42. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/pitfalls.md +0 -0
  43. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/skip_empty.md +0 -0
  44. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/snakemake.md +0 -0
  45. {patchworks-1.0.0 → patchworks-1.1.1}/docs/guide/tiling.md +0 -0
  46. {patchworks-1.0.0 → patchworks-1.1.1}/docs/index.md +0 -0
  47. {patchworks-1.0.0 → patchworks-1.1.1}/mkdocs.yml +0 -0
  48. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/__init__.py +0 -0
  49. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_chunks.py +0 -0
  50. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_cluster.py +0 -0
  51. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_core.py +0 -0
  52. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_distributed.py +0 -0
  53. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_io.py +0 -0
  54. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_relabel.py +0 -0
  55. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/_relations.py +0 -0
  56. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/plugins/__init__.py +0 -0
  57. {patchworks-1.0.0 → patchworks-1.1.1}/src/patchworks/plugins/cellpose.py +0 -0
  58. {patchworks-1.0.0 → patchworks-1.1.1}/tests/test_distributed.py +0 -0
  59. {patchworks-1.0.0 → patchworks-1.1.1}/tests/test_relations.py +0 -0
  60. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/README.md +0 -0
  61. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/Snakefile +0 -0
  62. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/config/config.yaml +0 -0
  63. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/config/config_cyto.yaml +0 -0
  64. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/config/config_nuclei.yaml +0 -0
  65. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/config/multi.yaml +0 -0
  66. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/pixi.toml +0 -0
  67. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/profile/slurm/config.yaml +0 -0
  68. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/rules/common.smk +0 -0
  69. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/rules/convert.smk +0 -0
  70. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/rules/merge.smk +0 -0
  71. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/rules/segment.smk +0 -0
  72. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/scripts/_pw.py +0 -0
  73. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/scripts/convert.py +0 -0
  74. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/scripts/fetch_model.py +0 -0
  75. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/scripts/prepare_tiles.py +0 -0
  76. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/scripts/run_multi.py +0 -0
  77. {patchworks-1.0.0 → patchworks-1.1.1}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Tiled processing of arbitrarily large images with globally consistent labels
5
5
  Project-URL: Homepage, https://github.com/imcf/patchworks
6
6
  Project-URL: Issues, https://github.com/imcf/patchworks/issues
@@ -34,7 +34,7 @@ Requires-Dist: glasbey; extra == 'all'
34
34
  Requires-Dist: imaris-ims-file-reader; extra == 'all'
35
35
  Requires-Dist: ipykernel<7; extra == 'all'
36
36
  Requires-Dist: lxml-html-clean; extra == 'all'
37
- Requires-Dist: napari-dask-ndmeasure>=0.1.0; extra == 'all'
37
+ Requires-Dist: napari-chunked-regionprops>=0.1.0; extra == 'all'
38
38
  Requires-Dist: napari[all]>=0.7.1; extra == 'all'
39
39
  Requires-Dist: numpy<2.5; extra == 'all'
40
40
  Requires-Dist: nvidia-ml-py; extra == 'all'
@@ -75,7 +75,7 @@ Provides-Extra: napari
75
75
  Requires-Dist: glasbey; extra == 'napari'
76
76
  Requires-Dist: ipykernel<7; extra == 'napari'
77
77
  Requires-Dist: lxml-html-clean; extra == 'napari'
78
- Requires-Dist: napari-dask-ndmeasure>=0.1.0; extra == 'napari'
78
+ Requires-Dist: napari-chunked-regionprops>=0.1.0; extra == 'napari'
79
79
  Requires-Dist: napari[all]>=0.7.1; extra == 'napari'
80
80
  Requires-Dist: numpy<2.5; extra == 'napari'
81
81
  Provides-Extra: workflow
@@ -67,16 +67,18 @@ imaris = ["imaris-ims-file-reader"]
67
67
  # - lxml-html-clean: napari's notebook_display imports lxml.html.clean, split
68
68
  # into a separate package in lxml >= 5.2 (else ImportError on Viewer()).
69
69
  # - glasbey: distinct high-contrast label LUTs for view_in_napari.
70
- # - napari-dask-ndmeasure: out-of-core regionprops-style measurements
70
+ # - napari-chunked-regionprops: out-of-core regionprops-style measurements
71
71
  # (area/centroid/intensity stats) for huge Labels layers, straight off
72
- # their backing dask/zarr arrays — the "Measure" dock widget.
72
+ # their backing dask/zarr arrays — the "Measure" dock widget. Formerly
73
+ # napari-dask-ndmeasure; renamed when its engine dropped dask_image.ndmeasure
74
+ # for a chunk-local map/merge that scales with chunk count, not object count.
73
75
  napari = [
74
76
  "napari[all]>=0.7.1",
75
77
  "numpy<2.5",
76
78
  "ipykernel<7",
77
79
  "lxml-html-clean",
78
80
  "glasbey",
79
- "napari-dask-ndmeasure>=0.1.0",
81
+ "napari-chunked-regionprops>=0.1.0",
80
82
  ]
81
83
  # workflow runs the Snakemake pipeline (per-tile SLURM jobs across GPUs).
82
84
  workflow = ["snakemake>=8", "snakemake-executor-plugin-slurm"]
@@ -459,7 +459,8 @@ def merge_tile_labels(
459
459
  stage_dir: Union[str, Path, None] = None,
460
460
  keep_stage: bool = False,
461
461
  progress: bool = False,
462
- ) -> "da.Array":
462
+ return_count: bool = False,
463
+ ) -> Union["da.Array", tuple["da.Array", Union[int, None]]]:
463
464
  """Merge per-tile labels into a globally consistent label array.
464
465
 
465
466
  Standalone merge step — use this when you already have per-tile labels
@@ -503,11 +504,21 @@ def merge_tile_labels(
503
504
  Keep the temp stage zarr after merging. Default False.
504
505
  progress:
505
506
  Show a progress bar during the relabel step.
507
+ return_count:
508
+ Also return the exact object count. Only meaningful (non-``None``)
509
+ when ``sequential_labels=True``, which already computes it for free
510
+ while renumbering to ``1..N`` — otherwise no step here knows the
511
+ final count without an extra full scan, so the second element is
512
+ ``None``. Useful to persist alongside the labels (e.g.
513
+ ``write_labels(..., n_objects=...)``) so a downstream consumer with
514
+ the count can skip re-deriving the id set from the array itself.
506
515
 
507
516
  Returns
508
517
  -------
509
518
  da.Array
510
- Merged label array (int32) backed by ``write_to``.
519
+ Merged label array (int32) backed by ``write_to``. Or, when
520
+ ``return_count=True``, a ``(labels, n_objects)`` tuple —
521
+ ``n_objects`` is ``None`` unless ``sequential_labels=True``.
511
522
 
512
523
  Examples
513
524
  --------
@@ -600,9 +611,10 @@ def merge_tile_labels(
600
611
  show_progress=progress,
601
612
  )
602
613
 
614
+ n_objects = None
603
615
  if sequential_labels:
604
616
  logger.info("Relabelling to contiguous ids…")
605
- relabel_sequential_zarr(effective_out, output_component)
617
+ n_objects = relabel_sequential_zarr(effective_out, output_component)
606
618
 
607
619
  # -- Cleanup temp stage (only when we created it) --
608
620
  if not isinstance(labeled, (str, Path)) and not keep_stage:
@@ -611,4 +623,5 @@ def merge_tile_labels(
611
623
  shutil.rmtree(stage_path, ignore_errors=True)
612
624
  logger.info("Removed stage store %s", stage_path)
613
625
 
614
- return da.from_zarr(effective_out, component=output_component)
626
+ result = da.from_zarr(effective_out, component=output_component)
627
+ return (result, n_objects) if return_count else result
@@ -179,6 +179,41 @@ def _pyramid_calibration(
179
179
  return scale, units
180
180
 
181
181
 
182
+ def _label_hint(path: Union[str, Path]) -> dict[str, Any]:
183
+ """Read the known-object-count hint from a label group's zarr attrs.
184
+
185
+ ``write_labels(..., n_objects=...)`` persists this when the labels were
186
+ renumbered to a contiguous ``1..N`` range (``sequential_labels=True``
187
+ during the merge) — the exact id set is then ``range(1, n_objects +
188
+ 1)`` by construction, with no scan needed. Passed through as a Labels
189
+ layer's ``metadata`` so a downstream consumer (e.g.
190
+ napari-chunked-regionprops) can use it instead of re-deriving the id
191
+ set from the array itself.
192
+
193
+ Parameters
194
+ ----------
195
+ path : str or Path
196
+ Label group path (e.g. ``f"{image}/labels/{name}"``).
197
+
198
+ Returns
199
+ -------
200
+ dict
201
+ ``{"n_objects": int, "sequential_labels": True}`` if the group has
202
+ the hint, else ``{}`` — safe to splat straight into
203
+ ``metadata=``/merge into a bigger dict either way.
204
+ """
205
+ try:
206
+ attrs = zarr.open_group(str(path), mode="r").attrs
207
+ except Exception:
208
+ return {}
209
+ if "n_objects" not in attrs:
210
+ return {}
211
+ return {
212
+ "n_objects": attrs["n_objects"],
213
+ "sequential_labels": attrs.get("sequential_labels", False),
214
+ }
215
+
216
+
182
217
  def _inner_label_names(store: Union[str, Path]) -> list[str]:
183
218
  """List label images registered under an OME-ZARR's ``labels/`` group.
184
219
 
@@ -351,12 +386,14 @@ def view_in_napari(
351
386
  if _is_zarr(labels)
352
387
  else (None, None)
353
388
  )
389
+ metadata = _label_hint(labels) if _is_zarr(labels) else {}
354
390
  viewer.add_labels(
355
391
  lab,
356
392
  name=labels_name,
357
393
  multiscale=isinstance(lab, list),
358
394
  scale=lab_scale,
359
395
  units=lab_units,
396
+ metadata=metadata,
360
397
  **label_kwargs,
361
398
  )
362
399
  elif _is_zarr(image):
@@ -377,6 +414,7 @@ def view_in_napari(
377
414
  multiscale=True,
378
415
  scale=lab_scale,
379
416
  units=lab_units,
417
+ metadata=_label_hint(store),
380
418
  **label_kwargs,
381
419
  )
382
420
  logger.info("auto-loaded labels/%s from %s", name, image)
@@ -1045,6 +1045,7 @@ def register_labels(
1045
1045
  chunks: Union[tuple[int, ...], None] = None,
1046
1046
  shard: ShardSpec = False,
1047
1047
  progress: bool = True,
1048
+ n_objects: Union[int, None] = None,
1048
1049
  ) -> str:
1049
1050
  """Pyramidalise and register an existing ``labels/<name>/0`` base level.
1050
1051
 
@@ -1076,6 +1077,14 @@ def register_labels(
1076
1077
  Sharding request (see :func:`to_ome_zarr`'s *shard*).
1077
1078
  progress : bool, optional
1078
1079
  Show a per-level dask progress bar (default ``True``).
1080
+ n_objects : int or None, optional
1081
+ Exact non-background object count, if known (e.g. from
1082
+ :func:`patchworks.merge_tile_labels`'s ``return_count=True`` after
1083
+ ``sequential_labels=True``, which means ``ids == range(1, n_objects
1084
+ + 1)`` by construction). When given, written into the label group's
1085
+ attrs as ``n_objects``/``sequential_labels`` so a downstream reader
1086
+ (e.g. napari-chunked-regionprops) can use the known id set instead
1087
+ of re-deriving it with a full-volume scan of its own.
1079
1088
 
1080
1089
  Returns
1081
1090
  -------
@@ -1106,6 +1115,9 @@ def register_labels(
1106
1115
  )
1107
1116
  grp = zarr.open_group(group, mode="a")
1108
1117
  grp.attrs["image-label"] = {"version": _NGFF_VERSION}
1118
+ if n_objects is not None:
1119
+ grp.attrs["n_objects"] = int(n_objects)
1120
+ grp.attrs["sequential_labels"] = True
1109
1121
 
1110
1122
  labels_grp = zarr.open_group(f"{store}/labels", mode="a")
1111
1123
  registered = list(labels_grp.attrs.get("labels", []))
@@ -1128,6 +1140,7 @@ def write_labels(
1128
1140
  shard: ShardSpec = False,
1129
1141
  progress: bool = True,
1130
1142
  overwrite: bool = False,
1143
+ n_objects: Union[int, None] = None,
1131
1144
  ) -> str:
1132
1145
  """Store *labels* inside *image_store* under the NGFF ``labels/`` group.
1133
1146
 
@@ -1166,6 +1179,9 @@ def write_labels(
1166
1179
  overwrite : bool, optional
1167
1180
  Replace an existing label image of the same *name* (default
1168
1181
  ``False``).
1182
+ n_objects : int or None, optional
1183
+ Exact non-background object count, if known — forwarded to
1184
+ :func:`register_labels`; see its docstring for what this enables.
1169
1185
 
1170
1186
  Returns
1171
1187
  -------
@@ -1175,10 +1191,16 @@ def write_labels(
1175
1191
  Examples
1176
1192
  --------
1177
1193
  >>> from patchworks import merge_tile_labels
1178
- >>> merged = merge_tile_labels(
1179
- ... "stage.zarr", input_component="staged", write_to="merged.zarr"
1194
+ >>> merged, n = merge_tile_labels(
1195
+ ... "stage.zarr",
1196
+ ... input_component="staged",
1197
+ ... write_to="merged.zarr",
1198
+ ... sequential_labels=True,
1199
+ ... return_count=True,
1200
+ ... ) # doctest: +SKIP
1201
+ >>> write_labels(
1202
+ ... "scan.zarr", merged, name="cells", n_objects=n
1180
1203
  ... ) # doctest: +SKIP
1181
- >>> write_labels("scan.zarr", merged, name="cells") # doctest: +SKIP
1182
1204
  'scan.zarr/labels/cells'
1183
1205
  """
1184
1206
  arr = labels if isinstance(labels, da.Array) else da.asarray(labels)
@@ -1209,4 +1231,5 @@ def write_labels(
1209
1231
  chunks=chunks,
1210
1232
  shard=shard,
1211
1233
  progress=progress,
1234
+ n_objects=n_objects,
1212
1235
  )
@@ -148,6 +148,62 @@ def test_merge_tile_labels_standalone(tmp_path):
148
148
  assert ids.size == 1, f"object split into {ids.size} labels, expected 1"
149
149
 
150
150
 
151
+ def test_merge_tile_labels_return_count(tmp_path):
152
+ # sequential_labels=True already computes the exact object count while
153
+ # renumbering to 1..N — return_count=True surfaces it instead of
154
+ # discarding it, so a caller can persist it (e.g. write_labels'
155
+ # n_objects=) for a downstream consumer to skip re-deriving the id set.
156
+ import dask.array as da
157
+
158
+ from patchworks import merge_tile_labels
159
+
160
+ data = np.zeros((1, 16, 32), dtype="uint16")
161
+ data[0, 2:6, 2:6] = 1
162
+ data[0, 2:6, 10:14] = 1 # same tile-local label, different object
163
+ image = da.from_array(data, chunks=(1, 16, 16))
164
+
165
+ def fn(tile):
166
+ from skimage.measure import label
167
+
168
+ return label(tile > 0).astype("int32")
169
+
170
+ labeled = image.map_blocks(
171
+ fn, dtype="int32", meta=np.empty((0,) * image.ndim, dtype="int32")
172
+ )
173
+ out = str(tmp_path / "merged.zarr")
174
+ merged, n_objects = merge_tile_labels(
175
+ labeled, write_to=out, sequential_labels=True, return_count=True
176
+ )
177
+ arr = merged.compute()
178
+ ids = np.unique(arr[arr > 0])
179
+ assert n_objects == ids.size
180
+ assert n_objects == 2
181
+
182
+
183
+ def test_merge_tile_labels_return_count_none_without_sequential(tmp_path):
184
+ import dask.array as da
185
+
186
+ from patchworks import merge_tile_labels
187
+
188
+ data = np.zeros((1, 16, 32), dtype="uint16")
189
+ data[0, 4:12, 8:24] = 1
190
+ image = da.from_array(data, chunks=(1, 16, 16))
191
+
192
+ def fn(tile):
193
+ from skimage.measure import label
194
+
195
+ return label(tile > 0).astype("int32")
196
+
197
+ labeled = image.map_blocks(
198
+ fn, dtype="int32", meta=np.empty((0,) * image.ndim, dtype="int32")
199
+ )
200
+ out = str(tmp_path / "merged.zarr")
201
+ merged, n_objects = merge_tile_labels(
202
+ labeled, write_to=out, sequential_labels=False, return_count=True
203
+ )
204
+ assert n_objects is None
205
+
206
+
151
207
  def test_merge_transitive_three_tiles(tmp_path):
152
208
  # A cell that spans 3 tiles (A→B→C) must be merged into one label even
153
209
  # though A and C never directly touch. Transitivity via connected_components.
@@ -68,3 +68,39 @@ def test_inner_label_discovery_none(tmp_path):
68
68
  np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=1
69
69
  )
70
70
  assert nplugin._inner_label_names(store) == []
71
+
72
+
73
+ def test_label_hint_present_when_n_objects_written(tmp_path):
74
+ """write_labels(..., n_objects=...) is readable back via _label_hint."""
75
+ from patchworks.plugins.ome_zarr import to_ome_zarr, write_labels
76
+
77
+ store = to_ome_zarr(
78
+ np.zeros((8, 8, 8), "uint16"), tmp_path / "scan.zarr", n_levels=1
79
+ )
80
+ write_labels(
81
+ store,
82
+ np.ones((8, 8, 8), "int32"),
83
+ name="cells",
84
+ n_levels=1,
85
+ n_objects=17,
86
+ )
87
+
88
+ hint = nplugin._label_hint(f"{store}/labels/cells")
89
+ assert hint == {"n_objects": 17, "sequential_labels": True}
90
+
91
+
92
+ def test_label_hint_empty_without_n_objects(tmp_path):
93
+ """No n_objects= at write time -> no hint, not a misleading default."""
94
+ from patchworks.plugins.ome_zarr import to_ome_zarr, write_labels
95
+
96
+ store = to_ome_zarr(
97
+ np.zeros((8, 8, 8), "uint16"), tmp_path / "scan.zarr", n_levels=1
98
+ )
99
+ write_labels(store, np.ones((8, 8, 8), "int32"), name="cells", n_levels=1)
100
+
101
+ assert nplugin._label_hint(f"{store}/labels/cells") == {}
102
+
103
+
104
+ def test_label_hint_missing_store_returns_empty():
105
+ """A path that doesn't exist (or isn't a label group) just yields {}."""
106
+ assert nplugin._label_hint("/no/such/store.zarr") == {}
@@ -130,6 +130,34 @@ def test_write_labels_into_store(tmp_path):
130
130
  assert lg.attrs["image-label"]["version"]
131
131
 
132
132
 
133
+ def test_write_labels_n_objects_persisted(tmp_path):
134
+ """n_objects lands in the label group's attrs for a downstream reader."""
135
+ store = to_ome_zarr(
136
+ np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=2
137
+ )
138
+ labels = np.ones((8, 8, 8), dtype="int32")
139
+
140
+ group = write_labels(store, labels, name="cells", n_levels=2, n_objects=42)
141
+
142
+ lg = zarr.open_group(group, mode="r")
143
+ assert lg.attrs["n_objects"] == 42
144
+ assert lg.attrs["sequential_labels"] is True
145
+
146
+
147
+ def test_write_labels_no_n_objects_by_default(tmp_path):
148
+ """Without n_objects=, no misleading count is written."""
149
+ store = to_ome_zarr(
150
+ np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=2
151
+ )
152
+ labels = np.ones((8, 8, 8), dtype="int32")
153
+
154
+ group = write_labels(store, labels, name="cells", n_levels=2)
155
+
156
+ lg = zarr.open_group(group, mode="r")
157
+ assert "n_objects" not in lg.attrs
158
+ assert "sequential_labels" not in lg.attrs
159
+
160
+
133
161
  def test_reuse_pyramid_ignored_for_arrays(tmp_path):
134
162
  """reuse_pyramid only affects .ims inputs; arrays still rebuild."""
135
163
  out = to_ome_zarr(
@@ -28,13 +28,14 @@ merged_store = str(Path(work_dir) / label_name / "_merged.zarr")
28
28
  default_workers = int(
29
29
  os.environ.get("SLURM_CPUS_PER_TASK", os.cpu_count() or 4)
30
30
  )
31
- merged = merge_tile_labels(
31
+ merged, n_objects = merge_tile_labels(
32
32
  stage_path(work_dir, label_name),
33
33
  write_to=merged_store,
34
34
  input_component="staged",
35
35
  sequential_labels=cfg.get("sequential_labels", True),
36
36
  n_workers=cfg.get("merge_workers", default_workers),
37
37
  progress=False,
38
+ return_count=True,
38
39
  )
39
40
  group = write_labels(
40
41
  image_store,
@@ -43,6 +44,7 @@ group = write_labels(
43
44
  n_levels=int(cfg.get("pyramid_levels", 5)),
44
45
  downscale=int(cfg.get("pyramid_downscale", 2)),
45
46
  overwrite=True,
47
+ n_objects=n_objects,
46
48
  )
47
49
 
48
50
  shutil.rmtree(merged_store, ignore_errors=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes