patchworks 0.9.1__tar.gz → 0.10.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 (51) hide show
  1. {patchworks-0.9.1 → patchworks-0.10.0}/PKG-INFO +1 -1
  2. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/_core.py +148 -4
  3. {patchworks-0.9.1 → patchworks-0.10.0}/.github/workflows/docs.yml +0 -0
  4. {patchworks-0.9.1 → patchworks-0.10.0}/.github/workflows/lint.yml +0 -0
  5. {patchworks-0.9.1 → patchworks-0.10.0}/.github/workflows/release.yml +0 -0
  6. {patchworks-0.9.1 → patchworks-0.10.0}/.gitignore +0 -0
  7. {patchworks-0.9.1 → patchworks-0.10.0}/.markdownlint-cli2.yaml +0 -0
  8. {patchworks-0.9.1 → patchworks-0.10.0}/README.md +0 -0
  9. {patchworks-0.9.1 → patchworks-0.10.0}/cliff.toml +0 -0
  10. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/chunks.md +0 -0
  11. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/cluster.md +0 -0
  12. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/io.md +0 -0
  13. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/merge_tile_labels.md +0 -0
  14. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/plugins/cellpose.md +0 -0
  15. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/plugins/napari.md +0 -0
  16. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/plugins/ome_zarr.md +0 -0
  17. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/relabel.md +0 -0
  18. {patchworks-0.9.1 → patchworks-0.10.0}/docs/api/tile_process.md +0 -0
  19. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/cellpose_2d.md +0 -0
  20. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/cellpose_2d.py +0 -0
  21. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/cellpose_3d.md +0 -0
  22. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/cellpose_3d.py +0 -0
  23. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/custom.md +0 -0
  24. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/custom_method.py +0 -0
  25. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/standalone_merge.md +0 -0
  26. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/stardist.md +0 -0
  27. {patchworks-0.9.1 → patchworks-0.10.0}/docs/examples/stardist_2d.py +0 -0
  28. {patchworks-0.9.1 → patchworks-0.10.0}/docs/getting_started.md +0 -0
  29. {patchworks-0.9.1 → patchworks-0.10.0}/docs/guide/gpu_distributed.md +0 -0
  30. {patchworks-0.9.1 → patchworks-0.10.0}/docs/guide/merging.md +0 -0
  31. {patchworks-0.9.1 → patchworks-0.10.0}/docs/guide/ome_zarr_napari.md +0 -0
  32. {patchworks-0.9.1 → patchworks-0.10.0}/docs/guide/performance.md +0 -0
  33. {patchworks-0.9.1 → patchworks-0.10.0}/docs/guide/pitfalls.md +0 -0
  34. {patchworks-0.9.1 → patchworks-0.10.0}/docs/guide/skip_empty.md +0 -0
  35. {patchworks-0.9.1 → patchworks-0.10.0}/docs/guide/tiling.md +0 -0
  36. {patchworks-0.9.1 → patchworks-0.10.0}/docs/index.md +0 -0
  37. {patchworks-0.9.1 → patchworks-0.10.0}/mkdocs.yml +0 -0
  38. {patchworks-0.9.1 → patchworks-0.10.0}/pyproject.toml +0 -0
  39. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/__init__.py +0 -0
  40. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/_chunks.py +0 -0
  41. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/_cluster.py +0 -0
  42. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/_io.py +0 -0
  43. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/_merge.py +0 -0
  44. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/_relabel.py +0 -0
  45. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/plugins/__init__.py +0 -0
  46. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/plugins/cellpose.py +0 -0
  47. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/plugins/napari.py +0 -0
  48. {patchworks-0.9.1 → patchworks-0.10.0}/src/patchworks/plugins/ome_zarr.py +0 -0
  49. {patchworks-0.9.1 → patchworks-0.10.0}/tests/test_core.py +0 -0
  50. {patchworks-0.9.1 → patchworks-0.10.0}/tests/test_napari.py +0 -0
  51. {patchworks-0.9.1 → patchworks-0.10.0}/tests/test_ome_zarr.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 0.9.1
3
+ Version: 0.10.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
@@ -20,6 +20,71 @@ from ._relabel import relabel_sequential_zarr
20
20
  logger = logging.getLogger(__name__)
21
21
 
22
22
 
23
+ def _attach_log_file(path: str) -> None:
24
+ """Tee the ``patchworks`` INFO log to *path* (replacing a prior auto file).
25
+
26
+ A previous auto-attached handler is removed first so repeated calls do not
27
+ stack handlers. The package logger level is raised to INFO if needed so the
28
+ messages are actually emitted.
29
+
30
+ Parameters
31
+ ----------
32
+ path : str
33
+ Destination log-file path.
34
+
35
+ Returns
36
+ -------
37
+ None
38
+ """
39
+ pkg = logging.getLogger("patchworks")
40
+ for h in list(pkg.handlers):
41
+ if getattr(h, "_patchworks_auto", False):
42
+ pkg.removeHandler(h)
43
+ h.close()
44
+ handler = logging.FileHandler(path)
45
+ handler._patchworks_auto = True # tag so we can find/replace it later
46
+ handler.setLevel(logging.INFO)
47
+ handler.setFormatter(
48
+ logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
49
+ )
50
+ pkg.addHandler(handler)
51
+ if pkg.level == logging.NOTSET or pkg.level > logging.INFO:
52
+ pkg.setLevel(logging.INFO)
53
+
54
+
55
+ def _read_amplification(
56
+ native_chunks: tuple[int, ...], tile_shape: tuple[int, ...]
57
+ ) -> float:
58
+ """Estimate how much extra data each tile read pulls off disk.
59
+
60
+ When the store's on-disk chunks are larger than the tile, every tile read
61
+ decodes whole chunks and discards most of them. Returns the ratio of bytes
62
+ read to bytes used (1.0 = no waste).
63
+
64
+ Parameters
65
+ ----------
66
+ native_chunks : tuple of int
67
+ The store's on-disk chunk shape.
68
+ tile_shape : tuple of int
69
+ The processing tile shape.
70
+
71
+ Returns
72
+ -------
73
+ float
74
+ Read-amplification factor (``bytes_read / bytes_used``).
75
+ """
76
+ import math
77
+
78
+ read = used = 1.0
79
+ for n, t in zip(native_chunks, tile_shape):
80
+ if n <= 0 or t <= 0:
81
+ continue
82
+ touched = math.ceil(t / n) # chunks a tile spans on this axis
83
+ read *= touched * n
84
+ used *= t
85
+ return read / used if used else 1.0
86
+
87
+
23
88
  def _stage_to_zarr(
24
89
  arr: da.Array, path: str, component: str, show_progress: bool
25
90
  ) -> None:
@@ -83,6 +148,7 @@ def tile_process(
83
148
  empty_threshold: float | None = None,
84
149
  stage_dir: Union[str, Path, None] = None,
85
150
  keep_stage: bool = False,
151
+ log_file: Union[str, Path, bool, None] = True,
86
152
  verbose: bool = False,
87
153
  ) -> da.Array:
88
154
  """Apply *fn* to every tile of *image* and merge labels globally.
@@ -180,6 +246,11 @@ def tile_process(
180
246
  keep_stage:
181
247
  Keep the temp stage store after merging (default: delete it). Useful
182
248
  for debugging or resuming an interrupted run.
249
+ log_file:
250
+ Where to tee the ``patchworks`` INFO log (including a per-tile
251
+ ``processing tile k/N`` counter). ``True`` (default) auto-writes
252
+ ``patchworks.log`` next to the output; a path writes there; ``False``/
253
+ ``None`` disables the file (logs still go to stderr).
183
254
  verbose:
184
255
  Log each tile's location and shape as it is processed.
185
256
 
@@ -250,10 +321,27 @@ def tile_process(
250
321
 
251
322
  # Load + tile
252
323
  image_source_path = None if isinstance(image, da.Array) else str(image)
324
+
325
+ # Auto log file (default): tee patchworks' INFO logs to a file next to the
326
+ # output, so a long run leaves a tailable record without notebook setup.
327
+ if log_file:
328
+ if log_file is True:
329
+ if write_to is not None:
330
+ _ldir = os.path.dirname(os.path.abspath(str(write_to)))
331
+ elif image_source_path is not None:
332
+ _ldir = os.path.dirname(os.path.abspath(image_source_path))
333
+ else:
334
+ _ldir = os.getcwd()
335
+ log_file = os.path.join(_ldir, "patchworks.log")
336
+ _attach_log_file(str(log_file))
337
+ logger.info("patchworks log → %s", log_file)
338
+
253
339
  _load_chunks: tuple[int, ...] | None = None
340
+ _native_chunks: tuple[int, ...] | None = None
254
341
 
255
342
  if not isinstance(image, da.Array):
256
343
  _peek = load_ome_zarr(image, channel=channel, level=level)
344
+ _native_chunks = _peek.chunksize # on-disk zarr chunk shape
257
345
  if callable(tile_shape):
258
346
  _load_chunks = tuple(tile_shape(_peek.shape, _peek.dtype))
259
347
  elif isinstance(tile_shape, str):
@@ -291,13 +379,30 @@ def tile_process(
291
379
  logger.info("Rechunked to %s", tile_shape)
292
380
 
293
381
  n_tiles = int(np.prod([len(c) for c in image.chunks]))
382
+ _tile = tuple(c[0] for c in image.chunks)
294
383
  logger.info(
295
384
  "Processing %d tiles (per-axis %s, tile shape %s)",
296
385
  n_tiles,
297
386
  tuple(len(c) for c in image.chunks),
298
- tuple(c[0] for c in image.chunks),
387
+ _tile,
299
388
  )
300
389
 
390
+ # Warn when the store's on-disk chunks are much larger than the tile: every
391
+ # tile read then decodes whole chunks and throws most away (slow I/O).
392
+ if _native_chunks is not None:
393
+ _amp = _read_amplification(_native_chunks, _tile)
394
+ if _amp >= 4:
395
+ logger.warning(
396
+ "Input chunks %s are much larger than the tile %s → ~%.0fx "
397
+ "read amplification (each tile decodes whole chunks and "
398
+ "discards most). Re-chunk the store near the tile size "
399
+ "(e.g. to_ome_zarr(..., chunks=...) without shard=) or read "
400
+ "the source file directly to avoid wasted I/O.",
401
+ _native_chunks,
402
+ _tile,
403
+ _amp,
404
+ )
405
+
301
406
  image_for_threshold = image
302
407
 
303
408
  # Overlap — build a per-axis depth dict (clips to fit each axis).
@@ -313,6 +418,23 @@ def tile_process(
313
418
  if skip_empty and _skip_thr is None:
314
419
  _skip_thr = _auto_empty_threshold(image_for_threshold, channel, level)
315
420
 
421
+ # Up-front heads-up for a big 3-D GPU job (z-stack tiles, many of them):
422
+ # an accurate ETA is logged after the first few tiles, but warn early that
423
+ # this is the expensive path and point at the faster alternatives.
424
+ if use_gpu and image.ndim >= 3 and _tile[0] > 4 and n_tiles >= 50:
425
+ logger.warning(
426
+ "Large 3-D GPU job: %d tiles of %s. Per-tile 3-D segmentation is "
427
+ "slow on a single device (a live ETA is logged after the first "
428
+ "tiles). If per-slice results are acceptable, 2-D (z=1 tiles) is "
429
+ "typically ~10x faster, or segment a lower pyramid level.",
430
+ n_tiles,
431
+ _tile,
432
+ )
433
+
434
+ # Tile counters + timing (GIL-safe enough for the threaded / in-process
435
+ # schedulers patchworks uses) so the log shows live "tile k/N + ETA".
436
+ _progress = {"done": 0, "seen": 0, "time": 0.0}
437
+
316
438
  def active_fn(block, block_info=None):
317
439
  """Run *fn* on one tile, or return zeros for an empty tile.
318
440
 
@@ -328,14 +450,36 @@ def tile_process(
328
450
  np.ndarray
329
451
  Integer labels, or an all-zero tile when skipped.
330
452
  """
453
+ import time
454
+
331
455
  loc = block_info[0].get("chunk-location") if block_info else "?"
456
+ _progress["seen"] += 1
332
457
  if skip_empty and block.size and block.max() <= _skip_thr:
333
458
  if verbose:
334
459
  logger.debug("skip empty tile %s (max<=%s)", loc, _skip_thr)
335
460
  return np.zeros(block.shape, dtype=np.int32)
336
- if verbose:
337
- logger.debug("process tile %s shape=%s", loc, block.shape)
338
- return fn(block)
461
+
462
+ t0 = time.perf_counter()
463
+ out = fn(block)
464
+ dt = time.perf_counter() - t0
465
+
466
+ _progress["done"] += 1
467
+ _progress["time"] += dt
468
+ done, seen = _progress["done"], _progress["seen"]
469
+ avg = _progress["time"] / done
470
+ # Extrapolate remaining non-empty tiles from the empty fraction seen.
471
+ remaining_nonempty = max(0, n_tiles - seen) * (done / seen)
472
+ eta_h = avg * remaining_nonempty / 3600
473
+ logger.info(
474
+ "tile %d done in %.1fs (avg %.1fs); %d/%d tiles seen; ETA ~%.1fh",
475
+ done,
476
+ dt,
477
+ avg,
478
+ seen,
479
+ n_tiles,
480
+ eta_h,
481
+ )
482
+ return out
339
483
 
340
484
  _meta = np.empty((0,) * image.ndim, dtype=np.int32)
341
485
  if overlap > 0:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes