patchworks 2.1.0__tar.gz → 2.1.2__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.1.0 → patchworks-2.1.2}/PKG-INFO +1 -1
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/snakemake.md +15 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/ome_zarr.py +211 -23
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_ome_zarr.py +177 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/run_multi.py +57 -1
- {patchworks-2.1.0 → patchworks-2.1.2}/.github/workflows/docs.yml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/.github/workflows/lint.yml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/.github/workflows/release.yml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/.gitignore +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/LICENSE +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/README.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/cliff.toml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/chunks.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/cluster.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/io.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/postprocess.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/relabel.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/tile_process.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/assets/logo.png +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/custom.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/custom_method.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/dog.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/dog.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/stardist.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/getting_started.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/label_relations.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/measurements.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/merging.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/performance.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/tiling.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/docs/index.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/mkdocs.yml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/pyproject.toml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/__init__.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_core.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_io.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_merge.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_relations.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/dog.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_allocation.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_core.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_distributed.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_dog.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_gpu.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_napari.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_occupancy.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_postprocess.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_relations.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/README.md +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/Snakefile +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config.yaml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config_cilia.yaml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/multi.yaml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/pixi.toml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/common.smk +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/convert.smk +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/merge.smk +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/segment.smk +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/_pw.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/convert.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/merge.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/prepare_tiles.py +0 -0
- {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/segment_tile.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchworks
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.2
|
|
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
|
|
@@ -346,6 +346,19 @@ the GPU partition stays busy instead of idling through every config's
|
|
|
346
346
|
`prepare` and multi-hour `merge` in turn. A config that fails does **not**
|
|
347
347
|
abort the others; you get a per-config status and a non-zero exit.
|
|
348
348
|
|
|
349
|
+
!!! tip "After a killed run"
|
|
350
|
+
Snakemake only releases its lock on a clean exit, so a run that was killed
|
|
351
|
+
(Ctrl-C, an SSH drop, an OOM) leaves the directory locked. Each phase has
|
|
352
|
+
its own state directory, so releasing them by hand means reconstructing
|
|
353
|
+
several paths — use the flag instead:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
pixi run multi -- --unlock # then re-run normally
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Running the orchestrator under `tmux` avoids most of these in the first
|
|
360
|
+
place: it survives a dropped connection.
|
|
361
|
+
|
|
349
362
|
!!! warning "`jobs:` is per config"
|
|
350
363
|
The profile's `jobs:` caps one Snakemake process. Running three configs
|
|
351
364
|
concurrently can therefore have 3× that many jobs in flight — lower it if
|
|
@@ -450,6 +463,8 @@ prologue. The simplest path is a single shared env that the compute nodes see.
|
|
|
450
463
|
| Symptom | Fix |
|
|
451
464
|
|---------|-----|
|
|
452
465
|
| `snakemake: command not found` | use `python -m snakemake` |
|
|
466
|
+
| `Directory cannot be locked` | a previous run was killed instead of exiting cleanly. For a multi-run: `pixi run multi -- --unlock` (it covers every state directory, including the conversion one). For a single config: add `--unlock --directory <the same one you ran with>` |
|
|
467
|
+
| `BioImage does not support the image: '.../*tif'` | a glob input needs `sequence_pattern` — see [A folder of single-plane TIFFs](ome_zarr_napari.md#a-folder-of-single-plane-tiffs) |
|
|
453
468
|
| Segment jobs pend forever | wrong `slurm_partition`/GPU request; on scicore use `gres: "gpu:1"` |
|
|
454
469
|
| Segment dies, `Network is unreachable` | offline GPU nodes — the `fetch_model` localrule caches the model on the submit host first; if it still fails, your submit host has no network either (pre-download manually) |
|
|
455
470
|
| `cellpose is not installed` in a job | the job's env lacks `patchworks[cellpose]` |
|
|
@@ -45,9 +45,11 @@ Usage
|
|
|
45
45
|
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
|
+
import glob
|
|
48
49
|
import logging
|
|
49
50
|
import math
|
|
50
51
|
from concurrent.futures import ThreadPoolExecutor
|
|
52
|
+
from contextlib import nullcontext as _nullcontext
|
|
51
53
|
from itertools import product as _iproduct
|
|
52
54
|
from pathlib import Path
|
|
53
55
|
from typing import Union
|
|
@@ -170,22 +172,41 @@ def _strides(axes: str, downscale: int) -> tuple[int, ...]:
|
|
|
170
172
|
return tuple(downscale if a in _DOWNSAMPLE_AXES else 1 for a in axes)
|
|
171
173
|
|
|
172
174
|
|
|
173
|
-
def _default_chunks(
|
|
175
|
+
def _default_chunks(
|
|
176
|
+
shape: tuple[int, ...],
|
|
177
|
+
axes: str,
|
|
178
|
+
source_chunks: Union[tuple[int, ...], None] = None,
|
|
179
|
+
) -> tuple[int, ...]:
|
|
174
180
|
"""Bounded chunk shape so writing a level never blows up RAM.
|
|
175
181
|
|
|
182
|
+
With *source_chunks*, the result never spans more than one source chunk on
|
|
183
|
+
any axis. That matters when the source's own granularity is coarse: a
|
|
184
|
+
folder of stitched TIFFs gives one chunk per **file**, so a plane can be
|
|
185
|
+
gigabytes. Grouping 16 of those into one output chunk (the ``z`` cap) means
|
|
186
|
+
holding 16 whole planes to write a 32 MB chunk -- tens of GB for the
|
|
187
|
+
smallest unit of work, which is how conversion gets OOM-killed.
|
|
188
|
+
|
|
189
|
+
Splitting a source chunk is fine (that is a slice of something already
|
|
190
|
+
read); combining several is not.
|
|
191
|
+
|
|
176
192
|
Parameters
|
|
177
193
|
----------
|
|
178
194
|
shape : tuple of int
|
|
179
195
|
Array shape.
|
|
180
196
|
axes : str
|
|
181
197
|
One letter per axis (selects the per-axis cap).
|
|
198
|
+
source_chunks : tuple of int, optional
|
|
199
|
+
The input array's own chunking.
|
|
182
200
|
|
|
183
201
|
Returns
|
|
184
202
|
-------
|
|
185
203
|
tuple of int
|
|
186
|
-
Per-axis chunk size, capped by ``_CHUNK_CAP
|
|
204
|
+
Per-axis chunk size, capped by ``_CHUNK_CAP`` and by *source_chunks*.
|
|
187
205
|
"""
|
|
188
|
-
|
|
206
|
+
caps = [min(s, _CHUNK_CAP.get(a, s)) for s, a in zip(shape, axes)]
|
|
207
|
+
if source_chunks is not None:
|
|
208
|
+
caps = [min(c, max(1, int(sc))) for c, sc in zip(caps, source_chunks)]
|
|
209
|
+
return tuple(caps)
|
|
189
210
|
|
|
190
211
|
|
|
191
212
|
ShardSpec = Union[bool, tuple[int, ...]]
|
|
@@ -445,6 +466,70 @@ def _stream_strided_level(
|
|
|
445
466
|
pass
|
|
446
467
|
|
|
447
468
|
|
|
469
|
+
# One chunk is one file without sharding. A shared cluster filesystem starts
|
|
470
|
+
# to hurt well before a million of them, and the cost lands on every later
|
|
471
|
+
# read too, not just the write.
|
|
472
|
+
_CHUNK_COUNT_WARN = 200_000
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _warn_if_many_chunks(
|
|
476
|
+
shape: tuple[int, ...], chunks: tuple[int, ...], shard: ShardSpec
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Warn when a level will be written as a very large number of files.
|
|
479
|
+
|
|
480
|
+
A coarse source forces fine output chunks (see :func:`_default_chunks`),
|
|
481
|
+
and fine chunks mean many files. Sharding packs them into far fewer
|
|
482
|
+
without changing the chunking, so the fix is cheap -- but only if someone
|
|
483
|
+
notices before the store is written.
|
|
484
|
+
"""
|
|
485
|
+
if shard:
|
|
486
|
+
return
|
|
487
|
+
count = int(np.prod([math.ceil(s / c) for s, c in zip(shape, chunks)]))
|
|
488
|
+
if count < _CHUNK_COUNT_WARN:
|
|
489
|
+
return
|
|
490
|
+
logger.warning(
|
|
491
|
+
"level 0 will be %s chunks, i.e. that many files: the source's own "
|
|
492
|
+
"granularity forced chunks of %s. Set shard=True (config: "
|
|
493
|
+
"shard: true) to pack them into far fewer files -- same chunking, "
|
|
494
|
+
"same memory, and kinder to a shared filesystem.",
|
|
495
|
+
f"{count:,}",
|
|
496
|
+
chunks,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _bounded_scheduler(arr: da.Array):
|
|
501
|
+
"""Cap dask's threads by the memory one **source** chunk costs.
|
|
502
|
+
|
|
503
|
+
Dask keeps a source chunk alive while every task that reads it runs, so
|
|
504
|
+
peak memory tracks the *input* granularity, not the output's. A folder of
|
|
505
|
+
stitched TIFFs gives one chunk per file -- gigabytes each -- and the
|
|
506
|
+
default worker count is the machine's core count, so 32 threads x a 3.6 GB
|
|
507
|
+
plane is over 100 GB before anything is written.
|
|
508
|
+
|
|
509
|
+
Sizing from the source chunk against the job's real budget keeps that
|
|
510
|
+
bounded. Returns a context manager; a no-op when a distributed client is
|
|
511
|
+
driving, since it schedules with its own memory awareness.
|
|
512
|
+
"""
|
|
513
|
+
import dask
|
|
514
|
+
|
|
515
|
+
from .._chunks import cpu_allocation, safe_worker_count
|
|
516
|
+
from .._cluster import _distributed_client
|
|
517
|
+
|
|
518
|
+
if _distributed_client() is not None:
|
|
519
|
+
return _nullcontext()
|
|
520
|
+
chunk_nbytes = int(np.prod(arr.chunksize)) * arr.dtype.itemsize
|
|
521
|
+
# Two live copies per worker: the source chunk plus the slice being written.
|
|
522
|
+
workers = min(
|
|
523
|
+
cpu_allocation(), safe_worker_count(chunk_nbytes, fn_overhead=2)
|
|
524
|
+
)
|
|
525
|
+
logger.info(
|
|
526
|
+
"writing with %d dask worker(s): source chunks are %.2f GB each",
|
|
527
|
+
workers,
|
|
528
|
+
chunk_nbytes / 1024**3,
|
|
529
|
+
)
|
|
530
|
+
return dask.config.set(scheduler="threads", num_workers=workers)
|
|
531
|
+
|
|
532
|
+
|
|
448
533
|
def _progress_ctx(progress: bool, label: str):
|
|
449
534
|
"""Return a progress-bar context manager.
|
|
450
535
|
|
|
@@ -647,7 +732,12 @@ def _write_pyramid(
|
|
|
647
732
|
strides = _strides(axes, downscale)
|
|
648
733
|
|
|
649
734
|
if write_base:
|
|
650
|
-
|
|
735
|
+
# Cap by the source's own chunking: combining several source chunks
|
|
736
|
+
# into one output chunk can mean holding gigabytes to write megabytes.
|
|
737
|
+
base_chunks = chunks or _default_chunks(
|
|
738
|
+
arr.shape, axes, source_chunks=arr.chunksize
|
|
739
|
+
)
|
|
740
|
+
_warn_if_many_chunks(arr.shape, base_chunks, shard)
|
|
651
741
|
_to_zarr_level(
|
|
652
742
|
arr.rechunk(base_chunks), group_path, base_name, shard, progress
|
|
653
743
|
)
|
|
@@ -871,16 +961,75 @@ _UNIT_TO_UM = {
|
|
|
871
961
|
_RESUNIT_TO_UM = {2: 25400.0, 3: 10000.0}
|
|
872
962
|
|
|
873
963
|
|
|
964
|
+
def _ome_xml_pixel_size(xml: str) -> PixelSize:
|
|
965
|
+
"""Voxel size from an OME-XML ``Pixels`` element.
|
|
966
|
+
|
|
967
|
+
Stitching and acquisition software commonly writes OME-XML into
|
|
968
|
+
``ImageDescription`` and nothing else -- no ImageJ block, and resolution
|
|
969
|
+
tags left at their defaults. Readers that only look at the TIFF tags then
|
|
970
|
+
report an uncalibrated image even though the size is right there.
|
|
971
|
+
|
|
972
|
+
Parameters
|
|
973
|
+
----------
|
|
974
|
+
xml : str
|
|
975
|
+
The OME-XML document.
|
|
976
|
+
|
|
977
|
+
Returns
|
|
978
|
+
-------
|
|
979
|
+
dict
|
|
980
|
+
``{axis: micrometers}`` for whichever axes carry a size.
|
|
981
|
+
"""
|
|
982
|
+
from xml.etree import ElementTree
|
|
983
|
+
|
|
984
|
+
try:
|
|
985
|
+
root = ElementTree.fromstring(xml)
|
|
986
|
+
except ElementTree.ParseError:
|
|
987
|
+
return {}
|
|
988
|
+
# The OME namespace is versioned, so match on the local tag name.
|
|
989
|
+
pixels = next(
|
|
990
|
+
(el for el in root.iter() if el.tag.rsplit("}", 1)[-1] == "Pixels"),
|
|
991
|
+
None,
|
|
992
|
+
)
|
|
993
|
+
if pixels is None:
|
|
994
|
+
return {}
|
|
995
|
+
out: PixelSize = {}
|
|
996
|
+
for axis in "zyx":
|
|
997
|
+
value = pixels.get(f"PhysicalSize{axis.upper()}")
|
|
998
|
+
if not value:
|
|
999
|
+
continue
|
|
1000
|
+
unit = pixels.get(f"PhysicalSize{axis.upper()}Unit", "µm")
|
|
1001
|
+
factor = _UNIT_TO_UM.get(str(unit).lower())
|
|
1002
|
+
if factor is None:
|
|
1003
|
+
logger.warning(
|
|
1004
|
+
"OME-XML PhysicalSize%s has unit %r, which is not recognised; "
|
|
1005
|
+
"ignoring it.",
|
|
1006
|
+
axis.upper(),
|
|
1007
|
+
unit,
|
|
1008
|
+
)
|
|
1009
|
+
continue
|
|
1010
|
+
try:
|
|
1011
|
+
out[axis] = float(value) * factor
|
|
1012
|
+
except ValueError:
|
|
1013
|
+
continue
|
|
1014
|
+
return out
|
|
1015
|
+
|
|
1016
|
+
|
|
874
1017
|
def _tiff_pixel_size(path: str) -> PixelSize:
|
|
875
1018
|
"""Read physical voxel size from a TIFF file's own metadata.
|
|
876
1019
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1020
|
+
Checks every place the size is commonly written, most explicit first:
|
|
1021
|
+
|
|
1022
|
+
1. **OME-XML** ``PhysicalSizeX/Y/Z`` (+ their units) in
|
|
1023
|
+
``ImageDescription`` — what stitched and OME-TIFF output usually
|
|
1024
|
+
carries, and the only one of the three that can give ``z`` for a
|
|
1025
|
+
sequence of single planes.
|
|
1026
|
+
2. **ImageJ metadata** — ``spacing`` (+ ``unit``) for ``z``.
|
|
1027
|
+
3. **Resolution tags** — ``XResolution``/``YResolution`` in pixels per
|
|
1028
|
+
unit, the unit from ``ResolutionUnit`` (plain TIFFs) or from ImageJ's
|
|
1029
|
+
``unit`` string (ImageJ writes ``ResolutionUnit=NONE``).
|
|
1030
|
+
|
|
1031
|
+
Earlier sources win per axis, so a file carrying both keeps the explicit
|
|
1032
|
+
OME value. Unrecognized units are ignored rather than guessed at.
|
|
884
1033
|
|
|
885
1034
|
Parameters
|
|
886
1035
|
----------
|
|
@@ -897,9 +1046,18 @@ def _tiff_pixel_size(path: str) -> PixelSize:
|
|
|
897
1046
|
|
|
898
1047
|
pixel_size: PixelSize = {}
|
|
899
1048
|
with tifffile.TiffFile(path) as tif:
|
|
1049
|
+
description = tif.pages[0].tags.get("ImageDescription")
|
|
1050
|
+
xml = tif.ome_metadata or (
|
|
1051
|
+
description.value
|
|
1052
|
+
if description and "<OME" in str(description.value)
|
|
1053
|
+
else None
|
|
1054
|
+
)
|
|
1055
|
+
if xml:
|
|
1056
|
+
pixel_size.update(_ome_xml_pixel_size(str(xml)))
|
|
1057
|
+
|
|
900
1058
|
ij = tif.imagej_metadata or {}
|
|
901
1059
|
spacing = ij.get("spacing")
|
|
902
|
-
if spacing:
|
|
1060
|
+
if spacing and "z" not in pixel_size:
|
|
903
1061
|
factor = _UNIT_TO_UM.get(str(ij.get("unit", "micron")).lower(), 1.0)
|
|
904
1062
|
pixel_size["z"] = float(spacing) * factor
|
|
905
1063
|
|
|
@@ -910,6 +1068,8 @@ def _tiff_pixel_size(path: str) -> PixelSize:
|
|
|
910
1068
|
) or _UNIT_TO_UM.get(str(ij.get("unit", "")).lower())
|
|
911
1069
|
if um_per_unit is not None:
|
|
912
1070
|
for axis, tag_name in (("y", "YResolution"), ("x", "XResolution")):
|
|
1071
|
+
if axis in pixel_size:
|
|
1072
|
+
continue
|
|
913
1073
|
tag = page.tags.get(tag_name)
|
|
914
1074
|
if tag and tag.value[0]:
|
|
915
1075
|
num, den = tag.value
|
|
@@ -1013,6 +1173,18 @@ def _open_tiff_sequence(
|
|
|
1013
1173
|
arr.shape,
|
|
1014
1174
|
pixel_size,
|
|
1015
1175
|
)
|
|
1176
|
+
missing = [a for a in axes if a in _SPATIAL_AXES and a not in pixel_size]
|
|
1177
|
+
if missing:
|
|
1178
|
+
logger.warning(
|
|
1179
|
+
"no voxel size for %s in %s -- checked OME-XML PhysicalSize*, "
|
|
1180
|
+
"ImageJ spacing/unit, and the resolution tags. The store will be "
|
|
1181
|
+
"uncalibrated on %s, which also leaves anything deriving physical "
|
|
1182
|
+
"units from it (e.g. deconvolution voxel sizes) with nothing to "
|
|
1183
|
+
"read. Pass pixel_size= to supply it.",
|
|
1184
|
+
"/".join(missing),
|
|
1185
|
+
Path(ts[0]).name,
|
|
1186
|
+
"/".join(missing),
|
|
1187
|
+
)
|
|
1016
1188
|
return arr, axes, pixel_size
|
|
1017
1189
|
|
|
1018
1190
|
|
|
@@ -1201,6 +1373,21 @@ def _to_dask(
|
|
|
1201
1373
|
if sequence_pattern is not None:
|
|
1202
1374
|
arr, detected, ps = _open_tiff_sequence(path, sequence_pattern)
|
|
1203
1375
|
return arr, axes or detected, ps
|
|
1376
|
+
if any(ch in path for ch in "*?[") or (
|
|
1377
|
+
not Path(path).exists() and glob.glob(path)
|
|
1378
|
+
):
|
|
1379
|
+
# Without sequence_pattern this would be handed to bioio, which reads
|
|
1380
|
+
# single files and reports only that it cannot handle the "format" --
|
|
1381
|
+
# pointing at the glob rather than the missing setting.
|
|
1382
|
+
raise ValueError(
|
|
1383
|
+
f"{path!r} looks like a glob over several files, but "
|
|
1384
|
+
"sequence_pattern is not set, so there is no way to tell which "
|
|
1385
|
+
"part of each filename is Z, C or T.\n"
|
|
1386
|
+
"Set it to a regex with named groups, e.g.\n"
|
|
1387
|
+
" sequence_pattern: '_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+'\n"
|
|
1388
|
+
"for files like sample_Z001_C0_V0.tif. Pass a single file "
|
|
1389
|
+
"instead if you did not mean a sequence."
|
|
1390
|
+
)
|
|
1204
1391
|
if path.endswith(".zarr"):
|
|
1205
1392
|
arr = load_ome_zarr(source, channel=None)
|
|
1206
1393
|
ax = axes or _default_axes(arr.ndim)
|
|
@@ -1345,17 +1532,18 @@ def to_ome_zarr(
|
|
|
1345
1532
|
|
|
1346
1533
|
out = str(out_path)
|
|
1347
1534
|
zarr.open_group(out, mode="w" if overwrite else "w-")
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1535
|
+
with _bounded_scheduler(arr):
|
|
1536
|
+
datasets = _write_pyramid(
|
|
1537
|
+
arr,
|
|
1538
|
+
axes,
|
|
1539
|
+
out,
|
|
1540
|
+
n_levels=n_levels,
|
|
1541
|
+
downscale=downscale,
|
|
1542
|
+
chunks=chunks,
|
|
1543
|
+
base_scale=base_scale,
|
|
1544
|
+
shard=shard,
|
|
1545
|
+
progress=progress,
|
|
1546
|
+
)
|
|
1359
1547
|
_write_multiscales(out, axes, datasets, Path(out).stem, calibrated=bool(ps))
|
|
1360
1548
|
return out
|
|
1361
1549
|
|
|
@@ -399,3 +399,180 @@ def test_sharding(tmp_path):
|
|
|
399
399
|
assert (
|
|
400
400
|
getattr(_zarr.open_array(f"{out3}/0", mode="r"), "shards", None) is None
|
|
401
401
|
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_glob_without_sequence_pattern_says_so(tmp_path):
|
|
405
|
+
"""A glob input must name the missing setting, not blame the format.
|
|
406
|
+
|
|
407
|
+
Without sequence_pattern the glob fell through to bioio, which reads one
|
|
408
|
+
file and could only report "does not support the image" -- pointing at the
|
|
409
|
+
path instead of at the config key that was missing.
|
|
410
|
+
"""
|
|
411
|
+
with pytest.raises(ValueError, match="sequence_pattern is not set"):
|
|
412
|
+
to_ome_zarr(str(tmp_path / "*.tif"), tmp_path / "out.zarr")
|
|
413
|
+
|
|
414
|
+
# A real single file must still reach the normal readers.
|
|
415
|
+
with pytest.raises(Exception) as exc:
|
|
416
|
+
to_ome_zarr(str(tmp_path / "scan.ims"), tmp_path / "o2.zarr")
|
|
417
|
+
assert "sequence_pattern" not in str(exc.value)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_base_chunks_never_group_source_chunks(tmp_path):
|
|
421
|
+
"""One output chunk must not require several source chunks.
|
|
422
|
+
|
|
423
|
+
A folder of stitched TIFFs gives one dask chunk per file, so a plane can
|
|
424
|
+
be gigabytes. The z cap of 16 would group sixteen of them into one output
|
|
425
|
+
chunk -- ~58 GB held to write 32 MB, against a 64 GB job. Splitting a
|
|
426
|
+
source chunk is fine; combining several is not.
|
|
427
|
+
"""
|
|
428
|
+
from patchworks.plugins.ome_zarr import _default_chunks
|
|
429
|
+
|
|
430
|
+
shape = (4, 126, 45961, 42072)
|
|
431
|
+
# one chunk per file: (c=1, z=1, whole plane)
|
|
432
|
+
assert _default_chunks(
|
|
433
|
+
shape, "czyx", source_chunks=(1, 1, 45961, 42072)
|
|
434
|
+
) == (
|
|
435
|
+
1,
|
|
436
|
+
1,
|
|
437
|
+
1024,
|
|
438
|
+
1024,
|
|
439
|
+
)
|
|
440
|
+
# a normally-chunked source is unaffected by the cap
|
|
441
|
+
assert _default_chunks(shape, "czyx", source_chunks=shape) == (
|
|
442
|
+
1,
|
|
443
|
+
16,
|
|
444
|
+
1024,
|
|
445
|
+
1024,
|
|
446
|
+
)
|
|
447
|
+
# and omitting it keeps the previous behaviour
|
|
448
|
+
assert _default_chunks(shape, "czyx") == (1, 16, 1024, 1024)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_tiff_sequence_keeps_one_plane_per_chunk(tmp_path):
|
|
452
|
+
"""End to end: a per-file source must not be z-grouped on write."""
|
|
453
|
+
tifffile = pytest.importorskip("tifffile")
|
|
454
|
+
n_z, n_c, size = 4, 2, 8
|
|
455
|
+
for z in range(n_z):
|
|
456
|
+
for c in range(n_c):
|
|
457
|
+
tifffile.imwrite(
|
|
458
|
+
tmp_path / f"s_Z{z:03d}_C{c}_V0.tif",
|
|
459
|
+
np.full((size, size), z * 10 + c, "uint16"),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
out = tmp_path / "seq.zarr"
|
|
463
|
+
to_ome_zarr(
|
|
464
|
+
str(tmp_path / "*.tif"),
|
|
465
|
+
out,
|
|
466
|
+
sequence_pattern=r"_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+",
|
|
467
|
+
n_levels=1,
|
|
468
|
+
progress=False,
|
|
469
|
+
)
|
|
470
|
+
level0 = zarr.open_array(str(out) + "/0", mode="r")
|
|
471
|
+
assert level0.chunks[1] == 1, (
|
|
472
|
+
f"z was grouped into {level0.chunks[1]} planes per chunk; each source "
|
|
473
|
+
"chunk is a whole file, so that multiplies the read"
|
|
474
|
+
)
|
|
475
|
+
# and the data still round-trips
|
|
476
|
+
result = np.asarray(load_ome_zarr(out, channel=None))
|
|
477
|
+
assert result.shape == (n_c, n_z, size, size)
|
|
478
|
+
assert (
|
|
479
|
+
result[:, :, 0, 0]
|
|
480
|
+
== [[z * 10 + c for z in range(n_z)] for c in range(n_c)]
|
|
481
|
+
).all()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _ome_xml(x, y, z, unit="um"):
|
|
485
|
+
return (
|
|
486
|
+
'<?xml version="1.0"?>'
|
|
487
|
+
'<OME xmlns="http://www.openmicroscopy.org/Schemas/OME/2016-06">'
|
|
488
|
+
f'<Image><Pixels PhysicalSizeX="{x}" PhysicalSizeXUnit="{unit}"'
|
|
489
|
+
f' PhysicalSizeY="{y}" PhysicalSizeYUnit="{unit}"'
|
|
490
|
+
f' PhysicalSizeZ="{z}" PhysicalSizeZUnit="{unit}"/></Image></OME>'
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def test_pixel_size_from_ome_xml(tmp_path):
|
|
495
|
+
"""OME-XML PhysicalSize* must be read, not just the resolution tags.
|
|
496
|
+
|
|
497
|
+
Stitched output commonly writes OME-XML into ImageDescription and leaves
|
|
498
|
+
the TIFF tags at their defaults, so a tag-only reader calls a perfectly
|
|
499
|
+
calibrated image uncalibrated -- and it is the only source that can give
|
|
500
|
+
z for a sequence of single planes.
|
|
501
|
+
"""
|
|
502
|
+
tifffile = pytest.importorskip("tifffile")
|
|
503
|
+
from patchworks.plugins.ome_zarr import _tiff_pixel_size
|
|
504
|
+
|
|
505
|
+
path = tmp_path / "ome.tif"
|
|
506
|
+
tifffile.imwrite(
|
|
507
|
+
path,
|
|
508
|
+
np.zeros((8, 8), "uint16"),
|
|
509
|
+
description=_ome_xml(0.325, 0.325, 1.5),
|
|
510
|
+
)
|
|
511
|
+
assert _tiff_pixel_size(str(path)) == pytest.approx(
|
|
512
|
+
{"z": 1.5, "y": 0.325, "x": 0.325}
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# mm is converted, not taken at face value
|
|
516
|
+
mm = tmp_path / "mm.tif"
|
|
517
|
+
tifffile.imwrite(
|
|
518
|
+
mm,
|
|
519
|
+
np.zeros((8, 8), "uint16"),
|
|
520
|
+
description=_ome_xml(0.001, 0.001, 0.002, "mm"),
|
|
521
|
+
)
|
|
522
|
+
assert _tiff_pixel_size(str(mm)) == pytest.approx(
|
|
523
|
+
{"z": 2.0, "y": 1.0, "x": 1.0}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_ome_xml_wins_over_the_resolution_tags(tmp_path):
|
|
528
|
+
"""The explicit source wins per axis when a file carries both."""
|
|
529
|
+
tifffile = pytest.importorskip("tifffile")
|
|
530
|
+
from patchworks.plugins.ome_zarr import _tiff_pixel_size
|
|
531
|
+
|
|
532
|
+
path = tmp_path / "both.tif"
|
|
533
|
+
tifffile.imwrite(
|
|
534
|
+
path,
|
|
535
|
+
np.zeros((8, 8), "uint16"),
|
|
536
|
+
description=_ome_xml(0.325, 0.325, 1.5),
|
|
537
|
+
resolution=(20000.0, 20000.0), # would say 0.5 µm
|
|
538
|
+
resolutionunit="CENTIMETER",
|
|
539
|
+
)
|
|
540
|
+
got = _tiff_pixel_size(str(path))
|
|
541
|
+
assert got["x"] == pytest.approx(0.325), "OME-XML must win over the tag"
|
|
542
|
+
assert got["z"] == pytest.approx(1.5)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def test_uncalibrated_tiff_reports_nothing(tmp_path):
|
|
546
|
+
"""No metadata gives an empty dict rather than an invented default."""
|
|
547
|
+
tifffile = pytest.importorskip("tifffile")
|
|
548
|
+
from patchworks.plugins.ome_zarr import _tiff_pixel_size
|
|
549
|
+
|
|
550
|
+
path = tmp_path / "bare.tif"
|
|
551
|
+
tifffile.imwrite(path, np.zeros((8, 8), "uint16"))
|
|
552
|
+
assert _tiff_pixel_size(str(path)) == {}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_many_chunks_suggests_sharding(caplog):
|
|
556
|
+
"""A coarse source forces fine chunks; fine chunks mean many files.
|
|
557
|
+
|
|
558
|
+
That trade-off is invisible until a shared filesystem is carrying a
|
|
559
|
+
million of them, so it has to be said at write time -- with the remedy.
|
|
560
|
+
"""
|
|
561
|
+
import logging
|
|
562
|
+
|
|
563
|
+
from patchworks.plugins.ome_zarr import _warn_if_many_chunks
|
|
564
|
+
|
|
565
|
+
real = (4, 126, 45961, 42072) # ~950k chunks at (1, 1, 1024, 1024)
|
|
566
|
+
with caplog.at_level(logging.WARNING, logger="patchworks.plugins.ome_zarr"):
|
|
567
|
+
_warn_if_many_chunks(real, (1, 1, 1024, 1024), shard=False)
|
|
568
|
+
assert "shard: true" in caplog.text
|
|
569
|
+
assert "952,560" in caplog.text
|
|
570
|
+
|
|
571
|
+
# Sharding already asked for, or a modest chunk count: nothing to say.
|
|
572
|
+
caplog.clear()
|
|
573
|
+
with caplog.at_level(logging.WARNING, logger="patchworks.plugins.ome_zarr"):
|
|
574
|
+
_warn_if_many_chunks(real, (1, 1, 1024, 1024), shard=True)
|
|
575
|
+
_warn_if_many_chunks(
|
|
576
|
+
(4, 126, 1024, 1024), (1, 16, 1024, 1024), shard=False
|
|
577
|
+
)
|
|
578
|
+
assert caplog.text == ""
|
|
@@ -46,6 +46,7 @@ def _snakemake_cmd(
|
|
|
46
46
|
dry_run: bool,
|
|
47
47
|
state_dir: Path | None = None,
|
|
48
48
|
targets: list[str] | None = None,
|
|
49
|
+
extra: list[str] | None = None,
|
|
49
50
|
) -> list[str]:
|
|
50
51
|
"""Build one snakemake invocation.
|
|
51
52
|
|
|
@@ -69,6 +70,8 @@ def _snakemake_cmd(
|
|
|
69
70
|
cmd += ["--cores", str(cores), "--rerun-triggers", "mtime"]
|
|
70
71
|
if dry_run:
|
|
71
72
|
cmd += ["-n", "-p"]
|
|
73
|
+
if extra:
|
|
74
|
+
cmd += extra
|
|
72
75
|
if targets:
|
|
73
76
|
# "--" ends option parsing: --rerun-triggers takes a variable number
|
|
74
77
|
# of values and would otherwise swallow the target path.
|
|
@@ -114,6 +117,18 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
|
|
|
114
117
|
f"share a chunk layout; got {_spread(key)}"
|
|
115
118
|
)
|
|
116
119
|
|
|
120
|
+
for path, cfg in zip(paths, cfgs):
|
|
121
|
+
source = str(cfg.get("input", ""))
|
|
122
|
+
if any(ch in source for ch in "*?[") and not cfg.get(
|
|
123
|
+
"sequence_pattern"
|
|
124
|
+
):
|
|
125
|
+
problems.append(
|
|
126
|
+
f"{path.name}: input {source!r} is a glob over several files "
|
|
127
|
+
"but sequence_pattern is unset, so nothing says which part of "
|
|
128
|
+
"each filename is Z/C/T. Set e.g. "
|
|
129
|
+
r"sequence_pattern: '_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+'"
|
|
130
|
+
)
|
|
131
|
+
|
|
117
132
|
names = [cfg.get("label_name") for cfg in cfgs]
|
|
118
133
|
duplicates = {n for n in names if names.count(n) > 1}
|
|
119
134
|
if duplicates:
|
|
@@ -160,6 +175,15 @@ def main() -> None:
|
|
|
160
175
|
action="store_true",
|
|
161
176
|
help="pass -n -p to every Snakemake run; skips relations",
|
|
162
177
|
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--unlock",
|
|
180
|
+
action="store_true",
|
|
181
|
+
help=(
|
|
182
|
+
"release stale Snakemake locks in every state directory this "
|
|
183
|
+
"script manages, then exit. Needed after a run was killed or "
|
|
184
|
+
"died: the lock is only released on a clean exit."
|
|
185
|
+
),
|
|
186
|
+
)
|
|
163
187
|
args = parser.parse_args()
|
|
164
188
|
|
|
165
189
|
workflow_dir = Path(__file__).resolve().parent.parent
|
|
@@ -173,6 +197,32 @@ def main() -> None:
|
|
|
173
197
|
work_dir = _validate_configs(seg_config_paths, seg_cfgs)
|
|
174
198
|
image_store = f"{work_dir}/image.zarr"
|
|
175
199
|
|
|
200
|
+
# Each phase gets its own Snakemake state directory (the lock lives in the
|
|
201
|
+
# working directory, not the config), so unlocking has to cover all of
|
|
202
|
+
# them -- and nobody should have to reconstruct these paths by hand.
|
|
203
|
+
state_dirs = [Path(work_dir) / ".snakemake_convert"] + [
|
|
204
|
+
Path(cfg["work_dir"]) / cfg["label_name"] / ".snakemake"
|
|
205
|
+
for cfg in seg_cfgs
|
|
206
|
+
]
|
|
207
|
+
if args.unlock:
|
|
208
|
+
for state_dir in state_dirs:
|
|
209
|
+
if not state_dir.exists():
|
|
210
|
+
continue
|
|
211
|
+
_run(
|
|
212
|
+
_snakemake_cmd(
|
|
213
|
+
seg_config_paths[0],
|
|
214
|
+
workflow_dir=workflow_dir,
|
|
215
|
+
profile=args.profile,
|
|
216
|
+
cores=args.cores,
|
|
217
|
+
dry_run=False,
|
|
218
|
+
state_dir=state_dir,
|
|
219
|
+
extra=["--unlock"],
|
|
220
|
+
),
|
|
221
|
+
workflow_dir,
|
|
222
|
+
)
|
|
223
|
+
print("[run_multi] unlocked; re-run without --unlock", flush=True)
|
|
224
|
+
return
|
|
225
|
+
|
|
176
226
|
# Phase A: convert exactly once. The three runs are about to go concurrent
|
|
177
227
|
# and `convert` writes with overwrite=True, so letting them race on it
|
|
178
228
|
# would have them clobbering one store. Ask for its marker explicitly.
|
|
@@ -189,7 +239,13 @@ def main() -> None:
|
|
|
189
239
|
workflow_dir,
|
|
190
240
|
)
|
|
191
241
|
if rc != 0:
|
|
192
|
-
print(
|
|
242
|
+
print(
|
|
243
|
+
"[run_multi] ERROR: conversion failed.\n"
|
|
244
|
+
" If the log says the directory cannot be locked, a previous run "
|
|
245
|
+
"was killed rather than exiting cleanly; release it with:\n"
|
|
246
|
+
f" {Path(sys.argv[0]).name} --config {args.config} --unlock",
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
193
249
|
sys.exit(rc)
|
|
194
250
|
|
|
195
251
|
# Still phase A: build the occupancy map here too. Every config's `prepare`
|
|
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
|