Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
49b15fa
ci: drop Python 3.11 from matrix and trigger on modular-viscy-staging
edyoshikun May 23, 2026
7540a5d
fix(deps): declare viscy-transforms test dep and hydra-core for dynacell
edyoshikun May 23, 2026
0de2ad9
fix(dynaclr): rename test_2d_mip_augmentation.py to demo_; scope pyte…
edyoshikun May 23, 2026
6147447
style: satisfy pre-commit (ruff format, EOF, D100/D102, E741, NPY002)
edyoshikun May 23, 2026
789409b
Merge origin/modular-viscy-staging into ci/staging-green-up
edyoshikun May 28, 2026
052ba88
fix(dynaclr): unpack tuple from ExperimentRegistry.from_cell_index in…
edyoshikun Jun 1, 2026
e93c31d
fix(dynacell): point predict-leaf test at split celldiff inference co…
edyoshikun Jun 1, 2026
2a70a61
style: ruff format PREDICT_LEAF_FILES comprehension
edyoshikun Jun 1, 2026
7afcca6
fix(viscy-data): pin iohub codec pipeline to zarr-python, not zarrs
edyoshikun Jun 1, 2026
eec3885
fix(dynacell): lazy-import torch_fidelity in feature_metrics
edyoshikun Jun 1, 2026
b4e5339
fix(dynaclr): repoint class-path-resolution tests at demo configs
edyoshikun Jun 1, 2026
69bf3e9
fix(dynacell): align 4gpu-constraint test with hardware_4gpu.yml (h10…
edyoshikun Jun 1, 2026
8ca1529
fix(viscy-data): make mmap-sharing test child picklable for spawn
edyoshikun Jun 1, 2026
d230bb1
fix(dynacell): reconcile benchmark-config tests with split/redesigned…
edyoshikun Jun 2, 2026
06ddf08
fix(viscy-data): use fork context + Windows skip for mmap-sharing test
edyoshikun Jun 2, 2026
89b2f8a
test(dynaclr): import NTXentLoss from viscy_models
srivarra Jun 2, 2026
a261dd8
test(dynaclr): use bag-of-channels for heterogeneous experiments
srivarra Jun 2, 2026
da8cdbd
test(dynaclr): repoint multi-experiment parquet test at cell_index_pa…
edyoshikun Jun 2, 2026
2060622
merge: integrate modular-viscy-staging (#429 eval refactor) into gree…
edyoshikun Jun 2, 2026
8697c11
deps: regenerate uv.lock after green-up merge
edyoshikun Jun 2, 2026
83971c9
merge: sync remote green-up dynaclr test fixes
edyoshikun Jun 2, 2026
146ac39
fix(dynacell): green up dynacell-configs CI (run_for_group import + a…
alxndrkalinin Jun 2, 2026
f296635
fix(dynacell/eval): unblock dynacell-configs CI (lazy cubic import) +…
alxndrkalinin Jun 2, 2026
b883a5e
fix(ci): install dynacell eval extra instead of hiding cubic imports
alxndrkalinin Jun 2, 2026
3e5005b
refactor(dynacell/eval): restore module-level torch_fidelity imports
alxndrkalinin Jun 2, 2026
8f60469
fix(dynacell/eval): don't hard-require cupy/cucim; use cubic CPU fall…
alxndrkalinin Jun 2, 2026
c612946
fix(dynacell/eval): resolve NPZ path for the embedding cache key
alxndrkalinin Jun 2, 2026
2dc9750
buump iohub 0.3.6
edyoshikun Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Lint

on:
push:
branches: [main]
branches: [main, modular-viscy-staging]
pull_request:
branches: [main]
branches: [main, modular-viscy-staging]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
33 changes: 19 additions & 14 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Test

on:
push:
branches: [main]
branches: [main, modular-viscy-staging]
pull_request:
branches: [main]
branches: [main, modular-viscy-staging]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -18,7 +18,7 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
python-version: ["3.12", "3.13"]
package: [viscy-transforms, viscy-models]

steps:
Expand Down Expand Up @@ -47,7 +47,7 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
python-version: ["3.12", "3.13"]

steps:
- name: Checkout repository
Expand Down Expand Up @@ -133,19 +133,24 @@ jobs:
enable-cache: true
cache-suffix: ubuntu-latest-3.13

- name: Install minimal dynacell (base deps + test group)
run: uv sync --frozen --group test
- name: Install dynacell with eval deps + test group (CPU)
# The eval-pipeline tests import dynacell.evaluation.pipeline, a hard
# consumer of the eval stack (cubic, cellpose, …). cubic is CPU-capable
# (falls back to numpy/scikit-image without CUDA), so the eval extra
# installs and runs on a GPU-less runner. eval_gpu (cupy/cucim) is NOT
# installed — those are the only CUDA-only deps.
run: uv sync --frozen --extra eval --group test
working-directory: applications/dynacell

- name: Run benchmark-schema + submit-tool + eval-runtime tests
# tests/test_runtime.py + tests/test_evaluation_pipeline_parallel.py
# cover the dynacell.evaluation.runtime module + FovResult pickle
# contract. tests/test_evaluation_pipeline_parallel_cpu.py drives
# evaluate_predictions end-to-end (serial vs spawn-process) on a
# tiny iohub fixture + prebuilt mask cache — no eval extras (no
# cellpose, transformers, cubic) needed since target_name=er +
# require_complete_cache=true short-circuit segmenter and feature
# extractor loads. tests/test_evaluation_grouped.py drives the
# test_benchmark_config_composition + test_submit_benchmark_job cover
# config composition + the launcher. test_runtime + the two
# test_evaluation_pipeline_parallel* suites cover the runtime module,
# the FovResult pickle contract, and evaluate_predictions end-to-end
# (serial vs spawn-process) on a tiny iohub fixture + prebuilt mask
# cache (target_name=er + require_complete_cache=true short-circuit the
# segmenter + feature-extractor model loads — but pipeline import still
# needs the eval stack). test_evaluation_grouped drives the
# multi-condition driver against the same cache-only fixture.
run: |
uv run --frozen pytest \
Expand Down
2 changes: 1 addition & 1 deletion applications/airtable/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
dynamic = [ "version" ]
dependencies = [
"click",
"iohub",
"iohub>=0.3.6",
"pandas",
"pyairtable",
"pydantic",
Expand Down
5 changes: 3 additions & 2 deletions applications/dynacell/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ classifiers = [
]
dynamic = [ "version" ]
dependencies = [
"hydra-core>=1.2",
"lightning>=2.3",
"monai",
"omegaconf",
Expand All @@ -46,7 +47,7 @@ optional-dependencies.eval = [
"cubic==0.7.0a9",
"dynaclr",
"hydra-core>=1.2",
"iohub",
"iohub>=0.3.6",
"itk",
"matplotlib",
"pandas",
Expand All @@ -72,7 +73,7 @@ optional-dependencies.eval_gpu = [
"cupy-cuda13x",
]
optional-dependencies.preprocess = [
"iohub",
"iohub>=0.3.6",
"tqdm",
]
optional-dependencies.report = [
Expand Down
136 changes: 112 additions & 24 deletions applications/dynacell/src/dynacell/evaluation/cross_condition_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@
_SOURCES = ("pred", "gt")
_CONDITION_TOKENS = ("mock", "denv", "zikv")
_DEFAULT_PAIRS = (("mock", "denv"), ("mock", "zikv"))
_FIELDNAMES = (
"feature_type",
"pair",
"source",
"n_cells_c0",
"n_cells_c1",
"n_fovs",
"n_folds",
"auroc_mean",
"auroc_std",
"skipped_reason",
)
#: Filename written into each infected condition's eval dir by :func:`run_for_group`.
GROUP_PROBE_FILENAME = "cross_condition_probe.csv"


def _detect_condition(eval_dir: Path) -> str:
Expand All @@ -51,14 +65,31 @@ def _detect_condition(eval_dir: Path) -> str:
raise ValueError(f"cannot infer condition from eval_dir name {name!r}: expected trailing _{{mock,denv,zikv}}")


def _load_embeddings(eval_dir: Path, source: str, feature: str) -> tuple[np.ndarray, np.ndarray]:
def _load_embeddings(
eval_dir: Path,
source: str,
feature: str,
cache: dict[Path, tuple[np.ndarray, np.ndarray]] | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Return ``(embeddings, fov_ids)`` from one ``*_single_cell_embeddings.npz``.

``np.load`` raises ``FileNotFoundError`` when the NPZ is missing.
``np.load`` raises ``FileNotFoundError`` when the NPZ is missing. When
*cache* is given, the result is memoized on the resolved NPZ path so the
shared reference (``mock``) side is read from disk once per group instead
of once per pair (see :func:`run` / :func:`run_for_group`). The key is
resolved so the same file reached via different relative paths/symlinks
still hits the cache.
"""
npz_path = eval_dir / "embeddings" / f"{source}_{feature}_single_cell_embeddings.npz"
# ``.resolve()`` (strict=False) canonicalizes the key without requiring the
# file to exist — np.load still raises FileNotFoundError below if it's missing.
npz_path = (eval_dir / "embeddings" / f"{source}_{feature}_single_cell_embeddings.npz").resolve()
if cache is not None and npz_path in cache:
return cache[npz_path]
with np.load(npz_path) as data:
Comment thread
alxndrkalinin marked this conversation as resolved.
return np.asarray(data["embeddings"]), np.asarray(data["fov"])
result = (np.asarray(data["embeddings"]), np.asarray(data["fov"]))
if cache is not None:
cache[npz_path] = result
return result


def _probe_pair(
Expand All @@ -68,6 +99,7 @@ def _probe_pair(
source: str,
n_splits: int,
rng_seed: int,
cache: dict[Path, tuple[np.ndarray, np.ndarray]] | None = None,
) -> dict:
"""Run one ``fov_stratified_auroc`` call for the given (pair, feature, source).

Expand All @@ -92,8 +124,8 @@ def _probe_pair(
row["skipped_reason"] = "missing eval dir for one side of pair"
return row
try:
x0, fov0 = _load_embeddings(eval_dirs_by_condition[c0], source, feature)
x1, fov1 = _load_embeddings(eval_dirs_by_condition[c1], source, feature)
x0, fov0 = _load_embeddings(eval_dirs_by_condition[c0], source, feature, cache)
x1, fov1 = _load_embeddings(eval_dirs_by_condition[c1], source, feature, cache)
except FileNotFoundError as e:
row["skipped_reason"] = f"missing embeddings file: {e}"
return row
Expand Down Expand Up @@ -135,6 +167,75 @@ def _probe_pair(
return row


def _write_rows(out_path: Path, rows: list[dict]) -> None:
"""Write probe rows as a CSV with the canonical field order."""
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=_FIELDNAMES)
writer.writeheader()
writer.writerows(rows)


def run_for_group(
eval_dirs: list[Path],
n_splits: int = 5,
rng_seed: int = 2020,
) -> list[Path]:
"""Probe each infected condition against mock and write a per-condition CSV.

Unlike :func:`run` (long-form CSV over all pairs at one ``out_path``),
this writes one :data:`GROUP_PROBE_FILENAME` into *each infected
condition's* eval dir, holding only that condition's ``mock_vs_<cond>``
rows (every feature × {pred, gt}). This colocates the probe with the eval
dir the reporting layer already resolves per (model, pool, organelle,
condition), so the table generator can read it without knowing about
sibling conditions.

Requires a ``mock`` reference dir plus at least one infected dir; returns
the list of CSV paths written (empty when the group has no mock or no
infected condition, e.g. the in-distribution iPSC eval).

Parameters
----------
eval_dirs : list[Path]
Per-condition eval dirs of one (model, pool, organelle) group. The
condition is inferred from each dir's trailing ``_{mock,denv,zikv}``;
dirs without a recognized token are ignored. Two dirs mapping to the
same condition raise ``ValueError`` (an ambiguous group) rather than
silently picking one.
n_splits, rng_seed : int
Forwarded to :func:`fov_stratified_auroc`.
"""
by_condition: dict[str, Path] = {}
for d in eval_dirs:
try:
cond = _detect_condition(d)
except ValueError:
continue
if cond in by_condition:
raise ValueError(f"duplicate condition {cond!r}: {by_condition[cond]} and {d}")
by_condition[cond] = d
if "mock" not in by_condition:
return []

# Shared across pairs so the mock reference embeddings are read once, not
# re-read for every infected condition. Local to this call -> released on return.
cache: dict[Path, tuple[np.ndarray, np.ndarray]] = {}
written: list[Path] = []
for ref, cond in _DEFAULT_PAIRS: # ref == "mock" for every default pair
if cond not in by_condition:
continue
rows = [
_probe_pair(by_condition, (ref, cond), feature, source, n_splits, rng_seed, cache)
for feature in _FEATURE_TYPES
for source in _SOURCES
]
out_path = by_condition[cond] / GROUP_PROBE_FILENAME
_write_rows(out_path, rows)
written.append(out_path)
return written


def run(
eval_dirs: list[Path],
out_path: Path,
Expand Down Expand Up @@ -163,29 +264,16 @@ def run(
raise ValueError(f"duplicate condition {cond!r}: {eval_dirs_by_condition[cond]} and {d}")
eval_dirs_by_condition[cond] = d

# Shared across pairs so a condition's embeddings (e.g. the mock reference
# reused by every pair) are read once. Local to this call -> released on return.
cache: dict[Path, tuple[np.ndarray, np.ndarray]] = {}
rows = []
for feature in _FEATURE_TYPES:
for pair in pairs:
for source in _SOURCES:
rows.append(_probe_pair(eval_dirs_by_condition, pair, feature, source, n_splits, rng_seed))
rows.append(_probe_pair(eval_dirs_by_condition, pair, feature, source, n_splits, rng_seed, cache))

out_path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"feature_type",
"pair",
"source",
"n_cells_c0",
"n_cells_c1",
"n_fovs",
"n_folds",
"auroc_mean",
"auroc_std",
"skipped_reason",
]
with out_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
_write_rows(out_path, rows)
return out_path


Expand Down
12 changes: 4 additions & 8 deletions applications/dynacell/src/dynacell/evaluation/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,14 @@ def _require_skimage():


def _require_cubic():
# cubic only: callers fall back to the numpy/CPU path when no GPU is
# present, so do not hard-require the eval_gpu (cupy/cucim) stack here — that
# would block CPU-only use. ``ascupy`` raises a clear error if a GPU upload
# is attempted without cupy.
if ascupy is None:
raise ImportError(
"cubic is required for GPU array operations. Install via the `eval` extra: `uv sync --extra eval`."
)
try:
import cucim # noqa: F401
import cupy # noqa: F401
except ImportError as e:
raise ImportError(
f"{e.name} is required for GPU-backed I/O. Install cupy-cuda12x "
"and cucim-cu12 via the `eval_gpu` extra: `uv sync --extra eval_gpu`."
) from e


def _is_zarr_path(path: Path) -> bool:
Expand Down
14 changes: 6 additions & 8 deletions applications/dynacell/src/dynacell/evaluation/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,17 @@


def _require_cubic():
# Only cubic itself is required: the metric helpers below gate the GPU
# upload on ``torch.cuda.is_available()`` and otherwise run on numpy, where
# cubic dispatches to its CPU (numpy / scikit-image) path. cucim / cupy are
# needed only when a GPU is actually present and used — and ``ascupy`` raises
# a clear "GPU requested but not available" there if they are missing — so
# this must NOT hard-require the eval_gpu stack (it would block the CPU path).
if ascupy is None:
raise ImportError(
"cubic is required for resolution and feature metrics. "
"Install via the `eval` extra: `uv sync --extra eval`."
)
try:
import cucim # noqa: F401
import cupy # noqa: F401
except ImportError as e:
raise ImportError(
f"{e.name} is required for GPU-backed metrics. Install cupy-cuda12x "
"and cucim-cu12 via the `eval_gpu` extra: `uv sync --extra eval_gpu`."
) from e


@torch.inference_mode()
Expand Down
Loading
Loading