Source code for itasc.contact_analysis.contacts.batch
"""Name-based batch discovery and headless contact-analysis builds.
The standalone contact piece is visualizer-first; the ``.h5`` it shows is a pure
derived artifact of the label inputs. This module lets users pre-compute that
artifact for many positions at once: name the three files (cell labels, optional
nucleus labels, output ``.h5``), point at one top-level folder, and every folder
that contains a cell-labels file becomes one job. A nucleus file is associated
only when it sits in that same folder; otherwise the job runs cell-only. Outputs
that already exist are skipped unless ``overwrite`` is set.
Position-agnostic and napari-free: shipped by the ``itasc-aggregate`` wheel.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from .build import ensure_contacts
__all__ = [
"ContactBatchJob",
"ContactBatchResult",
"discover_contact_batch_jobs",
"run_contact_batch",
]
[docs]
@dataclass(frozen=True)
class ContactBatchJob:
"""One discovered contact-analysis build: a cell-labels file and where its
output goes, with an optional co-located nucleus-labels file."""
group_dir: Path
cell_labels: Path
output: Path
nucleus_labels: Path | None = None
[docs]
@dataclass(frozen=True)
class ContactBatchResult:
"""Outcome of a single :class:`ContactBatchJob`.
``status`` is ``"built"`` (computed), ``"skipped"`` (output already present),
or ``"failed"`` (``error`` holds the message).
"""
job: ContactBatchJob
status: str
error: str | None = None
def _top_level_dir(path: Path, root: Path) -> Path:
"""The position folder a discovered file belongs to: the first directory
component under ``root`` (or ``root`` itself for files directly in it)."""
rel = path.relative_to(root)
return root / rel.parts[0] if len(rel.parts) > 1 else root
[docs]
def discover_contact_batch_jobs(
root: str | Path,
*,
cell_name: str,
h5_name: str,
nucleus_name: str | None = None,
) -> list[ContactBatchJob]:
"""Find contact-analysis jobs under ``root`` by file name.
Files are discovered recursively, then grouped by **position folder** — the
first directory under ``root`` on the path to each match (``root`` itself for
files lying directly in it). The named files of one position may live in
different subfolders (e.g. ``pos01/3_cell/`` and ``pos01/2_nucleus/``); they
are still grouped together.
For a position with exactly one cell-labels file, that file is one job whose
output is ``<position>/<h5_name>``; the nucleus is associated only when
exactly one ``nucleus_name`` file exists anywhere in that position (zero or
several → cell-only, "can't associate → don't assign"). A position holding
several cell-labels files is ambiguous to group, so each falls back to a
cell-only job written beside its own cell file. Jobs are sorted by cell path.
"""
root = Path(root)
cells = [c for c in root.rglob(cell_name) if c.is_file()]
groups: dict[Path, list[Path]] = {}
for cell in cells:
groups.setdefault(_top_level_dir(cell, root), []).append(cell)
jobs: list[ContactBatchJob] = []
for position, position_cells in groups.items():
if len(position_cells) == 1:
cell = position_cells[0]
nucleus: Path | None = None
if nucleus_name:
matches = [
n for n in position.rglob(nucleus_name) if n.is_file()
]
if len(matches) == 1:
nucleus = matches[0]
jobs.append(
ContactBatchJob(
group_dir=position,
cell_labels=cell,
output=position / h5_name,
nucleus_labels=nucleus,
)
)
else:
# Several cell files share a position folder: too ambiguous to pair a
# single nucleus or a single output, so keep them independent.
for cell in position_cells:
jobs.append(
ContactBatchJob(
group_dir=cell.parent,
cell_labels=cell,
output=cell.parent / h5_name,
nucleus_labels=None,
)
)
return sorted(jobs, key=lambda job: job.cell_labels)
[docs]
def run_contact_batch(
jobs: Iterable[ContactBatchJob],
*,
overwrite: bool = False,
edge_extraction_params: dict | None = None,
progress_cb: Callable[[int, int, str], None] | None = None,
cancel: Callable[[], bool] | None = None,
) -> list[ContactBatchResult]:
"""Build the contact-analysis HDF5 for each job, collecting per-job outcomes.
A single job's failure never aborts the run — it is recorded as a ``failed``
result and the batch continues. ``cancel`` is polled before each job;
``progress_cb(done, total, label)`` is reported after each.
"""
jobs = list(jobs)
total = len(jobs)
results: list[ContactBatchResult] = []
for index, job in enumerate(jobs):
if cancel is not None and cancel():
break
try:
_, built = ensure_contacts(
cell_labels_path=job.cell_labels,
output_path=job.output,
nucleus_labels_path=job.nucleus_labels,
overwrite=overwrite,
edge_extraction_params=edge_extraction_params,
)
results.append(
ContactBatchResult(job, "built" if built else "skipped")
)
except Exception as exc:
results.append(ContactBatchResult(job, "failed", str(exc)))
if progress_cb is not None:
progress_cb(index + 1, total, job.group_dir.name)
return results