patchworks 2.6.3__tar.gz → 2.6.5__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.
- {patchworks-2.6.3 → patchworks-2.6.5}/PKG-INFO +1 -1
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/snakemake.md +24 -3
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_run_multi.py +97 -11
- patchworks-2.6.5/workflow/scripts/relate.py +149 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/run_multi.py +186 -105
- {patchworks-2.6.3 → patchworks-2.6.5}/.github/workflows/docs.yml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/.github/workflows/lint.yml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/.github/workflows/release.yml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/.gitignore +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/LICENSE +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/README.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/cliff.toml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/chunks.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/cluster.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/io.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/postprocess.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/relabel.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/api/tile_process.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/assets/logo.png +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/custom.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/custom_method.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/dog.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/dog.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/stardist.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/getting_started.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/label_relations.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/measurements.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/merging.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/performance.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/guide/tiling.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/docs/index.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/mkdocs.yml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/pyproject.toml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/__init__.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_core.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_io.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_merge.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_notify.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_progress.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/_relations.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/dog.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_allocation.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_core.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_distributed.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_dog.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_gpu.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_napari.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_notify.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_occupancy.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_postprocess.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_progress.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/tests/test_relations.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/README.md +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/Snakefile +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/common.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config_cilia.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/config/multi.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/pixi.toml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/common.smk +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/convert.smk +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/merge.smk +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/rules/segment.smk +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/_pw.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/build_occupancy.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/convert.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/merge.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/prepare_tiles.py +0 -0
- {patchworks-2.6.3 → patchworks-2.6.5}/workflow/scripts/segment_tile.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: patchworks
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.5
|
|
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
|
|
@@ -398,12 +398,21 @@ results/image.zarr/labels/nuclei_labels/
|
|
|
398
398
|
results/image.zarr/labels/cyto_labels/
|
|
399
399
|
```
|
|
400
400
|
|
|
401
|
-
!!! tip "Keep `
|
|
402
|
-
Different segmentations of the same image can use different `channel`
|
|
403
|
-
`cellpose:` settings freely, but keep `
|
|
401
|
+
!!! tip "Keep `level` identical across configs; `tile_shape` usually takes care of itself"
|
|
402
|
+
Different segmentations of the same image can use different `channel`
|
|
403
|
+
and `cellpose:` settings freely, but keep `level` the same across
|
|
404
404
|
configs — the label arrays then share the exact same chunk layout, which
|
|
405
405
|
[`label_relations()`](label_relations.md) requires.
|
|
406
406
|
|
|
407
|
+
`tile_shape` is a little more forgiving under `run_multi.py`: leaving it
|
|
408
|
+
on `"auto"` (the default) would normally size a `nuclei_channel` config's
|
|
409
|
+
tile smaller than a single-channel one (the sizer charges per channel),
|
|
410
|
+
breaking that same requirement — `run_multi.py` detects this and pins
|
|
411
|
+
every config to the smallest computed tile automatically, so mixed
|
|
412
|
+
single-/two-channel configs under `"auto"` just work. Setting an
|
|
413
|
+
explicit `tile_shape` yourself still requires it to be identical across
|
|
414
|
+
configs, same as before.
|
|
415
|
+
|
|
407
416
|
See [Relating labels across segmentations](label_relations.md) for what
|
|
408
417
|
`label_relations()` returns and how to save it yourself — the cluster
|
|
409
418
|
workflow's own automation is below.
|
|
@@ -451,6 +460,18 @@ the GPU partition stays busy instead of idling through every config's
|
|
|
451
460
|
`prepare` and multi-hour `merge` in turn. A config that fails does **not**
|
|
452
461
|
abort the others; you get a per-config status and a non-zero exit.
|
|
453
462
|
|
|
463
|
+
!!! tip "The relate step runs on the cluster too, under `multi-slurm`"
|
|
464
|
+
`label_relations()` streams every chunk of two full-resolution label
|
|
465
|
+
volumes — real CPU/IO work, not orchestration. Under `multi-slurm` it is
|
|
466
|
+
submitted as its own `srun` job (`scripts/relate.py`) instead of running
|
|
467
|
+
in the driver process on the login node, the same fix already applied to
|
|
468
|
+
the occupancy map. Tune its allocation with `--relate-partition`,
|
|
469
|
+
`--relate-mem`, `--relate-cpus` and `--relate-time` (defaults: `scicore`,
|
|
470
|
+
`32G`, `8`, `180` minutes) — these are wide-margin guesses, not measured
|
|
471
|
+
numbers, so raise them for a very large or very object-dense pair. Under
|
|
472
|
+
plain `multi` (no `--profile`), it still runs locally, in-process, as
|
|
473
|
+
before.
|
|
474
|
+
|
|
454
475
|
!!! tip "After a killed run"
|
|
455
476
|
Snakemake only releases its lock on a clean exit, so a run that was killed
|
|
456
477
|
(Ctrl-C, an SSH drop, an OOM) leaves the directory locked. Each phase has
|
|
@@ -160,28 +160,114 @@ def test_occupancy_is_not_rebuilt_by_the_driver():
|
|
|
160
160
|
assert "occupancy.zarr" in src
|
|
161
161
|
|
|
162
162
|
|
|
163
|
-
def
|
|
164
|
-
"""
|
|
163
|
+
def test_relate_is_submitted_via_slurm_under_profile():
|
|
164
|
+
"""The relate step must never run in-process on the submit host.
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
Same failure mode as the occupancy map: label_relations() streams every
|
|
167
|
+
chunk of two full-resolution label volumes. A prior fix moved the map
|
|
168
|
+
build off the login node; the relate step made the identical mistake and
|
|
169
|
+
hung there for the same reason until this fix.
|
|
170
|
+
"""
|
|
171
|
+
src = (_workflow_dir() / "scripts" / "run_multi.py").read_text()
|
|
172
|
+
assert "from relate import run_relations" in src
|
|
173
|
+
assert '"srun"' in src
|
|
174
|
+
assert "label_relations(" not in src
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_relate_script_has_the_real_bookkeeping():
|
|
178
|
+
"""relate.py must be the actual implementation, not a stub.
|
|
179
|
+
|
|
180
|
+
Submitting the wrong (or a trimmed-down) script would silently produce a
|
|
181
|
+
workbook missing the unmatched-label rows the docstring promises.
|
|
182
|
+
"""
|
|
183
|
+
src = (_workflow_dir() / "scripts" / "relate.py").read_text()
|
|
184
|
+
assert "def run_relations(" in src
|
|
185
|
+
assert "label_relations" in src
|
|
186
|
+
assert "openpyxl" in src
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_mixed_nuclei_channel_auto_passes_validation():
|
|
190
|
+
"""A channel-count mismatch under `tile_shape: "auto"` is no longer
|
|
191
|
+
|
|
192
|
+
refused at validation time -- it's resolved automatically instead (see
|
|
193
|
+
`_resolve_shared_tile_shape`), which needs the converted image's real
|
|
194
|
+
shape/dtype and so can only run after phase A, not from
|
|
195
|
+
`_validate_configs()`. This used to `sys.exit` here; asserting that
|
|
196
|
+
would now be testing the wrong layer.
|
|
170
197
|
"""
|
|
171
198
|
paths = [Path("a.yaml"), Path("b.yaml")]
|
|
172
199
|
base = {"work_dir": "/w", "tile_shape": "auto", "level": 0}
|
|
173
200
|
|
|
174
|
-
|
|
201
|
+
mixed = [
|
|
175
202
|
{**base, "label_name": "a", "channel": 0, "nuclei_channel": 1},
|
|
176
203
|
{**base, "label_name": "b", "channel": 2},
|
|
177
204
|
]
|
|
178
|
-
|
|
179
|
-
_validate_configs(paths, bad)
|
|
205
|
+
assert _validate_configs(paths, mixed) == "/w"
|
|
180
206
|
|
|
181
207
|
# Same pair with one explicit shape is fine: both get that tile.
|
|
182
|
-
pinned = [{**c, "tile_shape": [16, 512, 512]} for c in
|
|
208
|
+
pinned = [{**c, "tile_shape": [16, 512, 512]} for c in mixed]
|
|
183
209
|
assert _validate_configs(paths, pinned) == "/w"
|
|
184
210
|
|
|
185
211
|
# And "auto" is fine when every config carries the same channel count.
|
|
186
|
-
both = [{**
|
|
212
|
+
both = [{**mixed[0]}, {**mixed[1], "nuclei_channel": 3}]
|
|
187
213
|
assert _validate_configs(paths, both) == "/w"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_resolve_shared_tile_shape_pins_the_smallest_candidate(
|
|
217
|
+
tmp_path, monkeypatch
|
|
218
|
+
):
|
|
219
|
+
"""The shared tile must be the tightest of every config's own budget.
|
|
220
|
+
|
|
221
|
+
A larger tile than some config's own sizer output would ask that config
|
|
222
|
+
for more memory than its settings were judged to need -- only the
|
|
223
|
+
smallest candidate is safe for every config at once.
|
|
224
|
+
"""
|
|
225
|
+
import run_multi
|
|
226
|
+
|
|
227
|
+
class _FakeImage:
|
|
228
|
+
shape = (10, 100, 100)
|
|
229
|
+
dtype = "uint16"
|
|
230
|
+
|
|
231
|
+
calls = []
|
|
232
|
+
|
|
233
|
+
def _fake_load_ome_zarr(store, *, channel, level):
|
|
234
|
+
calls.append((store, channel, level))
|
|
235
|
+
return _FakeImage()
|
|
236
|
+
|
|
237
|
+
# One tile per config, matched up by call order (channel 0 then 1).
|
|
238
|
+
fake_tiles = [(8, 64, 64), (4, 32, 32)]
|
|
239
|
+
|
|
240
|
+
def _fake_sizer_cellpose(shape, dtype, **kwargs):
|
|
241
|
+
return fake_tiles[len(calls) - 1]
|
|
242
|
+
|
|
243
|
+
monkeypatch.setattr(
|
|
244
|
+
"patchworks.load_ome_zarr", _fake_load_ome_zarr, raising=False
|
|
245
|
+
)
|
|
246
|
+
monkeypatch.setattr(
|
|
247
|
+
"patchworks.auto_tile_shape_cellpose",
|
|
248
|
+
_fake_sizer_cellpose,
|
|
249
|
+
raising=False,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
seg_cfgs = [
|
|
253
|
+
{
|
|
254
|
+
"channel": 0,
|
|
255
|
+
"level": 0,
|
|
256
|
+
"method": "cellpose",
|
|
257
|
+
"cellpose": {"do_3D": True, "gpu": True},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"channel": 1,
|
|
261
|
+
"level": 0,
|
|
262
|
+
"nuclei_channel": 2,
|
|
263
|
+
"method": "cellpose",
|
|
264
|
+
"cellpose": {"do_3D": True, "gpu": True},
|
|
265
|
+
},
|
|
266
|
+
]
|
|
267
|
+
out = run_multi._resolve_shared_tile_shape(
|
|
268
|
+
seg_cfgs, "/w/image.zarr", str(tmp_path)
|
|
269
|
+
)
|
|
270
|
+
assert out == tmp_path / ".multi_tile_shape.generated.yaml"
|
|
271
|
+
written = yaml.safe_load(out.read_text())
|
|
272
|
+
assert written == {"tile_shape": [4, 32, 32]} # the smaller candidate
|
|
273
|
+
assert len(calls) == 2
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Compute label_relations for configured pairs and write .xlsx.
|
|
2
|
+
|
|
3
|
+
Split out of run_multi.py so this step can be submitted as its own SLURM job
|
|
4
|
+
instead of running in-process on the login node. It streams every chunk of
|
|
5
|
+
two full-resolution label volumes -- real CPU/IO work, not orchestration --
|
|
6
|
+
same reasoning as the occupancy-map fix (see run_multi.py's phase A comment).
|
|
7
|
+
|
|
8
|
+
Usage (called by run_multi.py under --profile, but also runnable standalone,
|
|
9
|
+
e.g. under srun):
|
|
10
|
+
python scripts/relate.py --work-dir /path/to/work_dir \
|
|
11
|
+
--image-store /path/to/work_dir/image.zarr \
|
|
12
|
+
--relations '[{"a": "nuclei_labels", "b": "cyto_labels", "output": "nuclei_to_cyto.xlsx"}]'
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _label_ids(image_store: str, name: str) -> list[int]:
|
|
23
|
+
"""Ids present in a label image, without scanning the volume.
|
|
24
|
+
|
|
25
|
+
The merge writes n_objects/sequential_labels into the label group's
|
|
26
|
+
attrs precisely so consumers don't have to re-derive the id set; the ids
|
|
27
|
+
are 1..n_objects by construction. Fall back to a full scan only for a
|
|
28
|
+
label group written before those attrs existed.
|
|
29
|
+
"""
|
|
30
|
+
import dask.array as da
|
|
31
|
+
import zarr
|
|
32
|
+
|
|
33
|
+
attrs = dict(zarr.open_group(f"{image_store}/labels/{name}").attrs)
|
|
34
|
+
if attrs.get("sequential_labels") and attrs.get("n_objects") is not None:
|
|
35
|
+
return list(range(1, int(attrs["n_objects"]) + 1))
|
|
36
|
+
print(
|
|
37
|
+
f"[relate] {name}: no n_objects attr, falling back to a full scan "
|
|
38
|
+
"for its id set",
|
|
39
|
+
flush=True,
|
|
40
|
+
)
|
|
41
|
+
arr = da.from_zarr(image_store, component=f"labels/{name}/0")
|
|
42
|
+
return sorted(int(x) for x in da.unique(arr[arr > 0]).compute())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_relations(
|
|
46
|
+
work_dir: str, image_store: str, relations: list[dict]
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Compute and write every configured relation pair as an .xlsx workbook.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
work_dir : str
|
|
53
|
+
Directory relation workbooks are written into (a relation's
|
|
54
|
+
``output``, when relative, resolves against this).
|
|
55
|
+
image_store : str
|
|
56
|
+
The shared ``image.zarr`` holding every config's ``labels/<name>``.
|
|
57
|
+
relations : list of dict
|
|
58
|
+
Each ``{"a": ..., "b": ..., "output": ...}`` (``output`` optional,
|
|
59
|
+
defaults to ``<a>_to_<b>.xlsx``), matching ``multi.yaml``'s
|
|
60
|
+
``relations:`` list.
|
|
61
|
+
"""
|
|
62
|
+
import dask.array as da
|
|
63
|
+
import openpyxl
|
|
64
|
+
|
|
65
|
+
from patchworks import label_relations
|
|
66
|
+
|
|
67
|
+
for rel in relations:
|
|
68
|
+
a_name, b_name = rel["a"], rel["b"]
|
|
69
|
+
out_path = Path(work_dir) / rel.get(
|
|
70
|
+
"output", f"{a_name}_to_{b_name}.xlsx"
|
|
71
|
+
)
|
|
72
|
+
print(f"[relate] relating {a_name} -> {b_name} …", flush=True)
|
|
73
|
+
a = da.from_zarr(image_store, component=f"labels/{a_name}/0")
|
|
74
|
+
b = da.from_zarr(image_store, component=f"labels/{b_name}/0")
|
|
75
|
+
table = label_relations(a, b)
|
|
76
|
+
|
|
77
|
+
# label_relations() only returns a-objects that touch a b-object.
|
|
78
|
+
# Pull the full id sets so unmatched a-objects (zero overlap) and
|
|
79
|
+
# b-objects with no matches at all still get a row -- otherwise
|
|
80
|
+
# they'd silently vanish instead of counting as zero.
|
|
81
|
+
a_ids = _label_ids(image_store, a_name)
|
|
82
|
+
b_ids = _label_ids(image_store, b_name)
|
|
83
|
+
|
|
84
|
+
per_b = {b_id: {"count": 0, "overlap_voxels": 0} for b_id in b_ids}
|
|
85
|
+
for m in table.values():
|
|
86
|
+
agg = per_b.get(m["match"])
|
|
87
|
+
if agg is not None:
|
|
88
|
+
agg["count"] += 1
|
|
89
|
+
agg["overlap_voxels"] += m["overlap_voxels"]
|
|
90
|
+
|
|
91
|
+
wb = openpyxl.Workbook()
|
|
92
|
+
ws_a = wb.active
|
|
93
|
+
ws_a.title = a_name[:31] # Excel sheet-name length limit
|
|
94
|
+
ws_a.append(
|
|
95
|
+
[
|
|
96
|
+
f"{a_name}_id",
|
|
97
|
+
f"{b_name}_id",
|
|
98
|
+
"overlap_voxels",
|
|
99
|
+
"overlap_fraction",
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
for a_id in a_ids:
|
|
103
|
+
m = table.get(a_id)
|
|
104
|
+
if m is None:
|
|
105
|
+
ws_a.append([a_id, None, 0, 0]) # no overlap -- still counted
|
|
106
|
+
else:
|
|
107
|
+
ws_a.append(
|
|
108
|
+
[
|
|
109
|
+
a_id,
|
|
110
|
+
m["match"],
|
|
111
|
+
m["overlap_voxels"],
|
|
112
|
+
m["overlap_fraction"],
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
ws_b = wb.create_sheet(title=b_name[:31])
|
|
117
|
+
ws_b.append(
|
|
118
|
+
[f"{b_name}_id", f"{a_name}_count", "total_overlap_voxels"]
|
|
119
|
+
)
|
|
120
|
+
for b_id in b_ids:
|
|
121
|
+
agg = per_b[b_id]
|
|
122
|
+
ws_b.append([b_id, agg["count"], agg["overlap_voxels"]])
|
|
123
|
+
|
|
124
|
+
wb.save(out_path)
|
|
125
|
+
print(
|
|
126
|
+
f"[relate] wrote {out_path} "
|
|
127
|
+
f"({len(a_ids)} {a_name}, {len(b_ids)} {b_name})",
|
|
128
|
+
flush=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main() -> None:
|
|
133
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
134
|
+
parser.add_argument("--work-dir", required=True)
|
|
135
|
+
parser.add_argument("--image-store", required=True)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--relations",
|
|
138
|
+
required=True,
|
|
139
|
+
help=(
|
|
140
|
+
"JSON list of {a, b, output} dicts, matching multi.yaml's "
|
|
141
|
+
"relations:"
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
args = parser.parse_args()
|
|
145
|
+
run_relations(args.work_dir, args.image_store, json.loads(args.relations))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -20,12 +20,16 @@ Once all segmentations succeed, each configured relation pair is computed via
|
|
|
20
20
|
patchworks.label_relations and written as an Excel workbook in work_dir,
|
|
21
21
|
with two sheets: one row per a-object (unmatched ones included, with an
|
|
22
22
|
empty b-id and zeros) and one row per b-object (a-object count + total
|
|
23
|
-
overlap, including b-objects with zero matches).
|
|
23
|
+
overlap, including b-objects with zero matches). Under --profile, this runs
|
|
24
|
+
as a submitted SLURM job (see scripts/relate.py) rather than in-process here
|
|
25
|
+
-- same reasoning as the occupancy-map fix: it streams entire label volumes,
|
|
26
|
+
which is real work, not orchestration, and does not belong on the login node.
|
|
24
27
|
"""
|
|
25
28
|
|
|
26
29
|
from __future__ import annotations
|
|
27
30
|
|
|
28
31
|
import argparse
|
|
32
|
+
import json
|
|
29
33
|
import re
|
|
30
34
|
import subprocess
|
|
31
35
|
import sys
|
|
@@ -50,6 +54,7 @@ def _snakemake_cmd(
|
|
|
50
54
|
extra: list[str] | None = None,
|
|
51
55
|
jobname_prefix: str | None = None,
|
|
52
56
|
common: Path | None = None,
|
|
57
|
+
extra_configfiles: list[Path] | None = None,
|
|
53
58
|
) -> list[str]:
|
|
54
59
|
"""Build one snakemake invocation.
|
|
55
60
|
|
|
@@ -62,10 +67,17 @@ def _snakemake_cmd(
|
|
|
62
67
|
settings every config shares -- the input, the work_dir, everything
|
|
63
68
|
``convert`` reads -- live in one file and the per-config file carries only
|
|
64
69
|
what actually differs.
|
|
70
|
+
|
|
71
|
+
*extra_configfiles*, when given, are appended after *configfile* and so
|
|
72
|
+
win over both it and *common* -- used to pin a driver-computed value
|
|
73
|
+
(e.g. a resolved ``tile_shape``) across every config without editing any
|
|
74
|
+
config file on disk.
|
|
65
75
|
"""
|
|
66
76
|
configfiles = [str(configfile.resolve())]
|
|
67
77
|
if common is not None:
|
|
68
78
|
configfiles.insert(0, str(common.resolve()))
|
|
79
|
+
if extra_configfiles:
|
|
80
|
+
configfiles += [str(p.resolve()) for p in extra_configfiles]
|
|
69
81
|
cmd = [
|
|
70
82
|
"snakemake",
|
|
71
83
|
"-s",
|
|
@@ -230,21 +242,12 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
|
|
|
230
242
|
|
|
231
243
|
# `tile_shape: "auto"` is identical as a *value* across configs while
|
|
232
244
|
# producing different tiles: the sizer charges per channel, so a config
|
|
233
|
-
# with nuclei_channel gets a smaller one. The label groups then
|
|
234
|
-
# chunk layout and label_relations
|
|
235
|
-
# run.
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
problems.append(
|
|
240
|
-
'tile_shape: "auto" sizes a nuclei_channel config smaller '
|
|
241
|
-
"(a tile carries two channels), so the label groups would "
|
|
242
|
-
"not share a chunk layout and label_relations would fail "
|
|
243
|
-
f"after every segmentation had run; got "
|
|
244
|
-
f"{_spread('nuclei_channel')}. Set one explicit tile_shape in "
|
|
245
|
-
"the file `common:` points at, sized for the two-channel "
|
|
246
|
-
"config."
|
|
247
|
-
)
|
|
245
|
+
# with nuclei_channel gets a smaller one. The label groups would then
|
|
246
|
+
# disagree on chunk layout and label_relations would raise -- after every
|
|
247
|
+
# segmentation had run. That used to be a hard error asking for a manual
|
|
248
|
+
# explicit tile_shape; main() now resolves and pins one automatically
|
|
249
|
+
# (see _resolve_shared_tile_shape), once the converted image exists to
|
|
250
|
+
# size against, so there is nothing to check here anymore.
|
|
248
251
|
|
|
249
252
|
# Phase A converts once, from the first config. Anything `convert` reads
|
|
250
253
|
# out of a later config is therefore silently ignored -- someone setting
|
|
@@ -288,6 +291,92 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
|
|
|
288
291
|
return work_dirs.pop()
|
|
289
292
|
|
|
290
293
|
|
|
294
|
+
def _resolve_shared_tile_shape(
|
|
295
|
+
seg_cfgs: list[dict], image_store: str, work_dir: str
|
|
296
|
+
) -> Path:
|
|
297
|
+
"""Auto-size ``tile_shape`` once, shared across every config.
|
|
298
|
+
|
|
299
|
+
``tile_shape: "auto"`` resolves differently per config when
|
|
300
|
+
``nuclei_channel`` differs -- the sizer charges per channel, so a
|
|
301
|
+
two-channel config gets a smaller tile. Left alone, the label groups
|
|
302
|
+
would end up with different chunk layouts and ``label_relations`` would
|
|
303
|
+
raise, after every segmentation had already run.
|
|
304
|
+
|
|
305
|
+
Computes what each config's own settings (channel count, ``do_3D``,
|
|
306
|
+
``diameter``, GPU budget) would actually produce, then pins every config
|
|
307
|
+
to the *smallest* of them by voxel count -- the most memory-constrained
|
|
308
|
+
case, and safe for every config since a smaller tile only ever asks for
|
|
309
|
+
less memory than that config's own budget allows, never more. Configs
|
|
310
|
+
can therefore use different ``do_3D``/``diameter``/channel-count
|
|
311
|
+
settings and still end up with one shared, valid ``tile_shape``.
|
|
312
|
+
|
|
313
|
+
Only called once the converted image exists (the sizer needs its real
|
|
314
|
+
shape/dtype), so this runs from ``main()`` after phase A, not from
|
|
315
|
+
``_validate_configs()``.
|
|
316
|
+
|
|
317
|
+
Returns
|
|
318
|
+
-------
|
|
319
|
+
Path
|
|
320
|
+
A generated one-key YAML file (``tile_shape: [...]``), meant to be
|
|
321
|
+
passed as an ``extra_configfiles`` entry to ``_snakemake_cmd`` so it
|
|
322
|
+
overrides every config's own (or common's) ``tile_shape`` value.
|
|
323
|
+
"""
|
|
324
|
+
from functools import partial
|
|
325
|
+
|
|
326
|
+
import numpy as np
|
|
327
|
+
from patchworks import (
|
|
328
|
+
auto_tile_shape,
|
|
329
|
+
auto_tile_shape_cellpose,
|
|
330
|
+
load_ome_zarr,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
candidates = []
|
|
334
|
+
for cfg in seg_cfgs:
|
|
335
|
+
image = load_ome_zarr(
|
|
336
|
+
image_store, channel=cfg["channel"], level=int(cfg.get("level", 0))
|
|
337
|
+
)
|
|
338
|
+
gpu_gb = cfg.get("gpu_memory_gb")
|
|
339
|
+
gpu_bytes = int(gpu_gb * 1024**3) if gpu_gb else None
|
|
340
|
+
n_channels = 2 if cfg.get("nuclei_channel") is not None else 1
|
|
341
|
+
method = cfg.get("method", "cellpose")
|
|
342
|
+
if method == "cellpose":
|
|
343
|
+
cp = cfg["cellpose"]
|
|
344
|
+
sizer = partial(
|
|
345
|
+
auto_tile_shape_cellpose,
|
|
346
|
+
do_3D=cp.get("do_3D", False),
|
|
347
|
+
use_gpu=cp.get("gpu", True),
|
|
348
|
+
diameter=cp.get("diameter"),
|
|
349
|
+
gpu_memory=gpu_bytes,
|
|
350
|
+
n_channels=n_channels,
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
sizer = partial(
|
|
354
|
+
auto_tile_shape,
|
|
355
|
+
use_gpu=gpu_bytes is not None,
|
|
356
|
+
gpu_memory=gpu_bytes,
|
|
357
|
+
n_channels=n_channels,
|
|
358
|
+
)
|
|
359
|
+
candidates.append(tuple(int(x) for x in sizer(image.shape, image.dtype)))
|
|
360
|
+
|
|
361
|
+
tile_shape = min(candidates, key=lambda t: int(np.prod(t)))
|
|
362
|
+
print(
|
|
363
|
+
f'[run_multi] tile_shape: "auto" resolves differently across '
|
|
364
|
+
f"configs (nuclei_channel differs); pinning every config to the "
|
|
365
|
+
f"smallest computed tile {list(tile_shape)} so the label arrays "
|
|
366
|
+
f"share a chunk layout. Candidates were {[list(c) for c in candidates]}.",
|
|
367
|
+
flush=True,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
override_path = Path(work_dir) / ".multi_tile_shape.generated.yaml"
|
|
371
|
+
override_path.write_text(
|
|
372
|
+
"# Generated by run_multi.py -- pins tile_shape across configs so\n"
|
|
373
|
+
"# label_relations sees matching chunk layouts. Safe to delete; it\n"
|
|
374
|
+
"# is regenerated on the next multi-config run.\n"
|
|
375
|
+
f"tile_shape: {list(tile_shape)}\n"
|
|
376
|
+
)
|
|
377
|
+
return override_path
|
|
378
|
+
|
|
379
|
+
|
|
291
380
|
def _resolve(workflow_dir: Path, path_str: str) -> Path:
|
|
292
381
|
path = Path(path_str)
|
|
293
382
|
return path if path.is_absolute() else workflow_dir / path
|
|
@@ -337,6 +426,28 @@ def main() -> None:
|
|
|
337
426
|
"otherwise look identical: no email either way."
|
|
338
427
|
),
|
|
339
428
|
)
|
|
429
|
+
parser.add_argument(
|
|
430
|
+
"--relate-partition",
|
|
431
|
+
default="scicore",
|
|
432
|
+
help="SLURM partition for the relate step under --profile (default: scicore)",
|
|
433
|
+
)
|
|
434
|
+
parser.add_argument(
|
|
435
|
+
"--relate-mem",
|
|
436
|
+
default="32G",
|
|
437
|
+
help="srun --mem for the relate step under --profile (default: 32G)",
|
|
438
|
+
)
|
|
439
|
+
parser.add_argument(
|
|
440
|
+
"--relate-cpus",
|
|
441
|
+
type=int,
|
|
442
|
+
default=8,
|
|
443
|
+
help="srun --cpus-per-task for the relate step under --profile (default: 8)",
|
|
444
|
+
)
|
|
445
|
+
parser.add_argument(
|
|
446
|
+
"--relate-time",
|
|
447
|
+
type=int,
|
|
448
|
+
default=180,
|
|
449
|
+
help="srun --time in minutes for the relate step under --profile (default: 180)",
|
|
450
|
+
)
|
|
340
451
|
args = parser.parse_args()
|
|
341
452
|
|
|
342
453
|
workflow_dir = Path(__file__).resolve().parent.parent
|
|
@@ -427,6 +538,24 @@ def main() -> None:
|
|
|
427
538
|
)
|
|
428
539
|
sys.exit(rc)
|
|
429
540
|
|
|
541
|
+
# tile_shape: "auto" resolves differently per config when nuclei_channel
|
|
542
|
+
# differs (the sizer charges per channel) -- pin every config to one
|
|
543
|
+
# shared, computed value so the label arrays end up with matching chunk
|
|
544
|
+
# layouts. Needs the just-converted image, so this can only happen here,
|
|
545
|
+
# not in _validate_configs(). Dry runs never reach a real image.zarr.
|
|
546
|
+
tile_override = None
|
|
547
|
+
if not args.dry_run and {
|
|
548
|
+
cfg.get("tile_shape", "auto") for cfg in seg_cfgs
|
|
549
|
+
} == {"auto"}:
|
|
550
|
+
channel_counts = {
|
|
551
|
+
2 if cfg.get("nuclei_channel") is not None else 1
|
|
552
|
+
for cfg in seg_cfgs
|
|
553
|
+
}
|
|
554
|
+
if len(channel_counts) > 1:
|
|
555
|
+
tile_override = _resolve_shared_tile_shape(
|
|
556
|
+
seg_cfgs, image_store, work_dir
|
|
557
|
+
)
|
|
558
|
+
|
|
430
559
|
# Phase B: the segmentations touch disjoint files under
|
|
431
560
|
# work_dir/<label_name>/, so run them together and let the GPU partition
|
|
432
561
|
# stay busy instead of idling through each config's prepare and merge.
|
|
@@ -444,6 +573,7 @@ def main() -> None:
|
|
|
444
573
|
# Names the config in squeue, so concurrent runs are tellable apart.
|
|
445
574
|
jobname_prefix=slurm_jobname_prefix(cfg["label_name"]),
|
|
446
575
|
common=common_path,
|
|
576
|
+
extra_configfiles=[tile_override] if tile_override else None,
|
|
447
577
|
)
|
|
448
578
|
print(f"[run_multi] $ {' '.join(cmd)}", flush=True)
|
|
449
579
|
procs.append((cfg_path.name, subprocess.Popen(cmd, cwd=workflow_dir)))
|
|
@@ -467,96 +597,47 @@ def main() -> None:
|
|
|
467
597
|
if args.dry_run or not relations:
|
|
468
598
|
return
|
|
469
599
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
"
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
600
|
+
if args.profile:
|
|
601
|
+
# Real CPU/IO work -- tens of thousands of zarr chunk reads for a
|
|
602
|
+
# full-resolution label volume -- not orchestration, so (like the
|
|
603
|
+
# occupancy map) it does not belong in this driver process on the
|
|
604
|
+
# login node. Submit it as its own job instead.
|
|
605
|
+
cmd = [
|
|
606
|
+
"srun",
|
|
607
|
+
"--partition",
|
|
608
|
+
args.relate_partition,
|
|
609
|
+
"--mem",
|
|
610
|
+
args.relate_mem,
|
|
611
|
+
"--cpus-per-task",
|
|
612
|
+
str(args.relate_cpus),
|
|
613
|
+
"--time",
|
|
614
|
+
str(args.relate_time),
|
|
615
|
+
"--job-name",
|
|
616
|
+
"pw-relate",
|
|
617
|
+
sys.executable,
|
|
618
|
+
str(workflow_dir / "scripts" / "relate.py"),
|
|
619
|
+
"--work-dir",
|
|
620
|
+
work_dir,
|
|
621
|
+
"--image-store",
|
|
622
|
+
image_store,
|
|
623
|
+
"--relations",
|
|
624
|
+
json.dumps(relations),
|
|
625
|
+
]
|
|
626
|
+
rc = _run(cmd, workflow_dir)
|
|
627
|
+
if rc != 0:
|
|
628
|
+
print(
|
|
629
|
+
f"[run_multi] ERROR: relate step failed (exit {rc}). "
|
|
630
|
+
"Segmentations already succeeded -- only the relation "
|
|
631
|
+
"workbook(s) are missing. Re-run with the same --config to "
|
|
632
|
+
"retry just this step.",
|
|
633
|
+
file=sys.stderr,
|
|
634
|
+
)
|
|
635
|
+
sys.exit(rc)
|
|
636
|
+
return
|
|
498
637
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
"output", f"{a_name}_to_{b_name}.xlsx"
|
|
503
|
-
)
|
|
504
|
-
print(f"[run_multi] relating {a_name} -> {b_name} …", flush=True)
|
|
505
|
-
a = da.from_zarr(image_store, component=f"labels/{a_name}/0")
|
|
506
|
-
b = da.from_zarr(image_store, component=f"labels/{b_name}/0")
|
|
507
|
-
table = label_relations(a, b)
|
|
508
|
-
|
|
509
|
-
# label_relations() only returns a-objects that touch a b-object.
|
|
510
|
-
# Pull the full id sets so unmatched a-objects (zero overlap) and
|
|
511
|
-
# b-objects with no matches at all still get a row -- otherwise
|
|
512
|
-
# they'd silently vanish instead of counting as zero.
|
|
513
|
-
a_ids = _label_ids(a_name)
|
|
514
|
-
b_ids = _label_ids(b_name)
|
|
515
|
-
|
|
516
|
-
per_b = {b_id: {"count": 0, "overlap_voxels": 0} for b_id in b_ids}
|
|
517
|
-
for m in table.values():
|
|
518
|
-
agg = per_b.get(m["match"])
|
|
519
|
-
if agg is not None:
|
|
520
|
-
agg["count"] += 1
|
|
521
|
-
agg["overlap_voxels"] += m["overlap_voxels"]
|
|
522
|
-
|
|
523
|
-
wb = openpyxl.Workbook()
|
|
524
|
-
ws_a = wb.active
|
|
525
|
-
ws_a.title = a_name[:31] # Excel sheet-name length limit
|
|
526
|
-
ws_a.append(
|
|
527
|
-
[
|
|
528
|
-
f"{a_name}_id",
|
|
529
|
-
f"{b_name}_id",
|
|
530
|
-
"overlap_voxels",
|
|
531
|
-
"overlap_fraction",
|
|
532
|
-
]
|
|
533
|
-
)
|
|
534
|
-
for a_id in a_ids:
|
|
535
|
-
m = table.get(a_id)
|
|
536
|
-
if m is None:
|
|
537
|
-
ws_a.append([a_id, None, 0, 0]) # no overlap -- still counted
|
|
538
|
-
else:
|
|
539
|
-
ws_a.append(
|
|
540
|
-
[
|
|
541
|
-
a_id,
|
|
542
|
-
m["match"],
|
|
543
|
-
m["overlap_voxels"],
|
|
544
|
-
m["overlap_fraction"],
|
|
545
|
-
]
|
|
546
|
-
)
|
|
547
|
-
|
|
548
|
-
ws_b = wb.create_sheet(title=b_name[:31])
|
|
549
|
-
ws_b.append([f"{b_name}_id", f"{a_name}_count", "total_overlap_voxels"])
|
|
550
|
-
for b_id in b_ids:
|
|
551
|
-
agg = per_b[b_id]
|
|
552
|
-
ws_b.append([b_id, agg["count"], agg["overlap_voxels"]])
|
|
553
|
-
|
|
554
|
-
wb.save(out_path)
|
|
555
|
-
print(
|
|
556
|
-
f"[run_multi] wrote {out_path} "
|
|
557
|
-
f"({len(a_ids)} {a_name}, {len(b_ids)} {b_name})",
|
|
558
|
-
flush=True,
|
|
559
|
-
)
|
|
638
|
+
from relate import run_relations
|
|
639
|
+
|
|
640
|
+
run_relations(work_dir, image_store, relations)
|
|
560
641
|
|
|
561
642
|
|
|
562
643
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|