Skip to content

feat(dynacell): foreground-limited pixel metrics (opt-in) - #517

Merged
alxndrkalinin merged 19 commits into
dynacell-modelsfrom
dynacell-foreground-metrics
Oct 2, 2026
Merged

alxndrkalinin merged 19 commits into
dynacell-modelsfrom
dynacell-foreground-metrics

Conversation

@alxndrkalinin

Copy link
Copy Markdown
Collaborator

Summary

Adds opt-in foreground-limited pixel metrics to the dynacell evaluation: FG_PCC, FG_SI_SSIM, FG_SI_NRMSE, FG_SI_PSNR and FG_frac in pixel_metrics.csv, behind a new pixel_metrics.foreground block that is off by default. With it off, the pixel/mask CSVs, NPYs, provenance file and a GPU spectral run are byte-identical to ba71f4cb.

Motivation: Spotlight v1 (arXiv:2507.05383) reports PSNR/MicroMS3IM/FRC on the foreground because the background dominates the voxel count. Its evaluation code zeroes the background of both images and runs the whole-volume metric. That creates sharp edges, and it inflates PSNR by exactly 10·log10(1/FG_frac). This PR measures the foreground region without modifying the images.

Design

  • Foreground weight w, from the GT only, so every model is scored on the same region.
    • Gaussian smoothing (0.5 µm for nucleus, membrane and mito; 1.0 µm for ER), then Otsu on the smoothed volume, then a 0.5 µm Gaussian feather, giving w in [0, 1].
    • Zero-padded GT planes are excluded from the threshold.
    • Defaults are in a code table keyed by target_name. Grouped leaves set target_name directly and never load the target config group.
    • Raw Otsu (source: otsu, Spotlight's mask) is available for comparison.
  • Pointwise metrics:
    • FG_PCC is a w-weighted Pearson.
    • FG_SI_NRMSE and FG_SI_PSNR use a w-weighted scale-invariant affine fit and weighted MSE, with data_range taken from the GT inside the hard foreground.
  • FG_SI_SSIM: normalized (partial) convolution SSIM in the spirit of dycheck (Gao et al. 2022).
    • Local moments come from w-weighted Gaussian windows, normalized by the local weight mass.
    • The SSIM map is averaged with w, using skimage's border crop.
    • There is no image zeroing and no window erosion. cubic's eroded masked mean is empty for structures 1–9 voxels wide.
  • Invariant (tested): with w ≡ 1, the FG columns reproduce PCC/SI_SSIM/SI_NRMSE/SI_PSNR to ≤ 7e-16 in float64 on a real A549 volume (GPU and CPU).
  • Cache: the foreground recipe is recorded in the metrics provenance. The cache check requires the FG columns when the block is enabled, so a parameter change forces a recompute; a missing recipe also recomputes.
  • Other:
  • Deferred: MicroMS3IM (scalar-only and fit per leaf) and the spectral metrics, because a mask correlates the two images at high frequency.

Validation

  • Synthetic tests:
    • Identical foreground with a noisy background: FG gives 1.0 PCC and 1.0 SSIM, against 0.62 / 0.44 whole-image.
    • Error confined to the foreground: FG drops further than whole-image (SSIM 0.86 vs 0.98).
    • Uncorrelated texture inside identical blobs: Spotlight's zeroed background gives PCC 0.996 and SSIM 0.993. Ours, on the same raw Otsu mask, gives −0.03 and 0.008.
  • Real data (50 iPSC lite FOVs and 12 A549 mock FOVs × 3 timepoints; nucleus and membrane; Spotlight-v2 segaux vs baseline pairs):
    • Whole-image and FG verdicts differ in 14 of 24 cells, never with opposite significant signs.
    • FG is a different readout, not a steadier one, so Spotlight's "whole worsens, FG consistent" pattern does not reproduce.
  • Cost: about +0.12 s per 48×640×960 volume and +113 MiB GPU memory on an A40.

Caveats

  • The membrane foreground is intensity-defined: bright puncta plus the basal sheet on iPSC, with lateral walls only partly included. It is a "signal region", not a membrane mask.
  • The 0.5 µm feather also weights object edges, so FG columns reward correct placement as well as interior fidelity; setting feather_sigma_um: 0 scores the interior only.
  • No full dynacell evaluate run with FG on has been done yet. It is covered by the cache-only pipeline fixture and offline scoring.
  • Pre-existing and unrelated: three monkeypatch tests in provenance_test.py are order-dependent when run in one process after live_pipeline_module() users. The default testpaths never hits this.

Tests

  • Full dynacell suite in the main venv (-n 11): 3568 passed, 175 skipped, 0 failed.
  • New and touched test files, rerun independently: 58 passed, pytest rc 0.
  • Eval venv, serial: 60 passed, plus provenance_test 23 passed when run alone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J

alxndrkalinin and others added 7 commits October 2, 2026 00:08
foreground_weight builds a soft [0, 1] weight map from the GT alone
(smooth_otsu: Gaussian smooth -> Otsu -> Gaussian feather, physical
sigmas; otsu: Spotlight v1's raw Otsu mask). foreground_pixel_metrics
scores FG_PCC, FG_SI_NRMSE, FG_SI_PSNR and FG_SI_SSIM against it with a
weighted least-squares affine fit, and FG_SI_SSIM by normalized
convolution, so nothing is zeroed or eroded. compute_pixel_metrics adds
the columns only when given a foreground config.

A unit weight reproduces PCC/SI_SSIM/SI_NRMSE/SI_PSNR to <= 1e-6 on the
golden crop. Empty foreground -> NaN with FG_frac 0; a constant
prediction scores FG_PCC 0 instead of vanishing from a mean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
…split

Four of the 24 lite A549 SEC61B/TOMM20 mock GT FOVs start with two
all-zero planes. Otsu then splits padding from signal and calls ~94% of
the volume foreground, and smoothing blurs the padding into its
neighbours, which form the same outlier class (0.90 at 0.5 um).
Exactly constant planes are now left out of the threshold and of the
foreground, and smoothing averages over signal planes only (normalized
convolution) when padding is present; otherwise it is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
pixel_metrics.foreground {enabled: false, source, smooth_sigma_um,
feather_sigma_um} turns on the FG_* columns. Null sigmas take the
per-target defaults chosen on DynaCell-lite figures
(FOREGROUND_SIGMAS_UM: 0.5 um smoothing for nucleus, membrane and
mitochondria, 1.0 um for ER; 0.5 um feather). The resolved recipe is
stamped in metrics_provenance.json, and the final-metrics cache gate
requires both the matching stamp and the FG_* columns, so turning the
columns on or off, or changing a sigma, recomputes. The eval prints how
many (FOV, t) rows had no GT foreground.

Off by default: pixel/mask CSV+NPY and the provenance sidecar are
byte-identical to ba71f4c on the cache-only fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
FG_PCC, FG_SI_SSIM and FG_SI_PSNR join HIGHER_IS_BETTER, so to_latex
bolds them in the direction of their whole-image counterparts
(FG_SI_NRMSE stays lower-is-better). They stay out of PIXEL_METRICS:
default tables are unchanged and FG columns appear only when asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
In float64 a unit weight reproduces PCC/SI_SSIM/SI_NRMSE/SI_PSNR to
machine precision (<= 7e-16 on a real A549 volume, CPU and GPU), so the
golden-crop invariant now runs at 1e-12 there and at 1e-6 in float32,
where both sides only round. Mutating the SSIM window (truncate 4.0) or
K2 by 3e-6 relative fails both cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
…e feather weighs

dycheck normalizes by the mask-pixel count and averages over every pixel;
FG_SI_SSIM normalizes by the Gaussian weight mass and averages with the
weight. A feathered weight also weighs the object edges, so the FG
columns reward object placement as well as interior agreement
(synthetic: uncorrelated interior texture inside the right blobs scores
FG_PCC 0.83 with the 0.5 um feather, -0.03 on a hard mask).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Foreground edge cases can include padded planes or terminate evaluation with division-by-zero errors.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Adds opt-in foreground-limited pixel metrics to DynaCell evaluation.

Changes:

  • Implements weighted foreground metric computation.
  • Adds configuration, provenance, and cache invalidation.
  • Adds reporting support and comprehensive tests.
File Description
test_reporting_tables_extended.py Tests foreground metric ranking.
test_evaluation_pipeline.py Updates pipeline stubs.
test_evaluation_foreground_pipeline.py Tests pipeline integration and caching.
test_evaluation_foreground_metrics.py Tests foreground metric behavior.
reporting/​tables.py Defines foreground ranking direction.
evaluation/​README.md Documents the feature.
evaluation/​provenance.py Records foreground recipes.
evaluation/​provenance_test.py Tests provenance matching.
evaluation/​pipeline.py Integrates foreground scoring and cache validation.
evaluation/​metrics.py Implements foreground weights and metrics.
evaluation/​_configs/​eval.yaml Adds default-off configuration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

alxndrkalinin and others added 12 commits October 2, 2026 02:27
The unit-weight test cannot tell how the weight enters the metrics: with a
weight of 1 everywhere, dropping the local weight-mass normalization in the
SSIM, averaging the SSIM map unweighted over w > 0, or using an unweighted
Pearson over w > 0 all give the same values. Score a small volume with a
weight spanning [0, 1] against an independent per-voxel implementation of the
definitions (weighted Gaussian-window moments normalized by local weight mass,
weighted map average over skimage's crop, weighted Pearson, weighted affine
fit). Each of those three mutations now fails the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
…SIM border crop

_weighted_ssim divided by the weight left after skimage's border crop. A GT
whose foreground lies entirely in the outer planes (e.g. a bright patch in
z 0-2 of a 16-plane stack) leaves no weight there and raised
ZeroDivisionError, killing the FOV. Return NaN for FG_SI_SSIM in that case;
the other FG_* columns do not crop and stay defined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
foreground_weight zeroed the padded planes in the binary mask, then
feathered it, which blurred the neighbouring planes' foreground back into
the padding (up to ~0.4 weight per voxel, 8-13% of the total weight with
three padded planes). Those planes carry no GT signal, so the FG_* columns
scored the prediction against padding. Zero them again after feathering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
…eground

A target constant where weight > 0.5 but varying in the feather passes the
weighted-std guard, so data_range came out 0 and FG_SI_NRMSE raised
ZeroDivisionError. The SI columns are relative to that range and are
undefined there; return NaN for them, as the degenerate-input policy says,
and keep FG_PCC, which the weighted std still defines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
…ance floor

_CONSTANT_VARIANCE = 1e-12 compared the raw prediction variance with an
absolute threshold, so FG_PCC was not affine invariant: a correct centred
prediction scaled by 1e-7 scored FG_PCC 0 instead of its unscaled value.
Call the prediction constant only when every voxel with weight > 0 holds the
same value (or the weighted variance underflows to 0), which is
scale-free and still scores a collapsed prediction FG_PCC 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
foreground_weight ignores smooth_sigma_um under source "otsu", yet
_foreground_settings still resolved it from FOREGROUND_SIGMAS_UM and stamped
it: an otsu run on a target with no default raised for a value it never
uses, and changing that unused value invalidated the cache. Resolve and
stamp smooth_sigma_um only for smooth_otsu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
An unknown pixel_metrics.foreground.source or a negative sigma was only
rejected inside foreground_weight, i.e. per FOV after the segmenter and
feature extractors had loaded. Validate both in _foreground_settings and
resolve it first thing in _final_metrics_cache_valid, ahead of its early
returns: evaluate_model and the grouped driver both call that gate before
any model load, so a bad recipe now fails in seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
…und recipe

The pixel_foreground stamp held only {source, smooth_sigma_um,
feather_sigma_um}, but FG_* values also depend on dynacell code (padding
exclusion, hard threshold, SSIM window and crop, affine fit, data range)
and on pixel_metrics.spacing, which turns the physical sigmas into voxel
sigmas. A fix to that code, or a spacing change, would have reused caches
scored the old way. Add FOREGROUND_METRICS_VERSION to metrics.py (bump on
any value-changing change) and stamp it and the spacing beside the recipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
metrics_provenance_matches required the recorded pixel_foreground to equal
the run's, so a run with the columns off rejected a cache written with them
on and reran the whole final-metrics pass for nothing: every column it asks
for is there, and the extra FG_* columns cost it nothing. Compare the stamp
only when the run asks for FG columns. The README and eval.yaml also said
toggling the block recomputes "the pixel metrics"; the gate reruns the whole
final-metrics pass, and only turning it on or changing it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
A grouped condition overlay could set pixel_metrics.foreground.*, so one
bucket could hold conditions scored with and without the FG_* columns, or
with different recipes, and a cross-condition table would compare FG values
over different regions without any error. Require every condition to share
the base config's pixel_metrics.foreground, checked beside the
model-loading invariants. Only that block: pixel_metrics.spacing still
follows each condition's dataset_ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
The README and the pipeline test said the FG_* columns are appended, but
compute_pixel_metrics adds them right after the SI_* columns, so in a
production row they precede Spectral_PCC, the FSC columns, PerCell_* and
MicroMS3IM. State the real position, and make the test check only that the
row holds every previous column plus the FG_* ones; it passed by order only
because its fixture computes none of the later columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
FG_frac is the GT foreground fraction, a descriptor of the evaluation data
that is identical across models scored on the same GT, but to_latex treated
every column outside HIGHER_IS_BETTER as lower-is-better and bolded its
minimum as the "best" model. Skip columns in a new UNRANKED set, holding
FG_frac, when bolding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gwn9hwgT8uJSoWaCxCAm8J
@alxndrkalinin
alxndrkalinin merged commit 359ff43 into dynacell-models Oct 2, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants