patchworks 0.11.21__tar.gz → 1.1.0__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-0.11.21 → patchworks-1.1.0}/PKG-INFO +2 -2
  2. {patchworks-0.11.21 → patchworks-1.1.0}/pyproject.toml +1 -1
  3. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_chunks.py +1 -1
  4. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_merge.py +17 -4
  5. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_relabel.py +34 -4
  6. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/plugins/napari.py +38 -0
  7. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/plugins/ome_zarr.py +121 -0
  8. {patchworks-0.11.21 → patchworks-1.1.0}/tests/test_core.py +56 -0
  9. {patchworks-0.11.21 → patchworks-1.1.0}/tests/test_napari.py +36 -0
  10. {patchworks-0.11.21 → patchworks-1.1.0}/tests/test_ome_zarr.py +28 -0
  11. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/scripts/merge.py +3 -1
  12. {patchworks-0.11.21 → patchworks-1.1.0}/.github/workflows/docs.yml +0 -0
  13. {patchworks-0.11.21 → patchworks-1.1.0}/.github/workflows/lint.yml +0 -0
  14. {patchworks-0.11.21 → patchworks-1.1.0}/.github/workflows/release.yml +0 -0
  15. {patchworks-0.11.21 → patchworks-1.1.0}/.gitignore +0 -0
  16. {patchworks-0.11.21 → patchworks-1.1.0}/.markdownlint-cli2.yaml +0 -0
  17. {patchworks-0.11.21 → patchworks-1.1.0}/LICENSE +0 -0
  18. {patchworks-0.11.21 → patchworks-1.1.0}/README.md +0 -0
  19. {patchworks-0.11.21 → patchworks-1.1.0}/cliff.toml +0 -0
  20. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/chunks.md +0 -0
  21. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/cluster.md +0 -0
  22. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/io.md +0 -0
  23. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/merge_tile_labels.md +0 -0
  24. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/plugins/cellpose.md +0 -0
  25. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/plugins/napari.md +0 -0
  26. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/plugins/ome_zarr.md +0 -0
  27. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/relabel.md +0 -0
  28. {patchworks-0.11.21 → patchworks-1.1.0}/docs/api/tile_process.md +0 -0
  29. {patchworks-0.11.21 → patchworks-1.1.0}/docs/assets/logo.png +0 -0
  30. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/cellpose_2d.md +0 -0
  31. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/cellpose_2d.py +0 -0
  32. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/cellpose_3d.md +0 -0
  33. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/cellpose_3d.py +0 -0
  34. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/custom.md +0 -0
  35. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/custom_method.py +0 -0
  36. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/standalone_merge.md +0 -0
  37. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/stardist.md +0 -0
  38. {patchworks-0.11.21 → patchworks-1.1.0}/docs/examples/stardist_2d.py +0 -0
  39. {patchworks-0.11.21 → patchworks-1.1.0}/docs/getting_started.md +0 -0
  40. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/gpu_distributed.md +0 -0
  41. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/merging.md +0 -0
  42. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/ome_zarr_napari.md +0 -0
  43. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/performance.md +0 -0
  44. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/pitfalls.md +0 -0
  45. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/skip_empty.md +0 -0
  46. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/snakemake.md +0 -0
  47. {patchworks-0.11.21 → patchworks-1.1.0}/docs/guide/tiling.md +0 -0
  48. {patchworks-0.11.21 → patchworks-1.1.0}/docs/index.md +0 -0
  49. {patchworks-0.11.21 → patchworks-1.1.0}/mkdocs.yml +0 -0
  50. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/__init__.py +0 -0
  51. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_cluster.py +0 -0
  52. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_core.py +0 -0
  53. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_distributed.py +0 -0
  54. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_io.py +0 -0
  55. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/_relations.py +0 -0
  56. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/plugins/__init__.py +0 -0
  57. {patchworks-0.11.21 → patchworks-1.1.0}/src/patchworks/plugins/cellpose.py +0 -0
  58. {patchworks-0.11.21 → patchworks-1.1.0}/tests/test_distributed.py +0 -0
  59. {patchworks-0.11.21 → patchworks-1.1.0}/tests/test_relations.py +0 -0
  60. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/README.md +0 -0
  61. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/Snakefile +0 -0
  62. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/config/config.yaml +0 -0
  63. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/config/config_cyto.yaml +0 -0
  64. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/config/config_nuclei.yaml +0 -0
  65. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/config/multi.yaml +0 -0
  66. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/pixi.toml +0 -0
  67. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/profile/slurm/config.yaml +0 -0
  68. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/rules/common.smk +0 -0
  69. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/rules/convert.smk +0 -0
  70. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/rules/merge.smk +0 -0
  71. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/rules/segment.smk +0 -0
  72. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/scripts/_pw.py +0 -0
  73. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/scripts/convert.py +0 -0
  74. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/scripts/fetch_model.py +0 -0
  75. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/scripts/prepare_tiles.py +0 -0
  76. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/scripts/run_multi.py +0 -0
  77. {patchworks-0.11.21 → patchworks-1.1.0}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 0.11.21
3
+ Version: 1.1.0
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
@@ -8,7 +8,7 @@ Author-email: Laurent Guerard <laurent.guerard@unibas.ch>
8
8
  License: GPL-3.0-only
9
9
  License-File: LICENSE
10
10
  Keywords: bioimage,chunked,dask,image processing,segmentation,tiling,zarr
11
- Classifier: Development Status :: 4 - Beta
11
+ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Science/Research
13
13
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
14
14
  Classifier: Programming Language :: Python :: 3
@@ -19,7 +19,7 @@ keywords = [
19
19
  "bioimage",
20
20
  ]
21
21
  classifiers = [
22
- "Development Status :: 4 - Beta",
22
+ "Development Status :: 5 - Production/Stable",
23
23
  "Intended Audience :: Science/Research",
24
24
  "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
25
25
  "Programming Language :: Python :: 3",
@@ -179,7 +179,7 @@ def auto_tile_shape(
179
179
  --------
180
180
  >>> tile = auto_tile_shape((128, 2048, 2048), "uint16")
181
181
  >>> tile
182
- (8, 2048, 2048)
182
+ (128, 512, 512)
183
183
  """
184
184
  n_workers = n_workers or os.cpu_count() or 1
185
185
  itemsize = np.dtype(dtype).itemsize
@@ -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
@@ -33,7 +33,7 @@ def relabel_sequential_array(labels: np.ndarray) -> np.ndarray:
33
33
  Examples
34
34
  --------
35
35
  >>> relabel_sequential_array(np.array([0, 500000, 500000, 7]))
36
- array([0, 2, 2, 1])
36
+ array([0, 2, 2, 1], dtype=uint16)
37
37
  """
38
38
  uniq = np.unique(labels)
39
39
  max_label = int(uniq[-1])
@@ -53,11 +53,41 @@ def relabel_sequential_array(labels: np.ndarray) -> np.ndarray:
53
53
 
54
54
 
55
55
  def relabel_sequential_zarr(store_path: str, component: str = "labels") -> int:
56
- """Relabel a written label zarr to contiguous ids, in place. Returns N.
56
+ """Relabel a written label zarr to contiguous ids, in place.
57
57
 
58
58
  Two-pass streaming algorithm — safe for arrays far larger than RAM.
59
- Pass 1 collects unique ids (bounded memory: a set). Pass 2 applies the
60
- lookup-table remap chunk by chunk.
59
+ Pass 1 collects unique ids (bounded memory: a Python ``set``, not the
60
+ voxels themselves). Pass 2 applies the lookup-table remap chunk by
61
+ chunk, writing back into the same store.
62
+
63
+ Parameters
64
+ ----------
65
+ store_path : str
66
+ Path to the zarr store containing the label array.
67
+ component : str, optional
68
+ Array name inside the store to relabel in place (default
69
+ ``"labels"``).
70
+
71
+ Returns
72
+ -------
73
+ int
74
+ Number of distinct objects (``N``); the array now holds ``1..N``
75
+ (background ``0`` unchanged).
76
+
77
+ Examples
78
+ --------
79
+ >>> import zarr
80
+ >>> root = zarr.open_group("staged.zarr", mode="w") # doctest: +SKIP
81
+ >>> root.create_array(
82
+ ... "labels", shape=(4, 4), chunks=(4, 4), dtype="int32"
83
+ ... )[:] = [
84
+ ... [0, 500000, 500000, 0],
85
+ ... [0, 0, 0, 7],
86
+ ... [0, 0, 0, 0],
87
+ ... [0, 0, 0, 0],
88
+ ... ] # doctest: +SKIP
89
+ >>> relabel_sequential_zarr("staged.zarr") # doctest: +SKIP
90
+ 2
61
91
  """
62
92
  root = zarr.open_group(store_path, mode="r+")
63
93
  z = root[component]
@@ -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-dask-ndmeasure) can use it instead of re-deriving the id set
191
+ 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)
@@ -953,10 +953,41 @@ def add_pyramid(
953
953
  ``multiscales`` metadata. Existing calibration is preserved; pass
954
954
  *pixel_size* to set it.
955
955
 
956
+ Parameters
957
+ ----------
958
+ group_path : str or Path
959
+ Zarr group containing the full-resolution array at *base*.
960
+ base : str, optional
961
+ Component name of the existing full-resolution level (default
962
+ ``"0"``). Auto-detected from existing ``multiscales`` metadata if
963
+ present, overriding this.
964
+ axes : str, optional
965
+ One letter per axis, e.g. ``"zyx"``. ``None`` → inferred from
966
+ existing metadata, or from the array's dimensionality.
967
+ pixel_size : dict, tuple or None, optional
968
+ Physical voxel size in micrometers. ``None`` → read from the store's
969
+ existing calibration, if any.
970
+ n_levels : int, optional
971
+ Maximum number of levels including the existing full-resolution one
972
+ (default 5).
973
+ downscale : int, optional
974
+ Per-level X/Y downsampling factor (default 2).
975
+ chunks : tuple of int, optional
976
+ Chunk shape for the written levels. ``None`` → a bounded default.
977
+ shard : bool or tuple of int, optional
978
+ Sharding request (see :func:`to_ome_zarr`'s *shard*).
979
+ progress : bool, optional
980
+ Show a per-level dask progress bar (default ``True``).
981
+
956
982
  Returns
957
983
  -------
958
984
  str
959
985
  The path to the updated group.
986
+
987
+ Examples
988
+ --------
989
+ >>> add_pyramid("scan.zarr", n_levels=4) # doctest: +SKIP
990
+ 'scan.zarr'
960
991
  """
961
992
  if downscale < 2:
962
993
  raise ValueError("downscale must be >= 2")
@@ -1014,6 +1045,7 @@ def register_labels(
1014
1045
  chunks: Union[tuple[int, ...], None] = None,
1015
1046
  shard: ShardSpec = False,
1016
1047
  progress: bool = True,
1048
+ n_objects: Union[int, None] = None,
1017
1049
  ) -> str:
1018
1050
  """Pyramidalise and register an existing ``labels/<name>/0`` base level.
1019
1051
 
@@ -1023,10 +1055,46 @@ def register_labels(
1023
1055
  ``labels/.zattrs``, and inherits the parent image's pixel calibration
1024
1056
  (unless *pixel_size* is given).
1025
1057
 
1058
+ Parameters
1059
+ ----------
1060
+ image_store : str or Path
1061
+ OME-ZARR store path containing the image this label belongs to.
1062
+ name : str, optional
1063
+ Label image name under ``labels/`` (default ``"labels"``).
1064
+ axes : str, optional
1065
+ One letter per axis. ``None`` → inferred from the label array.
1066
+ pixel_size : dict, tuple or None, optional
1067
+ Physical voxel size in micrometers. ``None`` → inherited from the
1068
+ parent image's own calibration.
1069
+ n_levels : int, optional
1070
+ Maximum number of pyramid levels including full resolution
1071
+ (default 5).
1072
+ downscale : int, optional
1073
+ Per-level X/Y downsampling factor (default 2).
1074
+ chunks : tuple of int, optional
1075
+ Chunk shape for the written levels. ``None`` → a bounded default.
1076
+ shard : bool or tuple of int, optional
1077
+ Sharding request (see :func:`to_ome_zarr`'s *shard*).
1078
+ progress : bool, optional
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-dask-ndmeasure) can use the known id set instead of
1087
+ re-deriving it with a full-volume scan of its own.
1088
+
1026
1089
  Returns
1027
1090
  -------
1028
1091
  str
1029
1092
  Path to the label group (``image_store/labels/<name>``).
1093
+
1094
+ Examples
1095
+ --------
1096
+ >>> register_labels("scan.zarr", "cells") # doctest: +SKIP
1097
+ 'scan.zarr/labels/cells'
1030
1098
  """
1031
1099
  store = str(image_store)
1032
1100
  group = f"{store}/labels/{name}"
@@ -1047,6 +1115,9 @@ def register_labels(
1047
1115
  )
1048
1116
  grp = zarr.open_group(group, mode="a")
1049
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
1050
1121
 
1051
1122
  labels_grp = zarr.open_group(f"{store}/labels", mode="a")
1052
1123
  registered = list(labels_grp.attrs.get("labels", []))
@@ -1069,6 +1140,7 @@ def write_labels(
1069
1140
  shard: ShardSpec = False,
1070
1141
  progress: bool = True,
1071
1142
  overwrite: bool = False,
1143
+ n_objects: Union[int, None] = None,
1072
1144
  ) -> str:
1073
1145
  """Store *labels* inside *image_store* under the NGFF ``labels/`` group.
1074
1146
 
@@ -1078,10 +1150,58 @@ def write_labels(
1078
1150
  single OME-ZARR store. Calibration is inherited from the parent image
1079
1151
  unless *pixel_size* is given.
1080
1152
 
1153
+ Parameters
1154
+ ----------
1155
+ image_store : str or Path
1156
+ OME-ZARR store this label image belongs to.
1157
+ labels : da.Array or np.ndarray
1158
+ Integer label array (0 = background), same spatial shape as the
1159
+ image.
1160
+ name : str, optional
1161
+ Label image name under ``labels/`` (default ``"labels"``).
1162
+ axes : str, optional
1163
+ One letter per axis. ``None`` → inferred from *labels*'
1164
+ dimensionality.
1165
+ pixel_size : dict, tuple or None, optional
1166
+ Physical voxel size in micrometers. ``None`` → inherited from the
1167
+ parent image's own calibration.
1168
+ n_levels : int, optional
1169
+ Maximum number of pyramid levels including full resolution
1170
+ (default 5).
1171
+ downscale : int, optional
1172
+ Per-level X/Y downsampling factor (default 2).
1173
+ chunks : tuple of int, optional
1174
+ Chunk shape for the written levels. ``None`` → a bounded default.
1175
+ shard : bool or tuple of int, optional
1176
+ Sharding request (see :func:`to_ome_zarr`'s *shard*).
1177
+ progress : bool, optional
1178
+ Show a per-level dask progress bar (default ``True``).
1179
+ overwrite : bool, optional
1180
+ Replace an existing label image of the same *name* (default
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.
1185
+
1081
1186
  Returns
1082
1187
  -------
1083
1188
  str
1084
1189
  Path to the written label group (``image_store/labels/<name>``).
1190
+
1191
+ Examples
1192
+ --------
1193
+ >>> from patchworks import merge_tile_labels
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
1203
+ ... ) # doctest: +SKIP
1204
+ 'scan.zarr/labels/cells'
1085
1205
  """
1086
1206
  arr = labels if isinstance(labels, da.Array) else da.asarray(labels)
1087
1207
  if axes is None:
@@ -1111,4 +1231,5 @@ def write_labels(
1111
1231
  chunks=chunks,
1112
1232
  shard=shard,
1113
1233
  progress=progress,
1234
+ n_objects=n_objects,
1114
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