ci-deconvolve 0.2.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ci-deconvolve
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Focused command-line CI-RL deconvolution for OME-TIFF and OME-Zarr images.
5
5
  Author: NL-BioImaging contributors
6
6
  License-Expression: MIT
@@ -102,6 +102,7 @@ ci_deconvolve --input image.ome.tiff --output out ^
102
102
  --iterations 40 ^
103
103
  --device auto ^
104
104
  --output-format ome-tiff ^
105
+ --projection none ^
105
106
  --emission-wl 520 ^
106
107
  --excitation-wl 488 ^
107
108
  --pixel-size-xy 0.065 ^
@@ -146,6 +147,7 @@ does not process plate fields.
146
147
  | Option | Default | Description |
147
148
  | --- | --- | --- |
148
149
  | `--output-format ome-tiff\|ome-zarr` | `ome-tiff` | Writes `<stem>_decon.ome.tiff` or `<stem>_decon.ome.zarr`. |
150
+ | `--projection none\|max-z` | `none` | With `max-z`, writes a maximum-Z projection when the deconvolved result is 3D. 2D inputs are unchanged. |
149
151
  | `--iterations N[,N...]` | `40` | CI-RL iteration count. A comma- or semicolon-separated list applies per channel, with the last value reused for extra channels. |
150
152
  | `--device auto\|cpu\|cuda` | `auto` | Compute device. `auto` lets PyTorch choose CUDA when available, otherwise CPU. |
151
153
  | `-v`, `--verbose` | off | Enable INFO logging from the CLI and core deconvolution code. |
@@ -74,6 +74,7 @@ ci_deconvolve --input image.ome.tiff --output out ^
74
74
  --iterations 40 ^
75
75
  --device auto ^
76
76
  --output-format ome-tiff ^
77
+ --projection none ^
77
78
  --emission-wl 520 ^
78
79
  --excitation-wl 488 ^
79
80
  --pixel-size-xy 0.065 ^
@@ -118,6 +119,7 @@ does not process plate fields.
118
119
  | Option | Default | Description |
119
120
  | --- | --- | --- |
120
121
  | `--output-format ome-tiff\|ome-zarr` | `ome-tiff` | Writes `<stem>_decon.ome.tiff` or `<stem>_decon.ome.zarr`. |
122
+ | `--projection none\|max-z` | `none` | With `max-z`, writes a maximum-Z projection when the deconvolved result is 3D. 2D inputs are unchanged. |
121
123
  | `--iterations N[,N...]` | `40` | CI-RL iteration count. A comma- or semicolon-separated list applies per channel, with the last value reused for extra channels. |
122
124
  | `--device auto\|cpu\|cuda` | `auto` | Compute device. `auto` lets PyTorch choose CUDA when available, otherwise CPU. |
123
125
  | `-v`, `--verbose` | off | Enable INFO logging from the CLI and core deconvolution code. |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ci-deconvolve
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Focused command-line CI-RL deconvolution for OME-TIFF and OME-Zarr images.
5
5
  Author: NL-BioImaging contributors
6
6
  License-Expression: MIT
@@ -102,6 +102,7 @@ ci_deconvolve --input image.ome.tiff --output out ^
102
102
  --iterations 40 ^
103
103
  --device auto ^
104
104
  --output-format ome-tiff ^
105
+ --projection none ^
105
106
  --emission-wl 520 ^
106
107
  --excitation-wl 488 ^
107
108
  --pixel-size-xy 0.065 ^
@@ -146,6 +147,7 @@ does not process plate fields.
146
147
  | Option | Default | Description |
147
148
  | --- | --- | --- |
148
149
  | `--output-format ome-tiff\|ome-zarr` | `ome-tiff` | Writes `<stem>_decon.ome.tiff` or `<stem>_decon.ome.zarr`. |
150
+ | `--projection none\|max-z` | `none` | With `max-z`, writes a maximum-Z projection when the deconvolved result is 3D. 2D inputs are unchanged. |
149
151
  | `--iterations N[,N...]` | `40` | CI-RL iteration count. A comma- or semicolon-separated list applies per channel, with the last value reused for extra channels. |
150
152
  | `--device auto\|cpu\|cuda` | `auto` | Compute device. `auto` lets PyTorch choose CUDA when available, otherwise CPU. |
151
153
  | `-v`, `--verbose` | off | Enable INFO logging from the CLI and core deconvolution code. |
@@ -12,4 +12,5 @@ src/core/__init__.py
12
12
  src/core/_meta_helpers.py
13
13
  src/core/deconvolve.py
14
14
  src/core/deconvolve_ci.py
15
- src/core/ome_zarr_io.py
15
+ src/core/ome_zarr_io.py
16
+ tests/test_cli_contract.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ci-deconvolve"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Focused command-line CI-RL deconvolution for OME-TIFF and OME-Zarr images."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """Focused command-line package for CI-RL deconvolution."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -1,14 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  import logging
5
6
  import shutil
7
+ import signal
6
8
  import sys
7
9
  import time
8
10
  from pathlib import Path
9
11
  from typing import Iterable
10
12
 
13
+ from ci_deconvolve import __version__
14
+
11
15
  LOGGER = logging.getLogger("ci_deconvolve")
16
+ _CANCEL_REQUESTED = False
12
17
 
13
18
  OME_TIFF_SUFFIXES = (".ome.tif", ".ome.tiff")
14
19
  ZARR_SUFFIXES = (".zarr", ".ome.zarr")
@@ -105,13 +110,30 @@ def _build_parser() -> argparse.ArgumentParser:
105
110
  )
106
111
  parser.add_argument("input_path", nargs="?", help="Input file/folder. Alias for --input.")
107
112
  parser.add_argument("--input", dest="input_option", help="OME-TIFF, OME-Zarr, or folder.")
108
- parser.add_argument("--output", required=True, help="Output folder.")
113
+ parser.add_argument("--output", help="Output folder.")
114
+ parser.add_argument(
115
+ "--validate-env",
116
+ action="store_true",
117
+ help="Report Python/PyTorch/Zarr/CIDeconvolve environment details and exit.",
118
+ )
109
119
  parser.add_argument(
110
120
  "--output-format",
111
121
  choices=("ome-tiff", "ome-zarr"),
112
122
  default="ome-tiff",
113
123
  help="Output format. Default: ome-tiff.",
114
124
  )
125
+ parser.add_argument(
126
+ "--projection",
127
+ choices=("none", "max-z"),
128
+ default="none",
129
+ help="Write a max-Z projection instead of the full 3D stack when input data is 3D.",
130
+ )
131
+ parser.add_argument(
132
+ "--ome-zarr-pyramid",
133
+ choices=("auto", "on", "off"),
134
+ default="auto",
135
+ help="Write XY pyramid levels for OME-Zarr output. Default: auto.",
136
+ )
115
137
  parser.add_argument("--iterations", type=_parse_int_list, default=[40])
116
138
  parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
117
139
  parser.add_argument("--background", default="auto")
@@ -161,6 +183,10 @@ def _build_parser() -> argparse.ArgumentParser:
161
183
  )
162
184
  parser.add_argument("--two-d-wf-bg-radius-um", type=float, default=0.5)
163
185
  parser.add_argument("--two-d-wf-bg-scale", type=float, default=1.0)
186
+ parser.add_argument("--save-qc-mips", dest="save_qc_mips", action="store_true", default=True)
187
+ parser.add_argument("--no-qc-mips", dest="save_qc_mips", action="store_false")
188
+ parser.add_argument("--write-manifest", dest="write_manifest", action="store_true", default=True)
189
+ parser.add_argument("--no-manifest", dest="write_manifest", action="store_false")
164
190
  parser.add_argument("-v", "--verbose", action="store_true")
165
191
  return parser
166
192
 
@@ -175,6 +201,119 @@ def _output_path(input_path: Path, output_dir: Path, output_format: str) -> Path
175
201
  return output_dir / f"{_stem(input_path)}_decon.ome.tiff"
176
202
 
177
203
 
204
+ def _apply_projection(result: dict, projection: str) -> dict:
205
+ if projection != "max-z":
206
+ return result
207
+ channels = list(result.get("channels") or [])
208
+ if not channels:
209
+ return result
210
+ if channels[0].ndim == 4 and channels[0].shape[1] > 1:
211
+ import numpy as np
212
+
213
+ projected = dict(result)
214
+ projected["channels"] = [
215
+ np.max(channel, axis=1).astype(np.float32, copy=False)
216
+ for channel in channels
217
+ ]
218
+ if result.get("source_channels"):
219
+ projected["source_channels"] = [
220
+ np.max(channel, axis=1).astype(np.float32, copy=False)
221
+ if getattr(channel, "ndim", 0) == 4 and channel.shape[1] > 1
222
+ else channel
223
+ for channel in result["source_channels"]
224
+ ]
225
+ metadata = dict(result.get("metadata") or {})
226
+ metadata["projection"] = {"axis": "z", "method": "max"}
227
+ metadata["size_z"] = 1
228
+ projected["metadata"] = metadata
229
+ return projected
230
+ if channels[0].ndim != 3 or channels[0].shape[0] <= 1:
231
+ return result
232
+
233
+ import numpy as np
234
+
235
+ projected = dict(result)
236
+ projected["channels"] = [np.max(channel, axis=0).astype(np.float32, copy=False) for channel in channels]
237
+ if result.get("source_channels"):
238
+ projected["source_channels"] = [
239
+ np.max(channel, axis=0).astype(np.float32, copy=False)
240
+ if getattr(channel, "ndim", 0) == 3 and channel.shape[0] > 1
241
+ else channel
242
+ for channel in result["source_channels"]
243
+ ]
244
+ metadata = dict(result.get("metadata") or {})
245
+ metadata["projection"] = {"axis": "z", "method": "max"}
246
+ metadata["size_z"] = 1
247
+ projected["metadata"] = metadata
248
+ return projected
249
+
250
+
251
+ def _format_metadata_value(value) -> str:
252
+ if value is None:
253
+ return "missing"
254
+ if isinstance(value, float):
255
+ return f"{value:g}"
256
+ return str(value)
257
+
258
+
259
+ def _metadata_source(metadata: dict, key: str) -> str:
260
+ provenance = metadata.get("_metadata_provenance") or {}
261
+ fields = provenance.get("fields") if isinstance(provenance, dict) else {}
262
+ return str((fields or {}).get(key) or "unknown")
263
+
264
+
265
+ def _channel_metadata_source(metadata: dict, index: int, key: str) -> str:
266
+ provenance = metadata.get("_metadata_provenance") or {}
267
+ channels = provenance.get("channels") if isinstance(provenance, dict) else []
268
+ if isinstance(channels, list) and index < len(channels) and isinstance(channels[index], dict):
269
+ return str(channels[index].get(key) or "unknown")
270
+ return "unknown"
271
+
272
+
273
+ def _print_metadata_report(metadata: dict) -> None:
274
+ fields = [
275
+ ("pixel_size_x", "pixel size X", "um"),
276
+ ("pixel_size_y", "pixel size Y", "um"),
277
+ ("pixel_size_z", "pixel size Z", "um"),
278
+ ("na", "NA", ""),
279
+ ("refractive_index", "immersion RI", ""),
280
+ ("sample_refractive_index", "sample RI", ""),
281
+ ("microscope_type", "microscope", ""),
282
+ ]
283
+ print(" metadata:")
284
+ for key, label, unit in fields:
285
+ value = _format_metadata_value(metadata.get(key))
286
+ suffix = f" {unit}" if unit else ""
287
+ print(f" {label:14}: {value}{suffix} ({_metadata_source(metadata, key)})")
288
+
289
+ defaulted = sorted(str(key) for key in metadata.get("_defaulted_keys") or [])
290
+ inferred = sorted(str(key) for key in metadata.get("_inferred_keys") or [])
291
+ warnings = [str(item) for item in metadata.get("metadata_warnings") or [] if str(item).strip()]
292
+ if defaulted:
293
+ print(" defaults : " + ", ".join(defaulted))
294
+ if inferred:
295
+ print(" inferred : " + ", ".join(inferred))
296
+ if warnings:
297
+ print(" warnings : " + " | ".join(warnings))
298
+
299
+ channels = list(metadata.get("channels") or [])
300
+ for index, channel in enumerate(channels):
301
+ name = channel.get("name") or channel.get("label")
302
+ if not name and index < len(metadata.get("channel_names") or []):
303
+ name = metadata["channel_names"][index]
304
+ print(f" channel {index}{f' ({name})' if name else ''}:")
305
+ for key, label, unit in (
306
+ ("emission_wavelength", "emission", "nm"),
307
+ ("excitation_wavelength", "excitation", "nm"),
308
+ ("pinhole_airy_units", "pinhole", "AU"),
309
+ ):
310
+ value = _format_metadata_value(channel.get(key))
311
+ print(
312
+ f" {label:10}: {value} {unit} "
313
+ f"({_channel_metadata_source(metadata, index, key)})"
314
+ )
315
+
316
+
178
317
  def _run_one(input_path: Path, output_dir: Path, args: argparse.Namespace) -> Path:
179
318
  from core.deconvolve import deconvolve_image, save_result
180
319
  from core.ome_zarr_io import is_hcs_plate, save_result_ome_zarr
@@ -212,17 +351,20 @@ def _run_one(input_path: Path, output_dir: Path, args: argparse.Namespace) -> Pa
212
351
  two_d_wf_bg_radius_um=max(0.1, float(args.two_d_wf_bg_radius_um)),
213
352
  two_d_wf_bg_scale=max(0.1, float(args.two_d_wf_bg_scale)),
214
353
  device=device,
354
+ cancel_checker=lambda: _CANCEL_REQUESTED,
215
355
  )
356
+ result = _apply_projection(result, args.projection)
357
+ _print_metadata_report(result.get("metadata") or {})
216
358
 
217
359
  out_path = _output_path(input_path, output_dir, args.output_format)
218
360
  if args.output_format == "ome-zarr":
219
- return save_result_ome_zarr(result, out_path)
361
+ return save_result_ome_zarr(result, out_path, pyramid=args.ome_zarr_pyramid)
220
362
 
221
363
  tmp_dir = output_dir / ".ci_deconvolve_tmp"
222
364
  tmp_dir.mkdir(parents=True, exist_ok=True)
223
365
  tmp_path = tmp_dir / out_path.name
224
366
  try:
225
- save_result(result, tmp_path)
367
+ save_result(result, tmp_path, save_qc_mips=bool(args.save_qc_mips))
226
368
  if out_path.exists():
227
369
  out_path.unlink()
228
370
  shutil.move(str(tmp_path), str(out_path))
@@ -231,6 +373,58 @@ def _run_one(input_path: Path, output_dir: Path, args: argparse.Namespace) -> Pa
231
373
  return out_path
232
374
 
233
375
 
376
+ def _validate_environment() -> int:
377
+ print("CIDECONVOLVE_OK")
378
+ print(f"ci_deconvolve={__version__}")
379
+ print(f"python={sys.version.split()[0]}")
380
+ try:
381
+ import torch
382
+ print(f"torch={getattr(torch, '__version__', 'unknown')}")
383
+ print(f"cuda_available={bool(torch.cuda.is_available())}")
384
+ print(f"cuda_device_count={torch.cuda.device_count() if torch.cuda.is_available() else 0}")
385
+ except Exception as exc:
386
+ print(f"torch_error={type(exc).__name__}: {exc}")
387
+ return 1
388
+ try:
389
+ import zarr
390
+ print(f"zarr={getattr(zarr, '__version__', 'unknown')}")
391
+ print("ome_zarr_v2_write=ok")
392
+ except Exception as exc:
393
+ print(f"zarr_error={type(exc).__name__}: {exc}")
394
+ return 1
395
+ return 0
396
+
397
+
398
+ def _install_signal_handlers() -> None:
399
+ def _handler(signum, frame):
400
+ global _CANCEL_REQUESTED
401
+ _CANCEL_REQUESTED = True
402
+ print(f"Cancellation requested by signal {signum}.", file=sys.stderr)
403
+
404
+ for sig_name in ("SIGINT", "SIGTERM"):
405
+ sig = getattr(signal, sig_name, None)
406
+ if sig is not None:
407
+ try:
408
+ signal.signal(sig, _handler)
409
+ except Exception:
410
+ pass
411
+
412
+
413
+ def _write_manifest(output_dir: Path, records: list[dict], args: argparse.Namespace) -> None:
414
+ manifest = {
415
+ "ci_deconvolve_version": __version__,
416
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
417
+ "output_format": args.output_format,
418
+ "projection": args.projection,
419
+ "iterations": args.iterations,
420
+ "records": records,
421
+ }
422
+ (output_dir / "ci_deconvolve_manifest.json").write_text(
423
+ json.dumps(manifest, indent=2, default=str),
424
+ encoding="utf-8",
425
+ )
426
+
427
+
234
428
  def run(argv: Iterable[str] | None = None) -> int:
235
429
  parser = _build_parser()
236
430
  args = parser.parse_args(list(argv) if argv is not None else None)
@@ -239,6 +433,12 @@ def run(argv: Iterable[str] | None = None) -> int:
239
433
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
240
434
  )
241
435
 
436
+ if args.validate_env:
437
+ return _validate_environment()
438
+ if not args.output:
439
+ parser.error("--output is required unless --validate-env is used")
440
+ _install_signal_handlers()
441
+
242
442
  input_value = args.input_option or args.input_path
243
443
  if not input_value:
244
444
  parser.error("an input path is required via --input or positional input_path")
@@ -254,20 +454,50 @@ def run(argv: Iterable[str] | None = None) -> int:
254
454
  print(f" input count : {len(inputs)}")
255
455
  print(f" output : {output_dir}")
256
456
  print(f" output format: {args.output_format}")
457
+ print(f" projection : {args.projection}")
257
458
  print(f" iterations : {', '.join(str(v) for v in args.iterations)}")
258
459
 
259
460
  failures: list[tuple[Path, Exception]] = []
461
+ records: list[dict] = []
260
462
  for index, item in enumerate(inputs, start=1):
463
+ if _CANCEL_REQUESTED:
464
+ print("\nCancelled before remaining inputs.", file=sys.stderr)
465
+ return 130
261
466
  print(f"\n[{index}/{len(inputs)}] {item}")
262
467
  start = time.time()
263
468
  try:
264
469
  out_path = _run_one(item, output_dir, args)
265
470
  print(f" saved: {out_path}")
266
- print(f" time : {time.time() - start:.1f}s")
471
+ elapsed = time.time() - start
472
+ print(f" time : {elapsed:.1f}s")
473
+ records.append({
474
+ "input": str(item),
475
+ "output": str(out_path),
476
+ "status": "success",
477
+ "seconds": elapsed,
478
+ })
267
479
  except Exception as exc:
480
+ if _CANCEL_REQUESTED or str(exc) == "Cancelled":
481
+ elapsed = time.time() - start
482
+ print(" CANCELLED", file=sys.stderr)
483
+ records.append({
484
+ "input": str(item),
485
+ "status": "cancelled",
486
+ "seconds": elapsed,
487
+ })
488
+ if args.write_manifest:
489
+ _write_manifest(output_dir, records, args)
490
+ return 130
268
491
  failures.append((item, exc))
269
492
  print(f" ERROR: {exc}", file=sys.stderr)
270
-
493
+ records.append({
494
+ "input": str(item),
495
+ "status": "failed",
496
+ "error": str(exc),
497
+ })
498
+
499
+ if args.write_manifest:
500
+ _write_manifest(output_dir, records, args)
271
501
  if failures:
272
502
  print(f"\nFailed inputs: {len(failures)}", file=sys.stderr)
273
503
  for item, exc in failures: