patchworks 1.1.0__tar.gz → 1.2.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 (83) hide show
  1. {patchworks-1.1.0 → patchworks-1.2.0}/PKG-INFO +6 -3
  2. {patchworks-1.1.0 → patchworks-1.2.0}/cliff.toml +3 -2
  3. patchworks-1.2.0/docs/api/plugins/dog.md +3 -0
  4. patchworks-1.2.0/docs/examples/dog.md +110 -0
  5. patchworks-1.2.0/docs/examples/dog.py +24 -0
  6. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/snakemake.md +22 -1
  7. {patchworks-1.1.0 → patchworks-1.2.0}/mkdocs.yml +2 -0
  8. {patchworks-1.1.0 → patchworks-1.2.0}/pyproject.toml +13 -4
  9. patchworks-1.2.0/src/patchworks/plugins/dog.py +202 -0
  10. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/plugins/napari.py +2 -2
  11. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/plugins/ome_zarr.py +2 -2
  12. patchworks-1.2.0/tests/test_dog.py +49 -0
  13. patchworks-1.2.0/workflow/config/config_cilia.yaml +50 -0
  14. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/config/multi.yaml +11 -2
  15. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/pixi.toml +3 -1
  16. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/scripts/run_multi.py +50 -16
  17. {patchworks-1.1.0 → patchworks-1.2.0}/.github/workflows/docs.yml +0 -0
  18. {patchworks-1.1.0 → patchworks-1.2.0}/.github/workflows/lint.yml +0 -0
  19. {patchworks-1.1.0 → patchworks-1.2.0}/.github/workflows/release.yml +0 -0
  20. {patchworks-1.1.0 → patchworks-1.2.0}/.gitignore +0 -0
  21. {patchworks-1.1.0 → patchworks-1.2.0}/.markdownlint-cli2.yaml +0 -0
  22. {patchworks-1.1.0 → patchworks-1.2.0}/LICENSE +0 -0
  23. {patchworks-1.1.0 → patchworks-1.2.0}/README.md +0 -0
  24. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/chunks.md +0 -0
  25. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/cluster.md +0 -0
  26. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/io.md +0 -0
  27. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/merge_tile_labels.md +0 -0
  28. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/plugins/cellpose.md +0 -0
  29. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/plugins/napari.md +0 -0
  30. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/plugins/ome_zarr.md +0 -0
  31. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/relabel.md +0 -0
  32. {patchworks-1.1.0 → patchworks-1.2.0}/docs/api/tile_process.md +0 -0
  33. {patchworks-1.1.0 → patchworks-1.2.0}/docs/assets/logo.png +0 -0
  34. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/cellpose_2d.md +0 -0
  35. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/cellpose_2d.py +0 -0
  36. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/cellpose_3d.md +0 -0
  37. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/cellpose_3d.py +0 -0
  38. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/custom.md +0 -0
  39. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/custom_method.py +0 -0
  40. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/standalone_merge.md +0 -0
  41. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/stardist.md +0 -0
  42. {patchworks-1.1.0 → patchworks-1.2.0}/docs/examples/stardist_2d.py +0 -0
  43. {patchworks-1.1.0 → patchworks-1.2.0}/docs/getting_started.md +0 -0
  44. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/gpu_distributed.md +0 -0
  45. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/merging.md +0 -0
  46. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/ome_zarr_napari.md +0 -0
  47. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/performance.md +0 -0
  48. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/pitfalls.md +0 -0
  49. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/skip_empty.md +0 -0
  50. {patchworks-1.1.0 → patchworks-1.2.0}/docs/guide/tiling.md +0 -0
  51. {patchworks-1.1.0 → patchworks-1.2.0}/docs/index.md +0 -0
  52. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/__init__.py +0 -0
  53. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_chunks.py +0 -0
  54. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_cluster.py +0 -0
  55. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_core.py +0 -0
  56. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_distributed.py +0 -0
  57. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_io.py +0 -0
  58. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_merge.py +0 -0
  59. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_relabel.py +0 -0
  60. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/_relations.py +0 -0
  61. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/plugins/__init__.py +0 -0
  62. {patchworks-1.1.0 → patchworks-1.2.0}/src/patchworks/plugins/cellpose.py +0 -0
  63. {patchworks-1.1.0 → patchworks-1.2.0}/tests/test_core.py +0 -0
  64. {patchworks-1.1.0 → patchworks-1.2.0}/tests/test_distributed.py +0 -0
  65. {patchworks-1.1.0 → patchworks-1.2.0}/tests/test_napari.py +0 -0
  66. {patchworks-1.1.0 → patchworks-1.2.0}/tests/test_ome_zarr.py +0 -0
  67. {patchworks-1.1.0 → patchworks-1.2.0}/tests/test_relations.py +0 -0
  68. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/README.md +0 -0
  69. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/Snakefile +0 -0
  70. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/config/config.yaml +0 -0
  71. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/config/config_cyto.yaml +0 -0
  72. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/config/config_nuclei.yaml +0 -0
  73. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/profile/slurm/config.yaml +0 -0
  74. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/rules/common.smk +0 -0
  75. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/rules/convert.smk +0 -0
  76. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/rules/merge.smk +0 -0
  77. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/rules/segment.smk +0 -0
  78. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/scripts/_pw.py +0 -0
  79. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/scripts/convert.py +0 -0
  80. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/scripts/fetch_model.py +0 -0
  81. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/scripts/merge.py +0 -0
  82. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/scripts/prepare_tiles.py +0 -0
  83. {patchworks-1.1.0 → patchworks-1.2.0}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 1.1.0
3
+ Version: 1.2.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
@@ -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'
@@ -64,6 +64,8 @@ Requires-Dist: tqdm; extra == 'dev'
64
64
  Provides-Extra: docs
65
65
  Requires-Dist: mkdocs-material>=9.0; extra == 'docs'
66
66
  Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
67
+ Provides-Extra: dog
68
+ Requires-Dist: pycudadecon; extra == 'dog'
67
69
  Provides-Extra: gpu
68
70
  Requires-Dist: nvidia-ml-py; extra == 'gpu'
69
71
  Provides-Extra: imaris
@@ -75,10 +77,11 @@ Provides-Extra: napari
75
77
  Requires-Dist: glasbey; extra == 'napari'
76
78
  Requires-Dist: ipykernel<7; extra == 'napari'
77
79
  Requires-Dist: lxml-html-clean; extra == 'napari'
78
- Requires-Dist: napari-dask-ndmeasure>=0.1.0; extra == 'napari'
80
+ Requires-Dist: napari-chunked-regionprops>=0.1.0; extra == 'napari'
79
81
  Requires-Dist: napari[all]>=0.7.1; extra == 'napari'
80
82
  Requires-Dist: numpy<2.5; extra == 'napari'
81
83
  Provides-Extra: workflow
84
+ Requires-Dist: openpyxl; extra == 'workflow'
82
85
  Requires-Dist: snakemake-executor-plugin-slurm; extra == 'workflow'
83
86
  Requires-Dist: snakemake>=8; extra == 'workflow'
84
87
  Description-Content-Type: text/markdown
@@ -11,8 +11,9 @@ body = """
11
11
  {% if github.contributors | length > 0 %}
12
12
  ### 👥 Contributors
13
13
 
14
- {% for contributor in github.contributors | sort(attribute="username") %}\
15
- * @{{ contributor.username }}
14
+ {% for contributor in github.contributors %}\
15
+ {% if contributor.username %}* @{{ contributor.username }}
16
+ {% endif %}\
16
17
  {% endfor %}
17
18
  {% endif %}\
18
19
  """
@@ -0,0 +1,3 @@
1
+ # Difference-of-Gaussians (dog) plugin
2
+
3
+ ::: patchworks.plugins.dog.dog_label_fn
@@ -0,0 +1,110 @@
1
+ # Difference of Gaussians (blobs, threads, cilia, …)
2
+
3
+ A lightweight blob/thread detector for structures Cellpose isn't shaped for
4
+ (cilia, spots, fibres): blur twice at different sigmas, subtract, threshold,
5
+ label the connected components. CPU (scipy) by default, GPU (cupy) optional.
6
+ Optionally deconvolve each tile first with
7
+ [pycudadecon](https://github.com/tlambert03/pycudadecon).
8
+
9
+ ## Installation
10
+
11
+ `dog_label_fn` itself only needs patchworks' core deps (scipy). The
12
+ deconvolution step needs pycudadecon:
13
+
14
+ ```bash
15
+ pip install "patchworks[dog]"
16
+ ```
17
+
18
+ GPU blur/label (`use_gpu=True`) needs `cupy` too, matching your CUDA version
19
+ (e.g. `pip install cupy-cuda12x`) — not bundled in the `dog` extra since it's
20
+ CUDA-version-specific.
21
+
22
+ ## Code
23
+
24
+ ```python
25
+ import numpy as np
26
+ from patchworks import tile_process
27
+ from patchworks.plugins.dog import dog_label_fn
28
+
29
+ IMAGE = "image.zarr"
30
+ OUTPUT = "labels_dog.zarr"
31
+
32
+ fn = dog_label_fn(low_sigma=1.0, high_sigma=3.0, threshold=0.02)
33
+
34
+ tile_process(
35
+ IMAGE,
36
+ fn,
37
+ channel=1,
38
+ tile_shape=(1, 1024, 1024),
39
+ overlap=8, # just needs to cover one object + high_sigma
40
+ write_to=OUTPUT,
41
+ progress=True,
42
+ )
43
+ ```
44
+
45
+ ## Picking `low_sigma` / `high_sigma` / `threshold`
46
+
47
+ `dog = blur(low_sigma) - blur(high_sigma)`. `low_sigma` should be about the
48
+ object's radius (denoises without erasing it); `high_sigma` a few times
49
+ larger (models the background to subtract out). `threshold` is applied
50
+ directly to the DoG image — start near the DoG's typical peak value on a
51
+ known-positive region and adjust from there; there's no auto (Otsu-style)
52
+ option, since the DoG image isn't bimodal the way a raw intensity image is.
53
+
54
+ ## GPU
55
+
56
+ ```python
57
+ fn = dog_label_fn(low_sigma=1.0, high_sigma=3.0, threshold=0.02, use_gpu=True)
58
+ ```
59
+
60
+ Requires `cupy` (matching your CUDA version, e.g. `pip install cupy-cuda12x`)
61
+ — not a patchworks dependency, install it separately.
62
+
63
+ ## With deconvolution first
64
+
65
+ ```python
66
+ fn = dog_label_fn(
67
+ low_sigma=1.0, high_sigma=3.0, threshold=0.02,
68
+ decon_kwargs=dict(
69
+ psf=psf, dxpsf=xy_scale, dxdata=xy_scale,
70
+ dzpsf=z_scale, dzdata=z_scale,
71
+ wavelength=525, na=1.4, nimm=1.515,
72
+ ),
73
+ )
74
+ result = tile_process(IMAGE, fn, tile_shape=(1, 1024, 1024), overlap=32)
75
+ ```
76
+
77
+ !!! note "Deconvolution always needs a GPU"
78
+ `pycudadecon` is CUDA-only, independent of `dog_label_fn`'s own `use_gpu`
79
+ flag (which only picks the backend for the blur/label steps). A SLURM job
80
+ running this needs a GPU allocated. Widen `overlap` past the PSF support
81
+ so edge tiles keep enough context (a plain intensity/threshold halo is
82
+ too thin).
83
+
84
+ ## Using it in the Snakemake workflow
85
+
86
+ No dedicated wiring needed — `patchworks.plugins.dog` exposes a `segment(tile, **kwargs)`
87
+ adapter for the documented [`"custom"` method](../guide/snakemake.md#custom-segmentation-function):
88
+
89
+ ```yaml
90
+ method: "custom"
91
+ label_name: "cilia_labels"
92
+ custom:
93
+ module: "patchworks.plugins.dog"
94
+ function: "segment"
95
+ kwargs:
96
+ low_sigma: 1.0
97
+ high_sigma: 3.0
98
+ threshold: 0.02
99
+ ```
100
+
101
+ See `workflow/config/config_cilia.yaml` for a full example, including
102
+ deconvolution.
103
+
104
+ ## Relating cilia to their cell
105
+
106
+ Segment the cell body with Cellpose and the cilia with `dog_label_fn` as two
107
+ separate `tile_process` runs (same image, same `tile_shape`), then use
108
+ [`label_relations`](../guide/snakemake.md#relating-labels-across-segmentations)
109
+ to map each cilium to the cell it belongs to — see
110
+ `workflow/config/multi.yaml` for the same thing wired up as a cluster job.
@@ -0,0 +1,24 @@
1
+ """Difference-of-Gaussians blob/thread segmentation (e.g. cilia).
2
+
3
+ Blur twice at different sigmas, subtract, threshold, label the connected
4
+ components. CPU (scipy) by default.
5
+ """
6
+
7
+ from patchworks import tile_process
8
+ from patchworks.plugins.dog import dog_label_fn
9
+
10
+ IMAGE = "image.zarr"
11
+ OUTPUT = "labels_dog.zarr"
12
+ CHANNEL = 1
13
+
14
+ fn = dog_label_fn(low_sigma=1.0, high_sigma=3.0, threshold=0.02)
15
+
16
+ tile_process(
17
+ IMAGE,
18
+ fn,
19
+ channel=CHANNEL,
20
+ tile_shape=(1, 1024, 1024),
21
+ overlap=8,
22
+ write_to=OUTPUT,
23
+ progress=True,
24
+ )
@@ -303,7 +303,7 @@ segmentations:
303
303
  relations:
304
304
  - a: nuclei_labels
305
305
  b: cyto_labels
306
- output: nuclei_to_cyto.csv # written into work_dir
306
+ output: nuclei_to_cyto.xlsx # written into work_dir
307
307
  ```
308
308
 
309
309
  ```bash
@@ -317,6 +317,19 @@ Every listed segmentation config must share the same `work_dir` (so
317
317
  script checks this and errors out otherwise. `relations` is optional; omit it
318
318
  to just chain segmentations without a relation step.
319
319
 
320
+ Each `output:` is an Excel workbook (`openpyxl`, part of the `workflow`
321
+ extra) with two sheets:
322
+
323
+ | Sheet | One row per | Columns |
324
+ | --- | --- | --- |
325
+ | `<a>` | every non-background `a` label, **including unmatched ones** | `<a>_id`, `<b>_id` (blank if unmatched), `overlap_voxels`, `overlap_fraction` (0 if unmatched) |
326
+ | `<b>` | every non-background `b` label, **including ones with zero matches** | `<b>_id`, `<a>_count`, `total_overlap_voxels` |
327
+
328
+ Unlike calling `label_relations()` directly (which only returns matched `a`
329
+ labels — see below), the workbook always covers every object in both
330
+ segmentations, so counts (e.g. "how many nuclei have no matching cell",
331
+ "how many cells have zero cilia") aren't silently dropped.
332
+
320
333
  Both lists are ordinary lists, so 3+ segmentations work the same way — add
321
334
  more entries to `segmentations`, then list whichever pairs to relate. There's
322
335
  no automatic "chain": list every pair explicitly, e.g. for nuclei + cyto +
@@ -324,6 +337,14 @@ membrane you'd add `nuclei_labels -> cyto_labels`, `nuclei_labels ->
324
337
  membrane_labels`, and `cyto_labels -> membrane_labels` as three separate
325
338
  entries under `relations`.
326
339
 
340
+ The shipped `config/multi.yaml` is actually a three-way example: nuclei +
341
+ cytoplasm (Cellpose) plus cilia (`method: "custom"` ->
342
+ [`patchworks.plugins.dog`](../examples/dog.md), deconvolution + a
343
+ difference-of-Gaussians detector), related both ways (`cilia_labels ->
344
+ cyto_labels` and `cilia_labels -> nuclei_labels`) so you can use whichever
345
+ fits a given dataset. See `config/config_cilia.yaml`. Its deconvolution step
346
+ needs `pip install "patchworks[dog]"` in the segment jobs' environment.
347
+
327
348
  ## Measurements (fast, whole-volume regionprops)
328
349
 
329
350
  `skimage.measure.regionprops` needs the full labelled + intensity array in
@@ -48,6 +48,7 @@ nav:
48
48
  - Cellpose 2-D: examples/cellpose_2d.md
49
49
  - Cellpose 3-D: examples/cellpose_3d.md
50
50
  - StarDist: examples/stardist.md
51
+ - Difference of Gaussians: examples/dog.md
51
52
  - Custom function: examples/custom.md
52
53
  - Standalone merge: examples/standalone_merge.md
53
54
  - API Reference:
@@ -59,6 +60,7 @@ nav:
59
60
  - Cluster helpers: api/cluster.md
60
61
  - Plugins:
61
62
  - Cellpose: api/plugins/cellpose.md
63
+ - Difference of Gaussians: api/plugins/dog.md
62
64
  - OME-ZARR conversion: api/plugins/ome_zarr.md
63
65
  - napari viewer: api/plugins/napari.md
64
66
 
@@ -43,6 +43,11 @@ io = ["psutil", "tqdm"]
43
43
  cellpose = ["cellpose>=3.0"]
44
44
  cellpose3 = ["cellpose>=3.0,<4"]
45
45
  cellpose4 = ["cellpose>=4"]
46
+ # dog enables the deconvolution step in patchworks.plugins.dog (method:
47
+ # "custom" -> segment). cupy, for that plugin's use_gpu=True blur/label
48
+ # path, isn't included here — it's CUDA-version-specific (e.g.
49
+ # cupy-cuda12x), install it separately to match your CUDA version.
50
+ dog = ["pycudadecon"]
46
51
  gpu = ["nvidia-ml-py"]
47
52
  # bioio enables converting any image format (CZI, LIF, ND2, OME-TIFF, …) to
48
53
  # OME-ZARR. Ships the common native readers (faster than going through Java)
@@ -67,19 +72,23 @@ imaris = ["imaris-ims-file-reader"]
67
72
  # - lxml-html-clean: napari's notebook_display imports lxml.html.clean, split
68
73
  # into a separate package in lxml >= 5.2 (else ImportError on Viewer()).
69
74
  # - glasbey: distinct high-contrast label LUTs for view_in_napari.
70
- # - napari-dask-ndmeasure: out-of-core regionprops-style measurements
75
+ # - napari-chunked-regionprops: out-of-core regionprops-style measurements
71
76
  # (area/centroid/intensity stats) for huge Labels layers, straight off
72
- # their backing dask/zarr arrays — the "Measure" dock widget.
77
+ # their backing dask/zarr arrays — the "Measure" dock widget. Formerly
78
+ # napari-dask-ndmeasure; renamed when its engine dropped dask_image.ndmeasure
79
+ # for a chunk-local map/merge that scales with chunk count, not object count.
73
80
  napari = [
74
81
  "napari[all]>=0.7.1",
75
82
  "numpy<2.5",
76
83
  "ipykernel<7",
77
84
  "lxml-html-clean",
78
85
  "glasbey",
79
- "napari-dask-ndmeasure>=0.1.0",
86
+ "napari-chunked-regionprops>=0.1.0",
80
87
  ]
81
88
  # workflow runs the Snakemake pipeline (per-tile SLURM jobs across GPUs).
82
- workflow = ["snakemake>=8", "snakemake-executor-plugin-slurm"]
89
+ # openpyxl -> scripts/run_multi.py writes label_relations() output as an
90
+ # Excel workbook (per-object + per-container sheets), not a plain CSV.
91
+ workflow = ["snakemake>=8", "snakemake-executor-plugin-slurm", "openpyxl"]
83
92
  dev = ["pytest", "pytest-cov", "scikit-image", "psutil", "tqdm"]
84
93
  docs = ["mkdocs-material>=9.0", "mkdocstrings[python]>=0.24"]
85
94
  all = [
@@ -0,0 +1,202 @@
1
+ """Difference-of-Gaussians plugin for patchworks.
2
+
3
+ Optional deconvolution (pycudadecon) followed by a DoG blob/spot detector:
4
+ threshold the difference of two Gaussian blurs and label connected
5
+ components. CPU (scipy) or GPU (cupy) backed.
6
+
7
+ Usage
8
+ -----
9
+ >>> from patchworks.plugins.dog import dog_label_fn
10
+ >>> from patchworks import tile_process
11
+ >>>
12
+ >>> fn = dog_label_fn(low_sigma=1.0, high_sigma=3.0, threshold=0.02)
13
+ >>> result = tile_process("image.zarr", fn, tile_shape=(1, 2048, 2048),
14
+ ... overlap=8, write_to="labels.zarr", progress=True)
15
+
16
+ With deconvolution first (widen ``overlap`` to cover the PSF support):
17
+
18
+ >>> fn = dog_label_fn(
19
+ ... low_sigma=1.0, high_sigma=3.0, threshold=0.02,
20
+ ... decon_kwargs=dict(psf=psf, dxpsf=xy_scale, dxdata=xy_scale,
21
+ ... dzpsf=z_scale, dzdata=z_scale,
22
+ ... wavelength=wavelength, na=numerical_aperture,
23
+ ... nimm=refractive_index),
24
+ ... )
25
+ >>> result = tile_process("image.zarr", fn, tile_shape=(1, 2048, 2048), overlap=32)
26
+
27
+ For the Snakemake workflow's ``method: "custom"`` (see
28
+ docs/guide/snakemake.md "Custom segmentation function"), use the
29
+ :func:`segment` adapter instead of the factory directly:
30
+
31
+ >>> # custom: {module: "patchworks.plugins.dog", function: "segment",
32
+ >>> # kwargs: {low_sigma: 1.0, high_sigma: 3.0, threshold: 0.02}}
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from functools import partial
38
+ from typing import Any, Callable
39
+
40
+ import numpy as np
41
+
42
+
43
+ def _require_cupy():
44
+ """Raise an actionable ImportError if cupy is not installed.
45
+
46
+ Returns
47
+ -------
48
+ None
49
+ """
50
+ try:
51
+ import cupy # noqa: F401
52
+ import cupyx.scipy.ndimage # noqa: F401
53
+ except ImportError as exc:
54
+ raise ImportError(
55
+ "cupy is not installed. Install a build matching your CUDA "
56
+ "version (e.g. pip install cupy-cuda12x), or use use_gpu=False "
57
+ "for the CPU (scipy) path."
58
+ ) from exc
59
+
60
+
61
+ def _require_pycudadecon():
62
+ """Raise an actionable ImportError if pycudadecon is not installed.
63
+
64
+ Returns
65
+ -------
66
+ None
67
+ """
68
+ try:
69
+ import pycudadecon # noqa: F401
70
+ except ImportError as exc:
71
+ raise ImportError(
72
+ "pycudadecon is not installed. Install it with:\n"
73
+ " pip install pycudadecon\n"
74
+ "or drop decon_kwargs to skip deconvolution."
75
+ ) from exc
76
+
77
+
78
+ def dog_label_fn(
79
+ low_sigma: float | tuple[float, ...],
80
+ high_sigma: float | tuple[float, ...],
81
+ threshold: float,
82
+ *,
83
+ use_gpu: bool = False,
84
+ decon_kwargs: dict[str, Any] | None = None,
85
+ ) -> Callable[[np.ndarray], np.ndarray]:
86
+ """Return a ready-to-use DoG labeler for ``tile_process``.
87
+
88
+ Parameters
89
+ ----------
90
+ low_sigma, high_sigma:
91
+ Gaussian sigmas (pixels) for the narrow/wide blur.
92
+ ``dog = blur(low_sigma) - blur(high_sigma)``.
93
+ threshold:
94
+ Binary threshold applied to the DoG image (``dog > threshold``).
95
+ use_gpu:
96
+ Run gaussian_filter + label on GPU via cupy/cupyx instead of scipy.
97
+ Independent of ``decon_kwargs`` — pycudadecon always needs a CUDA
98
+ GPU regardless of this flag (it takes/returns plain NumPy), and this
99
+ flag only picks the backend for the blur/label steps that follow.
100
+ decon_kwargs:
101
+ If given, each tile is first deconvolved via
102
+ ``pycudadecon.decon(block, **decon_kwargs)`` before the DoG step.
103
+ ``None`` (default) skips deconvolution. pycudadecon is CUDA-only, so
104
+ a SLURM job running this needs a GPU allocated regardless of
105
+ ``use_gpu`` above. Widen ``tile_process``'s ``overlap`` to cover the
106
+ PSF support when deconvolving, so edge tiles don't get truncated
107
+ context.
108
+
109
+ Returns
110
+ -------
111
+ Callable[[ndarray], ndarray]
112
+ Picklable function ready for ``tile_process``.
113
+ """
114
+ if use_gpu:
115
+ _require_cupy()
116
+ if decon_kwargs is not None:
117
+ _require_pycudadecon()
118
+ cfg = {
119
+ "low_sigma": low_sigma,
120
+ "high_sigma": high_sigma,
121
+ "threshold": threshold,
122
+ "use_gpu": use_gpu,
123
+ "decon_kwargs": decon_kwargs,
124
+ }
125
+ return partial(_run, dog_dict=cfg)
126
+
127
+
128
+ def _run(block: np.ndarray, dog_dict: dict[str, Any]) -> np.ndarray:
129
+ """Deconvolve (optional), then DoG-threshold-label one tile.
130
+
131
+ Parameters
132
+ ----------
133
+ block : np.ndarray
134
+ One image tile.
135
+ dog_dict : dict
136
+ Configuration from :func:`dog_label_fn`.
137
+
138
+ Returns
139
+ -------
140
+ np.ndarray
141
+ Integer (``int32``) label array of the same shape.
142
+ """
143
+ img = block.astype("float32")
144
+
145
+ decon_kwargs = dog_dict["decon_kwargs"]
146
+ if decon_kwargs is not None:
147
+ # ponytail: re-inits the GPU/OTF context on every tile via the
148
+ # one-shot decon() API. Ceiling: per-tile setup cost dominates on
149
+ # many small tiles. Upgrade: cache a pycudadecon.RLContext per
150
+ # worker process (see cellpose.py's _model_cache) if that shows up
151
+ # in the per-tile timing that tile_process logs.
152
+ from pycudadecon import decon
153
+
154
+ img = decon(images=img, **decon_kwargs)
155
+
156
+ use_gpu = dog_dict["use_gpu"]
157
+ if use_gpu:
158
+ import cupy as cp
159
+ from cupyx.scipy.ndimage import gaussian_filter, label
160
+
161
+ img = cp.asarray(img)
162
+ else:
163
+ from scipy.ndimage import gaussian_filter, label
164
+
165
+ low_blur = gaussian_filter(img, sigma=dog_dict["low_sigma"])
166
+ high_blur = gaussian_filter(img, sigma=dog_dict["high_sigma"])
167
+ dog_image = low_blur - high_blur
168
+ mask = dog_image > dog_dict["threshold"]
169
+ labels, _ = label(mask)
170
+ labels = labels.astype("int32")
171
+
172
+ return cp.asnumpy(labels) if use_gpu else labels
173
+
174
+
175
+ def segment(tile: np.ndarray, **kwargs: Any) -> np.ndarray:
176
+ """``dog_label_fn`` as a direct-call function for ``method: "custom"``.
177
+
178
+ Snakemake's ``custom`` method (see docs/guide/snakemake.md) calls
179
+ ``segment(tile, **kwargs)`` directly rather than building a factory
180
+ first. This adapts :func:`dog_label_fn` to that contract, so this
181
+ plugin needs no dedicated wiring in the workflow's ``build_fn`` —
182
+ ``method: "custom"`` with ``module: "patchworks.plugins.dog"`` already
183
+ covers it.
184
+
185
+ Parameters
186
+ ----------
187
+ tile : np.ndarray
188
+ One image tile.
189
+ **kwargs : Any
190
+ Forwarded to :func:`dog_label_fn` (``low_sigma``, ``high_sigma``,
191
+ ``threshold``, ``use_gpu``, ``decon_kwargs``).
192
+
193
+ Returns
194
+ -------
195
+ np.ndarray
196
+ Integer (``int32``) label array of the same shape.
197
+ """
198
+ return dog_label_fn(**kwargs)(tile)
199
+
200
+
201
+ # Keep the lower-level name available for advanced users
202
+ run_dog_label = _run
@@ -187,8 +187,8 @@ def _label_hint(path: Union[str, Path]) -> dict[str, Any]:
187
187
  during the merge) — the exact id set is then ``range(1, n_objects +
188
188
  1)`` by construction, with no scan needed. Passed through as a Labels
189
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.
190
+ napari-chunked-regionprops) can use it instead of re-deriving the id
191
+ set from the array itself.
192
192
 
193
193
  Parameters
194
194
  ----------
@@ -1083,8 +1083,8 @@ def register_labels(
1083
1083
  ``sequential_labels=True``, which means ``ids == range(1, n_objects
1084
1084
  + 1)`` by construction). When given, written into the label group's
1085
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.
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.
1088
1088
 
1089
1089
  Returns
1090
1090
  -------
@@ -0,0 +1,49 @@
1
+ """Self-contained tests for the dog plugin. No frameworks, no fixtures."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _make_blob_image(shape=(1, 64, 64)):
7
+ img = np.zeros(shape, dtype="float32")
8
+ img[0, 28:36, 28:36] = 1.0
9
+ return img
10
+
11
+
12
+ def test_dog_label_fn_cpu_finds_blob():
13
+ from patchworks.plugins.dog import dog_label_fn
14
+
15
+ fn = dog_label_fn(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
16
+ labels = fn(_make_blob_image())
17
+
18
+ assert labels.shape == (1, 64, 64)
19
+ assert labels.dtype == np.int32
20
+ assert labels.max() >= 1 # the blob was detected
21
+ assert labels[0, 0, 0] == 0 # background stays unlabeled
22
+
23
+
24
+ def test_segment_adapter_matches_factory():
25
+ # method: "custom" calls segment(tile, **kwargs) directly — must match
26
+ # dog_label_fn(**kwargs)(tile) exactly.
27
+ from patchworks.plugins.dog import dog_label_fn, segment
28
+
29
+ kwargs = dict(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
30
+ img = _make_blob_image()
31
+
32
+ via_adapter = segment(img, **kwargs)
33
+ via_factory = dog_label_fn(**kwargs)(img)
34
+
35
+ np.testing.assert_array_equal(via_adapter, via_factory)
36
+
37
+
38
+ def test_dog_label_fn_with_tile_process():
39
+ import dask.array as da
40
+
41
+ from patchworks import tile_process
42
+ from patchworks.plugins.dog import dog_label_fn
43
+
44
+ arr = da.from_array(_make_blob_image((1, 64, 64)), chunks=(1, 64, 64))
45
+ fn = dog_label_fn(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
46
+ result = tile_process(arr, fn, overlap=4).compute()
47
+
48
+ assert result.shape == (1, 64, 64)
49
+ assert result.max() >= 1
@@ -0,0 +1,50 @@
1
+ # Example segmentation config: cilia channel, via method: "custom" ->
2
+ # patchworks.plugins.dog.segment (deconvolution + difference-of-Gaussians).
3
+ # Cilia are thin/small structures a cell-body model like Cellpose isn't
4
+ # shaped for. Paired with config_cyto.yaml / config_nuclei.yaml via
5
+ # config/multi.yaml to relate cilia -> containing cell / nucleus. Same
6
+ # work_dir and tile_shape as those two so patchworks.label_relations() can
7
+ # compare the label arrays chunk-for-chunk.
8
+ #
9
+ # pycudadecon is CUDA-only, so this segment job needs a GPU too — same
10
+ # profile/slurm as the Cellpose configs already covers it.
11
+
12
+ input: "/path/to/scan.ims"
13
+ work_dir: "/path/to/results"
14
+
15
+ reuse_pyramid: false
16
+ convert_chunks: null
17
+ shard: false
18
+
19
+ channel: 2 # cilia marker channel
20
+ level: 0
21
+ tile_shape: [16, 1024, 1024] # keep identical to config_cyto.yaml/config_nuclei.yaml
22
+ gpu_memory_gb: null
23
+ overlap: 30 # cover the PSF support (decon) + the DoG's high_sigma
24
+ skip_empty: true
25
+ empty_threshold: null
26
+
27
+ method: "custom"
28
+ label_name: "cilia_labels"
29
+ custom:
30
+ module: "patchworks.plugins.dog"
31
+ function: "segment"
32
+ kwargs:
33
+ low_sigma: 1.0
34
+ high_sigma: 3.0
35
+ threshold: 0.02
36
+ # use_gpu: true # optional: also run the blur/label step on GPU
37
+ decon_kwargs:
38
+ psf: "/path/to/psf.tif"
39
+ dxpsf: 0.1
40
+ dxdata: 0.1
41
+ dzpsf: 0.2
42
+ dzdata: 0.2
43
+ wavelength: 525
44
+ na: 1.4
45
+ nimm: 1.515
46
+
47
+ pyramid_levels: 5
48
+ pyramid_downscale: 2
49
+ sequential_labels: true
50
+ merge_workers: null
@@ -1,6 +1,8 @@
1
1
  # Drives `pixi run multi` / `scripts/run_multi.py`: runs several segmentation
2
2
  # configs (in order) against the same work_dir, then relates their labels by
3
- # voxel overlap (patchworks.label_relations) and writes a CSV per pair.
3
+ # voxel overlap (patchworks.label_relations) and writes an Excel workbook per
4
+ # pair (two sheets: one row per a-object incl. unmatched ones, one row per
5
+ # b-object incl. ones with zero matches — see docs/guide/snakemake.md).
4
6
  #
5
7
  # All listed segmentation configs must share the same work_dir/image.zarr and
6
8
  # the same tile_shape/level, so the resulting label arrays are directly
@@ -14,8 +16,15 @@
14
16
  segmentations:
15
17
  - config/config_nuclei.yaml
16
18
  - config/config_cyto.yaml
19
+ - config/config_cilia.yaml
17
20
 
18
21
  relations:
19
22
  - a: nuclei_labels
20
23
  b: cyto_labels
21
- output: nuclei_to_cyto.csv
24
+ output: nuclei_to_cyto.xlsx
25
+ - a: cilia_labels
26
+ b: cyto_labels
27
+ output: cilia_to_cell.xlsx
28
+ - a: cilia_labels
29
+ b: nuclei_labels
30
+ output: cilia_to_nucleus.xlsx
@@ -19,8 +19,10 @@ platforms = ["linux-64"]
19
19
  python = "3.12.*"
20
20
 
21
21
  # Default env uses patchworks[cellpose] (>=3.0, resolves to latest compatible).
22
+ # "dog" -> pycudadecon, for config_cilia.yaml's deconvolution step (part of
23
+ # the shipped config/multi.yaml example).
22
24
  [pypi-dependencies]
23
- patchworks = { version = "*", extras = ["workflow", "cellpose", "imaris", "bioio"] }
25
+ patchworks = { version = "*", extras = ["workflow", "cellpose", "imaris", "bioio", "dog"] }
24
26
 
25
27
  # Cellpose-version-pinned features for explicit environments below.
26
28
  [feature.cp3.pypi-dependencies]
@@ -12,13 +12,15 @@ a Snakemake rule — the segmentations already namespace their own paths under
12
12
  work_dir/<label_name>/, so running them one after another here is exactly
13
13
  equivalent to running each snakemake command by hand). Once all segmentations
14
14
  finish, each configured relation pair is computed via
15
- patchworks.label_relations and written as a CSV in work_dir.
15
+ patchworks.label_relations and written as an Excel workbook in work_dir,
16
+ with two sheets: one row per a-object (unmatched ones included, with an
17
+ empty b-id and zeros) and one row per b-object (a-object count + total
18
+ overlap, including b-objects with zero matches).
16
19
  """
17
20
 
18
21
  from __future__ import annotations
19
22
 
20
23
  import argparse
21
- import csv
22
24
  import subprocess
23
25
  import sys
24
26
  from pathlib import Path
@@ -121,31 +123,51 @@ def main() -> None:
121
123
  image_store = f"{work_dir}/image.zarr"
122
124
 
123
125
  import dask.array as da
126
+ import openpyxl
124
127
 
125
128
  from patchworks import label_relations
126
129
 
127
130
  for rel in relations:
128
131
  a_name, b_name = rel["a"], rel["b"]
129
132
  out_path = Path(work_dir) / rel.get(
130
- "output", f"{a_name}_to_{b_name}.csv"
133
+ "output", f"{a_name}_to_{b_name}.xlsx"
131
134
  )
132
135
  print(f"[run_multi] relating {a_name} -> {b_name} …", flush=True)
133
136
  a = da.from_zarr(image_store, component=f"labels/{a_name}/0")
134
137
  b = da.from_zarr(image_store, component=f"labels/{b_name}/0")
135
138
  table = label_relations(a, b)
136
139
 
137
- with open(out_path, "w", newline="") as f:
138
- writer = csv.writer(f)
139
- writer.writerow(
140
- [
141
- f"{a_name}_id",
142
- f"{b_name}_id",
143
- "overlap_voxels",
144
- "overlap_fraction",
145
- ]
146
- )
147
- for a_id, m in table.items():
148
- writer.writerow(
140
+ # label_relations() only returns a-objects that touch a b-object.
141
+ # Pull the full id sets so unmatched a-objects (zero overlap) and
142
+ # b-objects with no matches at all still get a row -- otherwise
143
+ # they'd silently vanish instead of counting as zero.
144
+ a_ids = sorted(int(x) for x in da.unique(a[a > 0]).compute())
145
+ b_ids = sorted(int(x) for x in da.unique(b[b > 0]).compute())
146
+
147
+ per_b = {b_id: {"count": 0, "overlap_voxels": 0} for b_id in b_ids}
148
+ for m in table.values():
149
+ agg = per_b.get(m["match"])
150
+ if agg is not None:
151
+ agg["count"] += 1
152
+ agg["overlap_voxels"] += m["overlap_voxels"]
153
+
154
+ wb = openpyxl.Workbook()
155
+ ws_a = wb.active
156
+ ws_a.title = a_name[:31] # Excel sheet-name length limit
157
+ ws_a.append(
158
+ [
159
+ f"{a_name}_id",
160
+ f"{b_name}_id",
161
+ "overlap_voxels",
162
+ "overlap_fraction",
163
+ ]
164
+ )
165
+ for a_id in a_ids:
166
+ m = table.get(a_id)
167
+ if m is None:
168
+ ws_a.append([a_id, None, 0, 0]) # no overlap -- still counted
169
+ else:
170
+ ws_a.append(
149
171
  [
150
172
  a_id,
151
173
  m["match"],
@@ -153,7 +175,19 @@ def main() -> None:
153
175
  m["overlap_fraction"],
154
176
  ]
155
177
  )
156
- print(f"[run_multi] wrote {out_path} ({len(table)} rows)", flush=True)
178
+
179
+ ws_b = wb.create_sheet(title=b_name[:31])
180
+ ws_b.append([f"{b_name}_id", f"{a_name}_count", "total_overlap_voxels"])
181
+ for b_id in b_ids:
182
+ agg = per_b[b_id]
183
+ ws_b.append([b_id, agg["count"], agg["overlap_voxels"]])
184
+
185
+ wb.save(out_path)
186
+ print(
187
+ f"[run_multi] wrote {out_path} "
188
+ f"({len(a_ids)} {a_name}, {len(b_ids)} {b_name})",
189
+ flush=True,
190
+ )
157
191
 
158
192
 
159
193
  if __name__ == "__main__":
File without changes
File without changes
File without changes
File without changes
File without changes