From a8dbfaf28f7c33faac16949f4f0956ebb7edeea9 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 17:35:47 -0500 Subject: [PATCH 01/38] feat(drift,particles): rigid drift solve + particle container and measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps A1 and B1/B5/B6 of DRIFT_AND_PARTICLES_PLAN.md. spyde/drift/ — rigid translation by FFT phase correlation with a running Fourier average reference and Guizar-Sicairos matrix-multiply DFT upsampling. Streams one frame at a time; a solve returns an (N, 2) DriftModel, never an aligned copy of the movie. The running reference is accumulated in FOURIER space via a phase ramp, which is exact for sub-pixel shifts and avoids the resample blur that would accumulate over thousands of frames. spyde/particles/ — the classical engine (ParticleSpy's segptcls vocabulary) plus split_instances(), the instance-split shared by all three planned engines, and calibrated regionprops measurement. spyde/signals/particles.py — SpyDEParticles, ragged per-frame CSR storage mirroring SpyDEDiffractionVectors. Outlines are quantised int16 contours (~120 MB at 1.5M particles, vs 770 MB for bbox bitmaps); full-frame label images are never stored. Three things measurement corrected: - _upsampled_dft ignored its upsample argument, so the refinement ran at 1/upsample of the intended resolution. It still found a peak, so every recovered shift merely quantised to 1/8 px. - The accuracy gate was vacuous: every truth shift was a multiple of 1/upsample and so exactly representable. Off-grid truth gives 0.065 px at u=8, and would have caught the bug above. - torch-CPU is 7.7x faster than numpy for per-frame FFT (139 vs 18 frames/s on 512^2) because np.fft is single-threaded. Backend order is now cuda > mps > torch-cpu > numpy; numpy stays as the parity reference. ParticleSpy's watershed_size filters markers by AREA, which erases a 3x3 particle's one-pixel local-maximum marker — the sensitivity failure plan Section 0.9 exists to prevent. Replaced by min_separation + marker_smooth. 103 tests; numbers in benchmarks.md. --- DRIFT_AND_PARTICLES_PLAN.md | 766 ++++++++++++++++++ benchmarks.md | 42 + spyde/drift/__init__.py | 36 + spyde/drift/frames.py | 92 +++ spyde/drift/model.py | 173 ++++ spyde/drift/translation.py | 503 ++++++++++++ spyde/drift/warp.py | 134 +++ spyde/particles/__init__.py | 41 + spyde/particles/classical.py | 391 +++++++++ spyde/particles/measure.py | 229 ++++++ spyde/signals/__init__.py | 9 +- spyde/signals/particles.py | 435 ++++++++++ .../tests/migrated/test_drift_translation.py | 419 ++++++++++ spyde/tests/migrated/test_particles_core.py | 504 ++++++++++++ 14 files changed, 3773 insertions(+), 1 deletion(-) create mode 100644 DRIFT_AND_PARTICLES_PLAN.md create mode 100644 spyde/drift/__init__.py create mode 100644 spyde/drift/frames.py create mode 100644 spyde/drift/model.py create mode 100644 spyde/drift/translation.py create mode 100644 spyde/drift/warp.py create mode 100644 spyde/particles/__init__.py create mode 100644 spyde/particles/classical.py create mode 100644 spyde/particles/measure.py create mode 100644 spyde/signals/particles.py create mode 100644 spyde/tests/migrated/test_drift_translation.py create mode 100644 spyde/tests/migrated/test_particles_core.py diff --git a/DRIFT_AND_PARTICLES_PLAN.md b/DRIFT_AND_PARTICLES_PLAN.md new file mode 100644 index 00000000..c376b8d4 --- /dev/null +++ b/DRIFT_AND_PARTICLES_PLAN.md @@ -0,0 +1,766 @@ +# Drift Correction & Particle Segmentation — Plan + +Two features that share one spine: **align the movie → segment each frame → +measure → link into trajectories → show it moving.** + +Reference points: [quantem `imaging/drift.py`][q] (scan model, Bézier knots, +non-rigid optimisation, directional Fourier merge) and [ParticleSpy][p] +(segmentation parameters, measured properties, trainable feature stack, +time-series linking). We take their **algorithms and parameter vocabulary** and +re-implement on the SpyDE stack: torch instead of skimage/scipy loops, batched +instead of per-frame, CSR instead of Python object lists. + +[q]: https://github.com/electronmicroscopy/quantem/blob/dev/src/quantem/imaging/drift.py +[p]: https://github.com/ePSIC-DLS/particlespy + +Prior art in this repo, in order of how much it saves us: +`signals/diffraction_vectors.py` (ragged CSR container — particles per frame is +the *same* data shape), `find_vectors/` (compute-package split + progressive, +cancellable result window — the interaction model this feature copies wholesale), +`vector_orientation_gpu.py` (batched-torch playbook + the Windows autograd traps), +`vector_overlay.py` (interactive per-point overlay with add/remove/state colouring), +`report/vectors_embed.py` (the interactive HTML explorer — §C4), +`models/registry.py` (upgradeable model delivery), `actions/README.md` (the +action contract — read before writing any action). + +--- + +## 0. Decisions locked before writing code + +### 0.1 Scale is the primary design driver + +**Thousands of frames at 2048²–4096². Tens of GB.** This is not a movie that +fits in RAM, and it invalidates the obvious implementation of nearly every +stage. Consequences, applied everywhere below: + +- **Nothing materialises the stack** — not the solver, not the aligned copy, + not the segmenter, not the label images. CLAUDE.md's memory-safety rule, + extended to two new packages. +- The drift solver **streams**: read frame → FFT → correlate → discard. Its + output is an `(N, 2)` array, not a stack. +- The corrected movie is a **lazy view**, never a written copy (§0.7). +- Particle masks need a storage budget, not an assumption (§0.5). +- Every long-running stage is **progressive and cancellable** (§0.8). + +### 0.2 One spine, three interchangeable mask sources + +Every segmentation path produces the **same** intermediate — a per-frame +foreground probability map — and then shares one downstream stage: + +``` + frame ──► [ classical | scribble | prompt ] ──► probability/mask + │ + instance split (distance-transform watershed) + │ + measure (regionprops → physical units) + │ + SpyDEParticles (CSR, per frame) + │ + link (Hungarian) ──► tracks + events +``` + +The three engines are **not** competing implementations to choose between — +they are three ways to fill the first box, and they compose (§0.4). The +instance-split + measure stage is written once and is the only code that ever +touches skimage. + +### 0.3 Package layout and dependencies + +| Submodule | Contents | Extra | +|---|---|---| +| `spyde/drift/` | warp, scan/deformation models, translation/affine/non-rigid solvers | — (core) | +| `spyde/particles/` | feature stack, three engines, instance split, measure, track | — (core) | +| `spyde/signals/particles.py` | `SpyDEParticles` CSR container | — (core) | +| `spyde/actions/drift_action.py` | the Drift wizard | — | +| `spyde/actions/particles_action.py` | the Segment Particles wizard | — | +| `spyde/actions/particle_overlay.py` | live outlines / trails / editing | — | + +torch, scikit-image, scipy and scikit-learn are **already core deps**, so the +compute needs no new dependency at all. Three new pieces of infrastructure: + +- **`anyplotlib` gains a brush/freehand widget** (B0). An upstream contribution + and a version bump, exactly like `max_extent` was added for the ROI cap. It + gates B3's UI, so it goes first. +- **EfficientSAM-Ti as an optional download, not a dependency.** No + `pip install` extra: the checkpoint is pulled on demand from Hugging Face + through the **existing** `spyde/models/registry.py`, the same path SpotUNet + weights already use. So the Prompt tab is present but shows a one-click + "Download model (≈40 MB)" until the weights are cached in `~/.spyde/models`. + Inference is plain torch, which is already core — there is nothing to install. + This is strictly better than a `requires_package` extra: no reinstall, no + environment surgery, and it upgrades without re-releasing SpyDE. +- **A generic table component** (`DataTable.tsx`) rather than a particle-specific + grid. Columns, sort, selection and virtualised rows are all data-agnostic; the + particle dock is its first consumer. Other features want it too — the vector + list, the event log, per-phase OM statistics, the fit component list. + +`spyde/models/` is the *neural disk detector* registry (RELEASE_0_3_0_PLAN +§0.1). We **extend** it to be architecture-generic rather than adding a second +registry — one model-delivery mechanism, one cache dir, one refresh path. See B4. + +### 0.4 Scribble vs prompt: what each is for + +| | Scribble classifier | Promptable (SAM-family) | +|---|---|---| +| Prior | none — learns *your* data | natural RGB photography | +| On EM contrast | adapts in ~30 s of painting | out of distribution; over-segments film texture, merges touching particles | +| Coverage | **dense** — every particle, every frame | one object per prompt | +| Cost per frame | one conv stack + a tiny head | one image embedding (expensive) + cheap per-prompt decode | +| Best at | batch measurement over a movie | "what is *this* thing", zero labels | + +**The scribble classifier is the workhorse; the prompt model is the bootstrap +and the single-particle tool.** They compose, and the composition is the point: + +> Click four particles with the prompt model. Those masks — plus their dilated +> surroundings as background — become the scribble classifier's training +> labels. Train (seconds), apply to all N frames. **No painting at all**, and +> the dense result is adapted to the data rather than to COCO. + +So `prompt.py` exposes `masks_to_labels()` and the wizard's Prompt tab has a +**"Use as training labels"** button that hands off to the Scribble tab. That +handoff is a first-class feature, not a convenience. + +The classical pipeline is always available (no model, no training) and is where +the shared instance-split lives, so it is never dead code. + +### 0.5 Storage: `SpyDEParticles`, a CSR container — with a mask budget + +Particles-per-frame is a **ragged per-navigation-position collection** — the +exact shape `SpyDEDiffractionVectors` already solves. Mirror it rather than +inventing a second pattern, and rather than ParticleSpy's list-of-Python-objects, +which does not survive 3000 frames. + +``` +flat_buffer : (N_total, K) float32 + columns: [t, label, y, x, area, equiv_diam, major, minor, perimeter, + circularity, eccentricity, solidity, intensity_mean, + intensity_max, intensity_std, background, bbox_y0, bbox_x0, + bbox_y1, bbox_x1, track_id] + sorted by t (outermost nav dim first) — same convention as vectors +nav_offsets : [t_offsets (n_t+1,)] — CSR row pointers, O(1) frame slice +``` + +**Do the arithmetic before choosing a mask representation.** At the stated +scale — 3000 frames × ~500 particles = 1.5M particles: + +| | per particle | total | +|---|---|---| +| property row (21 × float32) | 84 B | **126 MB** — fine, keep in RAM | +| bbox bitmap (packed 64² crop) | 512 B | **770 MB** — too big | +| contour polygon (~40 pts × 2 × f32) | 320 B | **480 MB** — still too big | +| contour, quantised int16 + RLE | ~80 B | **120 MB** — acceptable | + +So: **properties always in RAM; masks are stored as quantised contours and are +optional.** A `store_masks=False` measure-only mode is the default for very long +movies, and a full-frame label image is never stored at any setting — a 4096² +int32 label image is 64 MB *per frame*. `render_frame(t)` reconstructs the +overlay for the displayed frame from contours on demand, the direct analogue of +`SpyDEDiffractionVectors.render_frame`. + +What mirroring the vectors container buys for free: + +- `count_map_series()` → **particle count vs time**, the movie's navigator trace. +- `render_frame(t)` → the overlay for the currently displayed frame. +- `open_result_tree` → result window opens **early and fills progressively**. +- `save()` / `load()` → particles are a standalone saveable mini-dataset. + +### 0.6 Segmentation produces a NEW TREE, not an attribute + +**This resolves the 4D-STEM attachment ambiguity, and it is the right shape +generally.** A segmentation is not a property of the source movie — it is a +derived dataset computed *from* it, exactly like a strain map or an orientation +map. So `seg_run` spawns a new `SignalTree` through the existing +`commit.open_result_tree` door: + +``` +ParticleTree + root signal : lazy LABEL MOVIE — same nav/signal shape as the source, + each frame rendered from the stored contours on demand + tree.particles : SpyDEParticles (the CSR store) + tree.source_node : the node it was computed from + tree.nav_map : source nav indices → particle frame index (identity for a + movie; the parent's nav grid for a 4D-STEM virtual image) + navigator : stacked count(t) / size(t) / event lanes (C2) +``` + +Why this is better than `source_tree.particles = …`: + +- **It answers Wave D by construction.** Particles found on a 4D-STEM virtual + image record the node they came from and the nav positions each particle + covers, so "mean DP for this particle" is a slice of `source_node`'s parent + rather than a guess about which grid the coordinates belong to. +- **The label movie is scrubbable, saveable and reportable** — it behaves like + any other dataset, so the report builder, the movie editor and `save`/`load` + need no special case. +- **Re-segmenting doesn't destroy the previous result.** Two parameter choices + are two sibling trees you can compare, which is what the signal tree is for. +- Downstream actions gate on the *particle tree's* own type, so + `requires_particles` is a plain signal-type check rather than a hunt up the + parent chain. + +The label movie is **lazy and never materialised** — the contours are the truth, +`render_frame(t)` paints one frame on demand (§0.5). + +### 0.7 The drift-corrected movie is a LAZY VIEW, not a copy + +- Solver output is a small **`DriftModel`** — `(N, 2)` shifts, or the warp + parameters for the non-rigid case — stashed on the tree as `tree.drift` and + stamped into provenance. +- The corrected node is a lazy per-frame warp added with + `tree.add_transformation(...)`, composing with the existing + `LocalTransformReader`, so nav scrubbing works unchanged on day one. + +**Deferred, explicitly gated on review:** `array_cache/readers/per_frame.py` +has a deliberately conservative allowlist (`_rebin_fn`, `_crop_fn`) for +transforms it can reproduce exactly per frame. A rigid shift qualifies, and +adding it would make a drift-corrected movie scrub at parent-frame speed (the +CLAUDE.md rebin numbers: 2403 ms → 1.8 ms once the parent block is cached). +**This is a signal-tree read-path change and does not happen autonomously** — +benchmark, proposal, case-by-case review, then implement. Wave A ships without it. + +### 0.8 The interaction contract: preview → progressive → cancellable + +Locked, and it applies to both wizards: + +1. **Scrub and see the result on a single frame before committing to a run.** + Tuning happens on the displayed frame at full interactivity; nothing batch + runs until asked. +2. **The run is progressive** — the result window opens immediately and the + count-vs-time trace fills as frames complete, like the Find Vectors count map. +3. **The run is cancellable** — registered via `BaseSignalTree.register_cancel` + so closing the tree or hitting stop kills in-flight compute. +4. **Target: minutes, not hours.** ~20–100 frames/s for segment + measure. + +### 0.9 Detection sensitivity is the priority, not instance splitting + +Given hundreds of frequently-touching particles, the instinct is to pour effort +into watershed splitting. **The steer is the opposite: faint, small, low-contrast +particles must be found at all** — missing a particle's first appearance destroys +the nucleation event, which is the most interesting thing in the movie. + +Consequences: + +- The learned classifier is the primary path, not threshold tuning — a + threshold that catches a 3σ particle at t=0 is not the one that works at + t=end, and no single global threshold spans a nucleation sequence. +- Expose **one sensitivity control**, not independent knobs. Sensitivity and + separation trade off against each other (a threshold loose enough to catch + faint particles also merges neighbours), so it should be one axis the user + moves with live feedback, with splitting parameters secondary. +- Small-object detection needs the feature stack's fine scales — do not + downsample frames for speed without a documented sensitivity measurement. + +### 0.10 Scope: four data shapes, one code path + +| Shape | Path | +|---|---| +| In-situ movie (1-D time nav + 2-D signal) | primary | +| **5-D STEM reduced to a virtual image** | **identical — it *is* an in-situ movie** (time, nav_y, nav_x) | +| Single 2-D image | same segment+measure, no nav to fill, no tracking | +| 4D-STEM virtual image | same, plus the diffraction linkage in Wave D | + +Tracking is meaningless without a time axis, so it gates on `_signal_type == +"insitu"` exactly as Play/Fast-Forward already does. + +### 0.11 Baselines to measure BEFORE writing solver code + +Every number is a *baseline to beat*, recorded in `benchmarks.md`: + +| Stage | Reference | Target data | +|---|---|---| +| Rigid alignment | `skimage.registration.phase_cross_correlation` per pair | `load_test_data_movie`, then a real long `.mrc` | +| Non-rigid | quantem's scipy L-BFGS-B, per row | synthetic known warp | +| Feature stack | skimage filters on CPU | 2048² and 4096² frame | +| Scribble train+apply | sklearn RandomForest on the same stack | 2048², ~5k labelled px | +| Prompt latency | reference SAM predictor | 1024² embed + point decode | +| Full segment+measure | ParticleSpy `particle_analysis_series` | `pdcusi_insitu` | + +Two traps that have already been paid for here: + +- **Page cache.** Any drift benchmark reading a movie just written or just read + is measuring RAM. Use `purge_cache` from `benchmark_mrc_access_patterns.py` + and release live `np.memmap`/hyperspy handles first. +- **Time the arithmetic in RAM before reasoning about I/O.** The ROI-integrate + work found 500 ms of 660 ms was single-threaded numpy, not disk. Warping a + 4096² frame has the same profile. + +--- + +## Wave A — Drift correction (`spyde/drift/`) + +**A1. Batched rigid translation.** FFTs in bounded frame-batches with +`torch.fft.rfft2`; cross-power spectrum against a **running Fourier average** +reference — quantem's stabilisation, and the locked default, so one bad frame +can't become the reference. Subpixel refinement by matrix-multiply DFT +upsampling (Guizar-Sicairos), default `upsample_factor=8` — a small dense matmul, +not a padded inverse FFT. `min_image_shift` / `max_image_shift` bounds as quantem +has them. Optional Hann/Tukey apodisation: a movie with a moving feature at the +edge otherwise correlates on the frame border. Output: `(N, 2)` shifts. + +Sequential and fixed-reference modes exist behind the same solver but are not +the default. + +**A2. Two warp parameterisations, one solver.** *Both physical causes are real* +— scan distortion happens, and so does local sample drift with no global +reference — so the model is selectable rather than assumed: + +- **Scan-knot model** (quantem): fast/slow scan unit vectors from + `scan_direction_degrees`, knots `[2, slow_dim, n_knots]`, Bézier basis → per-row + coordinate map. `number_knots=1` is the documented default for uniform scan + distortion. Correct when the distortion is a scanning artifact. +- **Dense control-point field**: a coarse 2-D grid of displacement control + points with bending-energy regularisation — standard free-form deformation. + Correct when the *sample* deforms, and when parts of the field move + independently of each other. + +They share the warp (A3), the solver (A5), and the regularisation machinery; +only the parameter→displacement map differs. Building both is roughly 30% more +than building one. + +**A3. Differentiable KDE warp.** quantem resamples with a KDE scatter; in torch +that is `index_put_(accumulate=True)` over the four bilinear neighbours plus a +weight image for normalisation. Being differentiable is the whole point — it is +what makes A5 an autograd problem instead of a finite-difference one. Also +produces the coverage map A6 and the NaN-padding need. + +**A4. Affine / linear drift search.** Grid search over parameter perturbations +(quantem: `num_tests=9` circular pattern, `step=0.01`, optional refine at finer +step). Batched — all 9 candidates in one call, not looped. + +**A5. Non-rigid optimisation — the rewrite that justifies the port.** quantem +minimises with scipy L-BFGS-B, per row, with numerical gradients. Because A3 is +differentiable, we take an **analytic autograd gradient and optimise all rows +simultaneously** with `torch.optim.LBFGS` (or Adam + anneal, matching +`vector_orientation_gpu.py`'s proven schedule). Keep quantem's regularisation, +which is doing real work: Gaussian smoothing of residuals after polynomial trend +removal (`regularization_sigma_px=16`), max-displacement clamping, step damping +(0.8) over the 8 outer iterations. + +> **Windows + torch-CUDA autograd — both mitigations are load-bearing.** The fit +> dispatches to a daemon worker via `run_on_worker`, and `backward()` segfaults +> the first time it runs on a thread whose autograd engine is uninitialised. So: +> `warmup_autograd()` on the dispatch thread before the worker starts, and +> `torch.autograd.set_multithreading_enabled(False)` around the refine loop. +> Yield every ~12 steps *inside* the loop or the window freezes; drive progress +> from the compute's own `progress(done, total)`. + +**A6. Scan-rotation merge — LAST, and lowest priority.** No orthogonal-scan data +in hand, so this is built against synthetic ground truth or deferred entirely. +The spec is quantem's: directional Fourier filtering (bounded sine-squared +sigmoid on angle, `filter_midpoint`), cosine-tapered edge blending +(`mask_edge_blend=8`), `weight_thresh=0.1` coverage masking. + +**A7. Edges: NaN pad + coverage mask.** Full frame size retained; uncovered +pixels are NaN and a per-frame coverage mask records validity. Nothing is +silently cropped or filled with invented data. **Downstream contract:** +segmentation must respect the coverage mask, or it will find "particles" in the +padding — this is the single most likely integration bug and gets an explicit test. + +**A8. The Drift wizard** (`drift_` prefix, staged per `registry.py`). + +``` +drift_open mount → current-frame preview + empty shift trace +drift_set_method Rigid | Rigid+Affine | Non-rigid (scan-knot | dense field) +drift_tune debounced re-tune of upsample / max-shift / regularisation +drift_run solve on a worker, progressive shift-trace fill, cancellable +drift_commit add the corrected node to the tree +drift_close teardown +``` + +**Explicit only** — nothing runs on load. + +**A narrow caret plus a separate Drift Check window.** The caret holds the method +tabs, parameters, progress bar and Commit. The check window holds what needs +pixels: **before/after sum images side by side** — an aligned stack sums sharp, a +misaligned one blurs — with the **shift-vs-time trace** (dx, dy) beneath, both +filling incrementally as the solve progresses. A 240 px caret cannot show a sum +image at a size where sharpness is judgeable, which is the whole point of the +check; and the window closes once you trust the result. Registered via +`own_window` + `figure_registry.keep_alive`, since a bare-figure window is not a +Plot (`actions/README.md` §6). + +Declare the parameter schema as a `parameters` classattr **and** in +`registry._WIZARD_SCHEMAS` (`test_wizard_schemas.py` catches drift). + +**A9. The corrected node.** `tree.drift = DriftModel(...)`, lazy per-frame warp +node, provenance stamped. Trajectories can then be reported in the **lab frame** +or the **sample frame** by adding or subtracting the model — the correct way to +answer "did the particle move, or did the stage?" + +--- + +## Wave B — Particle segmentation (`spyde/particles/`) + +**B0. anyplotlib brush primitive (upstream, first).** A freehand/brush widget: +`pointer_down` starts a stroke, `pointer_move` extends it with client-side +rendering, `pointer_up` emits the accumulated polyline. Client-side accumulation +is required — a per-move round trip over the PLOTAPP line protocol is 60 +messages/s competing with the nav painter thread. Brush size and eraser are +widget properties. **Shift+drag paints**, leaving pan/zoom on the bare drag — +matching the existing Shift+click convention in Center Zero Beam, and avoiding a +mode that can be got stuck in. + +**Controls live on a floating strip next to the plot, not in the caret.** While +painting you are looking at the image, so the things switched most often — active +class, brush size, eraser — sit under the cursor. The strip is colour swatches +only; class *names* and pixel counts stay in the caret (B7), which is the +authoritative list. Same component shape as the movie editor's overlay strip. + +**B1. Classical baseline + the shared instance spine.** Port of ParticleSpy's +`segptcls.process`, keeping their parameter names so the caret is recognisable: +`rb_kernel` (rolling-ball via white-tophat), `gaussian`, `invert`, `threshold` +(otsu / mean / minimum / yen / isodata / li / local / local-otsu / niblack / +sauvola), `watershed`, `watershed_erosion`, `min_size`, `local_size`. + +**One deliberate deviation from ParticleSpy's parameters:** their `watershed_size` +filters watershed markers by AREA. That works for their thresholded-distance +markers but is actively harmful with local-maximum markers — a 3×3 particle's +marker is ONE pixel, so any area floor erases it and the particle vanishes without +changing anything a user would notice. It is replaced by **`min_separation`** (the +minimum distance between markers) plus **`marker_smooth`**. No marker is ever +dropped for being small; that is §0.9 applied to the splitting step, and +`test_particles_core.py::test_a_tiny_particle_survives_the_watershed` pins it. + +The second half — distance transform → `peak_local_max` → `watershed` → +`clear_border` → `remove_small_objects` — is factored out as +`split_instances(prob, params)` and is **shared by all three engines**. The only +module that imports skimage. + +**B2. Torch feature stack** (`features.py`). ParticleSpy's `trainable_parameters` +set, batched on GPU: gaussian, difference-of-gaussians, median / min / max (via +`unfold`), Sobel, Hessian eigenvalues, Laplacian, membrane projections. One +`(C, H, W)` tensor per frame, separable convolutions where the kernel allows, +one pass over the frame rather than one pass per feature. **Fine scales are +mandatory** — they are what detects small faint particles (§0.9). + +**B3. Scribble classifier** (`scribble.py`) — the workhorse. + +- **Multi-class, user-defined**: add/name/colour classes freely — particle / + carbon film / vacuum / beam-stop. A softmax head costs nothing over a sigmoid, + and in EM "background" is genuinely two or three different things that a + binary split confuses. +- **Labels accumulate across frames** into one training set, keyed by frame + index, with a small list showing which frames carry labels so they can be + revisited or cleared. Scrub to t=400, paint the newly-nucleated particle, + retrain — earlier strokes are still there. +- Head is a **small torch MLP** (one hidden layer, class-balanced loss) so the + whole path is one framework on the GPU. + `sklearn.ensemble.RandomForestClassifier` on the identical feature stack stays + in the test suite as the **parity reference** — it is what ParticleSpy and + ilastik use, and agreement on the same labels is the acceptance gate. +- **Hard interaction budget: train + apply to the visible frame under ~1 s.** + Train on labelled pixels only (thousands, not millions); apply to the visible + frame only while tuning. + +**B4. Promptable segmentation** (`prompt.py`) — the bootstrap. + +| Interaction | Widget | Prompt | +|---|---|---| +| click a particle | crosshair / point | point | +| drag a box | `add_rectangle_widget` | box | +| draw around it | `add_polygon_widget` | polygon → bbox + dense mask hint | + +> **Trap:** anyplotlib 2-D widgets report `cx/cy/x/y/w/h` in **image pixels**, +> no scale or offset applied. Building prompt coordinates in physical units gives +> an empty prompt on any calibrated axis — `masks.py::_signal_k_grids` documents +> exactly this bug class. + +**EfficientSAM-Ti**, delivered as an **optional Hugging Face download** through +the **existing** `spyde/models/registry.py`, generalised from SpotUNet-specific +hyperparams to an `arch` field with a per-arch builder. Not a `pip` extra: the +Prompt tab always exists and shows a one-click "Download model (≈40 MB)" until +the weights are cached in `~/.spyde/models`. Inference is plain torch, already a +core dep, so nothing is installed and the model upgrades without re-releasing +SpyDE. The registry's manifest merge, offline fallback and refresh path all apply +unchanged. + +Cost model: the image **embedding** is expensive (~100s of ms), the per-prompt +decode is milliseconds. Embed the current frame once on entering the Prompt tab, +cache by frame index, and every subsequent click is interactive. + +`masks_to_labels()` + **"Use as training labels"** is the handoff to B3 (§0.4). + +**B5. Measurement** (`measure.py`). ParticleSpy's property set, calibrated from +the signal axes so results are in nm/nm² not pixels: area, centroid, equivalent +circular diameter, major/minor axis, perimeter, circularity, eccentricity, +solidity, mean/max/std intensity, local background (mean over the dilated +boundary ring), bbox and bbox area. Vectorised via `regionprops_table`, never a +Python loop over regions. + +**B6. `SpyDEParticles`** (§0.5) — container, `render_frame`, `count_map_series`, +`save`/`load`, `to_dataframe()` for CSV export, contour-based optional masks. + +**B7. The Segment Particles wizard** (`seg_` prefix) — a **wide 2-column caret** +(330 px, using WizardShell's existing `width` override). Three tabs — Classical / +Scribble / Prompt — over a shared Preview and Run, honouring §0.8: preview on the +displayed frame, progressive fill, cancellable. + +``` +┌ Segment Particles ──────────────────────────── ✕ ┐ +│ [Classical] [Scribble] [Prompt] │ +├── params ──────────────┬── feedback ─────────────┤ +│ Sensitivity ▓▓▓▓▓░░ │ SIZE nm² (histogram) │ +│ Min size 24 │ ▁▃▅█▆▃▁ │ +│ Split touching on │ 212 found · median 96 │ +│ Store masks off ├── classes ──────────────┤ +│ │ ■ particle 1,204px │ +│ │ ■ carbon film 840px │ +│ │ ■ vacuum 612px │ +│ │ + add class │ +├──────────────────────────────────────────────────┤ +│ [Train] [Run all] │ +│ 3 frames labelled · 4 classes │ +└──────────────────────────────────────────────────┘ +``` + +The right column is **feedback and class management**: the live size histogram +re-renders as sensitivity is dragged (so you see the distribution shift rather +than guessing), and below it the authoritative class list with per-class labelled- +pixel counts — which is how you notice a class is under-trained. One +**sensitivity** control front and centre (§0.9); splitting parameters secondary. +The floating strip (B0) mirrors the class colours for in-canvas switching. + +**B8. Results surfaces — all four.** + +1. **The particle tree** (§0.6) — the label movie, with stacked count(t) / + size(t) / event-lane navigators. Saves, loads and reports like any dataset. +2. **Bottom dock, full width**, built on a new **generic `DataTable.tsx`** + (§0.3) — one row per particle or per track, sortable by any column, click a + row to highlight on the frame, with an Events tab beside the Table tab. + Full width buys columns that don't truncate, and it reuses the Log panel's + slot and show/hide behaviour. Costs vertical space, which is the accepted + trade. +3. **Overlay property readout** — click a particle, see its properties in a + popover on the frame. +4. **Histogram / scatter window** — ParticleSpy's `plot()`: histogram of one + property, scatter of two, coloured by cluster. Reuses existing 1-D panels, and + answers the size-distribution question that is usually the real one. + +**B9. Overlay and editing** (`particle_overlay.py`, modelled on +`vector_overlay.py`). + +- **Filled translucent, coloured by track ID.** +- **Labels on selection and hover only** — the selected particle gets its outline, + ID and a property readout; everything else stays a plain fill. Quiet at 500 + particles, precise on demand. Always-on IDs were rejected: legible in a 9-particle + mockup, a wall of numbers at real density. +- **Selection** three ways: click the particle on the frame (nearest-centroid + hit test, as the strain reference-pixel picker does), click a table row, and + **rubber-band a region** for bulk operations. +- **Manual correction in v1: delete + merge + split.** Delete drops a row; merge + unions two masks and re-measures; split cuts along a drawn line and re-measures. + Edits are recorded on the tree so a re-run does not silently discard them, and + they are stamped into provenance so a corrected result is still reproducible. + +--- + +## Wave C — Tracking, events, and showing motion + +**C1. The linker** (`track.py`). Frame-to-frame assignment by +`scipy.optimize.linear_sum_assignment` on a cost matrix of centroid distance +(gated by `max_dist`), optionally weighted by property similarity — trackpy's +model, no new dependency. `memory=k` lets a track survive k frames of +non-detection. Runs on drift-corrected coordinates, or raw minus `tree.drift`. + +**C2. Events on the navigator — the headline.** The linker's unmatched rows and +columns *are* the event stream: **birth** (nucleation), **death** (dissolution), +**merge** (coalescence), **split** (fragmentation). + +They surface three ways: + +- **Three stacked navigator lanes** on the particle tree — `count(t)`, + `mean size(t)`, and a dedicated **event lane**. Each curve keeps its own + y-scale (count and nm² have nothing in common, so a dual axis would squash + one and invite misreading), and events get their own row with a colour per + type — green birth, red death, mauve merge, yellow split — so you click + straight to a nucleation instead of inferring it from a kink. Tallest option + of the three considered; the stacked-navigator machinery already exists for + in-situ playback. +- **A flash/badge on the frame** at a particle's birth or death frame during + playback, so events are visible while watching rather than only on a timeline. +- **An Events tab in the table dock** — time, type, particle IDs — click to jump. + Same `DataTable` component as the particle list (§0.3), so it is a column + config, not a new panel. The rigorous path for counting events, and directly + exportable. + +**C3. Motion display.** + +- **Trails: fading line + head dot.** The last N frames of each track fade with + age, and a bright dot marks the current position so "now" is unambiguous — + a bare fade leaves direction inferable only by close inspection of one track. + N adjustable. One extra primitive per track. +- **Kymograph (v1), user-sortable** — tracks × time as an image, one row per + track, coloured by a chosen property. Row order is a control, not a constant, + matching the table dock's mental model: **by birth time** the leading edge's + slope *is* the nucleation rate; **by lifetime** short-lived noise detections + separate visually from real particles (segmentation QC); **by max area** ranks + by size. Re-renders per sort, which is cheap on a tracks × time image. +- **Property vs time** — any measured property for the selected track(s), + overlaid on a 1-D plot. +- **Committed maps** — `commit_result_tree` with count(t), total area(t), mean + diameter(t) as chip views. + +**C4. The deliverable: an interactive particles explorer in the report.** + +Modelled directly on the existing **vectors HTML explorer** +(`report/vectors_embed.py`): anyplotlib widgets + the touch shim, self-contained, +scrollable through the movie **without embedding a huge video**. A collaborator +opens the report and scrubs the particles themselves. + +> **Trap carried over from the vectors embed:** read recompute pixels via the +> **overlay** canvas — buffer assertions lie. That memory was paid for once. + +Secondary: **movie-editor overlays with explicit labels** — outlines, trails and +text callouts on the live movie figure, so an exported movie shows *and names* +what changed. The movie editor already composites anyplotlib widgets on the live +plot, so this is wiring rather than new export code. + +--- + +## Wave D — 4D-STEM linkage + +Particles segmented on a virtual image live in the **nav space of the parent 4D +dataset**, not in the virtual image's own space. §0.6 resolves this: the particle +tree records `source_node` and `nav_map`, so the relationship is stored rather +than inferred. Three things then follow — all requested, all cheap given the CSR +store: + +1. **Mean diffraction pattern per particle** — select a particle, get the + averaged DP over its nav positions. Phase or orientation *per particle*. The + thing no other tool does. +2. **Particle masks feed the existing vector/orientation actions** — a mask + becomes a nav-space region, so Find Vectors or orientation mapping runs per + particle rather than over the whole scan. The entire downstream pipeline is + reused unchanged. +3. **Per-particle statistics from any nav-space map** — mean/std of strain, + orientation or any virtual image within each particle. Turns every existing + map into a per-particle table. + +--- + +## Wave 0 — cross-cutting, do first + +- **anyplotlib brush widget** (B0) — upstream, gates B3's UI. +- **Generic `DataTable.tsx`** (§0.3) — columns, sort, selection, virtualised rows, + data-agnostic. The particle dock and the event log are its first two consumers. +- **`requires_particles` gate key** on `tree.particles`, mirroring + `requires_vectors`. Both filter paths. +- **`lifecycle.wait_for_particles`**, mirroring `wait_for_vectors` — the + find-vectors timing trap reproduces exactly here. `seg_run` opens its window + early and attaches `tree.particles` only on **finalise**; any downstream action + firing in that gap sees `None` on a tree that gets it seconds later. Gate on + the real completion signal, never a sleep. +- **`spyde/models/registry.py` generalisation** to an `arch` field (B4). +- **Synthetic test data**: `load_test_data_particles` — a bundled in-situ movie + with *known* ground truth: N particles of known radii, known per-frame rigid + drift, one nucleation at a known t, one dissolution, one merge, plus faint + low-contrast particles to exercise §0.9. Asymmetric and crisp per the + `si_grains`/`movie` precedent so a mirrored overlay or an off-by-one frame is + pixel-visible. **This is the acceptance gate for Waves A–C** — it makes every + stage checkable against a number rather than a screenshot. +- **Docs**: one guide in `guides/`, dataset wired into the Examples menu. + +--- + +## Traps — each previously paid for + +1. **Never materialise the movie.** No `.compute()` / `.result()` on the full + dataset in `spyde/drift/` or `spyde/particles/`. Mirror + `test_find_vectors_memory.py`'s `patch.object` guard on `da.Array.compute`. +2. **NaN padding + coverage mask** (A7) — segmentation that ignores the mask will + find particles in the padding. Explicit test. +3. **MPS device lock.** Every new torch call site takes `accelerator_lock(device)` + — feature stack, scribble train and apply, prompt embedder *and* decoder, + drift FFTs, non-rigid fit. A lock only works if every participant takes it; + the last crash of this class existed because one path skipped it. Long fits + hand the device back at yield points with `mps_sync()` **before** release. + Extend `test_device_lock.py`. +4. **Windows CUDA autograd off the main thread** (A5) — `warmup_autograd()` + + `set_multithreading_enabled(False)`. The failure is a hard segfault. +5. **Don't touch the nav read path.** The per-frame shift reader is a proposal + gated on benchmark + review (§0.7), not part of Wave A. +6. **anyplotlib 2-D widget coords are image pixels** — prompts, scribbles and + overlays build in pixel space, never `pixel*scale + offset`. +7. **Thread marshal.** Solvers and segmenters run on `run_on_worker`; plots, + figures and IPC state are touched only on the asyncio main thread via + `session._dispatch_to_main`. `emit_status`/`emit_error` are safe anywhere. +8. **Latest-wins.** Scribble re-tune, prompt clicks and drift re-solves can be + superseded — `bump_generation`/`is_current`; teardown bumps first. +9. **StrictMode double-mount.** Both wizards use `useWizardLifecycle` *and* the + backend generation guard; a double-fire test each. +10. **Report embed:** read recompute pixels via the overlay canvas (C4). +11. **GPU tests in a subprocess** on Windows; wiring tests force CPU with + `monkeypatch gpu_available → False`. +12. **Page-cache benchmarks** (§0.11) — purge and release handles, or you time RAM. + +--- + +## Acceptance gates + +Where we replace a reference implementation, **numerical parity against it is the +test** — not "it converged", not "the screenshot looks right". + +| Stage | Gate | +|---|---| +| A1 rigid | Recovers a synthetic shift to < 0.1 px; agrees with `phase_cross_correlation` on real frames | +| A5 non-rigid | Recovers a synthetic known warp, both parameterisations; residual ≤ quantem's on the same input | +| A7 edges | No particle is ever detected in NaN-padded region | +| B1 classical | Matches `segptcls.process` labels on identical parameters | +| B3 scribble | Matches the sklearn RandomForest reference on identical labels/features (IoU threshold) | +| B3 sensitivity | Detects the faint low-contrast particles in `load_test_data_particles` — the §0.9 priority made measurable | +| B5 measure | Matches `regionprops` on synthetic shapes; physical units correct under non-unit axis scale | +| C1 link | Recovers known trajectories, births, deaths and the merge exactly | +| Scale | A full run on thousands of 2048² frames completes in minutes without exceeding a fixed memory ceiling | +| Perf | Every stage beats its §0.11 baseline, recorded in `benchmarks.md` | + +## Verification standard + +A green pytest run and a clean `tsc` are **not** verification for anything that +adds windows, draws overlays or wires renderer↔backend — and these waves are +almost entirely that. Each ships with a Playwright spec on +`electron/tests/_harness.cjs`, driven with `load_test_data_particles`, with +screenshots that were actually looked at. Specifically pixel-checked: brush +strokes land where the cursor was, outlines sit **on** the particles rather than +mirrored or offset by one frame, trails follow motion in the right direction, and +the navigator curves' kinks align with the known ground-truth event frames. + +--- + +## Build order + +Locked: **A1 → B → C**, then A2–A6, then D. Rigid alignment is the only part of +Wave A that Wave B depends on, and getting a working segment-and-track loop in +front of a real dataset early is worth more than a finished drift feature — the +segmentation parameters and the overlay are where the unknowns are. A6 (scan +merge) is last regardless, since there is no orthogonal-scan data to validate it. + +| Step | Contents | Gate | +|---|---|---| +| **0** | brush widget, `DataTable`, `requires_particles`, `wait_for_particles`, `load_test_data_particles` | fixture ground truth is exact | +| **1** | A1 rigid translation + warp + `DriftModel` + corrected node | < 0.1 px on synthetic shift | +| **2** | B6 `SpyDEParticles`, B5 measure, B1 classical + instance split | parity vs `regionprops` / `segptcls` | +| **3** | B2 feature stack, B3 scribble, B7 wizard, B8 surfaces, B9 overlay | parity vs RandomForest; faint particles found | +| **4** | C1 linker, C2 events + navigator lanes, C3 trails/kymograph, C4 report embed | fixture trajectories and events exact | +| **5** | B4 EfficientSAM-Ti prompt + label handoff | one-click mask on a real particle | +| **6** | A2–A5 scan-knot + dense-field non-rigid | synthetic known warp | +| **7** | Wave D 4D-STEM linkage; A6 scan merge | per-particle mean DP correct | + +--- + +## Resolved — the four questions this plan opened with + +1. **Sequencing** → A1 → B → C, then A2–A6, then D. See Build order above. +2. **Prompt model** → **EfficientSAM-Ti**, as an optional Hugging Face download + through the existing model registry rather than a `pip` extra (§0.3, B4). +3. **Wave D attachment** → segmentation spawns a **new tree** carrying + `source_node` + `nav_map`, so the parent relationship is recorded rather than + inferred (§0.6). The B6 container can be frozen. +4. **Table component** → **generic `DataTable.tsx`** (§0.3); the particle dock + and the event log are its first two consumers. + +No blocking questions remain. What is deliberately deferred, and why: + +- The **per-frame shift reader** in `array_cache/readers/per_frame.py` — a + signal-tree read-path change, so benchmark → proposal → case-by-case review + before it is written (§0.7). +- **A6 scan-rotation merge** — no orthogonal-scan data to validate against. diff --git a/benchmarks.md b/benchmarks.md index 1b1c34fc..de08d9db 100644 --- a/benchmarks.md +++ b/benchmarks.md @@ -727,3 +727,45 @@ Three things this pins down: it (50.2 s) and neither does a bigger window — if the progressive fill is ever worth optimising further, the lever is fewer, larger display chunks, not more in-flight ones. + +## Rigid drift solve — phase correlation backends + accuracy vs upsample (2026-07-29) + +`spyde/drift/translation.py` step A1. Synthetic stack built by Fourier phase ramp +so sub-pixel ground truth is exact (no interpolation in the truth). + +**Accuracy** — 5 frames, 96×112, truth shifts deliberately OFF the `1/upsample` +grid (`1.37, -2.83, …`). This matters: shifts that happen to be multiples of +`1/upsample` come back at 0.00000 px, which looks superb and tests nothing. + +| upsample | max error | +|---|---| +| 1 | 0.440 px | +| 2 | 0.220 px | +| 8 | **0.065 px** ← inside the 0.1 px acceptance gate | +| 32 | 0.015 px | +| 64 | 0.014 px (floor) | + +Halves as expected until ~u=32, where it hits the scene's own noise floor. `u=8` +is the default: comfortably inside the gate at a fraction of the refinement cost. + +**Throughput** — 120 frames × 512², upsample=8, warm (cold CUDA run discarded). + +| backend | time | frames/s | | +|---|---|---|---| +| numpy | 6.57 s | 18 | reference path | +| torch **cpu** | 0.86 s | **139** | **7.7× numpy** | +| torch cuda | 0.42 s | 284 | 16× numpy | + +**Do NOT default to numpy on a CPU-only machine.** `_resolve_ops` originally +preferred numpy when no GPU was present, reasoning that torch's per-call dispatch +would dominate at one frame at a time. Wrong by 7.7×: `np.fft.fft2` is +single-threaded and `torch.fft.fft2` uses every core, and a per-frame FFT is the +entire cost of this solver. Order is now cuda > mps > **torch cpu** > numpy, with +numpy kept only as the explicitly-selectable parity reference. + +CUDA's 2× over torch-CPU is smaller than the batched-compute wins elsewhere in +SpyDE because this solver is deliberately *streaming* — one frame at a time, so +each frame pays a host→device transfer that a batched formulation would amortise. +That is the accepted trade for the Memory-Safety rule (a 3000 × 4096² movie is +tens of GB and cannot be batched wholesale). If the transfer ever dominates, the +fix is a bounded read-ahead of a few frames, not materialising the stack. diff --git a/spyde/drift/__init__.py b/spyde/drift/__init__.py new file mode 100644 index 00000000..d477f46d --- /dev/null +++ b/spyde/drift/__init__.py @@ -0,0 +1,36 @@ +""" +spyde.drift — drift correction for image stacks and in-situ movies. + +See ``DRIFT_AND_PARTICLES_PLAN.md`` (repo root) for the full design. The two +load-bearing constraints, both from CLAUDE.md: + +* **Nothing materialises the stack.** The target is thousands of frames at + 2048²–4096² (tens of GB). Every solver here STREAMS: read a frame, transform + it, discard it. The output of a solve is a small :class:`DriftModel` — an + ``(N, 2)`` shift array, not an aligned copy of the movie. +* **The corrected movie is a LAZY VIEW.** ``DriftModel`` describes the + correction; applying it is a per-frame numpy operation + (:func:`spyde.drift.warp.shift_frame`) wired into the signal tree as a lazy + transformation node. Nothing is ever written out. + +Public API:: + + from spyde.drift import DriftModel, solve_translation, shift_frame + + model = solve_translation(signal, upsample=8, max_shift=32) + aligned_frame = shift_frame(raw_frame, model.shifts[i]) +""" +from __future__ import annotations + +from spyde.drift.frames import frame_source +from spyde.drift.model import DriftModel +from spyde.drift.translation import solve_translation +from spyde.drift.warp import coverage_mask, shift_frame + +__all__ = [ + "DriftModel", + "solve_translation", + "shift_frame", + "coverage_mask", + "frame_source", +] diff --git a/spyde/drift/frames.py b/spyde/drift/frames.py new file mode 100644 index 00000000..23602b46 --- /dev/null +++ b/spyde/drift/frames.py @@ -0,0 +1,92 @@ +""" +frames.py — one streaming accessor for every kind of frame stack we accept. + +The whole point of this module is the Memory-Safety rule (CLAUDE.md): a drift +solve on a 3000 × 4096² movie must never hold more than a couple of frames at +once. So callers get a ``(n_frames, get_frame, frame_shape)`` triple and read +frames one at a time — they never touch ``.data`` directly and therefore cannot +accidentally ``.compute()`` the whole array. + +Accepted inputs, in the order they are tried: + +* a HyperSpy signal (lazy or eager) with 1-D navigation and 2-D signal axes +* a dask array of shape ``(n, h, w)`` — sliced and computed per frame +* a numpy array of shape ``(n, h, w)`` — already resident, just indexed +* any sequence of 2-D arrays +""" +from __future__ import annotations + +from typing import Callable + +import numpy as np + + +def _is_dask(obj) -> bool: + """True for a dask array, WITHOUT importing dask when it isn't already in.""" + return type(obj).__module__.startswith("dask.array") + + +def frame_source(data) -> tuple[int, Callable[[int], np.ndarray], tuple[int, int]]: + """Return ``(n_frames, get_frame, frame_shape)`` for *data*. + + ``get_frame(i)`` returns frame ``i`` as a **numpy** 2-D array. For a lazy + (dask) backing it computes exactly that one frame — never the whole stack. + + Raises + ------ + TypeError + If *data* is not a recognised stack, or is not 3-D / not a sequence of + 2-D frames. Failing loudly here is deliberate: a silently-wrong axis + order would produce a plausible but meaningless drift curve. + """ + # ── HyperSpy signal ────────────────────────────────────────────────────── + # Duck-typed on axes_manager so this module never imports hyperspy (import + # cost at backend startup) and so test doubles work. + if hasattr(data, "axes_manager") and hasattr(data, "data"): + am = data.axes_manager + nav = int(am.navigation_dimension) + sig = int(am.signal_dimension) + if sig != 2: + raise TypeError( + f"drift needs 2-D signal axes (images); got signal_dimension={sig}" + ) + if nav != 1: + raise TypeError( + "drift needs a 1-D navigation axis (a frame stack / movie); got " + f"navigation_dimension={nav}. Reduce a higher-dimensional " + "dataset to a movie first (e.g. a virtual image)." + ) + return frame_source(data.data) + + # ── dask / numpy 3-D array ─────────────────────────────────────────────── + if _is_dask(data) or isinstance(data, np.ndarray): + if data.ndim != 3: + raise TypeError(f"expected a 3-D (n, h, w) stack; got shape {data.shape}") + n, h, w = data.shape + if _is_dask(data): + def get_frame(i: int, _d=data) -> np.ndarray: + # ONE frame. Never `_d.compute()`. + return np.asarray(_d[int(i)].compute()) + else: + def get_frame(i: int, _d=data) -> np.ndarray: + return np.asarray(_d[int(i)]) + return int(n), get_frame, (int(h), int(w)) + + # ── sequence of 2-D frames ─────────────────────────────────────────────── + try: + n = len(data) + except TypeError as exc: + raise TypeError( + f"cannot read frames from {type(data).__name__}: expected a HyperSpy " + "signal, a 3-D array, or a sequence of 2-D arrays" + ) from exc + if n == 0: + raise TypeError("empty frame stack") + first = np.asarray(data[0]) + if first.ndim != 2: + raise TypeError(f"sequence elements must be 2-D frames; got ndim={first.ndim}") + + def get_frame(i: int, _d=data) -> np.ndarray: + return np.asarray(_d[int(i)]) + + return int(n), get_frame, (int(first.shape[0]), int(first.shape[1])) diff --git a/spyde/drift/model.py b/spyde/drift/model.py new file mode 100644 index 00000000..c5040cfd --- /dev/null +++ b/spyde/drift/model.py @@ -0,0 +1,173 @@ +""" +model.py — :class:`DriftModel`, the small serialisable result of a drift solve. + +This is the ONLY thing a solve produces. It is deliberately tiny (an ``(N, 2)`` +float32 array plus metadata) because the alternative — an aligned copy of the +movie — is tens of GB (see the module docstring in ``spyde/drift/__init__.py``). + +Sign convention — read this before touching anything +----------------------------------------------------- +``shifts[i]`` is the **correction**: the ``(dy, dx)`` you ADD to frame *i* to +bring it into the reference frame. So:: + + aligned_i = scipy.ndimage.shift(frame_i, model.shifts[i]) + +This matches ``skimage.registration.phase_cross_correlation``, whose docstring +defines its return as "the shift vector required to register moving_image with +reference_image", and matches ``scipy.ndimage.shift``'s own sign. Keeping all +three identical is why the convention is stated here rather than inferred at each +call site — an inverted sign produces a drift curve that looks entirely plausible +and doubles the drift instead of removing it. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +# Bumped when the on-disk layout changes incompatibly. +FORMAT_VERSION = 1 + + +@dataclass +class DriftModel: + """The correction for a frame stack. + + Parameters + ---------- + shifts + ``(N, 2)`` float32, ``(dy, dx)`` per frame — the correction to ADD (see + the module docstring on sign convention). + kind + ``"rigid"`` today. ``"affine"`` / ``"scan_knot"`` / ``"dense"`` are the + non-rigid parameterisations planned in Wave A2–A5; they will carry their + parameters in :attr:`extra` and keep ``shifts`` as the rigid component. + reference + How the reference was formed: ``"running"`` (running Fourier average), + ``"sequential"`` (frame-to-frame, cumulative) or ``"fixed:"``. + residuals + Optional ``(N,)`` per-frame correlation peak sharpness — a weak but free + quality signal. NaN where not computed. + params + The solver arguments, for provenance and for re-running. + """ + + shifts: np.ndarray + kind: str = "rigid" + reference: str = "running" + residuals: np.ndarray | None = None + params: dict[str, Any] = field(default_factory=dict) + provenance: dict[str, Any] | None = None + extra: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.shifts = np.ascontiguousarray(self.shifts, dtype=np.float32) + if self.shifts.ndim != 2 or self.shifts.shape[1] != 2: + raise ValueError( + f"shifts must be (N, 2); got {self.shifts.shape}" + ) + if self.residuals is not None: + self.residuals = np.ascontiguousarray(self.residuals, dtype=np.float32) + if self.residuals.shape != (self.n_frames,): + raise ValueError( + f"residuals must be ({self.n_frames},); got {self.residuals.shape}" + ) + + # ── basic properties ───────────────────────────────────────────────────── + + @property + def n_frames(self) -> int: + return int(self.shifts.shape[0]) + + @property + def max_abs_shift(self) -> float: + """Largest single-axis correction, in pixels. Sizes the padded border.""" + if self.n_frames == 0: + return 0.0 + return float(np.nanmax(np.abs(self.shifts))) + + @property + def is_integer(self) -> bool: + """True when every shift is a whole number of pixels. + + An integer-only model can be applied by ``np.roll`` and so **preserves + the source dtype**; a sub-pixel model needs interpolation and therefore + float output. Callers use this to avoid promoting a uint16 movie to + float32 when they don't have to. + """ + if self.n_frames == 0: + return True + finite = self.shifts[np.isfinite(self.shifts)] + return bool(finite.size == 0 or np.all(finite == np.round(finite))) + + def shift_at(self, index: int) -> np.ndarray: + """The ``(dy, dx)`` correction for frame *index*.""" + return self.shifts[int(index)] + + # ── frame-of-reference conversions ─────────────────────────────────────── + + def to_sample_frame(self, positions: np.ndarray, frame_index) -> np.ndarray: + """Map lab-frame ``(y, x)`` positions onto the corrected (sample) frame. + + *positions* is ``(M, 2)``; *frame_index* is a scalar or ``(M,)``. This is + how a trajectory measured on RAW frames is reported as if the stage had + never moved — the alternative to physically warping the movie first. + """ + pos = np.asarray(positions, dtype=np.float64) + if pos.ndim != 2 or pos.shape[1] != 2: + raise ValueError(f"positions must be (M, 2); got {pos.shape}") + idx = np.asarray(frame_index, dtype=np.intp) + return pos + self.shifts[idx].reshape(pos.shape) + + def to_lab_frame(self, positions: np.ndarray, frame_index) -> np.ndarray: + """Inverse of :meth:`to_sample_frame`.""" + pos = np.asarray(positions, dtype=np.float64) + if pos.ndim != 2 or pos.shape[1] != 2: + raise ValueError(f"positions must be (M, 2); got {pos.shape}") + idx = np.asarray(frame_index, dtype=np.intp) + return pos - self.shifts[idx].reshape(pos.shape) + + # ── serialisation ──────────────────────────────────────────────────────── + + def save(self, path: str) -> None: + """Write to a compressed ``.npz``. Small enough to sit beside the data.""" + meta = { + "format_version": FORMAT_VERSION, + "kind": self.kind, + "reference": self.reference, + "params": self.params, + "provenance": self.provenance, + "extra": self.extra, + } + arrays = {"shifts": self.shifts, "meta": np.array(json.dumps(meta))} + if self.residuals is not None: + arrays["residuals"] = self.residuals + np.savez_compressed(path, **arrays) + + @classmethod + def load(cls, path: str) -> "DriftModel": + with np.load(path, allow_pickle=False) as z: + meta = json.loads(str(z["meta"].item())) + ver = meta.get("format_version") + if ver != FORMAT_VERSION: + raise ValueError( + f"unsupported DriftModel format version {ver!r} " + f"(this build reads {FORMAT_VERSION})" + ) + return cls( + shifts=z["shifts"], + kind=meta.get("kind", "rigid"), + reference=meta.get("reference", "running"), + residuals=z["residuals"] if "residuals" in z.files else None, + params=meta.get("params") or {}, + provenance=meta.get("provenance"), + extra=meta.get("extra") or {}, + ) + + def __repr__(self) -> str: + return ( + f"DriftModel(kind={self.kind!r}, n_frames={self.n_frames}, " + f"reference={self.reference!r}, max_abs_shift={self.max_abs_shift:.2f} px)" + ) diff --git a/spyde/drift/translation.py b/spyde/drift/translation.py new file mode 100644 index 00000000..7d63b2cf --- /dev/null +++ b/spyde/drift/translation.py @@ -0,0 +1,503 @@ +""" +translation.py — rigid (translation-only) drift solve. Plan step A1. + +Algorithm: FFT phase correlation with a **running Fourier average** reference and +Guizar-Sicairos matrix-multiply DFT upsampling for the sub-pixel peak. + +Three things here are deliberate and worth reading before changing them. + +**1. It streams.** One frame is resident at a time (plus the accumulated reference +FFT, which is frame-sized). A 3000 × 4096² movie is tens of GB; nothing here ever +holds more than a few hundred MB. This is the CLAUDE.md Memory-Safety rule. + +**2. The reference is accumulated in FOURIER space, aligned by a phase ramp.** +To add frame *i* to the running average *already aligned*, we multiply its FFT by +``exp(-2πi(dy·fy + dx·fx))`` rather than resampling the frame and re-transforming. +A translation is exactly a phase ramp in the Fourier domain, so this is not an +approximation — it is free, it is exact even for sub-pixel shifts, and it avoids +the interpolation blur that resample-then-average would accumulate over thousands +of frames. That blur is the reason a naive running average degrades as the stack +gets longer. + +**3. Sub-pixel refinement is a small matmul, not a padded inverse FFT.** Zero- +padding the cross-correlation to get ``1/upsample`` resolution costs an FFT of +``(H·u, W·u)`` — for u=8 on a 4096² frame that is a 32768² transform. The +matrix-multiply DFT evaluates the correlation only on the ~12×12 window around +the coarse peak, which is what the refinement actually needs. + +The torch and numpy paths run the *same* algorithm through a small operator +adapter, so ``test_drift_translation.py`` can assert they agree bit-closely — that +parity test is what protects the GPU path, since it has no independent reference. +""" +from __future__ import annotations + +import logging +import math +from typing import Any, Callable + +import numpy as np + +from spyde.drift.frames import frame_source +from spyde.drift.model import DriftModel + +log = logging.getLogger(__name__) + +# Guizar-Sicairos: the refinement window spans 1.5 upsampled pixels either side of +# the coarse peak. Matches skimage's `phase_cross_correlation` so the two agree. +_UPSAMPLED_REGION_FACTOR = 1.5 + + +# ── operator adapters ──────────────────────────────────────────────────────── +# The algorithm below is written once against this interface. `_TorchOps` is the +# production path; `_NumpyOps` is the reference the parity test pins it against. + +class _NumpyOps: + name = "numpy" + + def __init__(self, device=None): + self.device = None + + def to_backend(self, a): + return np.asarray(a, dtype=np.float32) + + def fft2(self, a): + return np.fft.fft2(a).astype(np.complex64) + + def ifft2(self, a): + return np.fft.ifft2(a) + + def conj(self, a): + return np.conj(a) + + def abs(self, a): + return np.abs(a) + + def fftfreq(self, n): + return np.fft.fftfreq(n).astype(np.float32) + + def arange(self, n): + return np.arange(n, dtype=np.float32) + + def exp(self, a): + return np.exp(a) + + def masked_argmax(self, mag, mask): + m = np.where(mask, mag, -np.inf) + flat = int(np.argmax(m)) + return divmod(flat, mag.shape[1]) + + def argmax2d(self, mag): + flat = int(np.argmax(mag)) + return divmod(flat, mag.shape[1]) + + def tensordot_last(self, kernel, data): + """``kernel @ data`` contracting kernel's axis 1 with data's LAST axis.""" + return np.tensordot(kernel, data, axes=(1, -1)) + + def scalar(self, a) -> float: + return float(a) + + def mean_abs(self, a) -> float: + return float(np.mean(np.abs(a))) + + def to_numpy(self, a): + return np.asarray(a) + + +class _TorchOps: + name = "torch" + + def __init__(self, device): + import torch + self._torch = torch + self.device = device + + def to_backend(self, a): + t = self._torch + return t.as_tensor(np.ascontiguousarray(a, dtype=np.float32), device=self.device) + + def fft2(self, a): + return self._torch.fft.fft2(a).to(self._torch.complex64) + + def ifft2(self, a): + return self._torch.fft.ifft2(a) + + def conj(self, a): + return self._torch.conj(a) + + def abs(self, a): + return self._torch.abs(a) + + def fftfreq(self, n): + return self._torch.fft.fftfreq(n, device=self.device, dtype=self._torch.float32) + + def arange(self, n): + return self._torch.arange(n, device=self.device, dtype=self._torch.float32) + + def exp(self, a): + return self._torch.exp(a) + + def masked_argmax(self, mag, mask): + t = self._torch + m = mag.masked_fill(~mask, float("-inf")) + flat = int(t.argmax(m.reshape(-1)).item()) + return divmod(flat, mag.shape[1]) + + def argmax2d(self, mag): + t = self._torch + flat = int(t.argmax(mag.reshape(-1)).item()) + return divmod(flat, mag.shape[1]) + + def tensordot_last(self, kernel, data): + return self._torch.tensordot(kernel, data, dims=([1], [data.ndim - 1])) + + def scalar(self, a) -> float: + return float(a.item()) if hasattr(a, "item") else float(a) + + def mean_abs(self, a) -> float: + return float(self._torch.mean(self._torch.abs(a)).item()) + + def to_numpy(self, a): + return a.detach().cpu().numpy() + + +def _resolve_ops(device: str | None): + """Pick the backend: ``cuda`` > ``mps`` > **torch CPU** > numpy. + + **torch CPU beats numpy even with no GPU**, and by a lot — measured on this + box, 120 × 512² frames at upsample=8:: + + numpy 6.57 s 18 frames/s + torch cpu 0.86 s 139 frames/s (7.7x) + torch cuda 0.42 s 284 frames/s (16x) + + The reason is mundane: ``np.fft.fft2`` is single-threaded, ``torch.fft.fft2`` + uses every core. A per-frame FFT is the entire cost of this solver, so that + one difference is the whole gap. An earlier version of this function preferred + numpy on CPU-only machines on the assumption that torch's dispatch overhead + would dominate at one frame at a time; that assumption was wrong by 7.7x. + + numpy is kept as an **explicitly** selectable reference path (``device= + "numpy"``), which is what the backend-parity test pins the torch path against. + """ + if device == "numpy": + return _NumpyOps(None) + try: + import torch + except Exception: + return _NumpyOps(None) + + if device is None: + if torch.cuda.is_available(): + device = "cuda" + elif getattr(torch.backends, "mps", None) is not None and \ + torch.backends.mps.is_available(): + device = "mps" + else: + device = "cpu" + try: + return _TorchOps(torch.device(device)) + except Exception as exc: # pragma: no cover — bad device string + log.warning("[drift] torch device %r unusable (%s); using numpy", device, exc) + return _NumpyOps(None) + + +# ── windows and masks (built once per solve) ────────────────────────────────── + +def _hann2d(ops, h: int, w: int): + """Separable Hann window. + + Without apodisation a feature entering or leaving at the frame edge correlates + against the *border discontinuity* rather than the sample, which reads as a + spurious jump in the drift curve exactly when something interesting is moving + through the field of view. + """ + n = ops.arange(h) + m = ops.arange(w) + wy = 0.5 - 0.5 * _cos(ops, 2.0 * math.pi * n / max(1, h - 1)) + wx = 0.5 - 0.5 * _cos(ops, 2.0 * math.pi * m / max(1, w - 1)) + return wy.reshape(h, 1) * wx.reshape(1, w) + + +def _cos(ops, a): + if ops.name == "torch": + return ops._torch.cos(a) + return np.cos(a) + + +def _shift_mask(ops, h: int, w: int, max_shift: float | None, + min_shift: float | None): + """Which cross-correlation bins are admissible shifts. + + The correlation is un-shifted, so bin ``k`` means shift ``k`` for + ``k <= n//2`` and ``k - n`` above that. Both bounds are separable, so this is + an outer product of two 1-D masks — built once and reused for every frame. + """ + def axis_mask(n: int): + k = np.arange(n) + s = np.where(k > n // 2, k - n, k).astype(np.float64) + ok = np.ones(n, dtype=bool) + if max_shift is not None: + ok &= np.abs(s) <= float(max_shift) + return ok, s + + oky, sy = axis_mask(h) + okx, sx = axis_mask(w) + mask = oky[:, None] & okx[None, :] + if min_shift is not None: + # Exclude the near-zero-shift core. Useful when the reference already + # contains this frame (a running average does), because the trivial + # self-correlation peak at the origin can then outrank the real one. + r = np.hypot(sy[:, None], sx[None, :]) + mask &= r >= float(min_shift) + if not mask.any(): + raise ValueError( + "max_shift/min_shift exclude every possible shift " + f"(max_shift={max_shift}, min_shift={min_shift}, frame={h}x{w})" + ) + if ops.name == "torch": + return ops._torch.as_tensor(mask, device=ops.device) + return mask + + +# ── the correlation ────────────────────────────────────────────────────────── + +def _upsampled_dft(ops, data, region_size: int, upsample: float, offsets): + """Evaluate the inverse DFT of *data* on a small upsampled window. + + Mirrors ``skimage.registration._masked_phase_cross_correlation._upsampled_dft``: + one kernel matmul per axis, contracting the last axis each time. + """ + out = data + shape = tuple(data.shape) + for axis in (1, 0): # last axis first + n = shape[axis] + off = offsets[axis] + # NOTE the /upsample: this is `np.fft.fftfreq(n, upsample)` — frequencies + # scaled to the UPSAMPLED grid. Without it the window still evaluates and + # still finds a peak, but at 1/upsample of the intended resolution, so + # every recovered shift lands on a multiple of 1/upsample and the + # refinement silently does nothing. + freq = ops.fftfreq(n) / float(upsample) + kern = (ops.arange(region_size).reshape(region_size, 1) - float(off)) * \ + freq.reshape(1, n) + if ops.name == "torch": + kern = ops.exp(ops._torch.complex( + ops._torch.zeros_like(kern), -2.0 * math.pi * kern)) + else: + kern = np.exp(-2j * math.pi * kern).astype(np.complex64) + out = ops.tensordot_last(kern, out) + return out + + +def _peak_shift(ops, ref_fft, mov_fft, mask, upsample: float, + normalize: bool) -> tuple[float, float, float]: + """Return ``(dy, dx, sharpness)`` registering *mov* onto *ref*. + + Sign matches ``skimage.registration.phase_cross_correlation`` and + ``scipy.ndimage.shift``: the result is the correction to ADD to the moving + frame (see :mod:`spyde.drift.model`). + """ + product = ref_fft * ops.conj(mov_fft) + if normalize: + # Phase correlation proper: discard magnitude, keep only phase. Gives a + # far sharper peak than plain cross-correlation on images whose spectra + # are dominated by low frequencies, which every real micrograph is. + eps = 1e-12 + product = product / (ops.abs(product) + eps) + + cc = ops.ifft2(product) + mag = ops.abs(cc) + py, px = ops.masked_argmax(mag, mask) + + h, w = int(mag.shape[0]), int(mag.shape[1]) + peak = ops.scalar(mag[py, px]) + baseline = ops.mean_abs(cc) + sharpness = float(peak / baseline) if baseline > 0 else float("nan") + + dy = float(py - h) if py > h // 2 else float(py) + dx = float(px - w) if px > w // 2 else float(px) + + if upsample and upsample > 1: + u = float(upsample) + dy = round(dy * u) / u + dx = round(dx * u) / u + region = int(math.ceil(u * _UPSAMPLED_REGION_FACTOR)) + dftshift = float(region // 2) + offsets = (dftshift - dy * u, dftshift - dx * u) + fine = _upsampled_dft(ops, ops.conj(product), region, u, offsets) + fmag = ops.abs(fine) + my, mx = ops.argmax2d(fmag) + dy += (my - dftshift) / u + dx += (mx - dftshift) / u + + return dy, dx, sharpness + + +def _phase_ramp(ops, h: int, w: int, dy: float, dx: float): + """FFT multiplier that translates a frame by ``(dy, dx)`` exactly. + + ``F{f(y - dy, x - dx)}(k) = exp(-2πi(dy·fy + dx·fx))·F{f}(k)``. This is why + the running reference needs no resampling — see the module docstring. + """ + fy = ops.fftfreq(h).reshape(h, 1) + fx = ops.fftfreq(w).reshape(1, w) + ph = dy * fy + dx * fx + if ops.name == "torch": + return ops.exp(ops._torch.complex( + ops._torch.zeros_like(ph), -2.0 * math.pi * ph)) + return np.exp(-2j * math.pi * ph).astype(np.complex64) + + +# ── public solve ───────────────────────────────────────────────────────────── + +def solve_translation( + data, + *, + upsample: int = 8, + max_shift: float | None = 32.0, + min_shift: float | None = None, + reference: str = "running", + apodize: bool = True, + normalize: bool = True, + device: str | None = None, + progress: Callable[[int, int], None] | None = None, + cancel: Callable[[], bool] | None = None, + provenance: dict[str, Any] | None = None, +) -> DriftModel: + """Solve rigid drift for a frame stack. Returns a :class:`DriftModel`. + + Parameters + ---------- + data + A HyperSpy signal (1-D nav, 2-D signal), a 3-D numpy/dask array, or a + sequence of 2-D frames. Read one frame at a time — never materialised. + upsample + Sub-pixel factor. ``8`` resolves to 1/8 px, which is well past the + ~0.05 px accuracy floor set by noise on real data. + max_shift + Reject correlation peaks implying a larger per-frame shift, in pixels. + Guards against a spurious peak from a periodic lattice — the failure mode + where a crystalline sample locks onto the wrong lattice translation and + the drift curve jumps by exactly one lattice spacing. + min_shift + Exclude peaks *smaller* than this. Off by default; see + :func:`_shift_mask`. + reference + ``"running"`` — running Fourier average (default, robust to one bad + frame); ``"sequential"`` — register to the previous frame and accumulate + (handles large excursions, accumulates error); ``"first"`` or + ``"fixed:"`` — one fixed reference frame. + apodize + Apply a Hann window before transforming. See :func:`_hann2d`. + normalize + True phase correlation (unit-magnitude spectrum). Sharper peak. + device + ``None`` auto-selects CUDA/MPS then falls back to numpy; ``"numpy"`` + forces the reference path; or an explicit torch device string. + progress, cancel + ``progress(done, total)`` is called as frames complete. + ``cancel()`` returning True aborts; frames not yet reached keep NaN + shifts, so a cancelled solve is detectable rather than silently partial. + + Notes + ----- + Frame 0 is the origin by definition and always gets ``(0, 0)``. + """ + if reference not in ("running", "sequential", "first") and \ + not reference.startswith("fixed:"): + raise ValueError( + f"unknown reference {reference!r}; expected 'running', 'sequential', " + "'first' or 'fixed:'" + ) + if upsample < 1: + raise ValueError(f"upsample must be >= 1; got {upsample}") + + n_frames, get_frame, (h, w) = frame_source(data) + ops = _resolve_ops(device) + + shifts = np.full((n_frames, 2), np.nan, dtype=np.float32) + sharp = np.full((n_frames,), np.nan, dtype=np.float32) + + from spyde.device_lock import accelerator_lock + + # MPS is not thread-safe and every torch user in the process shares ONE lock + # (CLAUDE.md § GPU Computing). A null context off MPS, so CUDA keeps its + # stream concurrency. Held across the solve rather than per-frame: the solve + # runs on a worker thread and per-frame acquire/release would be pure + # overhead at thousands of frames. + with accelerator_lock(ops.device): + window = _hann2d(ops, h, w) if apodize else None + mask = _shift_mask(ops, h, w, max_shift, min_shift) + + def frame_fft(i: int): + f = ops.to_backend(get_frame(i)) + if window is not None: + f = f * window + return ops.fft2(f) + + fixed_index = 0 + if reference.startswith("fixed:"): + fixed_index = int(reference.split(":", 1)[1]) + if not 0 <= fixed_index < n_frames: + raise ValueError( + f"fixed reference index {fixed_index} outside 0..{n_frames - 1}" + ) + + first = frame_fft(fixed_index if reference.startswith("fixed:") else 0) + shifts[0] = (0.0, 0.0) + sharp[0] = np.inf if n_frames else np.nan + + ref_fft = first # running accumulator / fixed reference + ref_count = 1 + prev_fft = first # sequential mode + cumulative = np.zeros(2, dtype=np.float64) + + if progress is not None: + progress(1, n_frames) + + for i in range(1, n_frames): + if cancel is not None and cancel(): + log.info("[drift] cancelled at frame %d/%d", i, n_frames) + break + + mov = frame_fft(i) + + if reference == "sequential": + dy, dx, s = _peak_shift(ops, prev_fft, mov, mask, upsample, normalize) + cumulative += (dy, dx) + shifts[i] = cumulative + prev_fft = mov + else: + dy, dx, s = _peak_shift(ops, ref_fft, mov, mask, upsample, normalize) + shifts[i] = (dy, dx) + if reference == "running": + # Fold the ALIGNED frame in via a phase ramp — exact, and no + # resampling blur accumulates over the stack. + aligned = mov * _phase_ramp(ops, h, w, dy, dx) + ref_fft = (ref_fft * ref_count + aligned) / (ref_count + 1) + ref_count += 1 + sharp[i] = s + + if progress is not None: + progress(i + 1, n_frames) + + params = { + "upsample": int(upsample), + "max_shift": None if max_shift is None else float(max_shift), + "min_shift": None if min_shift is None else float(min_shift), + "reference": reference, + "apodize": bool(apodize), + "normalize": bool(normalize), + "backend": ops.name, + "n_frames": int(n_frames), + "frame_shape": [int(h), int(w)], + } + return DriftModel( + shifts=shifts, + kind="rigid", + reference=reference, + residuals=sharp, + params=params, + provenance=provenance, + ) diff --git a/spyde/drift/warp.py b/spyde/drift/warp.py new file mode 100644 index 00000000..03a46d64 --- /dev/null +++ b/spyde/drift/warp.py @@ -0,0 +1,134 @@ +""" +warp.py — apply a drift correction to ONE frame. + +Per-frame by design. This is the function a lazy signal-tree node calls, so the +aligned movie is never materialised (``spyde/drift/__init__.py``), and it is also +what the derived-view reader would call if the per-frame shift transform is ever +added to ``array_cache/readers/per_frame.py`` — which is a signal-tree read-path +change and therefore gated on review, NOT done here. + +Edge policy (locked, plan §A7): the frame keeps its full size and uncovered +pixels become **NaN**, with :func:`coverage_mask` giving the validity map. +Nothing is cropped and nothing is filled with invented data. + +**Downstream contract:** segmentation MUST respect the coverage mask. A NaN-padded +border is the single most likely integration bug in this feature — a threshold +applied to NaN, or a NaN-to-zero conversion, invents a large "particle" along the +edge that then nucleates a spurious track. +""" +from __future__ import annotations + +import numpy as np + + +def _split_shift(shift) -> tuple[np.ndarray, bool]: + s = np.asarray(shift, dtype=np.float64).reshape(-1) + if s.size != 2: + raise ValueError(f"shift must be (dy, dx); got {np.shape(shift)}") + if not np.all(np.isfinite(s)): + raise ValueError(f"shift must be finite; got {s!r}") + return s, bool(np.all(s == np.round(s))) + + +def coverage_mask(shape: tuple[int, int], shift) -> np.ndarray: + """Boolean map of which output pixels come from real source data. + + A pixel is covered when its source coordinate falls inside the source frame. + For a sub-pixel shift the border row/column that would need to interpolate + against off-frame data is treated as **uncovered** — bilinear interpolation + there would silently blend in the fill value. + """ + h, w = int(shape[0]), int(shape[1]) + s, integral = _split_shift(shift) + dy, dx = s + + mask = np.zeros((h, w), dtype=bool) + if integral: + y0, y1 = max(0, int(dy)), min(h, h + int(dy)) + x0, x1 = max(0, int(dx)), min(w, w + int(dx)) + else: + # Output pixel y draws from source y - dy; it needs floor and floor+1, + # so require 0 <= y - dy and y - dy + 1 <= h - 1. + y0 = int(np.ceil(max(0.0, dy))) + y1 = int(np.floor(min(float(h), h + dy - 1.0))) + 1 + x0 = int(np.ceil(max(0.0, dx))) + x1 = int(np.floor(min(float(w), w + dx - 1.0))) + 1 + if y1 > y0 and x1 > x0: + mask[y0:y1, x0:x1] = True + return mask + + +def shift_frame( + frame: np.ndarray, + shift, + *, + order: int = 1, + fill: float = np.nan, + preserve_dtype: bool = False, +) -> np.ndarray: + """Shift *frame* by ``(dy, dx)``, padding uncovered pixels with *fill*. + + Parameters + ---------- + frame + 2-D source frame, any dtype. + shift + ``(dy, dx)`` correction — the value ADDED to coordinates. See + :mod:`spyde.drift.model` for the sign convention. + order + Interpolation order for a sub-pixel shift (1 = bilinear, the default; + 3 = cubic). Ignored for a whole-pixel shift, which is done exactly. + fill + Value for uncovered pixels. NaN by default, which forces a float result. + preserve_dtype + Keep the source dtype. Only honoured for a whole-pixel shift with a + non-NaN *fill* — a uint16 frame cannot hold NaN, and interpolation + cannot be exact in an integer type. Raises otherwise rather than + silently returning something lossy. + + Notes + ----- + A whole-pixel shift takes an exact slice-copy path: no interpolation, no + float promotion, and bit-identical to the source pixels. This matters because + the common case for a well-behaved stage IS an integer shift, and running it + through ``scipy.ndimage.shift`` would resample (and blur) data that did not + need to move sub-pixel at all. + """ + src = np.asarray(frame) + if src.ndim != 2: + raise ValueError(f"frame must be 2-D; got shape {src.shape}") + s, integral = _split_shift(shift) + dy, dx = s + h, w = src.shape + + if preserve_dtype and not (integral and np.isfinite(fill)): + raise ValueError( + "preserve_dtype=True requires a whole-pixel shift and a finite fill " + f"(got shift={s.tolist()}, fill={fill!r}); a sub-pixel shift must " + "interpolate and NaN padding cannot be stored in an integer dtype" + ) + + out_dtype = src.dtype if preserve_dtype else np.float32 + + if integral: + out = np.full((h, w), fill, dtype=out_dtype) + iy, ix = int(dy), int(dx) + # Destination window, and the matching source window. + dy0, dy1 = max(0, iy), min(h, h + iy) + dx0, dx1 = max(0, ix), min(w, w + ix) + if dy1 > dy0 and dx1 > dx0: + out[dy0:dy1, dx0:dx1] = src[dy0 - iy:dy1 - iy, dx0 - ix:dx1 - ix] + return out + + from scipy.ndimage import shift as ndi_shift + + # ndimage cannot propagate NaN through its spline filter without smearing it, + # so interpolate with a finite sentinel and stamp the fill on afterwards using + # the analytic coverage map. This keeps the padded border crisp instead of + # letting a NaN bleed `order` pixels into real data. + work = src.astype(np.float32, copy=False) + out = ndi_shift(work, s, order=order, mode="constant", cval=0.0, prefilter=order > 1) + out = out.astype(out_dtype, copy=False) + cov = coverage_mask((h, w), s) + out[~cov] = fill + return out diff --git a/spyde/particles/__init__.py b/spyde/particles/__init__.py new file mode 100644 index 00000000..f2dcbde4 --- /dev/null +++ b/spyde/particles/__init__.py @@ -0,0 +1,41 @@ +""" +spyde.particles — particle segmentation, measurement and tracking. + +See ``DRIFT_AND_PARTICLES_PLAN.md`` (repo root) for the full design. The shape of +this package is its most important property: **three interchangeable ways to +produce a foreground probability map, and one shared downstream stage.** + + frame ──► [ classical | scribble | prompt ] ──► probability / mask + │ + split_instances() (watershed) + │ + measure_frame() (regionprops → units) + │ + SpyDEParticles (CSR, per frame) + │ + link() (Hungarian) → tracks + +The three engines are not alternatives to choose between — they are three ways to +fill the first box, and they compose (plan §0.4). Everything after the first box is +written once, in :mod:`spyde.particles.classical` (the split) and +:mod:`spyde.particles.measure`. +""" +from __future__ import annotations + +from spyde.particles.classical import ( + THRESHOLD_METHODS, + SegmentParams, + segment_frame, + split_instances, + threshold_mask, +) +from spyde.particles.measure import measure_frame + +__all__ = [ + "SegmentParams", + "THRESHOLD_METHODS", + "segment_frame", + "split_instances", + "threshold_mask", + "measure_frame", +] diff --git a/spyde/particles/classical.py b/spyde/particles/classical.py new file mode 100644 index 00000000..33ad2adf --- /dev/null +++ b/spyde/particles/classical.py @@ -0,0 +1,391 @@ +""" +classical.py — the always-available segmentation engine, and the SHARED +instance-split every engine uses. + +Two halves, and the split between them is the important part: + +* :func:`threshold_mask` / :func:`segment_frame` — the classical engine, a port of + ParticleSpy's ``segptcls.process``. Parameter *names* are kept identical so the + caret is recognisable to anyone arriving from ParticleSpy. +* :func:`split_instances` — takes a foreground **probability or mask** and splits + it into individual particles. This is used by all three engines (classical, + scribble, prompt), which is why it lives here as its own function rather than + inside the classical pipeline. It is the only place in the package that imports + skimage's segmentation machinery. + +Sensitivity is one axis, deliberately +-------------------------------------- +Plan §0.9 makes detection sensitivity the priority over instance splitting, and +notes that sensitivity and separation trade off against each other — a threshold +loose enough to catch a faint particle also merges neighbours. So +:attr:`SegmentParams.sensitivity` is a single 0..1 control that biases the +threshold, and the splitting parameters are secondary. Exposing "threshold offset" +and "split aggressiveness" as independent knobs invites the user to chase one with +the other. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +#: Thresholding methods, matching ParticleSpy's vocabulary. +THRESHOLD_METHODS: tuple[str, ...] = ( + "otsu", "mean", "minimum", "yen", "isodata", "li", + "local", "local_otsu", "niblack", "sauvola", +) + +_LOCAL_METHODS = frozenset({"local", "local_otsu", "niblack", "sauvola"}) + + +@dataclass +class SegmentParams: + """Classical-pipeline parameters. ParticleSpy names where they correspond.""" + + threshold: str = "otsu" + #: 0..1. 0.5 is the method's own threshold; >0.5 is more sensitive (lower + #: threshold, catches fainter particles, merges more); <0.5 is stricter. + sensitivity: float = 0.5 + rb_kernel: int = 0 # rolling-ball radius, 0 = off + gaussian: float = 0.0 # pre-blur sigma, 0 = off + invert: bool = False # dark particles on a bright background + local_size: int = 31 # window for the local threshold methods (odd) + watershed: bool = True # split touching particles + #: Minimum distance between watershed markers, px. **This replaces + #: ParticleSpy's ``watershed_size``**, whose semantics do not transfer: it + #: filtered markers by AREA, which works for their thresholded-distance + #: markers but silently deletes every small particle when markers are local + #: maxima (a 3x3 particle's marker is one pixel, so any area floor erases it). + #: That is precisely the sensitivity failure plan §0.9 exists to prevent. + min_separation: int = 3 + #: Gaussian sigma applied to the distance transform BEFORE peak finding. + #: Suppresses spurious maxima from a ragged boundary without merging genuinely + #: separate particles. 0 disables. + marker_smooth: float = 1.0 + watershed_erosion: int = 0 # erosions before the distance transform + min_size: int = 20 # discard instances smaller than this, px + max_size: int = 0 # discard instances larger than this, px; 0 = off + clear_border: bool = False # drop instances touching the frame edge + extra: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.threshold not in THRESHOLD_METHODS: + raise ValueError( + f"unknown threshold {self.threshold!r}; expected one of " + f"{', '.join(THRESHOLD_METHODS)}" + ) + if not 0.0 <= self.sensitivity <= 1.0: + raise ValueError(f"sensitivity must be in 0..1; got {self.sensitivity}") + if self.threshold in _LOCAL_METHODS and self.local_size % 2 == 0: + # skimage requires an odd window; silently bumping it would make the + # caret's number disagree with what actually ran. + raise ValueError( + f"local_size must be odd for threshold={self.threshold!r}; " + f"got {self.local_size}" + ) + + +# ── preprocessing ──────────────────────────────────────────────────────────── + +def _rolling_ball(img: np.ndarray, radius: int) -> np.ndarray: + """Background flatten via white top-hat, as ParticleSpy does it. + + Note this is the morphological top-hat, not skimage's newer + ``restoration.rolling_ball``. Keeping ParticleSpy's actual operation matters + for the parity gate — the two give visibly different backgrounds on a sloping + carbon film. + """ + from skimage.morphology import square, white_tophat + if radius <= 0: + return img + return white_tophat(img, footprint=square(int(radius))) + + +def _prepare(frame: np.ndarray, p: SegmentParams) -> np.ndarray: + """Rolling-ball → gaussian → invert, returning float32. + + NaN is filled with the finite minimum BEFORE filtering. A drift-corrected + frame carries a NaN border (``spyde.drift.warp``), and every skimage filter + propagates NaN outward, which would erase a band of real data around the edge. + Filling with the minimum makes the padding read as background — the one value + guaranteed not to threshold as a particle. + """ + from scipy.ndimage import gaussian_filter + from skimage.util import invert as sk_invert + + img = np.asarray(frame, dtype=np.float32) + bad = ~np.isfinite(img) + if bad.any(): + finite = img[~bad] + img = img.copy() + img[bad] = finite.min() if finite.size else 0.0 + + if p.rb_kernel > 0: + img = _rolling_ball(img, p.rb_kernel) + if p.gaussian > 0: + img = gaussian_filter(img, float(p.gaussian)) + if p.invert: + # sk_invert on a float image maps x -> -x, which is all thresholding needs. + img = sk_invert(img) + return img + + +# ── thresholding ───────────────────────────────────────────────────────────── + +def _sensitivity_offset(img: np.ndarray, sensitivity: float) -> float: + """Convert 0..1 sensitivity into an additive threshold offset. + + Scaled by the image's own robust spread (5-95 percentile), so the control + behaves the same on a uint16 frame and a normalised float one. At 0.5 the + offset is exactly zero, i.e. the method's own threshold is used unmodified — + which keeps the default path bit-identical to plain Otsu and makes the parity + test against ParticleSpy meaningful. + """ + if sensitivity == 0.5: + return 0.0 + finite = img[np.isfinite(img)] + if finite.size == 0: + return 0.0 + lo, hi = np.percentile(finite, [5, 95]) + spread = float(hi - lo) + if spread <= 0: + return 0.0 + # sensitivity 1.0 lowers the threshold by half the spread; 0.0 raises it. + return -(float(sensitivity) - 0.5) * spread + + +def threshold_mask(img: np.ndarray, p: SegmentParams) -> np.ndarray: + """Boolean foreground mask for a prepared image. + + Raises + ------ + ValueError + If the chosen method cannot be computed on this image. Some methods + genuinely have preconditions — ``minimum`` needs a bimodal histogram and + skimage raises ``RuntimeError`` when it cannot find two maxima, which + happens on a sparse field of small bright particles (nearly all background, + so the histogram is one spike). Re-raised here with the method named and a + suggestion, because the bare skimage error gives the user nothing to act on. + """ + from skimage import filters + + offset = _sensitivity_offset(img, p.sensitivity) + + try: + return _apply_threshold(img, p, offset) + except RuntimeError as exc: + raise ValueError( + f"threshold method {p.threshold!r} failed on this frame ({exc}). " + "It requires a clearly bimodal intensity histogram; a sparse field of " + "small particles does not have one. Try 'otsu', 'yen' or 'li', or a " + "local method such as 'sauvola'." + ) from exc + + +def _apply_threshold(img: np.ndarray, p: SegmentParams, offset: float) -> np.ndarray: + from skimage import filters + + if p.threshold == "otsu": + t = filters.threshold_otsu(img) + elif p.threshold == "mean": + t = filters.threshold_mean(img) + elif p.threshold == "minimum": + t = filters.threshold_minimum(img) + elif p.threshold == "yen": + t = filters.threshold_yen(img) + elif p.threshold == "isodata": + t = filters.threshold_isodata(img) + elif p.threshold == "li": + t = filters.threshold_li(img) + elif p.threshold == "local": + t = filters.threshold_local(img, block_size=p.local_size) + elif p.threshold == "niblack": + t = filters.threshold_niblack(img, window_size=p.local_size) + elif p.threshold == "sauvola": + t = filters.threshold_sauvola(img, window_size=p.local_size) + elif p.threshold == "local_otsu": + from skimage.filters.rank import otsu as rank_otsu + from skimage.morphology import disk + from skimage.util import img_as_ubyte + lo, hi = float(np.nanmin(img)), float(np.nanmax(img)) + norm = np.zeros_like(img) if hi <= lo else (img - lo) / (hi - lo) + t_u8 = rank_otsu(img_as_ubyte(norm), disk(max(1, p.local_size // 2))) + t = lo + (t_u8.astype(np.float32) / 255.0) * (hi - lo) + else: # pragma: no cover — guarded above + raise ValueError(f"unknown threshold {p.threshold!r}") + + return img > (np.asarray(t, dtype=np.float32) + np.float32(offset)) + + +# ── the shared instance split ──────────────────────────────────────────────── + +def split_instances( + foreground: np.ndarray, + p: SegmentParams, + *, + distance_from: np.ndarray | None = None, +) -> np.ndarray: + """Split a foreground mask (or probability map) into labelled instances. + + **Shared by every engine.** ``foreground`` may be boolean, or a float + probability in 0..1 (thresholded at 0.5), which is what the scribble and + prompt engines produce. + + Parameters + ---------- + distance_from + Optional alternative to the binary distance transform for seeding + watershed markers — e.g. a learned boundary-class probability. Passing the + classifier's own notion of "interior" separates touching particles better + than geometry does, which is why the hook exists. + + Returns + ------- + ``(h, w)`` int32 label image, relabelled 1..n with no gaps. + """ + from scipy import ndimage as ndi + from skimage.morphology import binary_erosion, disk + from skimage.segmentation import clear_border, watershed + + fg = np.asarray(foreground) + if fg.ndim != 2: + raise ValueError(f"foreground must be 2-D; got shape {fg.shape}") + if fg.dtype != bool: + fg = fg > 0.5 + + if p.min_size > 0 and fg.any(): + # Own implementation rather than skimage's `remove_small_objects`: that + # function is mid-deprecation (its replacement removes objects smaller + # than OR EQUAL to the threshold, a silent off-by-one against the + # documented `min_size` meaning), and we already need `_drop_small` for + # the post-watershed pass. + lab0, _ = ndi.label(fg) + fg = _drop_small(lab0, int(p.min_size)) > 0 + + if not fg.any(): + return np.zeros(fg.shape, dtype=np.int32) + + if p.watershed: + seed_src = fg + if p.watershed_erosion > 0: + seed_src = fg.copy() + for _ in range(int(p.watershed_erosion)): + seed_src = binary_erosion(seed_src, disk(1)) + + dist = (ndi.distance_transform_edt(seed_src) if distance_from is None + else np.asarray(distance_from, dtype=np.float32) * seed_src) + + markers = _distance_markers(dist, fg, p) + if markers.max() > 0: + labels = watershed(-dist, markers, mask=fg) + else: + labels, _ = ndi.label(fg) + else: + labels, _ = ndi.label(fg) + + labels = np.asarray(labels, dtype=np.int32) + + if p.clear_border: + labels = clear_border(labels) + if p.max_size > 0: + labels = _drop_large(labels, int(p.max_size)) + if p.min_size > 0: + labels = _drop_small(labels, int(p.min_size)) + + return _relabel_sequential(labels) + + +def _distance_markers(dist: np.ndarray, fg: np.ndarray, + p: SegmentParams) -> np.ndarray: + """One marker per particle, from the local maxima of the distance transform. + + Two failure modes have to be avoided at once, and they pull in opposite + directions: + + * **Thresholding the distance map merges neighbours.** Taking connected + components of ``dist > k`` looks appealing and is wrong: two discs whose + edges overlap have ``dist > k`` everywhere in the join, so the union is one + connected core and watershed is handed a single marker — it then cannot + split anything. This was the original implementation here and the touching- + discs test caught it. + * **Raw peak maxima over-split a round particle.** A disc's distance maximum + is a flat plateau, so a maximum filter marks every plateau pixel; treated as + separate markers, one disc becomes a pie chart of wedges. + + ``peak_local_max`` followed by ``ndi.label`` resolves both: the plateau's + pixels are contiguous, so labelling collapses them into ONE marker, while two + genuinely separate maxima stay separate. Smoothing the distance map first + removes the boundary-roughness maxima that would otherwise fragment an + irregular particle. + + No marker is ever dropped for being small — see + :attr:`SegmentParams.min_separation`. + """ + from scipy import ndimage as ndi + from skimage.feature import peak_local_max + + if dist.max() <= 0: + return np.zeros(fg.shape, dtype=np.int32) + + dist_pk = dist + if p.marker_smooth > 0: + from scipy.ndimage import gaussian_filter + dist_pk = gaussian_filter(dist.astype(np.float32), float(p.marker_smooth)) + + coords = peak_local_max( + dist_pk, + min_distance=max(1, int(p.min_separation)), + labels=fg, + exclude_border=False, + ) + peaks = np.zeros(fg.shape, dtype=bool) + if len(coords): + peaks[tuple(coords.T)] = True + markers, _ = ndi.label(peaks) + return np.asarray(markers, dtype=np.int32) + + +def _drop_small(labels: np.ndarray, min_size: int) -> np.ndarray: + counts = np.bincount(labels.ravel()) + bad = np.flatnonzero(counts < min_size) + bad = bad[bad > 0] + if bad.size: + labels = labels.copy() + labels[np.isin(labels, bad)] = 0 + return labels + + +def _drop_large(labels: np.ndarray, max_size: int) -> np.ndarray: + counts = np.bincount(labels.ravel()) + bad = np.flatnonzero(counts > max_size) + bad = bad[bad > 0] + if bad.size: + labels = labels.copy() + labels[np.isin(labels, bad)] = 0 + return labels + + +def _relabel_sequential(labels: np.ndarray) -> np.ndarray: + """Renumber to 1..n with no gaps, so ``label`` is a usable per-frame index.""" + present = np.unique(labels) + present = present[present > 0] + if present.size == 0: + return np.zeros(labels.shape, dtype=np.int32) + lut = np.zeros(int(labels.max()) + 1, dtype=np.int32) + lut[present] = np.arange(1, present.size + 1, dtype=np.int32) + return lut[labels] + + +# ── the classical engine ───────────────────────────────────────────────────── + +def segment_frame(frame: np.ndarray, p: SegmentParams | None = None) -> np.ndarray: + """Classical segmentation of one frame → int32 label image. + + ``prepare → threshold → split_instances``. The whole ParticleSpy pipeline, + with the instance step factored out so the other two engines share it. + """ + p = p or SegmentParams() + prepared = _prepare(frame, p) + fg = threshold_mask(prepared, p) + return split_instances(fg, p) diff --git a/spyde/particles/measure.py b/spyde/particles/measure.py new file mode 100644 index 00000000..186bde2e --- /dev/null +++ b/spyde/particles/measure.py @@ -0,0 +1,229 @@ +""" +measure.py — turn a label image into calibrated particle property rows. + +ParticleSpy's measured-property set, computed with ``regionprops_table`` (one +vectorised pass, never a Python loop over regions) and converted to physical units +exactly once, here. Every downstream consumer — the table, the histogram, the +kymograph, the CSV — reads the already-calibrated numbers, so there is one place +a unit can be wrong instead of a dozen. + +Two things that look like details and are not: + +* **NaN in the intensity image is respected, not coerced.** A drift-corrected + frame has a NaN-padded border (``spyde.drift.warp``). Letting ``np.nan`` reach + a plain ``mean`` makes every particle touching the border report NaN intensity; + coercing NaN to zero invents a dark rim that biases the measurement instead. + Both are wrong, so intensity statistics are computed over finite pixels only, + and a particle with no finite pixels is dropped (see :func:`measure_frame`). +* **Circularity uses ParticleSpy's convention**, ``4*pi*area / perimeter^2``, + which is 1 for a perfect disc. It is computed in PIXELS before calibration — + it is dimensionless, so calibrating area and perimeter separately and then + dividing would introduce a spurious ``scale`` factor. +""" +from __future__ import annotations + +import numpy as np + +from spyde.signals.particles import COL, N_COLUMNS + +# regionprops names we ask for. Kept minimal: each extra property is another pass +# over every region, and the ones omitted here are cheap to derive from these. +_PROPS = ( + "label", + "centroid", + "area", + "equivalent_diameter_area", + "major_axis_length", + "minor_axis_length", + "perimeter", + "eccentricity", + "solidity", + "bbox", +) + + +def measure_frame( + labels: np.ndarray, + intensity: np.ndarray | None = None, + *, + t: int = 0, + scale: float = 1.0, + background_ring: int = 3, + min_area_px: int = 0, +) -> tuple[np.ndarray, list[np.ndarray]]: + """Measure every instance in *labels*. + + Parameters + ---------- + labels + ``(h, w)`` integer label image; 0 is background. + intensity + Optional source frame for intensity statistics. May contain NaN (a + drift-corrected border does); NaN pixels are excluded rather than coerced. + t + Frame index stamped into the ``t`` column. + scale + Pixel size in physical units. Lengths are multiplied by it and areas by + its square; dimensionless quantities are untouched. + background_ring + Width in pixels of the dilated ring outside each particle used for the + local ``background`` measurement. 0 disables it (leaves NaN). + min_area_px + Drop instances smaller than this many pixels. Applied here, in pixels, + because it is a detector-resolution question, not a physical one. + + Returns + ------- + (rows, contours) + ``rows`` is ``(n, N_COLUMNS)`` float32 matching + :data:`spyde.signals.particles.COLUMNS`, with ``track_id`` set to -1. + ``contours`` is a list of ``(k, 2)`` int16 ``(y, x)`` outlines, one per + row and in the same order — the 1:1 correspondence + ``SpyDEParticles.from_frames`` requires. + """ + from skimage.measure import regionprops_table + + lab = np.asarray(labels) + if lab.ndim != 2: + raise ValueError(f"labels must be 2-D; got shape {lab.shape}") + if intensity is not None: + inten = np.asarray(intensity, dtype=np.float64) + if inten.shape != lab.shape: + raise ValueError( + f"intensity shape {inten.shape} != labels shape {lab.shape}" + ) + else: + inten = None + + if lab.max() <= 0: + return np.zeros((0, N_COLUMNS), np.float32), [] + + tbl = regionprops_table(lab, properties=_PROPS) + n = len(tbl["label"]) + + area_px = tbl["area"].astype(np.float64) + perim_px = tbl["perimeter"].astype(np.float64) + + keep = area_px >= float(min_area_px) + + # Dimensionless, so computed in pixels BEFORE calibration. + with np.errstate(divide="ignore", invalid="ignore"): + circularity = np.where(perim_px > 0, + 4.0 * np.pi * area_px / perim_px ** 2, np.nan) + + rows = np.zeros((n, N_COLUMNS), dtype=np.float32) + rows[:, COL["t"]] = float(t) + rows[:, COL["label"]] = tbl["label"] + rows[:, COL["y"]] = tbl["centroid-0"] * scale + rows[:, COL["x"]] = tbl["centroid-1"] * scale + rows[:, COL["area"]] = area_px * scale ** 2 + rows[:, COL["equiv_diameter"]] = tbl["equivalent_diameter_area"] * scale + rows[:, COL["major_axis"]] = tbl["major_axis_length"] * scale + rows[:, COL["minor_axis"]] = tbl["minor_axis_length"] * scale + rows[:, COL["perimeter"]] = perim_px * scale + rows[:, COL["circularity"]] = circularity + rows[:, COL["eccentricity"]] = tbl["eccentricity"] + rows[:, COL["solidity"]] = tbl["solidity"] + rows[:, COL["bbox_y0"]] = tbl["bbox-0"] + rows[:, COL["bbox_x0"]] = tbl["bbox-1"] + rows[:, COL["bbox_y1"]] = tbl["bbox-2"] + rows[:, COL["bbox_x1"]] = tbl["bbox-3"] + rows[:, COL["track_id"]] = -1.0 + rows[:, COL["intensity_mean"]] = np.nan + rows[:, COL["intensity_max"]] = np.nan + rows[:, COL["intensity_std"]] = np.nan + rows[:, COL["background"]] = np.nan + + if inten is not None: + _fill_intensity(rows, lab, inten, tbl, keep, background_ring) + + contours = _contours(lab, tbl) + + rows = rows[keep] + contours = [c for c, k in zip(contours, keep) if k] + return np.ascontiguousarray(rows), contours + + +def _fill_intensity(rows, lab, inten, tbl, keep, ring: int) -> None: + """Intensity statistics over FINITE pixels only, plus a local background ring. + + Done with per-particle bbox crops rather than one pass per statistic over the + whole frame: a crop is tiny, and it is also the only way to compute the ring + without dilating a full-frame mask once per particle (which at hundreds of + particles on a 4096^2 frame is the dominant cost of the whole measure step). + """ + from scipy.ndimage import binary_dilation + + h, w = lab.shape + for i in range(len(tbl["label"])): + if not keep[i]: + continue + lbl = int(tbl["label"][i]) + y0, x0 = int(tbl["bbox-0"][i]), int(tbl["bbox-1"][i]) + y1, x1 = int(tbl["bbox-2"][i]), int(tbl["bbox-3"][i]) + + # Pad by the ring width so the dilated boundary fits inside the crop. + py0, px0 = max(0, y0 - ring - 1), max(0, x0 - ring - 1) + py1, px1 = min(h, y1 + ring + 1), min(w, x1 + ring + 1) + sub_lab = lab[py0:py1, px0:px1] + sub_int = inten[py0:py1, px0:px1] + m = sub_lab == lbl + + vals = sub_int[m] + vals = vals[np.isfinite(vals)] + if vals.size: + rows[i, COL["intensity_mean"]] = vals.mean() + rows[i, COL["intensity_max"]] = vals.max() + # Normalised by the max, matching ParticleSpy, so it is comparable + # between particles of very different absolute brightness. + mx = vals.max() + rows[i, COL["intensity_std"]] = (vals.std() / mx) if mx else np.nan + + if ring > 0: + grown = binary_dilation(m, iterations=int(ring)) + # The ring is what the dilation added, minus anything belonging to a + # NEIGHBOURING particle — otherwise a touching particle's body is + # measured as this one's background, which is exactly backwards. + ring_mask = grown & ~m & (sub_lab == 0) + bvals = sub_int[ring_mask] + bvals = bvals[np.isfinite(bvals)] + if bvals.size: + rows[i, COL["background"]] = bvals.mean() + + +def _contours(lab: np.ndarray, tbl) -> list[np.ndarray]: + """One int16 outline per region, in ``tbl`` order. + + Traced inside each region's padded bbox crop, not on the whole frame: a + frame-wide ``find_contours`` would return every region's outline in arbitrary + order with no label attached, and re-associating them is both slow and + ambiguous where particles touch. + """ + from skimage.measure import find_contours + + h, w = lab.shape + out: list[np.ndarray] = [] + for i in range(len(tbl["label"])): + lbl = int(tbl["label"][i]) + y0, x0 = int(tbl["bbox-0"][i]), int(tbl["bbox-1"][i]) + y1, x1 = int(tbl["bbox-2"][i]), int(tbl["bbox-3"][i]) + py0, px0 = max(0, y0 - 1), max(0, x0 - 1) + py1, px1 = min(h, y1 + 1), min(w, x1 + 1) + sub = (lab[py0:py1, px0:px1] == lbl).astype(np.float32) + cs = find_contours(sub, 0.5) + if not cs: + # Degenerate (single pixel, or a region the tracer cannot close): + # fall back to the bbox corners so every row still has an outline and + # the 1:1 correspondence holds. + out.append(np.array( + [[y0, x0], [y0, x1 - 1], [y1 - 1, x1 - 1], [y1 - 1, x0]], + dtype=np.int16)) + continue + c = max(cs, key=len) # outer boundary + c = np.rint(c).astype(np.int32) + c[:, 0] += py0 + c[:, 1] += px0 + np.clip(c[:, 0], 0, h - 1, out=c[:, 0]) + np.clip(c[:, 1], 0, w - 1, out=c[:, 1]) + out.append(c.astype(np.int16)) + return out diff --git a/spyde/signals/__init__.py b/spyde/signals/__init__.py index b3e8dd65..0239a077 100644 --- a/spyde/signals/__init__.py +++ b/spyde/signals/__init__.py @@ -8,5 +8,12 @@ from spyde.signals.diffraction_vectors import SpyDEDiffractionVectors from spyde.signals.orientation_map import SpyDEOrientationMap from spyde.signals.insitu import InSitu, LazyInSitu +from spyde.signals.particles import SpyDEParticles -__all__ = ["SpyDEDiffractionVectors", "SpyDEOrientationMap", "InSitu", "LazyInSitu"] +__all__ = [ + "SpyDEDiffractionVectors", + "SpyDEOrientationMap", + "InSitu", + "LazyInSitu", + "SpyDEParticles", +] diff --git a/spyde/signals/particles.py b/spyde/signals/particles.py new file mode 100644 index 00000000..69ae505b --- /dev/null +++ b/spyde/signals/particles.py @@ -0,0 +1,435 @@ +""" +particles.py — :class:`SpyDEParticles`, ragged per-frame particle storage. + +Particles-per-frame is a **ragged per-navigation-position collection** — exactly +the shape :class:`spyde.signals.diffraction_vectors.SpyDEDiffractionVectors` +already solves. This mirrors that design rather than inventing a second pattern, +and rather than ParticleSpy's list-of-``Particle``-objects, which does not survive +the target scale. + +Why not a list of objects, and why not full-frame label images +-------------------------------------------------------------- +The target is thousands of frames with hundreds of particles each — around 1.5M +particles (DRIFT_AND_PARTICLES_PLAN.md §0.1). Do the arithmetic before choosing a +representation: + +=========================================== ============ ========== +representation per particle total +=========================================== ============ ========== +property row (21 x float32) 84 B 126 MB +bbox bitmap (packed 64^2 crop) 512 B 770 MB +contour polygon (~40 pts x 2 x float32) 320 B 480 MB +**contour polygon, int16** **~80 B** **120 MB** +=========================================== ============ ========== + +So: **properties always resident, outlines as int16 contours, and masks are +optional.** A full-frame ``int32`` label image is 64 MB *per frame* at 4096^2 and +is never stored at any setting — :meth:`SpyDEParticles.render_frame` paints one +frame on demand from its contours. + +Contours are quantised to whole pixels. That is a **display** fidelity choice, not +a measurement one: every measured quantity lives in the property row, computed at +full precision from the original mask by :mod:`spyde.particles.measure`. Rounding +an outline for drawing cannot corrupt an area, because the area was never derived +from the outline. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Sequence + +import numpy as np + +FORMAT_VERSION = 1 + +#: Column layout of ``flat_buffer``. Order is load-bearing — it is the on-disk +#: layout. Append new columns at the END and bump ``FORMAT_VERSION``. +COLUMNS: tuple[str, ...] = ( + "t", # frame index (float for a uniform buffer dtype) + "label", # per-frame instance label, 1-based (0 = background) + "y", "x", # centroid, calibrated units + "area", + "equiv_diameter", + "major_axis", "minor_axis", + "perimeter", + "circularity", + "eccentricity", + "solidity", + "intensity_mean", "intensity_max", "intensity_std", + "background", # mean intensity in the dilated boundary ring + "bbox_y0", "bbox_x0", "bbox_y1", "bbox_x1", # pixel indices, half-open + "track_id", # -1 until the linker runs +) +COL: dict[str, int] = {name: i for i, name in enumerate(COLUMNS)} +N_COLUMNS = len(COLUMNS) + +#: Columns a user can sort, histogram or colour by — i.e. real measurements, +#: excluding bookkeeping. Drives the table dock and the histogram window. +MEASURED_COLUMNS: tuple[str, ...] = ( + "area", "equiv_diameter", "major_axis", "minor_axis", "perimeter", + "circularity", "eccentricity", "solidity", + "intensity_mean", "intensity_max", "intensity_std", "background", +) + +#: Which measured columns scale with length, area, or not at all — used to apply +#: pixel-size calibration exactly once, in one place. +_LENGTH_COLUMNS = ("y", "x", "equiv_diameter", "major_axis", "minor_axis", "perimeter") +_AREA_COLUMNS = ("area",) + + +@dataclass +class SpyDEParticles: + """Ragged per-frame particle table with optional outlines. + + Parameters + ---------- + flat_buffer + ``(N_total, N_COLUMNS)`` float32, sorted by frame index ``t``. + t_offsets + ``(n_frames + 1,)`` int64 CSR row pointers. ``t_offsets[i]:t_offsets[i+1]`` + is frame *i*'s slice — an O(1) lookup, no search. + frame_shape + ``(h, w)`` of the source frames, in pixels. + contours, contour_offsets + Optional outlines: ``(M, 2)`` int16 ``(y, x)`` pixel coordinates and an + ``(N_total + 1,)`` int64 index. ``None`` when segmentation ran with + ``store_masks=False`` (the default for very long movies). + scale, units + Pixel size and its unit, so ``area`` is in ``units**2``. ``scale=1.0`` with + ``units="px"`` means uncalibrated. + """ + + flat_buffer: np.ndarray + t_offsets: np.ndarray + frame_shape: tuple[int, int] + contours: np.ndarray | None = None + contour_offsets: np.ndarray | None = None + scale: float = 1.0 + units: str = "px" + params: dict[str, Any] = field(default_factory=dict) + provenance: dict[str, Any] | None = None + + def __post_init__(self) -> None: + self.flat_buffer = np.ascontiguousarray(self.flat_buffer, dtype=np.float32) + if self.flat_buffer.ndim != 2 or self.flat_buffer.shape[1] != N_COLUMNS: + raise ValueError( + f"flat_buffer must be (N, {N_COLUMNS}); got {self.flat_buffer.shape}" + ) + self.t_offsets = np.ascontiguousarray(self.t_offsets, dtype=np.int64) + if self.t_offsets.ndim != 1 or self.t_offsets.size < 1: + raise ValueError(f"t_offsets must be 1-D and non-empty; got {self.t_offsets.shape}") + if int(self.t_offsets[0]) != 0 or int(self.t_offsets[-1]) != len(self.flat_buffer): + raise ValueError( + f"t_offsets must span 0..{len(self.flat_buffer)}; got " + f"{int(self.t_offsets[0])}..{int(self.t_offsets[-1])}" + ) + if np.any(np.diff(self.t_offsets) < 0): + raise ValueError("t_offsets must be non-decreasing") + self.frame_shape = (int(self.frame_shape[0]), int(self.frame_shape[1])) + + if (self.contours is None) != (self.contour_offsets is None): + raise ValueError("contours and contour_offsets must both be set or both None") + if self.contours is not None: + self.contours = np.ascontiguousarray(self.contours, dtype=np.int16) + if self.contours.ndim != 2 or self.contours.shape[1] != 2: + raise ValueError(f"contours must be (M, 2); got {self.contours.shape}") + self.contour_offsets = np.ascontiguousarray(self.contour_offsets, dtype=np.int64) + if self.contour_offsets.size != len(self.flat_buffer) + 1: + raise ValueError( + f"contour_offsets must be ({len(self.flat_buffer) + 1},); " + f"got {self.contour_offsets.shape}" + ) + + # ── shape ──────────────────────────────────────────────────────────────── + + @property + def n_particles(self) -> int: + return int(len(self.flat_buffer)) + + @property + def n_frames(self) -> int: + return int(self.t_offsets.size - 1) + + @property + def has_masks(self) -> bool: + return self.contours is not None + + @property + def has_tracks(self) -> bool: + """True once the linker has assigned track ids.""" + if self.n_particles == 0: + return False + return bool(np.any(self.flat_buffer[:, COL["track_id"]] >= 0)) + + # ── per-frame access ───────────────────────────────────────────────────── + + def at(self, t: int) -> np.ndarray: + """Frame *t*'s ``(n, N_COLUMNS)`` block. O(1) — a view, not a copy.""" + t = int(t) + if not 0 <= t < self.n_frames: + raise IndexError(f"frame {t} outside 0..{self.n_frames - 1}") + return self.flat_buffer[self.t_offsets[t]:self.t_offsets[t + 1]] + + def indices_at(self, t: int) -> np.ndarray: + """Global particle indices belonging to frame *t*.""" + t = int(t) + if not 0 <= t < self.n_frames: + raise IndexError(f"frame {t} outside 0..{self.n_frames - 1}") + return np.arange(self.t_offsets[t], self.t_offsets[t + 1], dtype=np.int64) + + def column(self, name: str) -> np.ndarray: + """One column across every particle.""" + try: + return self.flat_buffer[:, COL[name]] + except KeyError: + raise KeyError( + f"unknown column {name!r}; available: {', '.join(COLUMNS)}" + ) from None + + def contour_at(self, index: int) -> np.ndarray: + """``(k, 2)`` int16 ``(y, x)`` outline of global particle *index*.""" + if self.contours is None: + raise ValueError( + "no outlines stored (segmentation ran with store_masks=False)" + ) + i = int(index) + if not 0 <= i < self.n_particles: + raise IndexError(f"particle {i} outside 0..{self.n_particles - 1}") + return self.contours[self.contour_offsets[i]:self.contour_offsets[i + 1]] + + # ── navigator traces ───────────────────────────────────────────────────── + + def count_series(self) -> np.ndarray: + """``(n_frames,)`` particle count per frame — the navigator's count lane. + + Straight from the CSR row pointers, so it is O(n_frames) regardless of how + many particles there are. + """ + return np.diff(self.t_offsets).astype(np.float32) + + def property_series(self, name: str, reduce: str = "mean") -> np.ndarray: + """``(n_frames,)`` per-frame reduction of a column — e.g. mean size lane. + + Empty frames yield NaN rather than 0: a frame with no particles has no + mean size, and plotting it as zero would draw a spurious spike down to the + axis that reads as a real physical event. + """ + col = self.column(name) + fn = {"mean": np.mean, "sum": np.sum, "max": np.max, + "min": np.min, "median": np.median, "std": np.std}.get(reduce) + if fn is None: + raise ValueError( + f"unknown reduce {reduce!r}; expected mean/sum/max/min/median/std" + ) + out = np.full(self.n_frames, np.nan, dtype=np.float32) + for t in range(self.n_frames): + s, e = self.t_offsets[t], self.t_offsets[t + 1] + if e > s: + vals = col[s:e] + finite = vals[np.isfinite(vals)] + if finite.size: + out[t] = fn(finite) + return out + + # ── rendering ──────────────────────────────────────────────────────────── + + def render_frame(self, t: int, *, value: str = "label") -> np.ndarray: + """Paint frame *t*'s particles into an ``int32`` label image. + + Built on demand and never cached here — a 4096^2 label image is 64 MB, so + holding even a handful would dwarf the entire particle table. The caller + (the overlay) keeps only the frame it is displaying. + + Parameters + ---------- + value + ``"label"`` fills each particle with its per-frame label; + ``"track"`` fills with ``track_id + 1`` so the overlay can colour by + identity across frames (0 stays background). ``"index"`` fills with the + global particle index + 1, which is what a click-to-select hit test + wants. + """ + from skimage.draw import polygon as sk_polygon + + if self.contours is None: + raise ValueError( + "cannot render outlines: segmentation ran with store_masks=False" + ) + h, w = self.frame_shape + out = np.zeros((h, w), dtype=np.int32) + for gi in self.indices_at(t): + c = self.contour_at(gi) + if len(c) < 3: + continue + rr, cc = sk_polygon(c[:, 0].astype(np.intp), c[:, 1].astype(np.intp), + shape=(h, w)) + if value == "label": + fill = int(self.flat_buffer[gi, COL["label"]]) + elif value == "track": + fill = int(self.flat_buffer[gi, COL["track_id"]]) + 1 + elif value == "index": + fill = int(gi) + 1 + else: + raise ValueError( + f"unknown value {value!r}; expected 'label', 'track' or 'index'" + ) + out[rr, cc] = fill + return out + + def mask_at(self, index: int) -> tuple[np.ndarray, tuple[int, int, int, int]]: + """``(mask, (y0, x0, y1, x1))`` — one particle's boolean mask and its bbox. + + Cropped to the bounding box, so this stays small no matter the frame size. + This is what Wave D's per-particle mean diffraction pattern slices with. + """ + from skimage.draw import polygon as sk_polygon + + c = self.contour_at(index) + row = self.flat_buffer[int(index)] + y0, x0 = int(row[COL["bbox_y0"]]), int(row[COL["bbox_x0"]]) + y1, x1 = int(row[COL["bbox_y1"]]), int(row[COL["bbox_x1"]]) + h, w = max(1, y1 - y0), max(1, x1 - x0) + m = np.zeros((h, w), dtype=bool) + if len(c) >= 3: + rr, cc = sk_polygon((c[:, 0] - y0).astype(np.intp), + (c[:, 1] - x0).astype(np.intp), shape=(h, w)) + m[rr, cc] = True + return m, (y0, x0, y1, x1) + + # ── export ─────────────────────────────────────────────────────────────── + + def to_dataframe(self): + """A pandas DataFrame of every particle. Requires pandas at call time.""" + import pandas as pd + return pd.DataFrame(self.flat_buffer, columns=list(COLUMNS)) + + def to_csv(self, path: str) -> None: + """Write the property table as CSV, with a units line in the header.""" + header = ",".join(COLUMNS) + np.savetxt( + path, self.flat_buffer, delimiter=",", header=header, comments="", + fmt="%.6g", + ) + + # ── serialisation ──────────────────────────────────────────────────────── + + def save(self, path: str) -> None: + meta = { + "format_version": FORMAT_VERSION, + "columns": list(COLUMNS), + "frame_shape": list(self.frame_shape), + "scale": float(self.scale), + "units": self.units, + "params": self.params, + "provenance": self.provenance, + } + arrays = { + "flat_buffer": self.flat_buffer, + "t_offsets": self.t_offsets, + "meta": np.array(json.dumps(meta)), + } + if self.contours is not None: + arrays["contours"] = self.contours + arrays["contour_offsets"] = self.contour_offsets + np.savez_compressed(path, **arrays) + + @classmethod + def load(cls, path: str) -> "SpyDEParticles": + with np.load(path, allow_pickle=False) as z: + meta = json.loads(str(z["meta"].item())) + ver = meta.get("format_version") + if ver != FORMAT_VERSION: + raise ValueError( + f"unsupported SpyDEParticles format version {ver!r} " + f"(this build reads {FORMAT_VERSION})" + ) + saved_cols = tuple(meta.get("columns") or ()) + if saved_cols != COLUMNS: + raise ValueError( + "column layout changed since this file was written " + f"({len(saved_cols)} columns on disk, {N_COLUMNS} expected)" + ) + return cls( + flat_buffer=z["flat_buffer"], + t_offsets=z["t_offsets"], + frame_shape=tuple(meta["frame_shape"]), + contours=z["contours"] if "contours" in z.files else None, + contour_offsets=(z["contour_offsets"] + if "contour_offsets" in z.files else None), + scale=meta.get("scale", 1.0), + units=meta.get("units", "px"), + params=meta.get("params") or {}, + provenance=meta.get("provenance"), + ) + + # ── construction ───────────────────────────────────────────────────────── + + @classmethod + def from_frames( + cls, + per_frame: Sequence[np.ndarray], + *, + frame_shape: tuple[int, int], + contours_per_frame: Sequence[Sequence[np.ndarray]] | None = None, + scale: float = 1.0, + units: str = "px", + params: dict[str, Any] | None = None, + provenance: dict[str, Any] | None = None, + ) -> "SpyDEParticles": + """Build from a per-frame list of ``(n_i, N_COLUMNS)`` property blocks. + + *contours_per_frame*, when given, must line up exactly with *per_frame* — + one outline per row. A mismatch raises rather than silently pairing the + wrong outline with a particle, which would draw plausible nonsense. + """ + n_frames = len(per_frame) + blocks: list[np.ndarray] = [] + counts: list[int] = [] + for i, blk in enumerate(per_frame): + b = np.zeros((0, N_COLUMNS), np.float32) if blk is None or len(blk) == 0 \ + else np.asarray(blk, dtype=np.float32) + if b.ndim != 2 or b.shape[1] != N_COLUMNS: + raise ValueError( + f"frame {i}: expected (n, {N_COLUMNS}); got {b.shape}" + ) + blocks.append(b) + counts.append(len(b)) + + flat = (np.concatenate(blocks, axis=0) if blocks + else np.zeros((0, N_COLUMNS), np.float32)) + offsets = np.concatenate([[0], np.cumsum(counts)]).astype(np.int64) + + contours = contour_offsets = None + if contours_per_frame is not None: + if len(contours_per_frame) != n_frames: + raise ValueError( + f"contours_per_frame has {len(contours_per_frame)} frames, " + f"per_frame has {n_frames}" + ) + polys: list[np.ndarray] = [] + for i, (cs, n) in enumerate(zip(contours_per_frame, counts)): + cs = list(cs or ()) + if len(cs) != n: + raise ValueError( + f"frame {i}: {len(cs)} outlines for {n} particles — " + "outlines must correspond 1:1 with property rows" + ) + polys.extend(np.asarray(c, dtype=np.int16).reshape(-1, 2) for c in cs) + contours = (np.concatenate(polys, axis=0) if polys + else np.zeros((0, 2), np.int16)) + contour_offsets = np.concatenate( + [[0], np.cumsum([len(p) for p in polys])]).astype(np.int64) + + return cls( + flat_buffer=flat, t_offsets=offsets, frame_shape=frame_shape, + contours=contours, contour_offsets=contour_offsets, + scale=scale, units=units, params=params or {}, provenance=provenance, + ) + + def __repr__(self) -> str: + return ( + f"SpyDEParticles({self.n_particles} particles over {self.n_frames} " + f"frames, {self.frame_shape[0]}x{self.frame_shape[1]} px, " + f"masks={self.has_masks}, tracks={self.has_tracks})" + ) diff --git a/spyde/tests/migrated/test_drift_translation.py b/spyde/tests/migrated/test_drift_translation.py new file mode 100644 index 00000000..1889cf80 --- /dev/null +++ b/spyde/tests/migrated/test_drift_translation.py @@ -0,0 +1,419 @@ +""" +Tests for spyde.drift — rigid translation solve, warp, and DriftModel. + +The acceptance gate from DRIFT_AND_PARTICLES_PLAN.md is numerical, not +structural: recover a synthetically applied shift to better than 0.1 px, and +agree with ``skimage.registration.phase_cross_correlation`` on the same data. +That is what most of this file asserts. + +Qt-free and dask-free — these are pure-compute tests on small synthetic stacks. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from spyde.drift import DriftModel, coverage_mask, frame_source, shift_frame +from spyde.drift.translation import solve_translation + +# The solver's own tolerance target. Sub-pixel ground truth on a smooth +# synthetic scene should land well inside this. +GATE_PX = 0.1 + + +# ── synthetic data ─────────────────────────────────────────────────────────── + +def _scene(h=96, w=112, seed=3, noise=0.02): + """A smooth, asymmetric, non-periodic scene. + + Asymmetric on purpose: a symmetric scene correlates equally well at several + offsets, so a sign error or an axis swap would still pass. Non-periodic on + purpose: a lattice invites the exact wrong-translation lock that ``max_shift`` + exists to prevent, which is a separate test. + + ``noise=0`` gives a band-limited scene, needed wherever a test resamples + twice — bilinear interpolation legitimately destroys pixel-scale noise, so a + round-trip assertion on a noisy scene measures interpolation loss, not the + property under test. + """ + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:h, 0:w].astype(np.float64) + img = np.zeros((h, w), dtype=np.float64) + # A handful of gaussian blobs at irregular positions and widths. + for cy, cx, amp, sig in [ + (0.28 * h, 0.22 * w, 1.0, 5.0), + (0.61 * h, 0.44 * w, 0.7, 8.0), + (0.38 * h, 0.73 * w, 0.9, 4.0), + (0.79 * h, 0.66 * w, 0.5, 6.5), + (0.17 * h, 0.58 * w, 0.6, 3.5), + ]: + img += amp * np.exp(-((yy - cy) ** 2 + (xx - cx) ** 2) / (2 * sig ** 2)) + if noise: + img += noise * rng.standard_normal((h, w)) + return img + + +def _shifted_stack(shifts, h=96, w=112, seed=3): + """Stack whose frame i is the scene translated by ``-shifts[i]``. + + So the CORRECTION needed for frame i is ``+shifts[i]`` — matching the + DriftModel sign convention. Built by Fourier phase ramp so sub-pixel truth is + exact rather than interpolated, which keeps the 0.1 px gate meaningful. + """ + base = _scene(h, w, seed) + fy = np.fft.fftfreq(h)[:, None] + fx = np.fft.fftfreq(w)[None, :] + F = np.fft.fft2(base) + frames = [] + for dy, dx in shifts: + # Applying -shift here means +shift is the correction. + ramp = np.exp(-2j * np.pi * (-dy * fy + -dx * fx)) + frames.append(np.real(np.fft.ifft2(F * ramp))) + return np.stack(frames).astype(np.float32) + + +class TestFrameSource: + def test_numpy_stack(self): + arr = np.zeros((5, 8, 9), dtype=np.uint16) + n, get, shape = frame_source(arr) + assert n == 5 and shape == (8, 9) + assert get(3).shape == (8, 9) + + def test_sequence_of_frames(self): + seq = [np.zeros((4, 6)) for _ in range(3)] + n, get, shape = frame_source(seq) + assert n == 3 and shape == (4, 6) + + def test_rejects_2d(self): + with pytest.raises(TypeError, match="3-D"): + frame_source(np.zeros((8, 9))) + + def test_rejects_unknown(self): + with pytest.raises(TypeError, match="cannot read frames"): + frame_source(object()) + + def test_hyperspy_signal_wrong_nav_dim_is_rejected(self): + """A 4D-STEM scan is not a movie; say so instead of solving nonsense.""" + class _AM: + navigation_dimension = 2 + signal_dimension = 2 + + class _Sig: + axes_manager = _AM() + data = np.zeros((3, 3, 4, 4)) + + with pytest.raises(TypeError, match="1-D navigation"): + frame_source(_Sig()) + + def test_dask_reads_one_frame_only(self): + """The Memory-Safety rule, enforced: never compute the whole array.""" + da = pytest.importorskip("dask.array") + arr = da.zeros((6, 8, 9), chunks=(1, 8, 9)) + n, get, shape = frame_source(arr) + assert n == 6 and shape == (8, 9) + called = {"full": 0} + real_compute = da.Array.compute + + def guard(self, *a, **k): + if self.shape == (6, 8, 9): + called["full"] += 1 + return real_compute(self, *a, **k) + + try: + da.Array.compute = guard + f = get(2) + finally: + da.Array.compute = real_compute + assert f.shape == (8, 9) + assert called["full"] == 0, "sliced a frame but computed the whole stack" + + +class TestSolveTranslationAccuracy: + def test_recovers_integer_shifts(self): + truth = np.array([[0, 0], [3, -4], [-6, 2], [1, 7]], dtype=float) + stack = _shifted_stack(truth) + model = solve_translation(stack, device="numpy", upsample=8) + assert np.allclose(model.shifts, truth, atol=GATE_PX), model.shifts + + def test_recovers_subpixel_shifts_inside_gate(self): + """The headline acceptance gate: < 0.1 px on sub-pixel ground truth. + + The truth values are deliberately **off** the ``1/upsample`` grid. Shifts + that happen to be multiples of 1/8 are recovered to 0.00000 px by an + upsample=8 solve — which looks like a spectacular result and actually + tests nothing, because the answer is exactly representable. Off-grid truth + is what makes the tolerance meaningful. + """ + truth = np.array( + [[0, 0], [1.37, -2.83], [-3.06, 0.61], [4.19, 5.44], [-0.72, -1.28]], + dtype=float, + ) + # None of these may land on the upsampled grid, or the test is vacuous. + assert not np.any(np.isclose(truth[1:] * 8, np.round(truth[1:] * 8))) + + stack = _shifted_stack(truth) + model = solve_translation(stack, device="numpy", upsample=8, + reference="first") + err = np.abs(model.shifts - truth) + assert err.max() < GATE_PX, f"max error {err.max():.4f} px\n{model.shifts}" + + def test_higher_upsample_reduces_error(self): + """Off-grid error should shrink as the upsampled grid gets finer. + + This is the test that would have caught the `_upsampled_dft` bug where + the frequency scaling was omitted: with that bug every result quantised to + 1/upsample regardless, so raising upsample changed the quantum but the + error stayed the same order. Here it must genuinely improve. + """ + truth = np.array([[0, 0], [2.31, -1.77], [-3.42, 4.09]], dtype=float) + stack = _shifted_stack(truth) + errs = {} + for u in (2, 8, 32): + m = solve_translation(stack, device="numpy", upsample=u, + reference="first") + errs[u] = float(np.abs(m.shifts - truth).max()) + assert errs[8] < errs[2], errs + assert errs[32] <= errs[8] + 1e-4, errs + + def test_frame_zero_is_the_origin(self): + stack = _shifted_stack(np.array([[0, 0], [2, 3]], dtype=float)) + model = solve_translation(stack, device="numpy") + assert tuple(model.shifts[0]) == (0.0, 0.0) + + def test_agrees_with_skimage_reference(self): + """Parity against the implementation we are replacing.""" + skreg = pytest.importorskip("skimage.registration") + truth = np.array([[0, 0], [2.25, -3.5], [-1.75, 4.125]], dtype=float) + stack = _shifted_stack(truth) + model = solve_translation(stack, device="numpy", upsample=8, + reference="first", apodize=False) + for i in range(1, len(truth)): + ref, _, _ = skreg.phase_cross_correlation( + stack[0], stack[i], upsample_factor=8, normalization="phase") + assert np.allclose(model.shifts[i], ref, atol=0.05), ( + f"frame {i}: ours={model.shifts[i]} skimage={ref}") + + def test_sequential_reference_accumulates(self): + """Sequential mode must return CUMULATIVE shifts, not per-pair deltas.""" + truth = np.array([[0, 0], [2, 0], [4, 0], [6, 0]], dtype=float) + stack = _shifted_stack(truth) + model = solve_translation(stack, device="numpy", reference="sequential", + upsample=4) + assert np.allclose(model.shifts, truth, atol=GATE_PX), model.shifts + + def test_running_reference_survives_one_corrupt_frame(self): + """Why 'running' is the default: a single bad frame must not poison it.""" + truth = np.array([[0, 0], [2, 1], [4, 2], [6, 3], [8, 4]], dtype=float) + stack = _shifted_stack(truth).copy() + rng = np.random.default_rng(0) + stack[2] = rng.standard_normal(stack.shape[1:]).astype(np.float32) # garbage + model = solve_translation(stack, device="numpy", upsample=8, max_shift=20) + good = [1, 3, 4] + err = np.abs(model.shifts[good] - truth[good]).max() + assert err < 0.5, f"good frames drifted after a corrupt frame: {model.shifts}" + + +class TestSolveTranslationGuards: + def test_max_shift_rejects_far_peak(self): + """A shift beyond max_shift is clamped out of the search, not returned.""" + truth = np.array([[0, 0], [20, 0]], dtype=float) + stack = _shifted_stack(truth) + model = solve_translation(stack, device="numpy", max_shift=5, upsample=1) + assert abs(model.shifts[1][0]) <= 5.0 + 1e-6, model.shifts + + def test_impossible_bounds_raise(self): + stack = _shifted_stack(np.zeros((2, 2))) + with pytest.raises(ValueError, match="exclude every possible shift"): + solve_translation(stack, device="numpy", max_shift=1, min_shift=50) + + def test_bad_reference_name_raises(self): + stack = _shifted_stack(np.zeros((2, 2))) + with pytest.raises(ValueError, match="unknown reference"): + solve_translation(stack, device="numpy", reference="nonsense") + + def test_fixed_index_out_of_range_raises(self): + stack = _shifted_stack(np.zeros((2, 2))) + with pytest.raises(ValueError, match="outside"): + solve_translation(stack, device="numpy", reference="fixed:99") + + def test_progress_reports_every_frame(self): + stack = _shifted_stack(np.zeros((4, 2))) + seen = [] + solve_translation(stack, device="numpy", progress=lambda d, t: seen.append((d, t))) + assert seen[0] == (1, 4) and seen[-1] == (4, 4) + + def test_cancel_leaves_nan_not_a_silent_partial(self): + stack = _shifted_stack(np.array([[0, 0], [1, 1], [2, 2], [3, 3]], float)) + calls = {"n": 0} + + def cancel(): + calls["n"] += 1 + return calls["n"] > 1 + + model = solve_translation(stack, device="numpy", cancel=cancel) + assert np.isnan(model.shifts[-1]).all(), ( + "a cancelled solve must be detectable, not quietly truncated") + + +class TestBackendParity: + """The GPU path has no independent reference, so it is pinned to numpy.""" + + def test_torch_matches_numpy(self): + torch = pytest.importorskip("torch") + truth = np.array([[0, 0], [2.5, -1.75], [-4.25, 3.5]], dtype=float) + stack = _shifted_stack(truth) + ref = solve_translation(stack, device="numpy", upsample=8) + got = solve_translation(stack, device="cpu", upsample=8) + assert got.params["backend"] == "torch" + assert np.allclose(got.shifts, ref.shifts, atol=1e-2), ( + f"torch={got.shifts}\nnumpy={ref.shifts}") + + +class TestWarp: + def test_integer_shift_is_exact_and_preserves_dtype(self): + f = np.arange(24, dtype=np.uint16).reshape(4, 6) + out = shift_frame(f, (1, 2), fill=0, preserve_dtype=True) + assert out.dtype == np.uint16 + # The interior must be bit-identical — no resampling on a whole-pixel move. + # Destination [1:, 2:] is fed by source [:-1, :-2]. + assert np.array_equal(out[1:, 2:], f[:-1, :-2]) + assert np.all(out[0, :] == 0) and np.all(out[:, :2] == 0) + + def test_nan_padding_marks_uncovered(self): + f = np.ones((5, 5), dtype=np.float32) + out = shift_frame(f, (2, 0)) + assert np.isnan(out[:2]).all() + assert np.allclose(out[2:], 1.0) + + def test_subpixel_shift_interpolates_and_pads(self): + f = _scene(32, 32).astype(np.float32) + out = shift_frame(f, (0.5, -0.5)) + assert out.dtype == np.float32 + assert np.isnan(out[0]).all(), "top row needs off-frame data" + assert np.isnan(out[:, -1]).all(), "right column needs off-frame data" + assert np.isfinite(out[3:-3, 3:-3]).all() + + def test_nan_does_not_bleed_into_real_data(self): + """Interpolating with NaN cval would smear it `order` px inward.""" + f = np.ones((16, 16), dtype=np.float32) + out = shift_frame(f, (2.5, 0.0)) + assert np.isfinite(out[4:, :]).all(), "NaN bled past the padded border" + assert np.allclose(out[5:-1, :], 1.0, atol=1e-5) + + def test_preserve_dtype_rejects_subpixel(self): + f = np.zeros((4, 4), dtype=np.uint16) + with pytest.raises(ValueError, match="whole-pixel"): + shift_frame(f, (0.5, 0), fill=0, preserve_dtype=True) + + def test_round_trip_is_sign_symmetric(self): + """Shifting out and back must land where it started. + + This pins the SIGN symmetry, not interpolation fidelity — so the scene is + noise-free (see :func:`_scene`) and cubic interpolation is used. Two + bilinear passes over pixel-scale noise would lose ~1% of amplitude for + entirely legitimate reasons and tell us nothing about the sign. + """ + f = _scene(64, 64, noise=0.0).astype(np.float32) + moved = shift_frame(f, (3.25, -2.5), fill=0.0, order=3) + back = shift_frame(moved, (-3.25, 2.5), fill=0.0, order=3) + core = (slice(10, -10), slice(10, -10)) + err = np.abs(back[core] - f[core]).max() + assert err < 0.01, f"round trip lost {err:.4f} — sign asymmetry?" + + def test_round_trip_beats_the_uncorrected_offset(self): + """Sanity: the corrected result is far closer than the shifted one.""" + f = _scene(64, 64, noise=0.0).astype(np.float32) + moved = shift_frame(f, (3.25, -2.5), fill=0.0) + back = shift_frame(moved, (-3.25, 2.5), fill=0.0) + core = (slice(10, -10), slice(10, -10)) + assert np.abs(back[core] - f[core]).max() < \ + 0.1 * np.abs(moved[core] - f[core]).max() + + def test_rejects_non_finite_shift(self): + with pytest.raises(ValueError, match="finite"): + shift_frame(np.zeros((4, 4)), (np.nan, 0)) + + def test_coverage_matches_finite_pixels(self): + f = np.ones((20, 20), dtype=np.float32) + for s in [(0, 0), (3, -2), (2.5, 1.25), (-4.75, 6.5)]: + out = shift_frame(f, s) + cov = coverage_mask((20, 20), s) + assert np.array_equal(np.isfinite(out), cov), f"mismatch at shift {s}" + + +class TestDriftModel: + def test_shape_validation(self): + with pytest.raises(ValueError, match=r"\(N, 2\)"): + DriftModel(shifts=np.zeros((4, 3))) + + def test_residual_length_validation(self): + with pytest.raises(ValueError, match="residuals must be"): + DriftModel(shifts=np.zeros((4, 2)), residuals=np.zeros(3)) + + def test_is_integer(self): + assert DriftModel(shifts=np.array([[0, 0], [2, -3]], float)).is_integer + assert not DriftModel(shifts=np.array([[0, 0], [2.5, 0]], float)).is_integer + + def test_max_abs_shift_ignores_nan(self): + m = DriftModel(shifts=np.array([[0, 0], [3, -7], [np.nan, np.nan]], float)) + assert m.max_abs_shift == 7.0 + + def test_frame_conversions_are_inverses(self): + m = DriftModel(shifts=np.array([[0, 0], [2.5, -1.5], [4, 3]], float)) + pos = np.array([[10.0, 12.0], [20.0, 22.0]]) + idx = np.array([1, 2]) + assert np.allclose(m.to_lab_frame(m.to_sample_frame(pos, idx), idx), pos) + + def test_to_sample_frame_removes_stage_motion(self): + """A particle that only *appears* to move because the stage drifted.""" + m = DriftModel(shifts=np.array([[0, 0], [-5, 0], [-10, 0]], float)) + # Same physical spot, drifting downward in the raw frames. + lab = np.array([[30.0, 40.0], [35.0, 40.0], [40.0, 40.0]]) + idx = np.array([0, 1, 2]) + sample = m.to_sample_frame(lab, idx) + assert np.allclose(sample[:, 0], 30.0), sample + + def test_save_load_round_trip(self, tmp_path): + m = DriftModel( + shifts=np.array([[0, 0], [1.25, -2.5]], float), + residuals=np.array([np.inf, 12.5], np.float32), + params={"upsample": 8}, provenance={"action": "drift"}, + reference="running", + ) + p = str(tmp_path / "d.npz") + m.save(p) + back = DriftModel.load(p) + assert np.array_equal(back.shifts, m.shifts) + assert back.params["upsample"] == 8 + assert back.provenance == {"action": "drift"} + assert back.reference == "running" + assert np.array_equal(back.residuals, m.residuals) + + def test_load_rejects_future_format(self, tmp_path): + import json + p = str(tmp_path / "bad.npz") + np.savez_compressed( + p, shifts=np.zeros((2, 2), np.float32), + meta=np.array(json.dumps({"format_version": 999}))) + with pytest.raises(ValueError, match="unsupported DriftModel format"): + DriftModel.load(p) + + +class TestEndToEnd: + def test_solve_then_warp_aligns_the_stack(self): + """The whole point: after correction, every frame agrees with frame 0.""" + truth = np.array( + [[0, 0], [2.5, -3.0], [-4.25, 1.75], [6.0, 4.5]], dtype=float) + stack = _shifted_stack(truth, h=80, w=80) + model = solve_translation(stack, device="numpy", upsample=8, + reference="first") + + core = (slice(12, -12), slice(12, -12)) + ref = stack[0][core] + for i in range(1, len(truth)): + aligned = shift_frame(stack[i], model.shifts[i], fill=0.0) + resid = np.abs(aligned[core] - ref).max() + raw = np.abs(stack[i][core] - ref).max() + assert resid < raw * 0.2, ( + f"frame {i}: correction barely helped (resid={resid:.4f} " + f"raw={raw:.4f}) — check the SIGN convention") diff --git a/spyde/tests/migrated/test_particles_core.py b/spyde/tests/migrated/test_particles_core.py new file mode 100644 index 00000000..4317df33 --- /dev/null +++ b/spyde/tests/migrated/test_particles_core.py @@ -0,0 +1,504 @@ +""" +Tests for spyde.particles (classical engine + measurement) and +spyde.signals.particles (the CSR container). + +Acceptance gates from DRIFT_AND_PARTICLES_PLAN.md exercised here: + +* B5 measure — matches ``regionprops`` on synthetic shapes of known area and + eccentricity, and physical units are right under a non-unit axis scale. +* B1 classical — separates touching particles; the shared ``split_instances`` + behaves for a probability map as well as a boolean mask. +* A7 edges — no particle is ever detected in the NaN-padded border a + drift-corrected frame carries. This is called out in the plan as the single most + likely integration bug between the two features. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from spyde.particles import ( + THRESHOLD_METHODS, + SegmentParams, + measure_frame, + segment_frame, + split_instances, + threshold_mask, +) +from spyde.signals.particles import COL, COLUMNS, N_COLUMNS, SpyDEParticles + + +# ── synthetic scenes ───────────────────────────────────────────────────────── + +def _disc(shape, cy, cx, r, amp=1.0): + yy, xx = np.mgrid[0:shape[0], 0:shape[1]] + return amp * (((yy - cy) ** 2 + (xx - cx) ** 2) <= r * r) + + +def _field(h=120, w=140, radii=((30, 30, 9), (30, 100, 6), (85, 40, 12), + (90, 105, 7)), amp=1.0, bg=0.05): + img = np.full((h, w), bg, dtype=np.float32) + for cy, cx, r in radii: + img += _disc((h, w), cy, cx, r, amp).astype(np.float32) + return img + + +def _touching(h=80, w=120): + """Two discs of radius 14 whose edges overlap — the watershed's whole job.""" + img = np.full((h, w), 0.05, dtype=np.float32) + img += _disc((h, w), 40, 48, 14).astype(np.float32) + img += _disc((h, w), 40, 72, 14).astype(np.float32) + return np.clip(img, 0, 1.2) + + +class TestSegmentParams: + def test_rejects_unknown_threshold(self): + with pytest.raises(ValueError, match="unknown threshold"): + SegmentParams(threshold="magic") + + def test_rejects_sensitivity_out_of_range(self): + with pytest.raises(ValueError, match="sensitivity must be in 0..1"): + SegmentParams(sensitivity=1.5) + + def test_rejects_even_local_size_for_local_methods(self): + """Bumping it silently would make the caret disagree with what ran.""" + with pytest.raises(ValueError, match="local_size must be odd"): + SegmentParams(threshold="sauvola", local_size=30) + SegmentParams(threshold="otsu", local_size=30) # global: irrelevant + + +class TestThresholding: + @pytest.mark.parametrize("method", THRESHOLD_METHODS) + def test_every_method_runs_and_finds_the_discs(self, method): + # Blurred, so the histogram has real structure. A hard-edged synthetic + # field is essentially two delta spikes, which several legitimate methods + # cannot work with (see test_minimum_reports_actionably_on_a_spiky_field). + from scipy.ndimage import gaussian_filter + img = gaussian_filter(_field(), 2.0) + p = SegmentParams(threshold=method, local_size=31) + mask = threshold_mask(img.astype(np.float32), p) + assert mask.shape == img.shape and mask.dtype == bool + # All four discs are bright and large; any sane threshold finds signal. + assert mask.sum() > 100, f"{method} found almost nothing" + + def test_minimum_reports_actionably_on_a_spiky_field(self): + """skimage raises a bare RuntimeError; the user needs to know what to do.""" + img = _field().astype(np.float32) # hard edges → one-spike histogram + with pytest.raises(ValueError, match="requires a clearly bimodal"): + threshold_mask(img, SegmentParams(threshold="minimum")) + + def test_sensitivity_half_is_exactly_the_plain_method(self): + """0.5 must be a no-op offset, or the ParticleSpy parity gate is meaningless.""" + from skimage.filters import threshold_otsu + img = _field().astype(np.float32) + mask = threshold_mask(img, SegmentParams(threshold="otsu", sensitivity=0.5)) + assert np.array_equal(mask, img > threshold_otsu(img)) + + def test_higher_sensitivity_never_shrinks_the_mask(self): + img = _field(amp=0.4, bg=0.1).astype(np.float32) + prev = -1 + for s in (0.1, 0.3, 0.5, 0.7, 0.9): + n = int(threshold_mask(img, SegmentParams(sensitivity=s)).sum()) + assert n >= prev, f"sensitivity {s} shrank the mask ({n} < {prev})" + prev = n + + def test_invert_finds_dark_particles(self): + img = 1.0 - _field(amp=1.0, bg=0.05) # dark discs on bright ground + labels = segment_frame(img, SegmentParams(invert=True, min_size=30)) + assert labels.max() == 4, f"found {labels.max()} dark particles, expected 4" + + +class TestSplitInstances: + def test_separates_touching_discs(self): + img = _touching() + labels = segment_frame(img, SegmentParams(watershed=True, min_size=40)) + assert labels.max() == 2, ( + f"watershed merged the pair into {labels.max()} region(s)") + + def test_without_watershed_they_merge(self): + """Confirms the previous test is actually measuring the watershed.""" + img = _touching() + labels = segment_frame(img, SegmentParams(watershed=False, min_size=40)) + assert labels.max() == 1 + + def test_does_not_oversplit_a_single_round_disc(self): + """The plateau trap: peak_local_max on a flat distance maximum returns + several coincident peaks and watershed cuts one disc into wedges.""" + img = np.full((80, 80), 0.05, np.float32) + img += _disc((80, 80), 40, 40, 20).astype(np.float32) + labels = segment_frame(img, SegmentParams(watershed=True, min_size=50)) + assert labels.max() == 1, f"one disc split into {labels.max()} pieces" + + def test_accepts_a_probability_map(self): + """The scribble/prompt engines hand over float probabilities, not masks.""" + prob = np.zeros((60, 60), np.float32) + prob[10:25, 10:25] = 0.9 + prob[35:50, 35:50] = 0.8 + prob[5:8, 50:53] = 0.3 # below 0.5 — must be ignored + labels = split_instances(prob, SegmentParams(min_size=20)) + assert labels.max() == 2 + + def test_min_size_discards_small(self): + prob = np.zeros((60, 60), bool) + prob[10:30, 10:30] = True # 400 px + prob[45:48, 45:48] = True # 9 px + assert split_instances(prob, SegmentParams(min_size=100)).max() == 1 + assert split_instances(prob, SegmentParams(min_size=5)).max() == 2 + + def test_a_tiny_particle_survives_the_watershed(self): + """§0.9 regression: nothing in the split step may delete a small particle. + + The original marker step filtered markers by AREA (ParticleSpy's + ``watershed_size``). A 3x3 particle's local-maximum marker is ONE pixel, so + any area floor erased it and the particle vanished — silently, since the + frame still had a plausible count. + """ + prob = np.zeros((60, 60), bool) + prob[10:30, 10:30] = True # large + prob[45:48, 45:48] = True # tiny, 9 px + labels = split_instances(prob, SegmentParams(min_size=5, watershed=True)) + assert labels.max() == 2, "the tiny particle was dropped by the split step" + assert labels[46, 46] != 0 + + def test_max_size_discards_large(self): + prob = np.zeros((60, 60), bool) + prob[5:55, 5:55] = True # 2500 px + assert split_instances(prob, SegmentParams(max_size=1000)).max() == 0 + + def test_clear_border_drops_edge_touching(self): + prob = np.zeros((60, 60), bool) + prob[0:10, 0:10] = True # touches the border + prob[25:40, 25:40] = True + p = SegmentParams(min_size=20, clear_border=True, watershed=False) + assert split_instances(prob, p).max() == 1 + + def test_labels_are_sequential_with_no_gaps(self): + prob = np.zeros((80, 80), bool) + for i, (y, x) in enumerate([(5, 5), (5, 40), (40, 5), (40, 40)]): + prob[y:y + 12, x:x + 12] = True + labels = split_instances(prob, SegmentParams(min_size=20, watershed=False)) + present = np.unique(labels) + assert np.array_equal(present, np.arange(present.size)) + + def test_empty_input_gives_empty_labels(self): + labels = split_instances(np.zeros((20, 20), bool), SegmentParams()) + assert labels.max() == 0 and labels.dtype == np.int32 + + def test_rejects_3d(self): + with pytest.raises(ValueError, match="foreground must be 2-D"): + split_instances(np.zeros((2, 4, 4), bool), SegmentParams()) + + +class TestNaNBorder: + """Plan trap #2 / gate A7 — the drift↔segmentation seam.""" + + def test_nan_border_yields_no_particles_there(self): + from spyde.drift import shift_frame + img = _field() + shifted = shift_frame(img, (12, -15)) # NaN band top and right + assert np.isnan(shifted).any(), "test setup produced no NaN border" + + labels = segment_frame(shifted, SegmentParams(min_size=30)) + nan_mask = ~np.isfinite(shifted) + assert not np.any(labels[nan_mask]), ( + "found a particle inside the NaN-padded border — the padding was " + "coerced to a value that thresholds as signal") + + def test_nan_does_not_erase_real_data_near_the_border(self): + """The opposite failure: propagating NaN through the filters wipes a band.""" + from spyde.drift import shift_frame + img = _field() + shifted = shift_frame(img, (5, 0)) + labels = segment_frame(shifted, SegmentParams(gaussian=2.0, min_size=30)) + assert labels.max() == 4, ( + f"found {labels.max()} of 4 particles — NaN bled through the blur") + + +class TestMeasure: + def test_area_matches_regionprops(self): + from skimage.measure import regionprops + prob = np.zeros((80, 80), bool) + prob[10:30, 10:40] = True # exactly 600 px + labels = split_instances(prob, SegmentParams(min_size=10, watershed=False)) + rows, _ = measure_frame(labels) + assert rows.shape[1] == N_COLUMNS + ref = regionprops(labels)[0] + assert rows[0, COL["area"]] == pytest.approx(ref.area) + assert rows[0, COL["area"]] == pytest.approx(600) + + def test_circularity_of_a_disc_is_near_one(self): + labels = _disc((120, 120), 60, 60, 30).astype(np.int32) + rows, _ = measure_frame(labels) + # A pixelated disc's perimeter is slightly over-estimated, so ~0.9-1.05. + assert 0.85 < rows[0, COL["circularity"]] < 1.1, rows[0, COL["circularity"]] + + def test_eccentricity_of_a_disc_is_near_zero(self): + labels = _disc((120, 120), 60, 60, 25).astype(np.int32) + rows, _ = measure_frame(labels) + assert rows[0, COL["eccentricity"]] < 0.2 + + def test_calibration_scales_length_and_area_correctly(self): + prob = np.zeros((60, 60), bool) + prob[10:30, 10:30] = True # 400 px, 20 px across + labels = split_instances(prob, SegmentParams(min_size=10, watershed=False)) + r1, _ = measure_frame(labels, scale=1.0) + r2, _ = measure_frame(labels, scale=0.5) # 0.5 nm/px + assert r2[0, COL["area"]] == pytest.approx(r1[0, COL["area"]] * 0.25) + assert r2[0, COL["perimeter"]] == pytest.approx(r1[0, COL["perimeter"]] * 0.5) + assert r2[0, COL["y"]] == pytest.approx(r1[0, COL["y"]] * 0.5) + + def test_circularity_is_scale_invariant(self): + """Dimensionless quantities must NOT pick up a scale factor.""" + labels = _disc((100, 100), 50, 50, 22).astype(np.int32) + a, _ = measure_frame(labels, scale=1.0) + b, _ = measure_frame(labels, scale=0.137) + assert a[0, COL["circularity"]] == pytest.approx(b[0, COL["circularity"]]) + assert a[0, COL["eccentricity"]] == pytest.approx(b[0, COL["eccentricity"]]) + + def test_intensity_excludes_nan(self): + labels = np.zeros((40, 40), np.int32) + labels[10:20, 10:20] = 1 + inten = np.full((40, 40), 5.0) + inten[12, 12] = np.nan + rows, _ = measure_frame(labels, inten) + assert rows[0, COL["intensity_mean"]] == pytest.approx(5.0), ( + "NaN leaked into the mean, or was coerced to 0 and dragged it down") + + def test_background_ring_ignores_neighbouring_particles(self): + """A touching neighbour's body must not be measured as this one's background.""" + labels = np.zeros((40, 60), np.int32) + labels[15:25, 10:20] = 1 + labels[15:25, 21:31] = 2 # 1 px gap — inside a 3 px ring + inten = np.zeros((40, 60)) + inten[labels == 1] = 10.0 + inten[labels == 2] = 99.0 # a bright neighbour + inten[labels == 0] = 1.0 + rows, _ = measure_frame(labels, inten, background_ring=3) + bg = rows[0, COL["background"]] + assert bg == pytest.approx(1.0), ( + f"background {bg} — the neighbour's 99 leaked in") + + def test_min_area_px_filters_before_returning(self): + labels = np.zeros((40, 40), np.int32) + labels[5:25, 5:25] = 1 # 400 + labels[30:33, 30:33] = 2 # 9 + rows, contours = measure_frame(labels, min_area_px=100) + assert len(rows) == 1 and len(contours) == 1 + + def test_contours_match_rows_one_to_one(self): + labels = np.zeros((60, 60), np.int32) + labels[5:20, 5:20] = 1 + labels[30:50, 30:55] = 2 + rows, contours = measure_frame(labels) + assert len(rows) == len(contours) == 2 + for c in contours: + assert c.ndim == 2 and c.shape[1] == 2 and c.dtype == np.int16 + + def test_single_pixel_region_still_gets_a_contour(self): + """Degenerate regions must not break the 1:1 correspondence.""" + labels = np.zeros((20, 20), np.int32) + labels[10, 10] = 1 + rows, contours = measure_frame(labels) + assert len(rows) == 1 and len(contours) == 1 and len(contours[0]) >= 3 + + def test_empty_label_image(self): + rows, contours = measure_frame(np.zeros((20, 20), np.int32)) + assert rows.shape == (0, N_COLUMNS) and contours == [] + + def test_track_id_starts_unassigned(self): + labels = np.zeros((30, 30), np.int32) + labels[5:20, 5:20] = 1 + rows, _ = measure_frame(labels) + assert rows[0, COL["track_id"]] == -1 + + def test_intensity_shape_mismatch_raises(self): + with pytest.raises(ValueError, match="intensity shape"): + measure_frame(np.zeros((10, 10), np.int32), np.zeros((8, 8))) + + +# ── the container ──────────────────────────────────────────────────────────── + +def _build(n_frames=4, seed=1): + """A small SpyDEParticles built through the real segment→measure path.""" + rng = np.random.default_rng(seed) + per_frame, contours = [], [] + for t in range(n_frames): + img = np.full((90, 90), 0.05, np.float32) + # A growing number of particles per frame, so count_series is non-trivial. + for i in range(t + 1): + cy = 20 + 25 * (i % 3) + cx = 20 + 25 * (i // 3) + int(rng.integers(0, 3)) + img += _disc((90, 90), cy, cx, 8).astype(np.float32) + labels = segment_frame(img, SegmentParams(min_size=30)) + rows, cs = measure_frame(labels, img, t=t, scale=0.5) + per_frame.append(rows) + contours.append(cs) + return SpyDEParticles.from_frames( + per_frame, frame_shape=(90, 90), contours_per_frame=contours, + scale=0.5, units="nm") + + +class TestSpyDEParticles: + def test_builds_and_reports_shape(self): + p = _build() + assert p.n_frames == 4 + assert p.n_particles == 1 + 2 + 3 + 4 + assert p.has_masks and not p.has_tracks + + def test_csr_slice_is_o1_and_correct(self): + p = _build() + for t in range(p.n_frames): + blk = p.at(t) + assert len(blk) == t + 1 + assert np.all(blk[:, COL["t"]] == t) + + def test_count_series_matches_offsets(self): + p = _build() + assert np.array_equal(p.count_series(), np.array([1, 2, 3, 4], np.float32)) + + def test_property_series_is_nan_on_empty_frames(self): + """An empty frame has no mean size; zero would draw a fake event spike.""" + rows = np.zeros((2, N_COLUMNS), np.float32) + rows[:, COL["area"]] = [10.0, 20.0] + p = SpyDEParticles.from_frames( + [rows, np.zeros((0, N_COLUMNS), np.float32)], frame_shape=(10, 10)) + s = p.property_series("area", "mean") + assert s[0] == pytest.approx(15.0) + assert np.isnan(s[1]) + + def test_property_series_reductions(self): + rows = np.zeros((3, N_COLUMNS), np.float32) + rows[:, COL["area"]] = [1.0, 2.0, 6.0] + p = SpyDEParticles.from_frames([rows], frame_shape=(10, 10)) + assert p.property_series("area", "sum")[0] == pytest.approx(9.0) + assert p.property_series("area", "max")[0] == pytest.approx(6.0) + assert p.property_series("area", "median")[0] == pytest.approx(2.0) + + def test_unknown_reduce_and_column_raise(self): + p = _build() + with pytest.raises(ValueError, match="unknown reduce"): + p.property_series("area", "bogus") + with pytest.raises(KeyError, match="unknown column"): + p.column("nope") + + def test_frame_index_bounds(self): + p = _build() + with pytest.raises(IndexError, match="outside"): + p.at(99) + + def test_render_frame_paints_the_right_count(self): + p = _build() + img = p.render_frame(2) + assert img.shape == (90, 90) and img.dtype == np.int32 + painted = np.unique(img) + painted = painted[painted > 0] + assert painted.size == 3, f"painted {painted.size} of 3 particles" + + def test_render_frame_by_index_supports_hit_testing(self): + p = _build() + img = p.render_frame(1, value="index") + vals = np.unique(img) + vals = vals[vals > 0] - 1 + assert set(vals.tolist()) == set(p.indices_at(1).tolist()) + + def test_render_frame_rejects_unknown_value(self): + p = _build() + with pytest.raises(ValueError, match="unknown value"): + p.render_frame(0, value="colour") + + def test_render_without_masks_raises_clearly(self): + rows = np.zeros((1, N_COLUMNS), np.float32) + p = SpyDEParticles.from_frames([rows], frame_shape=(10, 10)) + assert not p.has_masks + with pytest.raises(ValueError, match="store_masks=False"): + p.render_frame(0) + + def test_mask_at_is_cropped_to_bbox(self): + p = _build() + m, (y0, x0, y1, x1) = p.mask_at(0) + assert m.shape == (y1 - y0, x1 - x0) + assert m.any(), "empty mask for a real particle" + assert m.size < 90 * 90, "mask was not cropped" + + def test_validation_rejects_bad_shapes(self): + with pytest.raises(ValueError, match=r"flat_buffer must be"): + SpyDEParticles(np.zeros((3, 5)), np.array([0, 3]), (10, 10)) + with pytest.raises(ValueError, match="t_offsets must span"): + SpyDEParticles(np.zeros((3, N_COLUMNS)), np.array([0, 2]), (10, 10)) + with pytest.raises(ValueError, match="non-decreasing"): + SpyDEParticles(np.zeros((3, N_COLUMNS)), np.array([0, 3, 1, 3]), (10, 10)) + + def test_contours_without_offsets_rejected(self): + with pytest.raises(ValueError, match="both be set or both None"): + SpyDEParticles(np.zeros((1, N_COLUMNS)), np.array([0, 1]), (10, 10), + contours=np.zeros((4, 2), np.int16)) + + def test_from_frames_rejects_mismatched_contours(self): + rows = np.zeros((2, N_COLUMNS), np.float32) + with pytest.raises(ValueError, match="outlines must correspond 1:1"): + SpyDEParticles.from_frames( + [rows], frame_shape=(10, 10), + contours_per_frame=[[np.zeros((4, 2), np.int16)]]) # 1 for 2 + + def test_from_frames_rejects_frame_count_mismatch(self): + rows = np.zeros((1, N_COLUMNS), np.float32) + with pytest.raises(ValueError, match="contours_per_frame has"): + SpyDEParticles.from_frames([rows, rows], frame_shape=(10, 10), + contours_per_frame=[[]]) + + def test_save_load_round_trip(self, tmp_path): + p = _build() + path = str(tmp_path / "p.npz") + p.save(path) + back = SpyDEParticles.load(path) + assert np.array_equal(back.flat_buffer, p.flat_buffer) + assert np.array_equal(back.t_offsets, p.t_offsets) + assert np.array_equal(back.contours, p.contours) + assert back.frame_shape == p.frame_shape + assert back.scale == p.scale and back.units == "nm" + # And the reloaded object still renders. + assert back.render_frame(1).max() > 0 + + def test_save_load_without_masks(self, tmp_path): + rows = np.zeros((2, N_COLUMNS), np.float32) + p = SpyDEParticles.from_frames([rows], frame_shape=(8, 8)) + path = str(tmp_path / "nomask.npz") + p.save(path) + back = SpyDEParticles.load(path) + assert not back.has_masks + + def test_load_rejects_future_format(self, tmp_path): + import json + path = str(tmp_path / "bad.npz") + np.savez_compressed( + path, flat_buffer=np.zeros((0, N_COLUMNS), np.float32), + t_offsets=np.array([0]), + meta=np.array(json.dumps({"format_version": 999, + "columns": list(COLUMNS), + "frame_shape": [4, 4]}))) + with pytest.raises(ValueError, match="unsupported SpyDEParticles format"): + SpyDEParticles.load(path) + + def test_load_rejects_changed_column_layout(self, tmp_path): + import json + path = str(tmp_path / "cols.npz") + np.savez_compressed( + path, flat_buffer=np.zeros((0, N_COLUMNS), np.float32), + t_offsets=np.array([0]), + meta=np.array(json.dumps({"format_version": 1, + "columns": ["t", "label"], + "frame_shape": [4, 4]}))) + with pytest.raises(ValueError, match="column layout changed"): + SpyDEParticles.load(path) + + def test_to_csv_writes_a_header_and_every_row(self, tmp_path): + p = _build() + path = str(tmp_path / "p.csv") + p.to_csv(path) + with open(path) as fh: + lines = fh.read().strip().splitlines() + assert lines[0].split(",") == list(COLUMNS) + assert len(lines) == p.n_particles + 1 + + def test_repr_is_informative(self): + assert "particles over" in repr(_build()) From 16ef8c8f59ece657009b71af04720c3ea1a6a130 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 18:12:19 -0500 Subject: [PATCH 02/38] feat(data,drift): synthetic particle-movie fixture, and two solver bugs it found Step 0 of DRIFT_AND_PARTICLES_PLAN.md: the fixture every later step is graded against. `spyde.data.synthetic.particle_movie` gives a 24-frame in-situ movie with nine particles on a drifting speckled support film, and stamps its whole motion model as ground truth: per-frame drift, radii, and the nucleation (8), dissolution (16) and merge (14) frames. `particle_truth_at` evaluates that model, so no consumer re-derives it and no test can pass by repeating the generator's own mistake. Reachable in the app as `load_test_data_particles`, lazy at one frame per chunk like a real .mrc. It earned its place immediately by finding two defects in the drift solver: 1. A FULL HANN WINDOW DESTROYS THE REGISTRATION. With apodize=1.0 the solve returned a 25 px error on a 6 px drift -- worse than not correcting. The spurious peak at (-19, 19) scores 0.121 against the true peak's 0.088, because a full-frame window reweights the two frames differently once the drift is large. skimage's phase_cross_correlation returns the SAME wrong answer on the same windowed input, so this is a property of full-frame windowing, not of either implementation. The window is now a Tukey taper (alpha 0.25, edge only): 0.124 px. 2. THE RUNNING REFERENCE WAS NOT ROBUST TO A BAD FRAME, despite the docstring saying so. A frame of pure noise has a broadband spectrum, so after phase normalisation it contributed as much to the accumulated reference as a good frame and dragged the next two registrations 3.9 px off. The per-frame peak sharpness was already computed and unused; it now gates entry to the reference. Both constants are measured, not chosen: worst natural frame sits at 0.388 of the running median, a noise frame at 0.007, so the threshold is 0.25. A 3-sample warm-up was tried and let the bad frame through on a short stack; windowing the median made no difference at N=3, 5 or unbounded. Also floored the phase-normalisation divisor at 100*eps instead of adding 1e-12, matching skimage -- correct in principle, though it was not the bug. Every claim above is pinned by a test, including two that assert the failure mode still exists when the fix is disabled, so they cannot quietly go vacuous. 189 tests. --- spyde/backend/_session_actions.py | 3 + spyde/backend/_session_testharness.py | 45 ++ spyde/data/__init__.py | 4 +- spyde/data/synthetic.py | 235 +++++++++++ spyde/drift/translation.py | 171 ++++++-- .../tests/migrated/test_drift_translation.py | 32 +- .../migrated/test_particle_movie_fixture.py | 388 ++++++++++++++++++ 7 files changed, 851 insertions(+), 27 deletions(-) create mode 100644 spyde/tests/migrated/test_particle_movie_fixture.py diff --git a/spyde/backend/_session_actions.py b/spyde/backend/_session_actions.py index c58a4af1..b09eb999 100644 --- a/spyde/backend/_session_actions.py +++ b/spyde/backend/_session_actions.py @@ -38,6 +38,7 @@ "load_test_data_si_grains", "load_test_data_sped_ag", "load_test_data_eels", "load_test_data_eds", "load_test_data_ebsd", "load_test_data_line", "load_test_data_movie", "load_test_data_5d", + "load_test_data_particles", "test_nav_drag", "test_region_scrub", "test_add_second_navigator", "load_test_vectors", "run_test_orientation", "dump_dask_state", @@ -148,6 +149,8 @@ def dispatch_action(self, msg: dict) -> None: self._load_test_data_movie(payload) elif action == "load_test_data_5d": self._load_test_data_5d(payload) + elif action == "load_test_data_particles": + self._load_test_data_particles(payload) elif action == "test_add_second_navigator": self._test_add_second_navigator() elif action == "test_nav_drag": diff --git a/spyde/backend/_session_testharness.py b/spyde/backend/_session_testharness.py index abc939be..4870ef94 100644 --- a/spyde/backend/_session_testharness.py +++ b/spyde/backend/_session_testharness.py @@ -415,6 +415,51 @@ def _load_test_data_movie(self, payload: dict | None = None) -> None: s.set_signal_type("insitu") # gates the Play/Fast Forward toolbar buttons self._add_signal(s, source_path="test_data_movie") + def _load_test_data_particles(self, payload: dict | None = None) -> None: + """The synthetic in-situ PARTICLE movie — the drift/segmentation fixture. + + Wraps :func:`spyde.data.synthetic.particle_movie`, whose ground truth + (per-frame drift, particle radii, and the nucleation / dissolution / merge + frames) is stamped into ``metadata.Spyde.synthetic`` — so an e2e spec can + assert against the numbers the data was built from instead of a golden + screenshot. See DRIFT_AND_PARTICLES_PLAN.md step 0. + + Loaded **lazy at one frame per chunk**, like ``load_test_data_movie`` and + like a real ``.mrc`` in-situ movie: each nav move is then a small cold read + of just that frame, which is the path a user actually drags. + + Payload: ``{"frames": int, "size": [ny, nx], "eager": bool}``. ``eager`` + keeps it in RAM, which skips the whole array-cache path — useful only for a + test that wants to isolate something else (an eager fixture is how + ``si_grains`` silently made a cache benchmark measure nothing). + """ + import dask.array as da + + from spyde.backend.heavy_imports import ensure_heavy_imports + from spyde.data.synthetic import particle_movie + + # BEFORE set_signal_type: racing the startup prewarm's pyxem import + # partially initialises the module and the cast then silently fails, + # taking every gated toolbar action with it. + ensure_heavy_imports() + + payload = payload or {} + n_frames = int(payload.get("frames", 24)) + shape = tuple(payload.get("size", (96, 112))) + + s = particle_movie(n_frames=n_frames, shape=shape) + if not payload.get("eager"): + eager = s.data + ny, nx = eager.shape[1:] + # `as_lazy()` carries the axes, the signal type AND the stamped ground + # truth across (metadata has no setter, so copying it by hand is not an + # option), then swap in the correctly-chunked array. Assigning `.data` + # rather than calling `.rechunk()` keeps this a graph construction over + # an array already in RAM — never a scheduler shuffle. + s = s.as_lazy() + s.data = da.from_array(eager, chunks=(1, ny, nx)) + self._add_signal(s, source_path="test_data_particles") + @staticmethod def _write_movie_mrc(frames) -> str: """Write ``frames`` as a minimal MRC2014 stack (mode 6 = uint16) into the diff --git a/spyde/data/__init__.py b/spyde/data/__init__.py index 44896f38..6478b512 100644 --- a/spyde/data/__init__.py +++ b/spyde/data/__init__.py @@ -26,7 +26,9 @@ eds_si, eels_si, ground_truth, + particle_movie, + particle_truth_at, ) __all__ = ["eels_si", "eds_si", "ebsd_patterns", "atom_lattice", - "ground_truth"] + "particle_movie", "particle_truth_at", "ground_truth"] diff --git a/spyde/data/synthetic.py b/spyde/data/synthetic.py index 8d5315e7..d813a18b 100644 --- a/spyde/data/synthetic.py +++ b/spyde/data/synthetic.py @@ -481,3 +481,238 @@ def ebsd_patterns(nav=(16, 16), detector=(60, 60), *, pc=(0.5, 0.5, 0.55), pc=np.asarray(pc, float), n_bands=int(len(normals)), grain2_mask=grain2) return s + + +# --------------------------------------------------------------------------- +# In-situ particle movie +# --------------------------------------------------------------------------- + +# The particle table. Hand-written rather than randomised so every number in +# the ground truth is exact and readable: a test that says "particle 4 +# dissolves at frame 16" can be checked against this table by eye. +# +# Columns: y0, x0, radius, amp, birth, death, vy, vx, faint +# y0/x0 position at t=0 in the SAMPLE frame, pixels +# birth first frame the particle exists (0 = present from the start) +# death first frame it is GONE (-1 = never dissolves) +# vy/vx sample-frame velocity, px/frame +# faint amplitude is scaled down to `faint_amplitude` -- the Section 0.9 probes +_PARTICLES: tuple[dict, ...] = ( + # two bright anchors, static and persistent + dict(y0=24.0, x0=22.0, radius=7.0, amp=1.00, birth=0, death=-1, vy=0.0, vx=0.0, faint=False), + dict(y0=70.0, x0=30.0, radius=9.0, amp=0.90, birth=0, death=-1, vy=0.0, vx=0.0, faint=False), + # a mover, for trails and displacement + dict(y0=30.0, x0=80.0, radius=6.0, amp=0.85, birth=0, death=-1, vy=1.10, vx=-0.70, faint=False), + # nucleation + dict(y0=60.0, x0=94.0, radius=5.0, amp=0.80, birth=8, death=-1, vy=0.0, vx=0.0, faint=False), + # dissolution + dict(y0=16.0, x0=58.0, radius=6.0, amp=0.75, birth=0, death=16, vy=0.0, vx=0.0, faint=False), + # the merge pair: converge along x until they overlap + dict(y0=84.0, x0=60.0, radius=5.0, amp=0.80, birth=0, death=-1, vy=0.0, vx=0.45, faint=False), + dict(y0=84.0, x0=82.0, radius=5.0, amp=0.80, birth=0, death=-1, vy=0.0, vx=-0.45, faint=False), + # faint, low-contrast -- these are what plan Section 0.9 is about + dict(y0=46.0, x0=102.0, radius=4.0, amp=1.00, birth=0, death=-1, vy=0.0, vx=0.0, faint=True), + dict(y0=88.0, x0=14.0, radius=3.0, amp=0.85, birth=0, death=-1, vy=0.0, vx=0.0, faint=True), +) + +#: The two particles that merge, plus the nucleating and dissolving ones. +MERGE_PAIR = (5, 6) +NUCLEATION_INDEX = 3 +DISSOLUTION_INDEX = 4 + + +def _drift_curve(n_frames: int, amplitude: float) -> np.ndarray: + """``(n_frames, 2)`` per-frame CORRECTION, matching ``DriftModel``'s sign. + + ``drift[t]`` is what you ADD to frame *t* to align it, so the sample appears + displaced by ``-drift[t]`` in the raw frame. + + The two axes get deliberately DIFFERENT shapes -- y grows monotonically with + a slight curve, x swings negative and comes back. A swapped or negated axis + therefore shows up as a wrong-shaped curve rather than merely a wrong + number, which is the whole point of an asymmetric fixture. + """ + if n_frames < 2: + return np.zeros((max(1, n_frames), 2), np.float64) + t = np.arange(n_frames, dtype=np.float64) / (n_frames - 1) + dy = amplitude * t ** 1.3 + dx = -0.6 * amplitude * np.sin(1.7 * np.pi * t) + return np.stack([dy, dx], axis=-1) + + +def _soft_disc(yy, xx, cy, cx, radius, edge=0.9): + """A disc with a smooth ~1 px edge, so sub-pixel centroids are meaningful. + + A hard-edged disc quantises its own centroid to the pixel grid, which would + make any sub-pixel tracking assertion against this fixture untestable. + """ + r = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2) + return 0.5 * (1.0 - np.tanh((r - radius) / edge)) + + +def _particle_arrays(drift: np.ndarray, faint_amplitude: float) -> dict: + """The per-particle arrays, in ONE place so the renderer and the stamped + ground truth cannot disagree about the motion model.""" + return dict( + drift=drift, + p_y0=np.array([p["y0"] for p in _PARTICLES]), + p_x0=np.array([p["x0"] for p in _PARTICLES]), + p_radius=np.array([p["radius"] for p in _PARTICLES]), + p_amp=np.array([(faint_amplitude * p["amp"]) if p["faint"] else p["amp"] + for p in _PARTICLES]), + p_birth=np.array([p["birth"] for p in _PARTICLES]), + p_death=np.array([p["death"] for p in _PARTICLES]), + p_vy=np.array([p["vy"] for p in _PARTICLES]), + p_vx=np.array([p["vx"] for p in _PARTICLES]), + p_faint=np.array([p["faint"] for p in _PARTICLES]), + n_particles=len(_PARTICLES), + ) + + +def particle_truth_at(truth: dict, t: int): + """Expected ``(positions, radii, present)`` at frame *t*. + + ``positions`` is ``(N, 2)`` ``(y, x)`` in **pixels, in the LAB frame** -- + what a segmenter looking at the RAW frame should find, drift included. + ``present`` is a boolean mask of which particles exist in that frame. + + Every consumer computes its expectations through here rather than + re-deriving the motion model, so a test cannot pass by repeating the same + mistake the generator made. + """ + t = int(t) + y0 = np.asarray(truth["p_y0"], float) + x0 = np.asarray(truth["p_x0"], float) + vy = np.asarray(truth["p_vy"], float) + vx = np.asarray(truth["p_vx"], float) + birth = np.asarray(truth["p_birth"], int) + death = np.asarray(truth["p_death"], int) + drift = np.asarray(truth["drift"], float) + + sample = np.stack([y0 + vy * t, x0 + vx * t], axis=-1) + lab = sample - drift[t] # see _drift_curve on the sign + present = (t >= birth) & ((death < 0) | (t < death)) + return lab, np.asarray(truth["p_radius"], float), present + + +def _merge_frame(arrays: dict, n_frames: int) -> int: + """First frame where the merge pair's discs overlap. + + Derived from the same motion model that draws them rather than hard-coded, + so the stamped truth stays correct if the table or the frame count changes. + Returns -1 if they never touch within the movie. + """ + a, b = MERGE_PAIR + ra, rb = _PARTICLES[a]["radius"], _PARTICLES[b]["radius"] + for t in range(n_frames): + pos, _, present = particle_truth_at(arrays, t) + if present[a] and present[b] and np.hypot(*(pos[a] - pos[b])) <= ra + rb: + return t + return -1 + + +def particle_movie(n_frames: int = 24, shape=(96, 112), *, + drift_amplitude: float = 6.0, + faint_amplitude: float = 0.11, + scale: float = 0.5, seed: int = 0, noise: float = 0.015): + """An in-situ particle movie whose every event is known exactly. + + A 1-D navigation (time) axis over 2-D images: nine particles on a drifting + support film, with one nucleation, one dissolution, one merge, one mover and + two deliberately faint low-contrast probes. + + Everything a downstream test needs to assert is stamped as ground truth -- + read it with :func:`ground_truth` and evaluate the motion model with + :func:`particle_truth_at`. + + Parameters + ---------- + n_frames + Number of time points. The nucleation (frame 8) and dissolution + (frame 16) frames are fixed, so keep this above ~20 for both to occur. + shape + ``(ny, nx)``. **Deliberately non-square** so a transposed frame is + obvious at a glance. + drift_amplitude + Peak per-frame drift correction, pixels. The support film drifts + rigidly; particles move relative to it. + faint_amplitude + Peak amplitude of the two faint probes. The default puts them around + 7x the noise sigma -- findable, but not by a threshold tuned for the + bright particles. This is the plan's Section 0.9 gate. + scale + Pixel size in nm, written onto both signal axes. + seed + Everything random here (film speckle, noise) comes from this. + noise + Gaussian sigma added last. 0 disables. + + Notes + ----- + **Why there is a speckled support film.** Drift is only recoverable if + something static dominates the correlation. Particles move, appear and + vanish, so they cannot serve; a smooth gradient has too little + high-frequency content to correlate sharply. A rigidly-drifting speckle + field is both physically right and what lets + :func:`spyde.drift.solve_translation` recover ``drift`` from this movie. + + The film is generated once on a padded canvas and sampled per frame with + bilinear interpolation, so there are no edge artifacts at any drift. + Particles are drawn **analytically** at their lab positions, so their + centroids stay exact regardless of how the film was resampled. + """ + import hyperspy.api as hs + from scipy.ndimage import gaussian_filter, map_coordinates + + ny, nx = int(shape[0]), int(shape[1]) + n_frames = int(n_frames) + rng = np.random.default_rng(seed) + drift = _drift_curve(n_frames, float(drift_amplitude)) + arrays = _particle_arrays(drift, float(faint_amplitude)) + + # The support film, on a canvas padded to cover every drift. + pad = int(np.ceil(np.abs(drift).max())) + 3 + fy, fx = ny + 2 * pad, nx + 2 * pad + yy_p, _ = np.mgrid[0:fy, 0:fx] + film = 0.10 + 0.12 * (yy_p / fy) # ramp along y ONLY (asymmetric) + film += 0.09 * gaussian_filter(rng.standard_normal((fy, fx)), 1.6) + + yy, xx = np.mgrid[0:ny, 0:nx].astype(np.float64) + frames = np.empty((n_frames, ny, nx), dtype=np.float32) + + for t in range(n_frames): + dy, dx = drift[t] + # Sample the film at the lab-frame offset: content sits at -drift. + frame = map_coordinates(film, [yy + pad - dy, xx + pad - dx], + order=1, mode="nearest") + pos, radii, present = particle_truth_at(arrays, t) + for i, p in enumerate(_PARTICLES): + if not present[i]: + continue + amp = (faint_amplitude * p["amp"]) if p["faint"] else p["amp"] + frame = frame + amp * _soft_disc(yy, xx, pos[i, 0], pos[i, 1], radii[i]) + frames[t] = frame + + if noise: + frames += (noise * rng.standard_normal(frames.shape)).astype(np.float32) + + s = hs.signals.Signal2D(frames) + for ax in s.axes_manager.signal_axes: + ax.scale, ax.units = float(scale), "nm" + tax = s.axes_manager.navigation_axes[0] + tax.name, tax.units, tax.scale = "time", "s", 0.05 + s.metadata.General.title = "Synthetic particle movie" + # `insitu` is registered by SpyDE's OWN hyperspy extension, so unlike the + # EELS/EBSD generators this cannot silently fail for a missing extra. + _try_signal_type(s, "insitu") + _stamp(s, kind="particle_movie", **arrays, + n_frames=n_frames, frame_shape=np.asarray([ny, nx]), + scale=float(scale), noise=float(noise), + faint_amplitude=float(faint_amplitude), + merge_pair=np.asarray(MERGE_PAIR), + nucleation_index=NUCLEATION_INDEX, + dissolution_index=DISSOLUTION_INDEX, + nucleation_frame=int(_PARTICLES[NUCLEATION_INDEX]["birth"]), + dissolution_frame=int(_PARTICLES[DISSOLUTION_INDEX]["death"]), + merge_frame=_merge_frame(arrays, n_frames)) + return s diff --git a/spyde/drift/translation.py b/spyde/drift/translation.py index 7d63b2cf..e8019054 100644 --- a/spyde/drift/translation.py +++ b/spyde/drift/translation.py @@ -46,6 +46,58 @@ # the coarse peak. Matches skimage's `phase_cross_correlation` so the two agree. _UPSAMPLED_REGION_FACTOR = 1.5 +# Magnitude FLOOR for phase normalisation — NOT an additive epsilon. +# +# Pure phase correlation divides the cross-power spectrum by its own magnitude, +# which is only meaningful where there is signal. Bins whose magnitude is +# numerically zero must be left alone; dividing them by a tiny epsilon amplifies +# rounding noise to UNIT magnitude, and since there are far more empty bins than +# populated ones, that noise then dominates the inverse transform. +# +# This is not theoretical. With an additive `1e-12` the solver recovered the +# synthetic particle movie's drift to 25 px (worse than not correcting at all) +# as soon as apodisation was enabled — because windowing concentrates spectral +# energy and pushes many more bins down into the numerical floor. With the floor +# below it: 0.06 px. Matches skimage's `100 * finfo(float32).eps`. +_PHASE_FLOOR = 100.0 * float(np.finfo(np.float32).eps) # ~1.19e-5 + +# A frame whose correlation peak is weaker than this fraction of the running +# MEDIAN peak is kept out of the accumulated reference. +# +# The running-average reference exists to be robust to one bad frame, but folding +# every frame in unconditionally does the opposite: a dropped / blanked / saturated +# frame has a broadband spectrum, so after phase normalisation it contributes as +# much to the reference as a good frame and drags every subsequent registration +# with it. Measured on a 5-frame stack with one frame replaced by pure noise, the +# two frames AFTER the bad one came back ~3.9 px wrong; with this rejection they +# are correct and only the bad frame itself is wrong. +# +# Both constants come from measurement, not taste. Peak strength relative to the +# running median, measured across four stacks: +# +# worst NATURAL frame (clean sub-pixel stack, erratic peaks) 0.388 +# a frame replaced by pure noise 0.007 +# +# So there is a ~50x gap to put a threshold in, and 0.25 sits inside it with margin +# both ways. 0.5 was tried first and produced a FALSE rejection on the clean +# sub-pixel stack — which is why this is not simply "half". +# +# _REJECT_MIN_SAMPLES is 1, not 3, and that is deliberate: a short stack cannot +# afford a warm-up. On a 5-frame stack the bad frame arrives before three good ones +# have been seen, so a 3-sample warm-up let it into the reference and the rule never +# fired (measured: 0 rejections, and the two frames after it came back 3.9 px wrong). +# With a 1-sample warm-up those two frames are recovered EXACTLY. +# +# The asymmetry justifies being aggressive: keeping a good frame OUT of the +# reference only slows the averaging, while letting a bad frame IN corrupts every +# registration after it. A rejected frame still gets its own shift reported. +# +# Windowing the median over the last N accepted frames was tried and made NO +# difference at N=3, 5 or unbounded — the natural decay in peak strength as the +# reference averages more frames is not steep enough to matter. Don't add it back. +_REJECT_FRACTION = 0.25 +_REJECT_MIN_SAMPLES = 1 + # ── operator adapters ──────────────────────────────────────────────────────── # The algorithm below is written once against this interface. `_TorchOps` is the @@ -72,6 +124,9 @@ def conj(self, a): def abs(self, a): return np.abs(a) + def clamp_min(self, a, floor): + return np.maximum(a, floor) + def fftfreq(self, n): return np.fft.fftfreq(n).astype(np.float32) @@ -128,6 +183,9 @@ def conj(self, a): def abs(self, a): return self._torch.abs(a) + def clamp_min(self, a, floor): + return self._torch.clamp(a, min=float(floor)) + def fftfreq(self, n): return self._torch.fft.fftfreq(n, device=self.device, dtype=self._torch.float32) @@ -204,25 +262,51 @@ def _resolve_ops(device: str | None): # ── windows and masks (built once per solve) ────────────────────────────────── -def _hann2d(ops, h: int, w: int): - """Separable Hann window. - - Without apodisation a feature entering or leaving at the frame edge correlates - against the *border discontinuity* rather than the sample, which reads as a - spurious jump in the drift curve exactly when something interesting is moving - through the field of view. +#: Default Tukey taper fraction. 0.25 tapers the outer ~12.5% at each edge and +#: leaves the middle 75% at unit weight. See :func:`_taper2d` for why this is +#: NOT 1.0 (a full Hann window). +DEFAULT_TAPER_ALPHA = 0.25 + + +def _tukey1d(n: int, alpha: float) -> np.ndarray: + """Tukey (cosine-tapered) window. ``alpha=0`` rectangular, ``alpha=1`` Hann.""" + if n < 2: + return np.ones(max(1, n), dtype=np.float32) + alpha = float(min(1.0, max(0.0, alpha))) + if alpha <= 0.0: + return np.ones(n, dtype=np.float32) + x = np.arange(n, dtype=np.float64) / (n - 1) + w = np.ones(n, dtype=np.float64) + lo = x < alpha / 2.0 + hi = x > 1.0 - alpha / 2.0 + w[lo] = 0.5 * (1.0 + np.cos(2.0 * np.pi / alpha * (x[lo] - alpha / 2.0))) + w[hi] = 0.5 * (1.0 + np.cos(2.0 * np.pi / alpha * (x[hi] - 1.0 + alpha / 2.0))) + return w.astype(np.float32) + + +def _taper2d(ops, h: int, w: int, alpha: float): + """Separable Tukey taper, built in numpy once per solve then moved on-device. + + **Why a Tukey taper and NOT a full Hann window.** Some apodisation is needed: + a feature entering or leaving at the frame edge otherwise correlates against + the *border discontinuity* rather than the sample. But a full Hann window + (``alpha=1``) reweights the entire frame, and once the drift is large the two + frames have different content under the taper — which manufactures a spurious + correlation peak that can outrank the true one. + + Measured on the synthetic particle movie, frame 23 (true drift ``(6.0, 2.9)``): + with a full Hann window the strongest peak sits at ``(-19, 19)`` scoring 0.121 + while the TRUE peak scores only 0.088, so the solve returns ``(-19.2, 18.6)`` + — a 25 px error, worse than not correcting at all. **skimage's + ``phase_cross_correlation`` returns the same wrong answer on the same windowed + input**, so this is a property of full-frame windowing, not of either + implementation. Tapering only the outer edge leaves the interior comparable + between frames and recovers 0.06 px. + + Do not "simplify" this back to a Hann window. """ - n = ops.arange(h) - m = ops.arange(w) - wy = 0.5 - 0.5 * _cos(ops, 2.0 * math.pi * n / max(1, h - 1)) - wx = 0.5 - 0.5 * _cos(ops, 2.0 * math.pi * m / max(1, w - 1)) - return wy.reshape(h, 1) * wx.reshape(1, w) - - -def _cos(ops, a): - if ops.name == "torch": - return ops._torch.cos(a) - return np.cos(a) + win = _tukey1d(h, alpha)[:, None] * _tukey1d(w, alpha)[None, :] + return ops.to_backend(win) def _shift_mask(ops, h: int, w: int, max_shift: float | None, @@ -303,8 +387,8 @@ def _peak_shift(ops, ref_fft, mov_fft, mask, upsample: float, # Phase correlation proper: discard magnitude, keep only phase. Gives a # far sharper peak than plain cross-correlation on images whose spectra # are dominated by low frequencies, which every real micrograph is. - eps = 1e-12 - product = product / (ops.abs(product) + eps) + # The divisor is FLOORED, not offset — see _PHASE_FLOOR. + product = product / ops.clamp_min(ops.abs(product), _PHASE_FLOOR) cc = ops.ifft2(product) mag = ops.abs(cc) @@ -334,6 +418,21 @@ def _peak_shift(ops, ref_fft, mov_fft, mask, upsample: float, return dy, dx, sharpness +def _accept_into_reference(sharpness: float, accepted: list[float], + enabled: bool) -> bool: + """Whether this frame is credible enough to join the running reference. + + Always True until there are :data:`_REJECT_MIN_SAMPLES` accepted frames to + take a median over — with nothing to compare against, rejecting would just be + guessing. See :data:`_REJECT_FRACTION`. + """ + if not enabled or len(accepted) < _REJECT_MIN_SAMPLES: + return True + if not math.isfinite(sharpness): + return False + return sharpness >= _REJECT_FRACTION * float(np.median(accepted)) + + def _phase_ramp(ops, h: int, w: int, dy: float, dx: float): """FFT multiplier that translates a frame by ``(dy, dx)`` exactly. @@ -358,8 +457,9 @@ def solve_translation( max_shift: float | None = 32.0, min_shift: float | None = None, reference: str = "running", - apodize: bool = True, + apodize: bool | float = True, normalize: bool = True, + reject_outliers: bool = True, device: str | None = None, progress: Callable[[int, int], None] | None = None, cancel: Callable[[], bool] | None = None, @@ -389,9 +489,17 @@ def solve_translation( (handles large excursions, accumulates error); ``"first"`` or ``"fixed:"`` — one fixed reference frame. apodize - Apply a Hann window before transforming. See :func:`_hann2d`. + Edge taper before transforming. ``True`` uses a Tukey window with + ``alpha=DEFAULT_TAPER_ALPHA``; a float sets alpha explicitly + (``1.0`` = full Hann, which is a trap — see :func:`_taper2d`); + ``False`` disables it. normalize True phase correlation (unit-magnitude spectrum). Sharper peak. + reject_outliers + ``running`` mode only: keep a frame out of the accumulated reference when + its correlation peak is not credible (see :data:`_REJECT_FRACTION`). Its + own shift is still reported — only the reference is protected. This is + what makes "robust to one bad frame" true rather than aspirational. device ``None`` auto-selects CUDA/MPS then falls back to numpy; ``"numpy"`` forces the reference path; or an explicit torch device string. @@ -427,7 +535,9 @@ def solve_translation( # runs on a worker thread and per-frame acquire/release would be pure # overhead at thousands of frames. with accelerator_lock(ops.device): - window = _hann2d(ops, h, w) if apodize else None + alpha = (DEFAULT_TAPER_ALPHA if apodize is True + else (0.0 if apodize is False else float(apodize))) + window = _taper2d(ops, h, w, alpha) if alpha > 0 else None mask = _shift_mask(ops, h, w, max_shift, min_shift) def frame_fft(i: int): @@ -452,6 +562,8 @@ def frame_fft(i: int): ref_count = 1 prev_fft = first # sequential mode cumulative = np.zeros(2, dtype=np.float64) + accepted_sharp: list[float] = [] # peak strengths folded into the reference + rejected = 0 if progress is not None: progress(1, n_frames) @@ -471,12 +583,19 @@ def frame_fft(i: int): else: dy, dx, s = _peak_shift(ops, ref_fft, mov, mask, upsample, normalize) shifts[i] = (dy, dx) - if reference == "running": + if reference == "running" and _accept_into_reference( + s, accepted_sharp, reject_outliers): # Fold the ALIGNED frame in via a phase ramp — exact, and no # resampling blur accumulates over the stack. aligned = mov * _phase_ramp(ops, h, w, dy, dx) ref_fft = (ref_fft * ref_count + aligned) / (ref_count + 1) ref_count += 1 + accepted_sharp.append(s) + elif reference == "running": + rejected += 1 + log.debug("[drift] frame %d kept out of the reference " + "(peak %.2f vs median %.2f)", i, s, + float(np.median(accepted_sharp))) sharp[i] = s if progress is not None: @@ -487,8 +606,10 @@ def frame_fft(i: int): "max_shift": None if max_shift is None else float(max_shift), "min_shift": None if min_shift is None else float(min_shift), "reference": reference, - "apodize": bool(apodize), + "apodize": float(alpha), "normalize": bool(normalize), + "reject_outliers": bool(reject_outliers), + "rejected_from_reference": int(rejected), "backend": ops.name, "n_frames": int(n_frames), "frame_shape": [int(h), int(w)], diff --git a/spyde/tests/migrated/test_drift_translation.py b/spyde/tests/migrated/test_drift_translation.py index 1889cf80..86b15bd1 100644 --- a/spyde/tests/migrated/test_drift_translation.py +++ b/spyde/tests/migrated/test_drift_translation.py @@ -202,7 +202,11 @@ def test_sequential_reference_accumulates(self): assert np.allclose(model.shifts, truth, atol=GATE_PX), model.shifts def test_running_reference_survives_one_corrupt_frame(self): - """Why 'running' is the default: a single bad frame must not poison it.""" + """Why 'running' is the default: a single bad frame must not poison it. + + The frames AFTER the corrupt one are what matters. The corrupt frame's own + shift is meaningless by construction and is not asserted on. + """ truth = np.array([[0, 0], [2, 1], [4, 2], [6, 3], [8, 4]], dtype=float) stack = _shifted_stack(truth).copy() rng = np.random.default_rng(0) @@ -211,6 +215,32 @@ def test_running_reference_survives_one_corrupt_frame(self): good = [1, 3, 4] err = np.abs(model.shifts[good] - truth[good]).max() assert err < 0.5, f"good frames drifted after a corrupt frame: {model.shifts}" + assert model.params["rejected_from_reference"] >= 1, ( + "nothing was kept out of the reference, so this passed by luck rather " + "than by the outlier rejection it is meant to exercise") + + def test_outlier_rejection_can_be_disabled(self): + """And with it off, the corrupt frame really does poison the reference — + which is what makes the test above non-vacuous.""" + truth = np.array([[0, 0], [2, 1], [4, 2], [6, 3], [8, 4]], dtype=float) + stack = _shifted_stack(truth).copy() + rng = np.random.default_rng(0) + stack[2] = rng.standard_normal(stack.shape[1:]).astype(np.float32) + model = solve_translation(stack, device="numpy", upsample=8, max_shift=20, + reject_outliers=False) + assert model.params["rejected_from_reference"] == 0 + good = [3, 4] + assert np.abs(model.shifts[good] - truth[good]).max() > 1.0, ( + "the corrupt frame no longer poisons an unprotected reference — if the " + "solver became robust some other way, check deliberately") + + def test_clean_stack_rejects_nothing(self): + """The rejection must not fire on ordinary frame-to-frame variation.""" + truth = np.array([[0, 0], [1.5, 0.5], [3, 1], [4.5, 1.5], [6, 2]], float) + model = solve_translation(_shifted_stack(truth), device="numpy", + upsample=8, max_shift=20) + assert model.params["rejected_from_reference"] == 0 + assert np.abs(model.shifts - truth).max() < GATE_PX class TestSolveTranslationGuards: diff --git a/spyde/tests/migrated/test_particle_movie_fixture.py b/spyde/tests/migrated/test_particle_movie_fixture.py new file mode 100644 index 00000000..0652cd31 --- /dev/null +++ b/spyde/tests/migrated/test_particle_movie_fixture.py @@ -0,0 +1,388 @@ +""" +The synthetic particle-movie fixture, and what it is the acceptance gate for. + +``spyde.data.synthetic.particle_movie`` is Step 0 of DRIFT_AND_PARTICLES_PLAN.md: +every later step is checked against a number from this fixture rather than against +a golden file or a screenshot. So this file has two jobs: + +1. **Pin the fixture itself.** If its ground truth drifts out of step with the + pixels it generates, every downstream gate silently becomes meaningless. +2. **Run the end-to-end gates it exists to serve** — drift recovery and + segmentation sensitivity — since a fixture nothing consumes is not verified. + +The regression in :class:`TestTaperTrap` is the reason this file exists at all: +the fixture found a defect in the drift solver on its first run. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from spyde.data.synthetic import ( + DISSOLUTION_INDEX, + MERGE_PAIR, + NUCLEATION_INDEX, + ground_truth, + particle_movie, + particle_truth_at, +) +from spyde.drift import solve_translation +from spyde.particles import SegmentParams, measure_frame, segment_frame +from spyde.signals.particles import COL + + +@pytest.fixture(scope="module") +def movie(): + """One build shared across the module — it takes ~1 s and is deterministic.""" + s = particle_movie() + return s, ground_truth(s) + + +class TestFixtureShape: + def test_is_an_insitu_signal(self, movie): + s, _ = movie + assert type(s).__name__ == "InSitu", ( + "the movie must cast to the insitu signal type or the Play / " + "Fast-Forward toolbar gating never applies to it") + + def test_dimensions_and_dtype(self, movie): + s, gt = movie + assert s.data.shape == (gt["n_frames"], *tuple(gt["frame_shape"])) + assert s.data.dtype == np.float32 + assert s.axes_manager.navigation_dimension == 1 + assert s.axes_manager.signal_dimension == 2 + + def test_frames_are_non_square(self, movie): + """A square fixture hides a transposed frame.""" + _, gt = movie + ny, nx = tuple(gt["frame_shape"]) + assert ny != nx + + def test_time_axis_is_calibrated(self, movie): + s, _ = movie + tax = s.axes_manager.navigation_axes[0] + assert tax.name == "time" and tax.units == "s" + assert tax.scale == pytest.approx(0.05), ( + "real-time playback reads the time-axis scale; scale=1 makes the " + "movie crawl at 1 fps") + + def test_signal_axes_are_calibrated(self, movie): + s, gt = movie + for ax in s.axes_manager.signal_axes: + assert ax.units == "nm" + assert ax.scale == pytest.approx(gt["scale"]) + + def test_deterministic(self): + a = particle_movie(n_frames=6) + b = particle_movie(n_frames=6) + assert np.array_equal(a.data, b.data) + + def test_seed_changes_only_the_noise_and_film(self): + a = particle_movie(n_frames=6, seed=0) + b = particle_movie(n_frames=6, seed=1) + assert not np.array_equal(a.data, b.data) + # The particles are analytic, so the ground truth is seed-independent. + assert np.array_equal(ground_truth(a)["p_y0"], ground_truth(b)["p_y0"]) + + +class TestAsymmetry: + """Every one of these would pass on symmetric data while hiding a real bug.""" + + def test_frame_differs_from_its_mirrors_and_transpose(self, movie): + s, _ = movie + f = s.data[0] + assert not np.allclose(f, f[::-1]), "vertically symmetric — a flip would hide" + assert not np.allclose(f, f[:, ::-1]), "horizontally symmetric" + # Non-square, so a transpose cannot even be compared — that is the point. + assert f.shape[0] != f.shape[1] + + def test_frames_differ_from_each_other(self, movie): + """A stale-frame bug must be visible.""" + s, gt = movie + for t in range(1, int(gt["n_frames"])): + assert not np.allclose(s.data[t], s.data[t - 1]), f"frame {t} == {t-1}" + + def test_drift_axes_have_different_shapes(self, movie): + """A swapped axis must show as a wrong-shaped curve, not a wrong number.""" + _, gt = movie + dy, dx = np.asarray(gt["drift"]).T + assert np.all(np.diff(dy) >= -1e-9), "dy should be monotonic" + assert dx.min() < -0.5 and dx.max() > 0.5, "dx should swing both ways" + + +class TestGroundTruth: + def test_drift_starts_at_zero(self, movie): + _, gt = movie + assert np.allclose(np.asarray(gt["drift"])[0], 0.0) + + def test_event_frames_are_in_range(self, movie): + _, gt = movie + n = int(gt["n_frames"]) + for key in ("nucleation_frame", "dissolution_frame", "merge_frame"): + assert 0 < int(gt[key]) < n, f"{key}={gt[key]} outside 0..{n}" + + def test_counts_match_the_event_timeline(self, movie): + """The count trace must step up at nucleation and down at dissolution.""" + _, gt = movie + n = int(gt["n_frames"]) + counts = np.array([particle_truth_at(gt, t)[2].sum() for t in range(n)]) + nuc, dis = int(gt["nucleation_frame"]), int(gt["dissolution_frame"]) + assert counts[nuc] == counts[nuc - 1] + 1, "no step up at nucleation" + assert counts[dis] == counts[dis - 1] - 1, "no step down at dissolution" + + def test_nucleating_particle_is_absent_then_present(self, movie): + _, gt = movie + i, nuc = NUCLEATION_INDEX, int(gt["nucleation_frame"]) + assert not particle_truth_at(gt, nuc - 1)[2][i] + assert particle_truth_at(gt, nuc)[2][i] + + def test_dissolving_particle_is_present_then_absent(self, movie): + _, gt = movie + i, dis = DISSOLUTION_INDEX, int(gt["dissolution_frame"]) + assert particle_truth_at(gt, dis - 1)[2][i] + assert not particle_truth_at(gt, dis)[2][i] + + def test_merge_pair_converges_and_overlaps_at_the_stamped_frame(self, movie): + _, gt = movie + a, b = tuple(gt["merge_pair"]) + radii = np.asarray(gt["p_radius"]) + touch = radii[a] + radii[b] + mf = int(gt["merge_frame"]) + + def gap(t): + pos = particle_truth_at(gt, t)[0] + return float(np.hypot(*(pos[a] - pos[b]))) + + assert gap(0) > touch, "the merge pair already overlaps at t=0" + assert gap(mf) <= touch, f"no overlap at the stamped merge_frame {mf}" + assert gap(mf - 1) > touch, f"they already overlapped before frame {mf}" + + def test_faint_probes_are_faint_but_findable(self, movie): + """The Section 0.9 gate: above noise, well below the bright particles.""" + _, gt = movie + faint = np.asarray(gt["p_faint"], bool) + amps = np.asarray(gt["p_amp"]) + noise = float(gt["noise"]) + assert faint.sum() == 2 + assert amps[faint].max() < 0.25 * amps[~faint].min(), "not actually faint" + assert amps[faint].min() / noise > 4.0, "buried in noise, unfindable" + + def test_ground_truth_raises_on_a_plain_signal(self): + import hyperspy.api as hs + with pytest.raises(ValueError, match="no synthetic ground truth"): + ground_truth(hs.signals.Signal2D(np.zeros((4, 4)))) + + +class TestTruthMatchesPixels: + """The fixture's stamped truth must describe the pixels it actually drew.""" + + def test_every_present_particle_is_brighter_than_its_surroundings(self, movie): + s, gt = movie + for t in (0, 12, int(gt["n_frames"]) - 1): + f = s.data[t] + pos, radii, present = particle_truth_at(gt, t) + faint = np.asarray(gt["p_faint"], bool) + for i in np.flatnonzero(present): + if faint[i]: + continue # covered by the sensitivity test + cy, cx = int(round(pos[i, 0])), int(round(pos[i, 1])) + if not (2 <= cy < f.shape[0] - 2 and 2 <= cx < f.shape[1] - 2): + continue # drifted out of frame + centre = f[cy - 1:cy + 2, cx - 1:cx + 2].mean() + assert centre > np.median(f) + 0.2, ( + f"frame {t} particle {i} at ({cy},{cx}) is not bright") + + def test_absent_particles_leave_nothing_behind(self, movie): + """A dissolved particle must actually be gone from the pixels.""" + s, gt = movie + i = DISSOLUTION_INDEX + dis = int(gt["dissolution_frame"]) + before, after = s.data[dis - 1], s.data[dis] + pos = particle_truth_at(gt, dis)[0] + cy, cx = int(round(pos[i, 0])), int(round(pos[i, 1])) + w = 3 + assert (before[cy - w:cy + w, cx - w:cx + w].mean() + > after[cy - w:cy + w, cx - w:cx + w].mean() + 0.3), ( + "the dissolving particle is still in the pixels after its death frame") + + +class TestDriftRecoveryGate: + """Plan gate A1, run against the fixture rather than a hand-made stack.""" + + def test_recovers_the_applied_drift(self, movie): + s, gt = movie + truth = np.asarray(gt["drift"]) + model = solve_translation(s.data, device="numpy", upsample=8, + reference="first", max_shift=20) + err = np.abs(model.shifts - truth) + assert err.max() < 0.5, f"max drift error {err.max():.3f} px\n{model.shifts}" + + def test_running_reference_also_works(self, movie): + s, gt = movie + truth = np.asarray(gt["drift"]) + model = solve_translation(s.data, device="numpy", upsample=8, max_shift=20) + assert np.abs(model.shifts - truth).max() < 0.5 + + def test_correction_flattens_the_drift(self, movie): + """End to end: solve, apply, and the film should stop moving.""" + from spyde.drift import shift_frame + s, gt = movie + model = solve_translation(s.data, device="numpy", upsample=8, + reference="first", max_shift=20) + core = (slice(20, -20), slice(20, -20)) + ref = s.data[0][core] + last = int(gt["n_frames"]) - 1 + raw = float(np.abs(s.data[last][core] - ref).mean()) + fixed = shift_frame(s.data[last], model.shifts[last], fill=0.0) + corrected = float(np.abs(fixed[core] - ref).mean()) + assert corrected < raw, ( + f"correction made it worse (raw {raw:.4f} -> {corrected:.4f}) — " + "check the SIGN convention") + + +class TestTaperTrap: + """A full Hann window destroys this registration. Do not re-enable it. + + This is a real defect the fixture caught on its first run: with ``apodize=1.0`` + the solve returns a 25 px error on a 6 px drift — worse than not correcting at + all. It is NOT specific to our implementation; ``skimage``'s + ``phase_cross_correlation`` returns the same wrong answer on the same windowed + input, because a full-frame window reweights the two frames' content + differently once the drift is large and manufactures a false peak. + """ + + def test_default_taper_is_a_partial_edge_taper(self): + from spyde.drift.translation import DEFAULT_TAPER_ALPHA + assert 0.0 < DEFAULT_TAPER_ALPHA < 0.6, ( + "the default must taper only the EDGE; alpha near 1.0 is the trap " + "this class documents") + + def test_full_hann_is_dramatically_worse_than_the_default(self, movie): + s, gt = movie + truth = np.asarray(gt["drift"]) + good = solve_translation(s.data, device="numpy", upsample=8, + reference="first", max_shift=20) + hann = solve_translation(s.data, device="numpy", upsample=8, + reference="first", max_shift=20, apodize=1.0) + e_good = np.abs(good.shifts - truth).max() + e_hann = np.abs(hann.shifts - truth).max() + assert e_good < 0.5, f"the default taper regressed: {e_good:.3f} px" + assert e_hann > 5.0 * e_good, ( + "full Hann is no longer catastrophic here — if the solver changed so " + "that it is safe, this test has served its purpose and can go, but " + f"check deliberately (default {e_good:.3f} px vs hann {e_hann:.3f} px)") + + def test_apodize_records_the_alpha_actually_used(self, movie): + s, _ = movie + assert solve_translation(s.data[:3], device="numpy", + apodize=False).params["apodize"] == 0.0 + assert solve_translation(s.data[:3], device="numpy", + apodize=0.3).params["apodize"] == pytest.approx(0.3) + + +class TestHarnessLoader: + """``load_test_data_particles`` — the door the e2e specs come through.""" + + def test_action_is_registered(self): + from spyde.backend._session_actions import _TEST_ACTIONS + assert "load_test_data_particles" in _TEST_ACTIONS, ( + "the action is not in _TEST_ACTIONS, so dispatch will reject it and " + "every e2e spec silently loads nothing") + + def test_loads_lazily_one_frame_per_chunk(self, window): + session = window["window"] + session._load_test_data_particles({"frames": 6}) + assert len(window["signal_trees"]) == 1 + root = window["signal_trees"][0].root + assert root._lazy, "must be lazy — an eager fixture skips the cache path" + assert root.data.chunksize[0] == 1, ( + f"expected one frame per chunk, got {root.data.chunksize} — that is " + "what makes each nav move a small cold read like a real .mrc") + + def test_signal_type_and_axes_survive_the_lazy_rewrap(self, window): + session = window["window"] + session._load_test_data_particles({"frames": 6}) + root = window["signal_trees"][0].root + assert getattr(root, "_signal_type", None) == "insitu", ( + "the insitu cast was lost, so Play / Fast-Forward will not appear") + tax = root.axes_manager.navigation_axes[0] + assert tax.name == "time" and tax.scale == pytest.approx(0.05) + assert root.axes_manager.signal_axes[0].units == "nm" + + def test_ground_truth_survives_the_lazy_rewrap(self, window): + """The whole point of the fixture is its stamped truth — losing it in the + re-wrap would leave every downstream gate asserting against nothing.""" + session = window["window"] + session._load_test_data_particles({"frames": 6}) + gt = ground_truth(window["signal_trees"][0].root) + assert gt["kind"] == "particle_movie" + assert int(gt["nucleation_frame"]) == NUCLEATION_INDEX + 5 # 8 + assert np.asarray(gt["drift"]).shape == (6, 2) + + def test_eager_option(self, window): + session = window["window"] + session._load_test_data_particles({"frames": 4, "eager": True}) + assert not window["signal_trees"][0].root._lazy + + def test_opens_two_windows(self, window): + """A 1-D-nav movie gives a navigator plus a signal window.""" + session = window["window"] + session._load_test_data_particles({"frames": 6}) + assert len(session._plots) >= 2, ( + f"expected navigator + signal plots, got {len(session._plots)}") + + +class TestSegmentationOnTheFixture: + """Plan gate B1/B5 against known radii, plus the Section 0.9 sensitivity gate.""" + + def test_finds_every_bright_particle(self, movie): + s, gt = movie + t = 12 # all nine present + labels = segment_frame(s.data[t], SegmentParams(min_size=25, gaussian=1.0)) + pos, _, present = particle_truth_at(gt, t) + faint = np.asarray(gt["p_faint"], bool) + want = np.flatnonzero(present & ~faint) + found = 0 + for i in want: + cy, cx = int(round(pos[i, 0])), int(round(pos[i, 1])) + if labels[cy, cx] != 0: + found += 1 + assert found == len(want), f"found {found} of {len(want)} bright particles" + + def test_default_sensitivity_misses_the_faint_probes(self, movie): + """Establishes that the sensitivity gate below is not vacuous.""" + s, gt = movie + t = 12 + labels = segment_frame(s.data[t], SegmentParams(min_size=25, gaussian=1.0)) + pos = particle_truth_at(gt, t)[0] + faint = np.flatnonzero(np.asarray(gt["p_faint"], bool)) + hit = sum(labels[int(round(pos[i, 0])), int(round(pos[i, 1]))] != 0 + for i in faint) + assert hit < len(faint), ( + "the faint probes are already found at default sensitivity — they are " + "not faint enough to test anything") + + def test_measured_radii_match_the_truth(self, movie): + s, gt = movie + t = 12 + labels = segment_frame(s.data[t], SegmentParams(min_size=25, gaussian=1.0)) + rows, _ = measure_frame(labels, s.data[t], t=t, scale=1.0) + pos, radii, present = particle_truth_at(gt, t) + faint = np.asarray(gt["p_faint"], bool) + checked = 0 + for i in np.flatnonzero(present & ~faint): + cy, cx = int(round(pos[i, 0])), int(round(pos[i, 1])) + lbl = labels[cy, cx] + if lbl == 0: + continue + row = rows[rows[:, COL["label"]] == lbl] + if not len(row): + continue + # Only isolated particles: a merged pair's area is not one disc. + if i in tuple(gt["merge_pair"]): + continue + got_r = float(row[0, COL["equiv_diameter"]]) / 2.0 + assert abs(got_r - radii[i]) < 0.35 * radii[i], ( + f"particle {i}: measured r={got_r:.2f} vs truth {radii[i]:.2f}") + checked += 1 + assert checked >= 3, f"only checked {checked} particles — test too weak" From 008406dbae9f239a022cd5406d1f27f0417574cf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 18:17:57 -0500 Subject: [PATCH 03/38] chore(scripts): one-command verification and benchmark for drift + particles `scripts/verify_drift_particles.py` runs the python suites, the frontend typecheck, the Playwright specs and the benchmarks from one place, because the feature spans two languages, three test tiers and a separate repo. Every stage is independent and the exit code is the worst result, so one broken tier still reports the state of the others -- it is a status board, not a fail-fast gate. Suites and specs that a plan step has not reached yet report SKIP rather than failing, so the same command is useful from the first step to the last. `scripts/bench_drift_particles.py` prints the numbers every recorded decision rests on, in a form that pastes into benchmarks.md. Deliberately not a pytest: these are machine-dependent, and a benchmark that fails CI because a runner was busy teaches nothing. Tests assert correctness; this reports cost. Recorded in benchmarks.md, including one number that points at future work: measure_frame costs 3.7x segment_frame, which is the wrong way round. The segmenter is vectorised over pixels while measurement still loops over particles to crop each bbox and trace each contour. Harmless at fixture scale (52 s for 3000 small frames) but it is the first place to look if the real 2048-4096 px target misses the plan's "minutes" budget. --- benchmarks.md | 59 +++++++++++ scripts/bench_drift_particles.py | 165 +++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 scripts/bench_drift_particles.py diff --git a/benchmarks.md b/benchmarks.md index de08d9db..999da853 100644 --- a/benchmarks.md +++ b/benchmarks.md @@ -769,3 +769,62 @@ each frame pays a host→device transfer that a batched formulation would amorti That is the accepted trade for the Memory-Safety rule (a 3000 × 4096² movie is tens of GB and cannot be batched wholesale). If the transfer ever dominates, the fix is a bounded read-ahead of a few frames, not materialising the stack. + +### Apodisation and reference robustness -- two traps the fixture found (2026-07-29) + +Both found by `spyde.data.synthetic.particle_movie` on its FIRST run, which is the +argument for building a ground-truth fixture before the thing it grades. + +**A full Hann window destroys the registration.** Synthetic movie, true drift at +frame 23 = `(6.0, 2.9)` px: + +| taper alpha | max error over 24 frames | +|---|---| +| 0.00 (none) | 0.125 px | +| 0.10 | 0.125 px | +| **0.25 (default)** | **0.124 px** | +| 0.50 | 0.227 px | +| 1.00 (full Hann) | **25.25 px** | + +At alpha=1 the strongest correlation peak sits at `(-19, 19)` scoring 0.121 while +the TRUE peak scores 0.088. **skimage's `phase_cross_correlation` returns the same +wrong answer on the same windowed input**, so this is a property of full-frame +windowing, not of either implementation: once the drift is large the window +reweights different content in each frame. `max_shift=20` does not save you -- the +false peak is inside the band. Taper the EDGE only. + +**The running reference needed explicit outlier rejection.** Peak strength relative +to the running median: + +| case | ratio | +|---|---| +| worst NATURAL frame (clean sub-pixel stack) | 0.388 | +| a frame replaced by pure noise | 0.007 | + +A ~50x gap, so the threshold is 0.25. Without rejection, one noise frame in a +5-frame stack dragged the two frames AFTER it 3.9 px off. With it, those two are +recovered exactly and only the bad frame is wrong. Two settings that did not +survive measurement: a 3-sample warm-up (too slow -- the bad frame is already in +the reference on a short stack) and windowing the median over the last N accepted +(no difference at N=3, 5 or unbounded). + +### Fixture + classical segmentation cost (2026-07-29) + +| stage | time | note | +|---|---|---| +| build fixture (24 x 96x112) | 98 ms | eager, deterministic | +| drift solve, 24 frames | 32 ms | 0.124 px max error | +| `segment_frame`, one 96x112 frame | 3.7 ms | 272 frames/s | +| `measure_frame`, one 96x112 frame | 13.7 ms | 73 frames/s | +| combined | 17.4 ms | 58 frames/s | + +**`measure_frame` is 3.7x the cost of `segment_frame`, which is the wrong way +round and is the thing to watch.** Segmentation is vectorised over pixels; +measurement still loops over PARTICLES to crop each bbox for the intensity ring and +to trace each contour. At 3000 frames of this size that is 52 s -- fine -- but the +plan's target frames are 2048-4096 square, and the loop cost grows with particle +count as well as pixels. If the combined figure misses the "minutes" target at real +scale, `measure_frame` is where to look first, not the segmenter. + +Regenerate all of the above with `python scripts/bench_drift_particles.py`. +Verify the whole feature with `python scripts/verify_drift_particles.py --all`. diff --git a/scripts/bench_drift_particles.py b/scripts/bench_drift_particles.py new file mode 100644 index 00000000..cc4c9404 --- /dev/null +++ b/scripts/bench_drift_particles.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python +""" +The drift + particles numbers, in one run. + + python scripts/bench_drift_particles.py + +Prints a table suitable for pasting into ``benchmarks.md``. Every number here is a +number some decision in ``DRIFT_AND_PARTICLES_PLAN.md`` rests on, so re-run it +after touching the solver or the feature stack rather than trusting the recorded +values — they were measured on one machine on one day. + +Deliberately NOT a pytest: these are slow, machine-dependent, and a benchmark that +fails CI because a runner was busy teaches nothing. The tests assert CORRECTNESS; +this reports COST. +""" +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def _t(fn, repeat: int = 3): + """Best of *repeat*, discarding the first run (cold CUDA init / kernel JIT).""" + fn() + best = float("inf") + for _ in range(repeat): + t0 = time.perf_counter() + fn() + best = min(best, time.perf_counter() - t0) + return best + + +def bench_drift_backends(rows: list[str]) -> None: + from spyde.drift.translation import solve_translation + + rng = np.random.default_rng(0) + for (nf, n) in [(60, 256), (60, 512)]: + base = rng.standard_normal((n, n)).astype(np.float32) + stack = np.repeat(base[None], nf, axis=0) + rows.append(f"\n### {nf} frames x {n}^2, upsample=8") + rows.append("") + rows.append("| backend | time | frames/s |") + rows.append("|---|---|---|") + for dev in ("numpy", "cpu", "cuda"): + try: + dt = _t(lambda d=dev: solve_translation(stack, device=d, upsample=8), + repeat=2) + rows.append(f"| {dev} | {dt:.2f} s | {nf / dt:.0f} |") + except Exception as exc: + rows.append(f"| {dev} | unavailable | {type(exc).__name__} |") + + +def bench_drift_accuracy(rows: list[str]) -> None: + """Error vs upsample, on truth deliberately OFF the 1/upsample grid. + + On-grid truth is recovered exactly at any upsample, which looks superb and + tests nothing — that mistake hid a real bug in the refinement once already. + """ + from spyde.drift.translation import solve_translation + from spyde.tests.migrated.test_drift_translation import _shifted_stack + + truth = np.array([[0, 0], [1.37, -2.83], [-3.06, 0.61], [4.19, 5.44], + [-0.72, -1.28]]) + stack = _shifted_stack(truth) + rows.append("\n### Sub-pixel accuracy vs upsample (off-grid truth)") + rows.append("") + rows.append("| upsample | max error |") + rows.append("|---|---|") + for u in (1, 2, 8, 32, 64): + m = solve_translation(stack, device="numpy", upsample=u, reference="first") + rows.append(f"| {u} | {np.abs(m.shifts - truth).max():.3f} px |") + + +def bench_fixture(rows: list[str]) -> None: + import spyde.data.synthetic as sy + from spyde.drift.translation import solve_translation + + rows.append("\n### Synthetic particle-movie fixture") + rows.append("") + build = _t(lambda: sy.particle_movie(), repeat=2) + s = sy.particle_movie() + gt = sy.ground_truth(s) + truth = np.asarray(gt["drift"]) + m = solve_translation(s.data, device="numpy", upsample=8, reference="first", + max_shift=20) + err = np.abs(m.shifts - truth) + solve = _t(lambda: solve_translation(s.data, device="numpy", upsample=8, + reference="first", max_shift=20), repeat=2) + rows.append("| stage | value |") + rows.append("|---|---|") + rows.append(f"| build (24 x 96x112) | {build * 1e3:.0f} ms |") + rows.append(f"| drift solve | {solve * 1e3:.0f} ms |") + rows.append(f"| drift error (max / mean) | {err.max():.3f} / {err.mean():.3f} px |") + rows.append(f"| frames rejected from reference | " + f"{m.params['rejected_from_reference']} |") + + +def bench_segment(rows: list[str]) -> None: + import spyde.data.synthetic as sy + from spyde.particles import SegmentParams, measure_frame, segment_frame + + s = sy.particle_movie() + frame = s.data[12] + p = SegmentParams(min_size=25, gaussian=1.0) + seg = _t(lambda: segment_frame(frame, p)) + labels = segment_frame(frame, p) + meas = _t(lambda: measure_frame(labels, frame, t=12, scale=0.5)) + rows.append("\n### Classical segment + measure, one 96x112 frame") + rows.append("") + rows.append("| stage | time | frames/s |") + rows.append("|---|---|---|") + rows.append(f"| segment_frame | {seg * 1e3:.1f} ms | {1 / seg:.0f} |") + rows.append(f"| measure_frame | {meas * 1e3:.1f} ms | {1 / meas:.0f} |") + rows.append(f"| combined | {(seg + meas) * 1e3:.1f} ms | {1 / (seg + meas):.0f} |") + rows.append("") + rows.append(f"Extrapolated to 3000 frames: **{3000 * (seg + meas):.0f} s** " + f"single-threaded. The plan's target is minutes, so this is the " + f"number to watch as frame size grows (cost is per-pixel, and a " + f"4096^2 frame is 1600x this one's area).") + + +def bench_optional(rows: list[str]) -> None: + """Stages whose modules may not have landed yet — reported as absent, not fatal.""" + try: + from spyde.particles import features # noqa: F401 + except Exception: + rows.append("\n### Feature stack — not implemented yet") + return + import spyde.data.synthetic as sy + from spyde.particles.features import FeatureSpec, compute_features + + s = sy.particle_movie() + frame = s.data[12] + spec = FeatureSpec() + dt = _t(lambda: compute_features(frame, spec, device="cpu")) + rows.append("\n### Torch feature stack, one 96x112 frame (CPU)") + rows.append("") + rows.append(f"| compute_features | {dt * 1e3:.1f} ms |") + rows.append("|---|---|") + + +def main() -> int: + rows: list[str] = ["# drift + particles benchmark", ""] + for fn in (bench_drift_accuracy, bench_drift_backends, bench_fixture, + bench_segment, bench_optional): + try: + fn(rows) + except Exception as exc: + rows.append(f"\n### {fn.__name__} FAILED: {type(exc).__name__}: {exc}") + text = "\n".join(rows) + "\n" + sys.stdout.write(text) + sys.stdout.flush() + # torch/CUDA teardown can crash the interpreter on Windows after CUDA work + # (CLAUDE.md); the output is already flushed, so leave immediately. + os._exit(0) + + +if __name__ == "__main__": + main() From 08effdd492f61715d044a2286e301a501a5c8b44 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 18:47:09 -0500 Subject: [PATCH 04/38] feat(particles): frame-to-frame linker and the birth/death/merge/split stream Wave C1/C2. One `linear_sum_assignment` per frame pair over a distance cost gated by `max_dist`, with `memory=k` gap closing; the assignment's leftovers carry the events. Distances are in the particles' CALIBRATED units because the measured centroids are, and `sample_frame_positions` is the single seam where DriftModel's pixels cross over. The gate uses a rectangular matrix with a derived sentinel for infeasible pairs rather than the padded square Jaqaman formulation -- same optimum, a quarter the matrix at 500 particles/frame. The property-similarity penalty deliberately cannot move the gate; it only reorders pairs that are already admissible. Verified independently of the agent that wrote it: no link exceeds `max_dist` at any `max_dist` from 1 to 40 px, and the identity `Dcount = births + splits - deaths - merges` holds on every frame of the fixture. Trajectories exact (mover is ONE track across all 24 frames, 0.175 px max error); one birth at frame 8; one death at frame 16; drift correction takes the static anchors from a 9.3 px lab excursion down to 0.3 px. THREE CORRECTIONS TO THE PLAN, all found by building this: 1. C1 said the linker runs on "raw minus tree.drift". That is `to_lab_frame` -- the INVERSE -- and doubles the drift instead of removing it. Exactly the sign trap drift/model.py's docstring warns about, written into the plan anyway. It is `to_sample_frame`, which ADDS the shifts. 2. C2 claimed the unmatched rows and columns ARE the event stream. Birth and death, yes. Merge and split, no: a one-to-one assignment cannot express two-to-one, so they need an explicit post-pass with its own radius and its own failure modes. Now documented, along with those failure modes. 3. "Recovers the merge exactly" was not achievable and is now reworded. The merge FRAME is a segmentation property: geometric contact is frame 14, but the segmenter resolves one region at 18 with watershed on and at 12 with it off -- the truth is bracketed on one boolean. The gate now asserts what belongs to the linker, and a separate test asserts the bracketing so the offset is provably segmentation, not a linker bug. 102 tests. --- DRIFT_AND_PARTICLES_PLAN.md | 57 +- spyde/particles/track.py | 894 +++++++++++++++ spyde/tests/migrated/test_particles_track.py | 1025 ++++++++++++++++++ 3 files changed, 1970 insertions(+), 6 deletions(-) create mode 100644 spyde/particles/track.py create mode 100644 spyde/tests/migrated/test_particles_track.py diff --git a/DRIFT_AND_PARTICLES_PLAN.md b/DRIFT_AND_PARTICLES_PLAN.md index c376b8d4..6d6c04be 100644 --- a/DRIFT_AND_PARTICLES_PLAN.md +++ b/DRIFT_AND_PARTICLES_PLAN.md @@ -560,11 +560,34 @@ The floating strip (B0) mirrors the class colours for in-canvas switching. `scipy.optimize.linear_sum_assignment` on a cost matrix of centroid distance (gated by `max_dist`), optionally weighted by property similarity — trackpy's model, no new dependency. `memory=k` lets a track survive k frames of -non-detection. Runs on drift-corrected coordinates, or raw minus `tree.drift`. - -**C2. Events on the navigator — the headline.** The linker's unmatched rows and -columns *are* the event stream: **birth** (nucleation), **death** (dissolution), -**merge** (coalescence), **split** (fragmentation). +non-detection. + +To report trajectories in the sample frame, use **`DriftModel.to_sample_frame`, +which ADDS the shifts**. An earlier draft of this plan said "raw minus +`tree.drift`" — that is `to_lab_frame`, the inverse, and implementing it literally +*doubles* the drift instead of removing it. That is precisely the sign trap +`drift/model.py`'s docstring exists to warn about, and it got into the plan anyway; +always go through the named methods rather than writing the arithmetic out. + +**Units:** `max_dist` is in the particles' **calibrated units** (nm), because the +measured centroids are. `DriftModel` is in **pixels**. One function owns that +seam (`sample_frame_positions`) — do not convert anywhere else. The caret must +render the unit label beside the field or users will type pixels. + +**C2. Events on the navigator — the headline.** **birth** (nucleation), **death** +(dissolution), **merge** (coalescence), **split** (fragmentation). + +Birth and death fall out of the assignment for free — an unmatched detection is a +birth, an unmatched track is a death. **Merge and split do NOT**, and an earlier +draft of this plan wrongly implied they did: a one-to-one assignment cannot +represent two-to-one, so they need an explicit post-pass with its own radius +parameter and its own failure modes. The implemented rule: a track that ends at +`t-1` whose last position is within a merge radius of a track present in *both* +`t-1` and `t` is a merge rather than a death (split is the mirror), with the radius +defaulting to the particle's own `equiv_diameter` so a large body absorbs from +further away. Merge/split **replace** the death/birth they explain, which gives the +checkable invariant `Δcount = births + splits − deaths − merges` — verified to hold +on every frame of the fixture. They surface three ways: @@ -709,10 +732,32 @@ test** — not "it converged", not "the screenshot looks right". | B3 scribble | Matches the sklearn RandomForest reference on identical labels/features (IoU threshold) | | B3 sensitivity | Detects the faint low-contrast particles in `load_test_data_particles` — the §0.9 priority made measurable | | B5 measure | Matches `regionprops` on synthetic shapes; physical units correct under non-unit axis scale | -| C1 link | Recovers known trajectories, births, deaths and the merge exactly | +| C1 link | Recovers the known trajectories, births and deaths **exactly** (measured: mover is one track over all frames, 0.175 px max error; one birth at frame 8; one death at frame 16) | +| C2 merge | Exactly one merge, involving both merge-pair tracks, coinciding with the frame the count drops. **NOT** asserted at a fixed frame — see below | +| C1 gate | No link ever exceeds `max_dist`, at any `max_dist`; and `Δcount = births + splits − deaths − merges` on every frame | | Scale | A full run on thousands of 2048² frames completes in minutes without exceeding a fixed memory ceiling | | Perf | Every stage beats its §0.11 baseline, recorded in `benchmarks.md` | +### Why the merge frame is not a fixed number + +The merge event's *frame* is a property of the **segmenter**, not the linker, and an +acceptance gate demanding a specific frame would only be satisfiable by tuning the +segmenter. Measured on the fixture, whose two merging discs first make geometric +contact at frame 14: + +| | frame | +|---|---| +| discs' centres within `r₁+r₂` (geometric truth) | 14 | +| segmenter resolves ONE region, watershed **on** | 18 | +| segmenter resolves ONE region, watershed **off** | 12 | + +Watershed exists to split touching particles, so it correctly keeps them apart for +four frames *past* first contact; without it the soft-edged tails connect two frames +*before*. The truth is bracketed, `12 < 14 < 18`, on one boolean. So the gate asserts +the invariants that genuinely belong to the linker — exactly one merge, the right two +tracks, coinciding with the count drop — and a separate test asserts the bracketing, +which is what proves the offset is a segmentation property rather than a linker bug. + ## Verification standard A green pytest run and a clean `tsc` are **not** verification for anything that diff --git a/spyde/particles/track.py b/spyde/particles/track.py new file mode 100644 index 00000000..7a756c4d --- /dev/null +++ b/spyde/particles/track.py @@ -0,0 +1,894 @@ +""" +track.py — frame-to-frame linking and the event stream. Plan steps C1 and C2. + +One pass over the frames, one `linear_sum_assignment` per frame pair, and the +assignment's **leftovers are the physics**: a detection nothing was assigned to is +a birth, a track nothing was assigned from is a death, and the two-body cases +(merge, split) are read off those leftovers by a post-pass. + +Why `scipy.optimize.linear_sum_assignment` and not trackpy +---------------------------------------------------------- +trackpy's model (centroid distance, gated by a search radius, optional memory) is +the right one and is what this implements — but scipy is already a core dependency, +and the whole linker plus the event post-pass is under 400 lines of code. Adding a +dependency to get a Hungarian solve we already ship is not a trade worth making. + +Units — read this before setting `max_dist` +------------------------------------------- +:mod:`spyde.particles.measure` writes centroids in **calibrated units** (pixels x +``scale``), the same units as ``area``, ``equiv_diameter`` and everything else in +the property row. So every distance here — :attr:`LinkParams.max_dist`, +``merge_dist``, ``split_dist``, the trajectories on :class:`LinkResult` — is in +``particles.units``, never pixels. A :class:`~spyde.drift.model.DriftModel`, +however, is in **pixels** (it is a shift applied to an image), so +:func:`sample_frame_positions` converts to pixels, asks the model, and converts +back. That conversion is in exactly one place for the same reason ``measure.py`` +calibrates in exactly one place. + +Why the gate is a maximum-cardinality trick rather than a big square matrix +--------------------------------------------------------------------------- +The textbook formulation (Jaqaman 2008) pads the cost matrix with dummy +rows/columns priced at the gate, giving a ``(n_t + n_d)`` square problem in which +an unmatched track and an unmatched detection are both first-class outcomes. That +is what makes "a detection may go unmatched rather than be forced into a bad pair" +true, and it is the property that matters. + +The rectangular matrix used here has the same optimum for strictly less work. +Infeasible pairs (distance above the gate) get a constant sentinel large enough +that the solver minimises the *number* of them before it looks at any real cost; +those pairs are then discarded. Both formulations therefore compute a maximum- +cardinality matching over the feasible pairs and, among those, the minimum total +cost — the padded version can never prefer two dummies to a feasible pair, because +a feasible pair costs less than the gate while two dummies cost twice it. The +rectangular problem is ``n_t x n_d`` instead of ``(n_t + n_d)^2``, i.e. 4x smaller +at 500 particles per frame, and `linear_sum_assignment` is superlinear in the +matrix size. + +Cost, and what the gate applies to +---------------------------------- +Cost is centroid distance, optionally plus a property-similarity penalty +(:attr:`LinkParams.property_weight`, **off by default**). The **gate is on +distance alone** — the penalty only re-orders pairs that are already admissible. +Letting the penalty push a pair over the gate would silently turn ``max_dist`` +into "max_dist minus however dissimilar these two happen to look", which is not +what a user setting a search radius means. + +The penalty is off by default because on real data a particle's measured area +fluctuates frame to frame by far more than its centroid moves — the area of a +threshold-defined region swings with noise, the centroid barely does — so +weighting area *adds* noise to a cost that was already the reliable signal. It +earns its keep only when positions are genuinely ambiguous (dense fields, fast +motion), which is why it is a knob and not a constant. + +Cost at scale +------------- +Per frame this is ``O(n_t * n_d)`` to build the matrix and up to ``O(n^3)`` for the +assignment, with *n* the particles in ONE frame — never in the movie. Measured on +this box, 50 frames of synthetic tracks (link only, no segmentation): + +=================== ============ ======================== +particles per frame ms per frame extrapolated 3000 frames +=================== ============ ======================== +50 0.23 0.7 s +100 0.47 1.4 s +200 1.19 3.6 s +500 9.5 28.6 s +800 23.8 71 s +=================== ============ ======================== + +So the plan's target (3000 frames, ~500 particles each) is **~29 s**, negligible +beside segmenting 3000 frames of 2048^2 — but the growth is clearly superlinear +(~n^2.2 over that range), so a field of several thousand particles per frame would +need the cost matrix restricted to near neighbours (a KD-tree query inside +``max_dist``, then a sparse assignment) rather than built dense. That is not done +here: it is unnecessary at the stated scale and would add a second code path with no +data to validate it against. For reference the whole 24-frame fixture links in +1.8 ms (74 us/frame at ~6 particles), against 486 ms to segment and measure it. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Sequence + +import numpy as np + +from spyde.signals.particles import COL, SpyDEParticles + +log = logging.getLogger(__name__) + +#: The four event types. Order is the navigator lane order (plan C2) — green +#: birth, red death, mauve merge, yellow split. +EVENT_KINDS: tuple[str, ...] = ("birth", "death", "merge", "split") + +#: Properties the optional similarity term compares. Both are in the property row +#: for every engine; ``intensity_mean`` is NaN when ``measure_frame`` ran without +#: an intensity image, which is handled as "unknown", not as "very different". +DEFAULT_SIMILARITY_PROPERTIES: tuple[str, ...] = ("area", "intensity_mean") + +# Floor for the ADAPTIVE merge/split radius, in pixels (scaled to calibrated units +# at use). The adaptive radius is the particle's own equivalent diameter, which is +# the right scale — a big particle absorbs a neighbour from further away than a +# small one does — but a 1-2 px detection has an equivalent diameter near zero, +# and a zero radius means its merges are never detected. Two pixels is the +# smallest radius at which "these two detections became one" is even meaningful. +_ADAPTIVE_RADIUS_FLOOR_PX = 2.0 + + +# ── events ─────────────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class ParticleEvent: + """One thing that happened to a track, at a frame. + + Frozen because the event stream is handed to the navigator lane, the Events + table and the report embed; a record three surfaces share must not be + mutable in one of them. + + Parameters + ---------- + frame + The frame the event is **observed at**. For a birth that is the track's + first detected frame. For a death it is ``last_detected + 1`` — the first + frame the particle is *gone*, which is the convention the synthetic + fixture's ``death`` column uses and the frame a user would point at and + call the dissolution. + kind + One of :data:`EVENT_KINDS`. + tracks + The track ids involved. Birth/death: one. Merge: ``(absorbed, survivor)``. + Split: ``(parent, fragment)``. + particles + Global particle indices into ``SpyDEParticles.flat_buffer``, in the same + order as *tracks* — so the Events table can jump straight to a row and the + overlay can highlight the exact detections. + """ + + frame: int + kind: str + tracks: tuple[int, ...] + particles: tuple[int, ...] + + def to_dict(self) -> dict[str, Any]: + """Plain JSON-safe dict — this is what crosses the IPC boundary.""" + return { + "frame": int(self.frame), + "kind": str(self.kind), + "tracks": [int(t) for t in self.tracks], + "particles": [int(i) for i in self.particles], + } + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "ParticleEvent": + return cls( + frame=int(d["frame"]), + kind=str(d["kind"]), + tracks=tuple(int(t) for t in d.get("tracks", ())), + particles=tuple(int(i) for i in d.get("particles", ())), + ) + + +def events_to_records(events: Sequence[ParticleEvent]) -> list[dict[str, Any]]: + """The event stream as JSON-safe records, ready for IPC or CSV.""" + return [e.to_dict() for e in events] + + +def events_from_records(records: Sequence[dict[str, Any]]) -> list[ParticleEvent]: + """Inverse of :func:`events_to_records`.""" + return [ParticleEvent.from_dict(r) for r in records] + + +def event_counts(events: Sequence[ParticleEvent], n_frames: int) -> dict[str, np.ndarray]: + """``{kind: (n_frames,) float32}`` counts — the navigator's event lane (C2). + + A dict of separate traces rather than one stacked array because each kind gets + its own colour and its own row in the lane; a caller that wants the total sums + them. + """ + out = {k: np.zeros(int(n_frames), dtype=np.float32) for k in EVENT_KINDS} + for e in events: + if 0 <= e.frame < int(n_frames) and e.kind in out: + out[e.kind][e.frame] += 1.0 + return out + + +# ── parameters ─────────────────────────────────────────────────────────────── + +@dataclass +class LinkParams: + """Linker parameters. Distances are in the particles' calibrated units. + + Parameters + ---------- + max_dist + Search radius: a track and a detection further apart than this are never + linked. **In ``particles.units``**, not pixels (see the module docstring). + The default is deliberately generous — on the synthetic fixture + (``scale=0.5`` nm/px) the fastest particle moves 2.2 px = 1.1 nm per frame, + so 10.0 is ~9x the true step. The asymmetry justifies it: a gate that is + too tight **fragments** a track, and a fragmented trajectory cannot be + repaired downstream, while a gate that is too loose only matters when two + particles are within it of each other — and there the assignment still + picks the globally cheapest pairing. Measured on the fixture, every gate + from 4 px to 80 px recovers exactly the same 7 tracks and the same events; + at 2 px the tracks fragment (8 tracks, 3 spurious deaths) and at 1.2 px it + collapses (26 tracks, 19 deaths, 4 phantom splits). The usable window is + wide upward and sharp downward, so err high. Dense fields should tighten it. + memory + Frames a track may go undetected and still be re-linked afterwards. This is + what makes a blinking detection **one** track instead of several. 0 means a + track ends the moment it is missed. Note the gate is measured from the + track's last *seen* position and is not widened by the gap, so a fast + particle that blinks may still fall outside it. + property_weight + Weight of the property-similarity penalty, expressed as a multiple of + *max_dist* so it is scale-free. 0 (default) is distance only; see the + module docstring for why that is the default. + properties + Which columns the similarity term compares. + merge_dist, split_dist + Radius for the merge / split post-pass. ``None`` (default) is **adaptive**: + the particle's own ``equiv_diameter``, floored at 2 px. Adaptive is right + because the signature of a merge is a centroid jumping to the join of two + bodies, and that jump is a fraction of the body size — a fixed radius that + works for 50 px particles invents merges among 5 px ones. + initial_births + Whether detections in frame 0 emit ``birth`` events. Default True: it makes + the event stream a *complete* description of the assignment (every track has + exactly one birth, so ``len(births) == n_tracks``), and a consumer that + wants only nucleations filters ``frame > 0`` — see + :meth:`LinkResult.events_of`. The reverse is not recoverable: drop them and + nothing downstream can tell which tracks were present from the start. + """ + + max_dist: float = 10.0 + memory: int = 0 + property_weight: float = 0.0 + properties: tuple[str, ...] = DEFAULT_SIMILARITY_PROPERTIES + merge_dist: float | None = None + split_dist: float | None = None + initial_births: bool = True + + def __post_init__(self) -> None: + if not np.isfinite(self.max_dist) or self.max_dist <= 0: + raise ValueError(f"max_dist must be finite and > 0; got {self.max_dist}") + if self.memory < 0: + raise ValueError(f"memory must be >= 0; got {self.memory}") + if self.property_weight < 0: + raise ValueError( + f"property_weight must be >= 0; got {self.property_weight}") + for name in self.properties: + if name not in COL: + raise KeyError(f"unknown property column {name!r} in properties") + for name in ("merge_dist", "split_dist"): + v = getattr(self, name) + if v is not None and (not np.isfinite(v) or v <= 0): + raise ValueError(f"{name} must be None or finite and > 0; got {v}") + + def to_dict(self) -> dict[str, Any]: + return { + "max_dist": float(self.max_dist), + "memory": int(self.memory), + "property_weight": float(self.property_weight), + "properties": list(self.properties), + "merge_dist": None if self.merge_dist is None else float(self.merge_dist), + "split_dist": None if self.split_dist is None else float(self.split_dist), + "initial_births": bool(self.initial_births), + } + + +# ── result ─────────────────────────────────────────────────────────────────── + +@dataclass +class LinkResult: + """Track ids, the event stream, and the positions the linking actually used. + + ``track_id`` is returned as a **parallel array** rather than written straight + into the buffer, so a link can be inspected, compared against another + parameter choice, or thrown away without having mutated the particle table. + :meth:`apply` performs the write when you want it. + + Parameters + ---------- + track_id + ``(n_particles,)`` int32, contiguous from 0. Every particle belongs to + exactly one track, so there is no -1 in a completed link. + events + Chronological (by ``frame``, then by track id). + positions + ``(n_particles, 2)`` float64 ``(y, x)`` in **calibrated units**, in + :attr:`reference`'s frame — i.e. what the cost matrix saw. Kept because a + trajectory read from the buffer would be in the lab frame even when the + link ran drift-corrected, and silently mixing the two is the bug this + field exists to prevent. + frame_index + ``(n_particles,)`` int32 frame of each particle, taken from the CSR row + pointers (the authoritative frame index) rather than the float ``t`` + column. + reference + ``"lab"`` or ``"sample"`` — which frame of reference :attr:`positions` + and every trajectory are in. + track_first_frame, track_last_frame, track_first_index, track_last_index + ``(n_tracks,)`` per-track endpoints. Computed for the event pass and + exposed because the kymograph, the table dock and the trails overlay all + want them and re-deriving them is a scan of the whole buffer. + """ + + track_id: np.ndarray + events: list[ParticleEvent] + positions: np.ndarray + frame_index: np.ndarray + reference: str + track_first_frame: np.ndarray + track_last_frame: np.ndarray + track_first_index: np.ndarray + track_last_index: np.ndarray + n_frames: int = 0 + params: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.track_id = np.ascontiguousarray(self.track_id, dtype=np.int32) + self.positions = np.ascontiguousarray(self.positions, dtype=np.float64) + self.frame_index = np.ascontiguousarray(self.frame_index, dtype=np.int32) + # Chronological order WITHIN each track, in one stable sort: the buffer is + # sorted by frame, so a stable sort on track id leaves each track's rows in + # increasing frame order. This is what makes `trajectory` a slice. + self._order = np.argsort(self.track_id, kind="stable") + counts = np.bincount(self.track_id, minlength=self.n_tracks) \ + if self.track_id.size else np.zeros(0, np.int64) + self._starts = np.concatenate([[0], np.cumsum(counts)]).astype(np.int64) + + @property + def n_tracks(self) -> int: + return int(self.track_first_frame.size) + + @property + def n_particles(self) -> int: + return int(self.track_id.size) + + # ── writing back ───────────────────────────────────────────────────────── + + def apply(self, particles: SpyDEParticles) -> SpyDEParticles: + """Write :attr:`track_id` into the buffer's ``track_id`` column, in place. + + The column is float32, which represents integers exactly up to 2**24 — 16.7M + tracks, against a target scale of 1.5M particles (plan §0.5), so an id can + never be rounded to a neighbouring track's. + """ + if len(particles.flat_buffer) != self.track_id.size: + raise ValueError( + f"this result is for {self.track_id.size} particles but the table " + f"has {len(particles.flat_buffer)} — it came from a different link" + ) + particles.flat_buffer[:, COL["track_id"]] = self.track_id.astype(np.float32) + return particles + + # ── per-track access ───────────────────────────────────────────────────── + + def track_indices(self, track: int) -> np.ndarray: + """Global particle indices of one track, in chronological order.""" + t = int(track) + if not 0 <= t < self.n_tracks: + raise IndexError(f"track {t} outside 0..{self.n_tracks - 1}") + return self._order[self._starts[t]:self._starts[t + 1]] + + def trajectory(self, track: int) -> np.ndarray: + """``(k, 3)`` ``[frame, y, x]`` for one track, in :attr:`reference`'s frame. + + Positions are in calibrated units. Frames are not necessarily contiguous — + with ``memory > 0`` a track can skip frames, and the gap is visible as a + jump in the first column rather than being interpolated over. Inventing a + position for a frame the particle was not detected in would put a + measurement in the table that nothing measured. + """ + idx = self.track_indices(track) + out = np.empty((idx.size, 3), dtype=np.float64) + out[:, 0] = self.frame_index[idx] + out[:, 1:] = self.positions[idx] + return out + + def track_lengths(self) -> np.ndarray: + """``(n_tracks,)`` number of frames each track was DETECTED in. + + Not ``last - first + 1``: with ``memory > 0`` those differ, and the + difference is exactly the QC signal (a track detected in 4 of 30 frames is + probably noise) that plan C3's lifetime sort is for. + """ + return np.diff(self._starts).astype(np.int64) + + def track_at(self, frame: int) -> np.ndarray: + """Track ids present in *frame*, in buffer order. + + ``searchsorted``, not a boolean scan: the overlay calls this on every + navigator move, and ``frame_index`` is non-decreasing (the buffer is sorted + by frame), so an O(log N) slice is available where an O(N) comparison over + 1.5M rows would otherwise run per frame. + """ + f = int(frame) + lo, hi = np.searchsorted(self.frame_index, [f, f + 1]) + return self.track_id[lo:hi] + + # ── events ─────────────────────────────────────────────────────────────── + + def events_of(self, kind: str | None = None, *, + exclude_initial: bool = False) -> list[ParticleEvent]: + """Events of one *kind*, optionally dropping frame-0 births. + + *exclude_initial* is the "only real nucleations" filter — see + :attr:`LinkParams.initial_births` for why frame-0 births are recorded at + all. + """ + if kind is not None and kind not in EVENT_KINDS: + raise ValueError( + f"unknown event kind {kind!r}; expected one of {', '.join(EVENT_KINDS)}") + out = [e for e in self.events if kind is None or e.kind == kind] + if exclude_initial: + out = [e for e in out if not (e.kind == "birth" and e.frame == 0)] + return out + + def event_counts(self) -> dict[str, np.ndarray]: + """Per-frame counts per kind — see :func:`event_counts`.""" + return event_counts(self.events, self.n_frames) + + def to_dict(self) -> dict[str, Any]: + """Serialisable summary. Arrays stay out — save those with the particles.""" + return { + "n_tracks": self.n_tracks, + "n_particles": self.n_particles, + "n_frames": int(self.n_frames), + "reference": self.reference, + "params": dict(self.params), + "events": events_to_records(self.events), + } + + def __repr__(self) -> str: + counts = {k: len(self.events_of(k)) for k in EVENT_KINDS} + return ( + f"LinkResult({self.n_tracks} tracks over {self.n_frames} frames, " + f"reference={self.reference!r}, events={counts})" + ) + + +# ── coordinates ────────────────────────────────────────────────────────────── + +def frame_indices(particles: SpyDEParticles) -> np.ndarray: + """``(n_particles,)`` int32 frame index per particle, from the CSR pointers. + + The ``t`` column carries the same number, but the row pointers are the + *definition* of which frame a row belongs to — and they are integers, whereas + ``t`` is a float32 that a caller could in principle have written a + non-integral value into. + """ + counts = np.diff(particles.t_offsets) + return np.repeat(np.arange(particles.n_frames, dtype=np.int32), + counts).astype(np.int32) + + +def sample_frame_positions(particles: SpyDEParticles, drift) -> np.ndarray: + """Lab-frame centroids mapped into the drift-corrected (sample) frame. + + ``(n_particles, 2)`` float64 ``(y, x)``, **still in calibrated units** — the + return value is directly comparable with the stored centroids. + + The conversion to and from pixels is what this function exists for. + :class:`~spyde.drift.model.DriftModel` is defined on image pixels (its shifts + are what you pass to ``scipy.ndimage.shift``) while the property row is + calibrated, so a caller adding ``model.shifts[t]`` straight onto ``y``/``x`` is + off by a factor of ``scale`` — an over- or under-correction that still yields a + smooth, visibly-improved trajectory and so does not announce itself. Only the + ``scale == 1`` case, where nobody looks, comes out right. + + Going through ``to_sample_frame`` rather than adding the shifts here also means + a future non-rigid model, whose mapping is not a plain addition, needs no change + on this side. + """ + if drift.n_frames < particles.n_frames: + raise ValueError( + f"drift model covers {drift.n_frames} frames but the particles span " + f"{particles.n_frames} — a shorter model would silently reuse the " + "last shift for every frame beyond it" + ) + scale = float(particles.scale) or 1.0 + pos_px = particles.flat_buffer[:, [COL["y"], COL["x"]]].astype(np.float64) / scale + idx = frame_indices(particles).astype(np.intp) + if pos_px.size == 0: + return pos_px + return np.asarray(drift.to_sample_frame(pos_px, idx), dtype=np.float64) * scale + + +# ── the linker ─────────────────────────────────────────────────────────────── + +def link( + particles: SpyDEParticles, + params: LinkParams | None = None, + *, + drift=None, + apply: bool = False, + **kwargs: Any, +) -> LinkResult: + """Link detections into tracks and extract the event stream. + + Parameters + ---------- + particles + The CSR table from segment + measure. Not modified unless *apply*. + params + :class:`LinkParams`. Individual fields may instead be passed as keyword + arguments (``link(p, max_dist=4, memory=1)``), which is what the wizard's + parameter dict does. + drift + Optional :class:`~spyde.drift.model.DriftModel`. When given, linking and + every reported trajectory run in the **sample** frame — the stage's motion + removed, so a static particle's trajectory is a point. When ``None``, the + lab frame, i.e. the raw centroids as measured. Both are valid answers to + different questions ("did the particle move, or did the stage?", plan A9), + which is why the choice is the caller's and is recorded on the result. + apply + Write the ids into ``particles.flat_buffer``'s ``track_id`` column as well + as returning them. Off by default so a link is a pure computation. + + Returns + ------- + LinkResult + + Notes + ----- + Cost per frame is ``O(n_t * n_d)`` to build the matrix and up to + ``O(n^3)`` for the assignment, with *n* the number of particles in ONE frame — + never the whole movie. Nothing here reads pixel data at all. + """ + if kwargs: + base = params.to_dict() if params is not None else LinkParams().to_dict() + unknown = set(kwargs) - set(base) + if unknown: + raise TypeError( + f"unknown link parameter(s): {', '.join(sorted(unknown))}") + base.update(kwargs) + base["properties"] = tuple(base["properties"]) + params = LinkParams(**base) + p = params or LinkParams() + + n_frames = particles.n_frames + n_particles = particles.n_particles + fidx = frame_indices(particles) + + if drift is not None: + positions = sample_frame_positions(particles, drift) + reference = "sample" + else: + positions = particles.flat_buffer[:, [COL["y"], COL["x"]]].astype(np.float64) + reference = "lab" + + track_id = _assign_tracks(particles, positions, p) + + n_tracks = int(track_id.max()) + 1 if track_id.size else 0 + if n_tracks: + # `track_id` is contiguous from 0 and the buffer is sorted by frame, so a + # STABLE sort groups each track's rows in chronological order — the endpoints + # are then the group's first and last element, with no per-track scan. + order = np.argsort(track_id, kind="stable") + starts = np.concatenate( + [[0], np.cumsum(np.bincount(track_id, minlength=n_tracks))]) + first_index = order[starts[:-1]].astype(np.int64) + last_index = order[starts[1:] - 1].astype(np.int64) + first_frame = fidx[first_index].astype(np.int32) + last_frame = fidx[last_index].astype(np.int32) + else: + first_index = last_index = np.zeros(0, np.int64) + first_frame = last_frame = np.zeros(0, np.int32) + + events = _extract_events( + particles, positions, track_id, n_frames, + first_frame=first_frame, last_frame=last_frame, + first_index=first_index, last_index=last_index, p=p, + ) + + result = LinkResult( + track_id=track_id, + events=events, + positions=positions, + frame_index=fidx, + reference=reference, + track_first_frame=first_frame, + track_last_frame=last_frame, + track_first_index=first_index, + track_last_index=last_index, + n_frames=n_frames, + params=p.to_dict(), + ) + log.info("[track] %d particles over %d frames -> %d tracks (%s frame), " + "%d events", n_particles, n_frames, result.n_tracks, reference, + len(events)) + if apply: + result.apply(particles) + return result + + +def _assign_tracks(particles: SpyDEParticles, positions: np.ndarray, + p: LinkParams) -> np.ndarray: + """The forward pass: one assignment per frame pair. Returns ``(N,)`` int32 ids. + + Ids are handed out in (frame, row-within-frame) order, which is why they are + stable: the same table linked twice produces the same numbering, and there is + no dictionary iteration or set ordering anywhere in the loop. + """ + from scipy.optimize import linear_sum_assignment + + n_particles = particles.n_particles + track_id = np.full(n_particles, -1, dtype=np.int32) + if n_particles == 0: + return track_id + + prop = _similarity_matrix_source(particles, p) + + # Live-track registry. Lists, not arrays: they are appended to once per new + # track and read once per frame, and the per-frame cost is dominated by the + # assignment. `active` holds only tracks still inside the memory window. + last_frame: list[int] = [] + last_index: list[int] = [] + active: list[int] = [] + + def start_track(gi: int, t: int) -> None: + tid = len(last_frame) + last_frame.append(t) + last_index.append(gi) + track_id[gi] = tid + active.append(tid) + + for t in range(particles.n_frames): + det = particles.indices_at(t) + + if t > 0 and active: + # Retire anything outside the memory window. `memory + 1` because a + # track last seen at t-1 has a gap of 1 and must always be eligible. + active[:] = [tid for tid in active + if t - last_frame[tid] <= p.memory + 1] + + if det.size and active: + src = np.fromiter((last_index[tid] for tid in active), + dtype=np.int64, count=len(active)) + cost, feasible = _cost_matrix(positions, src, det, prop, p) + rows, cols = linear_sum_assignment(cost) + for r, c in zip(rows, cols): + if not feasible[r, c]: + continue # a sentinel pair — see the module docstring + tid = active[r] + gi = int(det[c]) + track_id[gi] = tid + last_frame[tid] = t + last_index[tid] = gi + + for gi in det: + if track_id[gi] < 0: + start_track(int(gi), t) + + return track_id + + +def _similarity_matrix_source(particles: SpyDEParticles, + p: LinkParams) -> np.ndarray | None: + """``(N, k)`` float64 of the compared properties, or None when weight is 0.""" + if p.property_weight <= 0 or not p.properties: + return None + cols = [COL[name] for name in p.properties] + return particles.flat_buffer[:, cols].astype(np.float64) + + +def _cost_matrix(positions: np.ndarray, src: np.ndarray, det: np.ndarray, + prop: np.ndarray | None, + p: LinkParams) -> tuple[np.ndarray, np.ndarray]: + """``(cost, feasible)`` for one frame pair. + + Infeasible entries get a constant sentinel chosen so the solver minimises + their *count* before it looks at any real cost — which is what makes discarding + them afterwards equivalent to the padded square formulation (module docstring). + The sentinel is derived from the actual matrix rather than being a magic + literal, so it cannot be outgrown by a large ``max_dist`` or a big property + penalty. + """ + a = positions[src] # (n_t, 2) + b = positions[det] # (n_d, 2) + d = np.hypot(a[:, 0, None] - b[None, :, 0], + a[:, 1, None] - b[None, :, 1]) + + # The gate is on DISTANCE ONLY. See the module docstring. + feasible = d <= float(p.max_dist) + + cost = d.copy() + if prop is not None: + cost += float(p.property_weight) * float(p.max_dist) * \ + _property_dissimilarity(prop[src], prop[det]) + + if feasible.any(): + big = (min(len(src), len(det)) + 1) * float(cost[feasible].max()) + 1.0 + else: + big = 1.0 + cost = np.where(feasible, cost, big) + return cost, feasible + + +def _property_dissimilarity(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """``(n_t, n_d)`` mean symmetric relative difference in 0..1. + + ``|a - b| / (|a| + |b|)`` rather than ``|a - b| / a``: it is bounded, symmetric, + and needs no reference value, so one expression works for area (thousands of + nm^2) and for a normalised intensity (fractions of one) without per-column + scaling. + + A NaN on either side is **dropped from the mean**, not counted as zero. + ``intensity_mean`` is NaN whenever ``measure_frame`` ran without an intensity + image, and both alternatives are wrong in a way that is hard to see from the + outside: counting it as maximally different makes the term a uniform offset on + every pair, and counting it as zero *dilutes* the columns that were measured — + with one of two properties missing, a requested weight of 1.0 would silently + act as 0.5. Averaging over the known columns keeps the weight meaning what the + caller asked for. + """ + valid = np.isfinite(a)[:, None, :] & np.isfinite(b)[None, :, :] + num = np.abs(a[:, None, :] - b[None, :, :]) + den = np.abs(a[:, None, :]) + np.abs(b[None, :, :]) + with np.errstate(divide="ignore", invalid="ignore"): + # den == 0 means both values are 0, i.e. genuinely identical. + rel = np.where(den > 0, num / den, 0.0) + rel = np.where(valid & np.isfinite(rel), rel, 0.0) + n = valid.sum(axis=2) + return np.where(n > 0, rel.sum(axis=2) / np.maximum(n, 1), 0.0) + + +# ── the event post-pass ────────────────────────────────────────────────────── + +def _extract_events(particles, positions, track_id, n_frames, *, + first_frame, last_frame, first_index, last_index, + p: LinkParams) -> list[ParticleEvent]: + """Turn the assignment's leftovers into the event stream. + + Every track contributes at most two events: one for its start (``birth`` or + ``split``) and one for its end (``death`` or ``merge``). Merge and split + **replace** the death and birth they explain rather than accompanying them — + a particle that was absorbed did not dissolve, and reporting both would put a + red dissolution flag on the navigator lane at a frame where nothing dissolved. + The cost of that choice is that the event stream no longer closes the count + arithmetic by itself; the accounting is + ``count(t) - count(t-1) = births + splits - deaths - merges``. + + A track still detected in the FINAL frame gets no end event: the movie running + out is not a dissolution. + + Walks FRAMES, not tracks, so only two ``{track: row}`` maps are ever resident. + A single ``frame -> {track: row}`` index over the whole movie would be 1.5M dict + entries at the target scale (plan §0.5) — hundreds of MB of Python objects to + answer a question that only ever spans two adjacent frames. + """ + n_tracks = int(first_frame.size) + if n_tracks == 0 or n_frames == 0: + return [] + + starts_at: list[list[int]] = [[] for _ in range(n_frames)] + ends_at: list[list[int]] = [[] for _ in range(n_frames)] + for tid in range(n_tracks): + starts_at[int(first_frame[tid])].append(tid) + t1 = int(last_frame[tid]) + if t1 < n_frames - 1: + # The event frame is the first frame the particle is GONE. + ends_at[t1 + 1].append(tid) + + equiv = particles.flat_buffer[:, COL["equiv_diameter"]].astype(np.float64) + floor = _ADAPTIVE_RADIUS_FLOOR_PX * (float(particles.scale) or 1.0) + + def radius(gi: int, override: float | None) -> float: + if override is not None: + return float(override) + v = equiv[gi] + # An unmeasured diameter (no masks, or a degenerate region) must not become + # a zero radius that silently disables the whole post-pass. + return max(float(v) if np.isfinite(v) else 0.0, floor) + + events: list[ParticleEvent] = [] + prev_map: dict[int, int] = {} + + for t in range(n_frames): + # The maps are only ever read by a candidate event at t (which needs both t + # and t-1), so a frame with no candidate at t and none at t+1 needs no map at + # all. On a long stable movie that skips nearly every frame; when it does + # build one, the next iteration is guaranteed to want it. + wants = bool(ends_at[t]) or (t > 0 and bool(starts_at[t])) + nxt = t + 1 + wants_next = nxt < n_frames and (bool(ends_at[nxt]) or bool(starts_at[nxt])) + cur_map = ({int(track_id[gi]): int(gi) for gi in particles.indices_at(t)} + if (wants or wants_next) else {}) + + for tid in starts_at[t]: + gi = int(first_index[tid]) + if t == 0: + if p.initial_births: + events.append(ParticleEvent(0, "birth", (tid,), (gi,))) + continue + # SPLIT: a track that continues across t-1 -> t, whose position BEFORE + # the split is within its own body radius of this newcomer. + parent = _nearest_continuing( + cur_map, prev_map, positions, probe=positions[gi], exclude=tid, + measure_at=prev_map, + radius_of=lambda pgi: radius(pgi, p.split_dist), + ) + if parent is None: + events.append(ParticleEvent(t, "birth", (tid,), (gi,))) + else: + ptid, pgi = parent + events.append(ParticleEvent(t, "split", (ptid, tid), (pgi, gi))) + + for tid in ends_at[t]: + gi = int(last_index[tid]) + merge_r = radius(gi, p.merge_dist) + # MERGE: a track that continues across t-1 -> t whose position AT t is + # within the dying particle's body radius of where the dying particle + # was at t-1 — i.e. a centroid that jumped to the join of two bodies. + survivor = _nearest_continuing( + cur_map, prev_map, positions, probe=positions[gi], exclude=tid, + measure_at=cur_map, radius_of=lambda _pgi, r=merge_r: r, + ) + if survivor is None: + events.append(ParticleEvent(t, "death", (tid,), (gi,))) + else: + stid, sgi = survivor + events.append(ParticleEvent(t, "merge", (tid, stid), (gi, sgi))) + + prev_map = cur_map + + events.sort(key=lambda e: (e.frame, EVENT_KINDS.index(e.kind), e.tracks)) + return events + + +def _nearest_continuing(cur_map, prev_map, positions, *, probe, exclude, + measure_at, radius_of): + """Nearest track present in BOTH frames, measured on *measure_at*'s side. + + This is the whole merge/split rule, and the "both frames" requirement is what + keeps it honest. A one-to-one assignment cannot express two-to-one, so the + two-body events have to be inferred from a died track sitting next to a + surviving one — and the only way to tell a *survivor* from another newcomer is + that the survivor was already there the frame before. + + *measure_at* selects which side is compared with *probe*: ``cur_map`` for a + merge (where did the surviving centroid jump TO) and ``prev_map`` for a split + (where was the parent BEFORE it broke up). *radius_of* is passed the global row + index on the measured side, so a split can size its gate by the parent's own + body while a merge sizes it by the dying particle's. + + Known limits, stated rather than hidden: + + * **It cannot see a merge earlier than the segmenter does.** Measured on the + synthetic fixture, whose two converging discs geometrically touch at frame + 14: with watershed splitting ON they remain two separate detections until + **frame 18**, and with it OFF their thresholded blobs already connect at + **frame 12**. The linker reports the frame the detections became one, which + is a property of segmentation, not of linking, and no post-pass can recover + the other frame from the table alone. + * **A three-body coincidence is misread.** A track that genuinely dissolves + within one body-diameter of a surviving neighbour is reported as a merge. + * **A fragmentation in which the parent's OWN track ends is reported as two + births, not a split**, because then neither newcomer was present the frame + before. Requiring the parent to continue is what stops every ordinary birth + that happens to appear beside an unrelated particle from being called a + split; the trade is deliberate. + * **Merge and split are exclusive per track end/start.** Three tracks + collapsing into one in a single frame produce two merge events sharing one + survivor, which is the honest reading, but a track cannot be recorded as both + merging and splitting at the same frame. + * **Which track survives a SYMMETRIC merge is a genuine tie**, decided by the + assignment rather than by this rule. Two equal bodies merging put their joint + centroid the same distance from both, so either can be the survivor. Measured + on the fixture: linking in the lab frame reports ``tracks=(5, 4)`` and linking + the same table drift-corrected reports ``(4, 5)`` — same merge, same frame, + roles swapped, identical set of tracks and track lengths. Do not read meaning + into which id survives. + """ + best = None + best_d = np.inf + for tid, gi in cur_map.items(): + if tid == exclude or tid not in prev_map: + continue + mgi = int(measure_at[tid]) + d = float(np.hypot(*(probe - positions[mgi]))) + if d <= radius_of(mgi) and d < best_d: + best, best_d = (tid, gi), d + return best diff --git a/spyde/tests/migrated/test_particles_track.py b/spyde/tests/migrated/test_particles_track.py new file mode 100644 index 00000000..9c26da9b --- /dev/null +++ b/spyde/tests/migrated/test_particles_track.py @@ -0,0 +1,1025 @@ +""" +The linker and the event stream — plan steps C1 and C2, gate +"recovers known trajectories, births, deaths and the merge exactly". + +Structure follows what the gates actually are: + +* :class:`TestTrajectoryGate` / :class:`TestEventGates` / :class:`TestDriftFrame` + run against ``particle_movie()`` end to end — segment, measure, link — because + the fixture is the acceptance gate for this wave and a linker validated only on + hand-written coordinates is validated against nothing that segmentation produces. +* The remaining classes use small hand-built tables, where a two-particle + ambiguity can be constructed exactly and the assertion is unambiguous. + +One measured fact shapes several of these tests and is worth stating up front: +**the frame at which a merge becomes detectable is a property of the segmenter, +not of the linker.** The fixture's two discs geometrically overlap from frame 14 +(``ground_truth(sig)["merge_frame"]``), but the segmenter keeps them apart until +frame 18 with watershed splitting on, and fuses them already at frame 12 with it +off. A linker sees detections, not discs; the honest gate is that it reports the +frame the detections became one, and that this frame brackets the geometric truth +as the segmentation changes. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from spyde.data.synthetic import ( + DISSOLUTION_INDEX, + NUCLEATION_INDEX, + ground_truth, + particle_movie, + particle_truth_at, +) +from spyde.drift.model import DriftModel +from spyde.particles import SegmentParams, measure_frame, segment_frame +from spyde.particles.track import ( + EVENT_KINDS, + LinkParams, + LinkResult, + ParticleEvent, + event_counts, + events_from_records, + events_to_records, + frame_indices, + link, + sample_frame_positions, +) +from spyde.signals.particles import COL, N_COLUMNS, SpyDEParticles + +# The gate used for every fixture test. 3.0 nm = 6 px at the fixture's 0.5 nm/px, +# i.e. ~3x the fastest particle's 2.2 px/frame lab-frame step. Deliberately not the +# default 10.0: a test that only passes at a very loose gate would not notice a +# linker that pairs by luck. TestGateWidth covers the default. +FIXTURE_MAX_DIST = 3.0 + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _segment_movie(sig, gt, params=None, scale=None) -> SpyDEParticles: + """Run the real classical pipeline over every frame → a real particle table. + + Deliberately not a synthetic table: the linker's input is whatever + ``segment_frame`` + ``measure_frame`` produce, including their misses (the two + faint probes are never found at default sensitivity) and their merges. + """ + params = params or SegmentParams(min_size=25, gaussian=1.0) + scale = float(gt["scale"]) if scale is None else float(scale) + rows, contours = [], [] + for t in range(int(gt["n_frames"])): + labels = segment_frame(sig.data[t], params) + r, c = measure_frame(labels, sig.data[t], t=t, scale=scale) + rows.append(r) + contours.append(c) + return SpyDEParticles.from_frames( + rows, frame_shape=tuple(gt["frame_shape"]), contours_per_frame=contours, + scale=scale, units="nm" if scale != 1.0 else "px") + + +def _truth_of(res: LinkResult, gt, track: int, scale: float) -> tuple[int, float]: + """``(truth particle index, distance in px)`` nearest a track's first detection. + + Maps a track id back onto the fixture's hand-written particle table so an + assertion can say "the mover" rather than "track 2", which is an id the linker + is free to renumber. + """ + gi = int(res.track_first_index[track]) + t = int(res.frame_index[gi]) + pos = res.positions[gi] / scale + truth, _radii, present = particle_truth_at(gt, t) + d = np.hypot(*(truth - pos).T) + d[~present] = np.inf + return int(np.argmin(d)), float(d.min()) + + +def _track_for(res: LinkResult, gt, truth_index: int, scale: float) -> int: + matches = [tid for tid in range(res.n_tracks) + if _truth_of(res, gt, tid, scale)[0] == truth_index] + assert matches, f"no track corresponds to fixture particle {truth_index}" + return matches[0] + + +def _table(frames, *, scale: float = 1.0, diameter: float = 4.0, + areas=None) -> SpyDEParticles: + """A minimal particle table from per-frame ``[(y, x), ...]`` lists. + + Only the columns the linker reads are filled — positions, ``equiv_diameter`` + (the adaptive merge radius) and optionally ``area`` (the similarity term). Every + other column stays 0, which is exactly the point: the linker must not depend on + anything a caller might not have measured. + """ + blocks = [] + for t, pts in enumerate(frames): + pts = np.asarray(pts, dtype=np.float64).reshape(-1, 2) + row = np.zeros((len(pts), N_COLUMNS), np.float32) + row[:, COL["t"]] = t + row[:, COL["label"]] = np.arange(1, len(pts) + 1) + row[:, COL["y"]] = pts[:, 0] * scale + row[:, COL["x"]] = pts[:, 1] * scale + row[:, COL["equiv_diameter"]] = diameter * scale + row[:, COL["track_id"]] = -1.0 + if areas is not None: + row[:, COL["area"]] = np.asarray(areas[t], dtype=np.float32) + blocks.append(row) + return SpyDEParticles.from_frames(blocks, frame_shape=(128, 128), scale=scale, + units="nm" if scale != 1.0 else "px") + + +@pytest.fixture(scope="module") +def movie(): + s = particle_movie() + return s, ground_truth(s) + + +@pytest.fixture(scope="module") +def linked(movie): + """Segment + measure + link the fixture once — shared by the gate classes.""" + s, gt = movie + particles = _segment_movie(s, gt) + return s, gt, particles, link(particles, max_dist=FIXTURE_MAX_DIST) + + +# ── gate 1: trajectories ───────────────────────────────────────────────────── + +class TestTrajectoryGate: + """"Recovers known trajectories exactly" — no fragmentation, sub-pixel error.""" + + def test_one_track_per_bright_particle(self, linked): + """7 tracks: 6 bright particles present at t=0, plus the nucleation. + + The two faint probes are not detected by the classical engine at default + sensitivity (``test_particle_movie_fixture.py`` pins that), so the linker + cannot and must not invent tracks for them. + """ + _s, gt, _p, res = linked + assert res.n_tracks == 7, ( + f"expected 7 tracks, got {res.n_tracks} — a count above 7 means a " + f"trajectory fragmented (lengths {np.sort(res.track_lengths())})") + + def test_the_mover_is_one_unbroken_track(self, linked): + s, gt, _p, res = linked + tid = _track_for(res, gt, 2, float(gt["scale"])) # the constant-velocity one + traj = res.trajectory(tid) + assert len(traj) == int(gt["n_frames"]), ( + f"the mover's track spans {len(traj)} of {gt['n_frames']} frames") + assert np.array_equal(traj[:, 0], np.arange(int(gt["n_frames"]))), ( + "the mover's track has a frame gap") + + def test_mover_positions_match_the_truth(self, linked): + """Sub-pixel: the fixture draws analytic soft discs, so centroids are exact. + + Measured with the classical segmenter: max error 0.175 px, RMS 0.073 px over + 24 frames. The 0.4 px bar is ~2x the measured worst case — tight enough that + an off-by-one frame (which would show as ~1.3 px on this particle) fails. + """ + _s, gt, _p, res = linked + scale = float(gt["scale"]) + tid = _track_for(res, gt, 2, scale) + err = [] + for frame, y, x in res.trajectory(tid): + want = particle_truth_at(gt, int(frame))[0][2] + err.append(np.abs(np.array([y, x]) / scale - want)) + err = np.asarray(err) + assert err.max() < 0.4, f"max trajectory error {err.max():.3f} px" + + def test_every_bright_particle_gets_its_own_track(self, linked): + """No two tracks may map onto the same fixture particle.""" + _s, gt, _p, res = linked + scale = float(gt["scale"]) + owners = [_truth_of(res, gt, tid, scale)[0] for tid in range(res.n_tracks)] + assert len(set(owners)) == len(owners), f"two tracks share a particle: {owners}" + + def test_track_lengths_match_the_event_timeline(self, linked): + """Lengths, not just count: 24, 24, 24, 24 persistent + 16 + 18 + 16.""" + _s, gt, _p, res = linked + assert sorted(res.track_lengths().tolist()) == [16, 16, 18, 24, 24, 24, 24], ( + f"unexpected track lengths {np.sort(res.track_lengths())}") + + def test_trajectory_positions_agree_with_the_buffer(self, linked): + """A lab-frame link must report exactly the measured centroids.""" + _s, _gt, particles, res = linked + stored = particles.flat_buffer[:, [COL["y"], COL["x"]]].astype(np.float64) + assert np.array_equal(res.positions, stored) + assert res.reference == "lab" + + +# ── gates 2-4: events ──────────────────────────────────────────────────────── + +class TestEventGates: + """Births, deaths and the merge against the fixture's stamped event frames.""" + + def test_exactly_one_nucleation_birth_at_the_known_frame(self, linked): + """Frame-0 detections DO count as births (LinkParams.initial_births). + + So "the nucleation" is the one birth with ``frame > 0``, and the rest sit at + frame 0. Pinning both halves is what makes the assertion meaningful: a + linker that fragmented a track would add births at other frames. + """ + _s, gt, _p, res = linked + births = res.events_of("birth") + later = [e for e in births if e.frame > 0] + assert len(later) == 1, f"expected 1 nucleation, got {[e.frame for e in later]}" + assert later[0].frame == int(gt["nucleation_frame"]) == 8 + assert all(e.frame == 0 for e in births if e not in later) + assert len(births) == res.n_tracks, ( + "every track must have exactly one birth — that is what makes the " + "event stream a complete description of the assignment") + + def test_the_nucleation_birth_is_the_nucleating_particle(self, linked): + _s, gt, _p, res = linked + scale = float(gt["scale"]) + birth = res.events_of("birth", exclude_initial=True)[0] + assert _truth_of(res, gt, birth.tracks[0], scale)[0] == NUCLEATION_INDEX + + def test_exclude_initial_leaves_only_the_nucleation(self, linked): + _s, gt, _p, res = linked + got = res.events_of("birth", exclude_initial=True) + assert [e.frame for e in got] == [int(gt["nucleation_frame"])] + + def test_initial_births_can_be_turned_off(self, linked): + _s, _gt, particles, _res = linked + res = link(particles, max_dist=FIXTURE_MAX_DIST, initial_births=False) + assert [e.frame for e in res.events_of("birth")] == [8] + + def test_exactly_one_death_at_the_known_dissolution_frame(self, linked): + """The death frame is the first frame the particle is GONE — frame 16 here, + matching the fixture's ``death`` column, not the last frame it was seen.""" + _s, gt, _p, res = linked + deaths = res.events_of("death") + assert len(deaths) == 1, f"expected 1 death, got {[e.frame for e in deaths]}" + assert deaths[0].frame == int(gt["dissolution_frame"]) == 16 + + def test_the_death_is_the_dissolving_particle(self, linked): + _s, gt, _p, res = linked + death = res.events_of("death")[0] + assert _truth_of(res, gt, death.tracks[0], float(gt["scale"]))[0] \ + == DISSOLUTION_INDEX + + def test_a_track_alive_in_the_final_frame_has_no_death(self, linked): + """The movie ending is not a dissolution.""" + _s, _gt, _p, res = linked + ended = {e.tracks[0] for e in res.events if e.kind in ("death", "merge")} + for tid in range(res.n_tracks): + if int(res.track_last_frame[tid]) == res.n_frames - 1: + assert tid not in ended, f"track {tid} reaches the last frame yet ends" + + def test_the_merge_is_detected_once_and_involves_the_merge_pair(self, linked): + _s, gt, _p, res = linked + scale = float(gt["scale"]) + merges = res.events_of("merge") + assert len(merges) == 1, f"expected 1 merge, got {[e.frame for e in merges]}" + involved = {_truth_of(res, gt, t, scale)[0] for t in merges[0].tracks} + assert involved == set(int(i) for i in gt["merge_pair"]), ( + f"merge involves fixture particles {involved}, expected " + f"{tuple(gt['merge_pair'])}") + + def test_the_merge_lands_where_the_detections_actually_fuse(self, linked): + """The robust form of the merge-frame gate. + + The geometric ``merge_frame`` (14) is when the discs overlap; the segmenter + keeps them as two detections until 18. So the invariant that holds for ANY + segmentation is that the merge event coincides with the frame the particle + count drops because two detections became one — which is also the only frame + a linker could possibly report. + """ + _s, gt, particles, res = linked + merge = res.events_of("merge")[0] + counts = particles.count_series() + assert counts[merge.frame] == counts[merge.frame - 1] - 1, ( + f"the merge at frame {merge.frame} is not where the count drops " + f"({counts.astype(int)})") + assert abs(merge.frame - int(gt["merge_frame"])) <= 4, ( + f"merge reported at {merge.frame}, geometric truth " + f"{int(gt['merge_frame'])} — measured offset with this segmenter is +4") + + def test_the_merge_frame_follows_the_segmenter_not_the_linker(self, movie): + """Watershed OFF fuses the pair 6 frames earlier, and the linker follows. + + This is the honest statement of the limit: the same linker on the same movie + reports frame 18 with splitting on and frame 12 with it off, bracketing the + geometric truth at 14. Anything that claims to pin the merge to 14 exactly + is pinning a segmentation parameter, not the linker. + """ + s, gt = movie + with_ws = link(_segment_movie(s, gt), max_dist=FIXTURE_MAX_DIST) + no_ws = link(_segment_movie( + s, gt, SegmentParams(min_size=25, gaussian=1.0, watershed=False)), + max_dist=FIXTURE_MAX_DIST) + f_on = with_ws.events_of("merge")[0].frame + f_off = no_ws.events_of("merge")[0].frame + assert f_off < int(gt["merge_frame"]) < f_on, ( + f"expected the geometric merge frame {int(gt['merge_frame'])} to sit " + f"between the two segmentations ({f_off} and {f_on})") + + def test_no_spurious_splits(self, linked): + """The fixture contains no fragmentation, so any split is a false positive. + + Specifically: the nucleation at frame 8 appears 28 px from its nearest + neighbour, well outside that neighbour's ~12 px body, so it must read as a + birth and not as a split off it. + """ + _s, _gt, _p, res = linked + assert res.events_of("split") == [] + + def test_every_event_kind_is_known_and_ordered(self, linked): + _s, _gt, _p, res = linked + assert all(e.kind in EVENT_KINDS for e in res.events) + frames = [e.frame for e in res.events] + assert frames == sorted(frames), "events are not in chronological order" + + def test_event_lane_counts(self, linked): + """The C2 navigator lane: one trace per kind, spikes at the event frames.""" + _s, gt, _p, res = linked + lane = res.event_counts() + assert set(lane) == set(EVENT_KINDS) + for trace in lane.values(): + assert trace.shape == (int(gt["n_frames"]),) + assert lane["birth"][8] == 1 and lane["birth"][0] == 6 + assert lane["death"][16] == 1 and lane["death"].sum() == 1 + assert lane["merge"].sum() == 1 + assert lane["split"].sum() == 0 + + def test_event_counts_ignores_out_of_range_frames(self): + lane = event_counts([ParticleEvent(9, "birth", (0,), (0,))], n_frames=4) + assert lane["birth"].sum() == 0 + + +# ── gate 5: frame of reference ─────────────────────────────────────────────── + +class TestDriftFrame: + """Linking on lab and on drift-corrected coordinates, and what each means.""" + + def test_lab_frame_anchors_visibly_move(self, linked): + """The premise of the whole gate: without correction the static particles + travel with the stage, so a "did it move?" answer read off the lab frame is + wrong by the drift amplitude.""" + _s, gt, _p, res = linked + scale = float(gt["scale"]) + for anchor in (0, 1): + tid = _track_for(res, gt, anchor, scale) + tr = res.trajectory(tid)[:, 1:] / scale + excursion = float(np.hypot(*(tr.max(0) - tr.min(0)))) + assert excursion > 5.0, ( + f"anchor {anchor} only moves {excursion:.2f} px in the lab frame — " + "the fixture's drift is not being drawn") + + def test_drift_corrected_anchors_are_stationary(self, linked): + """Measured: 9.3 px lab excursion collapses to 0.29-0.36 px corrected.""" + _s, gt, particles, _res = linked + scale = float(gt["scale"]) + model = DriftModel(shifts=np.asarray(gt["drift"])) + res = link(particles, max_dist=FIXTURE_MAX_DIST, drift=model) + assert res.reference == "sample" + for anchor in (0, 1): + tid = _track_for(res, gt, anchor, scale) + tr = res.trajectory(tid)[:, 1:] / scale + excursion = float(np.hypot(*(tr.max(0) - tr.min(0)))) + assert excursion < 1.0, ( + f"anchor {anchor} still moves {excursion:.2f} px after correction") + + def test_a_solved_drift_model_works_as_well_as_the_truth(self, linked): + """End to end with A1's own output rather than the stamped truth, since + that is what the wizard will hand the linker.""" + from spyde.drift import solve_translation + s, gt, particles, _res = linked + scale = float(gt["scale"]) + model = solve_translation(s.data, device="numpy", upsample=8, + reference="first", max_shift=20) + res = link(particles, max_dist=FIXTURE_MAX_DIST, drift=model) + for anchor in (0, 1): + tid = _track_for(res, gt, anchor, scale) + tr = res.trajectory(tid)[:, 1:] / scale + assert float(np.hypot(*(tr.max(0) - tr.min(0)))) < 1.0 + + def test_the_mover_still_moves_after_correction(self, linked): + """Correction must remove the stage, not the sample. In the sample frame the + mover's displacement is its own constant velocity, 1.30 px/frame over 23 + frames = 29.9 px.""" + _s, gt, particles, _res = linked + scale = float(gt["scale"]) + model = DriftModel(shifts=np.asarray(gt["drift"])) + res = link(particles, max_dist=FIXTURE_MAX_DIST, drift=model) + tid = _track_for(res, gt, 2, scale) + tr = res.trajectory(tid)[:, 1:] / scale + assert float(np.hypot(*(tr[-1] - tr[0]))) == pytest.approx(29.9, abs=1.0) + + def test_both_references_find_the_same_tracks(self, linked): + """Same track set and same events either way — only the coordinates differ. + + Track *ids* are not compared: the fixture's merge is symmetric, so which of + the pair survives is a genuine tie that the change of coordinates can flip + (documented in ``_nearest_continuing``). + """ + _s, gt, particles, lab = linked + model = DriftModel(shifts=np.asarray(gt["drift"])) + sample = link(particles, max_dist=FIXTURE_MAX_DIST, drift=model) + assert sample.n_tracks == lab.n_tracks + assert np.array_equal(np.sort(sample.track_lengths()), + np.sort(lab.track_lengths())) + assert [(e.frame, e.kind) for e in sample.events] == \ + [(e.frame, e.kind) for e in lab.events] + + def test_calibration_is_applied_exactly_once(self): + """The trap this guards: ``DriftModel`` is in PIXELS, centroids are + calibrated. Adding the shifts straight onto ``y``/``x`` leaves the anchor + moving at ``1 - scale`` of its drift — plausible-looking and wrong.""" + scale = 0.25 + shift = np.array([[0.0, 0.0], [4.0, -3.0]]) + # A particle that sits still in the SAMPLE frame therefore appears at + # -shift in the lab frame. + particles = _table([[(10.0, 20.0)], [(10.0 - 4.0, 20.0 + 3.0)]], scale=scale) + got = sample_frame_positions(particles, DriftModel(shifts=shift)) + assert got == pytest.approx(np.array([[10.0, 20.0], [10.0, 20.0]]) * scale) + + def test_a_short_drift_model_is_rejected(self): + particles = _table([[(1.0, 1.0)]] * 4) + with pytest.raises(ValueError, match="covers 2 frames"): + sample_frame_positions(particles, DriftModel(shifts=np.zeros((2, 2)))) + + def test_frame_indices_come_from_the_row_pointers(self, linked): + _s, _gt, particles, res = linked + assert np.array_equal(frame_indices(particles), res.frame_index) + assert np.array_equal(res.frame_index, + particles.column("t").astype(np.int32)) + + +# ── the gate itself ────────────────────────────────────────────────────────── + +class TestDistanceGate: + """An unmatched detection must be genuinely possible, not forced into a pair.""" + + def test_a_detection_beyond_the_gate_starts_a_new_track(self): + p = _table([[(0.0, 0.0)], [(0.0, 50.0)]]) + res = link(p, max_dist=10.0) + assert res.n_tracks == 2 + assert [(e.frame, e.kind) for e in res.events] == \ + [(0, "birth"), (1, "birth"), (1, "death")] + + def test_the_gate_is_not_relaxed_to_keep_cardinality(self): + """The failure mode a plain rectangular Hungarian has: with one track and one + detection it MUST return a pair, so the gate has to be enforced afterwards. + """ + p = _table([[(0.0, 0.0)], [(0.0, 11.0)]]) + assert link(p, max_dist=10.0).n_tracks == 2 + assert link(p, max_dist=12.0).n_tracks == 1 + + def test_the_globally_cheapest_pairing_wins_over_the_greedy_one(self): + """Nearest-neighbour chaining gets this wrong; an assignment does not. + + Costs (x only): A at 10, B at 13; detections at 5 and 11. + + ====== ==== ==== + \\ d=5 d=11 + ====== ==== ==== + A 5 1 + B 8 2 + ====== ==== ==== + + Greedy takes the globally smallest edge A->11 (1) and then has to put B on + 5 (8), total 9. The optimum is A->5 (5) plus B->11 (2), total 7. Every + per-track ``argmin`` scheme fails this; the assignment is why we can charge + one track more so the pair costs less. + """ + p = _table([[(0.0, 10.0), (0.0, 13.0)], + [(0.0, 5.0), (0.0, 11.0)]]) + res = link(p, max_dist=10.0) + assert res.n_tracks == 2 + assert res.trajectory(0)[1, 2] == pytest.approx(5.0), ( + "track A took its own nearest detection — this is a greedy match, not " + "an assignment") + assert res.trajectory(1)[1, 2] == pytest.approx(11.0) + + def test_a_near_gate_pair_is_still_linked_rather_than_left_over(self): + """The other half of the gate contract: unmatched must be *possible*, not + *preferred*. + + A sits on top of a detection (cost 0) while B's only admissible partner + costs 10 out of a 10.5 gate. Both links must still be made — a solver whose + sentinel were too small, or one that stopped at the obvious cheap pair, + would leave B unmatched and invent a death plus a birth. + """ + p = _table([[(0.0, 0.0), (0.0, 20.0)], [(0.0, 0.0), (0.0, 10.0)]]) + res = link(p, max_dist=10.5) + assert res.n_tracks == 2 + assert res.events_of("death") == [] and \ + res.events_of("birth", exclude_initial=True) == [] + + def test_a_frame_with_no_detections_does_not_link_across_it(self): + p = _table([[(0.0, 0.0)], [], [(0.0, 0.0)]]) + res = link(p, max_dist=10.0, memory=0) + assert res.n_tracks == 2 + assert [(e.frame, e.kind) for e in res.events] == \ + [(0, "birth"), (1, "death"), (2, "birth")] + + def test_max_dist_must_be_positive(self): + for bad in (0.0, -1.0, np.nan, np.inf): + with pytest.raises(ValueError, match="max_dist"): + LinkParams(max_dist=bad) + + +class TestGateWidth: + """The measured usable window for ``max_dist`` on the fixture.""" + + @pytest.mark.parametrize("max_dist", [2.0, 3.0, 5.0, 10.0, 20.0, 40.0]) + def test_a_wide_range_of_gates_gives_the_same_answer(self, linked, max_dist): + """Including the 10.0 default. 2.0 nm = 4 px is the lower edge.""" + _s, _gt, particles, _res = linked + res = link(particles, max_dist=max_dist) + assert res.n_tracks == 7 + assert len(res.events_of("death")) == 1 + assert len(res.events_of("merge")) == 1 + + def test_too_tight_a_gate_fragments_and_is_visible_as_extra_deaths(self, linked): + """Establishes that the range above is not vacuous — and shows what a badly + set gate looks like, which is a wall of deaths rather than silence.""" + _s, _gt, particles, _res = linked + res = link(particles, max_dist=0.6) # 1.2 px, below the true step + assert res.n_tracks > 7 + assert len(res.events_of("death")) > 1 + + +# ── memory ─────────────────────────────────────────────────────────────────── + +class TestMemory: + """A blinking detection must be ONE track, which is what memory is for.""" + + def test_memory_zero_splits_a_blinking_detection(self): + p = _table([[(0.0, 0.0)], [(0.0, 1.0)], [], [(0.0, 3.0)]]) + res = link(p, max_dist=10.0, memory=0) + assert res.n_tracks == 2 + assert [(e.frame, e.kind) for e in res.events] == \ + [(0, "birth"), (2, "death"), (3, "birth")] + + def test_memory_one_bridges_a_single_missing_frame(self): + p = _table([[(0.0, 0.0)], [(0.0, 1.0)], [], [(0.0, 3.0)]]) + res = link(p, max_dist=10.0, memory=1) + assert res.n_tracks == 1 + assert [e.kind for e in res.events] == ["birth"] + + def test_memory_one_does_not_bridge_two_missing_frames(self): + p = _table([[(0.0, 0.0)], [], [], [(0.0, 3.0)]]) + res = link(p, max_dist=10.0, memory=1) + assert res.n_tracks == 2 + + def test_memory_two_bridges_two_missing_frames(self): + p = _table([[(0.0, 0.0)], [], [], [(0.0, 3.0)]]) + assert link(p, max_dist=10.0, memory=2).n_tracks == 1 + + def test_the_gate_is_measured_from_the_last_seen_position(self): + """Memory does not widen the search radius, so a fast particle that blinks + can still fall outside it. Documented behaviour, pinned here.""" + p = _table([[(0.0, 0.0)], [], [(0.0, 12.0)]]) + assert link(p, max_dist=10.0, memory=1).n_tracks == 2 + + def test_a_bridged_track_has_a_frame_gap_not_an_interpolated_row(self): + p = _table([[(0.0, 0.0)], [], [(0.0, 2.0)]]) + res = link(p, max_dist=10.0, memory=1) + assert np.array_equal(res.trajectory(0)[:, 0], [0, 2]) + assert res.track_lengths().tolist() == [2] + assert int(res.track_last_frame[0]) == 2 + + def test_memory_on_the_fixture_repairs_a_punched_hole(self, linked): + """The fixture has no dropouts, so one is introduced: delete the mover's + detection at frame 10. memory=0 fragments it into two tracks with a spurious + death; memory=1 recovers the single track and the spurious death vanishes. + """ + _s, gt, particles, _res = linked + scale = float(gt["scale"]) + want = particle_truth_at(gt, 10)[0][2] + rows, contours = [], [] + for t in range(particles.n_frames): + blk = particles.at(t).copy() + keep = np.ones(len(blk), bool) + if t == 10: + d = np.hypot(*(blk[:, [COL["y"], COL["x"]]] / scale - want).T) + keep[int(np.argmin(d))] = False + rows.append(blk[keep]) + contours.append([particles.contour_at(gi) + for gi, k in zip(particles.indices_at(t), keep) if k]) + holed = SpyDEParticles.from_frames( + rows, frame_shape=particles.frame_shape, contours_per_frame=contours, + scale=scale, units="nm") + + cold = link(holed, max_dist=FIXTURE_MAX_DIST, memory=0) + warm = link(holed, max_dist=FIXTURE_MAX_DIST, memory=1) + assert cold.n_tracks == 8 and len(cold.events_of("death")) == 2 + assert warm.n_tracks == 7 and len(warm.events_of("death")) == 1 + assert 10 not in [e.frame for e in warm.events_of("death")] + + def test_memory_must_not_be_negative(self): + with pytest.raises(ValueError, match="memory"): + LinkParams(memory=-1) + + +# ── property similarity ────────────────────────────────────────────────────── + +class TestPropertySimilarity: + """The optional weighting, and the promise that the GATE stays distance-only.""" + + def test_off_by_default(self): + assert LinkParams().property_weight == 0.0 + + def test_distance_alone_takes_the_wrong_pair_here(self): + """The setup: the distance-optimal pairing crosses the areas over. + + A(area 100) at 0 and B(area 10) at 10; detections at 4 (area 10) and 6 + (area 100). Distance prefers A->4, B->6 (total 8) over the area-consistent + A->6, B->4 (total 12), so this is a case where the property term has to + change the answer or it is doing nothing. + """ + p = _table([[(0.0, 0.0), (0.0, 10.0)], [(0.0, 4.0), (0.0, 6.0)]], + areas=[[100.0, 10.0], [10.0, 100.0]]) + res = link(p, max_dist=10.0, property_weight=0.0) + assert res.trajectory(0)[1, 2] == pytest.approx(4.0) + + def test_a_weighted_property_flips_it(self): + """0.245 is the analytic crossover for this geometry; 0.5 clears it.""" + p = _table([[(0.0, 0.0), (0.0, 10.0)], [(0.0, 4.0), (0.0, 6.0)]], + areas=[[100.0, 10.0], [10.0, 100.0]]) + res = link(p, max_dist=10.0, property_weight=0.5, properties=("area",)) + assert res.trajectory(0)[1, 2] == pytest.approx(6.0), ( + "the property term did not change the assignment") + assert res.n_tracks == 2 + + def test_the_gate_stays_on_distance_alone(self): + """A perfect property match cannot pull a pair inside the gate, and a bad + one cannot push a pair out of it. Otherwise ``max_dist`` silently means + "max_dist minus however dissimilar these two look".""" + far = _table([[(0.0, 0.0)], [(0.0, 30.0)]], areas=[[50.0], [50.0]]) + assert link(far, max_dist=10.0, property_weight=5.0).n_tracks == 2 + near = _table([[(0.0, 0.0)], [(0.0, 9.0)]], areas=[[1000.0], [1.0]]) + assert link(near, max_dist=10.0, property_weight=5.0, + properties=("area",)).n_tracks == 1 + + def test_a_missing_property_does_not_dilute_the_weight(self): + """``intensity_mean`` is NaN when nothing measured it. Averaging it in as + zero would halve a requested weight of 1.0 without saying so.""" + p = _table([[(0.0, 0.0), (0.0, 10.0)], [(0.0, 4.0), (0.0, 6.0)]], + areas=[[100.0, 10.0], [10.0, 100.0]]) + p.flat_buffer[:, COL["intensity_mean"]] = np.nan + only_area = link(p, max_dist=10.0, property_weight=0.5, properties=("area",)) + with_nan = link(p, max_dist=10.0, property_weight=0.5, + properties=("area", "intensity_mean")) + assert np.array_equal(only_area.track_id, with_nan.track_id) + assert with_nan.trajectory(0)[1, 2] == pytest.approx(6.0) + + def test_the_fixture_is_unaffected_by_the_weighting(self, linked): + """Positions on real segmented data are unambiguous, which is why the term + is off by default: it cannot help here and it can only add noise.""" + _s, _gt, particles, res = linked + for w in (0.25, 1.0, 5.0): + other = link(particles, max_dist=FIXTURE_MAX_DIST, property_weight=w) + assert np.array_equal(other.track_id, res.track_id), f"weight {w}" + + def test_unknown_property_is_rejected_at_construction(self): + with pytest.raises(KeyError, match="not_a_column"): + LinkParams(properties=("not_a_column",)) + + def test_negative_weight_is_rejected(self): + with pytest.raises(ValueError, match="property_weight"): + LinkParams(property_weight=-0.1) + + +# ── merge / split rule ─────────────────────────────────────────────────────── + +class TestMergeAndSplitRule: + """The post-pass, on geometries small enough to reason about exactly.""" + + def test_two_tracks_onto_one_detection_is_a_merge(self): + p = _table([[(0.0, 0.0), (0.0, 6.0)], + [(0.0, 0.5), (0.0, 5.5)], + [(0.0, 3.0)]], diameter=8.0) + res = link(p, max_dist=10.0) + merges = res.events_of("merge") + assert len(merges) == 1 and merges[0].frame == 2 + assert set(merges[0].tracks) == {0, 1} + assert res.events_of("death") == [], "a merge must replace the death" + + def test_one_track_into_two_detections_is_a_split(self): + p = _table([[(0.0, 3.0)], + [(0.0, 3.0)], + [(0.0, 0.5), (0.0, 5.5)]], diameter=8.0) + res = link(p, max_dist=10.0) + splits = res.events_of("split") + assert len(splits) == 1 and splits[0].frame == 2 + assert res.events_of("birth", exclude_initial=True) == [], ( + "a split must replace the birth") + + def test_a_lone_disappearance_far_from_anything_is_a_death(self): + p = _table([[(0.0, 0.0), (0.0, 60.0)], [(0.0, 0.0)]], diameter=8.0) + res = link(p, max_dist=10.0) + assert [e.kind for e in res.events_of("death")] == ["death"] + assert res.events_of("merge") == [] + + def test_a_lone_appearance_far_from_anything_is_a_birth(self): + p = _table([[(0.0, 0.0)], [(0.0, 0.0), (0.0, 60.0)]], diameter=8.0) + res = link(p, max_dist=10.0) + assert len(res.events_of("birth", exclude_initial=True)) == 1 + assert res.events_of("split") == [] + + def test_the_adaptive_radius_scales_with_the_particle(self): + """A disappearance 9 units from a survivor is a merge for a 20-unit body and + a death for a 4-unit one. A fixed radius cannot be right for both.""" + frames = [[(0.0, 0.0), (0.0, 9.0)], [(0.0, 0.0)]] + assert link(_table(frames, diameter=20.0), max_dist=10.0).events_of("merge") + assert link(_table(frames, diameter=4.0), max_dist=10.0).events_of("death") + + def test_merge_dist_can_be_overridden(self): + frames = [[(0.0, 0.0), (0.0, 9.0)], [(0.0, 0.0)]] + p = _table(frames, diameter=4.0) + assert link(p, max_dist=10.0, merge_dist=20.0).events_of("merge") + assert link(p, max_dist=10.0, merge_dist=2.0).events_of("death") + + def test_split_dist_can_be_overridden(self): + frames = [[(0.0, 0.0)], [(0.0, 0.0), (0.0, 9.0)]] + p = _table(frames, diameter=4.0) + assert link(p, max_dist=10.0, split_dist=20.0).events_of("split") + assert link(p, max_dist=10.0, split_dist=2.0).events_of( + "birth", exclude_initial=True) + + def test_a_zero_diameter_does_not_disable_the_post_pass(self): + """The floor. An unmeasured ``equiv_diameter`` would otherwise give a zero + radius, so no merge could ever be detected and the failure would be silent. + """ + p = _table([[(0.0, 0.0), (0.0, 1.0)], [(0.0, 0.5)]], diameter=0.0) + assert link(p, max_dist=10.0).events_of("merge") + + def test_the_floor_is_in_pixels_not_calibrated_units(self): + """A 2 px floor must stay 2 px when the axis is calibrated at 0.1 nm/px, + otherwise the post-pass gets 20x looser on a finer calibration.""" + frames = [[(0.0, 0.0), (0.0, 1.5)], [(0.0, 0.75)]] + for scale in (0.1, 1.0, 10.0): + res = link(_table(frames, diameter=0.0, scale=scale), max_dist=100.0) + assert res.events_of("merge"), f"scale {scale}" + + def test_a_disappearance_next_to_a_NEWCOMER_is_not_a_merge(self): + """The survivor must have existed the frame before. Without that + requirement, a death happening to coincide with an unrelated birth nearby + would be reported as a merge into it.""" + # Frame 3 keeps the far particle and introduces a NEW detection 4 units from + # where the first one died — inside the 8-unit merge radius but outside the + # 2-unit link gate, so the first track genuinely ends and the newcomer is + # genuinely new. The buffer order is reversed at frame 3 so that row order + # cannot stand in for the "existed before" test. + p = _table([[(0.0, 0.0), (0.0, 40.0)], + [(0.0, 1.0), (0.0, 40.0)], + [(0.0, 2.0), (0.0, 40.0)], + [(0.0, 40.0), (0.0, 6.0)]], diameter=8.0) + res = link(p, max_dist=2.0) + assert [(e.frame, e.kind) for e in res.events_of("death")] == [(3, "death")] + assert res.events_of("merge") == [] + assert [(e.frame, e.kind) + for e in res.events_of("birth", exclude_initial=True)] == [(3, "birth")] + assert res.events_of("split") == [], ( + "the newcomer is 34 units from the only continuing track, so it cannot " + "be a fragment of it either") + + def test_events_survive_a_long_uneventful_stretch(self): + """The post-pass skips building its per-frame lookup on frames that no + candidate event needs, which is most of a long stable movie. A merge is the + case that would break if the skip were off by one, because it is the only + event that reads the PREVIOUS frame's map — so nothing happens for seven + frames and then two detections become one. + """ + frames = [[(0.0, 0.0), (0.0, 6.0)]] * 8 + [[(0.0, 3.0)]] + res = link(_table(frames, diameter=8.0), max_dist=10.0) + merges = res.events_of("merge") + assert len(merges) == 1 and merges[0].frame == 8 + assert set(merges[0].tracks) == {0, 1} + + def test_a_late_split_survives_the_same_skip(self): + frames = [[(0.0, 3.0)]] * 8 + [[(0.0, 0.5), (0.0, 5.5)]] + res = link(_table(frames, diameter=8.0), max_dist=10.0) + assert [(e.frame, e.kind) for e in res.events_of("split")] == [(8, "split")] + + def test_merge_event_particle_indices_point_at_real_rows(self): + p = _table([[(0.0, 0.0), (0.0, 6.0)], + [(0.0, 0.5), (0.0, 5.5)], + [(0.0, 3.0)]], diameter=8.0) + res = link(p, max_dist=10.0) + e = res.events_of("merge")[0] + assert len(e.particles) == len(e.tracks) == 2 + absorbed, survivor = e.particles + assert int(res.frame_index[absorbed]) == e.frame - 1, ( + "the absorbed track has no row at the merge frame — its last one is the " + "only row that can identify it") + assert int(res.frame_index[survivor]) == e.frame + assert res.track_id[survivor] == e.tracks[1] + + +# ── determinism, degenerate input, bookkeeping ─────────────────────────────── + +class TestDeterminism: + def test_the_same_table_links_identically_twice(self, linked): + _s, _gt, particles, res = linked + again = link(particles, max_dist=FIXTURE_MAX_DIST) + assert np.array_equal(again.track_id, res.track_id) + assert again.events == res.events + + def test_a_rebuilt_table_links_identically(self, movie): + """Not just the same object twice — the same pixels through the whole + pipeline, so a dict- or set-ordering dependency anywhere would show.""" + s, gt = movie + a = link(_segment_movie(s, gt), max_dist=FIXTURE_MAX_DIST) + b = link(_segment_movie(s, gt), max_dist=FIXTURE_MAX_DIST) + assert np.array_equal(a.track_id, b.track_id) + assert a.events == b.events + + def test_ids_are_contiguous_from_zero(self, linked): + _s, _gt, _p, res = linked + assert res.track_id.min() == 0 + assert np.array_equal(np.unique(res.track_id), np.arange(res.n_tracks)) + + def test_ids_are_ordered_by_first_appearance(self, linked): + """Which is why they are stable: the numbering depends on the buffer order, + not on iteration order anywhere in the loop.""" + _s, _gt, _p, res = linked + assert np.all(np.diff(res.track_first_frame) >= 0) + assert np.all(np.diff(res.track_first_index) > 0) + + def test_a_symmetric_tie_is_broken_reproducibly(self): + """Two tracks exactly equidistant from one detection: whichever survives, it + must be the same one every run.""" + p = _table([[(0.0, 0.0), (0.0, 4.0)], [(0.0, 2.0)]], diameter=8.0) + first = link(p, max_dist=10.0) + for _ in range(5): + other = link(p, max_dist=10.0) + assert np.array_equal(other.track_id, first.track_id) + assert other.events == first.events + + +class TestDegenerate: + def test_no_frames_at_all(self): + res = link(SpyDEParticles.from_frames([], frame_shape=(8, 8))) + assert res.n_tracks == 0 and res.events == [] and res.n_frames == 0 + assert res.positions.shape == (0, 2) + + def test_every_frame_empty(self): + p = SpyDEParticles.from_frames([np.zeros((0, N_COLUMNS), np.float32)] * 5, + frame_shape=(8, 8)) + res = link(p) + assert res.n_tracks == 0 and res.events == [] + assert res.event_counts()["birth"].shape == (5,) + + def test_one_empty_frame_in_the_middle(self): + p = _table([[(0.0, 0.0)], [], [(0.0, 0.0)]]) + res = link(p, max_dist=5.0) + assert res.n_tracks == 2 + assert [e.frame for e in res.events_of("death")] == [1] + + def test_a_single_frame_gives_births_and_no_deaths(self): + """Nothing to link, and the movie ending is not a dissolution — so a + one-frame table is all births.""" + res = link(_table([[(0.0, 0.0), (5.0, 5.0)]])) + assert res.n_tracks == 2 + assert [e.kind for e in res.events] == ["birth", "birth"] + + def test_everything_vanishes_mid_movie(self): + p = _table([[(0.0, 0.0), (0.0, 20.0)], + [(0.0, 0.0), (0.0, 20.0)], + [], [], []]) + res = link(p, max_dist=5.0) + assert res.n_tracks == 2 + assert sorted(e.frame for e in res.events_of("death")) == [2, 2] + assert res.events_of("merge") == [] + + def test_a_particle_appearing_only_in_the_last_frame(self): + p = _table([[(0.0, 0.0)], [(0.0, 0.0)], [(0.0, 0.0), (0.0, 40.0)]]) + res = link(p, max_dist=5.0) + assert res.n_tracks == 2 + assert [e.frame for e in res.events_of("birth", exclude_initial=True)] == [2] + assert res.events_of("death") == [] + + def test_a_table_that_never_got_masks_still_links(self): + """``store_masks=False`` is the default for long movies, so the linker must + not need contours.""" + p = _table([[(0.0, 0.0)], [(0.0, 1.0)]]) + assert p.contours is None + assert link(p, max_dist=5.0).n_tracks == 1 + + +class TestResultBookkeeping: + def test_apply_writes_the_track_id_column(self, linked): + _s, _gt, particles, res = linked + copy = SpyDEParticles(flat_buffer=particles.flat_buffer.copy(), + t_offsets=particles.t_offsets.copy(), + frame_shape=particles.frame_shape, + scale=particles.scale, units=particles.units) + assert not copy.has_tracks + res.apply(copy) + assert copy.has_tracks + assert np.array_equal(copy.column("track_id").astype(np.int32), res.track_id) + + def test_link_does_not_mutate_unless_asked(self, linked): + _s, _gt, particles, _res = linked + before = particles.column("track_id").copy() + link(particles, max_dist=FIXTURE_MAX_DIST) + assert np.array_equal(particles.column("track_id"), before) + + def test_apply_true_writes_through(self): + p = _table([[(0.0, 0.0)], [(0.0, 1.0)]]) + res = link(p, max_dist=5.0, apply=True) + assert np.array_equal(p.column("track_id").astype(np.int32), res.track_id) + + def test_apply_rejects_a_mismatched_table(self, linked): + _s, _gt, _p, res = linked + with pytest.raises(ValueError, match="different link"): + res.apply(_table([[(0.0, 0.0)]])) + + def test_track_indices_are_chronological(self, linked): + _s, _gt, _p, res = linked + for tid in range(res.n_tracks): + idx = res.track_indices(tid) + assert np.all(res.track_id[idx] == tid) + assert np.all(np.diff(res.frame_index[idx]) > 0) + + def test_track_indices_cover_every_particle_exactly_once(self, linked): + _s, _gt, _p, res = linked + seen = np.concatenate([res.track_indices(t) for t in range(res.n_tracks)]) + assert np.array_equal(np.sort(seen), np.arange(res.n_particles)) + + def test_track_endpoints_agree_with_the_trajectories(self, linked): + _s, _gt, _p, res = linked + for tid in range(res.n_tracks): + traj = res.trajectory(tid) + assert traj[0, 0] == res.track_first_frame[tid] + assert traj[-1, 0] == res.track_last_frame[tid] + assert np.array_equal(traj[0, 1:], res.positions[res.track_first_index[tid]]) + assert np.array_equal(traj[-1, 1:], res.positions[res.track_last_index[tid]]) + + def test_track_at_matches_a_boolean_scan(self, linked): + """`track_at` uses searchsorted for speed; it must agree with the naive form.""" + _s, _gt, _p, res = linked + for t in range(res.n_frames): + assert np.array_equal(res.track_at(t), + res.track_id[res.frame_index == t]) + + def test_track_lengths_count_detections_not_span(self): + p = _table([[(0.0, 0.0)], [], [(0.0, 1.0)]]) + res = link(p, max_dist=5.0, memory=1) + assert res.track_lengths().tolist() == [2] + assert int(res.track_last_frame[0]) - int(res.track_first_frame[0]) == 2 + + def test_out_of_range_track_raises(self, linked): + _s, _gt, _p, res = linked + with pytest.raises(IndexError): + res.track_indices(res.n_tracks) + + def test_unknown_event_kind_raises(self, linked): + _s, _gt, _p, res = linked + with pytest.raises(ValueError, match="unknown event kind"): + res.events_of("explosion") + + def test_unknown_link_parameter_raises(self, linked): + _s, _gt, particles, _res = linked + with pytest.raises(TypeError, match="max_distance"): + link(particles, max_distance=5.0) + + def test_params_and_kwargs_compose(self, linked): + _s, _gt, particles, _res = linked + res = link(particles, LinkParams(max_dist=99.0), max_dist=FIXTURE_MAX_DIST) + assert res.params["max_dist"] == FIXTURE_MAX_DIST + + def test_params_are_recorded_for_provenance(self, linked): + _s, _gt, _p, res = linked + assert res.params["max_dist"] == FIXTURE_MAX_DIST + assert res.params["memory"] == 0 + assert res.params["merge_dist"] is None + + def test_repr_names_the_reference_and_the_event_counts(self, linked): + _s, _gt, _p, res = linked + text = repr(res) + assert "reference='lab'" in text and "'merge': 1" in text + + +class TestEventSerialisation: + def test_round_trips_through_records(self, linked): + _s, _gt, _p, res = linked + assert events_from_records(events_to_records(res.events)) == res.events + + def test_records_are_json_safe(self, linked): + import json + _s, _gt, _p, res = linked + text = json.dumps(events_to_records(res.events)) + assert json.loads(text)[0]["kind"] == "birth" + + def test_result_to_dict_is_json_safe(self, linked): + import json + _s, _gt, _p, res = linked + d = json.loads(json.dumps(res.to_dict())) + assert d["n_tracks"] == 7 and d["reference"] == "lab" + assert len(d["events"]) == len(res.events) + + def test_events_are_hashable_and_immutable(self, linked): + """Three surfaces share this record (navigator lane, table, report embed), + so one of them must not be able to rewrite it.""" + import dataclasses + _s, _gt, _p, res = linked + assert len(set(res.events)) == len(res.events) + with pytest.raises(dataclasses.FrozenInstanceError): + res.events[0].frame = 3 + + def test_params_to_dict_is_json_safe(self): + import json + assert json.loads(json.dumps(LinkParams().to_dict()))["memory"] == 0 From 5358fdf48202f5d301751c6bffae91fd2c208695 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 18:49:34 -0500 Subject: [PATCH 05/38] docs(plan): two overlay defects found by rendering real linker output A dead track kept painting its head dot for as long as its trajectory intersected the trailing window. The dot means 'the particle is HERE NOW', so on a dead track it is a lie -- it reads as a real particle the segmenter has stopped filling. Same for a track inside its memory gap. Recorded with the fix. The count lane was drawn as a straight interpolation between frames, which puts the visual transition half a frame early: nucleation at frame 8 looked like 7. Integer lanes are step plots; continuous ones stay lines. Neither is visible in a passing test -- both came from looking at the pixels, which is what CLAUDE.md's verification standard is about. --- DRIFT_AND_PARTICLES_PLAN.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/DRIFT_AND_PARTICLES_PLAN.md b/DRIFT_AND_PARTICLES_PLAN.md index 6d6c04be..c5a6bcce 100644 --- a/DRIFT_AND_PARTICLES_PLAN.md +++ b/DRIFT_AND_PARTICLES_PLAN.md @@ -612,6 +612,18 @@ They surface three ways: age, and a bright dot marks the current position so "now" is unambiguous — a bare fade leaves direction inferable only by close inspection of one track. N adjustable. One extra primitive per track. + > **A DEAD track must stop drawing its head dot.** Found by looking at a render: + > with a trailing window of N frames, a track that died at frame 16 was still + > painting a head dot at frame 18, because its trajectory still intersected the + > window. The dot means "the particle is HERE NOW", so on a dead track it is a + > lie — it reads as a real particle the segmenter has stopped filling. Either + > drop the head dot once a track has died and let the line fade out alone, or + > drop the whole trail at death. Same applies to a track inside its `memory` gap: + > it has no current position, so it gets no dot. +- **Integer lanes are STEP plots.** The count lane is integer data; drawing it as a + straight interpolation between frames puts the visual transition half a frame + early and makes a nucleation look like it happened at 7 when the event is at 8. + Use `steps-post` for count, a plain line for continuous quantities like mean size. - **Kymograph (v1), user-sortable** — tracks × time as an image, one row per track, coloured by a chosen property. Row order is a control, not a constant, matching the table dock's mental model: **by birth time** the leading edge's From b8dfc2873a9edc979919795850a00692210409cc Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 19:21:30 -0500 Subject: [PATCH 06/38] feat(particles): torch feature stack and scribble classifier Wave B2/B3. `features.py` builds a 36-channel stack (gaussian, DoG, rank median/min/max, Sobel, Hessian eigenvalues, Laplacian, optional membrane projections) batched in torch; `scribble.py` trains a small MLP head on pixels the user painted and returns a per-frame foreground probability that `split_instances` turns into instances. torch is imported lazily, and every call site takes `accelerator_lock` -- test_device_lock.py gains a class asserting that, since it is the shared-lock registry. Measured: train + apply on a 96x112 frame is 0.492 s, inside the ~1 s interaction budget, and the fit is FIXED cost (1.5 ms/step at any thread count from 1 to 24 -- pure dispatch, not arithmetic), so it will not degrade with frame size. IoU 0.941 against the sklearn RandomForest reference on identical labels and identical channels. Faint probes come back at 0.983 and 0.9997. Sharing intermediates across the feature family is worth 156 -> 54 ms; the rank family alone 205 -> 33 ms by reusing one unfold, and max_pool2d(stride=1) measured 4x SLOWER than unfold. THREE FINDINGS THAT CHANGE THE PLAN: 1. Section 0.9 needs at least one FAINT scribble. Trained on bright particles only, the head finds at most 1 of 2 faint probes and the forest finds 0 of 2 -- exactly 0.0, which is structural rather than noise, because a tree cannot predict outside the leaves it was shown. One seven-pixel dab fixes it. So the caret's per-class pixel counts are load-bearing, not decoration: under-training a class is THE failure mode, and no amount of sensitivity slider substitutes for a missing example. Recorded as its own test class rather than worked around. 2. "Fine scales are mandatory" was right for the wrong reason. A coarse (4, 8) stack still DETECTS both faint probes; what it loses is their SIZE -- radius error 13% -> 26% overall, and -12% -> -44% on the r=3 probe. The floor stays <=1 px for measurement fidelity, not detection. 3. Importing spyde.particles costs ~6 s, and torch is not why. measure.py reads the column schema from spyde.signals.particles, and importing anything under spyde.signals executes its __init__, which pulls hyperspy (5.5 s of the 6.3). Free in the app -- the backend loads hyperspy at startup anyway -- but it means a script that only wants segmentation pays for a signal framework it never touches, against the "constructible standalone" contract. Left alone deliberately: the fix is in a file other waves share. Also corrected an over-confident docstring: the bright-only faint-probe counts depend on where the background scribbles land. An independent probe with different dabs got 0 of 2, not 1 of 2 -- same conclusion, different numbers. The assertion was already the robust `< 2`; the prose now says so. 150 + 16 tests. --- DRIFT_AND_PARTICLES_PLAN.md | 46 +- spyde/particles/__init__.py | 91 +- spyde/particles/features.py | 947 ++++++++++ spyde/particles/scribble.py | 1050 +++++++++++ spyde/tests/migrated/test_device_lock.py | 52 + .../tests/migrated/test_particles_scribble.py | 1627 +++++++++++++++++ 6 files changed, 3806 insertions(+), 7 deletions(-) create mode 100644 spyde/particles/features.py create mode 100644 spyde/particles/scribble.py create mode 100644 spyde/tests/migrated/test_particles_scribble.py diff --git a/DRIFT_AND_PARTICLES_PLAN.md b/DRIFT_AND_PARTICLES_PLAN.md index c5a6bcce..70189a17 100644 --- a/DRIFT_AND_PARTICLES_PLAN.md +++ b/DRIFT_AND_PARTICLES_PLAN.md @@ -430,8 +430,23 @@ module that imports skimage. set, batched on GPU: gaussian, difference-of-gaussians, median / min / max (via `unfold`), Sobel, Hessian eigenvalues, Laplacian, membrane projections. One `(C, H, W)` tensor per frame, separable convolutions where the kernel allows, -one pass over the frame rather than one pass per feature. **Fine scales are -mandatory** — they are what detects small faint particles (§0.9). +one pass over the frame rather than one pass per feature. 36 default channels; +512² costs 71 ms, and sharing intermediates across the family is worth 156 → 54 ms +(the rank family alone 205 → 33 ms by reusing one `unfold` — note `max_pool2d(stride=1)` +measured **4× slower** than `unfold`, and a closed-form Hessian is 0.32 ms against +`eigvalsh`'s 125 ms). + +**Fine scales are mandatory — but the reason stated in an earlier draft was wrong.** +Measured: a coarse `(4, 8)` stack still *detects* both faint probes. What it loses is +their **size**: mean radius error goes 13% → 26%, and the r=3 probe from −12% to +−44%. So the floor must stay ≤ 1 px for **measurement fidelity**, not for detection. +Sigma 0.5 buys nothing measurable over 1.0 on the fixture and is kept only because it +costs 2 ms of the 71. + +`membrane` projections default **off**: ~8 ms/channel against ~2 ms for everything +else, and they moved the faint-probe result neither way. They do discriminate a line +from a blob 3:1, so they are the right switch for fibres and films — just not for +compact particles. **B3. Scribble classifier** (`scribble.py`) — the workhorse. @@ -450,7 +465,32 @@ mandatory** — they are what detects small faint particles (§0.9). ilastik use, and agreement on the same labels is the acceptance gate. - **Hard interaction budget: train + apply to the visible frame under ~1 s.** Train on labelled pixels only (thousands, not millions); apply to the visible - frame only while tuning. + frame only while tuning. **Measured: 0.492 s** on CPU for a 96×112 frame — + 14 ms featurise, 457 ms for 300 Adam steps, 20 ms apply. The fit is *fixed* + cost (1.5 ms/step at any torch thread count from 1 to 24, i.e. pure dispatch + overhead, not arithmetic), so it will not degrade with frame size — only the + featurise and apply terms will. + +> **§0.9 needs at least one FAINT scribble. "Paint the obvious ones" is not +> enough.** Measured: trained on bright particles only, with no faint example +> anywhere in the labels, the torch head finds at most 1 of the 2 faint probes and +> the RandomForest reference finds **0 of 2** — both exactly 0.0. No threshold +> rescues it; the missed probe sits around 19% probability. A single seven-pixel dab +> on one faint probe takes it to 2 of 2. An 8× contrast extrapolation from labels +> containing no faint example is simply not available from this feature stack. +> +> The forest's 0.0 is *structural*, not noise — a tree ensemble cannot predict +> outside the leaves its training data reached, while the MLP extrapolates its +> decision boundary and gets one for free. So on sensitivity specifically, which +> §0.9 makes the priority, the shipped head beats the reference it is validated +> against rather than merely being faster. +> +> **Two consequences for B7.** The per-class labelled-pixel counts in the caret are +> not decoration — under-training a class is *the* failure mode and the counts are +> how a user notices. And the wizard should say so: when a class has few labelled +> pixels and the preview finds fewer particles than the previous run, prompt for +> more examples rather than letting the user reach for the sensitivity slider, +> which cannot fix a missing example. **B4. Promptable segmentation** (`prompt.py`) — the bootstrap. diff --git a/spyde/particles/__init__.py b/spyde/particles/__init__.py index f2dcbde4..2635f6fd 100644 --- a/spyde/particles/__init__.py +++ b/spyde/particles/__init__.py @@ -13,12 +13,30 @@ │ SpyDEParticles (CSR, per frame) │ - link() (Hungarian) → tracks + link() (Hungarian) → tracks + events The three engines are not alternatives to choose between — they are three ways to -fill the first box, and they compose (plan §0.4). Everything after the first box is -written once, in :mod:`spyde.particles.classical` (the split) and -:mod:`spyde.particles.measure`. +fill the first box, and they compose (plan §0.4): a promptable model's masks become +scribble training labels via :func:`~spyde.particles.scribble.masks_to_labels`. +Everything after the first box is written once, in +:mod:`~spyde.particles.classical` (the instance split) and +:mod:`~spyde.particles.measure`. + +torch is imported **lazily**, inside the functions that need it — importing this +package must never pay for CUDA init. :func:`~spyde.particles.features.gpu_available` +answers the capability question without loading it. + +.. note:: + Importing this package currently costs ~6 s, because ``measure.py`` and + ``track.py`` read the column schema from :mod:`spyde.signals.particles`, and + importing anything under ``spyde.signals`` executes that package's ``__init__``, + which pulls in **hyperspy** (5.5 s of the 6.3 s; torch is NOT involved). That is + free in the running app — the backend loads hyperspy at startup regardless — but + it does mean a script that only wants image segmentation pays for a signal + framework it never touches, which is at odds with the "constructible standalone" + contract in :mod:`spyde.signals`. Fixing it means deferring the ``insitu`` import + in ``spyde/signals/__init__.py``; left alone deliberately, since that file is + shared with other waves. """ from __future__ import annotations @@ -29,13 +47,78 @@ split_instances, threshold_mask, ) +from spyde.particles.features import ( + DEFAULT_RANK_RADII, + DEFAULT_SIGMAS, + FeatureSpec, + PreparedFrame, + feature_names, + feature_stack, + feature_tensor, + gpu_available, + prepare_frame, + sample_features, + select_device, +) from spyde.particles.measure import measure_frame +from spyde.particles.scribble import ( + DEFAULT_CLASSES, + UNLABELLED, + LabelStore, + ScribbleClass, + ScribbleClassifier, + default_classes, + masks_to_labels, + random_forest_reference, +) +from spyde.particles.track import ( + EVENT_KINDS, + LinkParams, + LinkResult, + ParticleEvent, + event_counts, + frame_indices, + link, + sample_frame_positions, +) __all__ = [ + # classical engine + the shared instance split "SegmentParams", "THRESHOLD_METHODS", "segment_frame", "split_instances", "threshold_mask", + # measurement "measure_frame", + # feature stack + "FeatureSpec", + "PreparedFrame", + "DEFAULT_SIGMAS", + "DEFAULT_RANK_RADII", + "prepare_frame", + "feature_tensor", + "feature_stack", + "feature_names", + "sample_features", + "select_device", + "gpu_available", + # scribble engine + "ScribbleClass", + "ScribbleClassifier", + "LabelStore", + "DEFAULT_CLASSES", + "UNLABELLED", + "default_classes", + "masks_to_labels", + "random_forest_reference", + # tracking + "link", + "LinkParams", + "LinkResult", + "ParticleEvent", + "EVENT_KINDS", + "event_counts", + "frame_indices", + "sample_frame_positions", ] diff --git a/spyde/particles/features.py b/spyde/particles/features.py new file mode 100644 index 00000000..6e7dbc10 --- /dev/null +++ b/spyde/particles/features.py @@ -0,0 +1,947 @@ +""" +features.py — the torch feature stack the learned engines classify. Plan step B2. + +ParticleSpy's ``trainable_parameters`` set (which is Weka Trainable Segmentation's +set, which is ilastik's set): gaussian blur, difference-of-gaussians, median / +minimum / maximum rank filters, Sobel gradient magnitude, Hessian eigenvalues, +Laplacian, and membrane projections. Every channel is computed in torch, so the +same code runs on CPU, CUDA and Apple-MPS, and the classifier head +(:mod:`spyde.particles.scribble`) never leaves the device. + +Three properties are load-bearing and are what the implementation is shaped +around. + +**1. One pass over the frame, not one pass per feature.** The gaussian pyramid is +computed once (separably — two 1-D convolutions per sigma instead of one 2-D one) +and then *every* other family is derived from it: difference-of-gaussians is a +subtraction of two cached blurs, the Laplacian is the trace of the Hessian's own +second derivatives, the two Hessian eigenvalues come from the same three +derivative images, and median/minimum/maximum come from one unfolded window each. +:class:`_Pass` is the memo that makes that true — a channel never triggers work +another channel already did. Measured on a 512² float32 frame, CPU, default spec +(36 channels): **54 ms shared vs 156 ms** when each channel recomputes its own +intermediates. + +**2. The fine scales are load-bearing for SMALL particles** (plan §0.9) — and the +measurement is not the one you would guess. It is not about *detection*: on the +two deliberately faint probes in ``particle_movie()`` (r=4 and r=3) a coarse +``(4, 8)`` stack still finds both. It is about what is then *measured* of them. +Mean absolute error in recovered radius over the seven isolated particles, with +identical labels and an identical head, and the r=3 probe on its own:: + + sigmas (0.5, 1, 2, 4, 8) 13.4 % r=3 probe -12 % + sigmas (1, 2, 4, 8) 13.2 % r=3 probe -15 % + sigmas (2, 4, 8) 20.4 % r=3 probe -28 % + sigmas (4, 8) 25.5 % r=3 probe -44 % + +So the floor must not be raised above ~1 px, which is what plan §0.9's "not +without a documented sensitivity measurement" amounts to here — a found particle +measured 44% too small is worse than an honest miss, because it enters the size +distribution. 0.5 buys nothing over 1.0 on this fixture and is kept anyway: it is +2 ms of a 71 ms 512² stack, and it is what a genuinely 1–2 px feature needs. + +**3. Memory is bounded by row-banding, not by hope.** The full stack is +``C·H·W·4`` bytes — 2.4 GB for 36 channels at 4096², which is the plan's stated +frame size. So the stack is produced in **row bands with a halo** (:func:` +map_feature_bands`), and the two callers that matter never materialise the whole +thing: training samples the labelled pixels out of each band and drops it, and +:meth:`~spyde.particles.scribble.ScribbleClassifier.predict_proba` writes one +band's probabilities and drops it. With a halo of at least the largest filter +radius the banded result is *identical* to the unbanded one — pinned by a test. + +NaN input +--------- +A drift-corrected frame carries a NaN-padded border +(:mod:`spyde.drift.warp`), and every convolution propagates NaN outward, which +would erase a band of real data. :func:`prepare_frame` fills the non-finite +pixels with the finite **minimum** — the same choice, for the same reason, as +:func:`spyde.particles.classical._prepare`: the padding then reads as background, +the one value guaranteed not to classify as a particle. It also returns the +validity mask, and the classifier is required to force those pixels to zero +probability (plan trap 2). +""" +from __future__ import annotations + +import logging +import math +from dataclasses import asdict, dataclass, replace +from typing import Any, Callable, NamedTuple + +import numpy as np + +from spyde.device_lock import accelerator_lock + +log = logging.getLogger(__name__) + +#: Gaussian kernels are truncated at this many sigma, matching +#: ``scipy.ndimage.gaussian_filter``'s default so the two agree numerically +#: (``test_particles_scribble.py`` pins that against scipy with ``mode="mirror"``, +#: which is what torch's ``reflect`` padding is). +_TRUNCATE = 4.0 + +#: Default scales, in pixels, octave-spaced. The **floor** is what matters — see +#: the module docstring for the measured cost of raising it. The top end (8) is +#: what gives the head a local-background reference, which is how it separates a +#: faint particle from a bright patch of support film. +DEFAULT_SIGMAS: tuple[float, ...] = (0.5, 1.0, 2.0, 4.0, 8.0) + +#: Default rank-filter window radii, in pixels (window is ``2r+1`` square). +DEFAULT_RANK_RADII: tuple[int, ...] = (1, 2) + +#: Projections available across the rotated membrane responses (Weka's set). +MEMBRANE_PROJECTIONS: tuple[str, ...] = ("sum", "mean", "std", "median", + "max", "min") + +#: Target working-set size for one row band. Not a hard cap — a band is always at +#: least ``4·halo`` rows tall, because a band shorter than its own halo would +#: recompute more halo than payload. +BAND_BYTES: int = 256 << 20 + +#: Robust per-frame statistics are estimated from at most this many pixels. +#: ``np.percentile`` on a 4096² frame is a full sort (~1.5 s here) and would +#: dominate the whole interaction budget; a strided subsample of 10⁶ pixels puts +#: the median and IQR well inside their own sampling noise. Same trade-off, and +#: the same reasoning, as the subsampled first-paint histogram in ``plot.py``. +_STAT_SAMPLE_MAX = 1_000_000 + + +# ── spec ───────────────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class FeatureSpec: + """Which features to compute, and at which scales. + + Frozen and serialisable: :meth:`to_dict` / :meth:`from_dict` round-trip + through JSON, which is what lets a wizard parameter schema drive it and what + lets a trained recipe be saved next to its weights + (:meth:`spyde.particles.scribble.ScribbleClassifier.save`). A model whose + spec no longer matches its weights is meaningless, so the two are never + stored apart. + + Parameters + ---------- + sigmas + Gaussian scales in pixels, ascending. Drives ``gaussian``, + ``difference_of_gaussians``, ``sobel``, ``hessian`` and ``laplacian``. + rank_radii + Window radii for ``median`` / ``minimum`` / ``maximum``, in pixels. + membrane + Weka's membrane projections: a thin line kernel rotated through 180° and + projected. **Off by default**, which is a deliberate deviation from + ParticleSpy's set rather than an omission — the family exists to find + *elongated* structures (its name is literal; it was built for neuron + membranes), it is the most expensive channel here per channel (adds 33 ms + to a 71 ms 512² total for four channels, so ~8 ms/channel against ~2 ms + for everything else), and on the compact blobs this feature is for it did + not change the faint-probe result either way. Turn it on for fibres, + films or lattice fringes, where it earns its cost. + membrane_thickness + Line width in pixels. Rounded up to an odd number so the line is centred. + normalize_frame + Robustly standardise the frame (median / IQR over finite pixels) before + featurising. On by default because it is what makes a saved recipe + transferable: without it every channel carries the dataset's absolute + intensity scale, so a model trained on a float image in 0..1 predicts + nothing at all on the same sample recorded as uint16 counts. + """ + + sigmas: tuple[float, ...] = DEFAULT_SIGMAS + intensity: bool = True + gaussian: bool = True + difference_of_gaussians: bool = True + sobel: bool = True + hessian: bool = True + laplacian: bool = True + rank_radii: tuple[int, ...] = DEFAULT_RANK_RADII + median: bool = True + minimum: bool = True + maximum: bool = True + membrane: bool = False + membrane_patch: int = 19 + membrane_thickness: int = 1 + membrane_rotations: int = 12 + membrane_projections: tuple[str, ...] = ("mean", "std", "max", "min") + normalize_frame: bool = True + + def __post_init__(self) -> None: + # Coerce lists (which is what `from_dict` and a JSON parameter schema + # hand over) to tuples, so the dataclass stays hashable and comparable. + object.__setattr__(self, "sigmas", + tuple(float(s) for s in self.sigmas)) + object.__setattr__(self, "rank_radii", + tuple(int(r) for r in self.rank_radii)) + object.__setattr__(self, "membrane_projections", + tuple(str(p) for p in self.membrane_projections)) + if any(s <= 0 for s in self.sigmas): + raise ValueError(f"sigmas must be positive; got {self.sigmas}") + if tuple(sorted(self.sigmas)) != self.sigmas: + raise ValueError(f"sigmas must be ascending; got {self.sigmas}") + if any(r < 1 for r in self.rank_radii): + raise ValueError(f"rank_radii must be >= 1; got {self.rank_radii}") + bad = set(self.membrane_projections) - set(MEMBRANE_PROJECTIONS) + if bad: + raise ValueError( + f"unknown membrane projection(s) {sorted(bad)}; expected any of " + f"{', '.join(MEMBRANE_PROJECTIONS)}" + ) + if self.membrane and self.membrane_patch % 2 == 0: + raise ValueError( + f"membrane_patch must be odd so the line is centred; got " + f"{self.membrane_patch}" + ) + if self.membrane and self.membrane_rotations < 1: + raise ValueError( + f"membrane_rotations must be >= 1; got {self.membrane_rotations}") + if not self.channel_names(): + raise ValueError( + "this FeatureSpec produces no channels at all — every family is " + "disabled, or the enabled ones have empty sigmas/rank_radii") + + # -- serialisation --------------------------------------------------------- + + def to_dict(self) -> dict[str, Any]: + """Plain JSON-safe dict (tuples become lists).""" + out = asdict(self) + for key in ("sigmas", "rank_radii", "membrane_projections"): + out[key] = list(out[key]) + return out + + @classmethod + def from_dict(cls, d: dict[str, Any] | None) -> "FeatureSpec": + """Rebuild from :meth:`to_dict`, ignoring keys this build does not know. + + Unknown keys are dropped rather than raising: a spec written by a newer + build should still load with the features this one understands, and the + alternative is that a saved recipe becomes unopenable on a downgrade. + Missing keys take their defaults. + """ + if not d: + return cls() + fields = set(cls.__dataclass_fields__) + unknown = set(d) - fields + if unknown: + log.debug("FeatureSpec.from_dict ignoring unknown keys: %s", + sorted(unknown)) + return cls(**{k: v for k, v in d.items() if k in fields}) + + def replace(self, **kw: Any) -> "FeatureSpec": + """A copy with *kw* overridden (the frozen-dataclass setter).""" + return replace(self, **kw) + + # -- shape ----------------------------------------------------------------- + + def channel_names(self) -> list[str]: + """Channel names, in output order. + + Derived from the same :func:`_channel_plan` the tensor builder walks, so + the names cannot drift out of step with the channels — there is exactly + one definition of the order. + """ + return [name for _family, name, _args in _channel_plan(self)] + + @property + def n_channels(self) -> int: + return len(_channel_plan(self)) + + @property + def halo(self) -> int: + """Largest radius, in pixels, that any enabled filter reaches. + + This is the row overlap :func:`map_feature_bands` needs for a banded + stack to equal an unbanded one. The ``+1`` on the gaussian radius is the + 3-tap Sobel / second-difference stencil applied *after* the blur. + """ + r = 1 + if self.sigmas and (self.gaussian or self.difference_of_gaussians or + self.sobel or self.hessian or self.laplacian): + r = max(r, _gauss_radius(max(self.sigmas)) + 1) + if self.rank_radii and (self.median or self.minimum or self.maximum): + r = max(r, max(self.rank_radii)) + if self.membrane: + r = max(r, self.membrane_patch // 2) + return int(r) + + +# ── the channel plan: THE definition of channel order and channel names ────── + +def _fmt(x: float) -> str: + return f"{float(x):g}" + + +def _channel_plan(spec: FeatureSpec) -> list[tuple[str, str, tuple]]: + """``(family, name, args)`` for every channel, in output order. + + Both :meth:`FeatureSpec.channel_names` and :class:`_Pass` walk this list, so a + new family is added in one place and the names follow automatically. Grouping + by family rather than interleaving by sigma costs nothing — the shared + intermediates are memoised on :class:`_Pass`, so revisiting sigma 2 for the + Hessian after visiting it for the Sobel does not recompute its blur. + """ + plan: list[tuple[str, str, tuple]] = [] + if spec.intensity: + plan.append(("intensity", "intensity", ())) + if spec.gaussian: + plan += [("gaussian", f"gaussian_s{_fmt(s)}", (s,)) for s in spec.sigmas] + if spec.difference_of_gaussians: + plan += [("dog", f"dog_s{_fmt(a)}_s{_fmt(b)}", (a, b)) + for a, b in zip(spec.sigmas, spec.sigmas[1:])] + if spec.sobel: + plan += [("sobel", f"sobel_s{_fmt(s)}", (s,)) for s in spec.sigmas] + if spec.laplacian: + plan += [("laplacian", f"laplacian_s{_fmt(s)}", (s,)) for s in spec.sigmas] + if spec.hessian: + for s in spec.sigmas: + plan.append(("hessian_major", f"hessian_major_s{_fmt(s)}", (s,))) + plan.append(("hessian_minor", f"hessian_minor_s{_fmt(s)}", (s,))) + for stat in ("median", "minimum", "maximum"): + if getattr(spec, stat): + plan += [(stat, f"{stat}_r{r}", (r,)) for r in spec.rank_radii] + if spec.membrane: + plan += [("membrane", f"membrane_{p}", (p,)) + for p in spec.membrane_projections] + return plan + + +# ── device ─────────────────────────────────────────────────────────────────── + +def import_torch(): + """``import torch``, with a message that says whose problem it is if it fails. + + torch is a core SpyDE dependency, so a failure here is a broken environment + rather than a missing extra — and the bare ``ModuleNotFoundError`` from six + frames down does not say that. Shared with + :mod:`spyde.particles.scribble` rather than duplicated there. + """ + try: + import torch + return torch + except Exception as exc: # pragma: no cover + raise ImportError( + "spyde.particles needs torch, which is a core SpyDE dependency but " + f"failed to import: {exc}" + ) from exc + + + +def select_device(prefer: str | None = None): + """Best torch device for the feature stack: CUDA → Apple-MPS → CPU. + + Parameters + ---------- + prefer + An explicit device string (``"cpu"``, ``"cuda"``, ``"mps"``) to force. + Tests force ``"cpu"``: torch-CUDA work segfaults under the pytest process + on Windows (CLAUDE.md), which is a harness interaction rather than a code + defect, so the GPU path is exercised in a subprocess instead. + """ + torch = import_torch() + if prefer is not None: + return torch.device(prefer) + try: + if torch.cuda.is_available(): + return torch.device("cuda") + mps = getattr(torch.backends, "mps", None) + if mps is not None and mps.is_available(): + return torch.device("mps") + except Exception as exc: # pragma: no cover + log.debug("device probe failed (%s); using CPU", exc) + return torch.device("cpu") + + +def gpu_available() -> bool: + """True when a hardware-accelerated torch device exists (CUDA or MPS). + + CPU-only torch is False here even though the stack runs perfectly well on it + — this gate is about whether to *expect* interactive speed, matching + ``vector_orientation_gpu.gpu_available``. + """ + try: + return select_device().type in ("cuda", "mps") + except Exception: # pragma: no cover + return False + + +# ── frame preparation ──────────────────────────────────────────────────────── + +class PreparedFrame(NamedTuple): + """A frame ready to featurise, plus the validity mask the caller must honour. + + ``image`` + float32, non-finite pixels replaced (see :func:`prepare_frame`), and + robustly standardised when the spec asks for it. + ``valid`` + bool, True where the *source* pixel was finite. The classifier forces + zero foreground probability outside this — plan trap 2. + """ + + image: np.ndarray + valid: np.ndarray + + +def _robust_stats(values: np.ndarray) -> tuple[float, float]: + """``(centre, spread)`` — median and IQR/1.349, from a strided subsample. + + IQR/1.349 is the normal-consistent robust sigma. Robust rather than + mean/std because a frame *full of particles* has a heavy bright tail, and a + mean/std standardisation would then move the background level around with the + particle coverage — so the same physical background would present differently + at t=0 and t=end, which is exactly the drift a learned head must not see. + """ + v = values + if v.size > _STAT_SAMPLE_MAX: + v = v[:: int(np.ceil(v.size / _STAT_SAMPLE_MAX))] + centre = float(np.median(v)) + q1, q3 = np.percentile(v, [25.0, 75.0]) + spread = float(q3 - q1) / 1.349 + if not (spread > 0): + # Constant (or near-constant) frame: fall back to the std, then to 1 so + # the standardisation is a no-op rather than a division by zero. + spread = float(np.std(v)) or 1.0 + return centre, spread + + +def prepare_frame(frame, spec: FeatureSpec | None = None) -> PreparedFrame: + """Fill non-finite pixels, optionally standardise, and report validity. + + Parameters + ---------- + frame + 2-D array. May contain NaN (a drift-corrected border does). + spec + Only ``normalize_frame`` is consulted. + + Returns + ------- + PreparedFrame + + Notes + ----- + Non-finite pixels are filled with the finite **minimum**, not with zero and + not with the mean: the padding has to read as background, and the minimum is + the one value that cannot threshold or classify as a particle. This mirrors + :func:`spyde.particles.classical._prepare` deliberately — two engines that + disagree about what the padding *is* would disagree about the frame border + for no reason a user could see. + + The robust statistics are computed over finite pixels **only**, before the + fill. Filling first would let a large NaN border pull the median down and + rescale the whole frame by how much of it was padding. + """ + spec = spec or FeatureSpec() + img = np.asarray(frame, dtype=np.float32) + if img.ndim != 2: + raise ValueError(f"frame must be 2-D; got shape {img.shape}") + if min(img.shape) < 4: + raise ValueError( + f"frame must be at least 4x4 to filter; got {img.shape}") + + valid = np.isfinite(img) + if valid.all(): + finite = img.reshape(-1) + else: + img = img.copy() + finite = img[valid] + if finite.size == 0: + raise ValueError("frame has no finite pixels") + img[~valid] = finite.min() + + if spec.normalize_frame: + centre, spread = _robust_stats(finite) + img = (img - np.float32(centre)) / np.float32(spread) + + return PreparedFrame(np.ascontiguousarray(img, dtype=np.float32), valid) + + +def _as_prepared(frame, spec: FeatureSpec) -> PreparedFrame: + return frame if isinstance(frame, PreparedFrame) else prepare_frame(frame, spec) + + +# ── separable convolution primitives ───────────────────────────────────────── + +def _gauss_radius(sigma: float) -> int: + """Kernel half-width, matching ``scipy.ndimage``'s ``int(truncate*sd + 0.5)``.""" + return max(1, int(_TRUNCATE * float(sigma) + 0.5)) + + +def _gauss_kernel(torch, sigma: float, radius: int, device, dtype): + x = torch.arange(-radius, radius + 1, device=device, dtype=dtype) + k = torch.exp(-0.5 * (x / float(sigma)) ** 2) + return k / k.sum() + + +def _pad_edges(t, ry: int, rx: int): + """Pad ``(1, 1, h, w)`` by *ry* rows and *rx* columns, reflect where legal. + + Padding is torch's ``reflect``, which is ``scipy.ndimage``'s ``mirror`` (NOT + scipy's ``reflect``, which duplicates the edge sample); the parity test + against scipy passes ``mode="mirror"`` for exactly this reason. + + ``reflect`` requires the pad to be strictly smaller than the dimension, and + the kernels here are not small: a sigma-8 gaussian has radius 32 and a + 19x19 membrane patch radius 9, either of which exceeds a small frame. So the + reflect is taken as far as it is legal and the remainder is **replicated**. + Clamping the kernel radius instead was the first implementation, and it is + worse in a way that is invisible until it matters: it silently applies a + *different, narrower* filter than the one the FeatureSpec asked for, so the + same spec means different things on different frame sizes — and a saved recipe + would then not reproduce on a crop. + """ + import torch.nn.functional as F + h, w = int(t.shape[-2]), int(t.shape[-1]) + ry_ok, rx_ok = min(ry, h - 1), min(rx, w - 1) + if ry_ok or rx_ok: + t = F.pad(t, (rx_ok, rx_ok, ry_ok, ry_ok), mode="reflect") + over_y, over_x = ry - ry_ok, rx - rx_ok + if over_y or over_x: + t = F.pad(t, (over_x, over_x, over_y, over_y), mode="replicate") + return t + + +def _sep_conv(torch, t, ky, kx): + """Separable correlation of ``(1, 1, h, w)`` *t* with 1-D kernels. + + ``F.conv2d`` is a correlation, not a convolution, so a kernel is applied as + written — no flip. Either kernel may be ``None`` to skip that axis. + """ + import torch.nn.functional as F + if ky is not None: + t = _pad_edges(t, (ky.numel() - 1) // 2, 0) + t = F.conv2d(t, ky.view(1, 1, -1, 1)) + if kx is not None: + t = _pad_edges(t, 0, (kx.numel() - 1) // 2) + t = F.conv2d(t, kx.view(1, 1, 1, -1)) + return t + + +_MEMBRANE_CACHE: dict[tuple[int, int, int], np.ndarray] = {} + + +def _membrane_kernels(patch: int, thickness: int, rotations: int) -> np.ndarray: + """``(rotations, patch, patch)`` line kernels spanning 180°, sum-normalised. + + Built with ``scipy.ndimage.rotate`` once per configuration and cached. The + rotation is a spline resample of a tiny image, so this is not expensive + (measured 0.98 ms for the default twelve 19x19 kernels), but it sits on the + interaction path — every retrain re-featurises every labelled frame — and 1 ms + per frame has no reason to recur. + + Sum-normalised so a projection stays in the same units as the intensity + channel. Weka does not normalise; unnormalised, the ``sum`` projection is + ``patch`` times larger than every other channel, which makes the head's first + layer spend its capacity on rescaling. + """ + key = (int(patch), int(thickness), int(rotations)) + cached = _MEMBRANE_CACHE.get(key) + if cached is not None: + return cached + + from scipy.ndimage import rotate + + base = np.zeros((patch, patch), dtype=np.float32) + c = patch // 2 + half = max(1, int(thickness)) // 2 + base[:, c - half: c + half + 1] = 1.0 + + out = [] + for i in range(rotations): + k = base if i == 0 else rotate( + base, 180.0 * i / rotations, reshape=False, order=1, + mode="constant", cval=0.0) + k = np.clip(np.asarray(k, dtype=np.float32), 0.0, None) + s = float(k.sum()) + out.append(k / s if s > 0 else k) + stacked = np.stack(out) + _MEMBRANE_CACHE[key] = stacked + return stacked + + +# ── one pass over a frame (or a row band) ──────────────────────────────────── + +class _Pass: + """Shared intermediates for one image, computed at most once each. + + This is where "one pass over the frame, not one pass per feature" actually + lives. Anything more than one channel needs — the blur at a sigma, that + blur's three second derivatives, an unfolded rank window, the rotated + membrane responses — is memoised here, so the 36 default channels cost 5 + gaussian blurs, 5 derivative triples, 2 rank passes and nothing else. + """ + + def __init__(self, image, spec: FeatureSpec, device): + torch = import_torch() + self._torch = torch + self.spec = spec + self.device = device + arr = np.ascontiguousarray(image, dtype=np.float32) + self.h, self.w = int(arr.shape[0]), int(arr.shape[1]) + self.img = torch.as_tensor(arr, device=device) + self._t = self.img.view(1, 1, self.h, self.w) + self._blur: dict[float, Any] = {} + self._second: dict[float, tuple] = {} + self._rank: dict[tuple[int, str], Any] = {} + self._membrane: Any = None + + # -- primitives ---------------------------------------------------------- + + def blur(self, sigma: float): + """Gaussian blur at *sigma* as ``(1, 1, h, w)``. Memoised.""" + got = self._blur.get(sigma) + if got is None: + torch = self._torch + k = _gauss_kernel(torch, sigma, _gauss_radius(sigma), self.device, + self.img.dtype) + got = _sep_conv(torch, self._t, k, k) + self._blur[sigma] = got + return got + + def second(self, sigma: float): + """``(dyy, dxx, dxy)`` of the blur at *sigma*. Memoised. + + Second differences ``[1, -2, 1]`` and a cross term + ``[-½, 0, ½] ⊗ [-½, 0, ½]``. **Not** skimage's ``hessian_matrix``, which + applies ``np.gradient`` twice and therefore uses a 5-tap + ``[¼, 0, -½, 0, ¼]`` — a wider, softer stencil that skips the immediate + neighbours entirely. Both are valid discrete Hessians; the compact one is + used because it keeps the fine-scale response localised, which is what the + smallest sigmas are in the set for. Exact skimage parity is not a + requirement here — a channel is an input to a learned head, not a + published measurement. + """ + got = self._second.get(sigma) + if got is None: + torch = self._torch + b = self.blur(sigma) + d2 = torch.tensor([1.0, -2.0, 1.0], device=self.device, + dtype=self.img.dtype) + d1 = torch.tensor([-0.5, 0.0, 0.5], device=self.device, + dtype=self.img.dtype) + dyy = _sep_conv(torch, b, d2, None) + dxx = _sep_conv(torch, b, None, d2) + dxy = _sep_conv(torch, b, d1, d1) + got = (dyy, dxx, dxy) + self._second[sigma] = got + return got + + def rank(self, radius: int, stat: str): + """Median / minimum / maximum over a ``(2r+1)²`` window. Memoised.""" + got = self._rank.get((int(radius), stat)) + if got is None: + self._compute_rank(int(radius)) + got = self._rank[(int(radius), stat)] + return got + + def _compute_rank(self, radius: int) -> None: + """ONE ``unfold`` per radius serves all three rank statistics. + + ``unfold`` materialises ``(2r+1)²·h·w`` floats — 1.7 GB for r=2 on a + 4096² frame — which is why the whole stack is banded (:func:` + map_feature_bands`); the window is dropped as soon as the enabled + statistics are reduced out of it, so only one is ever alive. + + ``max_pool2d(stride=1)`` was the obvious alternative for min/max and is + **much slower**, which is the opposite of what its fused-reduction + implementation suggests. Measured on a 512² frame, CPU:: + + r=1 max_pool2d 18.4 ms unfold amin+amax 2.4 ms + r=2 max_pool2d 46.9 ms unfold amin+amax 5.3 ms + + torch's pooling kernels are tuned for the strided, downsampling case; at + stride 1 they re-read every window from scratch. Making the window + separable (two 1-D pools — a square min/max genuinely is separable, and + the results are bit-identical) only got r=2 from 46.9 to 23.7 ms, still + 4x the unfold. And the unfold is *shared* with the median, which needs the + window materialised regardless, so the whole rank family costs one pass: + 170 ms → 27 ms for the default two radii. + """ + import torch.nn.functional as F + spec = self.spec + k = 2 * int(radius) + 1 + u = F.unfold(_pad_edges(self._t, radius, radius), + kernel_size=(k, k)) # (1, k*k, h*w) + shape = (1, 1, self.h, self.w) + if spec.median: + self._rank[(radius, "median")] = u.median(dim=1).values.view(shape) + if spec.minimum: + self._rank[(radius, "minimum")] = u.amin(dim=1).view(shape) + if spec.maximum: + self._rank[(radius, "maximum")] = u.amax(dim=1).view(shape) + + def membrane(self): + """``(1, R, h, w)`` responses to the rotated line kernels. Memoised.""" + if self._membrane is None: + torch = self._torch + import torch.nn.functional as F + spec = self.spec + k = _membrane_kernels(spec.membrane_patch, spec.membrane_thickness, + spec.membrane_rotations) + weight = torch.as_tensor(k, device=self.device, + dtype=self.img.dtype).unsqueeze(1) + r = spec.membrane_patch // 2 + self._membrane = F.conv2d(_pad_edges(self._t, r, r), weight) + return self._membrane + + # -- channels ------------------------------------------------------------ + + def channel(self, family: str, args: tuple): + """One ``(h, w)`` channel tensor for a :func:`_channel_plan` entry.""" + if family == "intensity": + return self.img + if family == "gaussian": + return self.blur(args[0])[0, 0] + if family == "dog": + return (self.blur(args[0]) - self.blur(args[1]))[0, 0] + if family == "sobel": + return self._sobel(args[0]) + if family == "laplacian": + dyy, dxx, _ = self.second(args[0]) + return (dyy + dxx)[0, 0] + if family in ("hessian_major", "hessian_minor"): + major, minor = self._hessian_eigs(args[0]) + return major if family == "hessian_major" else minor + if family in ("median", "minimum", "maximum"): + return self.rank(args[0], family)[0, 0] + if family == "membrane": + return self._membrane_projection(args[0]) + raise ValueError(f"unknown feature family {family!r}") # pragma: no cover + + def _sobel(self, sigma: float): + """Sobel gradient magnitude of the blur at *sigma*. + + skimage's kernels exactly: derivative ``[1, 0, -1]`` against smoothing + ``[1, 2, 1]/4``, and the magnitude divided by ``sqrt(2)`` as + ``skimage.filters.sobel`` does. The constant is irrelevant once the head + standardises its inputs, but matching it means a reader can compare a + channel against skimage without wondering about a factor. + """ + torch = self._torch + b = self.blur(sigma) + der = torch.tensor([1.0, 0.0, -1.0], device=self.device, + dtype=self.img.dtype) + smo = torch.tensor([1.0, 2.0, 1.0], device=self.device, + dtype=self.img.dtype) / 4.0 + gy = _sep_conv(torch, b, der, smo) + gx = _sep_conv(torch, b, smo, der) + return (torch.sqrt(gy * gy + gx * gx) / math.sqrt(2.0))[0, 0] + + def _hessian_eigs(self, sigma: float): + """``(major, minor)`` eigenvalues of the 2×2 Hessian, signed. + + Analytic, not ``linalg.eigvalsh``: for a symmetric 2×2 the eigenvalues are + ``tr/2 ± sqrt((tr/2)² - det)``, a handful of elementwise ops over the + image. ``eigvalsh`` batched over h·w 2×2 matrices was measured at 125 ms + on a 512² frame against **0.32 ms** for the closed form — a factor of 390, + so this is not an optimisation but the only viable option. + + "Major"/"minor" are by signed value (major ≥ minor), which is what + distinguishes a bright blob (both strongly negative) from a ridge (one + negative, one near zero). Ordering by |value| would merge those two. + """ + torch = self._torch + dyy, dxx, dxy = self.second(sigma) + half_tr = 0.5 * (dyy + dxx) + det = dyy * dxx - dxy * dxy + disc = torch.sqrt(torch.clamp(half_tr * half_tr - det, min=0.0)) + return (half_tr + disc)[0, 0], (half_tr - disc)[0, 0] + + def _membrane_projection(self, projection: str): + resp = self.membrane()[0] # (R, h, w) + if projection == "sum": + return resp.sum(dim=0) + if projection == "mean": + return resp.mean(dim=0) + if projection == "std": + return resp.std(dim=0, unbiased=False) + if projection == "median": + return resp.median(dim=0).values + if projection == "max": + return resp.amax(dim=0) + if projection == "min": + return resp.amin(dim=0) + raise ValueError( # pragma: no cover + f"unknown membrane projection {projection!r}") + + +def _band_stack(image, spec: FeatureSpec, device): + """``(C, h, w)`` float32 tensor for one image (or row band).""" + torch = import_torch() + p = _Pass(image, spec, device) + plan = _channel_plan(spec) + out = torch.empty((len(plan), p.h, p.w), device=device, dtype=torch.float32) + for i, (family, _name, args) in enumerate(plan): + out[i] = p.channel(family, args) + return out + + +# ── banding ────────────────────────────────────────────────────────────────── + +def band_rows_for(spec: FeatureSpec, width: int, + budget_bytes: int = BAND_BYTES) -> int: + """How many rows one band should cover, given the per-band memory budget. + + The working set is bigger than the output stack — the pass also holds one + blur and three derivative images per sigma, plus an unfolded median window — + so the divisor counts those too rather than only ``n_channels``. Getting this + wrong does not produce a wrong answer, only a larger peak allocation, but the + plan's frame size leaves no headroom to be casual about it. + """ + working = spec.n_channels + 4 * len(spec.sigmas) + 8 + if (spec.median or spec.minimum or spec.maximum) and spec.rank_radii: + # One unfolded window is alive at a time (see `_Pass._compute_rank`), so + # it is the LARGEST radius that sets the peak, not the sum over radii. + working += (2 * max(spec.rank_radii) + 1) ** 2 + rows = int(max(1, budget_bytes // max(1, working * int(width) * 4))) + return max(rows, 4 * spec.halo) + + +def map_feature_bands( + frame, + spec: FeatureSpec | None = None, + *, + device=None, + fn: Callable[[int, int, Any], None], + band_rows: int | None = None, +) -> None: + """Compute the stack in row bands and hand each to *fn*. + + Calls ``fn(y0, y1, stack)`` where ``stack`` is a ``(C, y1-y0, W)`` tensor for + output rows ``y0:y1``. Each band is featurised with ``spec.halo`` extra rows + of real data above and below and then cropped, so **the banded result equals + the unbanded one exactly** for every row — the halo replaces what reflect + padding would otherwise have invented at a band boundary. Pinned by + ``test_particles_scribble.py::TestBanding``. + + A callback rather than a generator on purpose: this holds the process-wide + accelerator lock (:func:`spyde.device_lock.accelerator_lock`) for the whole + traversal, and a generator abandoned by its consumer would hold that lock + until garbage collection. + """ + spec = spec or FeatureSpec() + prepared = _as_prepared(frame, spec) + img = prepared.image + h, w = img.shape + if device is None: + device = select_device() + + halo = spec.halo + rows = int(band_rows or band_rows_for(spec, w)) + + with accelerator_lock(device): + if rows >= h: + fn(0, h, _band_stack(img, spec, device)) + return + y = 0 + while y < h: + y1 = min(h, y + rows) + ky0, ky1 = max(0, y - halo), min(h, y1 + halo) + stack = _band_stack(img[ky0:ky1], spec, device) + fn(y, y1, stack[:, y - ky0: y1 - ky0]) + y = y1 + + +def feature_tensor(frame, spec: FeatureSpec | None = None, *, device=None): + """``(C, H, W)`` float32 torch tensor of every channel in *spec*. + + Parameters + ---------- + frame + 2-D array, or a :class:`PreparedFrame` from :func:`prepare_frame`. + device + Torch device; ``None`` calls :func:`select_device`. + + Notes + ----- + This materialises the whole stack: ``C·H·W·4`` bytes, which is 2.4 GB for the + 36 default channels at 4096². That is fine for the interactive path (one + displayed frame, and the display is tiled well below that) and wrong for a + batch run over big frames — those go through :func:`map_feature_bands` or + :func:`sample_features`, which never hold more than one band. + """ + spec = spec or FeatureSpec() + prepared = _as_prepared(frame, spec) + torch = import_torch() + if device is None: + device = select_device() + h, w = prepared.image.shape + + # The allocation is a device submission too, so it goes inside the lock — the + # reentrant acquisition in `map_feature_bands` is free. + with accelerator_lock(device): + out = torch.empty((spec.n_channels, h, w), device=device, + dtype=torch.float32) + + def take(y0: int, y1: int, stack) -> None: + out[:, y0:y1] = stack + + map_feature_bands(prepared, spec, device=device, fn=take) + return out + + +def feature_stack(frame, spec: FeatureSpec | None = None, *, + device=None) -> np.ndarray: + """:func:`feature_tensor` as a ``(C, H, W)`` numpy array. + + The door the sklearn RandomForest parity reference comes through: the gate is + "same labels, same features, does the torch head agree with the forest", so + both sides must read the *identical* channels, not merely equivalent ones. + """ + if device is None: + device = select_device() + # The device->host copy is a submission as well, hence the (reentrant) lock. + with accelerator_lock(device): + return feature_tensor(frame, spec, device=device).detach().cpu().numpy() + + +def sample_features(frame, index, spec: FeatureSpec | None = None, *, + device=None): + """``(k, C)`` float32 tensor of the stack sampled at *k* pixels. + + Parameters + ---------- + index + Flat pixel indices into a ``(H, W)`` frame, ``(k,)`` integer; or ``(k, 2)`` + ``(y, x)`` pairs. + + Notes + ----- + This is how training reads its data, and why training does not care how big + the frame is: the stack is produced band by band and only the sampled rows + survive, so the peak allocation is one band plus ``k·C`` floats. Scribbles are + thousands of pixels, so ``k·C`` is under a megabyte however large the frame. + """ + spec = spec or FeatureSpec() + prepared = _as_prepared(frame, spec) + torch = import_torch() + if device is None: + device = select_device() + + h, w = prepared.image.shape + idx = np.asarray(index) + if idx.ndim == 2 and idx.shape[1] == 2: + flat = idx[:, 0].astype(np.int64) * w + idx[:, 1].astype(np.int64) + else: + flat = idx.reshape(-1).astype(np.int64) + if flat.size and (flat.min() < 0 or flat.max() >= h * w): + raise IndexError( + f"pixel index outside 0..{h * w - 1} for a {h}x{w} frame") + + ys, xs = np.divmod(flat, w) + with accelerator_lock(device): # see `feature_tensor` — allocation too + out = torch.empty((flat.size, spec.n_channels), device=device, + dtype=torch.float32) + + def take(y0: int, y1: int, stack) -> None: + sel = np.flatnonzero((ys >= y0) & (ys < y1)) + if not sel.size: + return + ty = torch.as_tensor(ys[sel] - y0, device=device, dtype=torch.long) + tx = torch.as_tensor(xs[sel], device=device, dtype=torch.long) + rows = torch.as_tensor(sel, device=device, dtype=torch.long) + out[rows] = stack[:, ty, tx].t() + + map_feature_bands(prepared, spec, device=device, fn=take) + return out + + +def feature_names(spec: FeatureSpec | None = None) -> list[str]: + """Channel names for *spec*, in the order :func:`feature_stack` returns them.""" + return (spec or FeatureSpec()).channel_names() diff --git a/spyde/particles/scribble.py b/spyde/particles/scribble.py new file mode 100644 index 00000000..4b0a70f9 --- /dev/null +++ b/spyde/particles/scribble.py @@ -0,0 +1,1050 @@ +""" +scribble.py — the scribble-trained pixel classifier. Plan step B3, the workhorse. + +The user paints a few strokes on a frame; this learns *their* data and produces a +per-frame foreground probability map that :func:`spyde.particles.classical. +split_instances` turns into instances. Same shared downstream stage as the +classical and prompt engines — the only thing that changes is how the first box +in the plan's §0.2 pipeline gets filled. + +Why this and not a threshold +---------------------------- +Plan §0.9 makes detection **sensitivity** the priority: missing a particle's first +appearance destroys the nucleation event, which is the most interesting thing in +the movie. No single global threshold spans a nucleation sequence — one that +catches a 3σ particle at t=0 is not the one that works at t=end. Measured on the +``particle_movie()`` fixture, whose two ``p_faint`` probes are exactly this case: +the classical engine at its default sensitivity finds **0 of 2** +(``test_particle_movie_fixture.py::test_default_sensitivity_misses_the_faint_probes`` +pins that), and this classifier trained on eleven scribbles finds **2 of 2** while +keeping all seven bright ones. + +Design decisions that are not obvious +------------------------------------- +**Multi-class, and class 0 is not special.** Classes are user-defined +``(id, name, colour)`` triples and a softmax head costs nothing over a sigmoid. In +EM "background" is genuinely two or three different things — carbon film, vacuum, +beam-stop — and forcing them into one class makes the head spend its capacity +proving they are the same rather than separating either from a particle. Which +classes count as foreground is a per-class flag (:attr:`ScribbleClass.particle`), +so several particle *phases* can be labelled separately and still sum into one +probability map. + +**Labels accumulate across frames, sparsely.** :class:`LabelStore` is keyed by +frame index, so painting on frame 0 and again on frame 400 trains one model from +both. It stores flat pixel indices, not label images: a scribble is a few thousand +pixels, and a dense ``int16`` map is 32 MB *per labelled frame* at 4096² — 320 MB +for ten labelled frames, to hold ~50 000 useful values. + +**A torch MLP, with sklearn's RandomForest kept as the parity reference.** One +hidden layer, class-balanced cross-entropy. The forest is what ParticleSpy and +ilastik use and it is a *better* classifier out of the box on this kind of +tabular problem — but it cannot live on the GPU next to the feature stack, and +the interaction budget is train+apply under 1 s on the displayed frame. So the +MLP is the shipped head and agreement with the forest on identical labels and +identical features is the acceptance gate +(``test_particles_scribble.py::TestRandomForestParity``). + +**The NaN border is forced to zero probability.** Plan trap 2: a drift-corrected +frame has a NaN-padded border, and segmentation that ignores it invents a large +"particle" along the edge which then nucleates a spurious track. +:func:`spyde.particles.features.prepare_frame` reports validity and every +prediction here is masked by it. +""" +from __future__ import annotations + +import json +import logging +import math +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Sequence + +import numpy as np + +from spyde.device_lock import accelerator_lock +from spyde.particles.features import ( + FeatureSpec, + import_torch, + map_feature_bands, + prepare_frame, + sample_features, + select_device, +) + +log = logging.getLogger(__name__) + +FORMAT_VERSION = 1 + +#: Sentinel for "no class" in a label map or a predicted label image. -1 rather +#: than 0 because class id 0 is a perfectly ordinary user class here. +UNLABELLED = -1 + +#: Default classes for a fresh session: one particle class and two backgrounds. +#: Two backgrounds and not one because that is what EM actually looks like, and +#: because the second one is free — see the module docstring. Colours are the +#: renderer's accent family so the floating brush strip (plan B0) needs no +#: palette of its own. +DEFAULT_CLASSES: tuple[tuple[int, str, str, bool], ...] = ( + (0, "particle", "#f9a03f", True), + (1, "support film", "#89b4fa", False), + (2, "vacuum", "#585b70", False), +) + + +# ── classes and the label store ────────────────────────────────────────────── + +@dataclass(frozen=True) +class ScribbleClass: + """One user-defined class. + + Parameters + ---------- + id + Stable integer key. Referenced by :class:`LabelStore` and by the trained + head's output column order, so it must not be reused after a delete. + name, colour + For the caret's class list and the in-canvas brush strip. ``colour`` is a + CSS hex string — the renderer's units, not the backend's. + particle + Whether this class counts toward the foreground probability map. Several + classes may set it (two particle phases, say) and their probabilities sum. + """ + + id: int + name: str + colour: str = "#ffffff" + particle: bool = False + + def to_dict(self) -> dict[str, Any]: + return {"id": int(self.id), "name": self.name, "colour": self.colour, + "particle": bool(self.particle)} + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "ScribbleClass": + return cls(int(d["id"]), str(d["name"]), str(d.get("colour", "#ffffff")), + bool(d.get("particle", False))) + + +def default_classes() -> list[ScribbleClass]: + """A fresh copy of :data:`DEFAULT_CLASSES`.""" + return [ScribbleClass(i, n, c, p) for i, n, c, p in DEFAULT_CLASSES] + + +@dataclass(eq=False) +class LabelStore: + """Scribble labels, keyed by frame index and accumulating across frames. + + Painted pixels are held as flat indices into a ``(h, w)`` frame plus a + parallel class-id array — see the module docstring for why this is not a dense + label image. Repainting a pixel *replaces* its class (last write wins), which + is what a user expects from a brush, and erasing removes it entirely rather + than assigning it to a background class. + + Parameters + ---------- + frame_shape + ``(h, w)``. Fixed for the life of the store: a flat index means nothing + without it, and silently accepting a differently-shaped frame would + scatter the labels across the image. + classes + The class list. Mutate through :meth:`add_class` / :meth:`remove_class` + so ids stay unique and a removed class takes its pixels with it. + + Notes + ----- + ``eq=False``: the generated ``__eq__`` would compare a dict of numpy arrays + and raise on the ambiguous truth value. Compare :meth:`to_dict` instead, which + is what the round-trip test does. + """ + + frame_shape: tuple[int, int] + classes: list[ScribbleClass] = field(default_factory=default_classes) + #: frame index -> (flat indices int64, class ids int16) + _frames: dict[int, tuple[np.ndarray, np.ndarray]] = field( + default_factory=dict, repr=False) + + def __post_init__(self) -> None: + self.frame_shape = (int(self.frame_shape[0]), int(self.frame_shape[1])) + ids = [c.id for c in self.classes] + if len(set(ids)) != len(ids): + raise ValueError(f"duplicate class ids: {ids}") + + # -- classes ------------------------------------------------------------- + + @property + def class_ids(self) -> list[int]: + return [c.id for c in self.classes] + + def class_by_id(self, cid: int) -> ScribbleClass: + for c in self.classes: + if c.id == int(cid): + return c + raise KeyError( + f"no class with id {cid}; have {self.class_ids}") + + def add_class(self, name: str, colour: str = "#ffffff", *, + particle: bool = False, id: int | None = None) -> ScribbleClass: + """Append a class. Its id is one past the current maximum unless given.""" + cid = int(id) if id is not None else ( + max(self.class_ids) + 1 if self.classes else 0) + if cid in self.class_ids: + raise ValueError(f"class id {cid} already exists") + c = ScribbleClass(cid, str(name), str(colour), bool(particle)) + self.classes.append(c) + return c + + def remove_class(self, cid: int) -> None: + """Drop a class **and every pixel labelled with it**. + + Leaving orphaned pixels behind would train a head with a column for a + class the user has deleted, and the pixel counts in the caret would stop + adding up to the labelled total — which is the one number that tells the + user a class is under-trained. + """ + cid = int(cid) + self.class_by_id(cid) # raises if unknown + self.classes = [c for c in self.classes if c.id != cid] + for t in list(self._frames): + idx, cls = self._frames[t] + keep = cls != cid + if keep.all(): + continue + if keep.any(): + self._frames[t] = (idx[keep], cls[keep]) + else: + del self._frames[t] + + # -- painting ------------------------------------------------------------ + + def _flatten(self, where) -> np.ndarray: + """Coerce a mask / ``(k, 2)`` yx / flat-index argument to flat indices.""" + h, w = self.frame_shape + a = np.asarray(where) + if a.dtype == bool: + if a.shape != (h, w): + raise ValueError( + f"mask shape {a.shape} != frame_shape {self.frame_shape}") + return np.flatnonzero(a.reshape(-1)).astype(np.int64) + if a.ndim == 2 and a.shape[1] == 2: + ys = np.rint(a[:, 0]).astype(np.int64) + xs = np.rint(a[:, 1]).astype(np.int64) + inside = (ys >= 0) & (ys < h) & (xs >= 0) & (xs < w) + return (ys[inside] * w + xs[inside]) + flat = a.reshape(-1).astype(np.int64) + return flat[(flat >= 0) & (flat < h * w)] + + def paint(self, t: int, where, class_id: int) -> int: + """Label pixels on frame *t*. Returns how many pixels the store now holds + for that frame. + + *where* may be a boolean mask the shape of the frame, a ``(k, 2)`` + ``(y, x)`` array, or flat indices. Out-of-frame coordinates are dropped + rather than raising — a brush stroke that runs off the edge is normal. + """ + cid = int(class_id) + self.class_by_id(cid) + t = int(t) + add = self._flatten(where) + if not add.size: + return len(self._frames.get(t, (np.empty(0),))[0]) + + cur_idx, cur_cls = self._frames.get( + t, (np.zeros(0, np.int64), np.zeros(0, np.int16))) + idx = np.concatenate([cur_idx, add]) + cls = np.concatenate([cur_cls, np.full(add.size, cid, np.int16)]) + self._frames[t] = _dedup_last_wins(idx, cls) + return int(self._frames[t][0].size) + + def paint_disc(self, t: int, y: float, x: float, radius: float, + class_id: int) -> int: + """Label a filled disc — one brush dab, and what a click paints.""" + return self.paint(t, _disc_indices(self.frame_shape, y, x, radius), + class_id) + + def paint_stroke(self, t: int, points: Sequence[Sequence[float]], + class_id: int, *, brush: float = 3.0) -> int: + """Label a brush stroke: a polyline of ``(y, x)`` points, *brush* px wide. + + This is the door the anyplotlib brush widget (plan B0) comes through, and + the coordinates arrive in **image pixels** with no scale or offset applied + — plan trap 6. Do not multiply by the axis scale on the way in. + + The polyline is densified to half-pixel steps before dabbing, because the + widget emits a pointer sample per frame and a fast stroke can jump 20 px + between them; dabbing only at the samples leaves a dotted line. + """ + pts = np.asarray(points, dtype=np.float64).reshape(-1, 2) + if not len(pts): + return int(self._frames.get(t, (np.empty(0),))[0].size) + r = max(0.5, float(brush) / 2.0) + dense = [pts[0]] + for a, b in zip(pts, pts[1:]): + n = int(np.ceil(np.hypot(*(b - a)) * 2.0)) + if n > 1: + dense.extend(a + (b - a) * (np.arange(1, n + 1) / n)[:, None]) + else: + dense.append(b) + idx = np.unique(np.concatenate( + [_disc_indices(self.frame_shape, py, px, r) for py, px in dense])) + return self.paint(t, idx, class_id) + + def erase(self, t: int, where) -> int: + """Unlabel pixels on frame *t* (the brush widget's eraser).""" + t = int(t) + got = self._frames.get(t) + if got is None: + return 0 + idx, cls = got + drop = np.isin(idx, self._flatten(where)) + if drop.all(): + del self._frames[t] + return 0 + self._frames[t] = (idx[~drop], cls[~drop]) + return int(self._frames[t][0].size) + + def clear_frame(self, t: int) -> None: + self._frames.pop(int(t), None) + + def clear(self) -> None: + self._frames.clear() + + # -- inspection ---------------------------------------------------------- + + def labelled_frames(self) -> list[int]: + """Frame indices carrying labels, ascending — the caret's revisit list.""" + return sorted(self._frames) + + def at(self, t: int) -> tuple[np.ndarray, np.ndarray]: + """``(flat_indices, class_ids)`` for frame *t*; empty arrays if none.""" + return self._frames.get( + int(t), (np.zeros(0, np.int64), np.zeros(0, np.int16))) + + def label_map(self, t: int) -> np.ndarray: + """Frame *t*'s labels as a dense ``(h, w)`` int16 map, :data:`UNLABELLED` + where unpainted. Built on demand for display; never stored (see the + module docstring).""" + idx, cls = self.at(t) + out = np.full(self.frame_shape, UNLABELLED, dtype=np.int16) + if idx.size: + out.reshape(-1)[idx] = cls + return out + + def counts(self) -> dict[int, int]: + """Labelled pixels per class id, across every frame. + + This is what the caret's class list shows, and it is how a user notices a + class is under-trained — so classes with zero pixels are present in the + result rather than absent from it. + """ + out = {c.id: 0 for c in self.classes} + for idx, cls in self._frames.values(): + if not cls.size: + continue + vals, n = np.unique(cls, return_counts=True) + for v, k in zip(vals.tolist(), n.tolist()): + out[int(v)] = out.get(int(v), 0) + int(k) + return out + + def __len__(self) -> int: + return int(sum(idx.size for idx, _ in self._frames.values())) + + @property + def n_classes_used(self) -> int: + return int(sum(1 for v in self.counts().values() if v > 0)) + + # -- serialisation ------------------------------------------------------- + + def to_dict(self) -> dict[str, Any]: + """JSON-safe, so a session's scribbles survive a save/reload. + + Indices go out as lists. That is fine at scribble scale (a few thousand + per frame) and is what keeps the format inspectable; a binary side-car + would only pay off at a density no human paints. + """ + return { + "frame_shape": list(self.frame_shape), + "classes": [c.to_dict() for c in self.classes], + "frames": {str(t): {"index": idx.tolist(), "class": cls.tolist()} + for t, (idx, cls) in sorted(self._frames.items())}, + } + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "LabelStore": + store = cls( + frame_shape=tuple(d["frame_shape"]), + classes=[ScribbleClass.from_dict(c) for c in d["classes"]], + ) + for key, blk in (d.get("frames") or {}).items(): + store._frames[int(key)] = ( + np.asarray(blk["index"], dtype=np.int64), + np.asarray(blk["class"], dtype=np.int16), + ) + return store + + +def _dedup_last_wins(idx: np.ndarray, cls: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Collapse repeated indices, keeping the LAST class written to each. + + ``np.unique`` keeps the *first* occurrence of each value, so the arrays are + reversed going in and the result flipped back — a repaint over an existing + stroke has to change its class, not be ignored. + """ + if idx.size < 2: + return idx.astype(np.int64), cls.astype(np.int16) + ridx, rcls = idx[::-1], cls[::-1] + uniq, first = np.unique(ridx, return_index=True) + return uniq.astype(np.int64), rcls[first].astype(np.int16) + + +def _disc_indices(shape: tuple[int, int], y: float, x: float, + radius: float) -> np.ndarray: + """Flat indices of a filled disc, clipped to the frame. + + Built from the bounding box rather than a full-frame distance map: a brush dab + is a handful of pixels and a 4096² ``mgrid`` per dab would make painting + unusable. + """ + h, w = int(shape[0]), int(shape[1]) + r = max(0.5, float(radius)) + y0, y1 = max(0, int(math.floor(y - r))), min(h - 1, int(math.ceil(y + r))) + x0, x1 = max(0, int(math.floor(x - r))), min(w - 1, int(math.ceil(x + r))) + if y1 < y0 or x1 < x0: + return np.zeros(0, np.int64) + ys = np.arange(y0, y1 + 1)[:, None] + xs = np.arange(x0, x1 + 1)[None, :] + inside = (ys - float(y)) ** 2 + (xs - float(x)) ** 2 <= r * r + yy, xx = np.nonzero(inside) + return ((yy + y0).astype(np.int64) * w + (xx + x0).astype(np.int64)) + + +# ── the SAM/prompt bootstrap (plan §0.4) ───────────────────────────────────── + +def masks_to_labels( + masks, + *, + t: int = 0, + frame_shape: tuple[int, int] | None = None, + store: LabelStore | None = None, + particle_class: int = 0, + background_class: int = 1, + gap: int = 2, + background_dilation: int = 8, + erode: int = 1, +) -> LabelStore: + """Turn promptable-segmentation masks into scribble labels. Plan §0.4. + + "Click four particles with the prompt model. Those masks — plus their dilated + surroundings as background — become the scribble classifier's training labels. + Train, apply to all N frames. No painting at all." This is that handoff, and + it is a first-class feature rather than a convenience: it is what makes the + dense result adapted to the data instead of to COCO. + + Parameters + ---------- + masks + A boolean ``(h, w)`` mask, a sequence of them, or an ``(n, h, w)`` array. + store + Accumulate into an existing store (so several prompt clicks on several + frames build one training set). A new one is created when omitted, which + needs *frame_shape* or at least one mask to infer it from. + gap + Pixels either side of the mask boundary left **unlabelled**. The boundary + is where the prompt model is least certain and where a particle's own + soft edge lives; labelling it either way teaches the head the wrong + thing about edges, which shows up as systematically over- or + under-sized instances downstream. + background_dilation + Outer radius of the background ring, in pixels. It must be wider than + *gap* or there is no ring at all. + erode + Pixels eroded off the mask interior before it becomes a particle label, + for the same reason as *gap*. **Skipped for any mask the erosion would + empty** — a 3 px particle is exactly the object plan §0.9 is about, and + silently dropping it here would defeat the whole bootstrap. + + Returns + ------- + LabelStore + The store, with *particle_class* painted on the mask interiors and + *background_class* on the surrounding rings. Rings never cover another + mask, so particle A is not taught as background for particle B. + + Notes + ----- + Each mask gets its own full-frame dilation, which is O(n_masks) frame-sized + boolean passes. That is fine and deliberate at the scale this runs at — a + handful of prompt clicks — and it is why this is not the batch path: the batch + path is "train once, apply to N frames", which never comes back here. + """ + from scipy.ndimage import binary_dilation, binary_erosion + + arr = np.asarray(masks) + if arr.dtype != bool: + arr = arr.astype(bool) + if arr.ndim == 2: + arr = arr[None] + if arr.ndim != 3: + raise ValueError( + f"masks must be (h, w) or (n, h, w) boolean; got shape {arr.shape}") + if background_dilation <= gap: + raise ValueError( + f"background_dilation ({background_dilation}) must exceed gap " + f"({gap}) or the background ring is empty") + + shape = tuple(arr.shape[1:]) if frame_shape is None else tuple(frame_shape) + if store is None: + store = LabelStore(frame_shape=shape) + elif tuple(store.frame_shape) != shape: + raise ValueError( + f"masks are {shape} but the store holds {store.frame_shape} labels") + store.class_by_id(particle_class) + store.class_by_id(background_class) + + union = arr.any(axis=0) + # Everything within `gap` of ANY mask is off-limits as background — computed + # once over the union rather than per mask, so overlapping prompts agree. + near_any = binary_dilation(union, iterations=int(gap)) if gap > 0 else union + + for m in arr: + if not m.any(): + continue + inner = m + if erode > 0: + shrunk = binary_erosion(m, iterations=int(erode)) + if shrunk.any(): + inner = shrunk + store.paint(t, inner, particle_class) + ring = binary_dilation(m, iterations=int(background_dilation)) & ~near_any + if ring.any(): + store.paint(t, ring, background_class) + return store + + +# ── the head ───────────────────────────────────────────────────────────────── + +def _frame_getter(frames) -> Callable[[int], np.ndarray]: + """``t -> 2-D frame`` for a callable, a mapping, a stack, or a single frame. + + Delegates the stack cases to :func:`spyde.drift.frames.frame_source`, which + already handles a HyperSpy signal, a dask array, a numpy array and a sequence + of frames and — importantly — reads exactly one frame at a time. Training + over a long movie must never materialise it (CLAUDE.md memory-safety rule), + and re-deriving that accessor here would be a second place to get it wrong. + """ + if callable(frames): + return frames + if isinstance(frames, dict): + return lambda t: np.asarray(frames[int(t)]) + arr = frames if hasattr(frames, "ndim") else None + if arr is not None and arr.ndim == 2: + return lambda t, _a=arr: np.asarray(_a) + from spyde.drift.frames import frame_source + _n, get_frame, _shape = frame_source(frames) + return get_frame + + +class ScribbleClassifier: + """Multi-class pixel classifier over the torch feature stack. + + Parameters + ---------- + spec + The feature stack to classify. Saved with the weights — a model and the + channels it was trained on are meaningless apart. + hidden + Hidden-layer width. 64 is where agreement with the RandomForest reference + stops improving — measured IoU on the fixture: 16 → 0.915, 32 → 0.939, + **64 → 0.941**, 128 → 0.936, 256 → 0.934 — and the fit is flat in width + (0.45–0.58 s across all of those) because the cost is per-step dispatch, + not arithmetic, so there is nothing to buy by going narrower. + epochs, lr, weight_decay + Full-batch Adam. 300 steps, again from the parity measurement: + 100 → 0.832, 200 → 0.899, **300 → 0.941**, 500 → 0.864 (it starts + over-tightening the boundary past 300). One step is ~1.5 ms at *any* torch + thread count from 1 to 24, i.e. entirely per-step overhead, so the epoch + count is a fixed ~0.45 s independent of frame size and of how much was + painted. + + Full-batch rather than mini-batch because the training set is *thousands* + of rows by design (plan B3): a mini-batch loop would add a shuffle order, + and therefore a seed dependence the user would perceive as the model + changing when they changed nothing, in exchange for no speed at this size. + seed + Weight initialisation. Same seed + same labels → same model, bit for bit + (``TestDeterminism``). + device + ``None`` auto-selects CUDA/MPS/CPU. Pass ``"cpu"`` in tests: torch-CUDA + work segfaults under the pytest process on Windows (CLAUDE.md). + """ + + def __init__( + self, + spec: FeatureSpec | None = None, + *, + hidden: int = 64, + epochs: int = 300, + lr: float = 0.05, + weight_decay: float = 1e-4, + seed: int = 0, + device=None, + ) -> None: + self.spec = spec or FeatureSpec() + self.hidden = int(hidden) + self.epochs = int(epochs) + self.lr = float(lr) + self.weight_decay = float(weight_decay) + self.seed = int(seed) + self.device = select_device(device) if not hasattr(device, "type") else device + self.classes: list[ScribbleClass] = [] + self._net = None + self._mean = None # (C,) feature standardisation, from training + self._std = None + self.report: dict[str, Any] = {} + + # -- state --------------------------------------------------------------- + + @property + def is_trained(self) -> bool: + return self._net is not None + + @property + def particle_class_ids(self) -> list[int]: + return [c.id for c in self.classes if c.particle] + + def _require_trained(self) -> None: + if not self.is_trained: + raise RuntimeError( + "this ScribbleClassifier has not been trained — call fit() with a " + "LabelStore first") + + # -- training ------------------------------------------------------------ + + def fit(self, store: LabelStore, frames, *, + progress: Callable[[int, int], None] | None = None) -> dict[str, Any]: + """Train on every labelled pixel in *store*. + + Parameters + ---------- + store + The accumulated scribbles. Every labelled frame contributes; classes + with no labelled pixels are dropped from the head (a column that + never sees a positive example would only ever emit noise). + frames + How to get frame *t*: a callable, a ``{t: frame}`` mapping, a 3-D + stack, a HyperSpy signal, or a single 2-D frame (for a one-frame + store). Read one frame at a time — never materialised. + progress + ``progress(done, total)`` over the labelled frames, then once more at + completion. Featurising is the slow part, so it is reported per frame + rather than per training epoch. + + Returns + ------- + dict + Training report: per-class pixel counts, final loss and training + accuracy, the wall-clock split between featurising and fitting, and + the device used. This is what the caret shows, and the featurise/fit + split is what tells a user whether adding a labelled frame or + widening the head is what costs them. + + Notes + ----- + Cost is ``one featurise per labelled frame`` plus a fixed fit. Measured on + the 96×112 fixture, CPU, default spec: **14 ms featurise + 457 ms fit**, so + the interaction budget is met with room, and re-training after adding a + stroke on a *new* frame costs one more featurise (≈1.1 s at 2048²). There is + deliberately **no cached feature sampler**: the cache would have to be + invalidated when the underlying frames change (a re-drift, a different + node), and nothing in this API can observe that — a silently stale + training set is far worse than a re-featurise the report already shows the + cost of. + """ + torch = import_torch() + if len(store) == 0: + raise ValueError("nothing painted yet — the label store is empty") + + get_frame = _frame_getter(frames) + t_frames = store.labelled_frames() + + # ONE lock acquisition around the whole fit, not one per stage. Every line + # below submits to the device — the per-frame featurise, the cat, the + # standardisation, the optimiser loop, the accuracy read — and MPS needs + # all of them serialised (CLAUDE.md § GPU Computing). Held across the fit + # for the same reason `drift.translation.solve_translation` holds it + # across a solve: this completes in well under a second by contract, so a + # concurrent preview waits a bounded time, and a partial hold would leave + # exactly the gaps that took the backend down last time. Reentrant, so + # `sample_features` taking it again is free. Null context off MPS. + t0 = time.perf_counter() + with accelerator_lock(self.device): + xs, ys = [], [] + for i, t in enumerate(t_frames): + idx, cls = store.at(t) + if not idx.size: + continue + frame = np.asarray(get_frame(t)) + if tuple(frame.shape) != tuple(store.frame_shape): + raise ValueError( + f"frame {t} is {frame.shape} but the label store holds " + f"{store.frame_shape} labels — the flat indices would " + "land in the wrong pixels") + xs.append(sample_features(frame, idx, self.spec, + device=self.device)) + ys.append(torch.as_tensor(cls.astype(np.int64), + device=self.device)) + if progress is not None: + progress(i + 1, len(t_frames)) + t_feat = time.perf_counter() - t0 + + X = torch.cat(xs, dim=0) + y_raw = torch.cat(ys, dim=0) + + # Only classes that actually carry labels become head columns, + # remapped to a contiguous 0..K-1 so cross-entropy has no dead output. + present = sorted({int(v) for v in y_raw.unique().tolist()}) + if len(present) < 2: + raise ValueError( + f"only one class is painted (id {present[0]}) — a classifier " + "needs at least two, e.g. a particle and some background") + self.classes = [store.class_by_id(c) for c in present] + lut = torch.full((max(present) + 1,), -1, dtype=torch.long, + device=self.device) + for k, cid in enumerate(present): + lut[cid] = k + y = lut[y_raw] + + t1 = time.perf_counter() + self._mean = X.mean(dim=0) + # Standardise per channel. A guard on the std and not an epsilon: a + # constant channel (a rank filter on a flat region) divided by a tiny + # number amplifies float noise to unit scale, and the head then fits + # it — the same failure the drift solver's phase FLOOR exists for. + std = X.std(dim=0, unbiased=False) + self._std = torch.where(std > 1e-6, std, torch.ones_like(std)) + Xn = (X - self._mean) / self._std + + self._net = _build_mlp(torch, X.shape[1], self.hidden, len(present), + self.seed, self.device) + counts = torch.bincount(y, minlength=len(present)).to(Xn.dtype) + # Class-balanced loss. Scribbles are wildly unbalanced by nature — a + # user paints a few dabs on particles and sweeps whole regions of + # background — and unweighted cross-entropy on a 40:1 split simply + # learns "background", which reads as the classifier not working. + weight = X.shape[0] / (len(present) * counts.clamp_min(1.0)) + loss_fn = torch.nn.CrossEntropyLoss(weight=weight) + opt = torch.optim.Adam(self._net.parameters(), lr=self.lr, + weight_decay=self.weight_decay) + loss = float("nan") + for _ in range(self.epochs): + opt.zero_grad(set_to_none=True) + out = self._net(Xn) + lo = loss_fn(out, y) + lo.backward() + opt.step() + loss = float(lo.detach()) + with torch.no_grad(): + acc = float((self._net(Xn).argmax(dim=1) == y).to(Xn.dtype).mean()) + t_fit = time.perf_counter() - t1 + + self.report = { + "device": str(self.device), + "n_pixels": int(X.shape[0]), + "n_channels": int(X.shape[1]), + "n_classes": len(present), + "labelled_frames": list(t_frames), + "pixels_per_class": {str(c.id): int(n) for c, n in + zip(self.classes, counts.tolist())}, + "loss": loss, + "train_accuracy": acc, + "featurise_s": t_feat, + "fit_s": t_fit, + } + if progress is not None: + progress(len(t_frames), len(t_frames)) + log.info("[scribble] trained on %d px x %d ch, %d classes: acc %.3f " + "(featurise %.2f s, fit %.2f s, %s)", X.shape[0], X.shape[1], + len(present), acc, t_feat, t_fit, self.device) + return self.report + + # -- prediction ---------------------------------------------------------- + + def predict_class_proba(self, frame) -> np.ndarray: + """``(K, H, W)`` float32 softmax over the trained classes. + + Column *k* is ``self.classes[k]``. Pixels that were non-finite in *frame* + get probability 0 in **every** class, so the columns do not sum to 1 + there — that is deliberate and is what the NaN-border contract means: an + invalid pixel is not "probably background", it is not a measurement. + """ + self._require_trained() + torch = import_torch() + prepared = prepare_frame(frame, self.spec) + h, w = prepared.image.shape + + # The lock spans the allocation, the per-band head evaluation AND the + # read-back: a device->host copy is a submission too, and doing it after + # releasing is exactly the kind of gap that reopens the MPS crash. + with accelerator_lock(self.device): + out = torch.zeros((len(self.classes), h, w), device=self.device, + dtype=torch.float32) + + def band(y0: int, y1: int, stack) -> None: + # `stack` is a row slice of a larger tensor and so not contiguous; + # `reshape` copies when it must, which `view` would refuse to do. + flat = stack.reshape(stack.shape[0], -1).t() # (rows*w, C) + with torch.no_grad(): + p = torch.softmax( + self._net((flat - self._mean) / self._std), dim=1) + out[:, y0:y1] = p.t().reshape(len(self.classes), y1 - y0, w) + + map_feature_bands(prepared, self.spec, device=self.device, fn=band) + proba = out.detach().cpu().numpy() + + proba[:, ~prepared.valid] = 0.0 + return proba + + def predict_proba(self, frame) -> np.ndarray: + """``(H, W)`` float32 **foreground** probability, 0..1. + + The sum over every class with :attr:`ScribbleClass.particle` set, which is + the per-frame probability map the plan's §0.2 spine consumes — hand it + straight to :func:`spyde.particles.classical.split_instances`. + + Zero inside a NaN-padded region (plan trap 2). + """ + self._require_trained() + proba = self.predict_class_proba(frame) + wanted = [k for k, c in enumerate(self.classes) if c.particle] + if not wanted: + raise RuntimeError( + "no trained class is marked as a particle class, so there is no " + "foreground to report — set ScribbleClass.particle on at least " + "one of " + f"{[c.name for c in self.classes]} and retrain") + return proba[wanted].sum(axis=0).astype(np.float32) + + def predict_labels(self, frame) -> np.ndarray: + """``(H, W)`` int16 argmax over classes, as **class ids**. + + :data:`UNLABELLED` (-1) where the source pixel was non-finite. Class ids, + not column indices, so the value indexes straight into the user's class + list and its colour. + """ + proba = self.predict_class_proba(frame) + ids = np.asarray([c.id for c in self.classes], dtype=np.int16) + out = ids[proba.argmax(axis=0)] + out[proba.sum(axis=0) <= 0.0] = UNLABELLED + return out + + def segment(self, frame, params=None) -> np.ndarray: + """Convenience: probability → labelled instances via the shared split. + + The whole point of plan §0.2 is that this engine stops at a probability + map and the instance stage is written once, so this is a two-line + forwarder — kept here only so the caller does not have to remember which + module owns the split. + """ + from spyde.particles.classical import SegmentParams, split_instances + return split_instances(self.predict_proba(frame), params or SegmentParams()) + + # -- serialisation ------------------------------------------------------- + + def save(self, path: str) -> None: + """Write weights + :class:`FeatureSpec` + classes to one ``.npz``. + + One file, never two: a recipe is the spec *and* the weights *and* the + feature standardisation, and any of them alone predicts nonsense. Written + with ``np.savez_compressed`` and read back with ``allow_pickle=False``, + matching :meth:`spyde.signals.particles.SpyDEParticles.save` — a model + file should not be able to execute code on load. + """ + self._require_trained() + with accelerator_lock(self.device): + state = {k: v.detach().cpu().numpy() + for k, v in self._net.state_dict().items()} + mean = self._mean.detach().cpu().numpy() + std = self._std.detach().cpu().numpy() + meta = { + "format_version": FORMAT_VERSION, + "spec": self.spec.to_dict(), + "classes": [c.to_dict() for c in self.classes], + "hidden": self.hidden, + "epochs": self.epochs, + "lr": self.lr, + "weight_decay": self.weight_decay, + "seed": self.seed, + "state_keys": sorted(state), + "report": _jsonable(self.report), + } + np.savez_compressed( + path, + meta=np.array(json.dumps(meta)), + feature_mean=mean, + feature_std=std, + **{f"w_{k}": v for k, v in state.items()}, + ) + + @classmethod + def load(cls, path: str, *, device=None) -> "ScribbleClassifier": + """Read a model written by :meth:`save`. + + The host→device transfers are taken under the accelerator lock. A cold + model load is the *specific* unlocked call site CLAUDE.md names — the + neural detector's ``load_model`` had exactly this hole and it was one of + the two threads in the MPS crash. + """ + torch = import_torch() + with np.load(path, allow_pickle=False) as z: + meta = json.loads(str(z["meta"].item())) + ver = meta.get("format_version") + if ver != FORMAT_VERSION: + raise ValueError( + f"unsupported scribble model format version {ver!r} " + f"(this build reads {FORMAT_VERSION})") + model = cls( + FeatureSpec.from_dict(meta["spec"]), + hidden=int(meta["hidden"]), + epochs=int(meta.get("epochs", 300)), + lr=float(meta.get("lr", 0.05)), + weight_decay=float(meta.get("weight_decay", 1e-4)), + seed=int(meta.get("seed", 0)), + device=device, + ) + model.classes = [ScribbleClass.from_dict(c) for c in meta["classes"]] + model.report = meta.get("report") or {} + n_in = int(np.size(z["feature_mean"])) + if n_in != model.spec.n_channels: + raise ValueError( + f"model expects {n_in} feature channels but its saved " + f"FeatureSpec produces {model.spec.n_channels} — the spec and " + "the weights have come apart") + with accelerator_lock(model.device): + model._mean = torch.as_tensor(z["feature_mean"], + device=model.device) + model._std = torch.as_tensor(z["feature_std"], + device=model.device) + net = _build_mlp(torch, n_in, model.hidden, len(model.classes), + model.seed, model.device) + net.load_state_dict( + {k: torch.as_tensor(z[f"w_{k}"], device=model.device) + for k in meta["state_keys"]}) + model._net = net + return model + + +def _build_mlp(torch, n_in: int, hidden: int, n_out: int, seed: int, device): + """One hidden layer, ReLU, deterministically initialised from *seed*. + + The weights are drawn from an explicitly seeded ``torch.Generator`` and copied + in, rather than letting ``nn.Linear`` use the global RNG: the global stream is + shared with everything else in the process (the neural detector, a dask + worker), so "same seed, same labels, same model" would otherwise depend on + what else happened to run first. ``torch.nn.init`` has no generator argument + on this torch version, which is why the scaling is written out. + + The construction is nevertheless wrapped in ``fork_rng``, because + ``nn.Linear.__init__`` draws its own default initialisation from the global + stream *before* it is overwritten here — so without the fork, training a + scribble model would silently shift every other consumer's random sequence. + ``devices=[]`` forks the CPU generator only; forking CUDA's would initialise + the CUDA context as a side effect. + """ + g = torch.Generator().manual_seed(int(seed)) + with torch.random.fork_rng(devices=[]): + net = torch.nn.Sequential( + torch.nn.Linear(n_in, hidden), + torch.nn.ReLU(), + torch.nn.Linear(hidden, n_out), + ) + with torch.no_grad(): + for lin, fan_in in ((net[0], n_in), (net[2], hidden)): + w = torch.randn(lin.weight.shape, generator=g) / math.sqrt(fan_in) + lin.weight.copy_(w) + lin.bias.zero_() + return net.to(device) + + +def _jsonable(obj): + """Coerce a report dict to something ``json.dumps`` accepts.""" + if isinstance(obj, dict): + return {str(k): _jsonable(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_jsonable(v) for v in obj] + if isinstance(obj, (np.integer,)): + return int(obj) + if isinstance(obj, (np.floating,)): + return float(obj) + return obj + + +# ── the sklearn parity reference (test-only, kept next to what it checks) ───── + +def random_forest_reference( + store: LabelStore, + frames, + spec: FeatureSpec | None = None, + *, + n_estimators: int = 100, + seed: int = 0, + device=None, +): + """Train ``sklearn.ensemble.RandomForestClassifier`` on the SAME features. + + This is the acceptance reference for the whole engine (plan B3): the forest is + what ParticleSpy and ilastik use, so agreement on identical labels and + identical channels is what says the torch head is a re-implementation rather + than a different algorithm that happens to produce pictures. + + It lives here, beside the head it checks, rather than in the test file — the + two must read the same feature stack through the same sampler, and the moment + the reference has its own copy of that plumbing the comparison stops being + apples to apples. + + Returns + ------- + (model, predict) + ``predict(frame)`` gives an ``(H, W)`` float32 foreground probability, on + the same convention as + :meth:`ScribbleClassifier.predict_proba` including the NaN-border zeroing. + """ + from sklearn.ensemble import RandomForestClassifier + + spec = spec or FeatureSpec() + device = select_device(device) if not hasattr(device, "type") else device + get_frame = _frame_getter(frames) + + xs, ys = [], [] + with accelerator_lock(device): + for t in store.labelled_frames(): + idx, cls = store.at(t) + if not idx.size: + continue + xs.append(sample_features(get_frame(t), idx, spec, + device=device).detach().cpu().numpy()) + ys.append(cls.astype(np.int64)) + X = np.concatenate(xs, axis=0) + y = np.concatenate(ys, axis=0) + + rf = RandomForestClassifier(n_estimators=n_estimators, random_state=seed, + class_weight="balanced", n_jobs=-1) + rf.fit(X, y) + particle_ids = {c.id for c in store.classes if c.particle} + cols = [i for i, c in enumerate(rf.classes_) if int(c) in particle_ids] + + def predict(frame) -> np.ndarray: + from spyde.particles.features import feature_stack + prepared = prepare_frame(frame, spec) + with accelerator_lock(device): + # The forest is CPU-only, so unlike `predict_class_proba` this one + # genuinely does materialise the whole stack (numpy) before scoring — + # which is the other reason the forest is a reference and not the + # shipped head. + stack = feature_stack(prepared, spec, device=device) + c, h, w = stack.shape + p = rf.predict_proba(stack.reshape(c, -1).T) + out = p[:, cols].sum(axis=1).reshape(h, w).astype(np.float32) + out[~prepared.valid] = 0.0 + return out + + return rf, predict diff --git a/spyde/tests/migrated/test_device_lock.py b/spyde/tests/migrated/test_device_lock.py index eecd3876..173827f2 100644 --- a/spyde/tests/migrated/test_device_lock.py +++ b/spyde/tests/migrated/test_device_lock.py @@ -277,3 +277,55 @@ def fake_calibrate(model, frames, device, **kw): monkeypatch.setattr(models, "calibrate", fake_calibrate) fvn.calibrate_neural([np.zeros((32, 32), dtype=np.float32)]) assert held == [True], "calibration forwards ran unserialised on MPS" + + +class TestParticleEnginesTakeLock: + """The particle feature stack and the scribble head are the newest torch users. + + They run from BOTH ends of the concurrency this lock exists for: a batch + segmentation run on a worker thread, and the interactive re-apply that fires on + every navigator move while the user tunes. That overlap is exactly the pair of + threads in the crash stacks at the top of this file. + + These two pin the *real* lock. Finer-grained nesting — that no submission + inside ``fit`` / ``predict`` / ``save`` / ``load`` escapes the block, which a + null context on a CPU box cannot show — is pinned in + ``test_particles_scribble.py::TestDeviceLock``. + """ + + def test_feature_stack_locks(self, monkeypatch): + """``map_feature_bands`` is the single torch entry point in features.py: + ``feature_tensor``, ``feature_stack`` and ``sample_features`` all route + through it, so this one acquisition covers every channel.""" + from spyde.particles import features as feat + + held = [] + monkeypatch.setattr(feat, "_band_stack", + lambda *a, **k: held.append(_lock_is_held_by_me())) + feat.map_feature_bands(np.zeros((8, 8), dtype=np.float32), + feat.FeatureSpec(), device=_FakeDev(), + fn=lambda *a: None) + assert held == [True], "the particle feature stack ran unserialised on MPS" + assert not _lock_is_held_by_me(), "lock leaked" + + def test_scribble_training_locks(self, monkeypatch): + from spyde.particles import scribble as scr + + class _Stop(Exception): + """Aborts the fit at its first device submission.""" + + held = [] + + def spy(*a, **kw): + held.append(_lock_is_held_by_me()) + raise _Stop + + monkeypatch.setattr(scr, "sample_features", spy) + store = scr.LabelStore(frame_shape=(16, 16)) + store.paint(0, [(2, 2)], 0) + store.paint(0, [(9, 9)], 1) + clf = scr.ScribbleClassifier(device=_FakeDev()) + with pytest.raises(_Stop): + clf.fit(store, {0: np.zeros((16, 16), dtype=np.float32)}) + assert held == [True], "scribble training ran unserialised on MPS" + assert not _lock_is_held_by_me(), "lock leaked" diff --git a/spyde/tests/migrated/test_particles_scribble.py b/spyde/tests/migrated/test_particles_scribble.py new file mode 100644 index 00000000..7485e4e1 --- /dev/null +++ b/spyde/tests/migrated/test_particles_scribble.py @@ -0,0 +1,1627 @@ +""" +The torch feature stack and the scribble classifier — DRIFT_AND_PARTICLES_PLAN.md +step 3 (B2 + B3), and the acceptance gates it exists to pass. + +Four of these classes are the plan's gates rather than ordinary unit tests, and +they are the reason this file is worth its runtime: + +:class:`TestRandomForestParity` + Plan B3: the torch head must agree with ``sklearn``'s RandomForest — what + ParticleSpy and ilastik actually use — on **identical labels and identical + feature channels**. Agreement by IoU, measured 0.94. +:class:`TestSensitivityGate` + Plan §0.9, the headline gate for the whole feature: the two deliberately + faint low-contrast probes in ``particle_movie()`` must be found. It is kept + non-vacuous by :class:`TestTheClassicalBaselineMissesThem`, which shows the + classical engine finds neither. +:class:`TestNaNBorder` + Plan trap 2 / A7: no foreground inside a drift-corrected frame's NaN padding. +:class:`TestInteractionBudget` + Plan B3's hard budget: train + apply to one visible frame under ~1 s on CPU. + +Everything runs on **CPU explicitly** (``select_device("cpu")``): torch-CUDA work +segfaults under the pytest process on Windows (CLAUDE.md), which is a harness +interaction and not a code defect, so the GPU path is left to the real app and to +a subprocess check. +""" +from __future__ import annotations + +import json +import threading +import time + +import numpy as np +import pytest + +from spyde.data.synthetic import ground_truth, particle_movie, particle_truth_at +from spyde.drift.warp import shift_frame +from spyde.particles import SegmentParams, segment_frame, split_instances +from spyde.particles import features as feat +from spyde.particles.features import ( + DEFAULT_SIGMAS, + FeatureSpec, + PreparedFrame, + band_rows_for, + feature_stack, + feature_tensor, + map_feature_bands, + prepare_frame, + sample_features, + select_device, +) +from spyde.particles.scribble import ( + UNLABELLED, + LabelStore, + ScribbleClass, + ScribbleClassifier, + default_classes, + masks_to_labels, + random_forest_reference, +) + +#: The frame every gate is measured on: all nine particles are present at t=12. +FRAME_T = 12 + +DEVICE = select_device("cpu") + + +# ── fixtures ───────────────────────────────────────────────────────────────── + +@pytest.fixture(scope="module") +def movie(): + """One build shared across the module — ~1 s and deterministic.""" + s = particle_movie() + return s, ground_truth(s) + + +@pytest.fixture(scope="module") +def geom(movie): + """``(positions, radii, present, faint, shape)`` at :data:`FRAME_T`.""" + _s, gt = movie + pos, radii, present = particle_truth_at(gt, FRAME_T) + faint = np.asarray(gt["p_faint"], bool) + return pos, radii, present, faint, tuple(gt["frame_shape"]) + + +def _clear_of_particles(shape, pos, radii, present, pad: float): + """True where no present particle is within *pad* px — where background may + be painted without accidentally labelling a particle as film.""" + h, w = shape + yy, xx = np.mgrid[0:h, 0:w] + keep = np.ones((h, w), bool) + for i in np.flatnonzero(present): + keep &= ((yy - pos[i, 0]) ** 2 + (xx - pos[i, 1]) ** 2) > (radii[i] + pad) ** 2 + return keep + + +def paint_scribbles(geom, *, include_faint=(8,), n_bright=4, + t: int = FRAME_T) -> LabelStore: + """The scribbles a user would actually paint on one frame. + + Four dabs on bright particles, a background stroke at each of those + particles' boundaries, four background sweeps across the film, and — unless + *include_faint* is emptied — one dab on the SMALLER of the two faint probes + (index 8, r=3). Index 7 (r=4) is never painted, so it is a genuinely held-out + detection in :class:`TestSensitivityGate`. + + The boundary strokes matter and are not padding: without them the head has + never seen a not-quite-particle pixel, its masks come out visibly fat, and + agreement with the RandomForest reference falls from 0.94 to 0.69 (measured). + That is a fact about labelling, not about either classifier. + """ + pos, radii, present, faint, shape = geom + h, w = shape + yy, xx = np.mgrid[0:h, 0:w] + store = LabelStore(frame_shape=shape, classes=default_classes()) + + bright = [i for i in np.flatnonzero(present) if not faint[i]][:n_bright] + for i in bright: + store.paint_disc(t, pos[i, 0], pos[i, 1], max(1.5, radii[i] * 0.5), 0) + for i in include_faint: + store.paint_disc(t, pos[i, 0], pos[i, 1], 1.5, 0) + + far = _clear_of_particles(shape, pos, radii, present, 3.0) + for (y0, x0, y1, x1) in ((4, 4, 8, 100), (h - 8, 4, h - 4, 100), + (40, 4, 60, 8), (20, 40, 40, 46)): + sweep = np.zeros((h, w), bool) + sweep[y0:y1, x0:x1] = True + store.paint(t, sweep & far, 1) + + touching = _clear_of_particles(shape, pos, radii, present, 0.0) + for i in bright: + d2 = (yy - pos[i, 0]) ** 2 + (xx - pos[i, 1]) ** 2 + ring = (d2 > (radii[i] + 2.5) ** 2) & (d2 <= (radii[i] + 4.0) ** 2) + store.paint(t, ring & touching, 1) + return store + + +@pytest.fixture(scope="module") +def labels(geom): + return paint_scribbles(geom) + + +@pytest.fixture(scope="module") +def trained(movie, labels): + s, _gt = movie + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0) + clf.fit(labels, {FRAME_T: s.data[FRAME_T]}) + return clf + + +@pytest.fixture(scope="module") +def proba(movie, trained): + s, _gt = movie + return trained.predict_proba(s.data[FRAME_T]) + + +def hit(prob_or_labels, pos, i) -> bool: + """Did the map fire at particle *i*'s ground-truth centre?""" + cy, cx = int(round(pos[i, 0])), int(round(pos[i, 1])) + v = prob_or_labels[cy, cx] + return bool(v > 0.5) if np.issubdtype(np.asarray(v).dtype, np.floating) \ + else bool(v != 0) + + +# ── FeatureSpec ────────────────────────────────────────────────────────────── + +class TestFeatureSpec: + def test_the_scale_floor_is_fine(self): + """Plan §0.9. What raising this costs is measured in + :meth:`TestSensitivityGate.test_a_coarse_stack_undersizes_the_small_particles` + — this only pins that it has not been raised without going and looking.""" + assert min(DEFAULT_SIGMAS) <= 1.0, ( + "the default scale floor has been raised above 1 px; that costs the " + "smallest particles' measured radius (see the features module " + "docstring) and needs a fresh sensitivity measurement, not a guess") + assert sorted(DEFAULT_SIGMAS) == list(DEFAULT_SIGMAS) + + def test_default_channel_count_and_names(self): + spec = FeatureSpec() + names = spec.channel_names() + assert len(names) == spec.n_channels == 36 + assert len(set(names)) == len(names), "duplicate channel names" + # Every promised family is present. + for token in ("intensity", "gaussian_s", "dog_s", "sobel_s", "laplacian_s", + "hessian_major_s", "hessian_minor_s", "median_r", + "minimum_r", "maximum_r"): + assert any(n.startswith(token) for n in names), f"missing {token}" + + def test_names_match_the_stack_for_every_configuration(self, movie): + """The names and the tensor come from one plan; this is what stops them + drifting apart when a family is added.""" + s, _gt = movie + frame = s.data[0][:32, :32] + for spec in (FeatureSpec(), + FeatureSpec(membrane=True), + FeatureSpec(sigmas=(1.0,), median=False, minimum=False, + maximum=False, membrane=True), + FeatureSpec(intensity=False, gaussian=False, sobel=False, + hessian=False, laplacian=False, + difference_of_gaussians=False)): + stack = feature_stack(frame, spec, device=DEVICE) + assert stack.shape[0] == len(spec.channel_names()) == spec.n_channels + + def test_round_trips_through_a_dict(self): + spec = FeatureSpec(sigmas=(0.7, 1.4), rank_radii=(3,), membrane=True, + membrane_projections=("sum", "min")) + d = spec.to_dict() + assert json.loads(json.dumps(d)) == d, "not JSON-safe" + assert FeatureSpec.from_dict(d) == spec + + def test_from_dict_ignores_unknown_keys(self): + """A recipe written by a newer build must still open here.""" + d = FeatureSpec().to_dict() + d["some_future_feature"] = True + assert FeatureSpec.from_dict(d) == FeatureSpec() + + def test_from_dict_of_nothing_is_the_default(self): + assert FeatureSpec.from_dict(None) == FeatureSpec() + assert FeatureSpec.from_dict({}) == FeatureSpec() + + def test_replace_and_hashable(self): + spec = FeatureSpec() + assert spec.replace(median=False).median is False + assert spec.median is True, "replace mutated the original" + assert hash(spec) == hash(FeatureSpec()) + + @pytest.mark.parametrize("kw, match", [ + ({"sigmas": (2.0, 1.0)}, "ascending"), + ({"sigmas": (0.0, 1.0)}, "positive"), + ({"rank_radii": (0,)}, "rank_radii"), + ({"membrane_projections": ("nope",)}, "membrane projection"), + ({"membrane": True, "membrane_patch": 18}, "odd"), + ({"membrane": True, "membrane_rotations": 0}, "rotations"), + ]) + def test_validation(self, kw, match): + with pytest.raises(ValueError, match=match): + FeatureSpec(**kw) + + def test_a_spec_with_no_channels_at_all_raises(self): + with pytest.raises(ValueError, match="no channels"): + FeatureSpec(intensity=False, gaussian=False, + difference_of_gaussians=False, sobel=False, hessian=False, + laplacian=False, median=False, minimum=False, + maximum=False) + + def test_halo_covers_the_largest_filter(self): + spec = FeatureSpec() + # sigma 8 truncated at 4 sigma is radius 32, plus the 3-tap derivative. + assert spec.halo == 33 + assert FeatureSpec(sigmas=(1.0,), rank_radii=(7,)).halo == 7 + assert FeatureSpec(sigmas=(1.0,), rank_radii=(1,), membrane=True, + membrane_patch=19).halo == 9 + + +# ── the feature stack ──────────────────────────────────────────────────────── + +class TestFeatureParity: + """Every channel against the scipy/skimage filter it re-implements. + + ``normalize_frame=False`` throughout, because the whole point is comparing the + *filters*, not the standardisation. Padding is compared with scipy's + ``mirror``, which is what torch's ``reflect`` is (scipy's own ``reflect`` + duplicates the edge sample and is a different thing). + """ + + @pytest.fixture(scope="class") + def img(self, movie): + s, _gt = movie + return np.ascontiguousarray(s.data[FRAME_T], dtype=np.float32) + + @pytest.fixture(scope="class") + def stack(self, img): + spec = FeatureSpec(normalize_frame=False, membrane=True) + arr = feature_stack(img, spec, device=DEVICE) + return spec, arr, spec.channel_names() + + def test_shape_and_dtype(self, img, stack): + spec, arr, _names = stack + assert arr.shape == (spec.n_channels, *img.shape) + assert arr.dtype == np.float32 + assert np.isfinite(arr).all() + + def test_intensity_channel_is_the_frame(self, img, stack): + _spec, arr, names = stack + assert np.array_equal(arr[names.index("intensity")], img) + + @pytest.mark.parametrize("sigma", DEFAULT_SIGMAS) + def test_gaussian_matches_scipy(self, img, stack, sigma): + from scipy.ndimage import gaussian_filter + _spec, arr, names = stack + ref = gaussian_filter(img, float(sigma), mode="mirror") + got = arr[names.index(f"gaussian_s{sigma:g}")] + assert np.abs(got - ref).max() < 1e-5 + + def test_difference_of_gaussians_is_the_difference(self, stack): + _spec, arr, names = stack + got = arr[names.index("dog_s1_s2")] + ref = arr[names.index("gaussian_s1")] - arr[names.index("gaussian_s2")] + assert np.array_equal(got, ref) + + @pytest.mark.parametrize("radius", (1, 2)) + def test_rank_filters_match_scipy(self, img, stack, radius): + from scipy.ndimage import maximum_filter, median_filter, minimum_filter + _spec, arr, names = stack + k = 2 * radius + 1 + for stat, fn in (("median", median_filter), ("minimum", minimum_filter), + ("maximum", maximum_filter)): + ref = fn(img, size=k, mode="mirror") + got = arr[names.index(f"{stat}_r{radius}")] + assert np.array_equal(got, ref), f"{stat} r={radius}" + + def test_sobel_matches_skimage(self, img, stack): + from scipy.ndimage import gaussian_filter + from skimage.filters import sobel + _spec, arr, names = stack + ref = sobel(gaussian_filter(img, 1.0, mode="mirror"), mode="mirror") + assert np.abs(arr[names.index("sobel_s1")] - ref).max() < 1e-5 + + def test_laplacian_is_the_hessian_trace(self, stack): + """Not decoration: the Laplacian channel is *derived* from the Hessian's + own second derivatives, which is what makes it free.""" + _spec, arr, names = stack + lap = arr[names.index("laplacian_s2")] + tr = (arr[names.index("hessian_major_s2")] + + arr[names.index("hessian_minor_s2")]) + assert np.abs(lap - tr).max() < 1e-4 + + def test_hessian_eigenvalues_are_ordered_and_signed(self, stack): + _spec, arr, names = stack + major = arr[names.index("hessian_major_s2")] + minor = arr[names.index("hessian_minor_s2")] + assert (major >= minor - 1e-6).all(), "major must be the larger SIGNED value" + # A bright blob is a maximum, so BOTH curvatures are negative at its centre. + assert minor.min() < 0 < major.max() + + @pytest.mark.parametrize("radius, matched_sigma", [(1.5, 1.0), (3.0, 2.0), + (8.0, 4.0)]) + def test_hessian_picks_a_blob_out_at_its_own_scale(self, radius, + matched_sigma): + """Scale space, and the whole reason the sigma set spans a range. + + A disc of radius *r* registers as a curvature minimum most strongly at + sigma ≈ ``r/sqrt(2)``, so which scale fires *is* the size measurement the + head has available. A stack whose floor is too high sees a small particle + only in its coarse channels, which is why it then measures it too big — + see :meth:`TestSensitivityGate. + test_a_coarse_stack_undersizes_the_small_particles`. + + Checked at the centre of a synthetic disc rather than on the fixture: a + fixture particle of radius 7 has a *flat* centre at sigma 2, so the plain + "is it a curvature minimum at the centre" assertion is scale-dependent and + would be measuring the wrong thing. + """ + n = 64 + yy, xx = np.mgrid[0:n, 0:n] + img = (((yy - 32) ** 2 + (xx - 32) ** 2) <= radius ** 2).astype(np.float32) + spec = FeatureSpec(normalize_frame=False) + names = spec.channel_names() + arr = feature_stack(img, spec, device=DEVICE) + centre = {s: float(arr[names.index(f"hessian_minor_s{s:g}")][32, 32]) + for s in DEFAULT_SIGMAS} + assert min(centre, key=lambda s: centre[s]) == matched_sigma, centre + assert centre[matched_sigma] < 0, "a bright blob must be a minimum" + + def test_membrane_projections_are_ordered(self, stack): + _spec, arr, names = stack + mx = arr[names.index("membrane_max")] + mn = arr[names.index("membrane_min")] + mean = arr[names.index("membrane_mean")] + assert (mn <= mean + 1e-6).all() and (mean <= mx + 1e-6).all() + assert (arr[names.index("membrane_std")] >= 0).all() + + def test_membrane_responds_to_a_line_not_a_blob(self): + """The family's whole purpose, and why it is off by default.""" + line = np.zeros((48, 48), np.float32) + line[24, 4:44] = 1.0 + blob = np.zeros((48, 48), np.float32) + yy, xx = np.mgrid[0:48, 0:48] + blob[(yy - 24) ** 2 + (xx - 24) ** 2 <= 9] = 1.0 + spec = FeatureSpec(normalize_frame=False, membrane=True) + names = spec.channel_names() + k = names.index("membrane_std") + line_std = feature_stack(line, spec, device=DEVICE)[k][24, 24] + blob_std = feature_stack(blob, spec, device=DEVICE)[k][24, 24] + assert line_std > 3 * blob_std, ( + f"membrane std does not discriminate a line ({line_std:.4f}) from a " + f"blob ({blob_std:.4f})") + + def test_deterministic(self, img): + spec = FeatureSpec() + a = feature_stack(img, spec, device=DEVICE) + b = feature_stack(img, spec, device=DEVICE) + assert np.array_equal(a, b) + + def test_runs_on_the_cpu_with_no_gpu(self, img, monkeypatch): + """CI and most user machines have no accelerator.""" + import torch + monkeypatch.setattr(torch.cuda, "is_available", lambda: False) + monkeypatch.setattr(feat, "select_device", lambda prefer=None: + torch.device("cpu")) + assert feature_stack(img[:32, :32]).shape[0] == FeatureSpec().n_channels + + +class TestPrepareFrame: + def test_non_finite_pixels_take_the_finite_minimum(self): + """Matching ``classical._prepare``: the padding must read as background, + which is the one value that cannot classify as a particle.""" + img = np.linspace(2.0, 6.0, 64, dtype=np.float32).reshape(8, 8) + img[0, :3] = np.nan + img[7, 7] = np.inf + finite_min = float(img[np.isfinite(img)].min()) + prepared = prepare_frame(img, FeatureSpec(normalize_frame=False)) + assert prepared.image[0, :3].tolist() == [finite_min] * 3 + assert prepared.image[7, 7] == pytest.approx(finite_min) + assert np.isfinite(prepared.image).all() + # Not zero and not the mean: the padding must read as background, and on a + # frame whose values are all well above zero, zero-fill would instead read + # as a hole and the mean as ordinary signal. + assert prepared.image.min() == pytest.approx(finite_min) + + def test_valid_mask_marks_exactly_the_finite_source_pixels(self): + img = np.ones((8, 8), np.float32) + img[3, 4] = np.nan + prepared = prepare_frame(img) + assert prepared.valid.sum() == 63 + assert not prepared.valid[3, 4] + + def test_robust_statistics_ignore_the_padding(self): + """Filling first would let a large NaN border rescale the whole frame by + how much of it was padding.""" + rng = np.random.default_rng(0) + img = rng.normal(100.0, 5.0, (64, 64)).astype(np.float32) + padded = img.copy() + padded[:32] = np.nan + a = prepare_frame(img).image[32:] + b = prepare_frame(padded).image[32:] + # Same finite content in rows 32+, so the same standardisation of it. + assert np.abs(a - b).max() < 0.15 + + def test_normalisation_makes_the_stack_scale_invariant(self, movie): + """The property that makes a saved recipe transferable: the same sample + recorded on a different intensity scale must featurise the same.""" + s, _gt = movie + frame = s.data[FRAME_T] + a = feature_stack(frame, FeatureSpec(), device=DEVICE) + b = feature_stack(frame * 1000.0 + 7.0, FeatureSpec(), device=DEVICE) + rms = np.sqrt(np.mean((a - b) ** 2)) + assert rms < 1e-3, f"features are not scale invariant (rms {rms:.4g})" + + def test_a_constant_frame_does_not_divide_by_zero(self): + out = prepare_frame(np.full((8, 8), 3.0, np.float32)) + assert np.isfinite(out.image).all() + + @pytest.mark.parametrize("bad, match", [ + (np.zeros((4, 4, 4), np.float32), "must be 2-D"), + (np.zeros((3, 8), np.float32), "at least 4x4"), + (np.full((8, 8), np.nan, np.float32), "no finite pixels"), + ]) + def test_errors(self, bad, match): + with pytest.raises(ValueError, match=match): + prepare_frame(bad) + + def test_accepts_an_already_prepared_frame(self, movie): + s, _gt = movie + prepared = prepare_frame(s.data[0]) + assert isinstance(prepared, PreparedFrame) + a = feature_stack(prepared, device=DEVICE) + b = feature_stack(s.data[0], device=DEVICE) + assert np.array_equal(a, b) + + +class TestSmallFramesAndPadding: + """A kernel wider than the image must not change which kernel is applied. + + ``F.pad(mode="reflect")`` refuses a pad larger than the dimension, and the + kernels here are not small — a sigma-8 gaussian has radius 32 and a 19x19 + membrane patch radius 9. The first implementation clamped the *radius*, which + silently substituted a narrower filter and so made a ``FeatureSpec`` mean + different things on different frame sizes; a saved recipe would then not + reproduce on a crop. The padding degrades to replicate instead. + """ + + @pytest.mark.parametrize("shape", [(4, 4), (5, 9), (8, 8), (20, 17)]) + @pytest.mark.parametrize("spec", [ + FeatureSpec(), + FeatureSpec(membrane=True), # 19x19 patch on a 4x4 frame + FeatureSpec(rank_radii=(9,)), # 19x19 window on a 4x4 frame + ], ids=["default", "membrane", "wide_rank"]) + def test_a_frame_smaller_than_the_kernels_still_works(self, shape, spec): + img = np.random.default_rng(0).standard_normal(shape).astype(np.float32) + stack = feature_stack(img, spec, device=DEVICE) + assert stack.shape == (spec.n_channels, *shape) + assert np.isfinite(stack).all() + + def test_the_kernel_is_the_same_one_on_a_crop(self, movie): + """The property clamping broke: interior pixels must featurise identically + whether or not the frame around them is big enough for the kernel.""" + s, _gt = movie + spec = FeatureSpec(sigmas=(4.0,), normalize_frame=False, median=False, + minimum=False, maximum=False) + big = np.ascontiguousarray(s.data[FRAME_T], dtype=np.float32) + whole = feature_stack(big, spec, device=DEVICE) + crop = feature_stack(big[20:76, 20:92], spec, device=DEVICE) + # 16 px in from the crop's edge is beyond the kernel's reach either way. + assert np.abs(whole[:, 36:60, 36:76] - crop[:, 16:40, 16:56]).max() < 1e-4 + + def test_the_normalisation_still_works_on_an_all_nan_border(self): + img = np.full((32, 32), np.nan, np.float32) + img[10:20, 10:20] = np.random.default_rng(1).standard_normal((10, 10)) + prepared = prepare_frame(img) + assert prepared.valid.sum() == 100 + assert np.isfinite(feature_stack(prepared, device=DEVICE)).all() + + +class TestBanding: + """Plan §0.1: nothing may assume the frame fits in memory. + + The banded stack must be IDENTICAL to the unbanded one, not merely close — a + halo of at least the largest filter radius replaces what reflect padding would + otherwise invent at a band boundary, so there is nothing to be approximate + about. Anything less than exact means the halo is too small. + """ + + @pytest.fixture(scope="class") + def img(self, movie): + s, _gt = movie + return np.ascontiguousarray(s.data[FRAME_T], dtype=np.float32) + + @pytest.mark.parametrize("rows", (16, 33, 40, 500)) + def test_banded_equals_unbanded_exactly(self, img, rows): + spec = FeatureSpec() + whole = feature_stack(img, spec, device=DEVICE) + got = np.zeros_like(whole) + seen = [] + + def take(y0, y1, stack): + seen.append((y0, y1)) + got[:, y0:y1] = stack.detach().cpu().numpy() + + map_feature_bands(img, spec, device=DEVICE, fn=take, band_rows=rows) + assert seen[0][0] == 0 and seen[-1][1] == img.shape[0] + assert [a for a, _ in seen[1:]] == [b for _, b in seen[:-1]], "gap or overlap" + assert np.array_equal(got, whole), ( + f"banded stack differs at band_rows={rows}: max " + f"{np.abs(got - whole).max():.3g} — the halo is too small") + + def test_band_rows_stays_above_the_halo(self): + spec = FeatureSpec() + # A tiny budget must still not produce a band shorter than its own halo, + # which would recompute more halo than payload. + assert band_rows_for(spec, 4096, budget_bytes=1) >= 4 * spec.halo + # A generous budget covers a whole 4096-row frame in one band. + assert band_rows_for(spec, 4096, budget_bytes=8 << 30) >= 4096 + + def test_sample_features_matches_the_full_stack(self, img): + spec = FeatureSpec() + whole = feature_stack(img, spec, device=DEVICE) + rng = np.random.default_rng(3) + flat = rng.choice(img.size, size=400, replace=False) + got = sample_features(img, flat, spec, device=DEVICE).detach().cpu().numpy() + ys, xs = np.divmod(flat, img.shape[1]) + assert np.array_equal(got, whole[:, ys, xs].T) + + def test_sample_features_accepts_yx_pairs(self, img): + spec = FeatureSpec(sigmas=(1.0,), median=False, minimum=False, + maximum=False) + yx = np.array([[5, 7], [40, 90], [0, 0]]) + a = sample_features(img, yx, spec, device=DEVICE).detach().cpu().numpy() + flat = yx[:, 0] * img.shape[1] + yx[:, 1] + b = sample_features(img, flat, spec, device=DEVICE).detach().cpu().numpy() + assert np.array_equal(a, b) + + def test_sample_features_rejects_an_out_of_range_index(self, img): + with pytest.raises(IndexError, match="outside"): + sample_features(img, np.array([img.size]), device=DEVICE) + + def test_feature_tensor_lives_on_the_requested_device(self, img): + t = feature_tensor(img[:32, :32], device=DEVICE) + assert t.device.type == "cpu" and t.dtype.is_floating_point + + +# ── label store ────────────────────────────────────────────────────────────── + +class TestLabelStore: + def test_paint_and_read_back(self): + store = LabelStore(frame_shape=(16, 20)) + mask = np.zeros((16, 20), bool) + mask[2:5, 3:6] = True + assert store.paint(0, mask, 0) == 9 + assert len(store) == 9 + assert store.labelled_frames() == [0] + lm = store.label_map(0) + assert (lm[2:5, 3:6] == 0).all() + assert (lm[8:, :] == UNLABELLED).all() + + def test_labels_accumulate_across_frames(self): + """Plan B3: paint on frame 0 and frame 400, both train one model.""" + store = LabelStore(frame_shape=(16, 20)) + store.paint_disc(0, 4, 4, 2, 0) + store.paint_disc(400, 9, 9, 2, 1) + assert store.labelled_frames() == [0, 400] + assert len(store) == len(store.at(0)[0]) + len(store.at(400)[0]) + assert set(store.counts()) >= {0, 1} + + def test_repainting_a_pixel_changes_its_class(self): + """Last write wins, which is what a brush does. A `np.unique` that kept + the FIRST occurrence would silently ignore a correction.""" + store = LabelStore(frame_shape=(8, 8)) + store.paint(0, [(3, 3)], 0) + store.paint(0, [(3, 3)], 1) + assert len(store) == 1 + assert store.label_map(0)[3, 3] == 1 + + def test_erase_removes_rather_than_reassigns(self): + store = LabelStore(frame_shape=(8, 8)) + store.paint(0, [(1, 1), (1, 2)], 0) + store.erase(0, [(1, 1)]) + assert len(store) == 1 + assert store.label_map(0)[1, 1] == UNLABELLED + + def test_erasing_everything_drops_the_frame(self): + store = LabelStore(frame_shape=(8, 8)) + store.paint(0, [(1, 1)], 0) + store.erase(0, [(1, 1)]) + assert store.labelled_frames() == [] + + def test_paint_stroke_is_continuous(self): + """The brush widget emits one sample per pointer frame, so a fast stroke + jumps many pixels; dabbing only at the samples leaves a dotted line.""" + store = LabelStore(frame_shape=(32, 32)) + store.paint_stroke(0, [(5, 2), (5, 28)], 0, brush=1.0) + row = store.label_map(0)[5] + painted = np.flatnonzero(row != UNLABELLED) + assert painted.min() <= 2 and painted.max() >= 28 + assert np.all(np.diff(painted) == 1), f"gaps in the stroke: {painted}" + + def test_paint_stroke_honours_brush_width(self): + store = LabelStore(frame_shape=(32, 32)) + thin = store.paint_stroke(0, [(10, 5), (10, 25)], 0, brush=1.0) + store.clear() + fat = store.paint_stroke(0, [(10, 5), (10, 25)], 0, brush=7.0) + assert fat > 3 * thin + + def test_out_of_frame_coordinates_are_dropped_not_wrapped(self): + """A stroke running off the edge is normal; wrapping it would paint the + opposite side of the image.""" + store = LabelStore(frame_shape=(10, 10)) + store.paint(0, [(-3, 4), (4, 40), (5, 5)], 0) + assert len(store) == 1 + assert store.label_map(0)[5, 5] == 0 + + def test_a_stroke_off_the_edge_paints_only_what_is_inside(self): + store = LabelStore(frame_shape=(16, 16)) + store.paint_stroke(0, [(8, -6), (8, 6)], 0, brush=3.0) + lm = store.label_map(0) + # The stroke ends at x=6 with a radius-1.5 brush, so nothing past x=7. + assert (lm[:, 8:] == UNLABELLED).all() + assert (lm[8, 0:6] == 0).all(), "the in-frame part of the stroke is missing" + + def test_counts_lists_classes_with_no_pixels(self): + """Which is how the caret shows a class is under-trained.""" + store = LabelStore(frame_shape=(8, 8)) + store.paint_disc(0, 4, 4, 2, 0) + counts = store.counts() + assert set(counts) == {0, 1, 2} + assert counts[1] == counts[2] == 0 + assert store.n_classes_used == 1 + + def test_add_and_remove_classes(self): + store = LabelStore(frame_shape=(8, 8)) + extra = store.add_class("beam stop", "#ff0000") + assert extra.id == 3 and store.class_by_id(3).name == "beam stop" + store.paint(0, [(1, 1)], 3) + store.paint(0, [(2, 2)], 0) + store.remove_class(3) + assert 3 not in store.class_ids + assert len(store) == 1, "removing a class must drop its pixels too" + + def test_removing_the_only_labelled_class_drops_the_frame(self): + store = LabelStore(frame_shape=(8, 8)) + store.paint(0, [(1, 1)], 0) + store.remove_class(0) + assert store.labelled_frames() == [] + + def test_unknown_class_raises(self): + store = LabelStore(frame_shape=(8, 8)) + with pytest.raises(KeyError, match="no class with id"): + store.paint(0, [(1, 1)], 99) + + def test_duplicate_class_ids_raise(self): + with pytest.raises(ValueError, match="duplicate class ids"): + LabelStore(frame_shape=(8, 8), + classes=[ScribbleClass(0, "a"), ScribbleClass(0, "b")]) + store = LabelStore(frame_shape=(8, 8)) + with pytest.raises(ValueError, match="already exists"): + store.add_class("dup", id=0) + + def test_mask_of_the_wrong_shape_raises(self): + store = LabelStore(frame_shape=(8, 8)) + with pytest.raises(ValueError, match="frame_shape"): + store.paint(0, np.ones((4, 4), bool), 0) + + def test_round_trips_through_a_dict(self): + store = LabelStore(frame_shape=(16, 24)) + store.paint_disc(0, 5, 5, 2, 0) + store.paint_stroke(7, [(1, 1), (1, 20)], 1, brush=2.0) + d = store.to_dict() + assert json.loads(json.dumps(d)) == d, "not JSON-safe" + back = LabelStore.from_dict(d) + assert back.frame_shape == store.frame_shape + assert back.labelled_frames() == store.labelled_frames() + assert back.counts() == store.counts() + for t in store.labelled_frames(): + assert np.array_equal(back.label_map(t), store.label_map(t)) + + def test_clear_frame_and_clear(self): + store = LabelStore(frame_shape=(8, 8)) + store.paint(0, [(1, 1)], 0) + store.paint(1, [(2, 2)], 0) + store.clear_frame(0) + assert store.labelled_frames() == [1] + store.clear() + assert len(store) == 0 + + +# ── the prompt-model bootstrap ─────────────────────────────────────────────── + +class TestMasksToLabels: + """Plan §0.4: prompt masks become scribble labels with no painting at all.""" + + @staticmethod + def _disc(shape, cy, cx, r): + yy, xx = np.mgrid[0:shape[0], 0:shape[1]] + return (yy - cy) ** 2 + (xx - cx) ** 2 <= r * r + + def test_interior_is_particle_and_surroundings_are_background(self): + m = self._disc((64, 64), 20, 20, 6) + store = masks_to_labels(m, t=3) + assert store.labelled_frames() == [3] + lm = store.label_map(3) + assert lm[20, 20] == 0, "mask centre is not the particle class" + assert (lm[m] != 1).all(), "background was painted inside the mask" + assert (lm == 1).sum() > 0, "no background ring" + + def test_the_boundary_itself_is_left_unlabelled(self): + """Where the prompt model is least certain and where a particle's own soft + edge lives — labelling it either way biases every instance's size.""" + m = self._disc((64, 64), 32, 32, 8) + lm = masks_to_labels(m, gap=2, erode=1).label_map(0) + yy, xx = np.mgrid[0:64, 0:64] + r = np.sqrt((yy - 32) ** 2 + (xx - 32) ** 2) + rim = (r > 8.0) & (r <= 9.0) + assert (lm[rim] == UNLABELLED).all() + + def test_the_ring_never_covers_another_mask(self): + """Otherwise particle A is taught as background for particle B.""" + a = self._disc((64, 64), 32, 24, 5) + b = self._disc((64, 64), 32, 34, 5) + lm = masks_to_labels(np.stack([a, b])).label_map(0) + assert (lm[a] != 1).all() and (lm[b] != 1).all() + + def test_a_tiny_mask_survives_the_erosion(self): + """Plan §0.9 applied here: a 3 px particle is exactly the object this + feature exists for, and eroding it away would defeat the bootstrap.""" + m = np.zeros((32, 32), bool) + m[16, 16] = True + lm = masks_to_labels(m, erode=1).label_map(0) + assert lm[16, 16] == 0 + + def test_accumulates_into_an_existing_store(self): + store = masks_to_labels(self._disc((64, 64), 20, 20, 5), t=0) + n0 = len(store) + masks_to_labels(self._disc((64, 64), 44, 44, 5), t=9, store=store) + assert store.labelled_frames() == [0, 9] + assert len(store) > n0 + + def test_a_trained_model_finds_the_prompted_particles(self, movie, geom): + """The end of the §0.4 handoff: masks in, dense segmentation out.""" + s, _gt = movie + pos, radii, present, faint, shape = geom + bright = [i for i in np.flatnonzero(present) if not faint[i]][:3] + masks = np.stack([self._disc(shape, pos[i, 0], pos[i, 1], radii[i]) + for i in bright]) + store = masks_to_labels(masks, t=FRAME_T) + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0) + clf.fit(store, {FRAME_T: s.data[FRAME_T]}) + prob = clf.predict_proba(s.data[FRAME_T]) + assert all(hit(prob, pos, i) for i in bright) + assert (prob > 0.5).mean() < 0.25, "foreground has swallowed the frame" + + @pytest.mark.parametrize("kw, match", [ + ({"gap": 8, "background_dilation": 4}, "background_dilation"), + ({"particle_class": 99}, "no class with id"), + ]) + def test_errors(self, kw, match): + m = self._disc((32, 32), 16, 16, 4) + with pytest.raises((ValueError, KeyError), match=match): + masks_to_labels(m, **kw) + + def test_a_4d_input_raises(self): + with pytest.raises(ValueError, match=r"\(h, w\) or \(n, h, w\)"): + masks_to_labels(np.zeros((2, 2, 8, 8), bool)) + + def test_a_store_of_the_wrong_shape_raises(self): + store = LabelStore(frame_shape=(16, 16)) + with pytest.raises(ValueError, match="the store holds"): + masks_to_labels(np.ones((32, 32), bool), store=store) + + +# ── training ───────────────────────────────────────────────────────────────── + +class TestTraining: + def test_report_describes_what_was_trained(self, trained, labels): + rep = trained.report + assert rep["n_pixels"] == len(labels) + assert rep["n_channels"] == FeatureSpec().n_channels + assert rep["labelled_frames"] == labels.labelled_frames() + assert rep["train_accuracy"] > 0.95 + assert rep["featurise_s"] > 0 and rep["fit_s"] > 0 + assert set(rep["pixels_per_class"]) == {"0", "1"} + + def test_only_painted_classes_become_head_columns(self, trained): + """The default store offers three classes; only two were painted, and a + column that never sees a positive example would emit noise.""" + assert [c.id for c in trained.classes] == [0, 1] + assert trained.particle_class_ids == [0] + + def test_multi_class_prediction(self, movie, geom): + s, _gt = movie + pos, radii, present, _faint, shape = geom + store = paint_scribbles(geom) + dark = np.zeros(shape, bool) + dark[0:6, 30:90] = True + store.paint(FRAME_T, dark & _clear_of_particles(shape, pos, radii, + present, 3.0), 2) + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0) + rep = clf.fit(store, {FRAME_T: s.data[FRAME_T]}) + assert rep["n_classes"] == 3 + lbl = clf.predict_labels(s.data[FRAME_T]) + assert set(np.unique(lbl).tolist()) == {0, 1, 2} + # The particle probability is still only the particle class. + prob = clf.predict_proba(s.data[FRAME_T]) + assert prob.max() <= 1.0 and (prob > 0.5).mean() < 0.25 + + def test_progress_is_reported_per_labelled_frame(self, movie, geom): + s, _gt = movie + store = paint_scribbles(geom) + store.paint_disc(20, 30, 30, 3, 1) + calls = [] + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0, epochs=20) + clf.fit(store, s.data, progress=lambda d, t: calls.append((d, t))) + assert calls[-1] == (2, 2) + assert all(t == 2 for _d, t in calls) + + def test_trains_from_a_hyperspy_signal_without_materialising_it(self, movie, + geom): + """`frames` goes through `drift.frames.frame_source`, which reads one + frame at a time — the CLAUDE.md memory-safety rule.""" + s, _gt = movie + store = paint_scribbles(geom) + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0, epochs=20) + assert clf.fit(store, s)["n_pixels"] == len(store) + + def test_an_empty_store_raises(self, movie): + s, _gt = movie + clf = ScribbleClassifier(device=DEVICE) + with pytest.raises(ValueError, match="nothing painted"): + clf.fit(LabelStore(frame_shape=s.data[0].shape), s.data) + + def test_one_class_only_raises(self, movie): + s, _gt = movie + store = LabelStore(frame_shape=s.data[0].shape) + store.paint_disc(0, 20, 20, 3, 0) + clf = ScribbleClassifier(device=DEVICE) + with pytest.raises(ValueError, match="only one class"): + clf.fit(store, s.data) + + def test_a_frame_of_the_wrong_shape_raises(self, movie, geom): + """A flat index means nothing without the shape; silently accepting a + different one would scatter the training samples.""" + s, _gt = movie + store = paint_scribbles(geom) + clf = ScribbleClassifier(device=DEVICE) + with pytest.raises(ValueError, match="label store holds"): + clf.fit(store, {FRAME_T: s.data[FRAME_T][:64, :64]}) + + def test_predicting_before_training_raises(self, movie): + s, _gt = movie + with pytest.raises(RuntimeError, match="not been trained"): + ScribbleClassifier(device=DEVICE).predict_proba(s.data[0]) + + def test_no_particle_class_is_a_clear_error(self, movie, geom): + """A softmax over three backgrounds is a valid model with no foreground; + say so instead of returning a zero map.""" + s, _gt = movie + pos, radii, present, faint, shape = geom + store = LabelStore(frame_shape=shape, + classes=[ScribbleClass(0, "film", particle=False), + ScribbleClass(1, "vacuum", particle=False)]) + store.paint(FRAME_T, _clear_of_particles(shape, pos, radii, present, 3.0) + & (np.mgrid[0:shape[0], 0:shape[1]][0] < 20), 0) + store.paint(FRAME_T, _clear_of_particles(shape, pos, radii, present, 3.0) + & (np.mgrid[0:shape[0], 0:shape[1]][0] > 80), 1) + clf = ScribbleClassifier(device=DEVICE, epochs=20) + clf.fit(store, {FRAME_T: s.data[FRAME_T]}) + with pytest.raises(RuntimeError, match="marked as a particle class"): + clf.predict_proba(s.data[FRAME_T]) + + def test_class_probabilities_sum_to_one_where_valid(self, movie, trained): + s, _gt = movie + p = trained.predict_class_proba(s.data[FRAME_T]) + assert np.abs(p.sum(axis=0) - 1.0).max() < 1e-4 + + def test_segment_forwards_to_the_shared_instance_split(self, movie, trained, + geom): + """Plan §0.2: this engine stops at a probability map; the instance stage + is written once, in classical.split_instances.""" + s, _gt = movie + pos, _radii, present, _faint, _shape = geom + lab = trained.segment(s.data[FRAME_T], SegmentParams(min_size=8)) + assert lab.dtype == np.int32 + assert lab.max() >= int(present.sum()) - 1 # the merge pair may join + for i in np.flatnonzero(present): + assert hit(lab, pos, i), f"particle {i} missing from the instances" + + +# ── the acceptance gates ───────────────────────────────────────────────────── + +class TestTheClassicalBaselineMissesThem: + """Makes :class:`TestSensitivityGate` non-vacuous, in this file. + + ``test_particle_movie_fixture.py::TestSegmentationOnTheFixture`` already pins + that the classical engine misses the faint probes at its default sensitivity; + this repeats the measurement next to the gate it justifies, because a gate + whose baseline lives in another file is one rename away from being vacuous. + """ + + def test_classical_finds_the_bright_particles(self, movie, geom): + s, _gt = movie + pos, _radii, present, faint, _shape = geom + lab = segment_frame(s.data[FRAME_T], + SegmentParams(min_size=25, gaussian=1.0)) + want = np.flatnonzero(present & ~faint) + assert all(hit(lab, pos, i) for i in want) + + def test_classical_finds_neither_faint_probe(self, movie, geom): + s, _gt = movie + pos, _radii, _present, faint, _shape = geom + lab = segment_frame(s.data[FRAME_T], + SegmentParams(min_size=25, gaussian=1.0)) + found = [int(i) for i in np.flatnonzero(faint) if hit(lab, pos, i)] + assert found == [], ( + f"the classical engine now finds faint probes {found}; the §0.9 gate " + "below is no longer measuring anything and the fixture's " + "faint_amplitude should be lowered") + + def test_a_looser_threshold_does_not_rescue_it(self, movie, geom): + """Why §0.9 says the learned classifier is the primary path and threshold + tuning is not: by the time the threshold is loose enough to include the + faint probes it has merged the film into one giant region.""" + s, _gt = movie + pos, _radii, _present, faint, _shape = geom + rescued = False + for sens in (0.6, 0.7, 0.8, 0.9, 1.0): + lab = segment_frame( + s.data[FRAME_T], + SegmentParams(min_size=25, gaussian=1.0, sensitivity=sens)) + got = [int(i) for i in np.flatnonzero(faint) if hit(lab, pos, i)] + if len(got) == 2: + # Only counts as a rescue if the frame is still a segmentation. + fg = float((lab > 0).mean()) + rescued = fg < 0.30 + assert not rescued, ( + "raising the classical sensitivity now finds both faint probes " + "without flooding the frame — if that is a real improvement this " + "test has served its purpose, but check deliberately") + + +class TestSensitivityGate: + """**Plan §0.9 — the headline gate for the whole feature.** + + Trained on eleven scribbles (four dabs on bright particles, one dab on the + smaller faint probe, four background sweeps and four boundary rings), the + classifier must find BOTH faint probes and keep every bright one. + + Note what the scribbles do and do not contain. Faint probe **8** (r=3) is + painted; probe **7** (r=4) is not, so its detection is genuinely held out. + :class:`TestBrightOnlyLabelsDoNotGeneralise` records the boundary of the + claim: with *no* faint example at all, neither this head nor the RandomForest + reference finds them, so at least one faint scribble is required. That is a + property of the problem — an 8x contrast extrapolation — not of the head. + """ + + def test_finds_both_faint_probes(self, movie, geom, proba): + _s, _gt = movie + pos, _radii, _present, faint, _shape = geom + scores = {int(i): float(proba[int(round(pos[i, 0])), + int(round(pos[i, 1]))]) + for i in np.flatnonzero(faint)} + assert all(v > 0.5 for v in scores.values()), ( + f"faint probes not found: {scores}") + + def test_the_held_out_faint_probe_is_found(self, geom, proba): + """Probe 7 is never painted. This is the part that is not memorisation.""" + pos = geom[0] + assert proba[int(round(pos[7, 0])), int(round(pos[7, 1]))] > 0.5 + + def test_every_bright_particle_is_still_found(self, geom, proba): + """Sensitivity that costs the easy detections is not sensitivity.""" + pos, _radii, present, faint, _shape = geom + for i in np.flatnonzero(present & ~faint): + assert hit(proba, pos, i), f"lost bright particle {i}" + + def test_the_foreground_has_not_swallowed_the_frame(self, geom, proba): + """A classifier that calls everything a particle would pass every test + above. The nine discs cover ~9% of the frame.""" + fraction = float((proba > 0.5).mean()) + assert 0.05 < fraction < 0.20, ( + f"foreground is {fraction:.1%} of the frame; the true particle " + "coverage is ~9%") + + def test_the_instance_count_is_right(self, movie, geom, proba): + _s, _gt = movie + present = geom[2] + lab = split_instances(proba, SegmentParams(min_size=8)) + assert int(present.sum()) - 1 <= lab.max() <= int(present.sum()) + 1, ( + f"found {lab.max()} instances, expected ~{int(present.sum())}") + + def test_it_generalises_to_frames_it_never_saw(self, movie, trained): + """Labels came from t=12 alone. Every other frame has different drift, + different particles present, and — at t=8 onwards — a nucleated one.""" + s, gt = movie + for t in (0, 6, 18, int(gt["n_frames"]) - 1): + prob = trained.predict_proba(s.data[t]) + pos, _radii, present = particle_truth_at(gt, t) + faint = np.asarray(gt["p_faint"], bool) + h, w = s.data[t].shape + missed = [] + for i in np.flatnonzero(present): + cy, cx = int(round(pos[i, 0])), int(round(pos[i, 1])) + if not (0 <= cy < h and 0 <= cx < w): + continue # drifted out of frame + if prob[cy, cx] <= 0.5: + missed.append((int(i), bool(faint[i]))) + assert not missed, f"frame {t}: missed {missed}" + + def test_a_coarse_stack_undersizes_the_small_particles(self, movie, geom, + labels): + """What the fine scales actually buy — and it is NOT detection. + + Plan §0.9 says small-object detection needs the fine scales and forbids + coarsening "without a documented sensitivity measurement". This is that + measurement, and it corrects the guess: a coarse ``(4, 8)`` stack still + *finds* both faint probes. What it loses is their size. Measured mean + absolute error in recovered radius over the isolated particles: + + (0.5, 1, 2, 4, 8) 13 % (2, 4, 8) 20 % (4, 8) 26 % + + A particle found and then measured 44% too small (the r=3 probe on the + ``(4, 8)`` stack) is worse than an honest miss, because it silently enters + the size distribution the whole feature exists to produce. + """ + from spyde.particles import measure_frame + from spyde.signals.particles import COL + + s, gt = movie + pos, radii, present, _faint, _shape = geom + merge = tuple(int(v) for v in gt["merge_pair"]) + + def radius_error(sigmas): + clf = ScribbleClassifier(FeatureSpec(sigmas=sigmas), device=DEVICE, + seed=0) + clf.fit(labels, {FRAME_T: s.data[FRAME_T]}) + lab = split_instances(clf.predict_proba(s.data[FRAME_T]), + SegmentParams(min_size=8)) + rows, _c = measure_frame(lab, s.data[FRAME_T], t=FRAME_T, scale=1.0) + errs = [] + for i in np.flatnonzero(present): + if i in merge: + continue # a merged pair is not one disc + lbl = lab[int(round(pos[i, 0])), int(round(pos[i, 1]))] + row = rows[rows[:, COL["label"]] == lbl] + assert lbl and len(row), f"sigmas={sigmas}: lost particle {i}" + got = float(row[0, COL["equiv_diameter"]]) / 2.0 + errs.append(abs(got - radii[i]) / radii[i]) + return float(np.mean(errs)) + + fine = radius_error(DEFAULT_SIGMAS) + coarse = radius_error((4.0, 8.0)) + assert fine < 0.20, f"default stack radius error {fine:.1%}" + assert coarse > 1.5 * fine, ( + f"a coarse (4, 8) stack now measures radii as well as the default one " + f"({coarse:.1%} vs {fine:.1%}) — if real, the sigma floor could be " + "raised, but re-measure deliberately before doing it") + + def test_the_nucleating_particle_appears_at_its_known_frame(self, movie, + trained): + """§0.9's actual motivation: missing a particle's first appearance + destroys the nucleation event.""" + s, gt = movie + i, nuc = int(gt["nucleation_index"]), int(gt["nucleation_frame"]) + before = trained.predict_proba(s.data[nuc - 1]) + after = trained.predict_proba(s.data[nuc]) + pos_b = particle_truth_at(gt, nuc - 1)[0] + pos_a = particle_truth_at(gt, nuc)[0] + assert before[int(round(pos_b[i, 0])), int(round(pos_b[i, 1]))] < 0.5 + assert after[int(round(pos_a[i, 0])), int(round(pos_a[i, 1]))] > 0.5 + + +class TestBrightOnlyLabelsAreNotEnough: + """Records the boundary of the §0.9 claim, and that it is not the head's fault. + + Trained on bright particles only — no faint example anywhere in the labels — + the head finds **1 of the 2** faint probes (0.67 for the r=3 one, 0.013 for the + r=4 one) and the sklearn RandomForest reference finds **0 of 2** (both exactly + 0.0). Adding a *single seven-pixel dab* on the r=3 probe takes the head to + 2 of 2 at 0.98 and 1.00. + + **Those exact counts depend on where the background scribbles land**, so the + assertion below is the robust `< 2` rather than `== 1`. An independent probe + with different background dabs got **0 of 2** bright-only and 1 of 2 with the + dab — same conclusion, different numbers. Read the figures above as one + observation, not as a constant; the claim being pinned is only that bright-only + labels are *not sufficient*. + + Two things worth having in the record: + + * The plan's §0.9 gate holds as written, with a clarification: "trained on a + few scribbles" must include **at least one faint example**. That is what a + user does anyway — they paint what they can see, and they can see these — + and it is why the wizard's class list shows per-class pixel counts (plan + B7): under-training a class is the failure mode, and the counts are how you + notice. + * The forest's 0.0 is not noise, it is structural. A tree ensemble cannot + predict outside the leaves its training data reached, so a contrast 8x below + anything it was shown is simply unreachable; the MLP extrapolates its + decision boundary and gets one of the two for free. So for *sensitivity* + specifically — which §0.9 makes the priority — the shipped head is better + than the reference it is checked against, not merely faster. + """ + + @pytest.fixture(scope="class") + def bright_only(self, movie, geom): + s, _gt = movie + store = paint_scribbles(geom, include_faint=()) + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0) + clf.fit(store, {FRAME_T: s.data[FRAME_T]}) + return store, clf, clf.predict_proba(s.data[FRAME_T]) + + def test_the_head_does_not_find_both(self, geom, bright_only): + pos, _radii, _present, faint, _shape = geom + _store, _clf, prob = bright_only + scores = {int(i): float(prob[int(round(pos[i, 0])), + int(round(pos[i, 1]))]) + for i in np.flatnonzero(faint)} + found = [i for i, v in scores.items() if v > 0.5] + assert len(found) < 2, ( + f"bright-only labels now generalise to BOTH faint probes ({scores}) — " + "if that is real, TestSensitivityGate can drop its faint scribble and " + "become a much stronger claim; check deliberately") + + def test_the_bright_particles_are_still_all_found(self, geom, bright_only): + pos, _radii, present, faint, _shape = geom + _store, _clf, prob = bright_only + assert all(hit(prob, pos, i) for i in np.flatnonzero(present & ~faint)) + + def test_the_random_forest_reference_finds_neither(self, movie, geom, + bright_only): + """The forest cannot extrapolate past its leaves; the MLP can. This is the + one place the shipped head beats its own reference.""" + s, _gt = movie + pos, _radii, _present, faint, _shape = geom + store, _clf, _prob = bright_only + _rf, predict = random_forest_reference( + store, {FRAME_T: s.data[FRAME_T]}, FeatureSpec(), device=DEVICE) + rp = predict(s.data[FRAME_T]) + assert max(float(rp[int(round(pos[i, 0])), int(round(pos[i, 1]))]) + for i in np.flatnonzero(faint)) < 0.5 + + def test_one_extra_dab_is_all_it_takes(self, movie, geom, bright_only): + """The interaction this feature is actually for: seven more labelled + pixels, one retrain, both probes found.""" + s, _gt = movie + pos, _radii, _present, faint, _shape = geom + store, _clf, _prob = bright_only + with_faint = paint_scribbles(geom) + assert 0 < len(with_faint) - len(store) < 20, ( + "the extra scribble is no longer a single small dab, so this stopped " + "being a statement about how little labelling is needed") + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0) + clf.fit(with_faint, {FRAME_T: s.data[FRAME_T]}) + prob = clf.predict_proba(s.data[FRAME_T]) + assert all(hit(prob, pos, i) for i in np.flatnonzero(faint)) + + +class TestRandomForestParity: + """**Plan B3's acceptance gate**: agreement with the reference implementation. + + ``sklearn.ensemble.RandomForestClassifier`` is what ParticleSpy and ilastik + use. Both sides read the *identical* channels through the *identical* sampler + (``random_forest_reference`` lives in ``scribble.py`` for exactly that + reason), so a disagreement is about the head and nothing else. + + Measured IoU of the two foreground masks on the fixture: **0.94**. The gate is + set at 0.75 — the margin is deliberate, because the objects are small discs + whose IoU is dominated by a one-pixel boundary difference, and a tighter bar + would fail on an epoch-count change that means nothing (0.94 at 300 epochs, + 0.90 at 200, 0.86 at 500). + """ + + @pytest.fixture(scope="class") + def forest(self, movie, labels): + s, _gt = movie + _rf, predict = random_forest_reference( + labels, {FRAME_T: s.data[FRAME_T]}, FeatureSpec(), device=DEVICE) + return predict(s.data[FRAME_T]) + + def test_foreground_masks_agree_by_iou(self, proba, forest): + a, b = proba > 0.5, forest > 0.5 + iou = float((a & b).sum()) / max(1, int((a | b).sum())) + assert iou > 0.75, ( + f"IoU {iou:.3f} against the RandomForest reference on identical " + "labels and identical features") + + def test_both_agree_on_every_ground_truth_particle(self, geom, proba, forest): + pos, _radii, present, _faint, _shape = geom + for i in np.flatnonzero(present): + assert hit(proba, pos, i) == hit(forest, pos, i), ( + f"the head and the forest disagree about particle {i}") + + def test_they_cover_a_similar_fraction_of_the_frame(self, proba, forest): + """A high IoU with wildly different coverage would mean one is a subset of + the other, which is a different kind of agreement.""" + fa, fb = float((proba > 0.5).mean()), float((forest > 0.5).mean()) + assert abs(fa - fb) < 0.05, f"coverage {fa:.3f} vs {fb:.3f}" + + +class TestNaNBorder: + """**Plan trap 2 / gate A7**: no particle is ever detected in NaN padding. + + A drift-corrected frame keeps its full size with uncovered pixels set to NaN + (``spyde.drift.warp``). Segmentation that ignores that finds a large "particle" + along the edge, which then nucleates a spurious track — the single most likely + integration bug in this feature. + """ + + @pytest.fixture(scope="class") + def warped(self, movie): + s, _gt = movie + out = shift_frame(s.data[FRAME_T], (7.0, -5.0)) # NaN on two edges + assert not np.isfinite(out).all(), "no NaN padding to test" + return out + + def test_probability_is_exactly_zero_in_the_padding(self, trained, warped): + prob = trained.predict_proba(warped) + bad = ~np.isfinite(warped) + assert float(prob[bad].max()) == 0.0 + + def test_no_instance_lands_in_the_padding(self, trained, warped): + prob = trained.predict_proba(warped) + lab = split_instances(prob, SegmentParams(min_size=8)) + assert set(np.unique(lab[~np.isfinite(warped)]).tolist()) == {0} + + def test_predict_labels_reports_unlabelled_not_a_class(self, trained, warped): + """-1, not the background class: an invalid pixel is not a measurement.""" + lbl = trained.predict_labels(warped) + bad = ~np.isfinite(warped) + assert set(np.unique(lbl[bad]).tolist()) == {UNLABELLED} + assert (lbl[~bad] != UNLABELLED).all() + + def test_real_data_next_to_the_padding_still_classifies(self, trained, warped, + movie): + """The other half of the contract: NaN must not erase a band of real data. + A filter that propagated NaN outward would blank ~33 px (the halo) inside + the border, which is most of a 96-row frame.""" + prob = trained.predict_proba(warped) + good = np.isfinite(warped) + assert float(prob[good].max()) > 0.5, "everything valid came back empty" + assert float((prob[good] > 0.5).mean()) > 0.02 + + def test_the_shifted_particles_are_found_at_their_shifted_positions( + self, movie, trained, warped, geom): + pos, _radii, present, _faint, _shape = geom + prob = trained.predict_proba(warped) + h, w = warped.shape + found = 0 + for i in np.flatnonzero(present): + cy, cx = int(round(pos[i, 0] + 7.0)), int(round(pos[i, 1] - 5.0)) + if 0 <= cy < h and 0 <= cx < w and np.isfinite(warped[cy, cx]): + found += prob[cy, cx] > 0.5 + assert found >= 6, f"only {found} particles survived the warp" + + +class TestDeterminism: + """Same seed + same labels → the same model. A user who changed nothing must + not see the segmentation change.""" + + def test_two_fits_with_the_same_seed_are_identical(self, movie, labels): + s, _gt = movie + outs = [] + for _ in range(2): + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0, + epochs=60) + clf.fit(labels, {FRAME_T: s.data[FRAME_T]}) + outs.append(clf.predict_proba(s.data[FRAME_T])) + assert np.array_equal(*outs) + + def test_the_seed_actually_does_something(self, movie, labels): + s, _gt = movie + outs = [] + for seed in (0, 1): + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=seed, + epochs=60) + clf.fit(labels, {FRAME_T: s.data[FRAME_T]}) + outs.append(clf.predict_proba(s.data[FRAME_T])) + assert not np.array_equal(*outs) + + def test_initialisation_does_not_consume_the_global_rng(self, movie, labels): + """The global torch stream is shared with the neural detector and every + dask worker; drawing from it would make 'same seed' depend on what else + ran first.""" + import torch + torch.manual_seed(1234) + before = torch.randn(3) + torch.manual_seed(1234) + ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0, epochs=5).fit( + labels, {FRAME_T: movie[0].data[FRAME_T]}) + after = torch.randn(3) + assert torch.equal(before, after) + + +class TestSaveLoad: + """A recipe is the spec *and* the weights *and* the standardisation; any of + them alone predicts nonsense, so they live in one file.""" + + def test_round_trip_predicts_identically(self, movie, trained, tmp_path): + s, _gt = movie + path = str(tmp_path / "model.npz") + trained.save(path) + back = ScribbleClassifier.load(path, device=DEVICE) + assert np.array_equal(back.predict_proba(s.data[FRAME_T]), + trained.predict_proba(s.data[FRAME_T])) + + def test_carries_the_spec_and_the_classes(self, trained, tmp_path): + path = str(tmp_path / "model.npz") + trained.save(path) + back = ScribbleClassifier.load(path, device=DEVICE) + assert back.spec == trained.spec + assert [c.to_dict() for c in back.classes] == \ + [c.to_dict() for c in trained.classes] + assert back.hidden == trained.hidden and back.seed == trained.seed + + def test_loads_without_pickle(self, trained, tmp_path): + """A model file must not be able to execute code on load.""" + path = str(tmp_path / "model.npz") + trained.save(path) + with np.load(path, allow_pickle=False) as z: + assert "meta" in z.files and "feature_mean" in z.files + + def test_a_recipe_applies_to_a_differently_scaled_dataset(self, movie, + trained, tmp_path): + """What ``normalize_frame`` buys: the same sample recorded as counts + rather than as a normalised float still segments.""" + s, _gt = movie + path = str(tmp_path / "model.npz") + trained.save(path) + back = ScribbleClassifier.load(path, device=DEVICE) + rescaled = s.data[FRAME_T] * 4096.0 + 100.0 + a = back.predict_proba(s.data[FRAME_T]) > 0.5 + b = back.predict_proba(rescaled) > 0.5 + iou = float((a & b).sum()) / max(1, int((a | b).sum())) + assert iou > 0.95, f"IoU across an intensity rescale is only {iou:.3f}" + + def test_saving_before_training_raises(self, tmp_path): + with pytest.raises(RuntimeError, match="not been trained"): + ScribbleClassifier(device=DEVICE).save(str(tmp_path / "x.npz")) + + def test_a_future_format_version_is_refused(self, trained, tmp_path): + path = tmp_path / "model.npz" + trained.save(str(path)) + with np.load(path, allow_pickle=False) as z: + arrays = {k: z[k] for k in z.files} + meta = json.loads(str(arrays.pop("meta").item())) + meta["format_version"] = 99 + np.savez_compressed(path, meta=np.array(json.dumps(meta)), **arrays) + with pytest.raises(ValueError, match="format version"): + ScribbleClassifier.load(str(path), device=DEVICE) + + def test_a_spec_that_no_longer_matches_the_weights_is_refused(self, trained, + tmp_path): + """The failure this guards is silent otherwise: a channel count mismatch + would only show as a wrong segmentation.""" + path = tmp_path / "model.npz" + trained.save(str(path)) + with np.load(path, allow_pickle=False) as z: + arrays = {k: z[k] for k in z.files} + meta = json.loads(str(arrays.pop("meta").item())) + meta["spec"]["median"] = False + np.savez_compressed(path, meta=np.array(json.dumps(meta)), **arrays) + with pytest.raises(ValueError, match="come apart"): + ScribbleClassifier.load(str(path), device=DEVICE) + + +class TestInteractionBudget: + """**Plan B3's hard budget**: train + apply to the visible frame under ~1 s. + + Measured here on the 96x112 fixture, CPU, default 36-channel spec: **0.49 s** + total = 14 ms featurise + 457 ms for the 300 Adam steps + 20 ms to apply. The + fit is dominated by per-step dispatch overhead rather than arithmetic — + 1.5 ms/step at any torch thread count from 1 to 24 — so it is a *fixed* cost, + independent of frame size and of how much was painted. + + The bar here is 2.0 s, not 1.0 s: this is a shared CI box and a *timing* + assertion at the measured value is a flake generator. The number that matters + is the one above; this test exists to catch an order-of-magnitude regression. + """ + + def test_train_plus_apply_is_interactive(self, movie, labels): + s, _gt = movie + frame = s.data[FRAME_T] + # Warm torch first: the FIRST op in a fresh process pays a one-time ~1 s + # of thread-pool and kernel-selection cost that no user ever sees twice. + ScribbleClassifier(FeatureSpec(), device=DEVICE, epochs=5).fit( + labels, {FRAME_T: frame}) + + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, seed=0) + t0 = time.perf_counter() + clf.fit(labels, {FRAME_T: frame}) + t1 = time.perf_counter() + clf.predict_proba(frame) + elapsed = time.perf_counter() - t0 + assert elapsed < 2.0, ( + f"train+apply took {elapsed:.2f} s (fit {t1 - t0:.2f} s); the plan's " + "budget is ~1 s and the measured value on this box is 0.49 s") + + def test_applying_to_one_frame_is_a_fraction_of_the_budget(self, movie, + trained): + """Scrubbing the navigator re-applies without re-training, so this is the + number that has to stay small.""" + s, _gt = movie + trained.predict_proba(s.data[0]) # warm + t0 = time.perf_counter() + trained.predict_proba(s.data[1]) + assert time.perf_counter() - t0 < 0.5 + + +# ── the MPS device-serialisation contract ──────────────────────────────────── + +class _FakeDev: + """Stands in for ``torch.device('mps')``; only ``.type`` is consulted. Same + double as ``test_device_lock.py`` uses, so the contract is exercised on any + machine without touching Metal.""" + + def __init__(self, type_="mps"): + self.type = type_ + + def __str__(self): + return self.type + + +def _lock_is_held_by_me() -> bool: + from spyde.device_lock import DEVICE_LOCK + got = [] + + def probe(): + got.append(DEVICE_LOCK.acquire(blocking=False)) + if got[-1]: + DEVICE_LOCK.release() + + t = threading.Thread(target=probe) + t.start() + t.join() + return not got[0] + + +class _LockSpy: + """A stand-in for ``accelerator_lock`` that records entries and nesting depth. + + Two things need pinning and neither is visible from the real lock on a CPU box + (where it is a null context): that the block is entered **with the right + device**, and that every device submission happens **inside** it. So the spy + records the devices it was called with and exposes :attr:`inside`, which the + patched call sites report. The real-lock behaviour on a fake MPS device is + pinned separately in ``test_device_lock.py``. + """ + + def __init__(self): + self.devices: list = [] + self.depth = 0 + + def __call__(self, device=None, **kw): + import contextlib + + @contextlib.contextmanager + def ctx(): + self.devices.append(device) + self.depth += 1 + try: + yield + finally: + self.depth -= 1 + + return ctx() + + @property + def inside(self) -> bool: + return self.depth > 0 + + +class TestDeviceLock: + """Every torch call site in these two modules takes ``accelerator_lock``. + + A lock only works if EVERY participant takes it — the last crash of this class + existed because one path skipped it (CLAUDE.md § GPU Computing). The shared + lock's *identity* and the real-lock behaviour are pinned in + ``test_device_lock.py``; these tests pin the *nesting*, i.e. that no device + submission escapes the block. + """ + + def test_feature_bands_hold_the_real_lock_on_mps(self, monkeypatch): + """``map_feature_bands`` is the ONE torch entry point in features.py — + ``feature_tensor``, ``feature_stack`` and ``sample_features`` all go + through it — so this one acquisition covers the whole module.""" + held = [] + monkeypatch.setattr(feat, "_band_stack", + lambda *a, **k: held.append(_lock_is_held_by_me())) + map_feature_bands(np.zeros((8, 8), np.float32), FeatureSpec(), + device=_FakeDev(), fn=lambda *a: None) + assert held == [True], "the feature stack ran unserialised on MPS" + assert not _lock_is_held_by_me(), "lock leaked" + + def test_no_lock_off_mps(self): + """CUDA concurrency is a deliberate throughput win; serialising it would + be a pure regression.""" + held = [] + map_feature_bands(np.zeros((8, 8), np.float32), + FeatureSpec(sigmas=(1.0,), median=False, minimum=False, + maximum=False), + device=DEVICE, + fn=lambda *a: held.append(_lock_is_held_by_me())) + assert held and not any(held) + + @pytest.mark.parametrize("call", [ + lambda img: feature_tensor(img, device=DEVICE), + lambda img: feature_stack(img, device=DEVICE), + lambda img: sample_features(img, np.array([0, 5]), device=DEVICE), + ]) + def test_every_features_entry_point_takes_the_lock(self, call, monkeypatch): + spy = _LockSpy() + monkeypatch.setattr(feat, "accelerator_lock", spy) + call(np.zeros((16, 16), np.float32)) + assert spy.devices and all(d is DEVICE for d in spy.devices) + assert spy.depth == 0, "lock not released" + + def test_the_fit_takes_the_lock_around_every_submission(self, movie, labels, + monkeypatch): + """The featurise, the optimiser loop and the ``.to(device)`` inside + ``_build_mlp`` — a cold head load is the specific hole CLAUDE.md names.""" + from spyde.particles import scribble as scr + + s, _gt = movie + spy = _LockSpy() + inside = [] + monkeypatch.setattr(scr, "accelerator_lock", spy) + for name in ("sample_features", "_build_mlp"): + real = getattr(scr, name) + monkeypatch.setattr(scr, name, lambda *a, _r=real, **k: ( + inside.append(spy.inside), _r(*a, **k))[1]) + + clf = ScribbleClassifier(FeatureSpec(), device=DEVICE, epochs=5) + clf.fit(labels, {FRAME_T: s.data[FRAME_T]}) + assert len(inside) >= 2 and all(inside), ( + "some of the fit's device work ran outside the lock") + assert spy.devices == [clf.device], ( + f"expected ONE acquisition with the fit's device; got {spy.devices}") + assert spy.depth == 0 + + def test_predict_takes_the_lock_around_every_submission(self, movie, trained, + monkeypatch): + """The interactive path — it fires on every navigator move, which is + exactly the concurrency that took the backend down last time.""" + from spyde.particles import scribble as scr + + s, _gt = movie + spy = _LockSpy() + inside = [] + real = scr.map_feature_bands + monkeypatch.setattr(scr, "accelerator_lock", spy) + monkeypatch.setattr(scr, "map_feature_bands", lambda *a, **k: ( + inside.append(spy.inside), real(*a, **k))[1]) + trained.predict_class_proba(s.data[FRAME_T]) + assert inside == [True] + assert spy.devices == [trained.device] + assert spy.depth == 0 + + def test_save_and_load_take_the_lock(self, trained, tmp_path, monkeypatch): + """Both move tensors across the host/device boundary, which is a + submission — ``save`` reading weights back and ``load`` pushing them out.""" + from spyde.particles import scribble as scr + + path = str(tmp_path / "m.npz") + spy = _LockSpy() + monkeypatch.setattr(scr, "accelerator_lock", spy) + trained.save(path) + assert spy.devices == [trained.device] + spy.devices.clear() + ScribbleClassifier.load(path, device=DEVICE) + assert len(spy.devices) == 1 + assert spy.depth == 0 + + def test_the_random_forest_reference_takes_the_lock_too(self, movie, labels, + monkeypatch): + """It is a test helper, but it still submits torch work from whatever + thread calls it, and an unlocked path is an unlocked path.""" + from spyde.particles import scribble as scr + + s, _gt = movie + spy = _LockSpy() + monkeypatch.setattr(scr, "accelerator_lock", spy) + _rf, predict = random_forest_reference( + labels, {FRAME_T: s.data[FRAME_T]}, + FeatureSpec(sigmas=(2.0,), median=False, minimum=False, + maximum=False), + n_estimators=4, device=DEVICE) + predict(s.data[FRAME_T]) + assert len(spy.devices) == 2 and all(d is DEVICE for d in spy.devices) + assert spy.depth == 0 From 53bce6fee2e40f4a498bed8a4dd1cf2e7ad0249b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 19:24:00 -0500 Subject: [PATCH 07/38] docs(plan): min_size is where the sensitivity trade lands, and min_size=0 is a footgun Adding one faint scribble to bright-only labels takes detection from 7/9 to 8/9 true particles -- and costs 25 spurious instances, because teaching the classifier faint contrast necessarily teaches it to fire on film speckle of similar contrast (foreground pixels 977 -> 1334). min_size=10 removes 24 of those 25. So the classifier is not what buys specificity; the instance-split's size filter is. Three consequences for the caret: a user who zeroes min_size to catch the small ones gets the opposite of what they want; the live preview must report the count AFTER the size filter or the number moves invisibly; and sensitivity and min_size are coupled, so they belong adjacent rather than in separate tabs. Found by looking at the rendered probability map. The tests were green and asserted only on the probes' own probabilities, which is exactly the blind spot CLAUDE.md's 'look at the pixels' rule exists for. --- DRIFT_AND_PARTICLES_PLAN.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/DRIFT_AND_PARTICLES_PLAN.md b/DRIFT_AND_PARTICLES_PLAN.md index 70189a17..80241f31 100644 --- a/DRIFT_AND_PARTICLES_PLAN.md +++ b/DRIFT_AND_PARTICLES_PLAN.md @@ -247,6 +247,31 @@ Consequences: separation trade off against each other (a threshold loose enough to catch faint particles also merges neighbours), so it should be one axis the user moves with live feedback, with splitting parameters secondary. + +> **But `min_size` is NOT secondary — it is where the sensitivity/specificity +> trade actually lands, and `min_size=0` is a footgun.** Measured on the fixture at +> one frame, adding a single faint scribble to bright-only labels: +> +> | labels | `min_size` | instances | true hit | spurious | +> |---|---|---|---|---| +> | bright only | any | 7 | 7/9 | 0 | +> | + one faint dab | **0** | 33 | 8/9 | **25** | +> | + one faint dab | 10 | 9 | 8/9 | 1 | +> +> Teaching the classifier faint contrast necessarily teaches it to fire on film +> speckle of *similar* contrast — foreground pixels go 977 → 1334, and the +> probability map visibly sprays across the background. `min_size=10` removes 24 of +> the 25 spurious instances. So the classifier is not what buys specificity; the +> instance-split's size filter is. +> +> Consequences: (a) a user who zeroes `min_size` to "catch the small ones" gets the +> opposite of what they want, so the caret should floor it or warn; (b) the live +> preview must show the count *after* the size filter, or the number moves for a +> reason the user cannot see; (c) sensitivity and `min_size` are coupled and the two +> should sit adjacent in the caret, not in separate tabs. +> +> Found by looking at the rendered probability map, not by a failing test — the +> tests were green and asserted only on the probes' own probabilities. - Small-object detection needs the feature stack's fine scales — do not downsample frames for speed without a documented sensitivity measurement. From 04f12f0e71c98f65ff5593a85c676042583bb8ff Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 19:52:23 -0500 Subject: [PATCH 08/38] feat(particles): the particle TREE, plus the requires_particles gate Plan step 5, the architectural half. Segmentation spawns a NEW SignalTree rather than decorating the movie it came from -- a segmentation is a derived dataset, like a strain map, not a property of its source. ParticleTree root lazy LABEL MOVIE, one frame per chunk, painted from contours .particles the SpyDEParticles store .source_node the signal it was computed from .nav_map particle frame -> source navigation index .nav_traces count(t) / mean size(t) / event lanes Three things this buys that an attribute would not. It answers Wave D by construction: particles found on a 4D-STEM virtual image record which node they came from and which parent nav positions each covers, so "the mean diffraction pattern for this particle" is a slice rather than a guess -- `particle_nav_positions` is that seam, and it distinguishes the movie case (a particle's pixels are SIGNAL coordinates, so the only nav index is the frame) from the virtual-image case (they ARE nav positions). The label movie is a real dataset, so scrubbing, saving, the report builder and the movie editor need no special case. And re-segmenting produces a sibling to compare against instead of destroying the previous result. The label movie stays lazy at one frame per chunk, and a test asserts that rendering one frame does not compute the stack -- the same patch.object guard find_vectors uses. A materialised label movie is 64 MB PER FRAME at 4096". Wave 0 alongside it: * `requires_particles` in BOTH toolbar filter paths. A test greps the module to enforce that, because a gate added to only one path renders a button that never dispatches -- actions/README.md section 6 records that bug already happening once with requires_vectors. * `lifecycle.wait_for_particles` + `seg_batch_running`. Deliberately no `strict` switch: wait_for_vectors needs one because vectors attach to the tree the user clicked, so an any-tree fallback could re-dispatch forever into a tree-specific gate. Particles live on their OWN tree, so there is no ambiguity to resolve and therefore no knob. Timeout is 600 s not 300 -- segmenting thousands of frames is the stated target and a legitimate eight-minute run must not be abandoned at five. * `particles` registered as a hyperspy signal type (ParticleMap / LazyParticleMap), so downstream actions gate by type instead of hunting up the parent chain -- the same job `insitu` does for Play / Fast-Forward. 22 tests. --- spyde/actions/particle_tree.py | 233 +++++++++++++ .../drawing/toolbars/plot_control_toolbar.py | 8 + spyde/hyperspy_extension.yaml | 12 + spyde/signals/__init__.py | 3 + spyde/signals/particle_map.py | 37 +++ spyde/tests/migrated/test_particle_tree.py | 312 ++++++++++++++++++ 6 files changed, 605 insertions(+) create mode 100644 spyde/actions/particle_tree.py create mode 100644 spyde/signals/particle_map.py create mode 100644 spyde/tests/migrated/test_particle_tree.py diff --git a/spyde/actions/particle_tree.py b/spyde/actions/particle_tree.py new file mode 100644 index 00000000..b1ad008f --- /dev/null +++ b/spyde/actions/particle_tree.py @@ -0,0 +1,233 @@ +""" +particle_tree.py — segmentation spawns a NEW SignalTree. Plan §0.6. + +A segmentation is not a property of the movie it came from; it is a derived +dataset computed *from* it, exactly like a strain map or an orientation map. So +this module builds a **particle tree** rather than hanging a ``particles`` +attribute off the source. + + ParticleTree + root signal : lazy LABEL MOVIE — same nav/signal shape as the source, + each frame painted from stored contours on demand + tree.particles : SpyDEParticles (the CSR store) + tree.source_node : the signal it was computed from + tree.nav_map : source nav indices → particle frame index + navigator : count(t) / mean size(t) / event lanes + +Three things this buys, none of which the attribute form does: + +* **It answers Wave D by construction.** Particles found on a 4D-STEM virtual + image record the node they came from and which parent nav positions each one + covers, so "the mean diffraction pattern for this particle" is a slice of the + source's parent rather than a guess about which grid the coordinates belong to. +* **The label movie is a dataset**, so scrubbing, saving, the report builder and + the movie editor all work on it with no special case. +* **Re-segmenting does not destroy the previous result** — two parameter choices + are two sibling trees you can compare, which is what the signal tree is for. + +The label movie is **lazy and never materialised**. A 4096² int32 label image is +64 MB *per frame*; the contours are the truth and ``render_frame`` paints one +frame when something asks for it. See ``spyde/signals/particles.py``. +""" +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np + +log = logging.getLogger(__name__) + +#: ``_signal_type`` carried by a particle tree's root. Toolbar entries gate on +#: this to offer particle actions (track, export, per-particle DP) on the result +#: and nowhere else. +PARTICLE_SIGNAL_TYPE = "particles" + + +def _label_movie(particles, *, chunk_frames: int = 1): + """A lazy ``(n_frames, h, w)`` int32 label movie backed by the contours. + + One frame per chunk, deliberately: each nav move then reads exactly the frame + it needs, which is both the cheapest possible read and the same access + granularity a real in-situ movie has (CLAUDE.md Live-Display §1). Painting is + done inside the dask graph, so no frame exists until something asks for it. + """ + import dask + import dask.array as da + + h, w = particles.frame_shape + n = particles.n_frames + + def _one(block_info=None): + # block_info tells us which frame this block is; there is exactly one + # frame per block, so the slice start IS the frame index. + t = 0 if block_info is None else int(block_info[None]["array-location"][0][0]) + try: + return particles.render_frame(t, value="track")[None, ...] + except Exception as exc: # pragma: no cover + log.debug("[particles] frame %d render failed: %s", t, exc) + return np.zeros((1, h, w), np.int32) + + with dask.config.set(scheduler="threads"): + return da.map_blocks( + _one, dtype=np.int32, + chunks=((chunk_frames,) * n, (h,), (w,)), + meta=np.zeros((0, 0, 0), np.int32), + ) + + +def _navigator_traces(particles, events=None) -> dict[str, np.ndarray]: + """The three navigator lanes, as plain arrays. + + ``count`` is integer data and the renderer must draw it as a STEP — a straight + interpolation between frames puts a nucleation's visual transition half a frame + early, so an event at frame 8 reads as 7 (plan C3). + """ + traces: dict[str, np.ndarray] = { + "count": particles.count_series(), + "size": particles.property_series("area", "mean"), + } + if events is not None: + from spyde.particles.track import event_counts + ec = event_counts(events, particles.n_frames) + for kind, arr in ec.items(): + traces[f"event_{kind}"] = np.asarray(arr, np.float32) + return traces + + +def open_particle_tree(session, *, particles, source_node, source_tree=None, + title: str | None = None, events=None, + nav_map=None, params: dict[str, Any] | None = None, + provenance: dict[str, Any] | None = None): + """Create the particle tree for a finished segmentation. + + Parameters + ---------- + particles + The :class:`~spyde.signals.particles.SpyDEParticles` store. + source_node + The signal segmentation ran on. Recorded on the tree so Wave D can walk + back to its parent; **not** used to hold the result. + source_tree + The tree *source_node* belongs to, when the caller knows it. Recorded for + provenance; the particle tree is a sibling, not a child. + events + Optional event list from :func:`spyde.particles.track.link`. When given, + the navigator gains an event lane. + nav_map + Optional ``(n_frames,)`` int array mapping each particle frame back to a + source navigation index. ``None`` means identity, which is the case for a + movie; a 4D-STEM virtual image passes the parent's grid. + + Returns + ------- + The new tree, with ``particles``, ``source_node``, ``nav_map`` and + ``nav_traces`` attached. + """ + import hyperspy.api as hs + + from spyde.actions.commit import open_result_tree + + n = particles.n_frames + h, w = particles.frame_shape + title = title or f"Particles — {particles.n_particles} in {n} frames" + + lazy = _label_movie(particles) + sig = hs.signals.Signal2D(lazy).as_lazy() + sig.data = lazy + + # Carry the source's calibration so a particle's centroid means the same + # thing on this tree as it did on the movie. Without it the label movie is + # in pixels while every measured property is in nm, which is the exact class + # of unit mismatch the linker's docstring warns about. + _copy_axes(source_node, sig, particles) + + tree = open_result_tree( + session, title=title, signal=sig, + signal_type=PARTICLE_SIGNAL_TYPE, + provenance=provenance or {"action": "segment_particles", + "params": dict(params or {})}, + ) + + tree.particles = particles + tree.source_node = source_node + tree.source_tree = source_tree + tree.nav_map = (np.arange(n, dtype=np.int64) if nav_map is None + else np.asarray(nav_map, np.int64)) + tree.particle_events = list(events or ()) + tree.nav_traces = _navigator_traces(particles, events) + return tree + + +def _copy_axes(source_node, sig, particles) -> None: + """Give the label movie the source's calibration, best-effort. + + Best-effort on purpose: a wrong calibration is worse than none, so every step + is guarded and a failure leaves the axis at its default rather than half- + applied. The signal axes fall back to the particle store's own ``scale``, + which is what the measurements were computed with. + """ + try: + src_sig = getattr(source_node, "axes_manager", None) + if src_sig is not None: + for dst, src in zip(sig.axes_manager.signal_axes, + source_node.axes_manager.signal_axes): + dst.scale, dst.units = float(src.scale), src.units + nav = source_node.axes_manager.navigation_axes + if nav: + dnav = sig.axes_manager.navigation_axes[0] + dnav.name, dnav.units = nav[0].name, nav[0].units + dnav.scale, dnav.offset = float(nav[0].scale), float(nav[0].offset) + return + except Exception as exc: + log.debug("[particles] copying source axes failed: %s", exc) + try: + for ax in sig.axes_manager.signal_axes: + ax.scale, ax.units = float(particles.scale), particles.units + except Exception as exc: # pragma: no cover + log.debug("[particles] fallback axis calibration failed: %s", exc) + + +def particle_nav_positions(tree, index: int): + """Source navigation indices covered by global particle *index*. + + This is the Wave D seam: a particle found on a derived node (a 4D-STEM virtual + image) needs to name positions in the PARENT's navigation grid before anything + can average diffraction patterns over it. + + Returns an ``(m, k)`` int array of navigation indices, where *k* is the source + navigation dimensionality. For a movie (1-D nav) that is the single frame the + particle lives in; for a virtual image it is every scan position under the + particle's mask. + """ + particles = tree.particles + row = particles.flat_buffer[int(index)] + from spyde.signals.particles import COL + + t = int(row[COL["t"]]) + frame = int(tree.nav_map[t]) if t < len(tree.nav_map) else t + + if not particles.has_masks: + # No outlines stored: the best we can name is the frame itself. + return np.asarray([[frame]], np.int64) + + mask, (y0, x0, _y1, _x1) = particles.mask_at(int(index)) + ys, xs = np.nonzero(mask) + if ys.size == 0: + return np.asarray([[frame]], np.int64) + + nav_dim = _source_nav_dim(tree) + if nav_dim <= 1: + # A movie: the particle's pixels are SIGNAL coordinates, not navigation + # ones, so the only navigation index involved is the frame. + return np.asarray([[frame]], np.int64) + # A virtual image: the particle's pixels ARE navigation positions. + return np.stack([ys + y0, xs + x0], axis=-1).astype(np.int64) + + +def _source_nav_dim(tree) -> int: + src = getattr(tree, "source_node", None) + try: + return int(src.axes_manager.navigation_dimension) + except Exception: + return 1 diff --git a/spyde/drawing/toolbars/plot_control_toolbar.py b/spyde/drawing/toolbars/plot_control_toolbar.py index 70c3727b..e10086ad 100644 --- a/spyde/drawing/toolbars/plot_control_toolbar.py +++ b/spyde/drawing/toolbars/plot_control_toolbar.py @@ -189,6 +189,7 @@ def get_toolbar_actions_for_plot( exclude_signal_types = meta.get("exclude_signal_types") signal_class = meta.get("signal_class") requires_vectors = meta.get("requires_vectors", False) + requires_particles = meta.get("requires_particles", False) plot_dim = meta.get("plot_dim", [1, 2]) navigation_only = meta.get("navigation") params = meta.get("parameters", {}) @@ -199,8 +200,11 @@ def get_toolbar_actions_for_plot( # requires_vectors: action only shows once the plot's signal tree has # diffraction_vectors attached (set after Find Vectors completes). # PlotState.rebuild_toolbars() re-runs this filter at that point. + # requires_particles is the same gate for tree.particles, set when a + # segmentation run finalizes. tree = getattr(plot_state.plot, "signal_tree", None) has_vectors = getattr(tree, "diffraction_vectors", None) is not None + has_particles = getattr(tree, "particles", None) is not None add_action = ( (signal_types is None or plot_signal_type in signal_types) @@ -221,6 +225,7 @@ def get_toolbar_actions_for_plot( or isinstance(signal, _resolve_signal_class(signal_class)) ) and (not requires_vectors or has_vectors) + and (not requires_particles or has_particles) # requires_original_metadata: hide unless the signal came from the # format this action is about (see _has_original_metadata). and _has_original_metadata( @@ -290,6 +295,7 @@ def _action_matches_plot(action: str, meta: dict, plot_state: "PlotState") -> bo exclude_signal_types = meta.get("exclude_signal_types") signal_class = meta.get("signal_class") requires_vectors = meta.get("requires_vectors", False) + requires_particles = meta.get("requires_particles", False) plot_dim = meta.get("plot_dim", [1, 2]) navigation_only = meta.get("navigation") @@ -298,6 +304,7 @@ def _action_matches_plot(action: str, meta: dict, plot_state: "PlotState") -> bo tree = getattr(plot_state.plot, "signal_tree", None) has_vectors = getattr(tree, "diffraction_vectors", None) is not None + has_particles = getattr(tree, "particles", None) is not None return ( (signal_types is None or plot_signal_type in signal_types) @@ -307,6 +314,7 @@ def _action_matches_plot(action: str, meta: dict, plot_state: "PlotState") -> bo or isinstance(signal, _resolve_signal_class(signal_class)) ) and (not requires_vectors or has_vectors) + and (not requires_particles or has_particles) and _has_original_metadata(signal, meta.get("requires_original_metadata")) and _packages_present(meta) and (plot_state.dimensions in plot_dim) diff --git a/spyde/hyperspy_extension.yaml b/spyde/hyperspy_extension.yaml index c36ba51c..23e82af8 100644 --- a/spyde/hyperspy_extension.yaml +++ b/spyde/hyperspy_extension.yaml @@ -29,3 +29,15 @@ signals: dtype: real lazy: True module: spyde.signals.insitu + ParticleMap: + signal_type: particles + signal_dimension: 2 + dtype: real + lazy: False + module: spyde.signals.particle_map + LazyParticleMap: + signal_type: particles + signal_dimension: 2 + dtype: real + lazy: True + module: spyde.signals.particle_map diff --git a/spyde/signals/__init__.py b/spyde/signals/__init__.py index 0239a077..5acf6c9c 100644 --- a/spyde/signals/__init__.py +++ b/spyde/signals/__init__.py @@ -8,6 +8,7 @@ from spyde.signals.diffraction_vectors import SpyDEDiffractionVectors from spyde.signals.orientation_map import SpyDEOrientationMap from spyde.signals.insitu import InSitu, LazyInSitu +from spyde.signals.particle_map import LazyParticleMap, ParticleMap from spyde.signals.particles import SpyDEParticles __all__ = [ @@ -16,4 +17,6 @@ "InSitu", "LazyInSitu", "SpyDEParticles", + "ParticleMap", + "LazyParticleMap", ] diff --git a/spyde/signals/particle_map.py b/spyde/signals/particle_map.py new file mode 100644 index 00000000..e8098c9e --- /dev/null +++ b/spyde/signals/particle_map.py @@ -0,0 +1,37 @@ +""" +ParticleMap — the signal type a segmentation result carries. + +The root of a particle tree (plan §0.6) is a **label movie**: same nav/signal +shape as the movie it was segmented from, each frame painted on demand from the +stored contours, with pixel values carrying track ids. It displays like any +navigated 2-D signal; only the signal type differs. + +The type exists so toolbar gating can offer particle actions — track, export, +per-particle diffraction — on a segmentation result and *nowhere else*. That is +the same job ``insitu`` does for Play / Fast-Forward, and it is why the plan puts +the result on its own tree: gating becomes a plain signal-type check instead of a +hunt up the parent chain for someone else's attribute. + +Registered as a HyperSpy extension (see ``spyde/hyperspy_extension.yaml``) so +``set_signal_type`` and save/load work. +""" +from __future__ import annotations + +from hyperspy._signals.signal2d import LazySignal2D, Signal2D + +SIGNAL_TYPE = "particles" + + +class ParticleMap(Signal2D): + """Per-frame particle label map (eager).""" + _signal_type = SIGNAL_TYPE + + +class LazyParticleMap(LazySignal2D): + """Per-frame particle label map (lazy) — the normal case. + + Lazy is not an optimisation here, it is the design: a materialised label + movie is 64 MB *per frame* at 4096², so frames are painted from contours only + when something asks for one. + """ + _signal_type = SIGNAL_TYPE diff --git a/spyde/tests/migrated/test_particle_tree.py b/spyde/tests/migrated/test_particle_tree.py new file mode 100644 index 00000000..a99b6214 --- /dev/null +++ b/spyde/tests/migrated/test_particle_tree.py @@ -0,0 +1,312 @@ +""" +The particle tree (plan §0.6) and the Wave-0 framework around it. + +Three separable claims, one per class: + +* segmentation spawns a NEW tree carrying its provenance, rather than decorating + the source — which is what makes Wave D's per-particle diffraction well-defined; +* the label movie is LAZY and stays lazy, because a materialised one is 64 MB per + frame at the plan's target size; +* ``requires_particles`` gates identically in both toolbar filter paths, because + a gate added to only one renders a button that never dispatches (or vice versa). +""" +from __future__ import annotations + +import numpy as np +import pytest + +import spyde.data.synthetic as sy +from spyde.actions.particle_tree import ( + PARTICLE_SIGNAL_TYPE, + open_particle_tree, + particle_nav_positions, +) +from spyde.particles import ( + LinkParams, + SegmentParams, + link, + measure_frame, + segment_frame, +) +from spyde.signals.particles import COL, SpyDEParticles + +N_FRAMES = 8 + + +@pytest.fixture(scope="module") +def built(): + """A real segmentation of the fixture — not a hand-made container.""" + s = sy.particle_movie(n_frames=N_FRAMES) + gt = sy.ground_truth(s) + scale = float(gt["scale"]) + per_frame, contours = [], [] + for t in range(N_FRAMES): + lab = segment_frame(s.data[t], SegmentParams(min_size=25, gaussian=1.0)) + rows, cs = measure_frame(lab, s.data[t], t=t, scale=scale) + per_frame.append(rows) + contours.append(cs) + parts = SpyDEParticles.from_frames( + per_frame, frame_shape=tuple(gt["frame_shape"]), + contours_per_frame=contours, scale=scale, units="nm") + res = link(parts, LinkParams(max_dist=10.0)) + res.apply(parts) + return s, gt, parts, res + + +class TestTreeCreation: + def test_spawns_a_new_tree_not_an_attribute(self, window, built): + """The §0.6 decision, asserted directly.""" + session = window["window"] + s, _gt, parts, res = built + before = len(session.signal_trees) + tree = open_particle_tree(session, particles=parts, source_node=s, + events=res.events) + assert len(session.signal_trees) == before + 1 + assert tree.particles is parts + assert tree.source_node is s + assert getattr(s, "particles", None) is None, ( + "the SOURCE signal was decorated — that is the design this replaces") + + def test_root_carries_the_particle_signal_type(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert getattr(tree.root, "_signal_type", None) == PARTICLE_SIGNAL_TYPE + + def test_label_movie_matches_the_source_shape(self, window, built): + session = window["window"] + s, gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert tree.root.data.shape == (N_FRAMES, *tuple(gt["frame_shape"])) + + def test_provenance_is_stamped(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s, + params={"sensitivity": 0.5}) + prov = getattr(tree, "_commit_provenance", None) or {} + assert prov.get("action") == "segment_particles" + assert prov.get("params", {}).get("sensitivity") == 0.5 + + def test_nav_map_defaults_to_identity(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert np.array_equal(tree.nav_map, np.arange(N_FRAMES)) + + def test_calibration_follows_the_source(self, window, built): + """A centroid must mean the same thing on both trees.""" + session = window["window"] + s, gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert tree.root.axes_manager.signal_axes[0].scale == \ + pytest.approx(float(gt["scale"])) + assert tree.root.axes_manager.signal_axes[0].units == "nm" + assert tree.root.axes_manager.navigation_axes[0].name == "time" + + +class TestLabelMovieStaysLazy: + """A materialised label movie is 64 MB per frame at the plan's target size.""" + + def test_root_is_lazy(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert tree.root._lazy + + def test_one_frame_per_chunk(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert tree.root.data.chunksize[0] == 1 + + def test_computing_one_frame_does_not_compute_the_stack(self, window, built): + """The Memory-Safety rule, enforced the way find_vectors enforces it.""" + import dask.array as da + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + full = tree.root.data.shape + seen = {"full": 0} + real = da.Array.compute + + def guard(self, *a, **k): + if self.shape == full: + seen["full"] += 1 + return real(self, *a, **k) + + try: + da.Array.compute = guard + frame = np.asarray(tree.root.data[3].compute()) + finally: + da.Array.compute = real + assert frame.shape == full[1:] + assert seen["full"] == 0, "rendering one frame computed the whole movie" + + def test_rendered_frame_carries_track_ids(self, window, built): + session = window["window"] + s, _gt, parts, res = built + tree = open_particle_tree(session, particles=parts, source_node=s, + events=res.events) + frame = np.asarray(tree.root.data[3].compute()) + painted = np.unique(frame) + painted = painted[painted > 0] + assert painted.size == len(parts.at(3)), ( + "painted a different number of particles than frame 3 holds") + + +class TestNavigatorTraces: + def test_count_and_size_lanes_exist(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert set(tree.nav_traces) >= {"count", "size"} + assert tree.nav_traces["count"].shape == (N_FRAMES,) + + def test_event_lanes_appear_only_with_events(self, window, built): + session = window["window"] + s, _gt, parts, res = built + without = open_particle_tree(session, particles=parts, source_node=s) + assert not any(k.startswith("event_") for k in without.nav_traces) + with_ev = open_particle_tree(session, particles=parts, source_node=s, + events=res.events) + assert any(k.startswith("event_") for k in with_ev.nav_traces) + + def test_count_lane_matches_the_store(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + assert np.array_equal(tree.nav_traces["count"], parts.count_series()) + + +class TestWaveDSeam: + """`particle_nav_positions` is what makes per-particle diffraction definable.""" + + def test_movie_particle_maps_to_its_frame(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + tree = open_particle_tree(session, particles=parts, source_node=s) + gi = int(parts.indices_at(3)[0]) + nav = particle_nav_positions(tree, gi) + assert nav.shape == (1, 1) and int(nav[0, 0]) == 3, ( + "on a MOVIE a particle's pixels are signal coordinates, so the only " + "navigation index involved is the frame") + + def test_nav_map_is_honoured(self, window, built): + session = window["window"] + s, _gt, parts, _res = built + shifted = np.arange(N_FRAMES) + 100 + tree = open_particle_tree(session, particles=parts, source_node=s, + nav_map=shifted) + gi = int(parts.indices_at(3)[0]) + assert int(particle_nav_positions(tree, gi)[0, 0]) == 103 + + def test_survives_a_store_without_masks(self, window, built): + """store_masks=False is the default for long movies — this must not raise.""" + session = window["window"] + s, _gt, parts, _res = built + bare = SpyDEParticles(parts.flat_buffer.copy(), parts.t_offsets.copy(), + parts.frame_shape, scale=parts.scale, + units=parts.units) + assert not bare.has_masks + tree = open_particle_tree(session, particles=bare, source_node=s) + nav = particle_nav_positions(tree, 0) + assert nav.shape == (1, 1) + + +class TestRequiresParticlesGate: + """Both filter paths, because one alone is a button that never dispatches.""" + + def _fake(self, has_particles: bool): + class _Sig: + _signal_type = "particles" + + class _Tree: + particles = object() if has_particles else None + diffraction_vectors = None + root = _Sig() + + class _Plot: + signal_tree = _Tree() + + class _State: + plot = _Plot() + current_signal = _Sig() + dimensions = 2 + navigation = False + return _State() + + def test_second_path_hides_and_shows(self): + from spyde.drawing.toolbars.plot_control_toolbar import _action_matches_plot + meta = {"requires_particles": True, "plot_dim": [1, 2]} + assert not _action_matches_plot("X", meta, self._fake(False)) + assert _action_matches_plot("X", meta, self._fake(True)) + + def test_ungated_actions_are_unaffected(self): + from spyde.drawing.toolbars.plot_control_toolbar import _action_matches_plot + meta = {"plot_dim": [1, 2]} + assert _action_matches_plot("X", meta, self._fake(False)) + + def test_both_paths_read_the_same_key(self): + """Guards the §6 pitfall directly: the key must appear in BOTH filters.""" + import inspect + from spyde.drawing.toolbars import plot_control_toolbar as mod + src = inspect.getsource(mod) + assert src.count("requires_particles") >= 4, ( + "requires_particles must be read in get_toolbar_actions_for_plot AND " + "_action_matches_plot — one alone renders a button that never " + "dispatches, or hides one that would have worked") + + +class TestWaitForParticles: + def test_returns_false_without_an_event_loop(self): + from spyde.actions.lifecycle import wait_for_particles + + class _S: + _dispatch_to_main = None + called = [] + started = wait_for_particles(_S(), None, lambda: called.append(1), + what="Test") + assert started is False and not called + + def test_fires_once_the_particles_land(self, built): + import threading + import time + from spyde.actions.lifecycle import wait_for_particles + + _s, _gt, parts, _res = built + + class _Tree: + particles = None + + class _Plot: + signal_tree = _Tree() + + done = threading.Event() + + class _S: + signal_trees = [_Tree()] + + @staticmethod + def _dispatch_to_main(fn): + fn() + + plot = _Plot() + assert wait_for_particles(_S(), plot, done.set, what="Test", grace=30.0) + time.sleep(0.25) + assert not done.is_set(), "fired before the particles attached" + plot.signal_tree.particles = parts + assert done.wait(5.0), "never fired after the particles attached" + + def test_seg_batch_running_reads_the_tree_flag(self): + from spyde.actions.lifecycle import seg_batch_running + + class _T: + _seg_batch_running = False + + class _S: + signal_trees = [_T()] + s = _S() + assert not seg_batch_running(s) + s.signal_trees[0]._seg_batch_running = True + assert seg_batch_running(s) From 2e484c4d5c02087460e1c9762195133a51453cf1 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 20:19:09 -0500 Subject: [PATCH 09/38] feat(renderer): generic DataTable and a resizable bottom dock The one genuinely new renderer primitive this feature needs. There was no table and no sorting anywhere in the app, and no virtualisation library -- react-rnd is a declared dependency that two files explicitly reject in favour of a hand-rolled Pointer-Capture gesture, so this follows that grain and adds no dependency. DataTable is data-agnostic: the backend supplies the column set, so showing tracks instead of particles is a column change rather than a new panel. Sortable headers, single/multi selection, swatch cells, inline units, tabular-nums for numerics. Virtualisation is a scrollTop slice plus two spacer divs, with scrollTop QUANTISED to the row grid so a wheel gesture re-renders about once per row instead of once per event. Verified at 5000 rows: ~30 DOM rows, and scrolling to row 2000 swaps the window without growing the DOM. Two traps the codebase had already paid for and documented: * `sendAction` is recreated on every provider render, so listing it in a dependency array re-runs the effect on every state update -- and if that effect requests data whose reply is state, it loops. Routed through a ref (ConsoleBar.tsx:226 records this as the "flashing preview" bug). * A Dropdown's menu is absolutely positioned, so one rendered inside the table's `overflow: auto` body would be clipped. Selects stay in the header; a comment marks the exact spot where a future one must not go. The dock is capped at 50% of the window: the whole bottom stack is `flexShrink: 0`, so LogPanel + this + ConsoleBar + StatusBar could otherwise squeeze MDIArea to nothing. Screenshot 07 confirms the MDI keeps real height at full extension. Visibility had to go through SpyDEContext rather than App state, because there was no View menu at all and MenuBar reads only the context -- so this adds View, following the existing dialog open/close triple, plus a StatusBar toggle beside Log. typecheck clean, build clean, 12/12 in tests/data_table.spec.ts, and 23/23 across the shell-adjacent specs (app_log, examples_menu, update_gpu_dialogs, ui_fixes). Screenshots in electron/data_table_shots were looked at, not just captured. Known gap until the backend lands: `particles_query` has no handler yet, so opening the dock logs one Unknown-action warning. --- electron/src/renderer/src/App.tsx | 6 + .../renderer/src/components/BottomDock.tsx | 352 ++++++++++++ .../src/renderer/src/components/DataTable.tsx | 529 ++++++++++++++++++ .../src/renderer/src/components/MenuBar.tsx | 24 +- .../src/renderer/src/components/StatusBar.tsx | 13 +- .../src/renderer/src/kernel/SpyDEContext.tsx | 15 + electron/src/renderer/src/kernel/protocol.ts | 63 +++ electron/tests/data_table.spec.ts | 343 ++++++++++++ 8 files changed, 1343 insertions(+), 2 deletions(-) create mode 100644 electron/src/renderer/src/components/BottomDock.tsx create mode 100644 electron/src/renderer/src/components/DataTable.tsx create mode 100644 electron/tests/data_table.spec.ts diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 9644ec69..0f55bbaf 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -6,6 +6,7 @@ import { ReportSidebar } from './components/ReportSidebar' import { ConsoleBar } from './components/ConsoleBar' import { StatusBar } from './components/StatusBar' import { LogPanel } from './components/LogPanel' +import { BottomDock } from './components/BottomDock' import { Tour } from './components/Tour' import { NavShapeGate } from './components/NavShapeGate' import { StackGate } from './components/StackGate' @@ -65,6 +66,11 @@ export function App() { {sidebarOpen && } {reportOpen && } + {/* Table dock above the log: a data surface belongs next to the plots, + diagnostics below it. Both are flexShrink:0 siblings of the body — + BottomDock's own maxHeight:50% is what stops the pair starving the + MDI area. Visibility comes from the context (View menu / StatusBar). */} + setLogOpen(false)} /> setLogOpen(v => !v)} /> diff --git a/electron/src/renderer/src/components/BottomDock.tsx b/electron/src/renderer/src/components/BottomDock.tsx new file mode 100644 index 00000000..cb2e07b2 --- /dev/null +++ b/electron/src/renderer/src/components/BottomDock.tsx @@ -0,0 +1,352 @@ +/** + * BottomDock.tsx — the tabbed table dock at the bottom of the app shell. + * + * Slots into the App's bottom stack exactly like LogPanel (a `flexShrink: 0` + * sibling of the body, no z-index games), and hosts the generic `DataTable`: + * + * Table — one row per particle/track (`particles_table.rows`, columns come + * from the backend so this file stays data-agnostic). + * Events — the birth/death/merge/split stream (`particles_table.events`), + * whose columns ARE fixed here because that record shape is fixed + * by `spyde/particles/track.py::ParticleEvent.to_dict`. + * + * Visibility lives in SpyDEContext (`tableDockOpen`), not in App state, because + * MenuBar's View menu has to read and toggle it and only sees the context. + * + * Height: resizable by its TOP edge with the SubWindow / ReportSidebar + * Pointer-Capture gesture (NOT react-rnd, which the app declares but + * deliberately never uses). Capped at 50% of the window — the whole bottom + * stack is `flexShrink: 0`, so LogPanel (220) + this + ConsoleBar + StatusBar + * would otherwise squeeze MDIArea (`flex: 1, minHeight: 0`) to nothing. + * + * Backend contract (the Python side is a separate workstream and may not exist + * yet — the dock degrades to a clear empty state until it does): + * → sendAction('particles_query', { window_id }) + * ← spyde:particles_table (see ParticlesTableMessage in kernel/protocol.ts) + */ +import React from 'react' +import { useSpyDE } from '../kernel/SpyDEContext' +import type { ParticlesTableMessage } from '../kernel/protocol' +import { DataTable, toCsv, type DataColumn, type DataRow } from './DataTable' + +const MIN_H = 120 +const DEFAULT_H = 260 +/** Hard ceiling as a fraction of the window — mirrors the `maxHeight: '50%'` + * style guard so a drag can't do what the stylesheet forbids. */ +const MAX_FRACTION = 0.5 + +type TabKey = 'table' | 'events' + +/** Plan C2's lane colours, reused so the table and the navigator event lane + * agree: green birth, red death, mauve merge, yellow split. */ +const EVENT_COLORS: Record = { + birth: '#a6e3a1', + death: '#f38ba8', + merge: '#cba6f7', + split: '#f9e2af', +} + +/** The Events tab's columns. Fixed here (not backend-supplied) because + * `ParticleEvent.to_dict()` is a fixed record: {frame, kind, tracks, particles}. */ +const EVENT_COLUMNS: DataColumn[] = [ + { key: 'frame', label: 'frame', width: 80, numeric: true }, + { + key: 'kind', label: 'event', width: 120, kind: 'swatch', + color: (v) => EVENT_COLORS[String(v)] ?? '#89b4fa', + }, + { key: 'tracks', label: 'track ids', width: 150, sortable: false }, + { key: 'particles', label: 'particle rows', sortable: false }, +] + +export function BottomDock() { + const { state, sendAction, tableDockOpen, closeTableDock } = useSpyDE() + const [tab, setTab] = React.useState('table') + const [height, setHeight] = React.useState(DEFAULT_H) + const [query, setQuery] = React.useState('') + const [copied, setCopied] = React.useState(false) + const [table, setTable] = React.useState(null) + + const activeId = state.activeWindowId + + // `sendAction` is recreated on EVERY provider render, so listing it in a dep + // array re-runs the effect on every unrelated state update — and an effect + // that re-requests data whose reply IS state becomes an infinite loop (the + // "flashing preview" bug, documented at ConsoleBar.tsx:225). Route sends + // through a ref instead. + const sendRef = React.useRef(sendAction) + sendRef.current = sendAction + + // Ask for this window's table when the dock opens and whenever the active + // window changes. Guarded on `activeId` so an empty session sends nothing + // (the backend logs "Unknown action" for anything it doesn't handle yet). + React.useEffect(() => { + if (!tableDockOpen || activeId == null) return + sendRef.current('particles_query', { window_id: activeId }, activeId) + }, [tableDockOpen, activeId]) + + // The backend's reply, re-broadcast as a DOM CustomEvent by SpyDEContext (the + // LayersSection idiom) — no reducer state for a panel-local payload. + React.useEffect(() => { + const on = (e: Event) => { + const msg = (e as CustomEvent).detail as ParticlesTableMessage + // A table for a DIFFERENT window is not ours; `window_id: null` means the + // backend sent an unscoped table, which always applies. + if (msg.window_id != null && activeId != null && msg.window_id !== activeId) return + setTable(msg) + } + window.addEventListener('spyde:particles_table', on) + return () => window.removeEventListener('spyde:particles_table', on) + }, [activeId]) + + // ── Top-edge resize (Pointer-Capture, per SubWindow / ReportSidebar) ─────── + const resizeGesture = React.useRef<{ py: number; h: number } | null>(null) + const onResizeDown = (e: React.PointerEvent) => { + try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) } catch { /* */ } + resizeGesture.current = { py: e.clientY, h: height } + } + const onResizeMove = (e: React.PointerEvent) => { + const g = resizeGesture.current + if (!g) return + // Dragging the TOP edge upwards grows the dock (its bottom edge is pinned). + const max = Math.max(MIN_H, Math.round(window.innerHeight * MAX_FRACTION)) + setHeight(Math.min(max, Math.max(MIN_H, g.h + (g.py - e.clientY)))) + } + const onResizeUp = (e: React.PointerEvent) => { + if (!resizeGesture.current) return + try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) } catch { /* */ } + resizeGesture.current = null + } + + // ── Rows for the active tab ─────────────────────────────────────────────── + const columns: DataColumn[] = React.useMemo(() => { + if (tab === 'events') return EVENT_COLUMNS + return (table?.columns ?? []).map((c) => ({ ...c })) + }, [tab, table]) + + const allRows: DataRow[] = React.useMemo(() => { + if (tab === 'events') return (table?.events ?? []) as unknown as DataRow[] + return table?.rows ?? [] + }, [tab, table]) + + // Free-text filter across every column value. Client-side and deliberately + // simple — the dock owns filtering so DataTable stays a pure view. + const rows = React.useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return allRows + return allRows.filter((r) => + columns.some((c) => String(r[c.key] ?? '').toLowerCase().includes(q)), + ) + }, [allRows, columns, query]) + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(toCsv(columns, rows)) + setCopied(true) + setTimeout(() => setCopied(false), 1200) + } catch { /* clipboard unavailable (rare in Electron) — no-op */ } + } + + // Selection stays renderer-side and is re-broadcast as a CustomEvent so a + // future particle overlay can highlight the picked rows on the frame without + // this dock guessing a backend action name that doesn't exist yet. + const onSelect = (keys: (string | number)[], picked: DataRow[]) => { + window.dispatchEvent(new CustomEvent('spyde:particles_selection', { + detail: { tab, window_id: table?.window_id ?? activeId ?? null, keys, rows: picked }, + })) + } + + if (!tableDockOpen) return null + + const emptyMessage = table + ? (tab === 'events' + ? 'No events in this result.\nEvents appear once the linker has run.' + : 'No rows in this result.') + : (activeId == null + ? 'No table data.\nOpen a dataset and run Segment Particles to populate this dock.' + : 'No table data for this window yet.\nRun Segment Particles, then press Refresh.') + + const title = table?.title ?? 'Particles' + + return ( +
+ + +
+ {title} +
+ + +
+ + {rows.length}{rows.length !== allRows.length ? ` / ${allRows.length}` : ''} + + {table?.partial && ( + streaming… + )} + setQuery(e.target.value)} + title="Filter visible rows (matches any column)" + /> + + {/* NB: any belongs HERE, in the header — its menu is + absolutely positioned at zIndex 9500 and an `overflow: auto` table + body clips it (see PlotControlDock's Layers note). */} + + + +
+ + {/* Keyed by tab so switching tabs remounts with a clean sort + selection + (the two tabs share no columns, so carrying either across is wrong). */} + (typeof row.id === 'number' ? row.id : index)} + selectionMode="multi" + onSelect={onSelect} + emptyMessage={emptyMessage} + /> +
+ ) +} + +function TabButton({ id, label, active, onPick }: { + id: TabKey; label: string; active: boolean; onPick: (t: TabKey) => void +}) { + const [hover, setHover] = React.useState(false) + return ( + + ) +} + +function ResizeHandle({ onDown, onMove, onUp }: { + onDown: (e: React.PointerEvent) => void + onMove: (e: React.PointerEvent) => void + onUp: (e: React.PointerEvent) => void +}) { + const [hover, setHover] = React.useState(false) + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ ...styles.resizeHandle, background: hover ? '#89b4fa' : 'transparent' }} + /> + ) +} + +/** Exported so a particle overlay can paint events in the same colours. */ +export { EVENT_COLORS } + +const styles: Record = { + root: { + position: 'relative', + flexShrink: 0, + // Belt-and-braces with the drag clamp: the bottom stack never shrinks, so an + // unbounded dock would starve the MDI area. + maxHeight: '50%', + minHeight: MIN_H, + display: 'flex', + flexDirection: 'column', + background: '#11111b', + borderTop: '1px solid #313244', + }, + resizeHandle: { + position: 'absolute', top: -3, left: 0, right: 0, height: 6, + cursor: 'ns-resize', zIndex: 5, + transition: 'background 120ms ease', + }, + header: { + display: 'flex', alignItems: 'center', gap: 8, + height: 30, flexShrink: 0, + padding: '0 10px', + background: '#181825', + borderBottom: '1px solid #313244', + userSelect: 'none', + }, + title: { fontSize: 12, fontWeight: 600, color: '#cdd6f4', letterSpacing: 0.3 }, + tabs: { display: 'flex', alignItems: 'center', gap: 2, marginLeft: 4 }, + tab: { + border: 'none', borderRadius: 5, cursor: 'pointer', + padding: '3px 11px', fontSize: 12, + transition: 'background 100ms ease, color 100ms ease', + }, + count: { + fontSize: 10.5, color: '#a6adc8', + background: '#313244', borderRadius: 9, padding: '1px 7px', + fontVariantNumeric: 'tabular-nums', + }, + streaming: { fontSize: 10.5, color: '#f9e2af' }, + search: { + background: '#1e1e2e', color: '#cdd6f4', + border: '1px solid #313244', borderRadius: 4, padding: '3px 8px', + fontSize: 12, width: 180, + }, + btn: { + background: '#313244', border: 'none', color: '#cdd6f4', + fontSize: 12, cursor: 'pointer', padding: '3px 10px', borderRadius: 4, + }, + btnDisabled: { color: '#585b70', cursor: 'default' }, + iconBtn: { + background: 'transparent', border: 'none', color: '#a6adc8', + fontSize: 18, lineHeight: '18px', cursor: 'pointer', padding: '0 4px', + }, +} diff --git a/electron/src/renderer/src/components/DataTable.tsx b/electron/src/renderer/src/components/DataTable.tsx new file mode 100644 index 00000000..4740d5ae --- /dev/null +++ b/electron/src/renderer/src/components/DataTable.tsx @@ -0,0 +1,529 @@ +/** + * DataTable.tsx — the app's generic, data-agnostic table. + * + * Columns, sorting, row selection and row virtualisation are all independent of + * what the rows MEAN; the particle dock (BottomDock) is its first consumer, and + * the vector list / per-phase OM statistics / fit component list are the next + * ones. Nothing in here knows about particles. + * + * Deliberate implementation choices: + * + * - **No virtualisation library, no `react-rnd`.** The renderer hand-rolls this + * kind of thing (see SubWindow.tsx / ReportSidebar.tsx, which both explicitly + * reject react-rnd for a Pointer-Capture gesture). Windowing here is a plain + * `scrollTop`-driven slice with two spacer divs — ~30 lines, no dependency. + * `scrollTop` is quantised to the row grid, so scrolling only re-renders when + * it crosses a row boundary rather than on every wheel tick. + * + * - **Flex divs, not ``.** A virtualised `
` needs spacer ``s + * whose height browsers treat as a suggestion; a flex row grid also matches + * the app's existing columnar layout (DaskMonitor's worker rows) — fixed-width + * `flexShrink: 0` cells, `tabular-nums` on the numbers. ARIA roles carry the + * table semantics that the markup no longer does. + * + * - **The body sets `userSelect: 'text'` explicitly.** `index.html` sets + * `:root { user-select: none }` app-wide (desktop feel: dragging a plot must + * not blue-highlight it), so without this a user cannot select a cell's text + * to copy it — the same fix LogPanel's body carries. + * + * - **Never put a `Dropdown` in a row.** Its menu is `position: absolute; + * zIndex: 9500`, which an `overflow: auto` scroll container CLIPS — see the + * note at PlotControlDock.tsx's Layers rows. Selects belong in the host + * panel's header. + * + * Every control carries a `data-testid` (project rule, electron/tests/README.md). + */ +import React from 'react' + +// ── Public types ───────────────────────────────────────────────────────────── + +export type ColumnAlign = 'left' | 'center' | 'right' +export type SortDir = 'asc' | 'desc' + +/** A row is an opaque bag of values; `columns[].key` indexes into it. */ +export type DataRow = Record + +export interface DataColumn { + /** Property read from each row. */ + key: string + /** Header text. */ + label: string + /** Fixed pixel width. Omitted → the column flexes to fill leftover space. */ + width?: number + /** Defaults to 'right' for `numeric` columns, 'left' otherwise. */ + align?: ColumnAlign + /** Right-align + tabular figures, so digits line up down the column. */ + numeric?: boolean + /** Header cycles asc → desc → unsorted. Default: true for every column. */ + sortable?: boolean + /** 'swatch' prefixes the cell with a colour chip (particles by track id). */ + kind?: 'text' | 'swatch' + /** Decimal places for a numeric cell (default: integers plain, floats 3 dp). */ + precision?: number + /** Appended to the formatted value ("nm", "nm²"). */ + units?: string + /** Full control of the displayed text (sorting still uses the raw value). */ + format?: (value: unknown, row: DataRow, index: number) => string + /** Swatch colour; defaults to `swatchColor(value)`. */ + color?: (value: unknown, row: DataRow, index: number) => string + /** Header tooltip. */ + title?: string +} + +export type RowKey = string | number + +export interface DataTableProps { + columns: DataColumn[] + rows: DataRow[] + /** Stable identity for selection. `index` is the row's position in `rows` + * (BEFORE sorting), so a key derived from it survives a re-sort. */ + rowKey?: (row: DataRow, index: number) => RowKey + rowHeight?: number + headerHeight?: number + /** Extra rows rendered above/below the viewport (default 8). */ + overscan?: number + selectionMode?: 'none' | 'single' | 'multi' + /** Fires on every selection change with the selected keys AND rows. */ + onSelect?: (keys: RowKey[], rows: DataRow[]) => void + /** Double-click / Enter on a row. */ + onRowActivate?: (row: DataRow, index: number) => void + initialSort?: { key: string; dir: SortDir } | null + emptyMessage?: React.ReactNode + /** Prefix for every `data-testid` this table emits. */ + testid?: string +} + +// ── Palette ────────────────────────────────────────────────────────────────── + +/** SpyDE's six accents, cycled for swatch cells (particle track colours). */ +export const SWATCH_COLORS = [ + '#89b4fa', '#f38ba8', '#a6e3a1', '#f9e2af', '#cba6f7', '#94e2d5', +] as const + +/** Stable colour for a swatch value: a non-negative integer indexes the palette + * directly (track 0 is always blue); anything else is hashed into it. */ +export function swatchColor(value: unknown): string { + if (typeof value === 'number' && Number.isFinite(value)) { + const n = Math.trunc(Math.abs(value)) + return SWATCH_COLORS[n % SWATCH_COLORS.length] + } + const s = String(value ?? '') + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return SWATCH_COLORS[Math.abs(h) % SWATCH_COLORS.length] +} + +// ── Value formatting / comparison ──────────────────────────────────────────── + +const isBlank = (v: unknown) => + v == null || v === '' || (typeof v === 'number' && !Number.isFinite(v)) + +/** Display text for a cell when the column supplies no `format`. */ +export function formatValue(value: unknown, col?: DataColumn): string { + if (isBlank(value)) return '—' + if (typeof value === 'number') { + let s: string + if (col?.precision != null) s = value.toFixed(col.precision) + else if (Number.isInteger(value)) s = String(value) + else { + const a = Math.abs(value) + s = a < 1e-3 || a >= 1e6 ? value.toExponential(2) : value.toFixed(3) + } + return col?.units ? `${s} ${col.units}` : s + } + if (typeof value === 'boolean') return value ? 'yes' : 'no' + if (Array.isArray(value)) return value.length ? value.join(', ') : '—' + return String(value) +} + +function compareValues(a: unknown, b: unknown): number { + if (typeof a === 'number' && typeof b === 'number') return a - b + if (typeof a === 'boolean' || typeof b === 'boolean') { + return (a ? 1 : 0) - (b ? 1 : 0) + } + return String(a).localeCompare(String(b), undefined, { numeric: true }) +} + +/** The visible table as CSV (header row + one line per row), for a Copy button. + * Values go through the column's own formatting, so what you paste is what you + * saw — quoted only when a comma/quote/newline forces it. */ +export function toCsv(columns: DataColumn[], rows: DataRow[]): string { + const cell = (s: string) => + /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s + const head = columns.map((c) => cell(c.label)).join(',') + const body = rows.map((r, i) => + columns + .map((c) => cell(c.format ? c.format(r[c.key], r, i) : formatValue(r[c.key], c))) + .join(','), + ) + return [head, ...body].join('\n') +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export function DataTable({ + columns, + rows, + rowKey, + rowHeight = 24, + headerHeight = 26, + overscan = 8, + selectionMode = 'single', + onSelect, + onRowActivate, + initialSort = null, + emptyMessage = 'No rows.', + testid = 'data-table', +}: DataTableProps) { + const bodyRef = React.useRef(null) + // Quantised scroll position: the index of the first row at/above the viewport + // top. Only changes when scrolling crosses a row boundary, so a wheel gesture + // re-renders ~once per row instead of once per event. + const [firstRow, setFirstRow] = React.useState(0) + const [viewH, setViewH] = React.useState(0) + const [sort, setSort] = React.useState<{ key: string; dir: SortDir } | null>(initialSort) + const [selected, setSelected] = React.useState>(() => new Set()) + // Anchor for shift-click ranges — an index into the SORTED view. + const anchorRef = React.useRef(null) + + const keyOf = React.useCallback( + (row: DataRow, index: number): RowKey => (rowKey ? rowKey(row, index) : index), + [rowKey], + ) + + // Viewport height drives how many rows exist at all. A ResizeObserver keeps it + // right through dock resizes and window resizes alike. + React.useEffect(() => { + const el = bodyRef.current + if (!el) return + setViewH(el.clientHeight) + if (typeof ResizeObserver === 'undefined') return + const ro = new ResizeObserver(() => setViewH(el.clientHeight)) + ro.observe(el) + return () => ro.disconnect() + }, []) + + const onScroll = () => { + const el = bodyRef.current + if (!el) return + const next = Math.max(0, Math.floor(el.scrollTop / rowHeight)) + setFirstRow((cur) => (cur === next ? cur : next)) + } + + // Sort a decorated copy so the ORIGINAL index survives (it is the default row + // key and the tiebreak that keeps the sort stable). + const view = React.useMemo(() => { + const decorated = rows.map((row, index) => ({ row, index })) + if (!sort) return decorated + const sign = sort.dir === 'asc' ? 1 : -1 + const k = sort.key + decorated.sort((a, b) => { + const va = a.row[k] + const vb = b.row[k] + const ba = isBlank(va) + const bb = isBlank(vb) + // Blanks sink to the bottom in BOTH directions — "no value" is not an + // extreme value, and floating them to the top of a descending sort hides + // the rows you asked to see. + if (ba !== bb) return ba ? 1 : -1 + if (ba) return a.index - b.index + const c = compareValues(va, vb) * sign + return c !== 0 ? c : a.index - b.index + }) + return decorated + }, [rows, sort]) + + const total = view.length + // `viewH || 320` keeps the FIRST paint non-empty: the ResizeObserver has not + // reported yet, and rendering zero rows then would flash a blank table. + const perView = Math.max(1, Math.ceil((viewH || 320) / rowHeight) + 1) + // Clamp against a stale `firstRow` (rows can shrink under us when the host + // filters), then widen by the overscan. + const clamped = Math.min(firstRow, Math.max(0, total - perView)) + const start = Math.max(0, clamped - overscan) + const end = Math.min(total, start + perView + overscan * 2) + const padTop = start * rowHeight + const padBottom = Math.max(0, (total - end) * rowHeight) + + // Minimum content width so fixed columns never squash; anything wider gets a + // horizontal scrollbar and the sticky header scrolls with it (correct — a + // sticky header only pins vertically). + const minRowWidth = React.useMemo( + () => columns.reduce((w, c) => w + (c.width ?? MIN_FLEX_WIDTH), 0), + [columns], + ) + + const emit = (next: Set) => { + setSelected(next) + if (!onSelect) return + const picked: DataRow[] = [] + const keys: RowKey[] = [] + for (const { row, index } of view) { + const k = keyOf(row, index) + if (next.has(k)) { keys.push(k); picked.push(row) } + } + onSelect(keys, picked) + } + + const onRowClick = (e: React.MouseEvent, viewIndex: number) => { + if (selectionMode === 'none') return + const { row, index } = view[viewIndex] + const k = keyOf(row, index) + const multi = selectionMode === 'multi' + if (multi && (e.ctrlKey || e.metaKey)) { + const next = new Set(selected) + if (next.has(k)) next.delete(k) + else next.add(k) + anchorRef.current = viewIndex + emit(next) + return + } + if (multi && e.shiftKey && anchorRef.current != null) { + const lo = Math.min(anchorRef.current, viewIndex) + const hi = Math.max(anchorRef.current, viewIndex) + const next = new Set() + for (let i = lo; i <= hi; i++) next.add(keyOf(view[i].row, view[i].index)) + emit(next) + return + } + anchorRef.current = viewIndex + emit(new Set([k])) + } + + // Identity-stable row callbacks. Without these every Row gets a fresh closure + // on each render and `React.memo` below can never skip anything — the memo + // would be decorative rather than the reason scrolling stays cheap at 100k + // rows. The refs carry the LATEST handler without changing identity. + const clickRef = React.useRef(onRowClick) + clickRef.current = onRowClick + const stableClick = React.useCallback( + (e: React.MouseEvent, viewIndex: number) => clickRef.current(e, viewIndex), [], + ) + const activateRef = React.useRef(onRowActivate) + activateRef.current = onRowActivate + const stableActivate = React.useCallback( + (row: DataRow, index: number) => activateRef.current?.(row, index), [], + ) + + const onHeaderClick = (col: DataColumn) => { + if (col.sortable === false) return + setSort((cur) => { + if (!cur || cur.key !== col.key) return { key: col.key, dir: 'asc' } + if (cur.dir === 'asc') return { key: col.key, dir: 'desc' } + return null // third click clears the sort + }) + // A re-sort moves rows under a range anchor that no longer means anything. + anchorRef.current = null + const el = bodyRef.current + if (el) { el.scrollTop = 0; setFirstRow(0) } + } + + return ( +
+
+
+
+ {columns.map((col, ci) => { + const sorted = sort?.key === col.key ? sort.dir : null + const canSort = col.sortable !== false + return ( +
onHeaderClick(col)} + style={{ + ...S.th, + ...cellBox(col), + cursor: canSort ? 'pointer' : 'default', + color: sorted ? '#89b4fa' : '#a6adc8', + }} + > + {col.label} + {canSort && ( + + {sorted === 'desc' ? '▾' : '▴'} + + )} +
+ ) + })} +
+ + {total === 0 ? ( +
{emptyMessage}
+ ) : ( + <> +
+ {view.slice(start, end).map(({ row, index }, i) => { + const k = keyOf(row, index) + return ( + + ) + })} +
+ + )} +
+
+
+ ) +} + +// One rendered row. Split out and memoised so a scroll re-renders only the rows +// that ENTERED the window: every prop is identity-stable for a row that stayed +// (see the stableClick/stableActivate refs above — without them the memo would +// never hit). +const Row = React.memo(function Row({ + columns, row, index, viewIndex, height, selected, testid, onPick, onActivate, +}: { + columns: DataColumn[] + row: DataRow + index: number + viewIndex: number + height: number + selected: boolean + testid: string + onPick: (e: React.MouseEvent, viewIndex: number) => void + onActivate: (row: DataRow, index: number) => void +}) { + const [hover, setHover] = React.useState(false) + return ( +
onPick(e, viewIndex)} + onDoubleClick={() => onActivate(row, index)} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + ...S.row, + height, + background: selected ? 'rgba(137,180,250,0.18)' + : hover ? 'rgba(137,180,250,0.07)' + : index % 2 ? 'rgba(255,255,255,0.014)' : 'transparent', + color: selected ? '#cdd6f4' : '#bac2de', + }} + > + {columns.map((col, ci) => { + const value = row[col.key] + const text = col.format ? col.format(value, row, index) : formatValue(value, col) + return ( +
+ {col.kind === 'swatch' && ( + + )} + {text} +
+ ) + })} +
+ ) +}) + +/** Fixed-width columns never shrink (DaskMonitor's numeric-column recipe); + * width-less columns share the leftover space. */ +const MIN_FLEX_WIDTH = 90 +function cellBox(col: DataColumn): React.CSSProperties { + const align = col.align ?? (col.numeric ? 'right' : 'left') + return { + ...(col.width != null + ? { flex: `0 0 ${col.width}px`, width: col.width } + : { flex: '1 1 auto', minWidth: MIN_FLEX_WIDTH }), + justifyContent: + align === 'right' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start', + textAlign: align, + fontVariantNumeric: col.numeric ? 'tabular-nums' : undefined, + } +} + +const S: Record = { + root: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }, + body: { + flex: 1, minHeight: 0, overflow: 'auto', + // `index.html` sets `user-select: none` on :root for the desktop feel, so a + // table has to opt its own text back IN or cells cannot be copied. + userSelect: 'text', WebkitUserSelect: 'text', + }, + head: { + position: 'sticky', top: 0, zIndex: 2, + display: 'flex', alignItems: 'center', + background: '#181825', + borderBottom: '1px solid #313244', + fontSize: 10.5, fontWeight: 600, letterSpacing: 0.3, + userSelect: 'none', + }, + th: { + display: 'flex', alignItems: 'center', gap: 3, + padding: '0 8px', height: '100%', overflow: 'hidden', + whiteSpace: 'nowrap', + }, + thLabel: { overflow: 'hidden', textOverflow: 'ellipsis' }, + sortMark: { fontSize: 9, flex: '0 0 auto', color: 'inherit' }, + row: { + display: 'flex', alignItems: 'center', + fontSize: 11.5, cursor: 'default', + borderBottom: '1px solid rgba(49,50,68,0.4)', + }, + td: { + display: 'flex', alignItems: 'center', gap: 6, + padding: '0 8px', height: '100%', overflow: 'hidden', whiteSpace: 'nowrap', + }, + cellText: { overflow: 'hidden', textOverflow: 'ellipsis' }, + swatch: { + width: 9, height: 9, borderRadius: 2, flex: '0 0 auto', + border: '1px solid rgba(0,0,0,0.45)', + }, + empty: { + color: '#6c7086', fontStyle: 'italic', fontSize: 11.5, + padding: '14px 10px', whiteSpace: 'pre-line', + }, +} diff --git a/electron/src/renderer/src/components/MenuBar.tsx b/electron/src/renderer/src/components/MenuBar.tsx index a0654ff8..1885d8ec 100644 --- a/electron/src/renderer/src/components/MenuBar.tsx +++ b/electron/src/renderer/src/components/MenuBar.tsx @@ -79,6 +79,10 @@ type Item = shape?: string /** Present on dataset rows: already on disk, or a download. */ downloaded?: boolean + /** Present on TOGGLE rows (the View menu's panels): renders a ✓ in the + * same left-hand mark column the download dot uses, so a menu never + * mixes two different marker alignments. */ + checked?: boolean tip?: Tip } | { label: string; submenu: Item[]; testId?: string; detail?: string } @@ -98,7 +102,9 @@ export function MenuBar({ onStartGuide, onShowInfo }: { /** Help → → Info… — opens GuideInfoDialog for that technique. */ onShowInfo: (g: Guide) => void }) { - const { sendAction, openStackDialog, openUpdateDialog, openGpuStatusDialog, openGpuHelpDialog, state } = useSpyDE() + const { sendAction, openStackDialog, openUpdateDialog, openGpuStatusDialog, + openGpuHelpDialog, state, + tableDockOpen, openTableDock, closeTableDock } = useSpyDE() const [open, setOpen] = useState(null) const barRef = useRef(null) const [exampleGroups, setExampleGroups] = useState([]) @@ -240,6 +246,17 @@ export function MenuBar({ onStartGuide, onShowInfo }: { onClick: () => sendAction('show_example_dir', {}), } as Item, ], + // Panels the user can show/hide. The bottom TABLE dock lives here (and in + // the status bar) because its open flag is context state — the control + // panel / report / log toggles are App-local and stay in the title bar. + View: [ + { + label: 'Table Dock', + testId: 'menu-item-table-dock', + checked: tableDockOpen, + onClick: () => (tableDockOpen ? closeTableDock() : openTableDock()), + }, + ], Help: [ // One row per TECHNIQUE, each opening a two-entry sub-menu: Info (the // background + further reading) and Guided tour (the in-app walkthrough). @@ -416,6 +433,11 @@ function MenuList({ items, onClose, testId, nested = false, onTip }: { {it.downloaded ? DOWNLOADED_MARK : NOT_DOWNLOADED_MARK} )} + {it.checked !== undefined && ( + + {it.checked ? '✓' : ''} + + )} {it.label} {it.shape ? {it.shape} : null} {it.detail && {it.detail}} diff --git a/electron/src/renderer/src/components/StatusBar.tsx b/electron/src/renderer/src/components/StatusBar.tsx index 4567437c..b34420cb 100644 --- a/electron/src/renderer/src/components/StatusBar.tsx +++ b/electron/src/renderer/src/components/StatusBar.tsx @@ -14,7 +14,8 @@ export function StatusBar({ logOpen, onToggleLog }: { logOpen?: boolean onToggleLog?: () => void }) { - const { state, openStackDialog, tileWindowsRef } = useSpyDE() + const { state, openStackDialog, tileWindowsRef, + tableDockOpen, openTableDock, closeTableDock } = useSpyDE() const hasWindows = Array.from(state.windows.values()).some(w => w.visible) // Badge unseen warnings/errors so problems are noticeable while the log is hidden. // Memoised: this bar re-renders on every context change (window moves, status @@ -49,6 +50,16 @@ export function StatusBar({ logOpen, onToggleLog }: { Log {problems > 0 && {problems}} + {/* The other bottom dock (particle/track table). Sits beside Log because + that is where the app's bottom-panel toggles live. */} + + + + + {/* Brush size lives here rather than in the caret's Scribble tab for the + same reason as the swatches: it is adjusted between strokes. */} + onBrush(Number(e.target.value))} + style={S.range} + /> + {brush} +
+ ) +} + +const S: Record = { + strip: { + display: 'flex', alignItems: 'center', gap: 5, + background: 'rgba(24,24,37,0.94)', border: '1px solid #313244', + borderRadius: 8, padding: '4px 7px', zIndex: 13, + boxShadow: '0 6px 20px rgba(0,0,0,0.5)', + width: 'max-content', + }, + swatch: { + width: 16, height: 16, borderRadius: 4, border: 'none', padding: 0, + cursor: 'pointer', flex: '0 0 auto', + }, + sep: { width: 1, height: 16, background: '#313244', flex: '0 0 auto' }, + iconBtn: { + width: 20, height: 18, padding: 0, fontSize: 11, lineHeight: '16px', + background: 'transparent', color: '#cdd6f4', border: '1px solid #45475a', + borderRadius: 4, cursor: 'pointer', flex: '0 0 auto', + }, + // Full `border` shorthand, not a `borderColor` longhand over iconBtn's + // shorthand — see the note on SegmentWizard's classRowActiveStyle. + iconBtnOn: { + background: '#89b4fa', color: '#11111b', border: '1px solid #89b4fa', + }, + range: { width: 70, flex: '0 0 auto' }, + brushVal: { + fontSize: 10, color: '#cdd6f4', minWidth: 14, textAlign: 'right', + fontVariantNumeric: 'tabular-nums', + }, +} diff --git a/electron/src/renderer/src/components/DriftWizard.tsx b/electron/src/renderer/src/components/DriftWizard.tsx new file mode 100644 index 00000000..e1953607 --- /dev/null +++ b/electron/src/renderer/src/components/DriftWizard.tsx @@ -0,0 +1,349 @@ +/** + * DriftWizard.tsx — the Drift Correction caret (`drift_` staged actions, + * backend: spyde/actions/drift_action.py; plan §A8). + * + * A NARROW 240 px caret, deliberately: **the verification surface is a separate + * window, not this one.** `drift_open` opens a bare-figure "Drift Check" window + * holding the raw and corrected sum images side by side — an aligned stack sums + * sharp, a misaligned one blurs, and judging that is the whole point of the + * check. A 240 px caret cannot show a sum image at a size where sharpness is + * judgeable, so it holds only the model tabs, the solver parameters, progress + * and Apply. + * + * The inline trace here is therefore a SUMMARY, not the verification: dy/dx vs + * frame at caret scale, enough to see "the stage crept 30 px to the right" or + * "frame 12 is an outlier" without moving your eyes off the controls. + * + * **Where the trace comes from (this differs from the plan text).** Plan A8 says + * the trace fills incrementally as the solve progresses, and the caret spec said + * to build it from streaming `drift_preview` messages. The backend does not (and + * currently cannot) do that, and says so in its own docstring: + * `solve_translation` returns its shifts only when it finishes — `progress(done, + * total)` carries no partial trace and the array is local to the solver. So: + * • `drift_progress` drives the PROGRESS BAR during the solve; + * • `drift_result` delivers the whole `shifts` array at the end and is what + * draws the trace; + * • `drift_preview` is the `drift_tune` reply and carries ONE pair's dy/dx + * (the first-pair re-solve), shown as the readout above the trace. + * Samples from `drift_preview` are still appended to the trace, so the day + * `spyde/drift/translation.py` grows an `on_shift(i, dy, dx)` callback the caret + * fills in progressively with no change here. + * + * Only `rigid` has a solver. `rigid+affine` and `non-rigid` are declared by the + * backend so the tabs can render, and both are shown LOCKED with the backend's + * own reason rather than silently falling back — a rigid solve under a caret + * claiming "rigid+affine" puts a wrong `kind` into the model's provenance, + * which is worse than the missing feature. + */ +import React from 'react' +import { WizardShell, TabRow, Field, NumInput, Select, Check, S } from './WizardShell' +import { useWizardLifecycle, useDebouncedAction, useWizardEvent, CommitButton } from './wizardHooks' +import type { SendAction } from './wizardHooks' + +interface Props { + caretPos: React.CSSProperties + windowId: number + sendAction: SendAction + onClose: () => void +} + +/** `drift_action.METHODS`. */ +type Method = 'rigid' | 'rigid_affine' | 'nonrigid' +type TabLabel = 'Rigid' | 'Rigid+Affine' | 'Non-rigid' +const TABS: readonly TabLabel[] = ['Rigid', 'Rigid+Affine', 'Non-rigid'] +const METHOD_OF: Record = { + 'Rigid': 'rigid', 'Rigid+Affine': 'rigid_affine', 'Non-rigid': 'nonrigid', +} +const TAB_OF: Record = { + rigid: 'Rigid', rigid_affine: 'Rigid+Affine', nonrigid: 'Non-rigid', +} +/** Verbatim from `drift_action._UNAVAILABLE` — the reason the backend gives. */ +const UNAVAILABLE: Partial> = { + rigid_affine: 'the affine drift search (plan A4) is not implemented in spyde.drift yet', + nonrigid: 'non-rigid warping (plan A5) is not implemented in spyde.drift yet', +} + +type Reference = 'running' | 'sequential' | 'first' +const REFERENCES: readonly { value: Reference; label: string }[] = [ + { value: 'running', label: 'Running average' }, + { value: 'sequential', label: 'Previous frame' }, + { value: 'first', label: 'First frame' }, +] + +/** Mirrors `drift_action.DEFAULTS`. */ +interface DriftSaved { + method: Method + reference: Reference + upsample: number + maxShift: number + apodize: boolean + normalize: boolean + rejectOutliers: boolean + order: number +} +const DEFAULTS: DriftSaved = { + method: 'rigid', reference: 'running', upsample: 8, maxShift: 32, + apodize: true, normalize: true, rejectOutliers: true, order: 1, +} +const _driftStore = new Map() + +export function DriftWizard({ caretPos, windowId, sendAction, onClose }: Props) { + const saved = _driftStore.get(windowId) ?? DEFAULTS + const [method, setMethod] = React.useState(saved.method) + const [reference, setReference] = React.useState(saved.reference) + const [upsample, setUpsample] = React.useState(saved.upsample) + const [maxShift, setMaxShift] = React.useState(saved.maxShift) + const [apodize, setApodize] = React.useState(saved.apodize) + const [normalize, setNormalize] = React.useState(saved.normalize) + const [rejectOutliers, setRejectOutliers] = React.useState(saved.rejectOutliers) + const [order, setOrder] = React.useState(saved.order) + + const [nFrames, setNFrames] = React.useState(0) + const [solved, setSolved] = React.useState(false) + const [progress, setProgress] = React.useState<{ done: number; total: number } | null>(null) + const [shifts, setShifts] = React.useState<[number, number][]>([]) + const [pair, setPair] = React.useState<{ dy: number; dx: number } | null>(null) + const [status, setStatus] = React.useState('Tune the solver, then Solve.') + + const vals = React.useRef(saved) + vals.current = { method, reference, upsample, maxShift, apodize, normalize, rejectOutliers, order } + React.useEffect(() => { _driftStore.set(windowId, vals.current) }) + + /** The backend's parameter names (`drift_action.DEFAULTS` keys). */ + const params = (): Record => { + const v = vals.current + return { + method: v.method, reference: v.reference, upsample: v.upsample, + max_shift: v.maxShift, apodize: v.apodize, normalize: v.normalize, + reject_outliers: v.rejectOutliers, order: v.order, + } + } + + // Mount → drift_open (opens the Drift Check window with the RAW sum; nothing + // solves — plan A8 is explicit that drift correction never runs on load). + // Unmount → drift_close tears the check window down. StrictMode-safe. + useWizardLifecycle({ + windowId, sendAction, + openAction: 'drift_open', openPayload: params, closeAction: 'drift_close', + }) + + // Debounced tune → re-solves the FIRST PAIR only (two FFTs), which answers + // the only question a tune can answer cheaply: are max_shift and upsample in + // the right range for this movie. + const sendTune = useDebouncedAction(sendAction, 'drift_tune', windowId) + const tune = () => sendTune(params) + const live = (set: (v: T) => void) => (v: T) => { set(v); tune() } + + useWizardEvent('spyde:drift_state', windowId, (d) => { + if (typeof d.n_frames === 'number') setNFrames(d.n_frames) + if (typeof d.solved === 'boolean') setSolved(d.solved) + // The backend refuses an unimplemented model and stays on rigid, so the + // tab follows what it actually selected — never what was clicked. + const m = String(d.method ?? '') as Method + if (m in TAB_OF) setMethod(m) + }) + + useWizardEvent('spyde:drift_preview', windowId, (d) => { + const dy = Number(d.dy), dx = Number(d.dx) + if (!Number.isFinite(dy) || !Number.isFinite(dx)) return + setPair({ dy, dx }) + // Only meaningful as a trace if the solver ever streams; see the header. + // Until then the first-pair sample is the whole "trace" before a solve. + setShifts(s => (s.length > 1 ? s : [[0, 0], [dy, dx]])) + setStatus(`First pair: dy ${dy.toFixed(2)} · dx ${dx.toFixed(2)} px`) + }) + + useWizardEvent('spyde:drift_progress', windowId, (d) => { + const done = Number(d.done ?? 0), total = Number(d.total ?? 0) + setProgress(total > 0 && done < total ? { done, total } : null) + }) + + useWizardEvent('spyde:drift_result', windowId, (d) => { + const raw = Array.isArray(d.shifts) ? (d.shifts as unknown[]) : [] + setShifts(raw.map(r => { + const p = r as [number, number] + return [Number(p?.[0]), Number(p?.[1])] as [number, number] + })) + setProgress(null) + setSolved(true) + const max = Number(d.max_abs_shift ?? 0) + const rejected = Number(d.rejected ?? 0) + setStatus(d.cancelled + ? `Cancelled — partial model, max shift ${max.toFixed(2)} px` + : `Solved: max shift ${max.toFixed(2)} px` + + (rejected ? ` · ${rejected} frames rejected` : '')) + }) + + const onMethod = (t: TabLabel) => { + const m = METHOD_OF[t] + setMethod(m) + vals.current = { ...vals.current, method: m } + sendAction('drift_set_method', { method: m }, windowId) + } + + const solve = () => { + setShifts([]) + setStatus(`Solving drift over ${nFrames || '…'} frames`) + sendAction('drift_run', params(), windowId) + } + + const locked = UNAVAILABLE[method] + const pct = progress ? Math.round((progress.done / progress.total) * 100) : 0 + + return ( + + Boolean(UNAVAILABLE[METHOD_OF[t]])} + testid={(t) => `drift-tab-${METHOD_OF[t]}`} + /> + {/* Both stubs are locked, so this names them rather than waiting for a + click that cannot happen. Text is the backend's own wording. One block, + not two stacked paragraphs — the caret's height decides whether it can + sit BELOW the window or gets pushed to the side, onto the Drift Check + window it just opened. */} +
+ {locked ?? 'Rigid+Affine and Non-rigid are not implemented in spyde.drift yet.'} + {' Check the result in the Drift Check window — an aligned stack sums sharp.'} +
+ + + + + + n.toFixed(1)} /> + + + + + + + + + + + + + + + n.toFixed(1)} /> + + + + + + +
+ )} + + + {/* ── right: feedback + classes ────────────────────────────────── */} +
+
size {areaUnits}
+ +
+ {preview + ? `${preview.count} found · med ${fmtArea(preview.median)}` + : 'no preview yet'} +
+ +
+ classes +
+
+ {classes.length === 0 &&
} + {classes.map(c => { + const low = c.pixels < LOW_PIXELS + return ( + + ) + })} + {/* The backend has no `seg_add_class` staged verb (LabelStore + .add_class exists but nothing routes to it), so this is an + affordance with the reason on it rather than a button that + silently does nothing. */} + +
+
+ + +
+ + + +
+
+ {labelledFrames.length} frames labelled · {classes.length} classes + {labelledPixels > 0 ? ` · ${labelledPixels.toLocaleString()} px` : ''} + {` · frame ${frame}`} +
+ + + {/* Painting controls go NEXT TO THE PLOT, not in the caret (plan B0). + Rendered AFTER the shell so FloatingToolbar's placement effect still + measures the caret (it reads the wrapper's firstElementChild). */} + {stripPos && classes.length > 0 && ( + { setBrush(b); vals.current = { ...vals.current, brush: b }; tune() }} + eraser={eraser} onEraser={setEraser} + posStyle={stripPos} + /> + )} + + ) +} + +// ── the size histogram ─────────────────────────────────────────────────────── + +const N_BINS = 18 + +/** + * A tiny inline SVG sparkline of the per-instance area distribution — no + * charting dependency, and it re-renders on every preview so you SEE the + * distribution shift as sensitivity is dragged instead of guessing from one + * count. + * + * Binned to the 98th percentile rather than the max: one 50× outlier (two + * merged particles, or the support film caught as one body) otherwise squashes + * every real bar into the first bin and the histogram says nothing. + */ +function SizeHistogram({ areas }: { areas: number[] }) { + const bins = React.useMemo(() => { + if (!areas.length) return new Array(N_BINS).fill(0) + const sorted = [...areas].sort((a, b) => a - b) + const hi = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.98))] || 1 + const out = new Array(N_BINS).fill(0) + for (const a of areas) { + const k = Math.min(N_BINS - 1, Math.max(0, Math.floor((a / hi) * N_BINS))) + out[k] += 1 + } + return out + }, [areas]) + + const peak = Math.max(1, ...bins) + const w = 100 / N_BINS + return ( + b > 0).length} + viewBox="0 0 100 32" preserveAspectRatio="none" + style={{ width: '100%', height: 32, display: 'block' }}> + + {bins.map((b, i) => { + const h = (b / peak) * 30 + return + })} + + ) +} + +function fmtArea(v: number): string { + if (!Number.isFinite(v)) return '—' + if (v === 0) return '0' + if (v >= 100) return v.toFixed(0) + if (v >= 10) return v.toFixed(1) + return v.toPrecision(2) +} + +// Module-scope, NOT inline: a component defined inside the render body is a new +// type every render, so React remounts the sliders on each keystroke and they +// lose their drag ("sliders don't work") — see FindVectorsWizard's Cell. +function Cell({ label, children }: { label: string; children: React.ReactNode }) { + return
{children}
+} +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +const colsStyle: React.CSSProperties = { + display: 'grid', gridTemplateColumns: '1fr 1fr', columnGap: 10, + // NO overflow here: the Threshold Dropdown's menu is absolutely positioned + // and any overflow:auto ancestor clips it (PlotControlDock.tsx:730). + alignItems: 'start', +} +const colStyle: React.CSSProperties = { + display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0, +} +const cellStyle: React.CSSProperties = { + display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, +} +const statsStyle: React.CSSProperties = { + fontSize: 10, color: '#cdd6f4', fontVariantNumeric: 'tabular-nums', +} +const classListStyle: React.CSSProperties = { + display: 'flex', flexDirection: 'column', gap: 2, + maxHeight: 132, overflowY: 'auto', +} +const classRowStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 5, width: '100%', + background: 'none', border: '1px solid transparent', borderRadius: 4, + padding: '2px 4px', cursor: 'pointer', color: '#cdd6f4', textAlign: 'left', +} +const classRowActiveStyle: React.CSSProperties = { + background: '#313244', + // The full `border` SHORTHAND, never a `borderColor` longhand on top of the + // base row's shorthand. React removes a dropped longhand by clearing that one + // property, which leaves the shorthand's width/style with a reset colour — a + // row that had been active kept a stale WHITE 1px border and read as selected + // alongside the real selection (caught in a screenshot, not by a test). + border: '1px solid #89b4fa', +} +const classDotStyle: React.CSSProperties = { + width: 9, height: 9, borderRadius: 2, flex: '0 0 auto', +} +const classNameStyle: React.CSSProperties = { + fontSize: 10, flex: 1, minWidth: 0, overflow: 'hidden', + textOverflow: 'ellipsis', whiteSpace: 'nowrap', +} +const classPxStyle: React.CSSProperties = { + fontSize: 10, fontVariantNumeric: 'tabular-nums', flex: '0 0 auto', +} +const addClassStyle: React.CSSProperties = { + background: 'none', border: '1px dashed #45475a', borderRadius: 4, + color: '#6c7086', fontSize: 10, padding: '2px 4px', cursor: 'not-allowed', + textAlign: 'left', +} +const btnRowStyle: React.CSSProperties = { + display: 'flex', gap: 6, flexWrap: 'wrap', borderTop: '1px solid #313244', + paddingTop: 6, +} +const disabledBtnStyle: React.CSSProperties = { + ...S.primary, background: '#313244', color: '#6c7086', cursor: 'not-allowed', +} +const moreStyle: React.CSSProperties = { + background: 'none', border: 'none', color: '#89b4fa', fontSize: 10, + cursor: 'pointer', padding: 0, textAlign: 'left', alignSelf: 'flex-start', +} +const noteStyle: React.CSSProperties = { + fontSize: 10, color: '#f9e2af', background: 'rgba(249,226,175,0.08)', + border: '1px solid rgba(249,226,175,0.25)', borderRadius: 4, padding: '3px 5px', +} +const warnStyle: React.CSSProperties = { ...noteStyle, color: '#fab387' } +const okNoteStyle: React.CSSProperties = { + fontSize: 10, color: '#a6e3a1', background: 'rgba(166,227,161,0.08)', + border: '1px solid rgba(166,227,161,0.25)', borderRadius: 4, padding: '3px 5px', +} diff --git a/electron/src/renderer/src/kernel/SpyDEContext.tsx b/electron/src/renderer/src/kernel/SpyDEContext.tsx index 772288bb..63e4dea4 100644 --- a/electron/src/renderer/src/kernel/SpyDEContext.tsx +++ b/electron/src/renderer/src/kernel/SpyDEContext.tsx @@ -1481,6 +1481,21 @@ export function SpyDEProvider({ children }: { children: React.ReactNode }) { // A panel-local payload, so it gets a CustomEvent rather than reducer // state, exactly like layers_state. case 'particles_table': + // Segment Particles caret (spyde/actions/particles_action.py) — + // `seg_state` is the authoritative caret state (classes + per-class + // labelled-pixel counts, effective params), `seg_preview` one frame's + // result (count, size histogram, the EFFECTIVE min_size), `seg_trained` + // the scribble classifier's fit report. Consumed by SegmentWizard. + case 'seg_state': + case 'seg_preview': + case 'seg_trained': + // Drift Correction caret (spyde/actions/drift_action.py) — caret state, + // the first-pair tune readout, whole-movie solve progress, and the + // solved shift trace. Consumed by DriftWizard. + case 'drift_state': + case 'drift_preview': + case 'drift_progress': + case 'drift_result': // Cluster telemetry — consumed by the StatusBar DaskMonitor HUD. case 'dask_stats': // Read-throughput readout — consumed by the StatusBar IoThroughput HUD. diff --git a/electron/src/renderer/src/kernel/protocol.ts b/electron/src/renderer/src/kernel/protocol.ts index 154a5fd0..81e3a792 100644 --- a/electron/src/renderer/src/kernel/protocol.ts +++ b/electron/src/renderer/src/kernel/protocol.ts @@ -873,6 +873,130 @@ export interface ParticlesTableMessage extends MsgBase { partial?: boolean } +// ── Segment Particles caret (spyde/actions/particles_action.py, plan B7) ───── + +/** One scribble class as the backend reports it — `ScribbleClass.to_dict()` + * plus the labelled-pixel count `SegmentWizard.class_report()` adds. + * + * `pixels` is NOT decoration: under-training a class is the failure mode + * plan §B3 calls out, and this count is how the user notices. A class with + * zero pixels is present in the list rather than absent from it. */ +export interface SegClassInfo { + id: number + name: string + /** CSS hex — the renderer's units; the backend never converts it. */ + colour: string + /** Counts toward the foreground probability map (several classes may). */ + particle: boolean + pixels: number +} + +/** The caret's authoritative state, emitted by `_emit_state` after open, + * method switch, paint and train. `params` are the EFFECTIVE (coerced) + * values, not what the caret last sent. */ +export interface SegStateMessage extends MsgBase { + type: 'seg_state' + window_id: number | null + /** 'classical' | 'scribble' | 'prompt'. */ + method: string + /** Navigator frame the preview is showing. */ + frame: number + n_frames: number + frame_shape: [number, number] + classes: SegClassInfo[] + /** Frame indices carrying at least one scribble. */ + labelled_frames: number[] + trained: boolean + params: Record +} + +/** One frame's preview result. `min_size` is the EFFECTIVE value that ran and + * `min_size_floored` says the backend raised what the caret asked for — the + * caret must show the effective number (plan §0.9: at min_size=0 the split + * returns background speckle as particles). `count` is already post-filter. */ +export interface SegPreviewMessage extends MsgBase { + type: 'seg_preview' + window_id: number | null + frame: number + count: number + /** Per-instance areas in calibrated units², capped at 2000 entries. */ + areas: number[] + median_area: number + /** Signal-axis unit ("nm"); areas are in unit². */ + units: string + method: string + min_size: number + min_size_floored: boolean + elapsed_ms: number +} + +/** Scribble classifier fit report (`ScribbleClassifier.fit`). */ +export interface SegTrainedMessage extends MsgBase { + type: 'seg_trained' + window_id: number | null + report: { + device?: string + n_pixels?: number + n_channels?: number + n_classes?: number + labelled_frames?: number[] + train_accuracy?: number + [k: string]: unknown + } +} + +// ── Drift Correction caret (spyde/actions/drift_action.py, plan A8) ────────── + +/** Caret state. `window_id` is the SOURCE plot's window (where the caret + * lives); `check_window_id` is the separate bare-figure Drift Check window + * that holds the before/after sums. */ +export interface DriftStateMessage extends MsgBase { + type: 'drift_state' + window_id: number | null + check_window_id: number | null + /** 'rigid' | 'rigid_affine' | 'nonrigid' — only rigid has a solver. */ + method: string + solved: boolean + params: Record + /** Present on the open/ready emissions. */ + n_frames?: number +} + +/** A `drift_tune` result — the FIRST PAIR only (two FFTs, lands inside a + * slider drag). NOT a per-frame stream: the whole-movie trace arrives once, + * in `drift_result`. */ +export interface DriftPreviewMessage extends MsgBase { + type: 'drift_preview' + window_id: number | null + dy: number + dx: number + /** Solver residual for the pair; NaN when the model carries none. */ + sharpness: number + params: Record +} + +/** Whole-movie solve progress (also emitted as a plain `progress` message). */ +export interface DriftProgressMessage extends MsgBase { + type: 'drift_progress' + window_id: number | null + done: number + total: number +} + +/** The solved model. `shifts[i]` is the correction ADDED to frame i, `[dy, dx]` + * in pixels; a cancelled solve leaves NaN rows for frames it never reached. */ +export interface DriftResultMessage extends MsgBase { + type: 'drift_result' + window_id: number | null + shifts: [number, number][] + kind: string + reference: string + max_abs_shift: number + /** Frames dropped from the running reference by the outlier rejector. */ + rejected: number + cancelled: boolean +} + /** * Wizard-scoped events re-broadcast verbatim as DOM CustomEvents (the caret * components subscribe directly). The payload beyond `type` is consumer-defined, @@ -999,6 +1123,13 @@ export type PlotAppMessage = | DaskStatsMessage | IoThroughputMessage | ParticlesTableMessage + | SegStateMessage + | SegPreviewMessage + | SegTrainedMessage + | DriftStateMessage + | DriftPreviewMessage + | DriftProgressMessage + | DriftResultMessage /** * Narrow a raw incoming message (`Record` from the IPC bridge) diff --git a/electron/tests/drift_wizard.spec.ts b/electron/tests/drift_wizard.spec.ts new file mode 100644 index 00000000..20036905 --- /dev/null +++ b/electron/tests/drift_wizard.spec.ts @@ -0,0 +1,97 @@ +/** + * drift_wizard.spec.ts — the Drift Correction caret, end-to-end on the bundled + * synthetic particle movie (whose per-frame drift is ground truth, stamped into + * `metadata.Spyde.synthetic`). + * + * What this proves that tsc + headless tests cannot: + * 1. `drift_open` opens the SEPARATE Drift Check window (plan A8 — a 240 px + * caret cannot show a sum image at a size where sharpness is judgeable, + * and judging sharpness IS the check), and the caret is not clipped. + * 2. The two unimplemented models are LOCKED with the backend's own reason — + * not silently falling back to rigid under a caret claiming otherwise. + * 3. Solve fills the inline dy/dx trace from `drift_result`. + * 4. Apply adds the lazy corrected node. + */ +import { test, expect } from '@playwright/test' +import { mkdirSync } from 'fs' +const { + launchApp, backendAction, waitForSubwindowCount, sigWindow, backendErrorLines, +} = require('./_harness.cjs') + +const SHOTS = 'drift_wizard_shots' +let ctx: Awaited> + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(300_000) + +test.beforeAll(async () => { + mkdirSync(SHOTS, { recursive: true }) + ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'INFO' } }) + const { page } = ctx + await page.waitForTimeout(1500) + await backendAction(page, 'load_test_data_particles', { frames: 8 }) + await waitForSubwindowCount(page, 2, 120_000) + await page.waitForTimeout(2000) +}) + +test.afterAll(async () => { + await ctx?.app?.close() +}) + +test('the caret opens its Drift Check window and locks the stub models', async () => { + const { page } = ctx + const sig = sigWindow(page) + await sig.getByTestId('subwindow-title').click() + await sig.getByTestId('subwindow-titlebar').hover() + await sig.getByTestId('action-btn-Drift Correction').click() + await expect(page.getByTestId('drift-wizard')).toBeVisible() + await page.screenshot({ path: `${SHOTS}/01-caret-open.png` }) + + // The verification surface is a WINDOW, not the caret (plan A8): raw sum + + // corrected sum + dy/dx panels. + await waitForSubwindowCount(page, 3, 120_000) + await page.waitForTimeout(3000) + await page.screenshot({ path: `${SHOTS}/02-check-window.png` }) + + await expect(page.getByTestId('drift-tab-rigid_affine')).toBeDisabled() + await expect(page.getByTestId('drift-tab-nonrigid')).toBeDisabled() + await expect(page.getByTestId('drift-unavailable')).toContainText('not implemented') + + await page.getByTestId('drift-wizard').screenshot({ path: `${SHOTS}/03-caret-detail.png` }) + ctx.assertNoJsErrors() +}) + +test('Solve fills the shift trace and Apply adds the corrected node', async () => { + const { page } = ctx + + // A tune re-solves the FIRST PAIR only (two FFTs) — the cheap answer to "is + // max_shift in the right range for this movie". + await page.getByTestId('drift-max-shift').fill('24') + await page.getByTestId('drift-max-shift').blur() + await expect(page.getByTestId('drift-preview-readout')).toBeVisible({ timeout: 60_000 }) + await page.getByTestId('drift-wizard').screenshot({ path: `${SHOTS}/04-first-pair.png` }) + + await page.getByTestId('drift-solve').click() + // The trace is drawn from drift_result's whole shifts array (the solver + // returns nothing partial — see the DriftWizard header), so wait for one + // point per frame. + await expect.poll( + async () => Number(await page.getByTestId('drift-trace').getAttribute('data-points')), + { timeout: 180_000, message: 'the shift trace never filled from drift_result' }, + ).toBeGreaterThan(2) + await expect(page.getByTestId('drift-status')).toContainText('Solved') + await page.getByTestId('drift-wizard').screenshot({ path: `${SHOTS}/05-solved.png` }) + await page.screenshot({ path: `${SHOTS}/06-solved-full.png` }) + + // Apply adds the LAZY corrected node to the tree (map_blocks, nothing copied) + // and shows it — so it appears in the Plot Control workflow list. + await page.getByTestId('drift-commit').click() + await expect(page.getByTestId('tree-node-Drift corrected')).toBeVisible({ timeout: 60_000 }) + await expect(page.getByTestId('status-text')).toContainText('Drift corrected node added') + await page.waitForTimeout(2000) + await page.screenshot({ path: `${SHOTS}/07-applied.png` }) + + const errors = backendErrorLines(ctx.backend) + expect(errors, `backend errors:\n${errors.join('\n')}`).toEqual([]) + ctx.assertNoJsErrors() +}) diff --git a/electron/tests/segment_wizard.spec.ts b/electron/tests/segment_wizard.spec.ts new file mode 100644 index 00000000..ce10d8db --- /dev/null +++ b/electron/tests/segment_wizard.spec.ts @@ -0,0 +1,268 @@ +/** + * segment_wizard.spec.ts — the Segment Particles caret, end-to-end on the + * bundled synthetic particle movie. + * + * What this actually proves (headless tests + tsc cannot see any of it): + * 1. The caret opens from the real toolbar button and the backend previews + * the DISPLAYED frame — the size histogram has bars, not an empty box. + * 2. The floating brush strip renders NEXT TO THE PLOT (plan B0) with one + * swatch per backend class, and is not clipped by the window. + * 3. `min_size` = 0 is FLOORED by the backend and the caret shows the + * EFFECTIVE value, not the 0 the user typed (plan §0.9 — at 0 the split + * returns background speckle as particles). + * 4. Run All opens a real particle result window. + * 5. A brush stroke reaches `seg_paint` and the per-class labelled-pixel + * counts in the caret update — the counts are how a user notices an + * under-trained class, so a count stuck at 0 is a real failure. + * + * Real Dask + `load_test_data_particles` (lazy, 1 frame/chunk, ground truth + * stamped into metadata) — the path a user actually drags. + */ +import { test, expect } from '@playwright/test' +import { mkdirSync } from 'fs' +const { + launchApp, backendAction, waitForSubwindowCount, sigWindow, backendErrorLines, +} = require('./_harness.cjs') + +const SHOTS = 'segment_wizard_shots' +let ctx: Awaited> +/** `data-testid="figure-"` on the signal window's iframe. */ +let figId = '' + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(300_000) + +test.beforeAll(async () => { + mkdirSync(SHOTS, { recursive: true }) + // INFO tees `logging` to stderr, which the harness captures — backend + // emit_error goes over the PLOTAPP protocol and never reaches this buffer. + ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'INFO' } }) + const { page } = ctx + // backend-ready can land a beat before the stdin pump is live; the lazy specs + // settle the same way so the first action isn't dropped. + await page.waitForTimeout(1500) + await backendAction(page, 'load_test_data_particles', { frames: 6 }) + await waitForSubwindowCount(page, 2, 120_000) + await page.waitForTimeout(2000) +}) + +test.afterAll(async () => { + await ctx?.app?.close() +}) + +/** The SOURCE movie's signal window specifically — `sigWindow` picks the first + * `S-` window, and Run All adds a second one ("S-Particles — 6 frames"). */ +function srcWindow() { + const { page } = ctx + return page.getByTestId('subwindow').filter({ + has: page.getByTestId('window-breadcrumb').filter({ hasText: /^S-Synthetic/ }), + }).first() +} + +/** Raise the source window above the result windows Run All cascades on top of + * it. The caret lives in that window's stacking context, so a newer window + * stacked above ALSO covers the caret and swallows clicks meant for it — the + * same thing a user does (click the window) fixes it. */ +async function raiseSource() { + await srcWindow().getByTestId('subwindow-title').click() +} + +/** Open the caret from the REAL toolbar button (hover the titlebar to reveal + * the floating bar), exactly as a user does. */ +async function openCaret() { + const { page } = ctx + const sig = sigWindow(page) + await sig.getByTestId('subwindow-title').click() + await sig.getByTestId('subwindow-titlebar').hover() + await sig.getByTestId('action-btn-Segment Particles').click() + await expect(page.getByTestId('segment-wizard')).toBeVisible() + return sig +} + +test('caret opens, previews the displayed frame, and shows the brush strip', async () => { + const { page } = ctx + await page.screenshot({ path: `${SHOTS}/01-movie-loaded.png` }) + + const sig = await openCaret() + const tid = await sig.locator('iframe').first().getAttribute('data-testid') + figId = (tid ?? '').replace(/^figure-/, '') + expect(figId).not.toBe('') + + await page.screenshot({ path: `${SHOTS}/02-caret-open.png` }) + + // The preview is a real backend round trip (seg_open → worker → seg_preview), + // so wait for the stats line to stop saying "no preview yet". + await expect.poll( + () => page.getByTestId('seg-preview-stats').textContent(), + { timeout: 60_000, message: 'seg_preview never reached the caret' }, + ).toMatch(/found/) + + // An EMPTY histogram is the classic "it rendered but says nothing" failure — + // assert on the bar count the component publishes, not on pixels. + await expect.poll( + async () => Number(await page.getByTestId('seg-histogram').getAttribute('data-nonzero')), + { timeout: 30_000, message: 'size histogram has no populated bins' }, + ).toBeGreaterThan(0) + + // The brush strip is next to the PLOT, not in the caret (plan B0). + const strip = page.getByTestId('seg-class-strip') + await expect(strip).toBeVisible() + await expect(page.getByTestId('seg-strip-class-0')).toBeVisible() + await expect(page.getByTestId('seg-strip-brush')).toBeVisible() + await expect(page.getByTestId('seg-strip-eraser')).toBeVisible() + + // The class list carries NAMES + per-class pixel counts (the caret is the + // authoritative list; the strip is swatches only). + await expect(page.getByTestId('seg-class-0')).toBeVisible() + await expect(page.getByTestId('seg-class-pixels-0')).toBeVisible() + + await page.getByTestId('segment-wizard').screenshot({ path: `${SHOTS}/03-caret-detail.png` }) + await page.screenshot({ path: `${SHOTS}/04-preview.png` }) + ctx.assertNoJsErrors() +}) + +test('sensitivity re-previews and min_size=0 is floored to the EFFECTIVE value', async () => { + const { page } = ctx + const stats = page.getByTestId('seg-preview-stats') + + const before = await stats.textContent() + await page.getByTestId('seg-sensitivity').fill('0.85') + await expect.poll(() => stats.textContent(), { + timeout: 60_000, message: 'dragging sensitivity did not re-preview', + }).not.toBe(before) + await page.screenshot({ path: `${SHOTS}/05-sensitivity.png` }) + + // min_size=0 is the footgun plan §0.9 measured (33 instances where 9 are + // real). The backend floors it to 10 and the caret must show 10. + const minSize = page.getByTestId('seg-min-size') + await minSize.fill('0') + await minSize.blur() + await expect(page.getByTestId('seg-min-size-floor')).toBeVisible({ timeout: 60_000 }) + await expect.poll(() => minSize.inputValue(), { + timeout: 30_000, message: 'caret still shows 0 while the backend ran 10', + }).toBe('10') + + await page.getByTestId('segment-wizard').screenshot({ path: `${SHOTS}/06-minsize-floored.png` }) + + // Back to a sane value for the batch below. + await minSize.fill('20') + await minSize.blur() + ctx.assertNoJsErrors() +}) + +test('Run All segments the movie into a new particle window', async () => { + const { page } = ctx + const before = await page.getByTestId('subwindow').count() + await page.getByTestId('seg-run').click() + await expect.poll(() => page.getByTestId('subwindow').count(), { + timeout: 180_000, message: 'the particle result window never opened', + }).toBeGreaterThan(before) + await page.screenshot({ path: `${SHOTS}/07a-run-early-window.png` }) + + // The window opens EARLY with an empty store; `tree.particles` attaches only + // at _finalize, which re-sends the toolbar — so the requires_particles-gated + // buttons appearing IS the "batch finished" signal, exactly as + // `particles_action._rebuild_toolbars` documents ("the e2e specs wait on + // exactly this appearing"). NB the status bar is NOT usable here: seg_run's + // last emit_progress leaves busy=true, and StatusBar shows loading.text over + // status while busy — so "Found N particles" never becomes visible. + // Depends on the requires_particles toolbar entries (plan B9, Particle + // Overlay / Particle Lanes) being present in spyde/toolbars.yaml. + await expect.poll( + () => page.getByTestId('action-btn-Particle Overlay').count(), + { timeout: 180_000, message: 'the segmentation batch never finalized' }, + ).toBeGreaterThan(0) + await page.waitForTimeout(2000) + await page.screenshot({ path: `${SHOTS}/07-run-all.png` }) + ctx.assertNoJsErrors() +}) + +test('a brush stroke reaches seg_paint and the class pixel counts update', async () => { + const { page } = ctx + + await raiseSource() + await page.getByTestId('seg-tab-scribble').click() + await expect(page.getByTestId('seg-scribble-note')).toBeVisible() + + const pixels0 = page.getByTestId('seg-class-pixels-0') + expect(await pixels0.textContent()).toContain('0') + + // Fatten the brush first, then paint with the strip's ACTIVE class — i.e. + // drive the same state the strip owns, not a synthetic payload. + await page.getByTestId('seg-strip-brush').fill('7') + await page.getByTestId('seg-strip-class-1').click() + + // The anyplotlib brush widget (plan B0) is not landed, so post the widget + // event the caret listens for. Points are IMAGE PIXELS [[y, x], …] with no + // scale/offset applied — plan trap 6, and what seg_paint documents. + const stroke = async (y: number, x0: number, x1: number) => { + await page.evaluate(({ id, y, x0, x1 }) => { + const points: number[][] = [] + for (let x = x0; x <= x1; x += 1) points.push([y, x]) + window.dispatchEvent(new CustomEvent('spyde:figure_event', { + detail: { figId: id, event: { type: 'brush_stroke', points } }, + })) + }, { id: figId, y, x0, x1 }) + } + // Selecting on the strip drives the caret's class list too — ONE active + // class, not two highlighted rows. + await expect(page.getByTestId('seg-class-1')).toHaveAttribute('data-active', 'true') + await expect(page.getByTestId('seg-class-0')).toHaveAttribute('data-active', 'false') + + await stroke(20, 20, 90) // class 1 (support film) + await expect.poll(() => page.getByTestId('seg-class-pixels-1').textContent(), { + timeout: 30_000, message: 'seg_paint never updated the class pixel counts', + }).not.toMatch(/^!?\s*0$/) + + await page.getByTestId('seg-strip-class-0').click() + await expect(page.getByTestId('seg-class-0')).toHaveAttribute('data-active', 'true') + await expect(page.getByTestId('seg-class-1')).toHaveAttribute('data-active', 'false') + await stroke(48, 30, 80) // class 0 (particle) + await expect.poll(() => pixels0.textContent(), { + timeout: 30_000, message: 'painting class 0 did not update its count', + }).not.toMatch(/^!?\s*0$/) + + await page.getByTestId('segment-wizard').screenshot({ path: `${SHOTS}/08-painted.png` }) + // A close-up of the class list on its own: the per-class counts and the + // active-row indication are the two things here that have to be legible. + await page.getByTestId('seg-class-list').screenshot({ path: `${SHOTS}/08b-class-list.png` }) + // EXACTLY ONE row may look selected. A previously-active row used to keep a + // stale white 1px border (React clears a dropped `borderColor` longhand but + // leaves the base `border` shorthand's width/style), so two rows read as + // selected at once — invisible to every attribute assertion above. + const borders = await page.evaluate(() => + [...document.querySelectorAll('[data-testid^="seg-class-"]')] + .filter(e => /^seg-class-\d+$/.test(e.getAttribute('data-testid') ?? '')) + .map(e => getComputedStyle(e).borderTopColor)) + expect(borders.filter(c => c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent'), + `rows with a visible border: ${JSON.stringify(borders)}`).toHaveLength(1) + await page.screenshot({ path: `${SHOTS}/09-painted-full.png` }) + + // The counts line is the "did I label enough" readout. + await expect(page.getByTestId('seg-counts')).toContainText('frames labelled') + ctx.assertNoJsErrors() +}) + +test('Train fits the scribble classifier and the caret reports it', async () => { + const { page } = ctx + await raiseSource() + const train = page.getByTestId('seg-train') + await expect(train).toBeEnabled() + await train.click() + + // Assert on the PERSISTENT report line, not the status: the backend follows + // seg_trained with a re-preview whose status overwrites it milliseconds later + // (a poll on the status races that and loses). + await expect(page.getByTestId('seg-trained-note')).toBeVisible({ timeout: 180_000 }) + await expect(page.getByTestId('seg-trained-note')).toContainText(/Trained on \d+ px/) + // Training flips the engine to scribble and unlocks the batch. + await expect(page.getByTestId('seg-scribble-note')).toHaveCount(0) + await expect(page.getByTestId('seg-run')).toBeEnabled() + + await page.getByTestId('segment-wizard').screenshot({ path: `${SHOTS}/10-trained.png` }) + await page.screenshot({ path: `${SHOTS}/11-trained-full.png` }) + + const errors = backendErrorLines(ctx.backend) + expect(errors, `backend errors:\n${errors.join('\n')}`).toEqual([]) + ctx.assertNoJsErrors() +}) diff --git a/spyde/actions/drift_action.py b/spyde/actions/drift_action.py index 5f9a8509..abc913b6 100644 --- a/spyde/actions/drift_action.py +++ b/spyde/actions/drift_action.py @@ -71,6 +71,12 @@ #: for both sums, or the comparison means nothing. _SUM_MAX_FRAMES = 64 +# Frames per streamed drift-trace message. One message per frame would flood the +# PLOTAPP line protocol at the plan's target scale (thousands of frames) for a +# curve the eye cannot follow at that resolution; batching by 16 keeps the trace +# visibly live while cutting the message count by the same factor. +_TRACE_BATCH = 16 + DEFAULTS: dict[str, Any] = dict( method="rigid", upsample=8, @@ -520,10 +526,29 @@ def _progress(done, total): emit({"type": "drift_progress", "window_id": wiz.src_window_id, "done": int(done), "total": int(total)}) + # Stream the curve as it solves. `progress` carries only a count and the + # shift array is solver-local until the return, so without this callback + # the caret can show a bar but not a trace. Batched rather than emitted + # per frame: at thousands of frames one message each would flood the + # PLOTAPP line protocol for a curve the eye cannot follow that finely. + pending: list[tuple[int, float, float]] = [] + + def _on_shift(i, dy, dx, _sharp): + pending.append((int(i), float(dy), float(dx))) + if len(pending) >= _TRACE_BATCH: + emit({"type": "drift_trace", "window_id": wiz.src_window_id, + "points": pending[:]}) + pending.clear() + model = solve_translation( - wiz.signal(), progress=_progress, cancel=lambda: stopped[0], + wiz.signal(), progress=_progress, on_shift=_on_shift, + cancel=lambda: stopped[0], provenance={"action": "Drift Correction", "params": dict(p)}, **_solver_kwargs(p)) + if pending: + emit({"type": "drift_trace", "window_id": wiz.src_window_id, + "points": pending[:]}) + pending.clear() if stopped[0]: return model, None # One extra streaming pass over the SAME bounded subset the raw sum diff --git a/spyde/actions/particles_action.py b/spyde/actions/particles_action.py index 1c90f15b..6d23bf8a 100644 --- a/spyde/actions/particles_action.py +++ b/spyde/actions/particles_action.py @@ -947,8 +947,16 @@ def _finalize(session, result, placeholder, per_frame, contours, p, log.debug("[seg] clearing stale cached dask array failed: %s", exc) _paint_count_trace(result, placeholder.count_series()) + _repaint_label_movie(result) _rebuild_toolbars(result) + # TERMINAL progress. Without it `state.loading.busy` never clears in the + # renderer: the StatusBar spinner spins forever and — because it prefers + # `loading.text` while busy (StatusBar.tsx) — "Segmenting (33%)" permanently + # masks the "Found N particles" line emitted immediately below. Found by + # driving the real UI; no headless test could see it. + emit_progress(n_frames, n_frames, "Segmenting") + n = placeholder.n_particles if cancelled: emit_status(f"Segmentation cancelled — found {n} particles in the " @@ -957,6 +965,36 @@ def _finalize(session, result, placeholder, per_frame, contours, p, emit_status(f"Found {n} particles in {n_frames} frames") +def _repaint_label_movie(result) -> None: + """Push the finalized frame to the label-movie window. + + Clearing the stale cached dask array (above) makes the NEXT read correct, but + nothing triggers a read — so the window keeps showing the placeholder's zeros + and the result looks empty until the user happens to scrub. Re-read the + currently-displayed frame and paint it. + + Best-effort: a failure here costs a stale frame until the next scrub, which is + exactly the state we were in before, so it must never take the finalize down + with it. + """ + from spyde.actions.lifecycle import paint_signal_plots + + try: + particles = result.particles + if particles is None or particles.n_frames == 0: + return + t = 0 + for plot in getattr(result, "plots", None) or (): + idx = getattr(plot, "current_indices", None) + if idx: + t = int(np.atleast_1d(idx)[0]) + break + t = max(0, min(t, particles.n_frames - 1)) + paint_signal_plots(result, particles.render_frame(t, value="track")) + except Exception as exc: + log.debug("[seg] repainting the label movie after finalize failed: %s", exc) + + def _adopt(placeholder, final) -> None: """Move *final*'s arrays onto *placeholder*, in place. From 4ac6604de65028c1bf9ef8f0f4a5ef442fbeb34d Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 22:06:32 -0500 Subject: [PATCH 12/38] feat(drift): correlate on an alignment ROI `solve_translation(..., roi=(y0, x0, h, w))` measures the shift on a sub-region while the returned shifts still apply to the whole frame -- a translation is a translation regardless of the window you measured it in. This is not a speed switch, it is often the more CORRECT answer. Whole-frame correlation averages over everything that moved, so on an in-situ movie where the sample is genuinely evolving -- particles growing, drifting, appearing -- the sample's own motion contaminates the estimate of the stage's. Restricting to a static feature-rich landmark measures the stage and nothing else. A test builds exactly that adversarial case: a bright square tracking the other way outside the ROI drags the whole-frame solve while the ROI solve correctly reports ~zero, and it asserts BOTH halves so the fixture cannot quietly stop demonstrating the point. The ROI is fixed in frame coordinates, so the landmark drifts within it; that is fine while the drift is small against the box, and it is why the box wants to be comfortably larger than the excursion. Documented, and the forthcoming caret preview exists so a user judges it by eye instead of guessing. Out-of-bounds and too-small ROIs RAISE rather than clamp. A silently shrunk box would correlate on a region the user never dragged, and the resulting drift curve would be wrong in a way nothing on screen could explain. 49 tests. --- spyde/drift/translation.py | 64 +++++++++++++- .../tests/migrated/test_drift_translation.py | 83 +++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/spyde/drift/translation.py b/spyde/drift/translation.py index f7464721..e0be84fa 100644 --- a/spyde/drift/translation.py +++ b/spyde/drift/translation.py @@ -98,6 +98,11 @@ _REJECT_FRACTION = 0.25 _REJECT_MIN_SAMPLES = 1 +# Smallest alignment ROI worth correlating. Below roughly this the upsampled +# refinement window (1.5 x upsample, so 12 px at the default) approaches the box +# itself and the peak has nowhere to sit. +_MIN_ROI = 16 + # ── operator adapters ──────────────────────────────────────────────────────── # The algorithm below is written once against this interface. `_TorchOps` is the @@ -418,6 +423,31 @@ def _peak_shift(ops, ref_fft, mov_fft, mask, upsample: float, return dy, dx, sharpness +def _validate_roi(roi, full_h: int, full_w: int): + """Normalise and bounds-check an ``(y0, x0, h, w)`` alignment ROI. + + Rejects rather than clamps. A silently shrunk ROI would correlate on a + different region than the one the user dragged, and the drift curve would be + wrong in a way nothing on screen could explain. + """ + if roi is None: + return None + try: + y0, x0, h, w = (int(v) for v in roi) + except (TypeError, ValueError): + raise ValueError( + f"roi must be (y0, x0, h, w) in pixels; got {roi!r}") from None + if h < _MIN_ROI or w < _MIN_ROI: + raise ValueError( + f"roi is {h}x{w} px; the correlation needs at least " + f"{_MIN_ROI}x{_MIN_ROI} to locate a peak at all") + if y0 < 0 or x0 < 0 or y0 + h > full_h or x0 + w > full_w: + raise ValueError( + f"roi (y0={y0}, x0={x0}, h={h}, w={w}) falls outside the " + f"{full_h}x{full_w} frame") + return (y0, x0, h, w) + + def _accept_into_reference(sharpness: float, accepted: list[float], enabled: bool) -> bool: """Whether this frame is credible enough to join the running reference. @@ -457,6 +487,7 @@ def solve_translation( max_shift: float | None = 32.0, min_shift: float | None = None, reference: str = "running", + roi: tuple[int, int, int, int] | None = None, apodize: bool | float = True, normalize: bool = True, reject_outliers: bool = True, @@ -489,6 +520,26 @@ def solve_translation( frame); ``"sequential"`` — register to the previous frame and accumulate (handles large excursions, accumulates error); ``"first"`` or ``"fixed:"`` — one fixed reference frame. + roi + ``(y0, x0, h, w)`` in pixels — correlate on this sub-region only. The + returned shifts still apply to the WHOLE frame; a translation is a + translation regardless of which window you measured it in. + + This is not merely a speed switch, it is often the more CORRECT answer. + Whole-frame correlation averages over everything that moved, so on an + in-situ movie where the sample is genuinely evolving — particles growing, + drifting, appearing — the sample's own motion contaminates the estimate of + the stage's. Restricting to a static, feature-rich landmark (a support + film edge, a fiducial, a stationary grain) measures the stage and nothing + else. It is also how a user can rescue a dataset where the field of view + is mostly featureless. + + **The ROI is FIXED in frame coordinates**, so the landmark drifts within + it. That is fine while the drift is small compared with the box, and it is + why the box wants to be comfortably larger than the total excursion — + the caret's preview exists so this is judged by eye rather than guessed. + A box smaller than the drift will lose the landmark and the solve will + wander. apodize Edge taper before transforming. ``True`` uses a Tukey window with ``alpha=DEFAULT_TAPER_ALPHA``; a float sets alpha explicitly @@ -534,7 +585,9 @@ def solve_translation( if upsample < 1: raise ValueError(f"upsample must be >= 1; got {upsample}") - n_frames, get_frame, (h, w) = frame_source(data) + n_frames, get_frame, (full_h, full_w) = frame_source(data) + crop = _validate_roi(roi, full_h, full_w) + h, w = (full_h, full_w) if crop is None else (crop[2], crop[3]) ops = _resolve_ops(device) shifts = np.full((n_frames, 2), np.nan, dtype=np.float32) @@ -554,7 +607,11 @@ def solve_translation( mask = _shift_mask(ops, h, w, max_shift, min_shift) def frame_fft(i: int): - f = ops.to_backend(get_frame(i)) + raw = get_frame(i) + if crop is not None: + y0, x0, ch, cw = crop + raw = raw[y0:y0 + ch, x0:x0 + cw] + f = ops.to_backend(raw) if window is not None: f = f * window return ops.fft2(f) @@ -629,7 +686,8 @@ def frame_fft(i: int): "rejected_from_reference": int(rejected), "backend": ops.name, "n_frames": int(n_frames), - "frame_shape": [int(h), int(w)], + "frame_shape": [int(full_h), int(full_w)], + "roi": None if crop is None else [int(v) for v in crop], } return DriftModel( shifts=shifts, diff --git a/spyde/tests/migrated/test_drift_translation.py b/spyde/tests/migrated/test_drift_translation.py index cbd465ca..0553c59f 100644 --- a/spyde/tests/migrated/test_drift_translation.py +++ b/spyde/tests/migrated/test_drift_translation.py @@ -306,6 +306,89 @@ def cancel(): "a cancelled solve must be detectable, not quietly truncated") +class TestAlignmentROI: + """Correlating on a sub-region. Not a speed switch — often the RIGHT answer. + + Whole-frame correlation averages over everything that moved, so on a movie + where the sample itself evolves, the sample's motion contaminates the estimate + of the stage's. Restricting to a static landmark measures the stage alone. + """ + + def test_roi_recovers_the_same_shift_as_the_full_frame(self): + truth = np.array([[0, 0], [2.5, -1.75], [-3.25, 4.0]], dtype=float) + stack = _shifted_stack(truth, h=96, w=112) + full = solve_translation(stack, device="numpy", upsample=8, + reference="first") + roi = solve_translation(stack, device="numpy", upsample=8, + reference="first", roi=(20, 20, 56, 64)) + assert np.abs(roi.shifts - truth).max() < 0.3, roi.shifts + assert np.abs(roi.shifts - full.shifts).max() < 0.3, ( + "the ROI solve disagrees with the full-frame solve on the same data") + + def test_shifts_apply_to_the_whole_frame(self): + """A translation is a translation; the ROI only chooses where to measure.""" + truth = np.array([[0, 0], [3.0, -2.0]], dtype=float) + stack = _shifted_stack(truth, h=96, w=112) + model = solve_translation(stack, device="numpy", upsample=8, + reference="first", roi=(30, 30, 40, 48)) + from spyde.drift import shift_frame + aligned = shift_frame(stack[1], model.shifts[1], fill=0.0) + core = (slice(40, -40), slice(40, -40)) # far OUTSIDE the ROI + resid = np.abs(aligned[core] - stack[0][core]).max() + raw = np.abs(stack[1][core] - stack[0][core]).max() + assert resid < 0.25 * raw, ( + "correcting with an ROI-derived shift did not align the region " + "outside the ROI") + + def test_roi_is_recorded_in_params(self): + stack = _shifted_stack(np.zeros((2, 2)), h=64, w=64) + m = solve_translation(stack, device="numpy", roi=(8, 8, 32, 32)) + assert m.params["roi"] == [8, 8, 32, 32] + assert m.params["frame_shape"] == [64, 64], ( + "frame_shape must stay the FULL frame — the shifts apply to it") + + def test_out_of_bounds_roi_raises_rather_than_clamping(self): + """A silently shrunk ROI would correlate on a region the user never chose.""" + stack = _shifted_stack(np.zeros((2, 2)), h=64, w=64) + for bad in [(0, 0, 80, 32), (40, 40, 32, 32), (-4, 0, 32, 32)]: + with pytest.raises(ValueError, match="outside"): + solve_translation(stack, device="numpy", roi=bad) + + def test_tiny_roi_raises(self): + stack = _shifted_stack(np.zeros((2, 2)), h=64, w=64) + with pytest.raises(ValueError, match="at least"): + solve_translation(stack, device="numpy", roi=(0, 0, 8, 8)) + + def test_malformed_roi_raises(self): + stack = _shifted_stack(np.zeros((2, 2)), h=64, w=64) + with pytest.raises(ValueError, match=r"\(y0, x0, h, w\)"): + solve_translation(stack, device="numpy", roi=(1, 2, 3)) + + def test_roi_ignores_motion_outside_it(self): + """The point of the feature, on data built to punish whole-frame.""" + rng = np.random.default_rng(3) + h, w, n = 96, 128, 5 + base = _scene(h, w, noise=0.0) + frames = [] + for t in range(n): + f = base.copy() + # A big bright square that moves the OTHER way, far from the ROI. + y, x = 60, 78 + 5 * t + f[y:y + 26, x:x + 26] += 3.0 + frames.append(f.astype(np.float32)) + stack = np.stack(frames) + roi = solve_translation(stack, device="numpy", upsample=8, + reference="first", roi=(4, 4, 44, 56)) + full = solve_translation(stack, device="numpy", upsample=8, + reference="first") + # The landmark region is STATIC, so the ROI answer should be ~zero. + assert np.abs(roi.shifts).max() < 0.6, ( + f"ROI solve drifted although its region never moved: {roi.shifts}") + assert np.abs(full.shifts).max() > np.abs(roi.shifts).max(), ( + "the whole-frame solve was not contaminated by the moving square, so " + "this fixture no longer demonstrates why the ROI exists") + + class TestBackendParity: """The GPU path has no independent reference, so it is pinned to numpy.""" From 4d988fef02f521bb0f74176ffaa647159657eeba Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 22:07:39 -0500 Subject: [PATCH 13/38] docs(plan): the caret shows ONE control; everything else is Advanced Added after reviewing the first carets: 'way too complicated. Too many options. Information overload.' A fair verdict on a Segment caret carrying ~15 visible controls, and a drift from section 0.9's own instruction -- 'expose one sensitivity control, not independent knobs' -- that happened one reasonable-looking addition at a time. The rule now applies to every action in this feature: the default face carries the TASK, not the algorithm; everything else sits behind a collapsed Advanced, including parameters that matter but that nobody should normally touch; a warning belongs beside the control it is about rather than on the front; and buttons are named for the job ('Find in all frames', not 'Run All'). Nothing is deleted -- the Python API and the provenance keep it all. Drift is the same rule applied harder, and gets a better shape than it had: its parameters have one right answer we already know, so the caret becomes a button, a progress bar and 2-3 toggles -- one being 'use ROI for alignment'. The dx/dy curve stops being caret furniture and becomes its own plot window filled as the solve runs. And discovery precedes commitment: a draggable ROI with a live drift-corrected sum over ~20 frames, so a user SEES whether alignment works on a subset before paying for the whole movie. --- DRIFT_AND_PARTICLES_PLAN.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/DRIFT_AND_PARTICLES_PLAN.md b/DRIFT_AND_PARTICLES_PLAN.md index 80241f31..6496adab 100644 --- a/DRIFT_AND_PARTICLES_PLAN.md +++ b/DRIFT_AND_PARTICLES_PLAN.md @@ -231,6 +231,37 @@ Locked, and it applies to both wizards: so closing the tree or hitting stop kills in-flight compute. 4. **Target: minutes, not hours.** ~20–100 frames/s for segment + measure. +### 0.9a The caret shows ONE control. Everything else is Advanced. + +Added after the first carets were reviewed: *"way too complicated. Too many +options. Information overload."* That was a fair verdict on a Segment caret with +~15 visible controls, and it is a drift from §0.9's own instruction ("expose one +sensitivity control, not independent knobs") that happened one reasonable-looking +addition at a time. + +The rule, for every action in this feature: + +- **The default face carries the task, not the algorithm.** One control that + changes the answer, the answer itself, and the button that commits it. +- **Everything else lives behind a collapsed `Advanced`**, including parameters + that are genuinely important (min-size) but that a user should not normally + touch. Nothing is deleted — the Python API and the provenance keep everything. +- **A warning belongs next to the control it is about**, inside Advanced, not on + the primary face. The min_size floor notice was a large orange block on the + front of the caret for a parameter most users will never open. +- **Buttons are named for the job** — "Find in all frames", not "Run All". + +The measured couplings from §0.9 still hold; they just are not the *front* of the +caret. Sensitivity and min-size stay adjacent **inside Advanced**. + +**Drift is the same rule applied harder.** Its parameters (reference mode, +upsample, max-shift) have one right answer we already know, so the caret is a +button, a progress bar, and 2–3 toggles — one of which is *"use ROI for +alignment"*. The dx/dy curve is not caret furniture: it is **its own plot window**, +filled progressively as the solve runs. And discovery comes before commitment — a +draggable ROI with a live drift-corrected sum over ~20 frames, so the user *sees* +whether alignment works on a subset before paying for the whole movie. + ### 0.9 Detection sensitivity is the priority, not instance splitting Given hundreds of frequently-touching particles, the instinct is to pour effort From 278824d04b92b59628109251f1658d4b7a8e8811 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 22:09:44 -0500 Subject: [PATCH 14/38] feat(particles): live overlay, editing, and the three navigator lanes Plan B9 + C2/C3. Track-coloured 25% fills with 1 px outlines on the label movie, labels on SELECTION only, trails as a fading line plus a head dot, click / track / rubber-band selection, and delete / merge / split recorded on the tree so a re-run cannot silently discard a correction. The two decisions that came out of looking at renders are implemented and pinned. A dead track draws NO head dot -- the dot means "the particle is here now", so on a dead track it reads as a real particle the segmenter stopped filling. And the count lane is a genuine steps-post staircase built into the DATA, because anyplotlib's plot has no drawstyle: verified that a change at frame 2 draws its transition AT frame 2, not at 1. FOUR BUGS FOUND BY DRIVING THE APP, none visible headless: 1. A `parameters:` block on a toolbar TOGGLE makes the renderer show a form with its own Run button -- so the overlay never drew and clicking only opened a panel. The knobs belong in the caret schema, not on the toggle. 2. FloatingToolbar sends `set_action_active(false)` but never a second `toolbar_action` for an action it believes is live, so a self-toggling function can be switched ON and never OFF. Returning a handle with `active_children` + `close()` hands teardown to `_track_action_artifacts`, which is the framework's own answer. 3. anyplotlib's Axes has no set_title/set_ylim (the existing `_stack_navigators` silently loses its titles the same way), so the event lane autoscaled to an invisible baseline and drew nothing. 4. The selection readout was drawn ACROSS the particle it described. Stacked-navigator machinery is reused wholesale except the figure builder, which draws every lane as a plain line -- count must be a staircase and events are markers, not a trace. Known limits, all documented rather than worked around: the lanes button cannot FORCE the stack on screen (WindowContent only stacks when its own chip selection has >=2 entries and no backend message can set that), so it registers the lanes as named navigators and the user shift-clicks; `tree.particle_edits` is recorded but nothing consumes it yet -- `pending_edits(tree)` is the seam for seg_run; and the birth/death badge on the frame (plan C2's third surface) is carried but undrawn. 88 tests. Full migrated suite 2961 passed. --- spyde/actions/navigator_views.py | 6 + spyde/actions/particle_overlay.py | 1766 +++++++++++++++++ spyde/actions/registry.py | 11 + spyde/tests/migrated/test_particle_overlay.py | 1108 +++++++++++ spyde/toolbars.yaml | 24 + 5 files changed, 2915 insertions(+) create mode 100644 spyde/actions/particle_overlay.py create mode 100644 spyde/tests/migrated/test_particle_overlay.py diff --git a/spyde/actions/navigator_views.py b/spyde/actions/navigator_views.py index 3231d7e4..4391181f 100644 --- a/spyde/actions/navigator_views.py +++ b/spyde/actions/navigator_views.py @@ -98,6 +98,12 @@ def select_navigator(session, plot, payload) -> None: _switch_navigator(tree, plot, names[0]) emit_navigator_options(tree) elif _tree_nav_is_1d(tree): + # A PARTICLE tree's lanes render themselves — the count lane is integer + # data and must be a step, and the event lane is coloured markers, not a + # trace. Delegate before falling back to the generic plain-line stacker. + from spyde.actions.particle_overlay import maybe_stack_particle_lanes + if maybe_stack_particle_lanes(session, plot, tree, names): + return # In-situ movie / time series: stack the 1-D traces with a shared, # linked time cursor (see module docstring). _stack_navigators(session, plot, tree, names) diff --git a/spyde/actions/particle_overlay.py b/spyde/actions/particle_overlay.py new file mode 100644 index 00000000..49e82ff1 --- /dev/null +++ b/spyde/actions/particle_overlay.py @@ -0,0 +1,1766 @@ +""" +particle_overlay.py — the live particle overlay + the stacked navigator lanes. + +Plan B9 (overlay and editing), C2 (events on the navigator) and C3 (trails, +integer lanes are step plots). Modelled on :mod:`spyde.actions.vector_overlay`: +marker groups on the signal plot, re-pushed from an ``index_hook`` on the tree's +navigation selectors, torn down through ``lifecycle.replace_tree_attr``. + +Two coordinate systems, and the whole overlay hangs on telling them apart +-------------------------------------------------------------------------- +anyplotlib 2-D markers drawn with ``transform="data"`` are addressed in +**image-pixel** coordinates — no axis scale or offset (``masks._signal_k_grids`` +documents the bug class; building geometry in physical units gives an empty or +misplaced overlay on any calibrated axis). But the two things this module draws +live in *different* spaces already: + +* **contours** are stored by ``measure_frame`` as int16 **pixel** ``(y, x)`` — so + they only need the axis swap to ``(x, y)``, never a scale; +* **centroids** (``COL["y"]``/``COL["x"]``) are **calibrated**, ``pixel * scale`` + — so they must be divided by ``particles.scale`` to reach widget space. + +Dividing a contour or forgetting to divide a centroid both produce an overlay +that is plausible at ``scale == 1`` and silently wrong everywhere else, which is +exactly how the ``_signal_k_grids`` bug survived. The synthetic fixture is +``scale=0.5`` on purpose, and ``test_particle_overlay.py`` pins the conversion +there. + +A click arrives in the THIRD space: anyplotlib's ``Event`` carries only +``xdata``/``ydata`` (the JS emits ``img_x``/``img_y`` too, but anyplotlib's Event +dataclass has no such field and drops them), and those are the panel's *physical* +data coordinates. So the hit test converts the click back to pixels through the +**plot's own** signal axes rather than assuming they agree with +``particles.scale``. + +One marker group per colour — not one group with a colour array +--------------------------------------------------------------- +anyplotlib's 1-D marker path accepts ``fill_color``/``color`` as arrays parallel +to the items; its **2-D** path does not (``drawMarkers2d`` in ``figure_esm.js`` +binds one ``fillStyle``/``strokeStyle`` for the whole set, and an array there is +an invalid canvas colour that silently leaves the previous style in place). The +overlay lives on a 2-D panel, so "colour per track" means one group per colour: +six accent buckets plus one grey for untracked rows. That is also why the trail +needs a group per (colour, age) pair — a polyline cannot fade along its own +length in this wire format. + +The consequence is ~30 marker groups, and ``MarkerGroup.set`` re-serialises the +WHOLE registry on every call, so the groups are updated through +:func:`_push_groups`, which mutates them and pushes **once** per frame. +""" +from __future__ import annotations + +import logging +import time +from typing import Any, Callable, Iterable, Sequence + +import numpy as np + +from spyde.signals.particles import COL, MEASURED_COLUMNS, N_COLUMNS + +log = logging.getLogger(__name__) + + +# ── palette ────────────────────────────────────────────────────────────────── + +#: SpyDE's six accents, in order. Track ids cycle through them by ``id % 6`` so +#: the mapping is a pure function of the id: the same track keeps its colour as +#: the user scrubs, across a re-open, and between the overlay and any other +#: surface that colours by track (the kymograph, the report embed). +TRACK_COLORS: tuple[str, ...] = ( + "#89b4fa", # blue — track 0 + "#f38ba8", # red + "#a6e3a1", # green + "#f9e2af", # yellow + "#cba6f7", # mauve + "#94e2d5", # teal +) + +#: Untracked rows (``track_id < 0``: the linker has not run, or this row was +#: created by an edit and has not been re-linked). Grey, not a seventh accent — +#: "no identity yet" must not read as "identity number six". +UNTRACKED_COLOR = "#6c7086" + +#: The selected particle's outline. Deliberately outside :data:`TRACK_COLORS` so +#: selection is never confusable with a track colour. +SELECTED_COLOR = "#ffffff" + +#: Fill opacity. 25% keeps the underlying image readable at the plan's target +#: density (hundreds of particles per frame) — the outline carries the shape. +FILL_ALPHA = 0.25 +OUTLINE_WIDTH = 1.0 +SELECTED_WIDTH = 2.0 + +#: Trailing window, in frames. ~8 is enough to read a direction without the +#: trails of a dense field crossing into an unreadable mesh. +DEFAULT_TRAIL_FRAMES = 8 + +#: How many opacity steps the trail fades through. A polyline carries ONE alpha +#: in this wire format (see the module docstring), so each step is a separate +#: marker group per colour: 6 x 4 = 24 groups. Four steps is where the ramp stops +#: looking banded on an 8-frame window; eight would double the group count for a +#: difference that is invisible at 1 px line width. +TRAIL_FADE_STEPS = 4 + +#: Head-dot radius in image pixels. +HEAD_RADIUS_PX = 2.5 + +#: Event lane colours, plan C2. Order is ``track.EVENT_KINDS``. +EVENT_COLORS: dict[str, str] = { + "birth": "#a6e3a1", # green + "death": "#f38ba8", # red + "merge": "#cba6f7", # mauve + "split": "#f9e2af", # yellow +} + +#: Properties shown in the selected particle's readout. Three, not twelve: the +#: readout sits ON the frame beside the particle, so it competes with the data. +READOUT_COLUMNS: tuple[str, ...] = ("area", "equiv_diameter", "circularity") + +#: The caret's parameter schema — the single host-agnostic source of truth +#: (README §4.2), resolved by ``registry.wizard_parameters("part")``. Same dict +#: spec as a ``toolbars.yaml`` ``parameters:`` block. +PARAMETERS: dict[str, dict] = { + "show_trails": { + "name": "Trails", "type": "bool", "default": False, + "description": "Fade the last N frames of each track behind a head dot.", + }, + "trail_frames": { + "name": "Trail length", "type": "int", "default": DEFAULT_TRAIL_FRAMES, + "min": 2, "max": 60, "step": 1, + "description": "Trailing window, in frames.", + }, + "region_select": { + "name": "Region select", "type": "bool", "default": False, + "description": "Rubber-band box for selecting many particles at once.", + }, +} + + +def track_color(track_id) -> str: + """Colour for a track id. + + Parameters + ---------- + track_id + The ``track_id`` column value. Negative (or NaN) means "not linked" and + maps to :data:`UNTRACKED_COLOR`. + + Returns + ------- + str + A ``#rrggbb`` hex string from :data:`TRACK_COLORS`, cycling every six. + """ + try: + tid = int(track_id) + except (TypeError, ValueError): + return UNTRACKED_COLOR + if tid < 0: + return UNTRACKED_COLOR + return TRACK_COLORS[tid % len(TRACK_COLORS)] + + +def fade(color: str, alpha: float) -> str: + """``#rrggbb`` + an 8-bit alpha byte → ``#rrggbbaa``. + + Canvas accepts 8-digit hex, which is how a trail segment carries its own + opacity even though the group's ``linewidth``/``color`` are scalars. + """ + a = int(round(float(np.clip(alpha, 0.0, 1.0)) * 255)) + return f"{color}{a:02x}" + + +def trail_alphas(steps: int = TRAIL_FADE_STEPS) -> list[float]: + """Opacity per age step, newest first. Linear from 1.0 down to 0.2. + + Not down to 0: the oldest step of a trail still has to be visible, or the + window reads as shorter than it is. + """ + n = max(1, int(steps)) + if n == 1: + return [1.0] + return [1.0 - 0.8 * i / (n - 1) for i in range(n)] + + +# ── geometry ───────────────────────────────────────────────────────────────── + +def frame_from_indices(indices, nav_to_frame=None) -> int: + """Particle-frame index from a navigation selector's committed indices. + + A particle tree's navigation space is 1-D (time), so the first raveled + coordinate IS the frame — the same read ``navigator_views._StackedNavCursor`` + makes. *nav_to_frame*, when given, maps a source navigation index onto a + particle frame (the inverse of ``tree.nav_map``), for an overlay drawn on the + SOURCE movie rather than on the particle tree's own label movie. + """ + try: + idx = int(np.asarray(indices).ravel()[0]) + except (TypeError, ValueError, IndexError): + return 0 + if nav_to_frame is not None: + return int(nav_to_frame.get(idx, idx)) + return idx + + +def centroids_px(rows: np.ndarray, scale: float) -> np.ndarray: + """``(n, 2)`` float32 ``(x, y)`` marker offsets from property rows. + + Centroids are stored calibrated (``pixel * scale``), markers want image + pixels — see the module docstring. Column order flips too: the row carries + ``(y, x)``, a marker offset is ``(x, y)``. + """ + rows = np.asarray(rows) + if rows.size == 0: + return np.zeros((0, 2), np.float32) + s = float(scale) or 1.0 + return np.column_stack([rows[:, COL["x"]] / s, + rows[:, COL["y"]] / s]).astype(np.float32) + + +def contour_xy(particles, index: int) -> np.ndarray: + """One particle's outline as ``(k, 2)`` float32 ``(x, y)`` image pixels. + + Contours are already stored in pixels, so this is an axis swap and a dtype + change — deliberately NOT a division by ``scale``. + """ + c = particles.contour_at(int(index)) + if len(c) < 3: + return np.zeros((0, 2), np.float32) + return np.column_stack([c[:, 1], c[:, 0]]).astype(np.float32) + + +def _axis_scale_offset(plot) -> tuple[float, float, float, float]: + """``(x_scale, x_offset, y_scale, y_offset)`` of the plot's displayed signal. + + Used to convert a click's ``xdata``/``ydata`` (physical) back to the image + pixels every marker lives in. Falls back to an identity mapping when the plot + has no calibrated axes, which is also what anyplotlib does — with no + ``x_axis`` array on the panel it reports ``xdata == img_x``. + """ + try: + state = getattr(plot, "plot_state", None) + sig = getattr(state, "current_signal", None) + ax = sig.axes_manager.signal_axes + return (float(ax[0].scale) or 1.0, float(ax[0].offset), + float(ax[1].scale) or 1.0, float(ax[1].offset)) + except Exception: + return 1.0, 0.0, 1.0, 0.0 + + +def _navigator_selectors_for(tree, plot) -> list: + """Navigation selectors that drive *plot*. + + Same resolution (and the same composite-selector dedup) as + ``vector_overlay._navigator_selectors_for``: a composite navigator selector + exposes both its crosshair and its region sub-selector, and registering the + hook on both fires two redraws per navigator move. + """ + npm = getattr(tree, "navigator_plot_manager", None) + if npm is None: + return [] + out = [sel for sel in npm.all_navigation_selectors + if plot in getattr(sel, "active_children", [])] + out = out or list(npm.all_navigation_selectors) + seen, uniq = set(), [] + for sel in out: + key = id(getattr(sel, "parent", sel) or sel) + if key in seen: + continue + seen.add(key) + uniq.append(sel) + return uniq + + +# ── marker pushes ──────────────────────────────────────────────────────────── + +def _push_groups(plot2d, updates: dict) -> None: + """Apply many marker-group updates with ONE panel push. + + ``MarkerGroup.set`` re-serialises the whole marker registry and pushes the + panel state on every call. A single navigator move touches the seven fill + groups, the seven head-dot groups and up to twenty-four trail groups, so + doing it through ``set`` would be ~30 full serialisations (and, in the + Electron host, ~30 PLOTAPP lines) per frame. Mutating the group dicts and + pushing once is the identical result for a thirtieth of the transport. + + Falls back to per-group ``set`` if ``_push_markers`` ever goes away. + """ + if not updates: + return + pusher = getattr(plot2d, "_push_markers", None) + if pusher is None: + for group, kwargs in updates.items(): + try: + group.set(**kwargs) + except Exception as exc: + log.debug("[particles] marker set failed: %s", exc) + return + for group, kwargs in updates.items(): + try: + group._data.update(kwargs) + except Exception as exc: + log.debug("[particles] marker update failed: %s", exc) + try: + pusher() + except Exception as exc: + log.debug("[particles] marker push failed: %s", exc) + + +# ── the overlay ────────────────────────────────────────────────────────────── + +class ParticleOverlay: + """Filled, track-coloured particle outlines on a signal plot, live. + + Draws frame *t*'s particles as 25%-filled polygons with a 1 px outline, + coloured by ``track_id % 6``; the selected particle gets a white outline, its + id and a short property readout. Optional trails fade the last *N* frames of + each track behind a head dot marking "here, now". + + Parameters + ---------- + plot + The :class:`~spyde.drawing.plots.plot.Plot` to draw on (the label movie's + signal plot, or the source movie's). + particles + The :class:`~spyde.signals.particles.SpyDEParticles` store. Held by + reference and MUTATED IN PLACE by the edit methods, because the lazy + label movie closes over this exact object (see + ``particle_tree.open_particle_tree``). + events + Optional :class:`~spyde.particles.track.ParticleEvent` list. Carried so a + consumer reading the overlay has them to hand; the navigator lanes read + ``tree.particle_events`` directly. Nothing here DRAWS them yet — plan C2's + third surface (a birth/death badge flashed on the frame during playback) + is not implemented. + nav_map + The tree's ``nav_map`` (particle frame → source navigation index). Given + only when the overlay is drawn on the SOURCE movie, where the navigator + indexes the source grid; it is inverted internally. + frame_provider + ``fn(t) -> (h, w) ndarray`` returning the intensity image for frame *t*. + Used to re-measure after a merge or split. ``None`` leaves the intensity + columns NaN on edited rows rather than inventing them. + trail_frames, show_trails + Trailing window length and whether trails start on. + on_select, on_edit + Callbacks fired after a selection change / an edit, so a caret or table + dock can follow. Both are called with no arguments. + """ + + def __init__(self, plot, particles, *, events: Sequence = (), + nav_map=None, frame_provider: Callable[[int], np.ndarray] | None = None, + trail_frames: int = DEFAULT_TRAIL_FRAMES, show_trails: bool = False, + on_select: Callable[[], None] | None = None, + on_edit: Callable[[], None] | None = None, + name: str = "particles"): + self.plot = plot + self.particles = particles + self.events = list(events or ()) + self.name = str(name) + self.frame_provider = frame_provider + self.trail_frames = max(1, int(trail_frames)) + self.show_trails = bool(show_trails) + self.on_select = on_select + self.on_edit = on_edit + + self.tree = None + self.selected: list[int] = [] + self.hovered: int | None = None + self._frame = 0 + self._hidden = False + self._groups: dict[str, Any] = {} + self._selectors: list = [] + self._handlers: list = [] + self._region_widget = None + # Latest-wins: a navigator move computes off the main thread and marshals + # the push; teardown bumps FIRST so a superseded payload never lands. + self._gen = 0 + # nav index → particle frame, only when the two grids differ. + self._nav_to_frame = None + if nav_map is not None: + nav = np.asarray(nav_map, np.int64).ravel() + if not np.array_equal(nav, np.arange(nav.size)): + self._nav_to_frame = {int(v): i for i, v in enumerate(nav)} + self._prev_settled_ms = None + # The store's identity at the last redraw. An edit rebuilds the buffers, + # so any cached global index the caret is holding is stale; the counter + # is what a consumer compares against. + self.revision = 0 + + # ── attach / detach ────────────────────────────────────────────────────── + + def attach(self, tree) -> "ParticleOverlay": + """Create the marker groups, wire the navigator and the click handlers.""" + self.tree = tree + plot2d = getattr(self.plot, "_plot2d", None) + if plot2d is None: + # Cosmetic, so not fatal — but say so, rather than leaving the user + # with a silently missing overlay (vector_overlay's rule). + log.warning("[particles] overlay skipped: plot has no live 2-D plot " + "(figure iframe not loaded?)") + return self + self._build_groups(plot2d) + self._wire_events(plot2d) + self._wire_navigator(tree) + self._redraw() + return self + + def _build_groups(self, plot2d) -> None: + """One group per colour (see the module docstring), created empty. + + CREATION ORDER IS DRAW ORDER, and it is by marker TYPE, not by group: + ``MarkerRegistry.to_wire_list`` walks its type dicts in the order they + were first touched and flattens each one's groups. So the first + ``add_lines`` puts every trail underneath every polygon, and the first + ``add_circles`` puts every head dot on top of both — which is the z-order + this wants (a head dot hidden under a fill is not a head dot). Within the + polygons type the selected outline is added after the fills, so it draws + over them. + """ + empty_poly: list = [] + empty_off = np.zeros((0, 2), np.float32) + empty_seg = np.zeros((0, 2, 2), np.float32) + colors = list(TRACK_COLORS) + [UNTRACKED_COLOR] + + for i, color in enumerate(colors): # bottom: trails + for step, alpha in enumerate(trail_alphas(TRAIL_FADE_STEPS)): + self._groups[f"trail{i}_{step}"] = plot2d.add_lines( + empty_seg, name=f"{self.name}_trail_{i}_{step}", + edgecolors=fade(color, alpha), linewidths=1.5, + transform="data") + for i, color in enumerate(colors): # then the fills + self._groups[f"fill{i}"] = plot2d.add_polygons( + empty_poly, name=f"{self.name}_fill_{i}", facecolors=color, + edgecolors=color, linewidths=OUTLINE_WIDTH, alpha=FILL_ALPHA, + transform="data") + self._groups["selected"] = plot2d.add_polygons( + empty_poly, name=f"{self.name}_selected", facecolors=None, + edgecolors=SELECTED_COLOR, linewidths=SELECTED_WIDTH, + transform="data") + for i, color in enumerate(colors): # then head dots + self._groups[f"head{i}"] = plot2d.add_circles( + empty_off, name=f"{self.name}_head_{i}", radius=HEAD_RADIUS_PX, + facecolors=color, edgecolors=color, linewidths=1.0, alpha=1.0, + transform="data") + self._groups["labels"] = plot2d.add_texts( # top: labels + empty_off, [], name=f"{self.name}_labels", color=SELECTED_COLOR, + fontsize=11, transform="data") + + def _wire_events(self, plot2d) -> None: + """Click-to-select and hover-to-label. + + ``double_click`` for the discrete pick, matching the strain reference + picker: a single click on a 2-D panel is ambiguous with panning, and + anyplotlib's own pan/click disambiguation has moved between versions. + + Hover runs off ``pointer_settled``, not ``pointer_move``: a label that + needed an IPC round trip per mouse move would put hundreds of messages a + second on the same stdout line protocol the nav painter uses. Settling is + also the correct semantics — the label answers "what am I pointing at", + which is only a question once the pointer has stopped. + """ + from spyde.drawing.selectors.base_selector import event_handler_fn + for event_type, method in (("double_click", self._on_click), + ("pointer_settled", self._on_settled)): + handler = event_handler_fn(method) + self._handlers.append(handler) + try: + plot2d.add_event_handler(handler, event_type) + except Exception as exc: + log.debug("[particles] wiring %s failed: %s", event_type, exc) + try: + self._prev_settled_ms = plot2d._state.get("pointer_settled_ms") + plot2d.configure_pointer_settled(180) + except Exception as exc: + log.debug("[particles] enabling pointer_settled failed: %s", exc) + + def _wire_navigator(self, tree) -> None: + self._selectors = _navigator_selectors_for(tree, self.plot) + for sel in self._selectors: + if self._on_indices not in sel.index_hooks: + sel.index_hooks.append(self._on_indices) + if getattr(sel, "current_indices", None) is not None: + self._frame = frame_from_indices(sel.current_indices, + self._nav_to_frame) + if not self._selectors: + log.warning("[particles] attached with NO navigator selectors — the " + "overlay will not follow the frame") + + def remove(self) -> None: + """Detach every hook, drop every marker group. Idempotent.""" + self._gen += 1 # teardown bumps FIRST (README §6) + for sel in self._selectors: + if self._on_indices in sel.index_hooks: + sel.index_hooks.remove(self._on_indices) + self._selectors = [] + self.set_region_select(False) + plot2d = getattr(self.plot, "_plot2d", None) + if plot2d is not None and self._prev_settled_ms is not None: + try: + plot2d.configure_pointer_settled(int(self._prev_settled_ms)) + except Exception as exc: + log.debug("[particles] restoring pointer_settled failed: %s", exc) + for group in self._groups.values(): + try: + group.remove() + except Exception as exc: + log.debug("[particles] removing marker group failed: %s", exc) + self._groups = {} + # anyplotlib has no remove_event_handler; dropping our references lets + # the wrappers (weakly registered) be collected, and an empty _groups + # makes every handler a no-op in the meantime. + self._handlers = [] + + # ── navigator ──────────────────────────────────────────────────────────── + + def _on_indices(self, indices) -> None: + """Navigation moved. Runs on the ``_NavDispatcher`` thread.""" + frame = frame_from_indices(indices, self._nav_to_frame) + if frame == self._frame: + return + self._frame = frame + self._request_redraw() + + def _request_redraw(self) -> None: + """Compute the payload here, push it on the asyncio main thread. + + The payload is pure numpy over one frame (plus the trail window), so it + is cheap enough to build on whichever thread asked. The PUSH is a figure + update and must be marshalled — CLAUDE.md's threading contract. + """ + if self._hidden or not self._groups: + return + gen = self._gen = self._gen + 1 + payload = self._payload(self._frame) + session = getattr(self.plot, "session", None) + dispatch = getattr(session, "_dispatch_to_main", None) + if dispatch is None: + self._apply(payload, gen) + return + dispatch(lambda: self._apply(payload, gen)) + + def _apply(self, payload: dict, gen: int) -> None: + if gen != self._gen or not self._groups: + return # superseded, or torn down + plot2d = getattr(self.plot, "_plot2d", None) + if plot2d is None: + return + _push_groups(plot2d, {self._groups[key]: kwargs + for key, kwargs in payload.items() + if key in self._groups}) + + def _redraw(self) -> None: + """Rebuild and push immediately (an edit / a selection, not a nav move).""" + if self._hidden or not self._groups: + return + gen = self._gen = self._gen + 1 + self._apply(self._payload(self._frame), gen) + + # ── the payload ────────────────────────────────────────────────────────── + + @staticmethod + def _empty_payload() -> dict: + """Every group key mapped to the contents that draw nothing.""" + out: dict[str, dict] = {} + for i in range(len(TRACK_COLORS) + 1): + out[f"fill{i}"] = {"vertices_list": []} + out[f"head{i}"] = {"offsets": np.zeros((0, 2), np.float32)} + for step in range(TRAIL_FADE_STEPS): + out[f"trail{i}_{step}"] = {"segments": np.zeros((0, 2, 2), np.float32)} + out["selected"] = {"vertices_list": []} + out["labels"] = {"offsets": np.zeros((0, 2), np.float32), "texts": []} + return out + + def _payload(self, t: int) -> dict: + """Every marker group's contents for frame *t*. Pure — no plot access. + + Returned as ``{group_key: set_kwargs}`` so the whole overlay is testable + without a live figure, which is the only way to assert the head-dot rule + and the pixel conversion in a headless suite. + """ + n_colors = len(TRACK_COLORS) + 1 + n_steps = TRAIL_FADE_STEPS + out: dict[str, dict] = {} + fills: list[list] = [[] for _ in range(n_colors)] + heads: list[list] = [[] for _ in range(n_colors)] + trails: list[list[list]] = [[[] for _ in range(n_steps)] + for _ in range(n_colors)] + + if 0 <= int(t) < self.particles.n_frames and self.particles.has_masks: + for gi in self.particles.indices_at(int(t)): + poly = contour_xy(self.particles, int(gi)) + if len(poly) < 3: + continue + fills[self._bucket(gi)].append(poly) + + if self.show_trails: + self._fill_trails(int(t), trails, heads) + + for i in range(n_colors): + out[f"fill{i}"] = {"vertices_list": fills[i]} + out[f"head{i}"] = {"offsets": (np.asarray(heads[i], np.float32) + if heads[i] + else np.zeros((0, 2), np.float32))} + for step in range(n_steps): + segs = trails[i][step] + out[f"trail{i}_{step}"] = { + "segments": (np.asarray(segs, np.float32) if segs + else np.zeros((0, 2, 2), np.float32))} + + out["selected"] = {"vertices_list": self._selected_polys(int(t))} + offsets, texts = self._labels(int(t)) + out["labels"] = {"offsets": offsets, "texts": texts} + return out + + def _bucket(self, gi: int) -> int: + """Colour-group index for a global particle row (last = untracked).""" + tid = int(self.particles.flat_buffer[int(gi), COL["track_id"]]) + if tid < 0: + return len(TRACK_COLORS) + return tid % len(TRACK_COLORS) + + def _fill_trails(self, t: int, trails, heads) -> None: + """Trail segments + head dots for the window ending at frame *t*. + + **A dead track draws no head dot.** The dot means "the particle is HERE + NOW", so it is drawn only for a track with a detection at exactly *t* — + which covers both a track that died before *t* and one inside its + ``memory`` gap, without either needing to be special-cased or the + ``LinkResult`` needing to be around. Found by looking at a render: a + track that died at frame 16 was still painting a head dot at 18 because + its trajectory still intersected the trailing window, and it read as a + real particle the segmenter had stopped filling (plan C3). + + Walks the window's FRAMES rather than indexing tracks, for the reason + ``track._extract_events`` gives: a ``{track: row}`` map over a whole + movie is 1.5M dict entries at the plan's target scale, to answer a + question that only ever spans the last few frames. + """ + scale = float(self.particles.scale) or 1.0 + t0 = max(0, t - self.trail_frames + 1) + per_track: dict[int, list[tuple[int, float, float]]] = {} + for f in range(t0, min(t, self.particles.n_frames - 1) + 1): + rows = self.particles.at(f) + if len(rows) == 0: + continue + xs = rows[:, COL["x"]] / scale + ys = rows[:, COL["y"]] / scale + tids = rows[:, COL["track_id"]].astype(np.int64) + for k in range(len(rows)): + tid = int(tids[k]) + if tid < 0: + continue # an untracked row has no trajectory + per_track.setdefault(tid, []).append((f, float(xs[k]), float(ys[k]))) + + n_steps = len(trail_alphas(TRAIL_FADE_STEPS)) + for tid, pts in per_track.items(): + bucket = tid % len(TRACK_COLORS) + for (_f0, x0, y0), (f1, x1, y1) in zip(pts, pts[1:]): + # Age is measured at the segment's NEWER end, so the piece next + # to the head is the brightest one. + step = min(n_steps - 1, + (t - f1) * n_steps // max(1, self.trail_frames)) + trails[bucket][step].append([[x0, y0], [x1, y1]]) + if pts and pts[-1][0] == t: + heads[bucket].append([pts[-1][1], pts[-1][2]]) + + def _selected_polys(self, t: int) -> list: + """Outlines of the selected particles that are visible in frame *t*.""" + if not self.selected or not self.particles.has_masks: + return [] + out = [] + for gi in self.selected: + if not self._in_frame(gi, t): + continue + poly = contour_xy(self.particles, gi) + if len(poly) >= 3: + out.append(poly) + return out + + def _labels(self, t: int) -> tuple[np.ndarray, list[str]]: + """Text labels — SELECTION and HOVER only. + + Always-on ids were rejected in the plan for a measured reason: legible on + a nine-particle mock-up, a wall of numbers at 500 particles per frame. + """ + shown = list(self.selected) + if self.hovered is not None and self.hovered not in shown: + shown.append(self.hovered) + shown = [gi for gi in shown if self._in_frame(gi, t)] + if not shown: + return np.zeros((0, 2), np.float32), [] + rows = self.particles.flat_buffer[np.asarray(shown, np.int64)] + scale = float(self.particles.scale) or 1.0 + offsets = centroids_px(rows, scale) + # Anchor OUTSIDE the body, up and to the right: anyplotlib draws text + # left-aligned / top-baselined from the offset, so anchoring on the + # centroid lays the readout across the particle it describes (seen in the + # app). One body radius clears it. + radii = rows[:, COL["equiv_diameter"]] / (2.0 * scale) + radii = np.where(np.isfinite(radii), radii, 0.0) + 2.0 + offsets[:, 0] += radii + offsets[:, 1] -= radii + texts = [self.describe(gi) for gi in shown] + return offsets, texts + + def _in_frame(self, gi: int, t: int) -> bool: + if not 0 <= int(gi) < self.particles.n_particles: + return False + lo, hi = self.particles.t_offsets[int(t)], self.particles.t_offsets[int(t) + 1] + return bool(lo <= int(gi) < hi) + + def describe(self, gi: int) -> str: + """One-line id + property readout for particle *gi*.""" + row = self.particles.flat_buffer[int(gi)] + tid = int(row[COL["track_id"]]) + head = f"#{int(gi)}" if tid < 0 else f"track {tid}" + units = self.particles.units + parts = [head] + for name in READOUT_COLUMNS: + value = float(row[COL[name]]) + if not np.isfinite(value): + continue + suffix = {"area": f" {units}²", "equiv_diameter": f" {units}"}.get(name, "") + parts.append(f"{name.replace('_', ' ')} {value:.3g}{suffix}") + return " ".join(parts) + + # ── display state ──────────────────────────────────────────────────────── + + def set_visible(self, visible: bool) -> None: + """Show or hide every group. + + A hidden overlay still TRACKS the navigator (``_frame`` keeps moving), so + re-showing draws the frame the user is on rather than the one they were + on when it went away — the same contract ``vector_overlay`` keeps. + """ + self._hidden = not bool(visible) + if not self._hidden: + self._redraw() + return + gen = self._gen = self._gen + 1 + self._apply(self._empty_payload(), gen) + + def set_trails(self, enabled: bool, frames: int | None = None) -> None: + """Toggle trails and set the trailing window length.""" + self.show_trails = bool(enabled) + if frames is not None: + self.trail_frames = max(1, int(frames)) + self._redraw() + + def set_frame(self, t: int) -> None: + """Programmatically move to particle frame *t* (playback, a table jump).""" + self._frame = int(t) + self._redraw() + + # ── selection ──────────────────────────────────────────────────────────── + + def select(self, indices: Iterable[int] | int | None) -> list[int]: + """Select particles by GLOBAL index — the external hook a table row uses. + + Returns the resulting selection. Indices outside the store are dropped + rather than raising: the caret's row list can lag an edit by a frame, and + a stale row must not take the backend down with it. + """ + if indices is None: + wanted: list[int] = [] + elif isinstance(indices, (int, np.integer)): + wanted = [int(indices)] + else: + wanted = [int(i) for i in indices] + n = self.particles.n_particles + self.selected = [i for i in wanted if 0 <= i < n] + self._after_select() + return list(self.selected) + + def select_track(self, track_id: int, *, frame: int | None = None) -> list[int]: + """Select a track's detection in one frame (default: the current one).""" + t = self._frame if frame is None else int(frame) + if not 0 <= t < self.particles.n_frames: + return self.select([]) + rows = self.particles.at(t) + hit = np.nonzero(rows[:, COL["track_id"]].astype(np.int64) == int(track_id))[0] + base = int(self.particles.t_offsets[t]) + return self.select([base + int(k) for k in hit]) + + def select_region(self, x0, y0, x1, y1, *, frame: int | None = None) -> list[int]: + """Rubber-band selection: every particle in *frame* whose centroid falls + inside the image-pixel box ``(x0, y0)-(x1, y1)``.""" + t = self._frame if frame is None else int(frame) + if not 0 <= t < self.particles.n_frames: + return self.select([]) + rows = self.particles.at(t) + if len(rows) == 0: + return self.select([]) + pts = centroids_px(rows, self.particles.scale) + lo_x, hi_x = sorted((float(x0), float(x1))) + lo_y, hi_y = sorted((float(y0), float(y1))) + inside = ((pts[:, 0] >= lo_x) & (pts[:, 0] <= hi_x) + & (pts[:, 1] >= lo_y) & (pts[:, 1] <= hi_y)) + base = int(self.particles.t_offsets[t]) + return self.select([base + int(k) for k in np.nonzero(inside)[0]]) + + def pick(self, px: float, py: float, *, frame: int | None = None) -> int | None: + """Nearest-centroid hit test at image-pixel ``(px, py)``. + + The hit radius is the particle's OWN ``equiv_diameter`` (floored at + 4 px), not a constant: a fixed radius that feels right for a 50 px body + makes a 5 px one unclickable, and one sized for the small body picks a + neighbour when the field is dense. Same reasoning as the linker's + adaptive merge radius. + """ + t = self._frame if frame is None else int(frame) + if not 0 <= t < self.particles.n_frames: + return None + rows = self.particles.at(t) + if len(rows) == 0: + return None + scale = float(self.particles.scale) or 1.0 + pts = centroids_px(rows, scale) + d2 = (pts[:, 0] - float(px)) ** 2 + (pts[:, 1] - float(py)) ** 2 + k = int(np.argmin(d2)) + radius = rows[k, COL["equiv_diameter"]] / scale + radius = max(4.0, float(radius) if np.isfinite(radius) else 0.0) + if d2[k] > radius ** 2: + return None + return int(self.particles.t_offsets[t]) + k + + def clear_selection(self) -> list[int]: + return self.select([]) + + def _after_select(self) -> None: + self._redraw() + if self.on_select is not None: + try: + self.on_select() + except Exception as exc: + log.debug("[particles] on_select failed: %s", exc) + + def _on_click(self, event=None) -> None: + if event is None or not self._groups: + return + try: + px, py = self._event_px(event) + except Exception as exc: + log.debug("[particles] click had no usable coordinates: %s", exc) + return + hit = self.pick(px, py) + self.select([] if hit is None else [hit]) + + def _on_settled(self, event=None) -> None: + if event is None or not self._groups: + return + try: + px, py = self._event_px(event) + except Exception: + return + hit = self.pick(px, py) + if hit == self.hovered: + return + self.hovered = hit + self._redraw() + + def _event_px(self, event) -> tuple[float, float]: + """A pointer event's position in IMAGE PIXELS. + + anyplotlib's Python ``Event`` carries only ``xdata``/``ydata`` (the JS + payload's ``img_x``/``img_y`` have no field on the dataclass and are + dropped), and those are the panel's PHYSICAL data coordinates. Markers + are in image pixels, so the click is converted back through the plot's + own axes — not through ``particles.scale``, which is the store's + calibration and need not be the displayed signal's. + """ + xs, xo, ys, yo = _axis_scale_offset(self.plot) + return (float(event.xdata) - xo) / xs, (float(event.ydata) - yo) / ys + + # ── rubber band ────────────────────────────────────────────────────────── + + def set_region_select(self, enabled: bool) -> None: + """Show/hide the rubber-band rectangle used for bulk selection. + + A widget rather than a drag on the canvas: the panel's own drag is pan, + and anyplotlib's rectangle widget already owns the handles, the clamping + and the pointer events. + """ + plot2d = getattr(self.plot, "_plot2d", None) + if not enabled: + if self._region_widget is not None and plot2d is not None: + try: + plot2d.remove_widget(self._region_widget) + except Exception as exc: + log.debug("[particles] removing region widget failed: %s", exc) + self._region_widget = None + return + if self._region_widget is not None or plot2d is None: + return + h, w = self.particles.frame_shape + try: + widget = plot2d.add_rectangle_widget( + x=w * 0.25, y=h * 0.25, w=w * 0.5, h=h * 0.5, color=SELECTED_COLOR) + except Exception as exc: + log.debug("[particles] adding region widget failed: %s", exc) + return + from spyde.drawing.selectors.base_selector import event_handler_fn + handler = event_handler_fn(self._on_region) + self._handlers.append(handler) + try: + widget.add_event_handler(handler, "pointer_up") + except Exception as exc: + log.debug("[particles] wiring region widget failed: %s", exc) + self._region_widget = widget + self._on_region() + + def _on_region(self, _event=None) -> None: + widget = self._region_widget + if widget is None: + return + try: + x, y = float(widget.x), float(widget.y) + w, h = float(widget.w), float(widget.h) + except Exception as exc: + log.debug("[particles] reading region widget failed: %s", exc) + return + self.select_region(x, y, x + w, y + h) + + # ── editing ────────────────────────────────────────────────────────────── + + def delete(self, indices: Iterable[int] | None = None) -> int: + """Delete particles (default: the selection). Returns how many went.""" + idx = sorted({int(i) for i in (self.selected if indices is None else indices)}) + if not idx: + return 0 + removed = delete_particles(self.particles, idx) + self._record("delete", indices=idx, frame=self._frame) + self.selected = [] + self._after_edit() + return removed + + def merge(self, indices: Iterable[int] | None = None) -> int: + """Merge particles into one, re-measure, return its new global index.""" + idx = sorted({int(i) for i in (self.selected if indices is None else indices)}) + if len(idx) < 2: + raise ValueError("merge needs at least two particles") + new_index = merge_particles(self.particles, idx, + frame_image=self._frame_image_for(idx[0])) + self._record("merge", indices=idx, frame=self._frame, result=[new_index]) + self.selected = [new_index] + self._after_edit() + return new_index + + def split(self, index: int | None = None, line=None) -> tuple[int, int]: + """Split one particle along *line* and re-measure both halves. + + *line* is ``((x0, y0), (x1, y1))`` in IMAGE PIXELS — the space the drawn + line widget reports in. + """ + if index is None: + if len(self.selected) != 1: + raise ValueError("split needs exactly one selected particle") + index = self.selected[0] + if line is None: + raise ValueError("split needs a cut line") + pair = split_particle(self.particles, int(index), line, + frame_image=self._frame_image_for(int(index))) + self._record("split", indices=[int(index)], frame=self._frame, + line=[[float(v) for v in pt] for pt in line], + result=list(pair)) + self.selected = list(pair) + self._after_edit() + return pair + + def _frame_image_for(self, gi: int): + if self.frame_provider is None: + return None + t = int(self.particles.flat_buffer[int(gi), COL["t"]]) + try: + return np.asarray(self.frame_provider(t)) + except Exception as exc: + log.debug("[particles] frame_provider(%d) failed: %s", t, exc) + return None + + def _record(self, kind: str, **fields) -> dict: + return record_edit(self.tree, self.particles, kind, **fields) + + def _after_edit(self) -> None: + self.revision += 1 + self.hovered = None + self._redraw() + if self.on_edit is not None: + try: + self.on_edit() + except Exception as exc: + log.debug("[particles] on_edit failed: %s", exc) + + +def attach_particle_overlay(plot, particles, tree, **kwargs) -> ParticleOverlay: + """Attach a :class:`ParticleOverlay` to *plot*, wired to *tree*'s navigator. + + Stored on the tree as ``tree._particle_overlay`` via + ``lifecycle.replace_tree_attr``, so re-running never stacks two overlays and + ``BaseSignalTree.close()`` reaps it. + """ + from spyde.actions.lifecycle import replace_tree_attr + return replace_tree_attr( + tree, "_particle_overlay", + lambda: ParticleOverlay(plot, particles, **kwargs).attach(tree)) + + +# ── edits on the store ─────────────────────────────────────────────────────── +# +# Separate from the overlay on purpose: an edit is a transformation of the CSR +# table and nothing about it needs a figure, so it is testable (and scriptable) +# on its own. Every one of them mutates the store IN PLACE — the lazy label movie +# closes over that exact object (``particle_tree.open_particle_tree``), so +# swapping in a new SpyDEParticles would leave the open window rendering the old +# contours forever. That is the same trap ``particles_action._adopt`` exists for. + +def _row_frames(particles) -> np.ndarray: + """``(n,)`` int64 frame index per row, from the CSR pointers.""" + return np.repeat(np.arange(particles.n_frames, dtype=np.int64), + np.diff(particles.t_offsets)) + + +def _splice(particles, *, drop: Sequence[int] = (), + add: Sequence[tuple[int, np.ndarray, np.ndarray | None]] = ()) -> np.ndarray: + """Rebuild the store in place with rows removed and/or added. + + *add* is a sequence of ``(frame, row, contour)``. Added rows land at the END + of their frame's block (a stable sort by frame over the kept rows followed by + the new ones), so existing global indices shift by at most the deletions + ahead of them and never by an insertion into the middle of a frame. + + Returns the ``(len(add),)`` global indices the added rows ended up at. + + Vectorised rather than looped: the plan's target is 1.5M rows, and an edit + that walked them in Python would take seconds for a single click. The only + loop is over the handful of rows being added. + """ + n = particles.n_particles + keep = np.ones(n, bool) + if len(drop): + keep[np.asarray(list(drop), np.int64)] = False + + frames = _row_frames(particles) + kept_frames = frames[keep] + kept_rows = particles.flat_buffer[keep] + + add = list(add) + add_frames = np.asarray([int(f) for f, _r, _c in add], np.int64) + add_rows = (np.asarray([r for _f, r, _c in add], np.float32).reshape(-1, N_COLUMNS) + if add else np.zeros((0, N_COLUMNS), np.float32)) + + all_frames = np.concatenate([kept_frames, add_frames]) + all_rows = np.concatenate([kept_rows, add_rows], axis=0) + order = np.argsort(all_frames, kind="stable") + + particles.flat_buffer = np.ascontiguousarray(all_rows[order], np.float32) + counts = np.bincount(all_frames, minlength=particles.n_frames) + particles.t_offsets = np.concatenate([[0], np.cumsum(counts)]).astype(np.int64) + + if particles.contours is not None: + lens = np.diff(particles.contour_offsets) + starts = particles.contour_offsets[:-1] + add_polys = [np.zeros((0, 2), np.int16) if c is None + else np.asarray(c, np.int16).reshape(-1, 2) for _f, _r, c in add] + add_lens = np.asarray([len(p) for p in add_polys], np.int64) + all_lens = np.concatenate([lens[keep], add_lens]) + # Point sources: kept polygons index the OLD contour array; added ones + # index a small appended block. One concatenation, then one gather. + pool = np.concatenate([particles.contours] + add_polys, axis=0) \ + if add_polys else particles.contours + add_starts = len(particles.contours) + np.concatenate( + [[0], np.cumsum(add_lens)])[:-1].astype(np.int64) + all_starts = np.concatenate([starts[keep], add_starts]) + + new_lens = all_lens[order] + new_off = np.concatenate([[0], np.cumsum(new_lens)]).astype(np.int64) + within = np.arange(int(new_off[-1])) - np.repeat(new_off[:-1], new_lens) + src = np.repeat(all_starts[order], new_lens) + within + particles.contours = np.ascontiguousarray(pool[src], np.int16) + particles.contour_offsets = new_off + + # Where each added row landed: its position in `order`. + rank = np.empty(order.size, np.int64) + rank[order] = np.arange(order.size, dtype=np.int64) + return rank[len(kept_rows):] + + +def delete_particles(particles, indices: Sequence[int]) -> int: + """Drop rows from the store, in place. Returns the number removed.""" + idx = sorted({int(i) for i in indices if 0 <= int(i) < particles.n_particles}) + if not idx: + return 0 + _splice(particles, drop=idx) + return len(idx) + + +def _full_mask(particles, index: int) -> np.ndarray: + """One particle's boolean mask, at full frame size.""" + mask, (y0, x0, y1, x1) = particles.mask_at(int(index)) + h, w = particles.frame_shape + out = np.zeros((h, w), bool) + out[y0:y1, x0:x1] |= mask + return out + + +def _remeasure(labels: np.ndarray, frame_image, t: int, scale: float): + """``measure_frame`` on a purpose-built label image. Returns (rows, contours).""" + from spyde.particles import measure_frame + return measure_frame(labels, frame_image, t=int(t), scale=float(scale)) + + +def merge_particles(particles, indices: Sequence[int], *, frame_image=None) -> int: + """Union the masks of two or more particles, re-measure, return the new index. + + Every input must be in the SAME frame — merging across frames is not an + editing operation, it is a linking one, and doing it here would silently + produce a row whose ``t`` contradicts its CSR block. + + The merged row inherits the ``track_id`` of the LARGEST input, matching the + linker's merge semantics (a large body absorbs a smaller one, ``track.py``); + the absorbed track simply stops, which is what a re-link would also conclude. + """ + idx = sorted({int(i) for i in indices}) + if len(idx) < 2: + raise ValueError("merge needs at least two particles") + if not particles.has_masks: + raise ValueError("cannot merge without outlines " + "(segmentation ran with store_masks=False)") + frames = {int(particles.flat_buffer[i, COL["t"]]) for i in idx} + if len(frames) != 1: + raise ValueError(f"merge needs one frame; got {sorted(frames)}") + t = frames.pop() + + from skimage.measure import label as connected_components + + union = np.zeros(particles.frame_shape, bool) + for i in idx: + union |= _full_mask(particles, i) + # Connected-component labelling, NOT ``union.astype(int32)``: casting the + # boolean union gives every pixel label 1, and ``regionprops`` measures a + # label as ONE region whether or not it is connected — so two discs on + # opposite sides of the frame would merge "successfully" into a row whose + # centroid sits in the empty space between them. 8-connectivity, because two + # bodies meeting at a corner are touching. + components = connected_components(union, connectivity=2) + n_components = int(components.max()) + if n_components == 0: + raise ValueError("the merged region measured as empty") + if n_components > 1: + raise ValueError( + f"the selected particles do not touch — merging them would make " + f"{n_components} disconnected regions, not one") + rows, contours = _remeasure(components.astype(np.int32), frame_image, t, + particles.scale) + if len(rows) != 1: + raise ValueError(f"the merged region measured as {len(rows)} rows, not 1") + + areas = particles.flat_buffer[np.asarray(idx), COL["area"]] + survivor = idx[int(np.argmax(areas))] + rows[0, COL["track_id"]] = particles.flat_buffer[survivor, COL["track_id"]] + rows[0, COL["label"]] = particles.flat_buffer[np.asarray(idx), COL["label"]].min() + + added = _splice(particles, drop=idx, add=[(t, rows[0], contours[0])]) + return int(added[0]) + + +def split_particle(particles, index: int, line, *, frame_image=None) -> tuple[int, int]: + """Cut one particle along *line* and re-measure both halves. + + *line* is ``((x0, y0), (x1, y1))`` in IMAGE PIXELS. The cut is the infinite + line through those two points; pixels of the particle's mask fall on one side + or the other by the sign of the 2-D cross product. An infinite line rather + than a segment because a user drawing a cut across a blob naturally starts + and ends outside it, and a segment-only rule would leave the ends uncut. + + The larger half keeps the parent's ``track_id``; the smaller is left + untracked (-1), because which fragment continues the track is exactly the + question a re-link answers and guessing it here would fabricate an identity. + """ + if not particles.has_masks: + raise ValueError("cannot split without outlines " + "(segmentation ran with store_masks=False)") + gi = int(index) + (x0, y0), (x1, y1) = ((float(line[0][0]), float(line[0][1])), + (float(line[1][0]), float(line[1][1]))) + if (x1 - x0) == 0.0 and (y1 - y0) == 0.0: + raise ValueError("the cut line has zero length") + + mask = _full_mask(particles, gi) + h, w = particles.frame_shape + yy, xx = np.mgrid[0:h, 0:w] + side = (xx - x0) * (y1 - y0) - (yy - y0) * (x1 - x0) + labels = np.zeros((h, w), np.int32) + labels[mask & (side < 0)] = 1 + labels[mask & (side >= 0)] = 2 + if not labels.any() or labels.max() < 2 or not (labels == 1).any(): + raise ValueError("the cut line does not divide this particle") + + t = int(particles.flat_buffer[gi, COL["t"]]) + rows, contours = _remeasure(labels, frame_image, t, particles.scale) + if len(rows) != 2: + raise ValueError( + f"the cut produced {len(rows)} regions, not 2 — the line probably " + "clipped a corner or the halves are disconnected") + + parent_track = particles.flat_buffer[gi, COL["track_id"]] + keeper = int(np.argmax(rows[:, COL["area"]])) + rows[keeper, COL["track_id"]] = parent_track + rows[1 - keeper, COL["track_id"]] = -1.0 + + added = _splice(particles, drop=[gi], + add=[(t, rows[k], contours[k]) for k in range(2)]) + return int(added[0]), int(added[1]) + + +def record_edit(tree, particles, kind: str, **fields) -> dict: + """Record a manual correction on the tree AND in the store's provenance. + + Two places, deliberately. ``tree.particle_edits`` is the live log a re-run + reads so it does not silently discard the user's corrections; the copy in + ``particles.provenance["edits"]`` travels with ``SpyDEParticles.save`` and is + stamped into the tree's commit provenance, so a corrected result is still + reproducible from the file alone. + """ + record = {"kind": str(kind), "at": time.time(), **fields} + if tree is not None: + record["revision"] = len(getattr(tree, "particle_edits", None) or ()) + 1 + edits = list(getattr(tree, "particle_edits", None) or ()) + edits.append(record) + tree.particle_edits = edits + commit = getattr(tree, "_commit_provenance", None) + if isinstance(commit, dict): + commit["edits"] = list(edits) + if particles is not None: + provenance = dict(particles.provenance or {}) + provenance["edits"] = list(provenance.get("edits") or ()) + [record] + particles.provenance = provenance + return record + + +def pending_edits(tree) -> list[dict]: + """The manual corrections recorded on *tree*, oldest first. + + **This is the seam a re-segmentation must consult.** A re-run rebuilds the + store from the raw frames, which discards every edit unless it reads this + first — and discarding them silently is the failure the plan calls out (B9). + Nothing in ``particles_action`` reads it yet; that is the segmentation + workstream's half of the contract, and it is a list of plain JSON-safe dicts + precisely so it can cross that boundary (and the IPC) unchanged. + """ + return list(getattr(tree, "particle_edits", None) or ()) + + +# ── the navigator lanes (plan C2) ──────────────────────────────────────────── + +#: Lane names, in stacking order. +LANE_COUNT = "count" +LANE_SIZE = "mean size" +LANE_EVENTS = "events" + +#: Row each event kind occupies inside the event lane, top to bottom. +EVENT_ROWS: dict[str, int] = {"birth": 3, "death": 2, "merge": 1, "split": 0} + + +def step_trace(values, x=None) -> tuple[np.ndarray, np.ndarray]: + """Duplicate samples so a polyline draws a ``steps-post`` staircase. + + **The count lane is integer data and must be drawn as a step.** A straight + interpolation between frames puts the visual transition half a frame early, + so a nucleation at frame 8 reads as 7 (plan C3, found by looking at a + render). anyplotlib's ``Axes.plot`` has no ``drawstyle``, so the staircase is + built into the DATA: each sample is held until the next x, then jumps. + Continuous quantities (mean size) stay plain lines. + + Returns + ------- + (x, y) + Both ``(2 * n,)``. The final sample is held one step wide so the last + frame is as visible as every other one. + """ + y = np.asarray(values, np.float32).ravel() + n = y.size + if n == 0: + return np.zeros(0, np.float64), np.zeros(0, np.float32) + xs = np.arange(n, dtype=np.float64) if x is None else np.asarray(x, np.float64).ravel() + width = float(xs[1] - xs[0]) if n > 1 else 1.0 + edges = np.concatenate([xs, [xs[-1] + width]]) + out_x = np.repeat(edges, 2)[1:-1] + out_y = np.repeat(y, 2) + return out_x, out_y + + +def _event_points(events, kind: str, scale: float, offset: float) -> np.ndarray: + """``(n, 2)`` ``(x, row)`` marker offsets for one event kind.""" + frames = [int(e.frame) for e in events if getattr(e, "kind", None) == kind] + if not frames: + return np.zeros((0, 2), np.float32) + row = float(EVENT_ROWS.get(kind, 0)) + return np.column_stack([np.asarray(frames, np.float64) * scale + offset, + np.full(len(frames), row)]).astype(np.float32) + + +def publish_navigator_lanes(session, tree, *, plot=None) -> bool: + """Publish ``tree.nav_traces`` as three stacked 1-D navigator lanes. + + ``count(t)``, ``mean size(t)`` and a dedicated event lane with a colour per + kind, stacked as rows on one shared time axis with a single logical cursor + wired to the tree's REAL 1-D navigation selector — so dragging any lane moves + the movie and playback moves every lane's line. + + Reuses ``navigator_views``' stacked-cursor machinery wholesale + (:class:`~spyde.actions.navigator_views._StackedNavCursor`, its registry and + its teardown), because the hard part of a stacked navigator is the two-way + cursor sync and its re-entrancy guard, and that is already written and + tested. What is NOT reusable is ``_stack_navigators``' figure builder: it + draws every lane as a plain line, and the count lane must be a step and the + event lane is markers rather than a trace at all. + + Each lane also lands in ``tree.navigator_signals`` so the chip strip lists it + and the existing ``select_navigator`` path can re-stack any subset. + + Returns + ------- + bool + True if the figure was emitted. + """ + import anyplotlib as apl + import anyplotlib._electron as _electron + import hyperspy.api as hs + + from spyde.actions.figure_registry import keep_alive + from spyde.actions.navigator_views import ( + STACKED_LABEL, _real_nav_selector, _selector_axis, _stacked_cursors, + _StackedNavCursor, _teardown_stacked, + ) + from spyde.backend.ipc import emit + from spyde.drawing.plots.plot import finalize_figure_html + + traces = dict(getattr(tree, "nav_traces", None) or {}) + if LANE_COUNT not in traces: + log.debug("[particles] no nav_traces on the tree; nothing to publish") + return False + if plot is None: + plot = _first_nav_plot(tree) + window_id = getattr(plot, "window_id", None) + if window_id is None: + log.debug("[particles] no navigator window to publish lanes onto") + return False + + count = np.asarray(traces[LANE_COUNT], np.float32) + size = np.asarray(traces.get("size", np.full(count.shape, np.nan)), np.float32) + events = list(getattr(tree, "particle_events", None) or ()) + + # Register the traces as named navigators too, so the chip strip offers them. + for name, data in ((LANE_COUNT, count), (LANE_SIZE, size)): + if name in getattr(tree, "navigator_signals", {}): + continue + try: + tree.add_navigator_signal(name, hs.signals.Signal1D(np.nan_to_num(data))) + except Exception as exc: + log.debug("[particles] registering navigator %r failed: %s", name, exc) + + selector = _real_nav_selector(session, int(window_id)) + scale, offset = _selector_axis(selector) if selector is not None else (1.0, 0.0) + current = 0 + if selector is not None and getattr(selector, "current_indices", None) is not None: + try: + current = int(np.asarray(selector.current_indices).ravel()[0]) + except Exception: + current = 0 + cursor_x = current * scale + offset + + _teardown_stacked(session, plot) + try: + fig, axes = apl.subplots(3, 1, sharex=True) + panels = np.array(axes, dtype=object).ravel() + widgets = [] + + step_x, step_y = step_trace(count, np.arange(count.size) * scale + offset) + lanes = [ + (panels[0], LANE_COUNT, step_y, step_x), + (panels[1], LANE_SIZE, np.nan_to_num(size), + np.arange(size.size) * scale + offset), + ] + # NB every setter below goes on the PLOT the panel returns, never on the + # Axes: anyplotlib's `Axes` has no `set_title`/`set_ylim` at all, so the + # guarded calls silently did nothing and the event lane came out + # autoscaled to the invisible baseline (verified in the app — the event + # rows sat off-scale and nothing drew). + for panel, title, ydata, xdata in lanes: + line = panel.plot(ydata, axes=[xdata], label=title) + _set_title(line, title) + widgets.append(_add_cursor(line, cursor_x)) + + # A transparent baseline establishes the x axis; the events themselves + # are markers, one group (and one row) per kind so the colours mean + # something. + base = panels[2].plot(np.zeros(count.size, np.float32), + axes=[np.arange(count.size) * scale + offset], + label=LANE_EVENTS, alpha=0.0) + _set_title(base, LANE_EVENTS) + for kind, color in EVENT_COLORS.items(): + base.add_points(_event_points(events, kind, scale, offset), + name=f"event_{kind}", sizes=5.0, color=color, + facecolors=color, alpha=1.0, label=kind) + try: + # Fixed rows, NOT autoscaled: the lane must read the same whether the + # movie contains one kind of event or all four, so a birth is always + # on the top row. + base.set_ylim(-0.5, max(EVENT_ROWS.values()) + 0.5) + except Exception as exc: + log.debug("[particles] setting event-lane ylim failed: %s", exc) + widgets.append(_add_cursor(base, cursor_x)) + + widgets = [w for w in widgets if w is not None] + if len(widgets) >= 2 and selector is not None: + _stacked_cursors(session)[int(window_id)] = _StackedNavCursor( + session, int(window_id), widgets, selector) + + fig_id = _electron.register(fig) + html = finalize_figure_html(fig, fig_id) + keep_alive(int(window_id), fig) + emit({"type": "figure", "fig_id": fig_id, "window_id": window_id, + "html": html, "title": " / ".join((LANE_COUNT, LANE_SIZE, LANE_EVENTS)), + "is_navigator": True, + "view_label": STACKED_LABEL, "view_kind": "stacked"}) + return True + except Exception as exc: + log.exception("[particles] publishing navigator lanes failed: %s", exc) + return False + + +def maybe_stack_particle_lanes(session, plot, tree, names) -> bool: + """Lane hook for ``navigator_views.select_navigator``. + + A particle tree's lanes do not render as plain lines: ``count`` is integer + data and must be a STEP, and the event lane is coloured markers rather than a + trace at all. ``_stack_navigators`` draws every named navigator the same way, + so when the user ⇧-clicks the chips on a particle tree the generic builder + would silently produce the wrong picture — a nucleation at frame 8 reading as + 7 is exactly the failure plan C3 calls out. + + Returns True when it built the lanes (the caller must then return). + """ + if not getattr(tree, "nav_traces", None) or LANE_COUNT not in tree.nav_traces: + return False + wanted = {str(n) for n in (names or ())} + if not wanted & {LANE_COUNT, LANE_SIZE}: + return False # the user picked other navigators; not our business + return publish_navigator_lanes(session, tree, plot=plot) + + +def _add_cursor(panel, x: float): + try: + return panel.add_vline_widget(x=float(x), color="#ff9100") + except Exception as exc: + log.debug("[particles] adding lane cursor failed: %s", exc) + return None + + +def _set_title(plot1d, title: str) -> None: + """Title a lane. Takes the PLOT, not the Axes — see the note in the builder.""" + try: + plot1d.set_title(title) + except Exception as exc: + log.debug("[particles] set_title on lane failed: %s", exc) + + +def _first_nav_plot(tree): + manager = getattr(tree, "navigator_plot_manager", None) + if manager is None: + return None + for window in list(manager.plot_windows.keys()): + plots = manager.plots.get(window) or [] + if plots: + return plots[0] + return None + + +def _first_signal_plot(tree): + for plot in list(getattr(tree, "signal_plots", None) or ()): + if getattr(plot, "plot_state", None) is not None: + return plot + return None + + +# ── staged handlers (registry.STAGED_HANDLERS, key "part") ─────────────────── + +def _resolve(session, plot): + """``(tree, overlay)`` for a handler. Prefers the clicked plot's own tree, + falling back to any tree that carries particles — the caret's window may + resolve to the count-map navigator rather than the label movie, the same way + ``lifecycle.resolve_vectors`` handles the vectors case.""" + tree = getattr(plot, "signal_tree", None) if plot is not None else None + if tree is not None and getattr(tree, "particles", None) is not None: + return tree, getattr(tree, "_particle_overlay", None) + for candidate in getattr(session, "signal_trees", None) or (): + if getattr(candidate, "particles", None) is not None: + return candidate, getattr(candidate, "_particle_overlay", None) + return tree, getattr(tree, "_particle_overlay", None) if tree is not None else None + + +def _source_frame_provider(tree): + """``fn(t) -> frame`` reading ONE frame of the tree's source movie. + + Used to re-measure intensity after an edit. Reads a single frame and computes + only that slice — never the movie (CLAUDE.md memory-safety rule). Returns + None when there is no source, in which case edited rows keep NaN intensities + rather than fabricated ones. + """ + source = getattr(tree, "source_node", None) + data = getattr(source, "data", None) + if data is None: + return None + + def read(t: int): + frame = data[int(t)] + if hasattr(frame, "compute"): + frame = frame.compute() + return np.asarray(frame) + + return read + + +def part_open(session, plot, payload=None) -> None: + """Caret mounted → attach the overlay to the tree's signal plot.""" + from spyde.actions.lifecycle import wait_for_particles + from spyde.backend.ipc import emit_error, emit_status + + payload = payload or {} + tree = getattr(plot, "signal_tree", None) if plot is not None else None + if tree is None: + emit_error("Particle Overlay: no active dataset") + return + if getattr(tree, "particles", None) is None: + # The segmentation attach gap (plan Wave 0): seg_run opens its window + # early and attaches tree.particles only at finalize. + if wait_for_particles(session, plot, + lambda: part_open(session, plot, payload), + what="Particle Overlay"): + return + emit_error("Particle Overlay needs a segmentation result (no particles).") + return + + target = _first_signal_plot(tree) or plot + overlay = attach_particle_overlay( + target, tree.particles, tree, + events=list(getattr(tree, "particle_events", None) or ()), + nav_map=getattr(tree, "nav_map", None), + frame_provider=_source_frame_provider(tree), + trail_frames=int(payload.get("trail_frames", DEFAULT_TRAIL_FRAMES)), + show_trails=bool(payload.get("show_trails", False)), + on_select=lambda: _emit_selection(tree), + ) + if overlay is None: + emit_error("Particle Overlay could not attach to this window") + return + emit_status(f"Particle overlay on — {tree.particles.n_particles} particles") + _emit_selection(tree) + + +def part_close(session, plot, payload=None) -> None: + """Caret unmounted → tear the overlay down.""" + from spyde.actions.lifecycle import replace_tree_attr + tree, _overlay = _resolve(session, plot) + if tree is not None: + replace_tree_attr(tree, "_particle_overlay", None) + + +def part_tune(session, plot, payload=None) -> None: + """Live parameter change: trails on/off and the trailing window length.""" + payload = payload or {} + _tree, overlay = _resolve(session, plot) + if overlay is None: + return + overlay.set_trails(bool(payload.get("show_trails", overlay.show_trails)), + payload.get("trail_frames")) + + +def part_select(session, plot, payload=None) -> None: + """External selection hook — a table row, a track id, or a region. + + Payload accepts ``{"indices": [...]}`` (global particle indices), + ``{"track_id": n}``, or ``{"region": [x0, y0, x1, y1]}`` in image pixels. + """ + payload = payload or {} + tree, overlay = _resolve(session, plot) + if overlay is None: + return + if "region" in payload: + overlay.select_region(*[float(v) for v in payload["region"]]) + elif "track_id" in payload: + overlay.select_track(int(payload["track_id"])) + else: + overlay.select(payload.get("indices") or []) + _emit_selection(tree) + + +def part_region_mode(session, plot, payload=None) -> None: + """Show/hide the rubber-band selection rectangle.""" + payload = payload or {} + _tree, overlay = _resolve(session, plot) + if overlay is not None: + overlay.set_region_select(bool(payload.get("enabled", True))) + + +def part_delete(session, plot, payload=None) -> None: + """Delete the selected particles (or ``payload["indices"]``).""" + from spyde.backend.ipc import emit_error, emit_status + payload = payload or {} + tree, overlay = _resolve(session, plot) + if overlay is None: + emit_error("Particle Overlay: nothing to edit") + return + try: + removed = overlay.delete(payload.get("indices")) + except Exception as exc: + emit_error(f"Delete particles failed: {exc}") + return + emit_status(f"Deleted {removed} particle{'' if removed == 1 else 's'}") + _emit_selection(tree) + + +def part_merge(session, plot, payload=None) -> None: + """Merge the selected particles into one and re-measure.""" + from spyde.backend.ipc import emit_error, emit_status + payload = payload or {} + tree, overlay = _resolve(session, plot) + if overlay is None: + emit_error("Particle Overlay: nothing to edit") + return + try: + index = overlay.merge(payload.get("indices")) + except Exception as exc: + emit_error(f"Merge particles failed: {exc}") + return + emit_status(f"Merged into particle {index}") + _emit_selection(tree) + + +def part_split(session, plot, payload=None) -> None: + """Split the selected particle along ``payload["line"]`` (image pixels).""" + from spyde.backend.ipc import emit_error, emit_status + payload = payload or {} + tree, overlay = _resolve(session, plot) + if overlay is None: + emit_error("Particle Overlay: nothing to edit") + return + try: + a, b = overlay.split(payload.get("index"), payload.get("line")) + except Exception as exc: + emit_error(f"Split particle failed: {exc}") + return + emit_status(f"Split into particles {a} and {b}") + _emit_selection(tree) + + +def part_lanes(session, plot, payload=None) -> None: + """Build (or rebuild) the three stacked navigator lanes.""" + from spyde.backend.ipc import emit_error + tree, _overlay = _resolve(session, plot) + if tree is None or not getattr(tree, "nav_traces", None): + emit_error("Particle lanes: this dataset has no navigator traces") + return + if not publish_navigator_lanes(session, tree): + emit_error("Particle lanes: could not build the stacked navigator") + + +def _json_float(value) -> float | None: + """A property value as JSON, with NaN mapped to ``null``. + + ``ipc.emit`` calls ``json.dumps`` with the default ``allow_nan=True``, which + writes the bare token ``NaN`` — not valid JSON, so ``JSON.parse`` on the + Electron side throws and the whole message is dropped. And NaN is the NORMAL + value here: ``measure_frame`` leaves every intensity column NaN when it runs + without an intensity image, which is exactly what an edit made with no + ``frame_provider`` produces. + """ + v = float(value) + return v if np.isfinite(v) else None + + +def _emit_selection(tree) -> None: + """Tell the renderer which particles are selected, with their properties. + + One message rather than a per-row query: the table dock highlights the + selection and shows the readout, and both come from the same rows. + """ + from spyde.backend.ipc import emit + overlay = getattr(tree, "_particle_overlay", None) if tree is not None else None + if overlay is None: + return + rows = [] + for gi in overlay.selected: + row = overlay.particles.flat_buffer[int(gi)] + record = {"index": int(gi), "frame": int(row[COL["t"]]), + "track_id": int(row[COL["track_id"]]), + "color": track_color(row[COL["track_id"]])} + record.update({name: _json_float(row[COL[name]]) for name in MEASURED_COLUMNS}) + rows.append(record) + emit({"type": "particle_selection", + "window_id": getattr(overlay.plot, "window_id", None), + "frame": int(overlay._frame), "revision": int(overlay.revision), + "indices": [int(i) for i in overlay.selected], "particles": rows}) + + +# ── toolbar entry ──────────────────────────────────────────────────────────── + +class _OverlayHandle: + """What the toolbar tracks so DESELECTING the action tears the overlay down. + + ``Session._track_action_artifacts`` records any object a toolbar action + returns that exposes ``active_children``, lights the button, and calls + ``.close()`` on it when the user clicks the lit button again. + + That is the ONLY un-toggle path the renderer offers: ``FloatingToolbar``'s + click handler sends ``set_action_active(active=false)`` for an action it + believes is live — never a second ``toolbar_action``. So an action that + toggles by inspecting its own state can be turned ON but never OFF. Verified + in the app: the second click re-ran ``part_open`` and the overlay stayed up. + """ + + active_children: tuple = () + + def __init__(self, session, plot): + self.session = session + self.plot = plot + + def close(self) -> None: + part_close(self.session, self.plot, {}) + + +def particle_overlay(ctx, action_name: str = "Particle Overlay", **params): + """Toolbar toggle: attach the overlay and hand the toolbar its teardown handle. + + Self-contained rather than a no-op wizard parent, so the overlay works from + the toolbar alone; the ``part_*`` staged handlers are the same operations for + a caret to drive once one exists. + """ + from spyde.backend import ipc + + plot = getattr(ctx, "plot", None) + session = getattr(plot, "session", None) + tree = getattr(plot, "signal_tree", None) if plot is not None else None + if tree is None: + ipc.emit_error("Particle Overlay: no active dataset") + return None + if getattr(tree, "_particle_overlay", None) is not None: + # Already up (a stale artifact, or a caret opened it): close so the click + # is still a toggle even when the artifact path is not in play. + part_close(session, plot, {}) + window_id = getattr(plot, "window_id", None) + if window_id is not None: + ipc.emit({"type": "action_active", "window_id": window_id, + "name": action_name, "active": False}) + return None + part_open(session, plot, params) + if getattr(tree, "_particle_overlay", None) is None: + return None # open failed; it emitted its own error + return _OverlayHandle(session, plot) + + +def particle_lanes(ctx, action_name: str = "Particle Lanes", **params): + """Toolbar entry: (re)build the three stacked navigator lanes.""" + plot = getattr(ctx, "plot", None) + session = getattr(plot, "session", None) + part_lanes(session, plot, params) + return None diff --git a/spyde/actions/registry.py b/spyde/actions/registry.py index 7b2c3429..6f0b517d 100644 --- a/spyde/actions/registry.py +++ b/spyde/actions/registry.py @@ -114,6 +114,16 @@ "seg_train": "spyde.actions.particles_action.seg_train", "seg_run": "spyde.actions.particles_action.seg_run", "seg_commit": "spyde.actions.particles_action.seg_commit", + # Particle overlay + editing (spyde/actions/particle_overlay.py) — plan B9/C2/C3. + "part_open": "spyde.actions.particle_overlay.part_open", + "part_close": "spyde.actions.particle_overlay.part_close", + "part_tune": "spyde.actions.particle_overlay.part_tune", + "part_select": "spyde.actions.particle_overlay.part_select", + "part_region_mode": "spyde.actions.particle_overlay.part_region_mode", + "part_delete": "spyde.actions.particle_overlay.part_delete", + "part_merge": "spyde.actions.particle_overlay.part_merge", + "part_split": "spyde.actions.particle_overlay.part_split", + "part_lanes": "spyde.actions.particle_overlay.part_lanes", # Drift Correction (spyde/actions/drift_action.py) — plan A8. "drift_open": "spyde.actions.drift_action.drift_open", "drift_close": "spyde.actions.drift_action.drift_close", @@ -252,6 +262,7 @@ def register_staged(name: str, dotted_path: str) -> None: "ebsd": ("spyde.actions.ebsd_action", "EbsdWizard"), "czb": ("spyde.actions.center_zero_beam", "PARAMETERS"), "seg": ("spyde.actions.particles_action", "SegmentWizard"), + "part": ("spyde.actions.particle_overlay", "PARAMETERS"), "drift": ("spyde.actions.drift_action", "DriftWizard"), # YAML-declared (resolved from spyde.TOOLBAR_ACTIONS): "fv": ("__yaml__", "Find Diffraction Vectors"), diff --git a/spyde/tests/migrated/test_particle_overlay.py b/spyde/tests/migrated/test_particle_overlay.py new file mode 100644 index 00000000..9cdaa52f --- /dev/null +++ b/spyde/tests/migrated/test_particle_overlay.py @@ -0,0 +1,1108 @@ +""" +The particle overlay (plan B9) and the stacked navigator lanes (C2/C3). + +Built on a REAL segmentation of the synthetic movie — ``segment_frame`` → +``measure_frame`` → ``SpyDEParticles.from_frames`` → ``link`` — rather than a +hand-made container, because three of the claims here are only meaningful +against real geometry: the calibrated→pixel conversion (the fixture is +``scale=0.5`` nm/px, so a missing division is visible), the nearest-centroid hit +test, and the split/merge round trip through ``measure_frame``. + +Five things are worth more than the rest: + +:class:`TestPixelConversion` + Centroids are CALIBRATED and must be divided by ``particles.scale``; + contours are already PIXELS and must not be. Both are right at scale 1 and + wrong everywhere else, which is exactly how ``masks._signal_k_grids``'s bug + class survives review. +:class:`TestTrails` + **A dead track draws no head dot.** The dot means "the particle is HERE + NOW"; on a track that has died — or one inside its ``memory`` gap — it reads + as a real particle the segmenter stopped filling (plan C3). +:class:`TestEdits` + Delete / merge / split mutate the store IN PLACE (the lazy label movie closes + over that object) and are recorded on the tree AND in provenance. +:class:`TestNavigatorLanes` + The count lane is integer data and is emitted as STEP data — a straight + interpolation puts a nucleation at 7 when the event is at 8. +:class:`TestLifecycle` + The overlay lives on the tree and ``BaseSignalTree.close()`` reaps it. +""" +from __future__ import annotations + +import time + +import numpy as np +import pytest + +import spyde.data.synthetic as sy +from spyde.actions import particle_overlay as po +from spyde.actions.particle_tree import open_particle_tree +from spyde.particles import ( + LinkParams, + SegmentParams, + link, + measure_frame, + segment_frame, +) +from spyde.signals.particles import COL, N_COLUMNS, SpyDEParticles + +N_FRAMES = 8 + + +# ── fixtures ───────────────────────────────────────────────────────────────── + +@pytest.fixture(scope="module") +def built(): + """A real segmentation + link of the fixture movie (copied from + ``test_particle_tree.py``'s ``built`` — same door, same numbers).""" + s = sy.particle_movie(n_frames=N_FRAMES) + gt = sy.ground_truth(s) + scale = float(gt["scale"]) + per_frame, contours = [], [] + for t in range(N_FRAMES): + lab = segment_frame(s.data[t], SegmentParams(min_size=25, gaussian=1.0)) + rows, cs = measure_frame(lab, s.data[t], t=t, scale=scale) + per_frame.append(rows) + contours.append(cs) + parts = SpyDEParticles.from_frames( + per_frame, frame_shape=tuple(gt["frame_shape"]), + contours_per_frame=contours, scale=scale, units="nm") + res = link(parts, LinkParams(max_dist=10.0)) + res.apply(parts) + return s, gt, parts, res + + +def _fresh(built): + """A private copy of the built store — the edit tests mutate it in place.""" + _s, _gt, parts, _res = built + return SpyDEParticles( + parts.flat_buffer.copy(), parts.t_offsets.copy(), parts.frame_shape, + contours=parts.contours.copy(), contour_offsets=parts.contour_offsets.copy(), + scale=parts.scale, units=parts.units) + + +@pytest.fixture +def make_tree(window, built): + """Build particle trees and CLOSE them on teardown. + + Not tidiness: the label movie is lazy, so a live tree runs a progressive + navigator compute on a daemon thread. Leaving one running past the end of the + test lets the interpreter finalise underneath it, which prints a truncated + traceback that looks like a failure and is not. ``close()`` sets the tree's + ``_nav_stop`` and is re-entrancy guarded, so a test that closes its own tree + is unaffected. + """ + session = window["window"] + s, _gt, parts, res = built + made = [] + + def build(*, events=True, **kwargs): + tree = open_particle_tree(session, particles=parts, source_node=s, + events=(res.events if events else None), **kwargs) + made.append(tree) + assert _wait(lambda: po._first_signal_plot(tree) is not None) + assert _wait(lambda: po._first_nav_plot(tree) is not None) + return tree + + yield build + for tree in made: + try: + tree.close() + except Exception: + pass + + +def _detached(particles, **kwargs) -> po.ParticleOverlay: + """An overlay with no plot: every payload/selection/edit method works without + a figure, which is what makes the geometry assertable headlessly.""" + return po.ParticleOverlay(None, particles, **kwargs) + + +def _signal_plot(session): + return next((p for p in session._plots + if not p.is_navigator and p.plot_state is not None), None) + + +def _wait(pred, timeout=20.0): + end = time.time() + timeout + while time.time() < end: + if pred(): + return True + time.sleep(0.05) + return False + + +def _csr_is_consistent(particles) -> None: + """Every invariant ``SpyDEParticles.__post_init__`` would have enforced.""" + assert particles.flat_buffer.shape[1] == N_COLUMNS + assert int(particles.t_offsets[0]) == 0 + assert int(particles.t_offsets[-1]) == len(particles.flat_buffer) + assert np.all(np.diff(particles.t_offsets) >= 0) + if particles.contours is not None: + assert particles.contour_offsets.size == len(particles.flat_buffer) + 1 + assert int(particles.contour_offsets[-1]) == len(particles.contours) + # The ``t`` column and the CSR block a row sits in must still agree. + frames = np.repeat(np.arange(particles.n_frames), np.diff(particles.t_offsets)) + assert np.array_equal(frames, particles.flat_buffer[:, COL["t"]].astype(np.int64)) + + +# ── colour ─────────────────────────────────────────────────────────────────── + +class TestColorCycle: + def test_track_zero_is_blue(self): + assert po.track_color(0) == "#89b4fa" + assert po.TRACK_COLORS[0] == "#89b4fa" + + def test_cycle_is_stable_and_wraps_at_six(self): + assert len(po.TRACK_COLORS) == 6 + for tid in range(24): + assert po.track_color(tid) == po.TRACK_COLORS[tid % 6] + # Stable means a pure function of the id — same answer every call, and + # the same answer for ids six apart. + assert po.track_color(3) == po.track_color(9) == po.track_color(15) + + def test_the_six_accents_are_distinct(self): + assert len(set(po.TRACK_COLORS)) == 6 + + def test_untracked_is_grey_not_a_seventh_accent(self): + assert po.track_color(-1) == po.UNTRACKED_COLOR + assert po.UNTRACKED_COLOR not in po.TRACK_COLORS + + def test_fade_appends_an_alpha_byte(self): + assert po.fade("#89b4fa", 1.0) == "#89b4faff" + assert po.fade("#89b4fa", 0.0) == "#89b4fa00" + assert len(po.fade("#89b4fa", 0.5)) == 9 + + def test_trail_alphas_run_newest_brightest(self): + alphas = po.trail_alphas(4) + assert alphas[0] == 1.0 + assert alphas == sorted(alphas, reverse=True) + assert min(alphas) > 0.0, "the oldest trail step must still be visible" + + def test_real_tracks_land_on_distinct_colours(self, built): + _s, _gt, parts, res = built + assert res.n_tracks == 6 + colors = {po.track_color(t) for t in range(res.n_tracks)} + assert colors == set(po.TRACK_COLORS) + + +# ── the calibrated → pixel conversion ──────────────────────────────────────── + +class TestPixelConversion: + def test_fixture_scale_is_not_one(self, built): + """The whole point of testing on this fixture.""" + _s, _gt, parts, _res = built + assert parts.scale == 0.5 + + def test_centroids_are_divided_by_scale(self, built): + _s, _gt, parts, _res = built + rows = parts.at(0) + px = po.centroids_px(rows, parts.scale) + assert np.allclose(px[:, 0], rows[:, COL["x"]] / 0.5) + assert np.allclose(px[:, 1], rows[:, COL["y"]] / 0.5) + # And they land INSIDE the frame in pixel space (a missing division would + # put every marker in the top-left quadrant of a 96x112 frame). + h, w = parts.frame_shape + assert px[:, 0].max() < w and px[:, 1].max() < h + + def test_marker_offsets_are_x_then_y(self, built): + """A property row is (y, x); a marker offset is (x, y).""" + _s, _gt, parts, _res = built + row = parts.at(0)[:1] + px = po.centroids_px(row, parts.scale) + assert px[0, 0] == pytest.approx(row[0, COL["x"]] / 0.5) + assert px[0, 1] == pytest.approx(row[0, COL["y"]] / 0.5) + assert px[0, 0] != pytest.approx(px[0, 1]) + + def test_contours_are_NOT_divided(self, built): + """Contours are stored in pixels already — dividing them by scale would + shrink every outline to a quarter of its body and leave it detached from + the centroid it belongs to.""" + _s, _gt, parts, _res = built + gi = int(parts.indices_at(0)[0]) + raw = parts.contour_at(gi) + poly = po.contour_xy(parts, gi) + assert np.allclose(poly[:, 0], raw[:, 1]) # x = column + assert np.allclose(poly[:, 1], raw[:, 0]) # y = row + + def test_centroid_sits_inside_its_own_outline(self, built): + """The conversion is only right if the two agree in ONE space.""" + _s, _gt, parts, _res = built + for gi in parts.indices_at(0): + poly = po.contour_xy(parts, int(gi)) + if len(poly) < 3: + continue + cx, cy = po.centroids_px(parts.flat_buffer[int(gi):int(gi) + 1], + parts.scale)[0] + assert poly[:, 0].min() - 1 <= cx <= poly[:, 0].max() + 1 + assert poly[:, 1].min() - 1 <= cy <= poly[:, 1].max() + 1 + + def test_a_scale_one_store_needs_no_division(self, built): + """Sanity: at scale=1 the two conventions coincide, which is exactly why + a scale=1 fixture cannot catch this class of bug.""" + _s, _gt, parts, _res = built + rows = parts.at(0) + assert np.allclose(po.centroids_px(rows, 1.0)[:, 0], rows[:, COL["x"]]) + + def test_click_is_converted_through_the_PLOT_axes(self, built): + """A click carries PHYSICAL xdata/ydata; markers are in pixels. + + The conversion goes through the displayed signal's axes, not through + ``particles.scale`` — the store's calibration and the plot's need not be + the same number. + """ + _s, _gt, parts, _res = built + + class _Axis: + def __init__(self, scale, offset): + self.scale, self.offset = scale, offset + + class _AxesManager: + signal_axes = (_Axis(0.25, 3.0), _Axis(0.25, 3.0)) + + class _Signal: + axes_manager = _AxesManager() + + class _State: + current_signal = _Signal() + + class _Plot: + plot_state = _State() + + class _Event: + xdata, ydata = 3.0 + 0.25 * 40, 3.0 + 0.25 * 12 + + overlay = po.ParticleOverlay(_Plot(), parts) + assert overlay._event_px(_Event()) == pytest.approx((40.0, 12.0)) + + def test_uncalibrated_plot_is_an_identity(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + + class _Event: + xdata, ydata = 17.0, 5.0 + assert overlay._event_px(_Event()) == pytest.approx((17.0, 5.0)) + + +# ── the payload ────────────────────────────────────────────────────────────── + +class TestPayload: + def test_every_particle_is_filled_exactly_once(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.set_frame(3) + payload = overlay._payload(3) + drawn = sum(len(payload[f"fill{i}"]["vertices_list"]) + for i in range(len(po.TRACK_COLORS) + 1)) + assert drawn == len(parts.at(3)) + + def test_fill_group_is_the_track_colour_bucket(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + payload = overlay._payload(2) + for gi in parts.indices_at(2): + tid = int(parts.flat_buffer[gi, COL["track_id"]]) + bucket = tid % len(po.TRACK_COLORS) + polys = payload[f"fill{bucket}"]["vertices_list"] + expected = po.contour_xy(parts, int(gi)) + assert any(p.shape == expected.shape and np.allclose(p, expected) + for p in polys), f"particle {gi} is not in bucket {bucket}" + + def test_untracked_rows_go_to_the_grey_bucket(self, built): + parts = _fresh(built) + parts.flat_buffer[:, COL["track_id"]] = -1.0 + overlay = _detached(parts) + payload = overlay._payload(0) + grey = len(po.TRACK_COLORS) + assert len(payload[f"fill{grey}"]["vertices_list"]) == len(parts.at(0)) + for i in range(grey): + assert payload[f"fill{i}"]["vertices_list"] == [] + + def test_labels_are_empty_until_something_is_selected(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + assert overlay._payload(0)["labels"]["texts"] == [] + assert overlay._payload(0)["selected"]["vertices_list"] == [] + + def test_selection_gets_an_outline_and_a_readout(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + gi = int(parts.indices_at(0)[1]) + overlay.select([gi]) + payload = overlay._payload(0) + assert len(payload["selected"]["vertices_list"]) == 1 + assert len(payload["labels"]["texts"]) == 1 + text = payload["labels"]["texts"][0] + assert "track" in text and "area" in text + + def test_hover_labels_without_selecting(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.hovered = int(parts.indices_at(0)[0]) + payload = overlay._payload(0) + assert len(payload["labels"]["texts"]) == 1 + assert payload["selected"]["vertices_list"] == [], \ + "hover must label, not outline — that is the selection's job" + + def test_a_selection_in_another_frame_is_not_drawn(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.select([int(parts.indices_at(5)[0])]) + payload = overlay._payload(0) + assert payload["selected"]["vertices_list"] == [] + assert payload["labels"]["texts"] == [] + + def test_out_of_range_frame_draws_nothing(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + payload = overlay._payload(999) + for i in range(len(po.TRACK_COLORS) + 1): + assert payload[f"fill{i}"]["vertices_list"] == [] + + +# ── trails and the head dot ────────────────────────────────────────────────── + +class TestTrails: + def _heads(self, payload) -> dict[int, int]: + return {i: len(payload[f"head{i}"]["offsets"]) + for i in range(len(po.TRACK_COLORS) + 1)} + + def _segments(self, payload, bucket) -> int: + return sum(len(payload[f"trail{bucket}_{s}"]["segments"]) + for s in range(po.TRAIL_FADE_STEPS)) + + def test_trails_off_draws_nothing(self, built): + _s, _gt, parts, _res = built + payload = _detached(parts, show_trails=False)._payload(5) + assert sum(self._heads(payload).values()) == 0 + assert all(self._segments(payload, i) == 0 + for i in range(len(po.TRACK_COLORS) + 1)) + + def test_a_live_track_gets_a_head_dot(self, built): + _s, _gt, parts, _res = built + payload = _detached(parts, show_trails=True)._payload(5) + assert sum(self._heads(payload).values()) == len(parts.at(5)) + + def test_head_dot_sits_on_the_centroid_in_pixels(self, built): + _s, _gt, parts, _res = built + payload = _detached(parts, show_trails=True)._payload(5) + gi = int(parts.indices_at(5)[0]) + bucket = int(parts.flat_buffer[gi, COL["track_id"]]) % len(po.TRACK_COLORS) + expected = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + heads = np.asarray(payload[f"head{bucket}"]["offsets"]) + assert np.allclose(heads[0], expected) + + def test_trail_spans_the_window_and_fades(self, built): + _s, _gt, parts, _res = built + payload = _detached(parts, show_trails=True, trail_frames=5)._payload(6) + gi = int(parts.indices_at(6)[0]) + bucket = int(parts.flat_buffer[gi, COL["track_id"]]) % len(po.TRACK_COLORS) + # 5 frames of window → at most 4 segments per track. + assert 1 <= self._segments(payload, bucket) <= 4 + # More than one fade step is populated, i.e. the ramp is actually used. + used = [s for s in range(po.TRAIL_FADE_STEPS) + if len(payload[f"trail{bucket}_{s}"]["segments"])] + assert len(used) >= 2 + + def test_a_DEAD_track_draws_no_head_dot(self, built): + """The plan's C3 note, asserted directly. + + Kill one track after frame 4 (delete its later detections), then look at + frame 6 — still inside the trailing window, so the fading line is there, + but the dot must be gone: it would read as a particle that is present + now, which is precisely what it is not. + """ + parts = _fresh(built) + dead = int(parts.at(0)[0, COL["track_id"]]) + doomed = [int(gi) for t in range(5, parts.n_frames) + for gi in parts.indices_at(t) + if int(parts.flat_buffer[gi, COL["track_id"]]) == dead] + assert doomed, "the fixture track never reached the later frames" + po.delete_particles(parts, doomed) + + overlay = _detached(parts, show_trails=True, trail_frames=8) + payload = overlay._payload(6) + bucket = dead % len(po.TRACK_COLORS) + assert self._segments(payload, bucket) > 0, \ + "the dead track's trail should still fade out" + assert len(payload[f"head{bucket}"]["offsets"]) == 0, \ + "a dead track drew a head dot — it reads as a live particle" + # Every OTHER track is unaffected. + live = sum(len(payload[f"head{i}"]["offsets"]) + for i in range(len(po.TRACK_COLORS) + 1) if i != bucket) + assert live == len(parts.at(6)) + + def test_a_track_inside_its_memory_GAP_draws_no_head_dot(self, built): + """Same rule, the other half: a track with no detection at *t* has no + current position, so it gets no dot even though it is not dead.""" + parts = _fresh(built) + tid = int(parts.at(0)[1, COL["track_id"]]) + gap = [int(gi) for gi in parts.indices_at(4) + if int(parts.flat_buffer[gi, COL["track_id"]]) == tid] + assert gap + po.delete_particles(parts, gap) + + payload = _detached(parts, show_trails=True, trail_frames=8)._payload(4) + bucket = tid % len(po.TRACK_COLORS) + assert self._segments(payload, bucket) > 0 + assert len(payload[f"head{bucket}"]["offsets"]) == 0 + + def test_untracked_rows_get_no_trail(self, built): + """An untracked row has no trajectory to draw — inventing one by + colour-bucket would join unrelated detections into a fake track.""" + parts = _fresh(built) + parts.flat_buffer[:, COL["track_id"]] = -1.0 + payload = _detached(parts, show_trails=True)._payload(5) + assert sum(self._heads(payload).values()) == 0 + + +# ── selection ──────────────────────────────────────────────────────────────── + +class TestSelection: + def test_select_by_index(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + want = [int(i) for i in parts.indices_at(2)[:2]] + assert overlay.select(want) == want + assert overlay.selected == want + + def test_select_drops_out_of_range_indices(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + assert overlay.select([0, 10_000, -5]) == [0] + + def test_select_by_track(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.set_frame(4) + tid = int(parts.at(4)[2, COL["track_id"]]) + picked = overlay.select_track(tid) + assert len(picked) == 1 + assert int(parts.flat_buffer[picked[0], COL["track_id"]]) == tid + assert int(parts.flat_buffer[picked[0], COL["t"]]) == 4 + + def test_click_picks_the_nearest_centroid(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.set_frame(1) + gi = int(parts.indices_at(1)[3]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + assert overlay.pick(cx + 0.5, cy - 0.5) == gi + + def test_click_on_empty_space_selects_nothing(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.set_frame(1) + assert overlay.pick(1.0, 1.0) is None + + def test_click_handler_selects_through_the_plot_axes(self, built): + """End-to-end for the click path: a physical xdata/ydata → a selection.""" + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.set_frame(1) + overlay._groups = {"sentinel": object()} # make the handler non-inert + gi = int(parts.indices_at(1)[2]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + + class _Event: # an uncalibrated plot → xdata IS the pixel column + xdata, ydata = float(cx), float(cy) + overlay._on_click(_Event()) + assert overlay.selected == [gi] + + def test_region_selects_in_bulk(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.set_frame(0) + pts = po.centroids_px(parts.at(0), parts.scale) + # A box around the left half of the frame. + picked = overlay.select_region(0, 0, 60, 200) + expected = int(((pts[:, 0] >= 0) & (pts[:, 0] <= 60)).sum()) + assert len(picked) == expected >= 1 + assert len(picked) < len(parts.at(0)), \ + "the box should not have caught every particle" + + def test_region_is_order_insensitive(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.set_frame(0) + a = overlay.select_region(10, 10, 80, 90) + b = overlay.select_region(80, 90, 10, 10) + assert a == b + + def test_clear_selection(self, built): + _s, _gt, parts, _res = built + overlay = _detached(parts) + overlay.select([0, 1]) + assert overlay.clear_selection() == [] + + def test_on_select_callback_fires(self, built): + _s, _gt, parts, _res = built + seen = [] + overlay = _detached(parts, on_select=lambda: seen.append(1)) + overlay.select([0]) + assert seen == [1] + + +# ── editing ────────────────────────────────────────────────────────────────── + +class TestEdits: + def test_delete_removes_the_row_and_keeps_the_CSR_valid(self, built): + parts = _fresh(built) + before = parts.n_particles + gi = int(parts.indices_at(3)[1]) + tid = int(parts.flat_buffer[gi, COL["track_id"]]) + assert po.delete_particles(parts, [gi]) == 1 + assert parts.n_particles == before - 1 + _csr_is_consistent(parts) + assert tid not in parts.at(3)[:, COL["track_id"]].astype(int) + + def test_delete_mutates_the_store_IN_PLACE(self, built): + """The lazy label movie closes over this object (particle_tree §0.6), so + an edit that rebound ``tree.particles`` would leave the open window + rendering the pre-edit contours forever.""" + parts = _fresh(built) + buffer_before = parts.flat_buffer + po.delete_particles(parts, [0]) + assert parts.flat_buffer is not buffer_before, "buffers are rebuilt…" + # …but the STORE is the same object, which is what the movie holds. + overlay = _detached(parts) + assert overlay.particles is parts + + def test_delete_keeps_contours_paired_with_their_rows(self, built): + parts = _fresh(built) + survivor = int(parts.indices_at(0)[2]) + expected = parts.contour_at(survivor).copy() + po.delete_particles(parts, [int(parts.indices_at(0)[0])]) + _csr_is_consistent(parts) + assert np.array_equal(parts.contour_at(survivor - 1), expected), \ + "a deletion re-paired an outline with the wrong property row" + + def test_delete_through_the_overlay_records_the_edit(self, built): + parts = _fresh(built) + + class _Tree: + particles = parts + tree = _Tree() + overlay = _detached(parts) + overlay.tree = tree + gi = int(parts.indices_at(2)[0]) + overlay.select([gi]) + assert overlay.delete() == 1 + assert len(tree.particle_edits) == 1 + record = tree.particle_edits[0] + assert record["kind"] == "delete" and record["indices"] == [gi] + assert parts.provenance["edits"][0]["kind"] == "delete" + assert overlay.selected == [] + + def test_split_makes_two_and_conserves_the_body(self, built): + parts = _fresh(built) + gi = int(parts.indices_at(0)[0]) + area = float(parts.flat_buffer[gi, COL["area"]]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + before = parts.n_particles + + a, b = po.split_particle(parts, gi, ((cx, cy - 50), (cx, cy + 50))) + assert parts.n_particles == before + 1 + _csr_is_consistent(parts) + halves = parts.flat_buffer[[a, b], COL["area"]] + assert halves.sum() == pytest.approx(area, rel=0.02) + assert min(halves) > 0 + + def test_split_keeps_the_parent_track_on_the_larger_half(self, built): + parts = _fresh(built) + gi = int(parts.indices_at(0)[0]) + parent = int(parts.flat_buffer[gi, COL["track_id"]]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + # An off-centre cut, so the halves are clearly unequal. + a, b = po.split_particle(parts, gi, ((cx + 2, cy - 50), (cx + 2, cy + 50))) + tracks = parts.flat_buffer[[a, b], COL["track_id"]].astype(int) + areas = parts.flat_buffer[[a, b], COL["area"]] + keeper = int(np.argmax(areas)) + assert tracks[keeper] == parent + assert tracks[1 - keeper] == -1, \ + "which fragment continues the track is a re-link's answer, not a guess" + + def test_split_pairs_each_new_row_with_its_OWN_outline(self, built): + """The add path through ``_splice``: a mis-gathered contour block would + pair a plausible outline with the wrong row, which draws as nonsense + rather than raising.""" + parts = _fresh(built) + gi = int(parts.indices_at(0)[0]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + a, b = po.split_particle(parts, gi, ((cx, cy - 50), (cx, cy + 50))) + for index in (a, b): + row = parts.flat_buffer[index] + contour = parts.contour_at(index) + assert len(contour) >= 3 + # The outline must sit inside its own row's bounding box (+1 px, the + # tracer runs on a padded crop). + assert contour[:, 0].min() >= row[COL["bbox_y0"]] - 1 + assert contour[:, 0].max() <= row[COL["bbox_y1"]] + 1 + assert contour[:, 1].min() >= row[COL["bbox_x0"]] - 1 + assert contour[:, 1].max() <= row[COL["bbox_x1"]] + 1 + # And the neighbours' outlines are untouched by the insertion. + _csr_is_consistent(parts) + + def test_split_that_misses_raises(self, built): + parts = _fresh(built) + gi = int(parts.indices_at(0)[0]) + with pytest.raises(ValueError, match="does not divide"): + po.split_particle(parts, gi, ((0.0, 0.0), (0.0, 10.0))) + + def test_merge_round_trips_a_split(self, built): + """Split then merge the halves back: one row, the original area.""" + parts = _fresh(built) + gi = int(parts.indices_at(0)[0]) + area = float(parts.flat_buffer[gi, COL["area"]]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + a, b = po.split_particle(parts, gi, ((cx, cy - 50), (cx, cy + 50))) + before = parts.n_particles + + merged = po.merge_particles(parts, [a, b]) + assert parts.n_particles == before - 1 + _csr_is_consistent(parts) + assert float(parts.flat_buffer[merged, COL["area"]]) == pytest.approx(area, rel=0.02) + + def test_merge_keeps_the_largest_bodys_track(self, built): + parts = _fresh(built) + gi = int(parts.indices_at(0)[0]) + parent = int(parts.flat_buffer[gi, COL["track_id"]]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + a, b = po.split_particle(parts, gi, ((cx + 2, cy - 50), (cx + 2, cy + 50))) + merged = po.merge_particles(parts, [a, b]) + assert int(parts.flat_buffer[merged, COL["track_id"]]) == parent + + def test_merging_particles_that_do_not_touch_raises(self, built): + """A boolean union cast to int32 would measure two distant discs as ONE + region with a centroid in the empty space between them.""" + parts = _fresh(built) + a, b = (int(i) for i in parts.indices_at(0)[:2]) + with pytest.raises(ValueError, match="do not touch"): + po.merge_particles(parts, [a, b]) + + def test_merging_across_frames_raises(self, built): + parts = _fresh(built) + a = int(parts.indices_at(0)[0]) + b = int(parts.indices_at(1)[0]) + with pytest.raises(ValueError, match="one frame"): + po.merge_particles(parts, [a, b]) + + def test_merge_needs_two(self, built): + parts = _fresh(built) + with pytest.raises(ValueError, match="at least two"): + po.merge_particles(parts, [0]) + + def test_edits_are_stamped_into_provenance_for_reproducibility(self, built): + parts = _fresh(built) + + class _Tree: + _commit_provenance = {"action": "segment_particles"} + tree = _Tree() + overlay = _detached(parts) + overlay.tree = tree + gi = int(parts.indices_at(0)[0]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + overlay.select([gi]) + overlay.split(gi, ((cx, cy - 50), (cx, cy + 50))) + overlay.merge(overlay.selected) + + kinds = [e["kind"] for e in tree.particle_edits] + assert kinds == ["split", "merge"] + # Both surfaces carry the log: the tree (so a re-run sees it) and the + # store's provenance (so a saved file still reproduces the result). + assert [e["kind"] for e in parts.provenance["edits"]] == kinds + assert [e["kind"] for e in tree._commit_provenance["edits"]] == kinds + + def test_pending_edits_is_the_re_run_seam(self, built): + """A re-segmentation rebuilds the store from the raw frames; unless it + reads this list first, every correction is silently discarded.""" + parts = _fresh(built) + + class _Tree: + pass + tree = _Tree() + assert po.pending_edits(tree) == [] + overlay = _detached(parts) + overlay.tree = tree + overlay.select([int(parts.indices_at(0)[0])]) + overlay.delete() + edits = po.pending_edits(tree) + assert len(edits) == 1 and edits[0]["revision"] == 1 + # JSON-safe: it crosses the IPC and gets saved with the store. + import json + json.dumps(edits) + + def test_edit_bumps_the_revision(self, built): + parts = _fresh(built) + overlay = _detached(parts) + assert overlay.revision == 0 + overlay.select([int(parts.indices_at(0)[0])]) + overlay.delete() + assert overlay.revision == 1 + + def test_frame_provider_fills_the_intensity_columns(self, built): + source, _gt, _parts, _res = built + parts = _fresh(built) + overlay = _detached(parts, frame_provider=lambda t: np.asarray(source.data[t])) + gi = int(parts.indices_at(0)[0]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + overlay.select([gi]) + a, b = overlay.split(gi, ((cx, cy - 50), (cx, cy + 50))) + assert np.isfinite(parts.flat_buffer[a, COL["intensity_mean"]]) + + def test_without_a_frame_provider_intensity_stays_NaN(self, built): + """NaN rather than an invented number — the same rule ``measure_frame`` + applies when it runs with no intensity image.""" + parts = _fresh(built) + overlay = _detached(parts) + gi = int(parts.indices_at(0)[0]) + cx, cy = po.centroids_px(parts.flat_buffer[gi:gi + 1], parts.scale)[0] + a, _b = overlay.split(gi, ((cx, cy - 50), (cx, cy + 50))) + assert not np.isfinite(parts.flat_buffer[a, COL["intensity_mean"]]) + + +# ── navigator lanes ────────────────────────────────────────────────────────── + +class TestNavigatorLanes: + def test_count_lane_is_emitted_as_STEP_data(self): + """Plan C3: a straight interpolation between frames puts a nucleation's + transition half a frame early, so 8 reads as 7.""" + counts = np.array([0, 0, 3, 3, 5], np.float32) + x, y = po.step_trace(counts) + # Every sample is HELD to the next x before it jumps: consecutive pairs + # share a y, and the jump happens exactly at the frame boundary. + assert y.tolist() == [0, 0, 0, 0, 3, 3, 3, 3, 5, 5] + assert x.tolist() == [0, 1, 1, 2, 2, 3, 3, 4, 4, 5] + rise = int(np.argmax(np.diff(y) > 0)) + 1 + assert x[rise] == 2.0, "the count must rise AT frame 2, not between 1 and 2" + + def test_step_trace_honours_a_calibrated_time_axis(self): + counts = np.array([1, 2, 3], np.float32) + x, _y = po.step_trace(counts, np.arange(3) * 0.05) + assert x[0] == pytest.approx(0.0) + assert x[-1] == pytest.approx(0.15) + + def test_step_trace_is_empty_for_an_empty_lane(self): + x, y = po.step_trace([]) + assert x.size == 0 and y.size == 0 + + def test_a_continuous_lane_is_NOT_stepped(self, built): + """Mean size is continuous; only the integer lane is a staircase. The + lane builder plots it straight, so its length is n, not 2n.""" + _s, _gt, parts, _res = built + size = parts.property_series("area", "mean") + assert size.shape == (N_FRAMES,) + + def test_event_points_carry_one_row_per_kind(self, built): + _s, _gt, _parts, res = built + pts = po._event_points(res.events, "birth", 1.0, 0.0) + assert len(pts) == len(res.events_of("birth")) + assert set(pts[:, 1]) == {float(po.EVENT_ROWS["birth"])} + assert po._event_points(res.events, "death", 1.0, 0.0).shape == (0, 2) + + def test_event_rows_and_colours_cover_every_kind(self): + from spyde.particles.track import EVENT_KINDS + assert set(po.EVENT_COLORS) == set(EVENT_KINDS) + assert set(po.EVENT_ROWS) == set(EVENT_KINDS) + assert len(set(po.EVENT_ROWS.values())) == len(EVENT_KINDS), \ + "two kinds sharing a row would overplot each other" + assert len(set(po.EVENT_COLORS.values())) == len(EVENT_KINDS) + assert po.EVENT_COLORS["birth"] == "#a6e3a1" # green + assert po.EVENT_COLORS["death"] == "#f38ba8" # red + assert po.EVENT_COLORS["merge"] == "#cba6f7" # mauve + assert po.EVENT_COLORS["split"] == "#f9e2af" # yellow + + def test_event_points_use_the_calibrated_time_axis(self, built): + _s, _gt, _parts, res = built + pts = po._event_points(res.events, "birth", 0.05, 0.0) + frames = [e.frame for e in res.events_of("birth")] + assert np.allclose(pts[:, 0], np.asarray(frames) * 0.05) + + def test_publish_emits_a_stacked_three_row_navigator(self, window, make_tree): + session = window["window"] + messages = window["messages"] + tree = make_tree() + + messages.clear() + assert po.publish_navigator_lanes(session, tree) is True + figures = [m for m in messages if m.get("type") == "figure" + and m.get("view_kind") == "stacked"] + assert figures, "no stacked lane figure emitted" + figure = figures[-1] + assert figure["is_navigator"] is True + assert figure["window_id"] == po._first_nav_plot(tree).window_id + for lane in (po.LANE_COUNT, po.LANE_SIZE, po.LANE_EVENTS): + assert lane in figure["title"] + + def test_lanes_reuse_the_shared_stacked_cursor(self, window, make_tree): + """The reusable half of ``navigator_views``: one logical time cursor + wired to the tree's REAL 1-D navigation selector, with a line per row.""" + session = window["window"] + tree = make_tree() + window_id = po._first_nav_plot(tree).window_id + assert _wait(lambda: session._nav_selectors.get(window_id) is not None) + + po.publish_navigator_lanes(session, tree) + cursor = session._stacked_nav_cursors.get(window_id) + assert cursor is not None + assert len(cursor.widgets) == 3, "one draggable line per lane" + assert cursor._index_hook in session._nav_selectors[window_id].index_hooks + + def test_lanes_are_registered_as_named_navigators(self, window, make_tree): + session = window["window"] + tree = make_tree() + po.publish_navigator_lanes(session, tree) + assert {po.LANE_COUNT, po.LANE_SIZE} <= set(tree.navigator_signals) + + def test_republishing_replaces_the_prior_cursor(self, window, make_tree): + session = window["window"] + tree = make_tree() + window_id = po._first_nav_plot(tree).window_id + assert _wait(lambda: session._nav_selectors.get(window_id) is not None) + + po.publish_navigator_lanes(session, tree) + first = session._stacked_nav_cursors[window_id] + po.publish_navigator_lanes(session, tree) + second = session._stacked_nav_cursors[window_id] + assert second is not first and first._closed is True + assert first._index_hook not in session._nav_selectors[window_id].index_hooks + + def test_a_tree_without_traces_publishes_nothing(self, window, make_tree): + session = window["window"] + tree = make_tree(events=False) + tree.nav_traces = {} + assert po.publish_navigator_lanes(session, tree) is False + + +# ── lifecycle on a real tree ───────────────────────────────────────────────── + +class TestLifecycle: + def test_attach_puts_the_overlay_on_the_tree(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + assert overlay is not None + assert tree._particle_overlay is overlay + assert overlay._groups, "no marker groups were created" + + def test_marker_group_count_is_one_per_colour(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + colours = len(po.TRACK_COLORS) + 1 + expected = colours * 2 + colours * po.TRAIL_FADE_STEPS + 2 + assert len(overlay._groups) == expected + + def test_draw_order_is_trails_then_fills_then_heads_then_labels(self, window, make_tree): + """Creation order IS draw order, and anyplotlib flattens the registry by + marker TYPE. A head dot underneath its own particle's fill is not a head + dot, so the type order has to come out lines → polygons → circles → texts. + """ + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + registry = po._first_signal_plot(tree)._plot2d.markers + assert list(registry) == ["lines", "polygons", "circles", "texts"] + # Within the polygons type the selected outline is added after the fills. + polygon_names = list(registry["polygons"].keys()) + assert polygon_names[-1].endswith("_selected") + + def test_attached_overlay_pushes_polygons_for_the_current_frame(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + overlay.set_frame(2) + drawn = sum(len(overlay._groups[f"fill{i}"]._data.get("vertices_list", [])) + for i in range(len(po.TRACK_COLORS) + 1)) + assert drawn == len(tree.particles.at(2)) + + def test_reattaching_does_not_stack_two_overlays(self, window, make_tree): + session = window["window"] + tree = make_tree() + plot = po._first_signal_plot(tree) + first = po.attach_particle_overlay(plot, tree.particles, tree) + second = po.attach_particle_overlay(plot, tree.particles, tree) + assert second is not first + assert tree._particle_overlay is second + assert first._groups == {}, "the prior overlay's markers were left behind" + + def test_navigator_hook_is_attached_and_detached(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + assert overlay._selectors, "the overlay found no navigator selector" + selectors = list(overlay._selectors) + assert all(overlay._on_indices in sel.index_hooks for sel in selectors) + overlay.remove() + assert all(overlay._on_indices not in sel.index_hooks for sel in selectors) + + def test_a_nav_move_redraws_the_new_frame(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + overlay._on_indices(np.array([5])) + assert overlay._frame == 5 + drawn = sum(len(overlay._groups[f"fill{i}"]._data.get("vertices_list", [])) + for i in range(len(po.TRACK_COLORS) + 1)) + assert drawn == len(tree.particles.at(5)) + + def test_a_superseded_payload_never_lands(self, window, make_tree): + """Latest-wins: a payload built for an older generation is dropped.""" + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + overlay.set_frame(3) + stale = overlay._payload(0) + current = overlay._gen + overlay._apply(stale, current - 1) + drawn = sum(len(overlay._groups[f"fill{i}"]._data.get("vertices_list", [])) + for i in range(len(po.TRACK_COLORS) + 1)) + assert drawn == len(tree.particles.at(3)) + + def test_teardown_bumps_the_generation_first(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + gen = overlay._gen + overlay.remove() + assert overlay._gen > gen + + def test_tree_close_reaps_the_overlay(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + selectors = list(overlay._selectors) + tree.close() + assert getattr(tree, "_particle_overlay", None) is None + assert overlay._groups == {} + assert all(overlay._on_indices not in sel.index_hooks for sel in selectors) + + def test_region_widget_appears_and_selects_in_bulk(self, window, make_tree): + session = window["window"] + tree = make_tree() + plot = po._first_signal_plot(tree) + overlay = po.attach_particle_overlay(plot, tree.particles, tree) + assert overlay._region_widget is None + + overlay.set_region_select(True) + assert overlay._region_widget is not None + # The default box covers the middle half of the frame, so it catches some + # particles but not all of them. + assert 0 < len(overlay.selected) <= len(tree.particles.at(overlay._frame)) + + overlay.set_region_select(False) + assert overlay._region_widget is None + assert plot._plot2d.list_widgets() == [] + + def test_hidden_overlay_still_follows_the_navigator(self, window, make_tree): + session = window["window"] + tree = make_tree() + overlay = po.attach_particle_overlay( + po._first_signal_plot(tree), tree.particles, tree) + overlay.set_visible(False) + overlay._on_indices(np.array([4])) + assert overlay._frame == 4 + drawn = sum(len(overlay._groups[f"fill{i}"]._data.get("vertices_list", [])) + for i in range(len(po.TRACK_COLORS) + 1)) + assert drawn == 0, "a hidden overlay drew markers" + overlay.set_visible(True) + drawn = sum(len(overlay._groups[f"fill{i}"]._data.get("vertices_list", [])) + for i in range(len(po.TRACK_COLORS) + 1)) + assert drawn == len(tree.particles.at(4)) + + +# ── the staged actions ─────────────────────────────────────────────────────── + +class TestStagedActions: + def test_every_part_handler_resolves(self): + from spyde.actions import registry + keys = [k for k in registry.STAGED_HANDLERS if k.startswith("part_")] + assert {"part_open", "part_close", "part_select", "part_delete", + "part_merge", "part_split", "part_lanes"} <= set(keys) + for key in keys: + assert callable(registry.resolve_staged(key)) + + def test_the_caret_schema_resolves(self): + from spyde.actions import registry + schema = registry.wizard_parameters("part") + assert schema and schema["trail_frames"]["default"] == po.DEFAULT_TRAIL_FRAMES + + def test_open_attaches_and_close_tears_down(self, window, make_tree): + session = window["window"] + tree = make_tree() + plot = po._first_signal_plot(tree) + + po.part_open(session, plot, {}) + assert getattr(tree, "_particle_overlay", None) is not None + po.part_close(session, plot, {}) + assert getattr(tree, "_particle_overlay", None) is None + + def test_select_emits_the_selection_for_the_renderer(self, window, make_tree, built): + session = window["window"] + messages = window["messages"] + _s, _gt, parts, _res = built + tree = make_tree() + plot = po._first_signal_plot(tree) + po.part_open(session, plot, {}) + + gi = int(parts.indices_at(0)[0]) + messages.clear() + po.part_select(session, plot, {"indices": [gi]}) + sent = [m for m in messages if m.get("type") == "particle_selection"] + assert sent, "no particle_selection message emitted" + payload = sent[-1] + assert payload["indices"] == [gi] + record = payload["particles"][0] + assert record["index"] == gi + assert record["color"] == po.track_color(record["track_id"]) + assert "area" in record and "circularity" in record + po.part_close(session, plot, {}) + + def test_selection_message_is_valid_JSON_even_with_NaN_properties(self, window, make_tree): + """``ipc.emit`` dumps with ``allow_nan=True``, so a bare ``NaN`` token + would make ``JSON.parse`` throw and the renderer lose the whole message — + and NaN is the normal value for an unmeasured intensity column.""" + import json + session = window["window"] + messages = window["messages"] + tree = make_tree() + plot = po._first_signal_plot(tree) + po.part_open(session, plot, {}) + gi = int(tree.particles.indices_at(0)[0]) + buffer = tree.particles.flat_buffer + saved = float(buffer[gi, COL["intensity_mean"]]) + buffer[gi, COL["intensity_mean"]] = np.nan + try: + messages.clear() + po.part_select(session, plot, {"indices": [gi]}) + sent = [m for m in messages if m.get("type") == "particle_selection"][-1] + assert sent["particles"][0]["intensity_mean"] is None + assert "NaN" not in json.dumps(sent) + finally: + # The store is module-scoped and shared by every test in this file. + buffer[gi, COL["intensity_mean"]] = saved + po.part_close(session, plot, {}) + + def test_open_without_particles_errors_rather_than_hanging(self, window): + """No segmentation running and no particles: say so, don't wait forever. + + (``wait_for_particles`` returns False with no event loop; the harness + Session HAS one, so the wait starts and the error arrives from the poll's + grace window — the assertion is only that open() does not attach.) + """ + session = window["window"] + session._load_test_data_particles({"frames": 4}) + assert _wait(lambda: _signal_plot(session) is not None) + plot = _signal_plot(session) + po.part_open(session, plot, {}) + assert getattr(plot.signal_tree, "_particle_overlay", None) is None diff --git a/spyde/toolbars.yaml b/spyde/toolbars.yaml index fb4752e3..a751f48e 100644 --- a/spyde/toolbars.yaml +++ b/spyde/toolbars.yaml @@ -384,6 +384,30 @@ functions: navigation: False toggle: True + Particle Overlay: + description: Draw the found particles on the frame — filled outlines coloured by track, labels on the selected one, optional fading motion trails. Double-click a particle to select it; delete, merge and split corrections are recorded on the dataset. + icon: drawing/toolbars/icons/peak_finding.svg + function: spyde.actions.particle_overlay.particle_overlay + requires_particles: True + plot_dim: [2] + toolbar_side: bottom + navigation: False + toggle: True + # NO `parameters:` block, deliberately. The Electron toolbar renders a + # parameter POPOVER with its own Run button for any action that declares + # one, so declaring the trail knobs here turned a one-click toggle into a + # form the user has to submit — verified in the app: the overlay never drew + # because the click only opened the panel. The knobs live in the caret's + # schema instead (registry.wizard_parameters("part") → part_tune). + + Particle Lanes: + description: Stack count-vs-time, mean size and the birth/death/merge/split event lane as one navigator with a shared time cursor. + icon: drawing/toolbars/icons/peak_finding.svg + function: spyde.actions.particle_overlay.particle_lanes + requires_particles: True + plot_dim: [1, 2] + toolbar_side: bottom + Fit: description: Fit a model to every spectrum. Add components line by line, watch the model curve on the spectrum, then Run to fit the whole scan on the GPU. Commit turns each component's integrated area into a map. icon: drawing/toolbars/icons/fit.svg From 171e4ea8da21980ef494f778f4dafd8af7ba5068 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 22:34:24 -0500 Subject: [PATCH 15/38] fix(signal_tree): close() leaked the new wizards and pinned the source movie BaseSignalTree.close() tears down by iterating hard-coded attribute NAME LISTS, so anything nobody remembers to add is silently exempt -- no error, no warning, just state that outlives its tree. Three omissions from this feature: * `_seg_wizard` / `_drift_wizard` were absent from the wizard list, so closing a tree left a live wizard controller holding its windows and generation state. * `particles`, `_seg_pending_particles`, `particle_events`, `particle_edits`, `nav_traces`, `drift` and `nav_map` were absent from the results list. * Worst: `source_node` and `source_tree`. A particle tree back-references the movie it was segmented FROM, so closing the particle tree kept the source's lazy multi-GB array reachable -- and closing the source movie freed nothing. test_particle_lifecycle.py closes things and asserts on what is left, including a weakref test proving the source signal really is collectable afterwards, plus its non-vacuity partner showing the source is still pinned WITHOUT close -- otherwise that test would only be demonstrating that a local went out of scope. Because the real failure mode is an attribute nobody thought to LIST, one class reads close()'s source and fails on a missing name. A purely behavioural test only catches the attributes you remembered to set, which is exactly the blind spot that caused the bug. Also: the verification runner was never actually in the repo. .gitignore carries a `verify_*.py` rule for throwaway ad-hoc scripts and it silently swallowed scripts/verify_drift_particles.py -- so an earlier commit claimed to add it while only the benchmark landed. Renamed to scripts/check_drift_particles.py rather than punching a hole in a shared ignore rule. 22 lifecycle tests. --- scripts/check_drift_particles.py | 195 ++++++++++++++ .../tests/migrated/test_particle_lifecycle.py | 250 ++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 scripts/check_drift_particles.py create mode 100644 spyde/tests/migrated/test_particle_lifecycle.py diff --git a/scripts/check_drift_particles.py b/scripts/check_drift_particles.py new file mode 100644 index 00000000..23790d27 --- /dev/null +++ b/scripts/check_drift_particles.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +""" +One command that verifies the whole drift + particles feature. + +(Named check_ rather than verify_ because .gitignore carries a `verify_*.py` rule +for throwaway ad-hoc scripts, which silently swallowed this file for several +commits — it ran fine locally and simply was not in the repo.) + + python scripts/check_drift_particles.py # python only (fast) + python scripts/check_drift_particles.py --all # + typecheck + e2e + python scripts/check_drift_particles.py --e2e # + e2e only + python scripts/check_drift_particles.py --bench # + report the numbers + +Exists because this feature spans two languages, three test tiers and a separate +repo, so "did I break it" was otherwise four commands with four different working +directories and one of them needed a build step first. Re-run it after every step +of DRIFT_AND_PARTICLES_PLAN.md. + +Every stage is INDEPENDENT and the exit code is the worst result, so one broken +tier still reports the state of the others — the point is a status board, not a +fail-fast gate. Stages that cannot run (no node_modules, no browser) are reported +as SKIP, not as failure: a missing optional tool is not a regression. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ELECTRON = ROOT / "electron" + +# The python suites this feature owns. Kept explicit rather than globbing +# `spyde/tests/migrated` so a new unrelated failure elsewhere does not read as a +# drift/particles regression — the full suite is a separate stage below. +FEATURE_SUITES = [ + "spyde/tests/migrated/test_drift_translation.py", + "spyde/tests/migrated/test_particles_core.py", + "spyde/tests/migrated/test_particle_movie_fixture.py", + "spyde/tests/migrated/test_particles_scribble.py", + "spyde/tests/migrated/test_particles_track.py", + "spyde/tests/migrated/test_particle_tree.py", + "spyde/tests/migrated/test_particles_wizard.py", + "spyde/tests/migrated/test_drift_wizard.py", + "spyde/tests/migrated/test_particle_overlay.py", + "spyde/tests/migrated/test_particle_lifecycle.py", +] + +# Playwright specs this feature owns. +FEATURE_SPECS = [ + "tests/particles_workflow.spec.ts", + "tests/drift_workflow.spec.ts", +] + +PASS, FAIL, SKIP = "PASS", "FAIL", "SKIP" +_C = {PASS: "\033[32m", FAIL: "\033[31m", SKIP: "\033[33m"} +_R = "\033[0m" + + +def _colour(state: str) -> str: + if os.environ.get("NO_COLOR") or not sys.stdout.isatty(): + return state + return f"{_C.get(state, '')}{state}{_R}" + + +def _python() -> str: + """The venv interpreter if there is one, else whatever is running us.""" + for rel in ("Scripts/python.exe", "bin/python"): + cand = ROOT / ".venv" / rel + if cand.exists(): + return str(cand) + return sys.executable + + +class Runner: + def __init__(self, verbose: bool) -> None: + self.verbose = verbose + self.results: list[tuple[str, str, float, str]] = [] + + def stage(self, name: str, cmd: list[str], *, cwd: Path = ROOT, + skip_if: str | None = None, env: dict[str, str] | None = None) -> str: + if skip_if: + self.results.append((name, SKIP, 0.0, skip_if)) + print(f" {_colour(SKIP)} {name} ({skip_if})") + return SKIP + print(f" .... {name}", end="\r", flush=True) + full_env = {**os.environ, **(env or {})} + t0 = time.perf_counter() + proc = subprocess.run(cmd, cwd=str(cwd), env=full_env, + capture_output=not self.verbose, text=True) + dt = time.perf_counter() - t0 + state = PASS if proc.returncode == 0 else FAIL + detail = "" + if state is FAIL and not self.verbose: + tail = ((proc.stdout or "") + (proc.stderr or "")).strip().splitlines() + detail = "\n".join(" " + ln for ln in tail[-25:]) + self.results.append((name, state, dt, detail)) + print(f" {_colour(state)} {name} ({dt:.1f}s) ") + if detail: + print(detail) + return state + + def summary(self) -> int: + print("\n" + "=" * 66) + worst = 0 + for name, state, dt, _ in self.results: + print(f" {_colour(state)} {name:<44} {dt:>6.1f}s") + if state is FAIL: + worst = 1 + n_fail = sum(1 for _, s, _, _ in self.results if s is FAIL) + n_skip = sum(1 for _, s, _, _ in self.results if s is SKIP) + n_pass = sum(1 for _, s, _, _ in self.results if s is PASS) + print("=" * 66) + print(f" {n_pass} passed, {n_fail} failed, {n_skip} skipped") + return worst + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--all", action="store_true", help="every stage") + ap.add_argument("--e2e", action="store_true", help="include Playwright specs") + ap.add_argument("--typecheck", action="store_true", help="include tsc") + ap.add_argument("--full-suite", action="store_true", + help="the whole python suite, not just this feature's") + ap.add_argument("--bench", action="store_true", + help="print the feature's benchmark numbers") + ap.add_argument("-v", "--verbose", action="store_true", + help="stream child output instead of capturing it") + args = ap.parse_args() + if args.all: + args.e2e = args.typecheck = args.full_suite = args.bench = True + + py = _python() + r = Runner(args.verbose) + print(f"\nverifying drift + particles\n python: {py}\n") + + # ── python ─────────────────────────────────────────────────────────────── + existing = [s for s in FEATURE_SUITES if (ROOT / s).exists()] + missing = [s for s in FEATURE_SUITES if not (ROOT / s).exists()] + if existing: + r.stage("python feature suites", + [py, "-m", "pytest", *existing, "-q", "--no-header", + "-p", "no:cacheprovider"], + env={"SPYDE_NO_DASK": "1"}) + for s in missing: + # Not yet written — a step of the plan that has not landed. Visible, not fatal. + r.stage(f"python {Path(s).stem}", [], skip_if="not implemented yet") + + if args.full_suite: + r.stage("python full suite", + [py, "-m", "pytest", "-q", "--no-header", "-p", "no:cacheprovider"], + env={"SPYDE_NO_DASK": "1"}) + + # ── frontend ───────────────────────────────────────────────────────────── + has_npm = shutil.which("npm") is not None + has_modules = (ELECTRON / "node_modules").is_dir() + front_skip = (None if (has_npm and has_modules) + else "no npm" if not has_npm else "run npm install in electron/") + + if args.typecheck: + r.stage("frontend typecheck", ["npm", "run", "typecheck"], + cwd=ELECTRON, skip_if=front_skip) + + if args.e2e: + specs = [s for s in FEATURE_SPECS if (ELECTRON / s).exists()] + if front_skip: + r.stage("e2e feature specs", [], skip_if=front_skip) + elif not specs: + r.stage("e2e feature specs", [], skip_if="no specs written yet") + else: + # test:build, not test — the harness launches out/main/index.js, so a + # renderer change that was never built is invisible to Playwright and + # the spec silently tests the previous bundle. + r.stage("e2e feature specs", + ["npm", "run", "test:build", "--", "--project=electron", + "--reporter=line", "--retries=0", *specs], + cwd=ELECTRON) + + # ── numbers ────────────────────────────────────────────────────────────── + if args.bench: + r.stage("bench drift + fixture", + [py, str(ROOT / "scripts" / "bench_drift_particles.py")], + skip_if=None if (ROOT / "scripts" / "bench_drift_particles.py").exists() + else "bench script not written yet") + + return r.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spyde/tests/migrated/test_particle_lifecycle.py b/spyde/tests/migrated/test_particle_lifecycle.py new file mode 100644 index 00000000..c2d03e6b --- /dev/null +++ b/spyde/tests/migrated/test_particle_lifecycle.py @@ -0,0 +1,250 @@ +""" +Teardown for everything this feature opens. Nothing may outlive its window. + +The feature adds a lot of surfaces — a particle tree, the Drift Check window, the +dx/dy plot, the ROI preview, overlay widgets, navigator lanes, two wizards — and +each is a separate chance to leak. The mechanisms exist (``register_cancel``, +``replace_tree_attr``, ``own_window``, ``figure_registry``), but the presence of a +mechanism is not evidence it fires, so these tests close things and assert on what +is left. + +**The list-drift hazard.** ``BaseSignalTree.close()`` tears down by iterating +hard-coded attribute NAME LISTS. A new wizard or result that nobody adds to those +lists is silently exempt — no error, no warning, just a controller that outlives +its tree. That already happened here: ``_seg_wizard`` / ``_drift_wizard`` were +absent from the wizard list, and ``particles`` / ``source_node`` / ``source_tree`` +from the results list, so closing a particle tree kept a back-reference to the +source movie's lazy array alive and closing the source freed nothing. +:class:`TestCloseListsCoverThisFeature` exists to make the next omission fail +rather than leak. +""" +from __future__ import annotations + +import gc +import weakref + +import numpy as np +import pytest + +import spyde.data.synthetic as sy +from spyde.actions import figure_registry +from spyde.actions.particle_tree import open_particle_tree +from spyde.particles import ( + LinkParams, + SegmentParams, + link, + measure_frame, + segment_frame, +) +from spyde.signals.particles import SpyDEParticles + +N_FRAMES = 5 + + +@pytest.fixture(scope="module") +def parts(): + s = sy.particle_movie(n_frames=N_FRAMES) + gt = sy.ground_truth(s) + per_frame, contours = [], [] + for t in range(N_FRAMES): + lab = segment_frame(s.data[t], SegmentParams(min_size=25, gaussian=1.0)) + rows, cs = measure_frame(lab, s.data[t], t=t, scale=float(gt["scale"])) + per_frame.append(rows) + contours.append(cs) + p = SpyDEParticles.from_frames( + per_frame, frame_shape=tuple(gt["frame_shape"]), + contours_per_frame=contours, scale=float(gt["scale"]), units="nm") + link(p, LinkParams(max_dist=10.0)).apply(p) + return s, gt, p + + +class TestCloseListsCoverThisFeature: + """Guards the hard-coded name lists in ``BaseSignalTree.close()``. + + These read the source rather than the behaviour on purpose: the failure mode + is an attribute nobody REMEMBERED to list, and a behavioural test only catches + the ones you thought to set. + """ + + def _close_src(self) -> str: + import inspect + + from spyde import signal_tree + src = inspect.getsource(signal_tree.BaseSignalTree.close) + return src + + @pytest.mark.parametrize("attr", ["_seg_wizard", "_drift_wizard"]) + def test_new_wizards_are_torn_down(self, attr): + assert attr in self._close_src(), ( + f"{attr} is not named in BaseSignalTree.close(), so a live wizard " + "controller outlives its tree — silently, because close() iterates " + "name lists and an unlisted attribute is simply skipped") + + @pytest.mark.parametrize("attr", [ + "particles", "_seg_pending_particles", "particle_events", + "particle_edits", "nav_traces", "drift", "nav_map", + ]) + def test_new_results_are_cleared(self, attr): + assert attr in self._close_src(), f"{attr} survives tree.close()" + + @pytest.mark.parametrize("attr", ["source_node", "source_tree"]) + def test_back_references_to_the_source_are_cleared(self, attr): + """The costly one: these hold the source movie's lazy array.""" + assert attr in self._close_src(), ( + f"{attr} survives tree.close(), so a particle tree pins the source " + "movie's signal and closing the movie frees nothing") + + def test_particle_overlay_is_torn_down(self): + assert "_particle_overlay" in self._close_src() + + +class TestParticleTreeTeardown: + def test_close_clears_the_result_and_the_back_reference(self, window, parts): + session = window["window"] + s, _gt, p = parts + tree = open_particle_tree(session, particles=p, source_node=s) + assert tree.particles is p and tree.source_node is s + tree.close() + assert getattr(tree, "particles", None) is None + assert getattr(tree, "source_node", None) is None + assert getattr(tree, "source_tree", None) is None + + def test_close_drops_the_plots(self, window, parts): + session = window["window"] + s, _gt, p = parts + tree = open_particle_tree(session, particles=p, source_node=s) + tree.close() + assert tree.signal_plots == [] + assert tree.navigator_plot_manager is None + + def test_the_source_signal_is_releasable_after_close(self, window, parts): + """The leak this was really about, tested by weak reference. + + A tree that keeps `source_node` set pins the movie. Build a THROWAWAY + source so the module fixture's own reference does not mask the result. + """ + session = window["window"] + _s, gt, p = parts + throwaway = sy.particle_movie(n_frames=3) + ref = weakref.ref(throwaway) + tree = open_particle_tree(session, particles=p, source_node=throwaway) + tree.close() + del throwaway + gc.collect() + assert ref() is None, ( + "the source signal is still reachable after the particle tree was " + "closed — something still holds source_node") + + def test_without_close_the_source_is_still_pinned(self, window, parts): + """Non-vacuity for the test above. + + If this ALSO collected, the weakref test would be proving nothing about + `close()` — it would just be showing that a local went out of scope. + """ + session = window["window"] + _s, _gt, p = parts + throwaway = sy.particle_movie(n_frames=3) + ref = weakref.ref(throwaway) + tree = open_particle_tree(session, particles=p, source_node=throwaway) + del throwaway + gc.collect() + assert ref() is not None, ( + "the source was collected without close() — so the companion test " + "does not demonstrate that close() is what releases it") + tree.close() + + def test_close_is_idempotent(self, window, parts): + session = window["window"] + s, _gt, p = parts + tree = open_particle_tree(session, particles=p, source_node=s) + tree.close() + tree.close() # must not raise + + +class TestPendingParticlesTeardown: + """A run cancelled before finalize must not leave the placeholder behind.""" + + def test_pending_store_is_cleared(self, window, parts): + session = window["window"] + s, _gt, p = parts + tree = open_particle_tree(session, particles=p, source_node=s, + attach=False) + assert tree.particles is None + assert tree._seg_pending_particles is p + tree.close() + assert getattr(tree, "_seg_pending_particles", None) is None + + def test_batch_flag_does_not_survive_as_a_phantom_run(self, window, parts): + """`lifecycle.seg_batch_running` scans live trees; a closed one that kept + the flag would make `wait_for_particles` wait on a run that has ended.""" + from spyde.actions.lifecycle import seg_batch_running + session = window["window"] + s, _gt, p = parts + tree = open_particle_tree(session, particles=p, source_node=s) + tree._seg_batch_running = True + assert seg_batch_running(session) + tree.close() + try: + session.signal_trees.remove(tree) + except ValueError: + pass + assert not seg_batch_running(session), ( + "a closed tree still reports a running segmentation batch") + + +class TestFigureRegistry: + """Bare-figure windows must not pin their figures past teardown.""" + + def test_forget_window_evicts_the_figure(self): + marker = object() + wid = 987654 + figure_registry.keep_alive(wid, marker) + assert wid in figure_registry._FIGS + figure_registry.forget_window(wid) + assert wid not in figure_registry._FIGS, ( + "figure_registry still holds the window's figure after teardown") + + def test_forgetting_an_unknown_window_is_harmless(self): + figure_registry.forget_window(123456789) + + def test_registry_holds_no_module_state_for_a_closed_window(self, window, + parts): + """actions/README.md §3: figure_registry._FIGS is the ONLY module-level + mutable state allowed, and it must be evicted by _forget_window.""" + session = window["window"] + s, _gt, p = parts + before = set(figure_registry._FIGS) + tree = open_particle_tree(session, particles=p, source_node=s) + wid = None + for plot in tree.signal_plots or []: + wid = getattr(plot, "window_id", None) + if wid is not None: + break + tree.close() + if wid is not None: + session._forget_window(wid) + leaked = set(figure_registry._FIGS) - before + assert not leaked, f"figures left registered for windows {leaked}" + + +class TestSessionArtifacts: + def test_forget_window_drops_controller_and_artifacts(self, window): + session = window["window"] + wid = 424242 + + class _Ctrl: + closed = False + window_id = wid + + def close(self): + _Ctrl.closed = True + + session.register_window_controller(wid, _Ctrl()) + session._action_artifacts[(wid, "Correct Drift")] = {"selector": None} + assert wid in session._window_controllers + + session._forget_window(wid) + assert wid not in session._window_controllers, "controller not dropped" + assert _Ctrl.closed, "controller.close() was never called" + assert not [k for k in session._action_artifacts if k[0] == wid], ( + "action artifacts survived the window") From a194dd81abae99b20e310a7d761417130c89f761 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 29 Jul 2026 22:45:37 -0500 Subject: [PATCH 16/38] refactor(renderer): the Segment caret is one slider and a button Per plan section 0.9a, after "way too complicated. Too many options. Information overload." The Classical default face goes from 17 interactive elements to 7 -- and from 13 actual knobs to THREE: the sensitivity slider, "Find in all frames", and a collapsed Advanced. The spec pins the 7 and asserts 17 named testids are absent from the DOM, so it cannot quietly drift back. Everything moved, nothing deleted: min_size (with its floor warning now directly under the field it explains rather than shouting from the front), max_size, split/separation/smoothing, threshold, pre-blur, rolling ball, local window, dark-particles, store-outlines, link-tracks, the histogram, and Commit Frame. Grouped under quiet section labels. params() is byte-identical and no Python changed. Two further removals, made deliberately rather than demoted: * `+ add class` is GONE. It was permanently disabled with "not wired yet" on it -- pure noise on a face this redesign had just emptied, advertising something that cannot happen. It comes back WITH a seg_add_class verb. * The floating brush strip is now scoped to the Scribble tab. It floats OVER the image, so on Classical -- where there is nothing to paint -- it was chrome covering the data for no reason. The spec asserts it is absent on Classical, appears on Scribble, and goes away again on the way back. A real bug fixed on the way (predates this change, but the redesign makes the disclosure the main interaction so it would have bitten immediately): the caret's placement layout-effect only ran when FloatingToolbar itself re-rendered, but the caret's height changes from the WIZARD's own state, and a child's setState does not re-render its parent. So placement went stale and the caret jumped on some later unrelated render -- landing between a mousedown and a mouseup, which makes the browser emit no click at all. The symptom was "every other click on the caret does nothing". Fixed with a ResizeObserver on the caret box. typecheck + build clean; 5/5 segment_wizard.spec.ts; fit_wizard, drift_wizard and play_no_caret_loops all still pass. --- .../src/components/FloatingToolbar.tsx | 27 +- .../renderer/src/components/SegmentWizard.tsx | 454 +++++++++++------- electron/tests/segment_wizard.spec.ts | 178 ++++++- 3 files changed, 459 insertions(+), 200 deletions(-) diff --git a/electron/src/renderer/src/components/FloatingToolbar.tsx b/electron/src/renderer/src/components/FloatingToolbar.tsx index 20e8ebfb..ae96530a 100644 --- a/electron/src/renderer/src/components/FloatingToolbar.tsx +++ b/electron/src/renderer/src/components/FloatingToolbar.tsx @@ -142,7 +142,10 @@ export function FloatingToolbar({ // room again. const wr = winRect ?? { x: 0, y: 0, w: 0, h: 0 } const area = areaSize ?? { w: 100000, h: 100000 } - React.useLayoutEffect(() => { + // Held in a ref so the ResizeObserver below always runs the LATEST closure — + // `wr`/`area` change on every window move and resize. + const place = React.useRef<() => void>(() => {}) + place.current = () => { if (!openName) return const el = caretWrapRef.current?.firstElementChild as HTMLElement | null if (el) { @@ -157,7 +160,27 @@ export function FloatingToolbar({ next = wr.x + wr.w + CARET_GAP + cw <= area.w ? 'right' : 'left' } setPlacement(p => (p === next ? p : next)) - }) + } + React.useLayoutEffect(() => { place.current() }) + + // A caret's height can change WITHOUT this component re-rendering: a wizard's + // disclosure (Segment's `▸ Advanced`, a growing class list, a result note) is + // the WIZARD's own React state, and a child's state update does not re-render + // its parent. The layout effect above then never re-runs, the placement stays + // stale, and the caret finally JUMPS on some unrelated later render. + // + // That is not merely cosmetic: a jump that lands between a mousedown and a + // mouseup means the two land on DIFFERENT elements, so the browser emits no + // `click` at all. The control takes focus and silently does nothing, and the + // next click works — "every other click is ignored". Observing the caret's own + // box is what makes the placement track its content. + React.useLayoutEffect(() => { + const el = caretWrapRef.current?.firstElementChild as HTMLElement | null + if (!el || typeof ResizeObserver === 'undefined') return + const ro = new ResizeObserver(() => place.current()) + ro.observe(el) + return () => ro.disconnect() + }, [openName]) React.useEffect(() => { if (!openName) return diff --git a/electron/src/renderer/src/components/SegmentWizard.tsx b/electron/src/renderer/src/components/SegmentWizard.tsx index 87099b6c..134d9d4b 100644 --- a/electron/src/renderer/src/components/SegmentWizard.tsx +++ b/electron/src/renderer/src/components/SegmentWizard.tsx @@ -2,41 +2,57 @@ * SegmentWizard.tsx — the Segment Particles caret (`seg_` staged actions, * backend: spyde/actions/particles_action.py; plan §B7). * - * A WIDE 2-COLUMN caret (330 px). Left column = the parameters you turn, right - * column = the feedback that tells you whether turning them helped: + * ONE KNOB AND A BUTTON. The caret used to show ~15 controls at once (a + * sensitivity slider, min-size, three checkboxes, a `▸ more` block of ten more + * parameters, a histogram, a stats line, the class list, three buttons and two + * status lines) and the verdict on it was "information overload". The task is + * "find the particles"; everything else is tuning for someone who already knows + * the answer is wrong. * - * ┌ Segment Particles ─────────────── ✕ ┐ - * │ [Classical] [Scribble] [Prompt] │ - * ├── params ──────────┬── feedback ─────┤ - * │ Sensitivity ▓▓▓▓░ │ SIZE nm² histo │ - * │ Min size 24 │ ▁▃▅█▆▃▁ │ - * │ Split on │ 212 · med 96 │ - * │ Store masks off ├── classes ──────┤ - * │ │ ■ particle 1204 │ - * ├──────────────────────────────────────┤ - * │ [Train] [Run all] │ - * └──────────────────────────────────────┘ + * ┌ Segment Particles ──────── ✕ ┐ + * │ [Classical] [Scribble] [Prompt]│ + * │ Fewer ────●──── More │ + * │ 6 particles on this frame │ + * │ [ Find in all frames ] │ + * │ ▸ Advanced │ + * └────────────────────────────────┘ * - * Three things here are load-bearing, none of them cosmetic: + * Four things here are load-bearing, none of them cosmetic: * - * 1. **Sensitivity is the headline control and `min_size` sits next to it.** - * Measured (plan §0.9), not taste: teaching the classifier faint contrast - * buys +1 true particle and 25 spurious ones, and `min_size=10` removes 24 - * of the 25. The classifier is not what buys specificity — the size filter - * is. Two coupled knobs in separate tabs would be tuned against each other - * blind, so they are adjacent and both above the fold. + * 1. **Sensitivity is the ONLY control on the default face.** Measured (plan + * §0.9), not taste: teaching the classifier faint contrast buys +1 true + * particle and 25 spurious ones, and `min_size=10` removes 24 of the 25. + * But the floor is applied by the BACKEND unconditionally, so the user does + * not have to know that — `min_size` is a recovery knob, not a tuning knob, + * and it lives in Advanced next to the floor warning that explains it. * * 2. **The EFFECTIVE `min_size` is what is shown.** The backend floors it and * reports the floored value + a flag in every `seg_preview`; the caret snaps * its field to that value rather than leaving the user's 0 on screen while a * 10 ran. Showing a number different from the one that ran is exactly the - * failure `SegmentParams` refuses for `local_size`. + * failure `SegmentParams` refuses for `local_size`. The warning that explains + * the snap renders INSIDE Advanced, immediately under the field it is about — + * as a block on the primary face it was a large orange alarm for a parameter + * nobody should normally touch. * - * 3. **Per-class labelled-pixel counts are the point of the class list.** - * Under-training a class is *the* failure mode and these counts are how you - * notice; a class below `LOW_PIXELS` is dimmed and flagged so "the preview - * got worse" pushes you toward painting another example instead of toward - * the sensitivity slider, which cannot fix a missing example. + * 3. **Per-class labelled-pixel counts are the point of the class list, and the + * class list is the SCRIBBLE tab's business.** Under-training a class is *the* + * failure mode and these counts are how you notice; a class below `LOW_PIXELS` + * is dimmed and flagged so "the preview got worse" pushes you toward painting + * another example instead of toward the sensitivity slider, which cannot fix a + * missing example. On the Classical tab there is nothing to train, so the list + * is not shown there. + * + * 4. **Nothing was deleted, only demoted.** Every control that left the primary + * face is inside `▸ Advanced` and sends the identical action with the + * identical payload — `params()` is unchanged and the backend schema is + * untouched. Advanced is collapsed by default and remembers its state for the + * session (module-scope, not per-window: it is a preference about the UI, not + * about a dataset). The one place Advanced is tab-scoped is the classical + * MASK block (threshold / pre-blur / rolling ball / local window / dark), and + * that is correctness rather than tidiness: the scribble engine hands + * `split_instances` a probability map thresholded at 0.5 and never reads + * them, so on the Scribble tab they would be knobs that do nothing. * * The brush swatches / size / eraser are NOT here — they live on the floating * ClassStrip next to the plot (plan B0), because while painting you are looking @@ -122,6 +138,11 @@ const DEFAULTS: SegSaved = { // closing the caret to look at the frame doesn't lose the tuning. const _segStore = new Map() +// Whether `▸ Advanced` is open. Module scope, NOT per-window and NOT persisted +// to disk: a user who opened it once is mid-tuning and wants it open on the next +// caret too, but a fresh session starts calm again. +let _advancedOpen = false + interface Preview { frame: number count: number @@ -131,6 +152,10 @@ interface Preview { minSize: number floored: boolean elapsedMs: number + /** Monotonic per-caret preview counter. The COUNT is not a reliable "did it + * re-run" signal (two sensitivities can find the same number of particles), + * so the caret publishes this as `data-seq` for the e2e to poll instead. */ + seq: number } export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPos }: Props) { @@ -154,7 +179,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const [brush, setBrush] = React.useState(saved.brush) const [activeClass, setActiveClass] = React.useState(saved.activeClass) const [eraser, setEraser] = React.useState(saved.eraser) - const [more, setMore] = React.useState(false) + const [advanced, setAdvanced] = React.useState(_advancedOpen) // Backend-owned state (never edited here, only rendered). const [classes, setClasses] = React.useState([]) @@ -167,8 +192,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo // whose "N particles on frame M" status overwrites it within milliseconds — // so a transient status is a report the user never gets to read. const [trainReport, setTrainReport] = React.useState(null) - const [status, setStatus] = React.useState( - 'Tune on the displayed frame, then Run all.') + const [status, setStatus] = React.useState('Drag Fewer / More, then find in all frames.') const vals = React.useRef(saved) vals.current = { @@ -178,7 +202,16 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo } React.useEffect(() => { _segStore.set(windowId, vals.current) }) - /** The backend's parameter names (`particles_action.DEFAULTS` keys). */ + // Mirror the disclosure into module scope from an EFFECT, never from inside + // the state updater: React may invoke an updater more than once per dispatch + // (StrictMode's double-invoke, the eager-bailout probe, a replayed queue), so + // a write in there is not a "toggle once" — it is a toggle per invocation, and + // the disclosure sticks open. + React.useEffect(() => { _advancedOpen = advanced }, [advanced]) + const toggleAdvanced = () => setAdvanced(a => !a) + + /** The backend's parameter names (`particles_action.DEFAULTS` keys). Demoting + * a control into Advanced changes NOTHING here — same keys, same shape. */ const params = (): Record => { const v = vals.current return { @@ -227,11 +260,12 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const areas = Array.isArray(d.areas) ? (d.areas as number[]) : [] const eff = Number(d.min_size ?? vals.current.minSize) const floored = Boolean(d.min_size_floored) - setPreview({ + setPreview(prev => ({ frame: Number(d.frame ?? 0), count: Number(d.count ?? 0), areas, median: Number(d.median_area ?? 0), units: String(d.units ?? 'px'), minSize: eff, floored, elapsedMs: Number(d.elapsed_ms ?? 0), - }) + seq: (prev?.seq ?? 0) + 1, + })) // Never leave a number on screen that is not the one that ran. Snapping is // loop-free: the backend coerces the snapped value to itself, so the next // tune round-trips unchanged. @@ -239,7 +273,9 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo setMinSize(eff) vals.current = { ...vals.current, minSize: eff } } - setStatus(`${d.count} particles on frame ${d.frame} · ${d.elapsed_ms} ms`) + // The COUNT now has its own line above the button, so the footer carries + // only what that line does not: which frame, and how long it took. + setStatus(`Frame ${d.frame} · ${d.elapsed_ms} ms`) }) useWizardEvent('spyde:seg_trained', windowId, (d) => { @@ -297,6 +333,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const labelledPixels = classes.reduce((a, c) => a + (c.pixels || 0), 0) const isPrompt = method === 'prompt' + const isScribble = method === 'scribble' const canTrain = labelledPixels > 0 && !isPrompt const canRun = !isPrompt && (method !== 'scribble' || trained) @@ -310,13 +347,16 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo } const areaUnits = preview ? `${preview.units}²` : 'px²' + const countText = preview + ? `${preview.count} particle${preview.count === 1 ? '' : 's'} on this frame` + : 'no preview yet' return ( <> )} - {method === 'scribble' && !trained && ( -
- Paint with the strip on the image — include at least one FAINT - particle — then Train. + + {/* ── Classical: one knob ──────────────────────────────────────────── */} + {method === 'classical' && ( +
+ Fewer + {/* No numeric readout: "0.50" of what? The endpoints ARE the units. + The value is still stored and sent unchanged. */} + { const n = Number(e.target.value); setSensitivity(n); tune() }} /> + More
)} - {trainReport && ( + + {/* ── Scribble: the class list + Train ─────────────────────────────── */} + {isScribble && ( + <> + {!trained && ( +
+ Paint with the strip on the image — include at least one FAINT + particle — then Train. +
+ )} + {trainReport && ( +
{trainReport}
+ )} + { setEraser(false); setActiveClass(id) }} /> + + + )} + + {/* The train report is the direct answer to the Train button, so on any + other tab it would be a report with no question. */} + {!isScribble && trainReport && (
{trainReport}
)} -
- {/* ── left: params ─────────────────────────────────────────────── */} -
-
params
- - n.toFixed(2)} /> - +
+ {countText} +
+ + + + {/* ── everything else ──────────────────────────────────────────────── */} + + + {advanced && ( +
+
size filter
+ {/* The floor warning belongs HERE, under the field it explains — on + the primary face it was a large orange alarm about a parameter + the backend already fixed on the user's behalf. */} {preview?.floored && (
floored to {preview.minSize} px — at 0 the split returns background speckle as particles
)} + + + + +
splitting
- - - - - {more && ( -
+ + + + + n.toFixed(1)} /> + + + + {/* CLASSICAL ONLY, and not merely for space: these build the + classical MASK. The scribble engine hands `split_instances` a + probability map thresholded at 0.5, so it never reads threshold / + sensitivity / gaussian / rb_kernel / invert / local_size — see + `spyde/particles/classical.py::split_instances`. Rendering them + on the Scribble tab would be six knobs that do nothing, which is + the overload complaint in miniature. They keep their stored + values and are still sent in every payload. */} + {method === 'classical' && ( + <> +
detection
- - - - - - - - - - - - - - - {pair && ( -
- first pair · dy {pair.dy.toFixed(2)} · dx {pair.dx.toFixed(2)} px -
- )} + - + {progress && (
@@ -249,84 +242,125 @@ export function DriftWizard({ caretPos, windowId, sendAction, onClose }: Props)
)} -
- - {/* Apply adds the LAZY corrected node (map_blocks over the source's own - chunking) — nothing is copied, so this is cheap even on a multi-GB - movie. Disabled until there is a model to apply. */} - - -
-
- {nFrames ? `${nFrames} frames` : 'reading the movie…'} - {solved ? ' · solved' : ''} -
+ {result && ( + <> +
+ {result.cancelled ? '◐' : '✓'}{' '} + {Number.isFinite(result.gain) ? `${result.gain.toFixed(1)}x sharper · ` : ''} + {result.maxShift.toFixed(1)} px drift + {result.rejected ? ` · ${result.rejected} bad frames` : ''} +
+
+ {/* Apply adds the LAZY corrected node (map_blocks over the source's + own chunking) — nothing is copied, so this is cheap even on a + multi-GB movie. */} + + +
+ + )} + + setAdvanced(v => !v)}> + Boolean(UNAVAILABLE[METHOD_OF[t]])} + testid={(t) => `drift-tab-${METHOD_OF[t]}`} + /> + {/* Both stubs are locked, so this names them rather than waiting for a + click that cannot happen. Text is the backend's own wording. */} +
+ {locked ?? 'Rigid+Affine and Non-rigid are not implemented in spyde.drift yet.'} +
+ + + + + + + + )} , so `selectOption`/`inputValue` do nothing + // here — click the trigger, then the option, and read `data-value`. + await expect(page.getByTestId('drift-nonrigid-model')) + .toHaveAttribute('data-value', 'scan_knot') + await page.getByTestId('drift-nonrigid-model').click() + await page.getByTestId('drift-nonrigid-model-opt-dense').click() + await expect(page.getByTestId('drift-nonrigid-model')) + .toHaveAttribute('data-value', 'dense') + await page.getByTestId('drift-wizard') + .screenshot({ path: `${SHOTS}/11-nonrigid-dense.png` }) + ctx.assertNoJsErrors() +}) + +test('a Non-rigid solve actually runs the non-rigid fit, not a quiet fallback', async () => { + const { page, backend } = ctx + // The caret showing "Non-rigid" proves nothing about what the SOLVER did: + // drift_run falls back to rigid whenever the fit cannot run (no torch, OOM, + // a device that will not take the graph) and that fallback is deliberate. + // So assert on the backend's own report of the KIND it produced. + await page.getByTestId('drift-solve').click() + await backend.waitForLog('non-rigid fit:', 120_000) + const done = await backend.waitForLog('non-rigid fit done:', 300_000) + expect(String(done), 'the solve fell back to rigid').toContain('kind=dense') + await page.screenshot({ path: `${SHOTS}/12-nonrigid-solved.png` }) + expect(backendErrorLines(backend), 'backend errors during the non-rigid solve') + .toEqual([]) + ctx.assertNoJsErrors() +}) + test('dragging the ROI re-solves the preview and moves the sharpness number', async () => { const { page } = ctx const sig = sigWindow(page) diff --git a/spyde/actions/drift_action.py b/spyde/actions/drift_action.py index 98c74d63..a299a476 100644 --- a/spyde/actions/drift_action.py +++ b/spyde/actions/drift_action.py @@ -791,11 +791,15 @@ def _solve_nonrigid_step(wiz, p: dict, rigid_model, get_frame, n_frames: int, """ from spyde.drift import solve_nonrigid + t0 = time.monotonic() stack = _decimated_stack(get_frame, n_frames, cancel=cancel) if stack.shape[0] < 2: return rigid_model + log.info("[drift] non-rigid fit: %s, %d frames at %dx%d (decimated from %s)", + p["nonrigid_model"], stack.shape[0], stack.shape[1], stack.shape[2], + "the movie") try: - return solve_nonrigid( + model = solve_nonrigid( stack, model=str(p["nonrigid_model"]), rigid=rigid_model, @@ -806,6 +810,14 @@ def _solve_nonrigid_step(wiz, p: dict, rigid_model, get_frame, n_frames: int, provenance={"action": "Drift Correction (non-rigid)", "params": dict(p)}, ) + # Say what actually happened, with the KIND. Without this there is no + # way — from a log, a test, or the UI — to tell a real non-rigid solve + # from one that fell back to rigid, and "the caret said non-rigid" is + # exactly the claim that must not be taken on trust. + log.info("[drift] non-rigid fit done: kind=%s in %.2fs (mse %.4g)", + model.kind, time.monotonic() - t0, + model.extra.get("final_mse", float("nan"))) + return model except Exception as exc: # torch missing, CUDA OOM, a device that will not take the graph — all # end here. Say so and keep the rigid model rather than failing the run. From bc39fe8fb7fd722f68588be5274d4866687a0ebb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 1 Aug 2026 06:34:24 -0500 Subject: [PATCH 28/38] fix(seg): the drift-padded border segments as a giant particle when inverting Found reviewing the branch. `classical._prepare` fills NaN with the finite MINIMUM before filtering -- correct, and the docstring called it "the one value guaranteed not to threshold as a particle". That guarantee is false whenever `invert` is set. `invert` (dark particles on a bright background) maps x -> -x AFTER the fill, so the minimum becomes the MAXIMUM: the drift-padded border ends up the brightest region in the image that is actually thresholded. Measured on a 96x96 frame with a 12 px NaN border: the border reads as the frame maximum and `segment_frame` puts a 240 px instance on it. This is precisely the failure `spyde.drift.warp` names as the most likely integration bug in the feature -- "a threshold applied to NaN ... invents a large 'particle' along the edge that then nucleates a spurious track" -- so a drift-corrected in-situ movie of dark particles would have grown a spurious edge track through the linker, in every frame, silently. The fill cannot simply move after the invert: it has to happen BEFORE filtering because skimage filters propagate NaN outward and would erase a band of real data. So the fill takes the polarity of the thresholded image -- finite MAXIMUM when inverting, finite minimum otherwise -- which leaves the padding at the minimum after inversion, exactly what the rest of the function already assumes. Note the asymmetry that hid this: the scribble and CNN engines both force invalid pixels to zero probability in every class (`proba[:, ~prepared.valid] = 0`), so neither could label the padding. Only the classical path relied on the fill VALUE alone, and only in one polarity. Regression test covers both polarities: the padding must not be the brightest region, and no instance may be found on it. --- spyde/particles/classical.py | 28 +++++++++--- spyde/tests/migrated/test_particles_core.py | 48 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/spyde/particles/classical.py b/spyde/particles/classical.py index a7eae0aa..613760f6 100644 --- a/spyde/particles/classical.py +++ b/spyde/particles/classical.py @@ -165,11 +165,24 @@ def _rolling_ball(img: np.ndarray, radius: int) -> np.ndarray: def _prepare(frame: np.ndarray, p: SegmentParams) -> np.ndarray: """Rolling-ball → gaussian → invert, returning float32. - NaN is filled with the finite minimum BEFORE filtering. A drift-corrected - frame carries a NaN border (``spyde.drift.warp``), and every skimage filter - propagates NaN outward, which would erase a band of real data around the edge. - Filling with the minimum makes the padding read as background — the one value - guaranteed not to threshold as a particle. + NaN is filled BEFORE filtering. A drift-corrected frame carries a NaN border + (``spyde.drift.warp``), and every skimage filter propagates NaN outward, + which would erase a band of real data around the edge. + + **The fill polarity follows ``invert``.** The padding has to read as + background in the image that is finally THRESHOLDED, and `invert` maps + ``x -> -x`` after this fill — so filling with the finite minimum when + inverting makes the border the brightest thing in the frame, and it + segments as one enormous particle hugging the edge. Measured on a 96² + frame with a 12 px NaN border: a 240 px instance on the padding, with the + border reading as the frame maximum. + + That is the failure ``spyde.drift.warp`` names as the most likely + integration bug in the whole feature ("a threshold applied to NaN … invents + a large 'particle' along the edge that then nucleates a spurious track"), + so it is fixed here rather than papered over downstream. Filling with the + finite MAXIMUM when ``invert`` is set leaves the padding at the minimum + after inversion, which is what the rest of this function already assumes. """ from scipy.ndimage import gaussian_filter from skimage.util import invert as sk_invert @@ -179,7 +192,10 @@ def _prepare(frame: np.ndarray, p: SegmentParams) -> np.ndarray: if bad.any(): finite = img[~bad] img = img.copy() - img[bad] = finite.min() if finite.size else 0.0 + if finite.size: + img[bad] = finite.max() if p.invert else finite.min() + else: + img[bad] = 0.0 if p.rb_kernel > 0: img = _rolling_ball(img, p.rb_kernel) diff --git a/spyde/tests/migrated/test_particles_core.py b/spyde/tests/migrated/test_particles_core.py index 4476e2d7..dd0e8689 100644 --- a/spyde/tests/migrated/test_particles_core.py +++ b/spyde/tests/migrated/test_particles_core.py @@ -736,3 +736,51 @@ def test_to_csv_writes_a_header_and_every_row(self, tmp_path): def test_repr_is_informative(self): assert "particles over" in repr(_build()) + + +class TestNaNBorderPolarity: + """The drift-padded border must never segment — in EITHER polarity. + + `spyde.drift.warp` names this as the most likely integration bug in the + feature: "a threshold applied to NaN ... invents a large 'particle' along + the edge that then nucleates a spurious track". `_prepare` fills NaN before + filtering (filters propagate NaN outward and would erase real data), so the + fill value has to read as background in the image that is finally + THRESHOLDED — and `invert` flips that image after the fill. + + Filling with the finite minimum while inverting made the padding the + BRIGHTEST region: measured, a 240 px instance sitting on the border of a + 96x96 frame. + """ + + @staticmethod + def _frame(dark_particles: bool): + import numpy as _np + h = w = 96 + bg, fg = (200.0, 40.0) if dark_particles else (40.0, 200.0) + img = _np.full((h, w), bg, _np.float32) + y, x = _np.mgrid[0:h, 0:w] + for cy, cx in ((40, 40), (65, 70)): + img[(y - cy) ** 2 + (x - cx) ** 2 < 8 ** 2] = fg + img[:12, :] = _np.nan # the drift-corrected border + img[:, :12] = _np.nan + return img + + @pytest.mark.parametrize("invert", [False, True]) + def test_the_padded_border_never_becomes_a_particle(self, invert): + from spyde.particles.classical import SegmentParams, _prepare, segment_frame + + img = self._frame(dark_particles=invert) + p = SegmentParams(invert=invert, rb_kernel=0, gaussian=0) + + prep = _prepare(img, p) + border = prep[:12, :12] + assert border.mean() < prep.max() - 1e-3, ( + f"invert={invert}: the NaN padding is the brightest region in the " + f"thresholded image, so it segments as one huge edge particle") + + labels = segment_frame(img, p) + on_border = sorted(set(np.unique(labels[:12, :12])) - {0}) + assert not on_border, ( + f"invert={invert}: instances {on_border} were found on the NaN " + f"border, which is padding and not data") From 6d4e6dcf0dc974588e544d6ff789ffcc05ee3db9 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 1 Aug 2026 07:10:22 -0500 Subject: [PATCH 29/38] perf(dask): the navigator fill submitted ONE task at a time -- 970 round trips -> 2 Reported as "why is the computing navigator using a single worker": the dashboard showed the distributed backend live and the cluster idle, tasks arriving one by one, and it got worse with dataset size (an 800 GB 4D-STEM scan and a long movie both). Not placement. `dispatch_chunks` tops up on EVERY completion, so on the UNPINNED lane `lane_cap - outstanding` is 1 in steady state and n = min(submit_batch, len(pending), lane_cap - outstanding[lane]) collapses to n = 1. `submit_batch=8` only ever applied to the first fill. Every chunk after that was its own blocking scheduler round trip with the GIL held in the client process -- precisely the cost #95 existed to remove, reintroduced through the back door by me, and scaling with chunk count. Hence "only big datasets". The window was never justified on this lane: * it did not measure as backpressure -- benchmarks.md has bounded at 46-50 s against unbounded at 50.2 s on the same 977-chunk movie, within noise; * distributed >= 2022.3 queues root tasks at the scheduler, which is this window's stated job done in the right process without our GIL; * there is no placement decision to make when nothing is pinned, so there is nothing for a window to balance. Prime with one small batch, then send the rest in a single submit. 977 chunks: one-at-a-time (the bug) 970 submits first 656 ms 12.40 s all-at-once 1 submit first 1292 ms 5.03 s prime + bulk (shipped) 2 submits first 45 ms 5.28 s 485x fewer round trips, 14.6x faster to first paint, 2.3x faster overall. The middle row is why this is two submits and not one: all-at-once is fastest in total but DOUBLES time-to-first-chunk, because the client serialises the whole graph before anything returns -- and the progressive fill exists so the navigator starts filling immediately. Measuring only wall-clock would have shipped the wrong variant. THE DUAL-LANE PATH IS UNCHANGED and keeps its window: there a completion genuinely pulls the next chunk so a ~30x-faster GPU lane and the CPU lane drain one pool and finish together. That is real work stealing the scheduler cannot do, and it is why this module exists. `batch_unpinned=False` restores the old behaviour per call, which is what the benchmark's control arm uses. test_chunk_dispatch_guard asserted `max_in_flight <= 4` as backpressure -- a fence I put there myself. Retired with the argument against it written out in the test, and replaced by an assertion on the submit COUNT; the scheduler owns memory backpressure now. 377 dispatch/nav/progressive tests pass. --- benchmarks.md | 52 ++++++++ spyde/compute_dispatch.py | 46 ++++++- spyde/tests/benchmark_nav_submit_batching.py | 112 ++++++++++++++++++ .../migrated/test_chunk_dispatch_guard.py | 43 +++++-- .../migrated/test_progressive_windowed.py | 81 +++++++++---- 5 files changed, 300 insertions(+), 34 deletions(-) create mode 100644 spyde/tests/benchmark_nav_submit_batching.py diff --git a/benchmarks.md b/benchmarks.md index 807f59a3..09c65ad3 100644 --- a/benchmarks.md +++ b/benchmarks.md @@ -1692,3 +1692,55 @@ Fine for a resampled display frame; do not build an equality test on it. MPS is deliberately NOT selected by `device=None`. The win here is one fused kernel, and an unsolicited MPS submission contends for the shared device lock that the neural/scribble paths hold. Opt in with `device="mps"`. + +### The navigator fill was submitting ONE task at a time (2026-08-01) + +Reported as "why is the computing navigator using a single worker" — the dask +dashboard showed the distributed backend live, the cluster idle, and tasks +arriving one by one. It got worse with dataset size (an 800 GB 4D-STEM scan and +a long movie both), which is the tell. + +Not a placement bug. `dispatch_chunks` tops up on EVERY completion, so on the +**unpinned** lane `lane_cap - outstanding` is 1 in steady state and + + n = min(submit_batch, len(pending), lane_cap - outstanding[lane]) + +collapses to **n = 1**. `submit_batch=8` only ever applied to the first fill. +Every subsequent chunk was its own blocking scheduler round trip with the GIL +held in the client process — the exact cost #95 was written to remove, +reintroduced through the back door, and scaling with chunk count. + +The window was never justified for this lane anyway: + +* **It did not measure as backpressure.** Bounded 46-50 s vs unbounded 50.2 s on + the same 977-chunk movie (above) — within noise. +* **`distributed` >= 2022.3 already does it**, queuing root tasks at the + scheduler. We were duplicating the scheduler's job, worse, in our process. +* There is **no placement decision** to make on an unpinned lane, so there is + nothing for a window to balance. + +Fixed by priming with one small batch and then sending the rest in a single +submit. 977 chunks, 6 workers x 2 threads: + +| | submits | first chunk | total | +|---|---|---|---| +| one-at-a-time (the bug) | **970** | 656 ms | 12.40 s | +| all-at-once | 1 | **1292 ms** | 5.03 s | +| **prime + bulk (shipped)** | **2** | **45 ms** | **5.28 s** | + +**485x fewer round trips, 14.6x faster to first paint, 2.3x faster overall** — +and note the middle row. Going straight to one all-at-once submit is fastest in +total but DOUBLES time-to-first-chunk, because the client serialises the whole +graph before anything comes back. The progressive fill exists so the navigator +starts filling immediately, so that regression matters as much as the total; the +priming batch buys it back for one extra round trip. Measuring only wall-clock +would have shipped the wrong one. + +On a synthetic graph each round trip is cheap, so these totals UNDERSTATE the +real gain: on a real memmap-backed graph a submit is ~14 ms (above), i.e. ~13.6 s +of GIL-held client time for 977 chunks. + +**The dual-lane path keeps its window.** There a completion genuinely pulls the +next chunk so a ~30x-faster GPU lane and the CPU lane drain one pool and finish +together — real work stealing the scheduler cannot do, and the reason this +module exists. Only the unpinned lane changed. diff --git a/spyde/compute_dispatch.py b/spyde/compute_dispatch.py index d50e88f7..157f7fa2 100644 --- a/spyde/compute_dispatch.py +++ b/spyde/compute_dispatch.py @@ -163,6 +163,7 @@ def dispatch_chunks( fill_value=np.nan, stall_timeout_s: float = 600.0, submit_batch: int = 8, + batch_unpinned: bool = True, label: str = "dispatch", on_chunk_done=None, lane_default_mode: str = "one", @@ -313,7 +314,17 @@ def _band_key(i): completed_futures: list = [] # held until the end — see module docstring outstanding = {"gpu": 0, "cpu": 0} state = {"completed": 0, "error": None, "last_progress": time.time(), - "lane_done": {"gpu": 0, "cpu": 0}, "mem_hot": False} + "lane_done": {"gpu": 0, "cpu": 0}, "mem_hot": False, + # The unpinned lane PRIMES with one small batch before sending the + # rest in a single submit. Measured on 977 chunks: going straight + # to one all-at-once submit cut round trips 970 -> 1 and total + # 12.1 s -> 5.0 s, but DOUBLED time-to-first-chunk (642 -> 1292 ms) + # because the client serialises the whole graph before anything + # comes back. The progressive fill exists so the navigator starts + # filling immediately, so that regression matters as much as the + # total. Priming keeps the fast first paint and still costs only + # two submits. + "primed": False} def _submit_next(lane): """Top up `lane` with a batch of pending chunks (lock held). @@ -340,7 +351,38 @@ def _submit_next(lane): # already folded into `caps` above; shadowing it here would read as if # the parameter were being recomputed per top-up. lane_cap = _lane_cap(caps[lane], lane_threads[lane], state["mem_hot"]) - n = min(submit_batch, len(pending), lane_cap - outstanding[lane]) + if unpinned and lane == "cpu" and batch_unpinned and state["primed"]: + # ONE submit for the whole job. There is no placement decision to + # make on the unpinned lane and therefore nothing for a window to + # balance — so the window bought nothing and cost a blocking, + # GIL-held scheduler round trip PER CHUNK. + # + # It was worse than "no benefit": `_submit_next` runs on every + # completion, so `lane_cap - outstanding` is 1 in steady state and + # `min(submit_batch, …)` collapsed to ONE task per submit. The + # batching applied only to the very first fill. On a 977-chunk + # movie that is 977 round trips at ~14 ms with the GIL held — + # exactly the cost this module was written to remove, reintroduced + # through the back door. It scaled with dataset size, so it looked + # like "big datasets use one worker". + # + # Unbounded is measured-safe here: benchmarks.md has bounded at + # 46-50 s against unbounded at 50.2 s on the 977-chunk movie — + # within noise. And distributed >=2022.3 QUEUES root tasks at the + # scheduler (worker-saturation), which is this window's stated job + # done properly, in the right process, without our GIL. + # + # The DUAL-LANE path below keeps its window: there a completion + # genuinely pulls the next chunk so a ~30x-faster GPU lane and the + # CPU lane drain one pool and finish together. That is real work + # stealing the scheduler cannot do, and it is why this module + # exists at all. + n = len(pending) + elif unpinned and lane == "cpu" and batch_unpinned: + n = min(submit_batch, len(pending)) # priming batch + state["primed"] = True + else: + n = min(submit_batch, len(pending), lane_cap - outstanding[lane]) if n <= 0: return idxs = [pending.popleft() for _ in range(n)] diff --git a/spyde/tests/benchmark_nav_submit_batching.py b/spyde/tests/benchmark_nav_submit_batching.py new file mode 100644 index 00000000..b7f134a4 --- /dev/null +++ b/spyde/tests/benchmark_nav_submit_batching.py @@ -0,0 +1,112 @@ +""" +benchmark_nav_submit_batching.py — how many times do we call the scheduler? + +Run directly:: + + python -m spyde.tests.benchmark_nav_submit_batching + python -m spyde.tests.benchmark_nav_submit_batching --chunks 977 + +The navigator fill on a big dataset looked like "the cluster is using one +worker". It was not a placement problem: `dispatch_chunks` tops up on EVERY +completion, so on the unpinned lane `lane_cap - outstanding` is 1 in steady +state and the batch size collapsed to ONE task per submit. `submit_batch=8` +only ever applied to the first fill. + +Each of those submits is a blocking scheduler round trip with the GIL held in +the client process, so the client — not the cluster — became the bottleneck, +and it got worse with more chunks. Hence "only big datasets". + +What this measures, and why not just wall-clock: on a synthetic cluster the +per-task WORK is trivial, so total time understates the problem. The honest +metric is **how many times we call `client.compute`** (each is a round trip +that scales with graph size), plus time-to-first-chunk, which is what the user +actually watches. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time + +import numpy as np + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--chunks", type=int, default=256) + ap.add_argument("--workers", type=int, default=6) + ap.add_argument("--threads", type=int, default=2) + ap.add_argument("--json", default=None) + a = ap.parse_args() + + import dask.array as da + from dask.distributed import Client, LocalCluster + import spyde.compute_dispatch as cd + + cluster = LocalCluster(n_workers=a.workers, threads_per_worker=a.threads, + processes=True, dashboard_address=None, silence_logs=50) + client = Client(cluster) + client.wait_for_workers(a.workers) + print(f"cluster: {a.workers} workers x {a.threads} threads " + f"nav chunks: {a.chunks}\n") + + # A 1-D navigator sum, one task per chunk — the movie shape. + arr = da.zeros((a.chunks, 64, 64), chunks=(1, 64, 64), dtype=np.float32) + nav = arr.sum(axis=(1, 2)) + + out: dict = {"chunks": a.chunks, "workers": a.workers, "threads": a.threads} + + # `submit_batch=1` reproduces the OLD steady-state behaviour exactly: the + # window refilled one task at a time. Comparing against it isolates the + # batching change from everything else in the dispatcher. + for label, kw in (("one-at-a-time (the bug)", dict(batch_unpinned=False, cap=8)), + ("batched (fixed)", dict())): + calls = {"n": 0} + real = client.compute + + def counting(x, *args, **kwargs): + if isinstance(x, (list, tuple)): + calls["n"] += 1 + return real(x, *args, **kwargs) + + client.compute = counting + first = {"t": None} + t0 = time.perf_counter() + + def assemble(res, sl, val): + if first["t"] is None: + first["t"] = time.perf_counter() - t0 + res[sl] = val + + try: + cd.dispatch_chunks(client, nav, 1, [], None, assemble=assemble, + fill_value=np.nan, label=label, + lane_default_mode="off", **kw) + finally: + client.compute = real + el = time.perf_counter() - t0 + print(f" {label:24} submits={calls['n']:>4} " + f"first-chunk={first['t'] * 1e3:>6.0f} ms total={el:>6.2f}s") + out[label] = {"submits": calls["n"], "first_ms": first["t"] * 1e3, + "total_s": el} + + a_, b_ = out["one-at-a-time (the bug)"], out["batched (fixed)"] + print(f"\n submits: {a_['submits']} -> {b_['submits']} " + f"({a_['submits'] / max(b_['submits'], 1):.0f}x fewer round trips)") + print(" NB each saved round trip is ~14 ms with the GIL held on a real " + "graph (benchmarks.md), which a synthetic graph does not charge us.") + + if a.json: + with open(a.json, "w", encoding="utf-8") as fh: + json.dump(out, fh, indent=2, default=float) + client.close() + cluster.close() + sys.stdout.flush() # _exit skips stdio flushing + sys.stderr.flush() + os._exit(0) + + +if __name__ == "__main__": + main() diff --git a/spyde/tests/migrated/test_chunk_dispatch_guard.py b/spyde/tests/migrated/test_chunk_dispatch_guard.py index 2d8778fb..57b32c58 100644 --- a/spyde/tests/migrated/test_chunk_dispatch_guard.py +++ b/spyde/tests/migrated/test_chunk_dispatch_guard.py @@ -249,18 +249,43 @@ def _wait(pred, timeout=10.0, required=True): class TestNavigatorFillThroughDispatcher: - def test_batched_submit_and_bounded_window(self): - """16 one-frame nav chunks on a 4-thread cluster: window = - max(4, 4 // 2) = 4, and each top-up is ONE submit call.""" + def test_batched_submit_prime_then_bulk(self): + """16 one-frame nav chunks: a small priming batch, then the rest — and + only a couple of scheduler round trips in total. + + This test used to assert ``max_in_flight <= 4`` as BACKPRESSURE, on the + reasoning that the navigator fill must not "submit all 977 up front". + That guard is retired deliberately, so here is the argument against my + own fence: + + * It did not measure as backpressure. benchmarks.md has the bounded + window at 46-50 s against unbounded at 50.2 s on the same 977-chunk + movie — within noise. The window never bought throughput or safety + that anyone demonstrated. + * ``distributed`` >= 2022.3 QUEUES root tasks at the scheduler + (worker-saturation), which is exactly this guard's job, done in the + right process, without holding OUR GIL. We were duplicating it badly. + * Keeping it was actively harmful: ``_submit_next`` runs on every + completion, so ``lane_cap - outstanding`` was 1 in steady state and + the batch collapsed to ONE task per submit — 970 blocking round trips + for 977 chunks, which scaled with dataset size and read to users as + "big datasets only use one worker". + + What replaces it: the assertion below that the whole job goes out in a + couple of submits, and the scheduler's own queuing for memory. The + DUAL-LANE path keeps its window (see the module docstring) because + there a completion genuinely pulls the next chunk so a ~30x-faster GPU + lane and the CPU lane finish together — that is real work stealing the + scheduler cannot do. + """ src, client, handle, seen, futs, max_in_flight = _run_nav_fill( nav=(16,), chunks=(1,)) assert len(seen) == 16 # every chunk streamed - assert len(client.created) == 16 - # The old loop made 16 submit calls (+1 for the duplicate whole-array - # graph) — one blocking scheduler round trip each. - assert client.submit_calls < 16 - # BACKPRESSURE: the navigator fill used to submit all 977 up front. - assert max_in_flight <= 4 + assert len(client.created) == 16 # one future per chunk + # The bug this replaced made ~one submit per chunk. + assert client.submit_calls <= 3, ( + f"{client.submit_calls} submits for 16 chunks — the unpinned lane " + f"is back to per-completion top-ups") np.testing.assert_array_equal(handle.result(), src.compute()) def test_result_is_assembled_not_recomputed(self): diff --git a/spyde/tests/migrated/test_progressive_windowed.py b/spyde/tests/migrated/test_progressive_windowed.py index d753479a..db51127f 100644 --- a/spyde/tests/migrated/test_progressive_windowed.py +++ b/spyde/tests/migrated/test_progressive_windowed.py @@ -44,6 +44,13 @@ def __init__(self, value_fn): def add_done_callback(self, cb): self._cbs.append(cb) + @property + def fired(self): + """Already completed. The tests fire ONE future to release the bulk + submit and then fire "the rest"; without this guard the first one runs + its done-callback twice and the assembly counts 17 chunks out of 16.""" + return self._done + def done(self): return self._done @@ -139,40 +146,66 @@ class _Sel: class TestWindowedProgressive: - def test_bounded_in_flight_and_assembly(self): + def test_primes_small_then_sends_the_rest_in_one_submit(self): + """The UNPINNED lane submits in TWO calls: a small priming batch, then + everything else. + + It used to keep a bounded in-flight window and top up on every + completion — which meant `lane_cap - outstanding` was 1 in steady state + and the batch collapsed to ONE task per submit, i.e. a blocking + GIL-held round trip per chunk. Measured on 977 chunks: 970 submits, + 12.4 s. There is nothing to balance on an unpinned lane (no placement + decision), so the window bought nothing; `distributed` queues root + tasks at the scheduler anyway. + + The priming batch is why this is two calls and not one: going straight + to a single all-at-once submit doubled time-to-first-chunk (642 -> + 1292 ms) because the client serialises the whole graph before anything + returns. Priming keeps the first paint fast — measured 45 ms, better + than the windowed version's 656 ms — for one extra round trip. + """ src, client, handle, seen = _run() - # 16 chunks total, but only the window (4) submitted up front... - assert len(client.created) == 4 - # ...and that whole window went out in ONE submit call, not four - # blocking scheduler round trips. - assert client.submit_calls == 1 - assert not handle.done() - # Completing futures tops up the window without ever exceeding it. - fired = 0 - while fired < len(client.created): - client.created[fired].fire() - fired += 1 - in_flight = len(client.created) - fired - assert in_flight <= 4 - _wait(lambda f=fired: len(client.created) > f or handle.done(), - timeout=2.0, required=False) - # Every chunk streamed a callback and the assembly equals the source. + # The priming batch only — the bulk submit follows the first completion + # (or the wait loop's next top-up), which is what keeps first paint fast. + primed = len(client.created) + assert primed <= 8, f"priming batch was {primed}, expected <= 8" + client.created[0].fire() + _wait(lambda: len(client.created) == 16, timeout=5.0) + assert client.submit_calls <= 2, ( + f"{client.submit_calls} submits for 16 chunks — the unpinned lane " + f"is back to per-completion top-ups") + for f in list(client.created): + if not f.fired: + f.fire() np.testing.assert_array_equal(handle.result(timeout=10.0), src.compute()) assert len(seen) == 16 - def test_cancel_stops_submission_and_outstanding(self): + def test_cancel_cancels_every_outstanding_chunk(self): + """Cancellation still stops the work. + + With the whole job submitted up front there is no pending queue left to + withhold, so "stop" means CANCELLING the outstanding futures rather than + declining to submit more. That is the property that actually matters — + a superseded VI stream must not keep computing through an ROI drag — + and dask cancels queued tasks it has not started. + """ src, client, handle, seen = _run() - assert len(client.created) == 4 + client.created[0].fire() # let the bulk submit go out + _wait(lambda: len(client.created) == 16, timeout=5.0) handle.cancel() # Cancel is IMMEDIATE (dispatch_chunks' on_start hook wakes the wait # loop) — a superseded VI stream must not keep computing through an ROI # drag while the next tick's stream is already running. - _wait(lambda: all(f.cancelled for f in client.created)) + # Every future that had NOT already completed. The one fired above to + # release the bulk submit is done, and a completed future is not + # cancellable — asserting "all cancelled" would just be wrong. + _wait(lambda: all(f.cancelled for f in client.created if not f.fired)) # Firing the cancelled futures must not submit more work. for f in list(client.created): - f.fire() + if not f.fired: + f.fire() _wait(handle.done) - assert len(client.created) == 4 + assert len(client.created) == 16, "firing cancelled futures resubmitted work" def test_stop_event_halts_topups(self): stop = threading.Event() @@ -181,7 +214,9 @@ def test_stop_event_halts_topups(self): for f in list(client.created): f.fire() _wait(handle.done) - assert len(client.created) == 4 # no top-ups after stop + # No BULK submit after the stop: only the priming batch was ever sent. + # (It was 4 when the lane kept a window; the priming batch is 8.) + assert len(client.created) <= 8, "work was submitted after the stop" def test_error_propagates_via_result(self): from spyde.drawing.update_functions import compute_with_live_buffer From cc938196e374b9138928052f326266174e6217c5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 1 Aug 2026 07:22:29 -0500 Subject: [PATCH 30/38] fix(nav): the threaded navigator fill never noticed the cluster arriving `_start_progressive_nav_compute` picks its path from `self.client` ONCE. The cluster takes ~10 s, so a file opened right after launch finds None and takes the threaded branch -- one background thread, one chunk at a time. That choice was then PERMANENT: however many workers registered a second later, the entire fill ran single-threaded. On a 977-frame movie that is the difference between seconds and minutes, and on the dashboard it looks like an idle cluster. Why it presented as a movie-only bug: a 4D-STEM scan goes through the nav-shape prompt, which is a human round trip, so its cluster is always up by the time the tree is built. A movie opens straight through and loses the race. Same code, different timing. The loop now re-checks each chunk and re-enters the dispatcher path once a client exists. Re-entering rather than switching in place is deliberate: the distributed branch owns cancellation, the sidecar save and the final repaint, and duplicating any of that in the threaded loop is how two paths drift apart. Handing over recomputes the chunks already painted, hence _NAV_HANDOVER_MIN_CHUNKS = 8 -- with only a handful left the recompute costs more than it saves. The check runs per chunk, so in practice it fires within the first few and almost nothing is redone. Tests cover the decision (hands over on the first chunk after the cluster registers; does NOT near the end; never without a cluster) plus a wiring guard, because the decision logic is only meaningful if the real loop still makes it. --- spyde/signal_tree.py | 41 +++++++ .../migrated/test_nav_cluster_handover.py | 100 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 spyde/tests/migrated/test_nav_cluster_handover.py diff --git a/spyde/signal_tree.py b/spyde/signal_tree.py index fcb2d3c3..18f08265 100644 --- a/spyde/signal_tree.py +++ b/spyde/signal_tree.py @@ -22,6 +22,14 @@ logger = logging.getLogger(__name__) +#: Chunks that must still be OUTSTANDING for the threaded navigator fill to hand +#: over to the cluster mid-flight. Handing over recomputes the chunks already +#: painted, so with only a handful left that costs more than it saves; with a +#: movie's worth left it is the difference between seconds and minutes. The +#: check runs per chunk, so in practice this fires within the first few. +_NAV_HANDOVER_MIN_CHUNKS = 8 + + def _materialise_signal(signal: BaseSignal, array: np.ndarray) -> None: """Swap a LAZY signal's dask data for an in-RAM array, the way hyperspy's own @@ -409,6 +417,39 @@ def _bg_nav(_dask=nav_dask, _plot=nav_plot, _sig=nav_signals, for combo in itertools.product(*axes_ranges): if _stop.is_set(): return + # HAND OVER to the cluster the moment it exists. + # + # This branch was chosen because `self.client` was None + # at the START — the cluster takes ~10 s and a file + # opened right after launch beats it. Without this check + # that decision was PERMANENT: the whole fill then ran + # single-threaded, one chunk at a time, however many + # workers turned up a second later. On a 977-frame movie + # that is the difference between seconds and minutes, + # and it looked exactly like "the cluster is idle". + # + # It bit movies and not 4D-STEM scans purely by timing: + # a scan goes through the nav-shape prompt (a human + # round trip) so the cluster is always up by the time + # its tree is built, while a movie opens straight + # through. + # + # Re-enter rather than switch in place: the distributed + # branch below owns cancellation, the sidecar save and + # the final repaint, and duplicating any of that here is + # how the two paths drift apart. The chunks already + # painted are recomputed, which is why the threshold + # exists — a handover is only worth it with real work + # left, and this fires within the first few chunks. + if (self.client is not None + and (total_chunks - done_chunks) + > _NAV_HANDOVER_MIN_CHUNKS): + logger.info( + "navigator fill: cluster came up after %d/%d " + "chunks — handing the rest to the dispatcher", + done_chunks, total_chunks) + self._start_progressive_nav_compute(_dask, deep=_deep) + return # Yield the disk to active scrubbing: for a large movie # this per-chunk sum reads the whole file, which otherwise # starves the crosshair's own frame read (the signal plot diff --git a/spyde/tests/migrated/test_nav_cluster_handover.py b/spyde/tests/migrated/test_nav_cluster_handover.py new file mode 100644 index 00000000..0a5393cb --- /dev/null +++ b/spyde/tests/migrated/test_nav_cluster_handover.py @@ -0,0 +1,100 @@ +"""test_nav_cluster_handover.py — the threaded navigator fill hands over. + +`_start_progressive_nav_compute` picks its path from `self.client`. The cluster +takes ~10 s to come up, so a file opened right after launch finds it None and +takes the THREADED branch — one background thread, one chunk at a time. + +That choice used to be permanent. However many workers registered a second +later, the whole fill ran single-threaded, which on a long movie is the +difference between seconds and minutes and reads as "the cluster is idle". + +Why it looked like a movie-only bug: a 4D-STEM scan goes through the nav-shape +prompt (a human round trip), so its cluster is always up by the time the tree is +built. A movie opens straight through and loses the race. +""" +from __future__ import annotations + +import threading +import time + +import dask.array as da +import numpy as np +import pytest + + +class _Plot: + def __init__(self): + self.window_id = 1 + self.painted = [] + + def set_data(self, arr, levels=None): + self.painted.append(arr) + + def _emit_histogram(self, *a, **k): + pass + + +class _Tree: + """Just enough tree to drive the threaded fill's handover check.""" + + def __init__(self, client_after: int): + self._client_after = client_after # chunks before the cluster "starts" + self._chunks_done = 0 + self.handed_over_with = None + self.session = None + self.source_path = None + + @property + def client(self): + return object() if self._chunks_done >= self._client_after else None + + def _start_progressive_nav_compute(self, nav_dask, deep=None): + self.handed_over_with = (nav_dask, deep) + + +def _drive(tree, total_chunks: int, min_remaining: int): + """The handover decision, lifted verbatim from the fill loop.""" + for done in range(total_chunks): + tree._chunks_done = done + if (tree.client is not None + and (total_chunks - done) > min_remaining): + return done + return None + + +class TestHandover: + def test_hands_over_once_the_cluster_appears(self): + from spyde.signal_tree import _NAV_HANDOVER_MIN_CHUNKS + tree = _Tree(client_after=3) + at = _drive(tree, total_chunks=100, min_remaining=_NAV_HANDOVER_MIN_CHUNKS) + assert at == 3, ( + "the fill did not hand over on the first chunk after the cluster " + "registered — the threaded path would run the whole movie") + + def test_does_not_hand_over_near_the_end(self): + """Handing over recomputes what is already painted, so with only a + handful of chunks left it costs more than it saves.""" + from spyde.signal_tree import _NAV_HANDOVER_MIN_CHUNKS + tree = _Tree(client_after=0) + total = _NAV_HANDOVER_MIN_CHUNKS # every step has <= min remaining + assert _drive(tree, total_chunks=total, + min_remaining=_NAV_HANDOVER_MIN_CHUNKS) is None + + def test_never_hands_over_without_a_cluster(self): + from spyde.signal_tree import _NAV_HANDOVER_MIN_CHUNKS + tree = _Tree(client_after=10_000) # cluster never arrives + assert _drive(tree, total_chunks=500, + min_remaining=_NAV_HANDOVER_MIN_CHUNKS) is None + + +class TestWiring: + def test_the_fill_loop_actually_contains_the_handover(self): + """Guard against the check being dropped in a refactor: the decision + above is only meaningful if the real loop still makes it.""" + import inspect + from spyde.signal_tree import BaseSignalTree + src = inspect.getsource(BaseSignalTree._start_progressive_nav_compute) + assert "_NAV_HANDOVER_MIN_CHUNKS" in src, ( + "the threaded navigator fill no longer checks for a late cluster") + assert "_start_progressive_nav_compute(_dask" in src, ( + "the handover no longer re-enters the dispatcher path") From 664e7f7c26abedca92f2a2fffea56db42a4dfa46 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 1 Aug 2026 08:12:17 -0500 Subject: [PATCH 31/38] fix(seg): the preview-window box only ever appeared under Classical Reported: "the 1k x 1k outline only shows in the classical; if you move to a different mode it continues to show, but toggle out/back in and it won't show up again." Three symptoms, one cause. The box was drawn only as a SIDE EFFECT of a successful segmentation, and `_preview` returns early when there is no engine -- an untrained Scribble, or Prompt before anything is prompted -- painting nothing and clearing nothing: * Classical is the one engine that always has a solver, so only it drew the box. * Switching to an untrained engine left the OLD box on screen, because the early return cleared nothing. It looked correct and was stale. * Toggling the caret ran _drop_overlay, and reopening in an untrained engine early-returned again, so it never came back. The box documents WHERE the 1-megapixel preview budget looks. That is true whenever the caret is open and has nothing to do with whether an engine has been trained -- arguably it matters MOST before training, when the user is deciding where to scribble. `show_preview_window()` now draws it (and clears any previous engine's outlines, so they cannot linger looking like the new engine's answer) on the no-engine path. It pushes only when the box or the cleared-state CHANGES. `seg_tune` fires on every slider tick and lands here whenever there is no engine, and `_push_groups` falls back to `MarkerGroup.set`, which re-serialises the whole panel -- so an unconditional push would put a full serialisation on every tick of a drag to redraw a rectangle that had not moved. That is what `test_an_unrelated_tune_does_NOT_force_a_panel_push` caught when I first wrote this without the guard, which is exactly what that test is for. 664 particle/seg/scribble tests pass. --- spyde/actions/particles_action.py | 64 +++++++++++++++++ spyde/tests/migrated/test_particles_wizard.py | 69 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/spyde/actions/particles_action.py b/spyde/actions/particles_action.py index 6cd21114..001db59f 100644 --- a/spyde/actions/particles_action.py +++ b/spyde/actions/particles_action.py @@ -304,6 +304,8 @@ def remove(self) -> None: self._closed = True self._unwire_navigator() self._drop_overlay() + self._ov_cleared = False + self._ov_box_state = None if getattr(self.tree, "_seg_wizard", None) is self: self.tree._seg_wizard = None @@ -358,9 +360,58 @@ def set_overlay(self, contours=None, box=None) -> None: # `particle_overlay._payload` writes. A key the group does not know # is accepted silently by `_push_groups` and simply never drawn. _push_groups(plot2d, updates) + # Real outlines are up now, so the no-engine path must redraw rather + # than skip on its cached "already cleared" state. + self._ov_cleared = False + self._ov_box_state = None except Exception as exc: log.debug("[seg] overlay push failed: %s", exc) + def show_preview_window(self) -> None: + """Draw the preview-window box ALONE, with no instance outlines. + + For the states that have no engine to run: an untrained Scribble, or + Prompt before anything is prompted. Callers that DO have a result go + through :meth:`set_overlay`, which draws both. + + Clearing the outlines here is the other half of the fix — switching to + an untrained engine must not leave the previous one's instances on + screen looking like the new engine's answer. + """ + plot2d = getattr(self.src_plot, "_plot2d", None) + if plot2d is None: + return + try: + _n, get_frame, _shape = self.frames() + _frame, box = _preview_window(np.asarray(get_frame(self.frame_index()))) + except Exception as exc: + log.debug("[seg] preview-window box: no frame to size it from: %s", exc) + return + # Only push when something actually CHANGES. `seg_tune` fires on every + # slider tick and lands here whenever there is no engine, and + # `_push_groups` falls back to `MarkerGroup.set`, which re-serialises + # the panel — so an unconditional push would put a full serialisation + # on every tick of a drag to redraw a rectangle that did not move. + state = (tuple(box) if box is not None else None, + getattr(self, "_ov_cleared", False)) + if state == getattr(self, "_ov_box_state", None): + return + updates = {} + group = self._overlay_group(plot2d) + if group is not None and not getattr(self, "_ov_cleared", False): + updates[group] = {"vertices_list": []} # no instances yet + frame_group = self._window_group(plot2d) + if frame_group is not None: + updates[frame_group] = {"vertices_list": _box_poly(box)} + if not updates: + return + try: + _push_groups(plot2d, updates) + self._ov_cleared = True + self._ov_box_state = (tuple(box) if box is not None else None, True) + except Exception as exc: + log.debug("[seg] preview-window push failed: %s", exc) + def _overlay_group(self, plot2d): """The lazily-created polygon group the preview outlines live in.""" if self._ov_group is not None: @@ -850,6 +901,19 @@ def _preview(wiz: SegmentWizard, gen: int) -> None: p = dict(wiz.params) engine = _engine(wiz, p) if engine is None: + # No engine yet — an untrained Scribble, or Prompt before any prompt. + # Still show WHERE the preview window is, and clear any outlines the + # previous engine left. + # + # The window box used to be drawn only as a side effect of a successful + # segmentation, which made it lie in three ways: it appeared only under + # Classical (the one engine that always has a solver), it SURVIVED a + # switch to an untrained Scribble because this early return painted + # nothing and cleared nothing, and after toggling the caret off and on + # it never came back. The box documents where the 1-megapixel budget + # looks; that is true whenever the caret is open and has nothing to do + # with whether an engine is trained. + wiz.show_preview_window() return t = wiz.frame_index() scale, units = wiz.scale_units() diff --git a/spyde/tests/migrated/test_particles_wizard.py b/spyde/tests/migrated/test_particles_wizard.py index 5db28605..5a569d62 100644 --- a/spyde/tests/migrated/test_particles_wizard.py +++ b/spyde/tests/migrated/test_particles_wizard.py @@ -994,3 +994,72 @@ def test_the_chip_comes_down_when_the_batch_ends(self, window): for m in msgs), timeout=180), ( "the Calculating chip never came down — it will spin forever over a " "window that has finished") + + +class TestPreviewWindowBoxLifecycle: + """The 1-megapixel preview-window box must not depend on an ENGINE. + + Reported: "the 1k x 1k outline only shows in classical; if you move to a + different mode it continues to show, but toggle out/back in and it won't + show up again." All three symptoms are one cause — the box was drawn only + as a side effect of a successful segmentation, and `_preview` returns early + when there is no engine (an untrained Scribble, or Prompt before any + prompt), painting nothing and CLEARING nothing: + + * Classical always has a solver, so only it drew the box. + * Switching to an untrained engine left the previous box on screen, + because the early return cleared nothing. + * Toggling the caret dropped the overlay groups, and reopening in an + untrained engine early-returned again, so it never came back. + + The box documents WHERE the budget looks. That is true whenever the caret + is open. + """ + + @staticmethod + def _wiz(): + import types + import spyde.actions.particles_action as pa + + class _Grp: # hashable by identity, unlike SimpleNamespace + def __init__(self, name): + self.name, self.removed = name, False + + def remove(self): + self.removed = True + + class _P2D: + def add_polygons(self, *a, **k): + return _Grp(k.get("name")) + + wiz = object.__new__(pa.SegmentWizard) + wiz._ov_group = None + wiz._ov_box_group = None + wiz.src_plot = types.SimpleNamespace(_plot2d=_P2D()) + wiz.frames = lambda: (1, lambda i: np.zeros((2048, 2048), np.float32), + (2048, 2048)) + wiz.frame_index = lambda: 0 + return wiz + + def test_box_is_drawn_with_no_engine(self, monkeypatch): + import spyde.actions.particles_action as pa + pushed = {} + monkeypatch.setattr(pa, "_push_groups", lambda p, u: pushed.update( + {g.name: pl.get("vertices_list") for g, pl in u.items()})) + self._wiz().show_preview_window() + box = pushed.get("seg_preview_window") + assert box, "no preview-window box without an engine — the untrained " \ + "Scribble/Prompt states show nothing at all" + assert len(box[0]) >= 4, "the box is not a closed rectangle" + + def test_switching_to_an_untrained_engine_clears_stale_outlines(self, monkeypatch): + """A previous engine's instances must not linger looking like the new + engine's answer.""" + import spyde.actions.particles_action as pa + pushed = {} + monkeypatch.setattr(pa, "_push_groups", lambda p, u: pushed.update( + {g.name: pl.get("vertices_list") for g, pl in u.items()})) + self._wiz().show_preview_window() + assert pushed.get("seg_preview_outline") == [], ( + "the outline group was not cleared, so the old engine's particles " + "stay on screen") From 3a343fbf021271d462c22c7b6bcf1b06fec44ceb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 1 Aug 2026 08:46:04 -0500 Subject: [PATCH 32/38] feat(seg): one Confidence slider that means "fewer particles" on every engine Reported with a screenshot: 547 instances in the preview window where ~30 are real, and no obvious way to cut them down. The Advanced block has six knobs -- min size, max size, split touching, min separation, marker smoothing, drop edge -- and not one of them says "fewer particles". Worse, the size/shape ones CANNOT fix this failure: over-split support-film texture is often small AND round, which is exactly what a size or circularity filter keeps. Adds a per-instance confidence score and a single slider that filters on it. WHAT THE SCORE IS. Contrast-to-noise against the instance's own dilated background ring, |intensity_mean - background| / spread, squashed to [0, 1] by cnr/(cnr+1) so the control is a plain 0-100% with no dataset-dependent range to explain. That statistic is the one that separates these two populations: a real particle sits well away from its immediate surroundings, while a fragment of textured film is BY CONSTRUCTION the same brightness as the texture around it. Measured on a fixture built to reproduce the failure (30 real particles, 300 labelled fragments of a noisy film): real median 0.947 (p10 0.945) against texture median 0.090 (p90 0.221) -- a clean gap, and a 0.94 cut keeps 30/30 real with zero texture. That fixture is synthetic and built to have the property, so it validates the MECHANISM, not anyone's data; the test asserts a separating threshold EXISTS rather than any particular number. WHY IT IS FAST AND UNIFORM. The score is derived from intensity columns already measured, so scoring costs no extra pass, and `filter_by_score` is a numpy mask over a few hundred rows. Dragging re-filters an existing result and never re-segments. It also means the same thing on Classical, Scribble and Prompt because it acts on the measured OUTPUT rather than on any one method's parameters -- which is why the slider is on the default face for all three. min_score=0 is a no-op returning the inputs unchanged, so the default behaves exactly as before. An UNMEASURABLE instance (no background ring) scores 1.0, not 0.0. Absent evidence is not evidence of a bad particle, and a "hide the marginal ones" control must not silently delete things it knows nothing about. FORMAT_VERSION 1 -> 2, with MIGRATION rather than rejection. `score` is appended at the end, which is the documented way to extend this layout, so an older file now loads with the column padded (1.0, per above). Refusing it would make every previously-saved particle result unopenable in order to add one derived number -- and it is derived, so it never needed to have been stored. The layout guard still rejects a genuinely reordered/renamed layout; its test now checks that case instead of the version, which is what it was always for. 686 particle/seg/scribble/track tests pass. --- .../renderer/src/components/SegmentWizard.tsx | 24 +++- spyde/actions/particles_action.py | 39 ++++- spyde/particles/measure.py | 58 ++++++++ spyde/signals/particles.py | 42 +++++- spyde/tests/migrated/test_particles_core.py | 136 +++++++++++++++++- 5 files changed, 285 insertions(+), 14 deletions(-) diff --git a/electron/src/renderer/src/components/SegmentWizard.tsx b/electron/src/renderer/src/components/SegmentWizard.tsx index d66d96e4..4cc69ef1 100644 --- a/electron/src/renderer/src/components/SegmentWizard.tsx +++ b/electron/src/renderer/src/components/SegmentWizard.tsx @@ -108,6 +108,7 @@ const LOW_PIXELS = 200 interface SegSaved { method: Method sensitivity: number + minScore: number threshold: Threshold minSize: number maxSize: number @@ -127,7 +128,7 @@ interface SegSaved { eraser: boolean } const DEFAULTS: SegSaved = { - method: 'classical', sensitivity: 0.5, threshold: 'otsu', minSize: 20, + method: 'classical', sensitivity: 0.5, minScore: 0, threshold: 'otsu', minSize: 20, maxSize: 0, watershed: true, minSeparation: 3, markerSmooth: 1.0, gaussian: 0.0, rbKernel: 0, invert: false, localSize: 31, clearBorder: false, storeMasks: true, track: true, maxDist: 10.0, brush: 3.0, @@ -165,6 +166,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const saved = _segStore.get(windowId) ?? DEFAULTS const [method, setMethod] = React.useState(saved.method) const [sensitivity, setSensitivity] = React.useState(saved.sensitivity) + const [minScore, setMinScore] = React.useState(saved.minScore) const [threshold, setThreshold] = React.useState(saved.threshold) const [minSize, setMinSize] = React.useState(saved.minSize) const [maxSize, setMaxSize] = React.useState(saved.maxSize) @@ -199,7 +201,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const vals = React.useRef(saved) vals.current = { - method, sensitivity, threshold, minSize, maxSize, watershed, minSeparation, + method, sensitivity, minScore, threshold, minSize, maxSize, watershed, minSeparation, markerSmooth, gaussian, rbKernel, invert, localSize, clearBorder, storeMasks, track, maxDist, brush, activeClass, eraser, } @@ -218,7 +220,8 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const params = (): Record => { const v = vals.current return { - method: v.method, sensitivity: v.sensitivity, threshold: v.threshold, + method: v.method, sensitivity: v.sensitivity, min_score: v.minScore, + threshold: v.threshold, min_size: v.minSize, max_size: v.maxSize, watershed: v.watershed, min_separation: v.minSeparation, marker_smooth: v.markerSmooth, gaussian: v.gaussian, rb_kernel: v.rbKernel, invert: v.invert, @@ -411,6 +414,21 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo
)} + {/* ── Confidence: the ONE "too many particles" control ───────────── + Shown for EVERY engine and meaning the same thing in each, because + it acts on the measured OUTPUT (each instance's contrast-to-noise + score) rather than on any one method's parameters. Filtering an + already-measured result is a numpy mask over a few hundred rows, so + dragging re-filters instantly and never re-segments. */} +
+ All + { const n = Number(e.target.value); setMinScore(n); tune() }} /> + Strong +
+ {/* ── Scribble: the class list + Train ─────────────────────────────── */} {isScribble && ( <> diff --git a/spyde/actions/particles_action.py b/spyde/actions/particles_action.py index 001db59f..887b24e9 100644 --- a/spyde/actions/particles_action.py +++ b/spyde/actions/particles_action.py @@ -90,6 +90,13 @@ # secondary and sits below it in the caret. sensitivity=0.5, threshold="otsu", + # THE control for "too many particles". Filters the measured instances by + # their confidence score (spyde.particles.measure.particle_scores), which is + # computed WITH the measurement — so this re-filters an existing result and + # never re-segments, and a drag is instant. It also means the same thing on + # every engine, because it acts on the OUTPUT rather than on any one + # method's parameters. 0 keeps everything (the old behaviour). + min_score=0.0, min_size=20, max_size=0, watershed=True, @@ -565,6 +572,7 @@ def _coerce(payload: dict | None) -> dict: p["local_size"] += 1 p["min_size"] = int(p["min_size"]) + p["min_score"] = float(min(0.99, max(0.0, p.get("min_score", 0.0) or 0.0))) p["min_size_floored"] = p["min_size"] < MIN_SIZE_FLOOR if p["min_size_floored"]: p["min_size"] = MIN_SIZE_FLOOR @@ -892,6 +900,33 @@ def _chase_nav(wiz: SegmentWizard) -> None: _preview_for_nav(wiz) +def filter_by_score(rows, contours, min_score: float): + """Keep only instances scoring at or above *min_score*. + + Separate from the measurement on purpose: this is what the caret's single + "Confidence" slider calls, and it must be able to run WITHOUT re-segmenting + — the score already lives in the row (see + :func:`spyde.particles.measure.particle_scores`), so a drag is a numpy mask + over a few hundred rows rather than a fresh segmentation of a 1-megapixel + window. + + ``min_score <= 0`` returns the inputs untouched, so the default costs + nothing and behaves exactly as before this control existed. + """ + import numpy as _np + from spyde.signals.particles import COL as _COL + + if min_score is None or float(min_score) <= 0.0 or len(rows) == 0: + return rows, contours + keep = _np.asarray(rows[:, _COL["score"]] >= float(min_score), bool) + if keep.all(): + return rows, contours + kept_rows = _np.ascontiguousarray(rows[keep]) + kept_contours = ([c for c, k in zip(contours, keep) if k] + if contours is not None else contours) + return kept_rows, kept_contours + + def _preview(wiz: SegmentWizard, gen: int) -> None: """Segment the displayed frame on a worker and paint the result. @@ -926,7 +961,9 @@ def _work(): labels = engine(frame) from spyde.particles import measure_frame rows, contours = measure_frame(labels, frame, t=t, scale=scale) - return {"frame": t, "labels": labels, "rows": rows, + n_all = len(rows) + rows, contours = filter_by_score(rows, contours, p.get("min_score", 0.0)) + return {"frame": t, "labels": labels, "rows": rows, "n_all": n_all, "contours": contours, "elapsed": time.perf_counter() - t0, "box": box, "full_shape": full.shape} diff --git a/spyde/particles/measure.py b/spyde/particles/measure.py index fc15116c..b361e601 100644 --- a/spyde/particles/measure.py +++ b/spyde/particles/measure.py @@ -221,6 +221,10 @@ def measure_frame( rows = rows[keep] contours = [c for c, k in zip(contours, keep) if k] + # Score LAST: it is derived from the intensity columns filled above, so it + # costs no extra pass over the frame. That is what lets the caret filter on + # it without re-segmenting — see `particle_scores`. + rows[:, COL["score"]] = particle_scores(rows) return np.ascontiguousarray(rows), contours @@ -364,3 +368,57 @@ def _contours(lab: np.ndarray, tbl, *, fast: bool | None = None np.clip(c[:, 1], 0, w - 1, out=c[:, 1]) out.append(c.astype(np.int16)) return out + + +def particle_scores(rows: np.ndarray) -> np.ndarray: + """Per-particle confidence in [0, 1] — "is this a particle, or texture?" + + Derived from columns already measured, which is the whole point: scoring + costs no extra pass over the frame, so the caret can filter on it without + re-segmenting and a slider drag is instant. + + The statistic is CONTRAST-TO-NOISE against the particle's own dilated + background ring:: + + |intensity_mean - background| / spread + + with *spread* the particle's own intensity spread. That is the quantity + that separates the two populations in the failure this exists for: a real + particle sits well away from its immediate surroundings, while a fragment + of textured support film is, by construction, the same brightness as the + texture around it however large or round it happens to be. Size, circularity + and solidity all fail here — over-split speckle is often small AND round. + + Squashed to [0, 1] with ``cnr / (cnr + 1)`` so the caret's slider is a plain + 0-100% control with no dataset-dependent range to explain: 0.5 means "as far + from its background as its own noise", which is a weak particle, and real + ones land well above it. + + NaN (no background ring measured — ``background_ring=0``, or a particle + whose ring fell entirely outside the frame) scores 1.0 rather than 0.0. + An unmeasurable particle must not be silently filtered away; the slider is + a "hide the marginal ones" control, and something with no evidence against + it is not marginal. + """ + if rows.size == 0: + return np.zeros((0,), np.float32) + mean = rows[:, COL["intensity_mean"]].astype(np.float64) + bg = rows[:, COL["background"]].astype(np.float64) + std = rows[:, COL["intensity_std"]].astype(np.float64) + + contrast = np.abs(mean - bg) + # `intensity_std` is already normalised by the particle's max (see + # _fill_intensity), so put it back on the intensity scale before using it + # as a noise estimate. Guard the degenerate flat particle. + noise = np.where(np.isfinite(std) & (std > 0), std * np.abs(mean), np.nan) + floor = np.nanmedian(noise) if np.isfinite(noise).any() else 1.0 + if not np.isfinite(floor) or floor <= 0: + floor = 1.0 + noise = np.where(np.isfinite(noise) & (noise > 0), noise, floor) + + with np.errstate(invalid="ignore", divide="ignore"): + cnr = contrast / noise + score = cnr / (cnr + 1.0) + # Unmeasurable => not marginal => keep. See the docstring. + score = np.where(np.isfinite(score), score, 1.0) + return np.clip(score, 0.0, 1.0).astype(np.float32) diff --git a/spyde/signals/particles.py b/spyde/signals/particles.py index 69ae505b..580f5999 100644 --- a/spyde/signals/particles.py +++ b/spyde/signals/particles.py @@ -41,7 +41,7 @@ import numpy as np -FORMAT_VERSION = 1 +FORMAT_VERSION = 2 #: Column layout of ``flat_buffer``. Order is load-bearing — it is the on-disk #: layout. Append new columns at the END and bump ``FORMAT_VERSION``. @@ -60,6 +60,14 @@ "background", # mean intensity in the dilated boundary ring "bbox_y0", "bbox_x0", "bbox_y1", "bbox_x1", # pixel indices, half-open "track_id", # -1 until the linker runs + # How confidently this is a PARTICLE rather than a fragment of textured + # background, in [0, 1]. Contrast-to-noise against the particle's own + # dilated background ring by default (see spyde.particles.measure), which + # an engine with a real probability may overwrite. It exists so the caret + # can offer ONE control that means "fewer / more particles": the score is + # computed with the measurement, so filtering on it needs no + # re-segmentation and a drag is instant. + "score", ) COL: dict[str, int] = {name: i for i, name in enumerate(COLUMNS)} N_COLUMNS = len(COLUMNS) @@ -70,6 +78,7 @@ "area", "equiv_diameter", "major_axis", "minor_axis", "perimeter", "circularity", "eccentricity", "solidity", "intensity_mean", "intensity_max", "intensity_std", "background", + "score", ) #: Which measured columns scale with length, area, or not at all — used to apply @@ -339,19 +348,38 @@ def load(cls, path: str) -> "SpyDEParticles": with np.load(path, allow_pickle=False) as z: meta = json.loads(str(z["meta"].item())) ver = meta.get("format_version") - if ver != FORMAT_VERSION: + if not isinstance(ver, int) or ver > FORMAT_VERSION: raise ValueError( f"unsupported SpyDEParticles format version {ver!r} " f"(this build reads {FORMAT_VERSION})" ) saved_cols = tuple(meta.get("columns") or ()) + buf = z["flat_buffer"] + # An OLDER file is readable when the only difference is columns + # APPENDED since — which is the documented way to extend this + # layout, and the reason new columns go at the end. Refusing it + # would make every previously-saved particle result unopenable to + # add one derived number, and the number is derived precisely so it + # need not have been stored at the time. if saved_cols != COLUMNS: - raise ValueError( - "column layout changed since this file was written " - f"({len(saved_cols)} columns on disk, {N_COLUMNS} expected)" - ) + if saved_cols != COLUMNS[:len(saved_cols)]: + raise ValueError( + "column layout changed since this file was written " + f"({len(saved_cols)} columns on disk, {N_COLUMNS} " + "expected)" + ) + pad = np.full((buf.shape[0], N_COLUMNS - len(saved_cols)), + np.nan, np.float32) + for i, name in enumerate(COLUMNS[len(saved_cols):]): + # `score` is a confidence, and an absent one means "no + # evidence against this particle" — the caret's filter must + # not silently delete instances measured before it existed. + if name == "score": + pad[:, i] = 1.0 + buf = np.ascontiguousarray( + np.concatenate([buf, pad], axis=1), np.float32) return cls( - flat_buffer=z["flat_buffer"], + flat_buffer=buf, t_offsets=z["t_offsets"], frame_shape=tuple(meta["frame_shape"]), contours=z["contours"] if "contours" in z.files else None, diff --git a/spyde/tests/migrated/test_particles_core.py b/spyde/tests/migrated/test_particles_core.py index dd0e8689..36284a4b 100644 --- a/spyde/tests/migrated/test_particles_core.py +++ b/spyde/tests/migrated/test_particles_core.py @@ -714,17 +714,49 @@ def test_load_rejects_future_format(self, tmp_path): SpyDEParticles.load(path) def test_load_rejects_changed_column_layout(self, tmp_path): + """A REORDERED/renamed layout is unreadable — but see the test below: + columns merely APPENDED since are not a layout change.""" import json + from spyde.signals.particles import FORMAT_VERSION path = str(tmp_path / "cols.npz") np.savez_compressed( - path, flat_buffer=np.zeros((0, N_COLUMNS), np.float32), + path, flat_buffer=np.zeros((0, 2), np.float32), t_offsets=np.array([0]), - meta=np.array(json.dumps({"format_version": 1, - "columns": ["t", "label"], + meta=np.array(json.dumps({"format_version": FORMAT_VERSION, + "columns": ["label", "t"], # swapped "frame_shape": [4, 4]}))) with pytest.raises(ValueError, match="column layout changed"): SpyDEParticles.load(path) + def test_load_migrates_a_file_written_before_a_column_was_appended(self, tmp_path): + """An older file must still open. + + New columns go at the END precisely so this is possible. Refusing the + file would make every previously-saved particle result unopenable in + order to add one DERIVED number — and it is derived, so it never needed + to have been stored. + + The migrated `score` is 1.0, not 0.0: absent evidence is not evidence of + a bad particle, and the caret's confidence filter must not silently + delete instances measured before the score existed. + """ + import json + from spyde.signals.particles import COLUMNS as _COLS, COL as _COL + old_cols = _COLS[:-1] # everything before `score` + path = str(tmp_path / "old.npz") + buf = np.zeros((3, len(old_cols)), np.float32) + buf[:, 0] = [0, 0, 1] # t + np.savez_compressed( + path, flat_buffer=buf, t_offsets=np.array([0, 2, 3]), + meta=np.array(json.dumps({"format_version": 1, + "columns": list(old_cols), + "frame_shape": [4, 4]}))) + p = SpyDEParticles.load(path) + assert p.flat_buffer.shape == (3, N_COLUMNS) + assert np.all(p.flat_buffer[:, _COL["score"]] == 1.0), ( + "migrated particles must not be filtered away by the confidence " + "slider — they carry no evidence, not bad evidence") + def test_to_csv_writes_a_header_and_every_row(self, tmp_path): p = _build() path = str(tmp_path / "p.csv") @@ -784,3 +816,101 @@ def test_the_padded_border_never_becomes_a_particle(self, invert): assert not on_border, ( f"invert={invert}: instances {on_border} were found on the NaN " f"border, which is padding and not data") + + +class TestConfidenceScore: + """One slider that means "fewer / more particles" on every engine. + + The reported failure was 547 instances where ~30 were real, and six Advanced + knobs none of which says "fewer particles": min size, max size, split + touching, min separation, marker smoothing, drop-edge. Size and shape cannot + fix it — over-split support-film texture is often SMALL and ROUND, which is + exactly what a size/circularity filter keeps. + + Contrast-to-noise against the instance's own dilated background ring is the + statistic that does separate them: a real particle sits well away from its + surroundings, while a fragment of textured film is by construction the same + brightness as the texture around it. + """ + + @staticmethod + def _field(): + """~30 genuinely dark particles in a noisy support film, plus ~300 + labelled fragments OF that film — the reported failure, in miniature.""" + rng = np.random.default_rng(0) + h = w = 256 + frame = np.full((h, w), 200.0, np.float32) + frame += rng.normal(0, 6.0, (h, w)).astype(np.float32) + labels = np.zeros((h, w), np.int32) + truth, nxt = {}, 1 + y, x = np.mgrid[0:h, 0:w] + for _ in range(30): + cy, cx, r = rng.uniform(15, h - 15), rng.uniform(15, w - 15), rng.uniform(5, 10) + m = (y - cy) ** 2 + (x - cx) ** 2 < r * r + frame[m] = 120.0 + rng.normal(0, 5.0, int(m.sum())).astype(np.float32) + labels[m] = nxt; truth[nxt] = True; nxt += 1 + for _ in range(300): + cy, cx, r = rng.uniform(8, h - 8), rng.uniform(8, w - 8), rng.uniform(3, 6) + m = ((y - cy) ** 2 + (x - cx) ** 2 < r * r) & (labels == 0) + if m.sum() < 20: + continue + labels[m] = nxt; truth[nxt] = False; nxt += 1 + return frame, labels, truth + + def test_the_score_separates_particles_from_textured_film(self): + from spyde.particles.measure import measure_frame + from spyde.signals.particles import COL + + frame, labels, truth = self._field() + rows, _ = measure_frame(labels, frame, t=0) + scores = rows[:, COL["score"]] + real = np.array([truth.get(int(l), False) + for l in rows[:, COL["label"]].astype(int)]) + assert real.sum() and (~real).sum(), "the fixture built only one population" + # A gap, not merely a difference in means: the slider is only usable if + # SOME threshold cleanly separates them. + assert np.percentile(scores[real], 10) > np.percentile(scores[~real], 90), ( + f"no separating threshold exists: real p10=" + f"{np.percentile(scores[real], 10):.3f} vs texture p90=" + f"{np.percentile(scores[~real], 90):.3f}") + + def test_filtering_keeps_rows_and_contours_aligned(self): + """A misalignment draws one particle's outline on another's row.""" + from spyde.actions.particles_action import filter_by_score + from spyde.signals.particles import COL, N_COLUMNS + + rows = np.zeros((5, N_COLUMNS), np.float32) + rows[:, COL["score"]] = [0.05, 0.2, 0.6, 0.95, 0.99] + rows[:, COL["label"]] = [1, 2, 3, 4, 5] + contours = [np.full((3, 2), i, np.int16) for i in range(5)] + kept_rows, kept_contours = filter_by_score(rows, contours, 0.5) + assert len(kept_rows) == len(kept_contours) == 3 + # the SURVIVING contours must be the ones belonging to the kept rows + assert [int(c[0, 0]) for c in kept_contours] == [2, 3, 4] + + def test_zero_is_the_old_behaviour_untouched(self): + from spyde.actions.particles_action import filter_by_score + from spyde.signals.particles import COL, N_COLUMNS + + rows = np.zeros((3, N_COLUMNS), np.float32) + rows[:, COL["score"]] = [0.0, 0.5, 1.0] + cont = [np.zeros((2, 2), np.int16)] * 3 + out_rows, out_cont = filter_by_score(rows, cont, 0.0) + assert out_rows is rows and out_cont is cont, ( + "the default must be a no-op, not a copy") + + def test_unmeasurable_particles_are_kept_not_hidden(self): + """No background ring => no evidence => not marginal. + + Scoring an unmeasurable instance 0 would let the slider silently delete + particles it knows nothing about, which is the opposite of what a + 'hide the marginal ones' control should do. + """ + from spyde.particles.measure import particle_scores + from spyde.signals.particles import COL, N_COLUMNS + + rows = np.zeros((1, N_COLUMNS), np.float32) + rows[:, COL["intensity_mean"]] = 100.0 + rows[:, COL["background"]] = np.nan # ring fell outside the frame + rows[:, COL["intensity_std"]] = np.nan + assert particle_scores(rows)[0] == 1.0 From 87a0d75da335180c3e399f99b59cbbda579009b2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 1 Aug 2026 08:58:48 -0500 Subject: [PATCH 33/38] perf(seg): draw ONE raster mask above 100 particles instead of N polygons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "A lot of particles overlaid starts to make everything slow." Every contour is a path the renderer re-transforms on each pan/zoom frame, so several hundred of them cost real interactivity. Above _RASTER_ABOVE (100) the overlay switches to a single mask; below it the vector outlines stay, because they are crisp at any zoom and each is an object the UI can hover. Only the DRAWING changes -- the particle count and every measurement are untouched. Two things had to be got right, and I had the first one wrong going in. 1. WHICH PRIMITIVE. `Plot2D` has no `add_raster` at all (only `Plot1D` does), so the marker-layer raster I had planned does not exist on the plot type the signal uses. The right primitive is `Plot2D.set_overlay_mask`, which composites client-side onto the transparent 2-D canvas that sits ABOVE the WebGPU canvas -- so it works on a GPU-rendered base. My note that "raster masks are invisible under GPU tile mode" was too broad: the mask layer is fine, and the real constraint is (2). 2. THE TILING RESOLUTION. In tile mode the renderer checks `bytes.length === iw * ih` with `iw = base_width || image_width` -- the OVERVIEW size, not the native frame. A native-resolution mask fails that check and is dropped SILENTLY: no error, no overlay, nothing in the log to say why. On a 4096² movie (always tiled) the feature would simply have drawn nothing, and looked like it was not wired up. The mask is therefore built at frame size and reduced to `base_width x base_height` whenever it is set. The reduction is a block ANY, not a subsample: particles are often a few pixels across, and striding a 4096² mask down to 1024² drops three quarters of them at random. Mask colour is the same green as the outlines so crossing the threshold does not read as a mode change, and the raster is cleared when the count drops back below it and on caret teardown, so the two can never double up. Tests cover both resolutions (native when untiled, overview when tiled, with the failing case spelled out), that small particles survive the reduction, and that a 3-particle frame is NOT rastered. 690 tests pass. --- spyde/actions/particles_action.py | 109 +++++++++++++++++- spyde/tests/migrated/test_particles_wizard.py | 91 +++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/spyde/actions/particles_action.py b/spyde/actions/particles_action.py index 887b24e9..cc44ae6e 100644 --- a/spyde/actions/particles_action.py +++ b/spyde/actions/particles_action.py @@ -316,7 +316,8 @@ def remove(self) -> None: if getattr(self.tree, "_seg_wizard", None) is self: self.tree._seg_wizard = None - def set_overlay(self, contours=None, box=None) -> None: + def set_overlay(self, contours=None, box=None, labels=None, + full_shape=None) -> None: """Draw (or clear) the previewed instances as OUTLINES on the source plot. Outlines and not a translucent raster mask, for two independent reasons — @@ -349,6 +350,27 @@ def set_overlay(self, contours=None, box=None) -> None: group = self._overlay_group(plot2d) if group is None: return + # Hundreds of polygons is what makes the whole app sluggish — each is a + # path the renderer re-transforms every pan/zoom frame. Above the + # threshold draw ONE mask instead; the particle COUNT and every + # measurement are unaffected, only how they are drawn. + n = len(contours) if contours is not None else 0 + if n > _RASTER_ABOVE and labels is not None: + if self._set_raster_overlay(labels, box, full_shape): + self._ov_raster = True + # Drop the vector outlines so the two do not double up. + updates = {group: {"vertices_list": []}} + frame_group = self._window_group(plot2d) + if frame_group is not None: + updates[frame_group] = {"vertices_list": _box_poly(box)} + try: + _push_groups(plot2d, updates) + self._ov_cleared = False + self._ov_box_state = None + except Exception as exc: + log.debug("[seg] overlay push failed: %s", exc) + return + self._clear_raster_overlay() polys = _contour_polys(contours, box) updates = {group: {"vertices_list": polys}} # The preview window's own outline. Without it "only the middle of my @@ -374,6 +396,72 @@ def set_overlay(self, contours=None, box=None) -> None: except Exception as exc: log.debug("[seg] overlay push failed: %s", exc) + def _set_raster_overlay(self, labels, box, full_shape) -> bool: + """Draw the instances as ONE mask instead of N polygons. True if drawn. + + Uses ``Plot2D.set_overlay_mask``, which composites client-side onto the + transparent 2-D canvas that sits ABOVE the WebGPU canvas — so this works + on a GPU-rendered base, which a marker-layer raster would not. + + THE TILING TRAP. When a large frame is in tile mode the renderer's mask + check is ``bytes.length === iw * ih`` where ``iw = base_width || + image_width`` — i.e. the OVERVIEW size, not the native frame. A + native-resolution mask therefore fails that check and is dropped + SILENTLY: no error, no overlay, and nothing in the log to say why. So + the mask is built at the frame size and then reduced to the overview + grid whenever ``base_width`` is set. + + The reduction is a block ANY, not a subsample: particles here are often + a few pixels across, and a strided sample of a 4096² mask at 1024² + drops three quarters of them at random. + """ + plot2d = getattr(self.src_plot, "_plot2d", None) + if plot2d is None or not hasattr(plot2d, "set_overlay_mask"): + return False + try: + lab = np.asarray(labels) + if lab.ndim != 2 or not lab.any(): + return False + fh, fw = (int(full_shape[0]), int(full_shape[1])) if full_shape \ + else lab.shape + mask = np.zeros((fh, fw), bool) + if box is not None: + y0, x0, h, w = (int(v) for v in box) + mask[y0:y0 + h, x0:x0 + w] = lab[:h, :w] > 0 + else: + mask[:lab.shape[0], :lab.shape[1]] = lab > 0 + + state = getattr(plot2d, "_state", {}) or {} + bw, bh = int(state.get("base_width") or 0), int(state.get("base_height") or 0) + if bw > 0 and bh > 0 and (bw, bh) != (fw, fh): + ys = max(1, fh // bh) + xs = max(1, fw // bw) + # Block ANY via a reshape-reduce, then pad/crop to exactly the + # overview grid — the renderer's length check is exact. + cut = mask[:(fh // ys) * ys, :(fw // xs) * xs] + small = cut.reshape(fh // ys, ys, fw // xs, xs).any(axis=(1, 3)) + out = np.zeros((bh, bw), bool) + sh, sw = min(bh, small.shape[0]), min(bw, small.shape[1]) + out[:sh, :sw] = small[:sh, :sw] + mask = out + plot2d.set_overlay_mask(mask, color=_PREVIEW_COLOR, alpha=_RASTER_ALPHA) + return True + except Exception as exc: + log.debug("[seg] raster overlay failed (%s); using outlines", exc) + return False + + def _clear_raster_overlay(self) -> None: + plot2d = getattr(self.src_plot, "_plot2d", None) + if plot2d is None or not hasattr(plot2d, "set_overlay_mask"): + return + if not getattr(self, "_ov_raster", False): + return + try: + plot2d.set_overlay_mask(None) + except Exception as exc: + log.debug("[seg] clearing the raster overlay failed: %s", exc) + self._ov_raster = False + def show_preview_window(self) -> None: """Draw the preview-window box ALONE, with no instance outlines. @@ -447,6 +535,7 @@ def _window_group(self, plot2d): return self._ov_box_group def _drop_overlay(self) -> None: + self._clear_raster_overlay() for attr in ("_ov_group", "_ov_box_group"): group = getattr(self, attr, None) if group is None: @@ -818,6 +907,20 @@ def _preview_window(frame: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, #: painted class (orange / blue / grey / pink). _PREVIEW_WINDOW_COLOR = "#cdd6f4" +#: Above this many instances the overlay switches from one polygon per particle +#: to a SINGLE raster mask. Measured symptom: several hundred vector contours +#: make the whole app sluggish, because every one is a path the renderer +#: re-transforms on each pan/zoom frame. A mask is one image however many +#: particles it contains. +#: +#: Below the threshold the vector outlines stay, and they are worth keeping: +#: they are crisp at any zoom and each is a real object the UI can hover. +_RASTER_ABOVE = 100 + +#: The raster overlay's colour/opacity. Deliberately the same green as the +#: vector outlines so crossing the threshold does not look like a mode change. +_RASTER_ALPHA = 0.45 + def _box_poly(box) -> list: """The preview window as a one-polygon list, or empty when there is none. @@ -987,7 +1090,9 @@ def _done(res): # The outlines carry the crop offset themselves (`_contour_polys`), so # unlike the retired mask path nothing has to be re-rasterised onto a # full-frame array first — at 4096² that array alone was 16 MB per frame. - wiz.set_overlay(res["contours"], res.get("box")) + wiz.set_overlay(res["contours"], res.get("box"), + labels=res.get("labels"), + full_shape=res.get("full_shape")) # The count reported is the count AFTER the size filter (plan §0.9b) — # `split_instances` applies min_size last, so `rows` is already filtered # and this number is the one the histogram below describes. diff --git a/spyde/tests/migrated/test_particles_wizard.py b/spyde/tests/migrated/test_particles_wizard.py index 5a569d62..df0ee13c 100644 --- a/spyde/tests/migrated/test_particles_wizard.py +++ b/spyde/tests/migrated/test_particles_wizard.py @@ -1063,3 +1063,94 @@ def test_switching_to_an_untrained_engine_clears_stale_outlines(self, monkeypatc assert pushed.get("seg_preview_outline") == [], ( "the outline group was not cleared, so the old engine's particles " "stay on screen") + + +class TestRasterOverlayAboveThreshold: + """Hundreds of vector contours make the app sluggish; draw one mask instead. + + Every contour is a path the renderer re-transforms on each pan/zoom frame, + so a few hundred of them cost real interactivity. A mask is ONE image + however many particles it contains. Below the threshold the outlines stay — + crisp at any zoom, and each is an object the UI can hover. + """ + + @staticmethod + def _p2d(base_w=0, base_h=0): + class _P2D: + def __init__(self): + self._state = {"base_width": base_w, "base_height": base_h, + "image_width": 4096, "image_height": 4096} + self.mask = "unset" + + def set_overlay_mask(self, mask, color=None, alpha=None): + self.mask = mask + + def add_polygons(self, *a, **k): + return None + return _P2D() + + @staticmethod + def _wiz(p2d): + import types + import spyde.actions.particles_action as pa + w = object.__new__(pa.SegmentWizard) + w._ov_group = None + w._ov_box_group = None + w._ov_raster = False + w.src_plot = types.SimpleNamespace(_plot2d=p2d) + return w + + @staticmethod + def _labels(): + lab = np.zeros((1024, 1024), np.int32) + y, x = np.mgrid[0:1024, 0:1024] + for i, (cy, cx) in enumerate([(200, 200), (500, 700), (800, 300)], start=1): + lab[(y - cy) ** 2 + (x - cx) ** 2 < 25] = i # 5 px radius + return lab + + def test_untiled_mask_is_the_native_frame_size(self): + p = self._p2d() + assert self._wiz(p)._set_raster_overlay( + self._labels(), (1536, 1536, 1024, 1024), (4096, 4096)) + assert p.mask.shape == (4096, 4096) + + def test_tiled_mask_is_built_at_the_OVERVIEW_size(self): + """The trap this exists for. + + In tile mode the renderer checks ``bytes.length === iw * ih`` where + ``iw = base_width || image_width`` — the OVERVIEW size. A + native-resolution mask fails that check and is dropped SILENTLY: no + error, no overlay, nothing in the log. So the mask must be reduced to + the overview grid, exactly. + """ + p = self._p2d(1024, 1024) + assert self._wiz(p)._set_raster_overlay( + self._labels(), (1536, 1536, 1024, 1024), (4096, 4096)) + assert p.mask.shape == (1024, 1024), ( + f"mask is {p.mask.shape} but the renderer expects the overview " + f"grid (1024, 1024) — it would draw nothing at all") + + def test_the_reduction_keeps_small_particles(self): + """Block ANY, not a subsample. + + Particles are often a few pixels across; striding a 4096² mask down to + 1024² would drop three quarters of them at random. + """ + p = self._p2d(1024, 1024) + self._wiz(p)._set_raster_overlay( + self._labels(), (1536, 1536, 1024, 1024), (4096, 4096)) + assert p.mask.sum() > 0, "every particle vanished in the reduction" + + def test_below_the_threshold_nothing_is_rastered(self): + """Few particles → keep the crisp, hoverable vector outlines.""" + import spyde.actions.particles_action as pa + assert pa._RASTER_ABOVE > 1 + p = self._p2d() + wiz = self._wiz(p) + wiz._ov_cleared = False + wiz._ov_box_state = None + contours = [np.zeros((3, 2), np.int16) for _ in range(3)] + wiz.set_overlay(contours, None, labels=self._labels(), + full_shape=(4096, 4096)) + assert p.mask in ("unset", None), ( + "a 3-particle frame was rastered; the outlines are better there") From 175e3e7dd0c342ff9b05469e64e9e523176e5915 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 1 Aug 2026 11:56:49 -0500 Subject: [PATCH 34/38] perf(seg): watershed per COMPONENT, not per frame -- 852 MB/frame was pausing workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a real batch run: dask workers pausing at 80% of a 9.24 GiB limit, restarting at 95%, "Unmanaged memory: 6.47 GiB". The dask graph was never the problem -- segment_movie is a plain map_blocks over time chunks and nothing computes the movie, so it is genuinely embarrassingly parallel. ONE unit of that work was simply enormous. Measured at 4096², classical: input frame 64 MB _prepare 24 MB 0.04 s threshold 7 MB 0.02 s split_instances (watershed) 546 MB 4.15 s <-- 64% of the peak whole frame 852 MB 4.4 s With threads_per_worker=4 that is ~3.4 GB of concurrent peak per worker before frames in flight. "Unmanaged" is exactly right: the rasters are ours, inside the task, so dask can neither account for them nor spill them. Fix: watershed each connected component inside its own bbox. EXACT, not an approximation -- a component is surrounded by background by definition, so a 1 px pad holds every pixel the distance transform, the markers and the watershed can depend on, and no watershed flows between components that do not touch. Same shape measure.py already uses for contours. 4096², 400 overlapping discs: whole-frame 538 MB / 1.59 s per-component 272 MB / 1.09 s 2x less memory AND 1.5x faster -- the distance transform now runs over the particles' bboxes instead of 16.7 M pixels of mostly background. ONE BEHAVIOUR CHANGE, and it is an improvement. Counts differ on large frames (395 vs 388) because _split_factor decimates the whole-frame split geometry 4x at 4096² (the earlier 7.1 -> 2.8 s optimisation) while a per-component crop is a few hundred px and gets factor 1, i.e. full-resolution markers. At 1024², where neither route decimates, the two agree PIXEL FOR PIXEL -- that is what the parity test asserts, and it is why the difference is decimation rather than a cropping error. Gated at _COMPONENT_ROUTE_PX (4 MP); below it the bookkeeping costs more than it saves. Tests: exact parity where neither decimates, a long diagonal particle is not broken at a crop edge, and labels stay globally unique across components (an off-by-one in the offset would silently merge two particles). 693 tests pass. --- benchmarks.md | 54 ++++++ .../renderer/src/components/SegmentWizard.tsx | 52 ++++-- spyde/actions/particles_action.py | 46 ++++- spyde/particles/classical.py | 167 +++++++++++++++++- spyde/tests/migrated/test_particles_core.py | 102 +++++++++++ 5 files changed, 403 insertions(+), 18 deletions(-) diff --git a/benchmarks.md b/benchmarks.md index 09c65ad3..45d029f7 100644 --- a/benchmarks.md +++ b/benchmarks.md @@ -1744,3 +1744,57 @@ of GIL-held client time for 977 chunks. next chunk so a ~30x-faster GPU lane and the CPU lane drain one pool and finish together — real work stealing the scheduler cannot do, and the reason this module exists. Only the unpinned lane changed. + +### Batch segmentation paused dask workers — the per-FRAME peak, not the graph (2026-08-01) + +Reported from a real run: workers pausing at 80% of a 9.24 GiB limit, restarting +at 95%, and `Unmanaged memory: 6.47 GiB`. + +The graph was never the problem. `segment_movie` is a plain `map_blocks` over +time chunks and nothing calls `.compute()` on the movie — it is genuinely +embarrassingly parallel. What was wrong is that ONE unit of that parallel work +is enormous. Measured, 4096² frame, classical engine: + +| stage | peak | time | +|---|---|---| +| input frame (float32) | 64 MB | — | +| `_prepare` | 24 MB | 0.04 s | +| threshold | 7 MB | 0.02 s | +| **`split_instances` (watershed)** | **546 MB** | **4.15 s** | +| **whole frame** | **852 MB** | 4.4 s | + +The cluster runs `threads_per_worker=4`, so that is **~3.4 GB of concurrent peak +on one worker** before frames in flight or allocator fragmentation. And +"unmanaged" is the correct label: the rasters are ours, inside the task, so dask +can neither account for them nor spill them. + +The watershed is 64% of the peak because the distance transform, its smoothed +copy, the marker labelling and the watershed each materialise a full-frame +float32/int32 raster. + +**Fix: watershed each connected component in its own bbox.** This is EXACT, not +an approximation — a component is surrounded by background by definition, so a +1 px pad contains every pixel the distance transform, the markers and the +watershed can depend on, and no watershed can flow between components that do +not touch. It is the same shape `measure.py` already uses to trace contours. + +| 4096², 400 overlapping discs | peak | time | +|---|---|---| +| whole-frame | 538 MB | 1.59 s | +| **per-component** | **272 MB** | **1.09 s** | + +**2x less memory and 1.5x faster.** Faster because the distance transform now +runs over the particles' own bboxes instead of 16.7 M pixels of mostly +background. + +**One behaviour change, and it is an improvement.** The counts differ on large +frames (395 vs 388 above) because `_split_factor` decimates the whole-frame +split geometry 4x at 4096² (the earlier 7.1 s -> 2.8 s optimisation), while a +per-component crop is a few hundred pixels and so gets factor 1 — full +resolution markers. At 1024², where neither route decimates, the two agree +PIXEL FOR PIXEL, which is what the parity test pins. So the per-component route +is more accurate as well as smaller; the decimation that bought the original +speedup is simply no longer needed on this path. + +Gated at `_COMPONENT_ROUTE_PX` (4 MP): below it the per-crop bookkeeping costs +more than the memory it saves. diff --git a/electron/src/renderer/src/components/SegmentWizard.tsx b/electron/src/renderer/src/components/SegmentWizard.tsx index 4cc69ef1..94a703d1 100644 --- a/electron/src/renderer/src/components/SegmentWizard.tsx +++ b/electron/src/renderer/src/components/SegmentWizard.tsx @@ -109,6 +109,8 @@ interface SegSaved { method: Method sensitivity: number minScore: number + mergeNm: number + minNm: number threshold: Threshold minSize: number maxSize: number @@ -128,7 +130,8 @@ interface SegSaved { eraser: boolean } const DEFAULTS: SegSaved = { - method: 'classical', sensitivity: 0.5, minScore: 0, threshold: 'otsu', minSize: 20, + method: 'classical', sensitivity: 0.5, minScore: 0, mergeNm: 0, minNm: 0, + threshold: 'otsu', minSize: 20, maxSize: 0, watershed: true, minSeparation: 3, markerSmooth: 1.0, gaussian: 0.0, rbKernel: 0, invert: false, localSize: 31, clearBorder: false, storeMasks: true, track: true, maxDist: 10.0, brush: 3.0, @@ -167,6 +170,8 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const [method, setMethod] = React.useState(saved.method) const [sensitivity, setSensitivity] = React.useState(saved.sensitivity) const [minScore, setMinScore] = React.useState(saved.minScore) + const [mergeNm, setMergeNm] = React.useState(saved.mergeNm) + const [minNm, setMinNm] = React.useState(saved.minNm) const [threshold, setThreshold] = React.useState(saved.threshold) const [minSize, setMinSize] = React.useState(saved.minSize) const [maxSize, setMaxSize] = React.useState(saved.maxSize) @@ -201,7 +206,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const vals = React.useRef(saved) vals.current = { - method, sensitivity, minScore, threshold, minSize, maxSize, watershed, minSeparation, + method, sensitivity, minScore, mergeNm, minNm, threshold, minSize, maxSize, watershed, minSeparation, markerSmooth, gaussian, rbKernel, invert, localSize, clearBorder, storeMasks, track, maxDist, brush, activeClass, eraser, } @@ -221,6 +226,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo const v = vals.current return { method: v.method, sensitivity: v.sensitivity, min_score: v.minScore, + merge_nm: v.mergeNm, min_nm: v.minNm, threshold: v.threshold, min_size: v.minSize, max_size: v.maxSize, watershed: v.watershed, min_separation: v.minSeparation, marker_smooth: v.markerSmooth, @@ -414,20 +420,34 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo
)} - {/* ── Confidence: the ONE "too many particles" control ───────────── - Shown for EVERY engine and meaning the same thing in each, because - it acts on the measured OUTPUT (each instance's contrast-to-noise - score) rather than on any one method's parameters. Filtering an - already-measured result is a numpy mask over a few hundred rows, so - dragging re-filters instantly and never re-segments. */} -
- All - { const n = Number(e.target.value); setMinScore(n); tune() }} /> - Strong -
+ {/* ── The two face controls, both in NANOMETRES ──────────────────── + A distance in the image is something the eye can judge against the + scale bar; a 0-1 "confidence" is not, which is why that control read + as meaningless. Both are engine-independent — they act on the + measured instances, not on any one method's parameters — so the + caret's face is the same on Classical, Scribble and Prompt. + + `merge` answers "some of these should be one particle": pieces whose + gap is under it are relabelled as one. `min` drops anything smaller + than that across. Confidence is still available under Advanced. */} + +
+ { const n = Number(e.target.value); setMergeNm(n); tune() }} /> + {mergeNm ? `${mergeNm} nm` : 'off'} +
+
+ +
+ { const n = Number(e.target.value); setMinNm(n); tune() }} /> + {minNm ? `${minNm} nm` : 'off'} +
+
{/* ── Scribble: the class list + Train ─────────────────────────────── */} {isScribble && ( diff --git a/spyde/actions/particles_action.py b/spyde/actions/particles_action.py index cc44ae6e..71b21a5e 100644 --- a/spyde/actions/particles_action.py +++ b/spyde/actions/particles_action.py @@ -97,6 +97,14 @@ # every engine, because it acts on the OUTPUT rather than on any one # method's parameters. 0 keeps everything (the old behaviour). min_score=0.0, + # THE two face controls, both in NANOMETRES because a distance in the image + # is something the eye can judge and a 0-1 confidence is not. + # merge_nm — pieces closer than this are ONE particle + # min_nm — smallest particle worth keeping (diameter) + # Both are converted to pixels with the signal's own scale at dispatch, so + # they mean the same thing at any magnification. 0 disables either. + merge_nm=0.0, + min_nm=0.0, min_size=20, max_size=0, watershed=True, @@ -681,11 +689,47 @@ def _segment_kwargs(p: dict) -> dict: rb_kernel=int(p["rb_kernel"]), gaussian=float(p["gaussian"]), invert=bool(p["invert"]), local_size=int(p["local_size"]), watershed=bool(p["watershed"]), min_separation=int(p["min_separation"]), - marker_smooth=float(p["marker_smooth"]), min_size=int(p["min_size"]), + marker_smooth=float(p["marker_smooth"]), min_size=_min_size_px(p), max_size=int(p["max_size"]), clear_border=bool(p["clear_border"]), + merge_distance=_nm_to_px(p.get("merge_nm", 0.0), p), ) +def _nm_to_px(value_nm: float, p: dict) -> float: + """A face control in nanometres -> pixels, using the signal's own scale. + + The face controls are physical so they mean the same thing at any + magnification, and so the number matches what the scale bar says. `scale` is + stashed on the params at dispatch (nm per pixel); a missing or zero scale + means the signal is uncalibrated, and then the value IS pixels rather than + being silently divided by nothing. + """ + try: + v = float(value_nm or 0.0) + except (TypeError, ValueError): + return 0.0 + if v <= 0: + return 0.0 + try: + scale = float(p.get("scale") or 0.0) + except (TypeError, ValueError): + scale = 0.0 + return v / scale if scale > 0 else v + + +def _min_size_px(p: dict) -> int: + """`min_nm` (a DIAMETER) wins over the raw pixel `min_size` when set. + + Area, not diameter, is what the size filter compares -- a particle of + diameter d covers ~pi/4 d^2 pixels -- so converting the physical control + means going through the area, not handing the diameter straight over. + """ + d_px = _nm_to_px(p.get("min_nm", 0.0), p) + if d_px > 0: + return max(1, int(round(3.14159265 / 4.0 * d_px * d_px))) + return int(p["min_size"]) + + def _segment_params(p: dict): from spyde.particles import SegmentParams return SegmentParams(**_segment_kwargs(p)) diff --git a/spyde/particles/classical.py b/spyde/particles/classical.py index 613760f6..d5530fd2 100644 --- a/spyde/particles/classical.py +++ b/spyde/particles/classical.py @@ -80,6 +80,13 @@ class SegmentParams: #: maxima (a 3x3 particle's marker is one pixel, so any area floor erases it). #: That is precisely the sensitivity failure plan §0.9 exists to prevent. min_separation: int = 3 + #: Instances whose gap is smaller than this many PIXELS are merged back into + #: one particle (:func:`merge_close_instances`). This is the control for + #: "some of these should be one particle" -- a real particle comes back as + #: several instances when the watershed splits a lumpy blob or a ragged edge + #: sheds a fragment, and both are answered by the same question: how far + #: apart must two pieces be to count as separate? 0 disables. + merge_distance: float = 0.0 #: Gaussian sigma applied to the distance transform BEFORE peak finding. #: Suppresses spurious maxima from a ragged boundary without merging genuinely #: separate particles. 0 disables. @@ -355,7 +362,14 @@ def split_instances( if not fg.any(): return np.zeros(fg.shape, dtype=np.int32) - if p.watershed: + if p.watershed and fg.size >= _COMPONENT_ROUTE_PX: + # Big frame: watershed each connected component in its own bbox. Exact + # (a component is surrounded by background, so nothing crosses a crop) + # and it is what keeps a dask worker off its memory limit — the + # whole-frame route below allocates several full-frame rasters, 546 MB + # of the 852 MB peak measured at 4096². See _watershed_per_component. + labels = _watershed_per_component(fg, p, distance_from) + elif p.watershed: seed_src = fg if p.watershed_erosion > 0: seed_src = fg.copy() @@ -533,6 +547,12 @@ def _finalize_labels(labels: np.ndarray, p: SegmentParams) -> np.ndarray: twice, which is why they are one function rather than two composed ones. """ labels = np.asarray(labels) + # MERGE first, size-filter second. Two fragments of one particle are each + # small; merged they are one particle of the real size, and filtering before + # merging would delete the pieces and leave nothing to join. This is also why + # it lives here rather than in either split path: every engine reaches + # `_finalize_labels`, so the control means the same thing in all of them. + labels = merge_close_instances(labels, getattr(p, "merge_distance", 0.0)) counts = np.bincount(labels.ravel()) # `counts > 0` and not `slice(1, None)`: a label id absent from the raster # (a gap left by clear_border) must not be handed an output number, which is @@ -715,3 +735,148 @@ def segment_frame(frame: np.ndarray, p: SegmentParams | None = None) -> np.ndarr prepared = _prepare(frame, p) fg = threshold_mask(prepared, p) return split_instances(fg, p) + + +def merge_close_instances(labels: np.ndarray, merge_px: float) -> np.ndarray: + """Merge instances separated by a gap smaller than *merge_px* pixels. + + The user-facing answer to "some of these should be one particle". A real + particle can come back as several instances for two very different reasons + — the watershed split a lumpy blob, or a ragged edge broke off a fragment — + and BOTH are fixed by the same question: how far apart do two pieces have + to be before they are genuinely separate particles? That is a distance the + user can see in the image, which is what the abstract 0-1 confidence + control was not. + + Implemented by growing every instance by half the gap and taking connected + components of the result: two instances whose boundaries come within + *merge_px* touch once grown, so they land in one component and are + relabelled together. Growing by HALF is what makes the parameter mean the + gap between them rather than the radius each one is inflated by. + + The instances themselves are NOT dilated in the output — only their + identities change. Areas, centroids and outlines are still measured from + the original mask, so merging cannot inflate a measurement. + + ``merge_px <= 0`` returns *labels* unchanged. + """ + if merge_px is None or merge_px <= 0: + return labels + lab = np.asarray(labels) + n = int(lab.max()) if lab.size else 0 + if n < 2: + return lab + + from scipy import ndimage as ndi + + radius = max(1, int(round(float(merge_px) / 2.0))) + # A cross/disk structuring element applied `radius` times is far cheaper + # than building a (2r+1)² disk and is close enough for a gap test. + grown = ndi.binary_dilation(lab > 0, iterations=radius) + comp, n_comp = ndi.label(grown) + if n_comp >= n: + return lab # nothing came together + + # Each original instance lies wholly inside one grown component (it can + # only have grown outward), so sampling the component at the instance's + # own pixels is exact — take the max for safety on a degenerate label. + comp_of = ndi.maximum(comp, lab, index=np.arange(1, n + 1)) + comp_of = np.asarray(comp_of, np.int64).ravel() + + # Renumber the surviving components 1..k so the output is a dense label + # image, which everything downstream (measure, contours) assumes. + uniq, dense = np.unique(comp_of, return_inverse=True) + lut = np.zeros(n + 1, np.int32) + lut[1:] = dense.astype(np.int32) + 1 + return lut[lab] + + +#: Frames at or above this many pixels take the per-component watershed route +#: instead of the whole-frame one. Below it the bookkeeping costs more than the +#: memory it saves; above it the whole-frame route is what pauses a dask worker. +_COMPONENT_ROUTE_PX: int = 4 << 20 # ~2048², i.e. 4 megapixels + + +def _watershed_per_component(fg: np.ndarray, p: SegmentParams, + distance_from: np.ndarray | None = None + ) -> np.ndarray: + """Watershed each connected component inside its own bounding box. + + **This is exact, not an approximation.** A connected component is + surrounded by background by definition, so within its (1 px padded) bbox + every quantity the watershed needs is already correct: the distance + transform measures distance to the nearest background pixel, and that + pixel is inside the pad; markers are local maxima of that distance; and a + watershed cannot flow between two components because they do not touch. + Cropping therefore changes nothing about the answer, only the peak memory. + + Why it matters. Measured on a 4096² frame, whole-frame classical + segmentation peaks at **852 MB**, of which `split_instances` alone is + 546 MB — the distance transform, its smoothed copy, the marker labelling + and the watershed each materialise a full-frame float32/int32 raster. With + dask running ``threads_per_worker=4`` that is ~3.4 GB of concurrent peak + per worker, which is what drives a 9.24 GiB worker into the pause/restart + loop with "unmanaged memory" warnings: the arrays are ours, inside the + task, so dask cannot see or spill them. + + Per component the peak is one particle's bbox — kilobytes — plus the two + full-frame rasters that cannot be avoided (the input mask and the output + labels). + + It is also the same shape `measure.py` already uses to trace contours + ("inside each region's padded bbox crop, not on the whole frame"). + """ + from scipy import ndimage as ndi + + comp, n_comp = ndi.label(fg) + out = np.zeros(fg.shape, np.int32) + if n_comp == 0: + return out + slices = ndi.find_objects(comp) + next_label = 1 + h, w = fg.shape + for i, sl in enumerate(slices, start=1): + if sl is None: + continue + # 1 px pad so the crop's border is background and the distance + # transform inside sees the true nearest zero. + y0 = max(0, sl[0].start - 1); y1 = min(h, sl[0].stop + 1) + x0 = max(0, sl[1].start - 1); x1 = min(w, sl[1].stop + 1) + sub_fg = comp[y0:y1, x0:x1] == i + sub_dist_src = (None if distance_from is None + else np.asarray(distance_from[y0:y1, x0:x1], np.float32)) + sub = _split_one_component(sub_fg, p, sub_dist_src) + if sub is None: + continue + m = sub > 0 + if not m.any(): + continue + # Renumber this component's instances into the global label space. + out[y0:y1, x0:x1][m] = sub[m] + (next_label - 1) + next_label += int(sub.max()) + return out + + +def _split_one_component(sub_fg: np.ndarray, p: SegmentParams, + distance_from: np.ndarray | None): + """The watershed body, on ONE component's crop. Labels are 1..k locally.""" + from skimage.morphology import binary_erosion, disk + from skimage.segmentation import watershed + from scipy import ndimage as ndi + + seed_src = sub_fg + if p.watershed_erosion > 0: + seed_src = sub_fg.copy() + for _ in range(int(p.watershed_erosion)): + seed_src = binary_erosion(seed_src, disk(1)) + + if distance_from is not None: + dist = np.asarray(distance_from, np.float32) * seed_src + markers = _distance_markers(dist, sub_fg, p) + else: + dist, markers = _distance_and_markers(seed_src, sub_fg, p) + + if markers.max() > 0: + return np.asarray(watershed(-dist, markers, mask=sub_fg), np.int32) + lab, _ = ndi.label(sub_fg) + return np.asarray(lab, np.int32) diff --git a/spyde/tests/migrated/test_particles_core.py b/spyde/tests/migrated/test_particles_core.py index 36284a4b..4218f6fb 100644 --- a/spyde/tests/migrated/test_particles_core.py +++ b/spyde/tests/migrated/test_particles_core.py @@ -914,3 +914,105 @@ def test_unmeasurable_particles_are_kept_not_hidden(self): rows[:, COL["background"]] = np.nan # ring fell outside the frame rows[:, COL["intensity_std"]] = np.nan assert particle_scores(rows)[0] == 1.0 + + +class TestPerComponentWatershed: + """Large frames watershed each component in its own bbox. + + A whole-frame watershed allocates several full-frame rasters — measured at + 4096², `split_instances` alone peaks at 546 MB of an 852 MB total. With + dask's `threads_per_worker=4` that is ~3.4 GB of concurrent peak per worker, + which drove a 9.24 GiB worker into a pause/resume/restart loop with + "unmanaged memory" warnings (the arrays are ours, inside the task, so dask + can neither see nor spill them). + + Cropping is EXACT, not an approximation: a connected component is surrounded + by background by definition, so a 1 px pad contains every pixel the distance + transform, the markers and the watershed can depend on, and no watershed can + flow between two components that do not touch. + """ + + @staticmethod + def _field(n, k, seed=0): + rng = np.random.default_rng(seed) + fg = np.zeros((n, n), bool) + y, x = np.mgrid[0:n, 0:n] + for _ in range(k): + cy, cx = rng.uniform(20, n - 20), rng.uniform(20, n - 20) + r = rng.uniform(8, 22) + fg |= (y - cy) ** 2 + (x - cx) ** 2 < r * r # overlapping on purpose + return fg + + def test_identical_to_the_whole_frame_route_when_neither_decimates(self): + """The exactness claim, where it can be checked directly. + + Below `_SPLIT_DECIMATE_ABOVE` both routes compute the split geometry at + full resolution, so they must agree pixel for pixel. Above it they do + NOT, and that is decimation rather than a cropping error — see the next + test. + """ + import spyde.particles.classical as C + from spyde.particles.classical import SegmentParams, split_instances + + fg = self._field(1024, 90) + p = SegmentParams(min_size=5, watershed=True) + assert C._split_factor(fg.shape, p) == 1, "fixture must not decimate" + + orig = C._COMPONENT_ROUTE_PX + try: + C._COMPONENT_ROUTE_PX = 1 << 60 # force whole-frame + whole = split_instances(fg, p) + C._COMPONENT_ROUTE_PX = 0 # force per-component + comp = split_instances(fg, p) + finally: + C._COMPONENT_ROUTE_PX = orig + + assert whole.max() == comp.max(), ( + f"instance COUNT differs with no decimation in play: " + f"{whole.max()} vs {comp.max()}") + # Ids may be numbered differently; the PARTITION must be the same. + for v in np.unique(whole): + if v == 0: + continue + m = whole == v + ids = np.unique(comp[m]) + assert len(ids) == 1 and ids[0] != 0, ( + "a whole-frame instance was split across the crop boundary") + + def test_a_component_spanning_a_crop_is_not_broken_up(self): + """One long diagonal particle — the shape a naive band split ruins.""" + import spyde.particles.classical as C + from spyde.particles.classical import SegmentParams, split_instances + + n = 512 + fg = np.zeros((n, n), bool) + for i in range(40, n - 40): + fg[i - 3:i + 3, i - 3:i + 3] = True # corner to corner + orig = C._COMPONENT_ROUTE_PX + try: + C._COMPONENT_ROUTE_PX = 0 + lab = split_instances(fg, SegmentParams(min_size=5, watershed=True)) + finally: + C._COMPONENT_ROUTE_PX = orig + assert lab.max() >= 1 + # every foreground pixel belongs to a labelled instance + assert (lab[fg] > 0).all(), "pixels were dropped at a crop edge" + + def test_labels_are_globally_unique_across_components(self): + """Each crop labels 1..k locally; the offset into the global space is + where an off-by-one silently merges two particles.""" + import spyde.particles.classical as C + from spyde.particles.classical import SegmentParams, split_instances + + fg = np.zeros((256, 256), bool) + fg[20:60, 20:60] = True # three well-separated squares + fg[20:60, 120:160] = True + fg[120:160, 20:60] = True + orig = C._COMPONENT_ROUTE_PX + try: + C._COMPONENT_ROUTE_PX = 0 + lab = split_instances(fg, SegmentParams(min_size=5, watershed=True)) + finally: + C._COMPONENT_ROUTE_PX = orig + assert lab.max() == 3, f"expected 3 instances, got {lab.max()}" + assert len(set(np.unique(lab)) - {0}) == 3 From 514f427d051ea64ce9b02c0792f7048905c282c4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 2 Aug 2026 11:01:23 -0500 Subject: [PATCH 35/38] fix(seg): the overlay's raster path could never run on a tiled frame Reported on a real in-situ movie: "14028 particles in this region", the preview window a flat sheet of green, the renderer hung, and the Scribble tab unusable because the frame you have to paint on was under that sheet. Four defects, one screenshot, and every test green through all of them. THE RASTER OVERLAY WAS UNREACHABLE ABOVE 1024 px -- i.e. on exactly the frames it was added for. The renderer sizes the mask against `base_width || image_width` (the tile OVERVIEW grid) while tile mode sets `image_width` to the FULL native frame. `_set_raster_overlay` reduced to the overview grid, which is what the renderer wants and what `set_overlay_mask` then rejected against the image shape; the ValueError landed in a bare `except` and was logged at DEBUG, so every large-frame preview fell back to one filled polygon per instance. At 14028 instances that is the hang. Verified both directions on a 4096^2 tiled plot: the overview-sized mask raised, and a full-resolution one encoded 22.4 MB that the renderer silently discards. anyplotlib now owns the reduction and accepts either shape (CSSFrancis/anyplotlib fix/overlay-mask-tile-mode), so this hands over the native mask and the arithmetic has ONE owner that cannot disagree with the shape check beside it. The failure log is now WARNING: the fallback from there is worse than the failure. NOTHING CAPPED THAT FALLBACK. `_MAX_OUTLINE_POLYS` is the seatbelt, independent of `_RASTER_ABOVE` -- reaching it means the raster was unavailable, and the honest answer is then "too many to draw" rather than thousands of paths the renderer re-transforms every pan frame. The count still reports every instance. `show_preview_window` CLEARED THE OUTLINES BUT NOT THE RASTER, so above 100 instances the previous engine's result survived a switch to an untrained Scribble -- covering the image you have to paint on. Its own docstring says it prevents exactly that; it only covered one of the two drawing routes. A FAILED THRESHOLD IS NOT A RESULT. Otsu on a low-contrast frame has no bimodal histogram to find, lands inside the noise, and the split shatters the support film. Measured on a stand-in (noisy film, 8 faint particles, 1024^2): defaults give 4873 instances at 39% coverage; min_size=2000 gives 17 at 7.2%; and gaussian=2 + min_size=200 + no watershed gives 8 -- which looks like the 8 real particles until you see it covers 52% of the frame, because those 8 bodies ARE the film. So `_threshold_failed` tests count AND coverage, neither being diagnostic alone, and the caret names the failure and points at Scribble (plan 0.9) rather than reporting a number. It does not silently re-tune. Also: contours are ~half a preview's cost at high instance counts and are discarded above the draw cap anyway, so `measure_frame(want_contours=False)` skips them there -- 1513 -> 969 ms at n=4873 with the rows BIT-IDENTICAL, so count, histogram, median and the confidence filter are untouched. That breaks the one-contour-per-row correspondence `SpyDEParticles.from_frames` needs, so `commit()` refuses such a preview and says why instead of building a store whose outlines do not match its rows. `set_overlay` now takes the instance count explicitly, since `len(contours)` is no longer it. The test for the tiling trap FAKED `set_overlay_mask` and asserted the mask SpyDE built, so it never enforced the contract that failed and was green throughout. It uses a real Plot2D now and asserts the bytes that SHIP. 692 particle/seg tests pass; 3415 in the full suite. --- spyde/actions/particles_action.py | 251 +++++++++++-- spyde/particles/measure.py | 22 +- spyde/tests/migrated/test_particles_wizard.py | 332 ++++++++++++++++-- 3 files changed, 538 insertions(+), 67 deletions(-) diff --git a/spyde/actions/particles_action.py b/spyde/actions/particles_action.py index 71b21a5e..a02d29ad 100644 --- a/spyde/actions/particles_action.py +++ b/spyde/actions/particles_action.py @@ -260,6 +260,36 @@ def scale_units(self) -> tuple[float, str]: log.debug("[seg] reading signal calibration failed: %s", exc) return 1.0, "px" + def set_params(self, payload: dict | None, *, merge: bool = False) -> None: + """Coerce *payload* into ``self.params``, WITH the signal's scale. + + The one place params are assigned, because of the second line. The face + controls `merge_nm` / `min_nm` are physical, and :func:`_nm_to_px` + converts them with ``p["scale"]`` — but :func:`_coerce` builds `p` from + the ``DEFAULTS`` keys alone, so nothing put a scale there and the + conversion took its uncalibrated branch on EVERY signal. The slider said + "50 nm" and the backend merged at 50 *pixels*: silent, and wrong by + exactly the magnification. Stashing it at dispatch is what `_nm_to_px` + already documents ("stashed on the params at dispatch"); it just was + never done. + + Assigning `wiz.params = _coerce(...)` directly re-opens that hole, so + don't — the scale is only correct if it is refreshed here, on the + DISPLAYED node, which is what a rebinned or cropped view changes. + + The stashed value is **nm per pixel**, not the raw axis scale, and it is + 0.0 when the axis is not a real-space length at all. A signal calibrated + in `nm^-1` reported a perfectly good positive scale, so converting + against it produced a merge radius that was silently wrong by the camera + length; 0.0 routes those signals to the pixel fallback instead, and + `_face_units` relabels the sliders so the caret does not claim nm. + """ + base = {**self.params, **(payload or {})} if merge else payload + p = _coerce(base) + scale, units = self.scale_units() + p["scale"] = _length_nm_per_px(scale, units) + self.params = p + def frame_index(self) -> int: """Where the navigator is sitting. @@ -325,7 +355,7 @@ def remove(self) -> None: self.tree._seg_wizard = None def set_overlay(self, contours=None, box=None, labels=None, - full_shape=None) -> None: + full_shape=None, n_instances=None) -> None: """Draw (or clear) the previewed instances as OUTLINES on the source plot. Outlines and not a translucent raster mask, for two independent reasons — @@ -362,7 +392,14 @@ def set_overlay(self, contours=None, box=None, labels=None, # path the renderer re-transforms every pan/zoom frame. Above the # threshold draw ONE mask instead; the particle COUNT and every # measurement are unaffected, only how they are drawn. - n = len(contours) if contours is not None else 0 + # The true instance count, which is NOT `len(contours)`: the preview + # skips outline extraction above the draw cap precisely because this + # branch is about to discard them. Keying the decision off the contour + # list would then read "0 instances", fall through to the polygon path, + # draw nothing, and leave the frame bare in exactly the case that most + # needs a mask. + n = n_instances if n_instances is not None else ( + len(contours) if contours is not None else 0) if n > _RASTER_ABOVE and labels is not None: if self._set_raster_overlay(labels, box, full_shape): self._ov_raster = True @@ -380,6 +417,19 @@ def set_overlay(self, contours=None, box=None, labels=None, return self._clear_raster_overlay() polys = _contour_polys(contours, box) + # HARD CAP, independent of `_RASTER_ABOVE`. Reaching here with a huge n + # means the raster path was unavailable or failed, and the honest answer + # is then "too many to draw" — NOT 14028 paths the renderer must + # re-transform on every pan and zoom frame. That is a hang, and because + # thousands of translucent fills overlap into one flat sheet it is not + # even a legible one: the user sees a solid green block and no data. + # The COUNT still reports every instance; only the drawing is dropped, + # and the caret says so rather than leaving a silently empty frame. + if len(polys) > _MAX_OUTLINE_POLYS: + log.warning("[seg] %d outlines exceeds the %d draw cap and the raster " + "overlay was unavailable; drawing none", + len(polys), _MAX_OUTLINE_POLYS) + polys = [] updates = {group: {"vertices_list": polys}} # The preview window's own outline. Without it "only the middle of my # 4k frame has any segmentation" reads as a BUG rather than as the @@ -411,17 +461,22 @@ def _set_raster_overlay(self, labels, box, full_shape) -> bool: transparent 2-D canvas that sits ABOVE the WebGPU canvas — so this works on a GPU-rendered base, which a marker-layer raster would not. - THE TILING TRAP. When a large frame is in tile mode the renderer's mask - check is ``bytes.length === iw * ih`` where ``iw = base_width || - image_width`` — i.e. the OVERVIEW size, not the native frame. A - native-resolution mask therefore fails that check and is dropped - SILENTLY: no error, no overlay, and nothing in the log to say why. So - the mask is built at the frame size and then reduced to the overview - grid whenever ``base_width`` is set. - - The reduction is a block ANY, not a subsample: particles here are often - a few pixels across, and a strided sample of a 4096² mask at 1024² - drops three quarters of them at random. + THE TILING TRAP, and why the reduction is NOT done here any more. When a + large frame is in tile mode the renderer sizes the mask against + ``base_width || image_width`` — the OVERVIEW grid — while tile mode sets + ``image_width`` to the FULL native frame. This method used to reduce the + mask to the overview grid itself, which was right for the renderer and + REJECTED by ``set_overlay_mask``'s own shape check (it compared against + the image shape). The ``ValueError`` landed in the ``except`` below, was + logged at DEBUG, and every large-frame preview fell back to N polygons — + so on a 4096² frame the raster path could never run, which is precisely + the case it was added for. At N=14028 that fallback is what hangs the + renderer and paints the frame a solid sheet of green. + + anyplotlib now accepts a full-resolution mask in tile mode and does the + block-ANY reduction itself (``_reduce_mask_any``), so there is ONE owner + of that arithmetic and it cannot disagree with the shape check sitting + next to it. Hand it the native-resolution mask and let it decide. """ plot2d = getattr(self.src_plot, "_plot2d", None) if plot2d is None or not hasattr(plot2d, "set_overlay_mask"): @@ -438,24 +493,15 @@ def _set_raster_overlay(self, labels, box, full_shape) -> bool: mask[y0:y0 + h, x0:x0 + w] = lab[:h, :w] > 0 else: mask[:lab.shape[0], :lab.shape[1]] = lab > 0 - - state = getattr(plot2d, "_state", {}) or {} - bw, bh = int(state.get("base_width") or 0), int(state.get("base_height") or 0) - if bw > 0 and bh > 0 and (bw, bh) != (fw, fh): - ys = max(1, fh // bh) - xs = max(1, fw // bw) - # Block ANY via a reshape-reduce, then pad/crop to exactly the - # overview grid — the renderer's length check is exact. - cut = mask[:(fh // ys) * ys, :(fw // xs) * xs] - small = cut.reshape(fh // ys, ys, fw // xs, xs).any(axis=(1, 3)) - out = np.zeros((bh, bw), bool) - sh, sw = min(bh, small.shape[0]), min(bw, small.shape[1]) - out[:sh, :sw] = small[:sh, :sw] - mask = out plot2d.set_overlay_mask(mask, color=_PREVIEW_COLOR, alpha=_RASTER_ALPHA) return True except Exception as exc: - log.debug("[seg] raster overlay failed (%s); using outlines", exc) + # WARNING, not debug. The fallback from here is N polygons, and N is + # only large enough to be here because it was already too large to + # draw — so a silent failure trades a missing overlay for a hung + # renderer. If this line appears, that is the bug. + log.warning("[seg] raster overlay failed (%s); falling back to outlines", + exc) return False def _clear_raster_overlay(self) -> None: @@ -480,10 +526,18 @@ def show_preview_window(self) -> None: Clearing the outlines here is the other half of the fix — switching to an untrained engine must not leave the previous one's instances on screen looking like the new engine's answer. + + BOTH drawing routes, which is the half that was missing. This cleared + the vector outlines and left any RASTER mask in place, so above + `_RASTER_ABOVE` instances the previous engine's result survived the + switch untouched. On the Scribble tab that is not merely stale, it is + disabling: the mask covers the image you have to paint on, and the + reported symptom was exactly "moved to scribble, immediately unusable". """ plot2d = getattr(self.src_plot, "_plot2d", None) if plot2d is None: return + self._clear_raster_overlay() try: _n, get_frame, _shape = self.frames() _frame, box = _preview_window(np.asarray(get_frame(self.frame_index()))) @@ -623,6 +677,19 @@ def commit(self): if prev is None or self.session is None: emit_error("Segment Particles: nothing previewed to commit") return None + # `SpyDEParticles.from_frames` requires one contour PER ROW, in order. + # The preview skips outline extraction above the draw cap, so committing + # that preview would build a store whose outlines silently do not + # correspond to its rows. Refuse, and say why — the only way to be here + # is a preview with thousands of instances, which is the failed-threshold + # case (`_threshold_failed`) and not something worth committing anyway. + if len(prev["contours"]) != len(prev["rows"]): + emit_error( + f"Segment Particles: {len(prev['rows'])} instances is too many " + "to commit as outlines — this is usually a threshold that " + "landed in the noise. Tighten the size filter, or train the " + "Scribble classifier, then commit.") + return None return commit_single_frame( self.session, self, prev["labels"], prev["rows"], prev["contours"], int(prev["frame"])) @@ -695,14 +762,52 @@ def _segment_kwargs(p: dict) -> dict: ) +#: Axis units that are a REAL-SPACE LENGTH, and their size in nanometres. The +#: face controls are in nm, so an axis calibrated in µm or Å converts through +#: this rather than being divided by a raw number in the wrong unit. +#: +#: Anything NOT in here — `nm^-1` and friends from a reciprocal-space signal, +#: `mrad`, `px`, an empty unit — is not a length, and then there is no physical +#: distance to convert to. Those signals fall back to PIXELS and the caret +#: relabels the two sliders accordingly (`_face_units`). A reciprocal axis is +#: the case that made this necessary: dividing a nanometre by a value in nm⁻¹ +#: is dimensionally meaningless, and it silently produced a merge radius wrong +#: by whatever the camera length happened to be. +_NM_PER: dict[str, float] = { + "m": 1e9, "cm": 1e7, "mm": 1e6, + "um": 1e3, "µm": 1e3, "μm": 1e3, "micron": 1e3, + "nm": 1.0, + "a": 0.1, "å": 0.1, "ang": 0.1, "angstrom": 0.1, + "pm": 1e-3, +} + + +def _length_nm_per_px(scale: float, units: str) -> float: + """*scale* in nm/px, or 0.0 when the axis is not a real-space length.""" + try: + s = float(scale) + except (TypeError, ValueError): + return 0.0 + if not (s > 0): + return 0.0 + factor = _NM_PER.get(str(units or "").strip().lower()) + return s * factor if factor else 0.0 + + +def _face_units(scale: float, units: str) -> str: + """What the two face sliders are actually in: 'nm' or 'px'.""" + return "nm" if _length_nm_per_px(scale, units) > 0 else "px" + + def _nm_to_px(value_nm: float, p: dict) -> float: """A face control in nanometres -> pixels, using the signal's own scale. The face controls are physical so they mean the same thing at any magnification, and so the number matches what the scale bar says. `scale` is - stashed on the params at dispatch (nm per pixel); a missing or zero scale - means the signal is uncalibrated, and then the value IS pixels rather than - being silently divided by nothing. + stashed on the params at dispatch **already converted to nm per pixel** + (:meth:`SegmentWizard.set_params`); a missing or zero scale means the signal + is uncalibrated *or its axis is not a length at all*, and then the value IS + pixels rather than being silently divided by a number in the wrong unit. """ try: v = float(value_nm or 0.0) @@ -961,10 +1066,53 @@ def _preview_window(frame: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, #: they are crisp at any zoom and each is a real object the UI can hover. _RASTER_ABOVE = 100 +#: The absolute ceiling on polygons handed to the renderer, whatever route got +#: us here. `_RASTER_ABOVE` chooses the nicer drawing; this one is the seatbelt +#: for when the raster is unavailable or fails, because the fallback used to be +#: unbounded — a report of 14028 instances on a real in-situ frame meant 14028 +#: filled paths, which hangs the renderer and composites into one flat green +#: sheet that shows nothing at all. Well above any legitimate outline count. +_MAX_OUTLINE_POLYS = 1500 + #: The raster overlay's colour/opacity. Deliberately the same green as the #: vector outlines so crossing the threshold does not look like a mode change. _RASTER_ALPHA = 0.45 +#: A preview that calls this much of the window foreground, AND shatters it into +#: this many pieces, is a FAILED THRESHOLD being reported as a result. +#: +#: Measured on a synthetic stand-in for the reported frame (low-contrast noisy +#: support film, 8 faint dark particles, 1024²) — a global threshold has no +#: bimodal histogram to find here, so otsu lands in the middle of the noise: +#: +#: defaults ............................. 4873 instances, 39% coverage +#: min_size=200 .......................... 208 instances, 14% coverage +#: min_size=2000 .......................... 17 instances, 7.2% coverage +#: watershed off .......................... 751 instances, 40% coverage +#: gaussian=2 + min_size=200 + no watershed . 8 instances, 52% coverage +#: +#: The last row is the trap and the reason the test is on BOTH numbers: 8 +#: instances looks like the 8 real particles, but at 52% coverage those 8 bodies +#: ARE the film. Neither number alone is diagnostic — a genuinely crowded frame +#: can be 40% covered by real particles, and a coarse threshold can find 8 real +#: objects. Together they are: thousands of pieces AND half the frame is the +#: signature of noise being segmented. +#: +#: Deliberately NOT a silent auto-correction. The plan's §0.9 answer to this +#: data is the scribble classifier, and no threshold tweak substitutes for it — +#: so the caret's job is to say the threshold failed and point at Scribble, not +#: to quietly pick different parameters that fail differently. +_FAIL_COVERAGE = 0.25 +_FAIL_COUNT = 500 + + +def _threshold_failed(count: int, coverage: float) -> bool: + """True when a preview is noise being reported as particles. + + Both conditions, for the reason spelled out on `_FAIL_COVERAGE`. + """ + return count >= _FAIL_COUNT and coverage >= _FAIL_COVERAGE + def _box_poly(box) -> list: """The preview window as a one-polygon list, or empty when there is none. @@ -1107,12 +1255,26 @@ def _work(): t0 = time.perf_counter() labels = engine(frame) from spyde.particles import measure_frame - rows, contours = measure_frame(labels, frame, t=t, scale=scale) + # Outlines only when they can actually be drawn. `labels.max()` is the + # instance count for the price of one pass, and above the draw cap the + # overlay discards the polygons anyway — extracting thousands of them + # first is pure latency on the tune the user is waiting for (measured: + # ~half of a 1353 ms preview at 4873 instances). The measured PROPERTIES + # are all still computed, so the count, the histogram, the median and + # the confidence filter are unaffected. + n_lab = int(np.asarray(labels).max()) if np.asarray(labels).size else 0 + rows, contours = measure_frame( + labels, frame, t=t, scale=scale, + want_contours=n_lab <= _MAX_OUTLINE_POLYS) n_all = len(rows) rows, contours = filter_by_score(rows, contours, p.get("min_score", 0.0)) + # What FRACTION of the previewed window was called foreground. This is + # the one number that separates "found the particles" from "the + # threshold landed inside the noise" — see `_threshold_failed`. + coverage = float((np.asarray(labels) > 0).mean()) if labels.size else 0.0 return {"frame": t, "labels": labels, "rows": rows, "n_all": n_all, "contours": contours, "elapsed": time.perf_counter() - t0, - "box": box, "full_shape": full.shape} + "coverage": coverage, "box": box, "full_shape": full.shape} def _done(res): # Release the navigator gate BEFORE the generation guard: a superseded or @@ -1136,7 +1298,8 @@ def _done(res): # full-frame array first — at 4096² that array alone was 16 MB per frame. wiz.set_overlay(res["contours"], res.get("box"), labels=res.get("labels"), - full_shape=res.get("full_shape")) + full_shape=res.get("full_shape"), + n_instances=int(len(rows))) # The count reported is the count AFTER the size filter (plan §0.9b) — # `split_instances` applies min_size last, so `rows` is already filtered # and this number is the one the histogram below describes. @@ -1156,6 +1319,18 @@ def _done(res): # 4096² frame where only the middle megapixel was looked at. "preview_box": (None if res.get("box") is None else [int(v) for v in res["box"]]), + # The threshold-failure verdict (see `_threshold_failed`). Reported + # rather than silently swallowed: the count is still true, it just + # is not an ANSWER, and the caret has to say which of the two it is + # holding. + "coverage": round(float(res.get("coverage") or 0.0), 4), + "threshold_failed": _threshold_failed( + int(len(rows)), float(res.get("coverage") or 0.0)), + # What the two FACE sliders are in — 'nm' on a real-space axis, 'px' + # when the axis is not a length (a reciprocal-space signal reports a + # healthy positive scale in nm^-1, and labelling that 'nm' is a + # claim about the scale bar that is simply false). + "face_units": _face_units(scale, units), }) if p["min_size_floored"]: emit_status( @@ -1191,14 +1366,14 @@ def seg_open(session, plot, payload) -> None: if existing is not None and not existing._closed: # Idempotent re-open: adopt the new parameters and re-preview rather # than building a second controller with its own scribbles. - existing.params = _coerce(payload) + existing.set_params(payload) gen = existing.guard() _emit_state(existing) _preview(existing, gen) return wiz = SegmentWizard(session, tree, src) - wiz.params = _coerce(payload) + wiz.set_params(payload) # BEFORE anything deferred: React StrictMode fires open/close/open # synchronously, so the close's bump must be able to invalidate this open. gen = wiz.guard() @@ -1253,7 +1428,7 @@ def seg_tune(session, plot, payload) -> None: wiz = _wizard(session, plot) if wiz is None: return - wiz.params = _coerce({**wiz.params, **(payload or {})}) + wiz.set_params(payload, merge=True) # Paint state must reach the WIDGET, not just the params dict — the widget is # what tags a stroke, so a class change that stops here paints the old colour. _sync_brush(wiz) @@ -1716,7 +1891,7 @@ def seg_run(session, plot, payload) -> None: if wiz is None: wiz = SegmentWizard(session, tree, src) tree._seg_wizard = wiz - wiz.params = _coerce({**wiz.params, **(payload or {})}) + wiz.set_params(payload, merge=True) p = dict(wiz.params) engine = _engine(wiz, p) diff --git a/spyde/particles/measure.py b/spyde/particles/measure.py index b361e601..1bef6102 100644 --- a/spyde/particles/measure.py +++ b/spyde/particles/measure.py @@ -128,6 +128,7 @@ def measure_frame( background_ring: int = 3, min_area_px: int = 0, fast: bool | None = None, + want_contours: bool = True, ) -> tuple[np.ndarray, list[np.ndarray]]: """Measure every instance in *labels*. @@ -153,6 +154,13 @@ def measure_frame( Force the vectorised property path on/off. ``None`` reads ``SPYDE_PARTICLE_PROPS``. The two paths are asserted equal column by column in ``test_particles_props_parity.py``. + want_contours + Extract the per-instance outlines. ``False`` returns an EMPTY contour + list and skips that work — for a caller that only needs the measured + properties. Every stored result needs the outlines (the 1:1 + correspondence below is what ``SpyDEParticles.from_frames`` requires), + so this is only safe for a transient consumer such as a live preview + that has already decided the instances are too numerous to draw. Returns ------- @@ -161,7 +169,8 @@ def measure_frame( :data:`spyde.signals.particles.COLUMNS`, with ``track_id`` set to -1. ``contours`` is a list of ``(k, 2)`` int16 ``(y, x)`` outlines, one per row and in the same order — the 1:1 correspondence - ``SpyDEParticles.from_frames`` requires. + ``SpyDEParticles.from_frames`` requires. Empty when *want_contours* is + False, which breaks that correspondence by design. """ lab = np.asarray(labels) if lab.ndim != 2: @@ -217,10 +226,17 @@ def measure_frame( if inten is not None: _fill_intensity(rows, lab, inten, tbl, keep, background_ring, fast=fast) - contours = _contours(lab, tbl, fast=fast) + # Outlines are ~half the cost of a measure once the instance count runs into + # the thousands (measured on a 1024² over-segmented frame: 647 ms total, of + # which the 4873 contours are the bulk; the same frame filtered to 17 + # instances measures in 62 ms). The live PREVIEW asks for them only when it + # is actually going to draw them, which it will not do above the overlay's + # draw cap. Every other caller keeps the default and is unaffected. + contours = _contours(lab, tbl, fast=fast) if want_contours else [] rows = rows[keep] - contours = [c for c, k in zip(contours, keep) if k] + if want_contours: + contours = [c for c, k in zip(contours, keep) if k] # Score LAST: it is derived from the intensity columns filled above, so it # costs no extra pass over the frame. That is what lets the caret filter on # it without re-segmenting — see `particle_scores`. diff --git a/spyde/tests/migrated/test_particles_wizard.py b/spyde/tests/migrated/test_particles_wizard.py index df0ee13c..d3087c8a 100644 --- a/spyde/tests/migrated/test_particles_wizard.py +++ b/spyde/tests/migrated/test_particles_wizard.py @@ -527,6 +527,226 @@ def test_a_junk_value_keeps_the_default(self): pa.DEFAULTS["min_separation"] +class TestPhysicalFaceControls: + """`merge_nm` / `min_nm` are the two controls on the caret's face, and they + are in NANOMETRES — so the whole feature is the nm→px conversion. + + It was dead on arrival. ``_nm_to_px`` divides by ``p["scale"]`` and falls + back to "the value IS pixels" when there is no scale, which is the right + behaviour for an uncalibrated signal — but ``_coerce`` builds the dict from + the ``DEFAULTS`` keys alone, so no dispatch path ever put a scale in it and + EVERY signal took the uncalibrated branch. A slider reading "50 nm" merged + at 50 pixels, wrong by exactly the magnification and silent about it. + + Hence the first test: it asserts the scale is on the params after each + dispatch verb, which is the thing that was missing, rather than asserting + ``_nm_to_px`` divides — that part was always correct. + """ + + def test_every_dispatch_stashes_the_signal_scale(self, window): + session, plot, _tree, wiz = _opened(window) + scale = wiz.scale_units()[0] + assert scale > 0, "the fixture must be calibrated or this proves nothing" + + assert wiz.params["scale"] == scale, "seg_open did not stash the scale" + pa.seg_tune(session, plot, {"merge_nm": 30.0}) + assert _wait(lambda: wiz.params.get("merge_nm") == 30.0) + assert wiz.params["scale"] == scale, "seg_tune dropped the scale" + pa.seg_set_method(session, plot, {"method": "classical"}) + assert wiz.params["scale"] == scale, "seg_set_method dropped the scale" + + # The particle fixture is calibrated at 1.0 nm/px, which makes every + # conversion below the identity — so these pass an explicit NON-UNIT scale + # instead of reading the fixture's. At 1.0 the arithmetic tests hold whether + # or not the conversion happens at all, which is exactly how it shipped + # unconverted. Only the dispatch test above needs a real wizard. + + def test_merge_nm_reaches_the_solver_in_PIXELS(self): + """A nm distance must be divided by the scale before it is a radius.""" + p = pa._coerce({"merge_nm": 30.0}) + assert pa._segment_kwargs({**p, "scale": 0.4})["merge_distance"] == \ + pytest.approx(75.0) + + def test_min_nm_is_a_DIAMETER_and_converts_through_the_AREA(self): + """`min_size` filters on AREA, so handing it a diameter straight over + under-filters by a factor of ~d — a face control that barely does + anything is worse than one that is not there.""" + p = pa._coerce({"min_nm": 20.0}) + d_px = 20.0 / 0.4 + assert pa._min_size_px({**p, "scale": 0.4}) == \ + int(round(np.pi / 4.0 * d_px * d_px)) + # and not the diameter, which is the plausible wrong answer + assert pa._min_size_px({**p, "scale": 0.4}) != int(d_px) + + def test_min_nm_wins_over_the_pixel_min_size(self): + """Both exist (one on the face, one in Advanced) and they filter the same + thing, so the physical one has to be the tie-break — otherwise the face + control is silently overridden by a value the user cannot see.""" + p = pa._coerce({"min_nm": 20.0, "min_size": 25}) + assert pa._min_size_px({**p, "scale": 0.4}) != 25 + assert pa._min_size_px({**p, "min_nm": 0.0, "scale": 0.4}) == 25 + + def test_an_uncalibrated_signal_reads_the_value_as_pixels(self): + """No scale is the only case where nm==px is correct, and it must not + divide by zero to get there.""" + assert pa._nm_to_px(12.0, {}) == 12.0 + assert pa._nm_to_px(12.0, {"scale": 0.0}) == 12.0 + assert pa._nm_to_px(0.0, {"scale": 0.5}) == 0.0 + + def test_a_length_axis_converts_through_NANOMETRES(self): + """An axis in µm or Å is still a length; the slider is still nm.""" + assert pa._length_nm_per_px(2.0, "nm") == pytest.approx(2.0) + assert pa._length_nm_per_px(2.0, "um") == pytest.approx(2000.0) + assert pa._length_nm_per_px(2.0, "µm") == pytest.approx(2000.0) + assert pa._length_nm_per_px(2.0, "Å") == pytest.approx(0.2) + # ...so "20 nm" is 10 px at 2 nm/px and 0.01 px at 2 µm/px. + assert pa._nm_to_px(20.0, {"scale": pa._length_nm_per_px(2.0, "nm")}) \ + == pytest.approx(10.0) + + def test_a_RECIPROCAL_axis_is_not_a_length_and_falls_back_to_pixels(self): + """The reported bug: the caret said 'nm' on an axis reading nm⁻¹. + + A reciprocal-space signal reports a perfectly healthy positive scale, so + the conversion ran and produced a merge radius wrong by whatever the + camera length was — silently, because nothing checked the UNIT. There is + no distance to convert to here, so the controls are pixels and the caret + has to relabel; claiming nm is a claim about the scale bar that is false. + """ + for units in ("nm^-1", "1/nm", "nm⁻¹", "mrad", "px", "", None): + assert pa._length_nm_per_px(0.9, units) == 0.0, units + assert pa._face_units(0.9, units) == "px", units + assert pa._face_units(0.5, "nm") == "nm" + # and the pixel fallback is the IDENTITY, not a division by a number in + # the wrong unit + assert pa._nm_to_px(30.0, {"scale": pa._length_nm_per_px(0.9, "nm^-1")}) \ + == 30.0 + + def test_the_wizard_stashes_nm_per_px_not_the_raw_axis_scale(self, window): + _s, _p, _t, wiz = _opened(window) + scale, units = wiz.scale_units() + assert wiz.params["scale"] == pytest.approx( + pa._length_nm_per_px(scale, units)) + + +class TestThresholdFailureIsNotAResult: + """14028 'particles' covering the frame is a failed threshold, not an answer. + + Measured on a low-contrast noisy stand-in for the reported frame: otsu has + no bimodal histogram to find, lands inside the noise, and the split shatters + the support film. `min_size` alone moves 4873 -> 17 instances but coverage + only 39% -> 7%, and the settings that DO yield 8 instances cover 52% of the + frame -- those 8 bodies ARE the film. So neither number alone is diagnostic + and the verdict needs both. + """ + + def test_both_conditions_are_required(self): + assert pa._threshold_failed(5000, 0.40) is True + # A crowded frame of REAL particles: many instances, but they do not + # blanket the frame. + assert pa._threshold_failed(5000, 0.05) is False + # A coarse threshold that found a few big real objects. + assert pa._threshold_failed(12, 0.40) is False + assert pa._threshold_failed(0, 0.0) is False + + def test_a_normal_preview_is_not_flagged(self, window): + """The fixture must stay UNflagged or the notice is just noise.""" + session, plot, _tree, _wiz = _opened(window) + msgs = window["messages"] + pa.seg_tune(session, plot, {"sensitivity": 0.55}) + assert _wait(lambda: len(_of_type(msgs, "seg_preview")) >= 2) + msg = _of_type(msgs, "seg_preview")[-1] + assert msg["threshold_failed"] is False, msg + assert 0.0 <= msg["coverage"] <= 1.0 + + def test_the_preview_reports_coverage_and_face_units(self, window): + session, plot, _tree, _wiz = _opened(window) + msgs = window["messages"] + pa.seg_tune(session, plot, {"sensitivity": 0.5}) + assert _wait(lambda: len(_of_type(msgs, "seg_preview")) >= 2) + msg = _of_type(msgs, "seg_preview")[-1] + assert "coverage" in msg and "face_units" in msg + assert msg["face_units"] in ("nm", "px") + + +class TestOutlineDrawCap: + """No route may hand the renderer an unbounded number of polygons. + + `_RASTER_ABOVE` picks the nicer drawing; this cap is the seatbelt for when + the raster is unavailable. It had none, so a tile-mode raster failure fell + back to one filled path per instance -- 14028 of them, which hangs the + renderer and composites into a flat green sheet showing nothing. + """ + + def test_the_cap_is_well_above_any_real_outline_count(self): + assert pa._MAX_OUTLINE_POLYS > pa._RASTER_ABOVE * 5 + + def test_a_huge_contour_list_draws_NONE_rather_than_all(self, monkeypatch): + import types + pushed = {} + monkeypatch.setattr(pa, "_push_groups", lambda p, u: pushed.update( + {g.name: pl.get("vertices_list") for g, pl in u.items()})) + + w = object.__new__(pa.SegmentWizard) + w._ov_group = None + w._ov_box_group = None + w._ov_raster = False + w._ov_cleared = False + w._ov_box_state = None + # No `set_overlay_mask` on this plot => the raster path is unavailable, + # which is exactly the state the tile-mode ValueError used to produce. + w.src_plot = types.SimpleNamespace(_plot2d=types.SimpleNamespace()) + + # A plain class, NOT SimpleNamespace: the group is used as a dict KEY in + # the `_push_groups` payload, and SimpleNamespace defines __eq__ so it + # is unhashable. + class _Group: + name = "seg_preview_outline" + + monkeypatch.setattr(pa.SegmentWizard, "_overlay_group", + lambda self, p: _Group()) + monkeypatch.setattr(pa.SegmentWizard, "_window_group", lambda self, p: None) + + n = pa._MAX_OUTLINE_POLYS + 1 + w.set_overlay([np.zeros((3, 2), np.int16) for _ in range(n)], None, + labels=None, n_instances=n) + assert pushed.get("seg_preview_outline") == [], ( + f"{n} polygons reached the renderer; the cap is " + f"{pa._MAX_OUTLINE_POLYS}") + + +class TestMeasureSkipsOutlinesItCannotDraw: + """Contours are ~half a preview's cost once instances run to thousands.""" + + def test_want_contours_false_skips_them_but_keeps_every_measurement(self): + from spyde.particles import measure_frame + + lab = np.zeros((64, 64), np.int32) + lab[5:15, 5:15] = 1 + lab[30:40, 30:40] = 2 + frame = np.random.default_rng(0).random((64, 64)).astype(np.float32) + + rows_a, cont_a = measure_frame(lab, frame, t=0, scale=0.5) + rows_b, cont_b = measure_frame(lab, frame, t=0, scale=0.5, + want_contours=False) + assert len(cont_a) == 2 and cont_b == [] + # The ROWS -- every measured property, including the score the + # confidence filter reads -- must be untouched by the flag. + assert np.array_equal(rows_a, rows_b) + + def test_commit_refuses_a_preview_whose_outlines_were_skipped(self, window): + """A store needs one contour PER ROW; committing without them would + build a silently mis-corresponding dataset.""" + session, plot, _tree, wiz = _opened(window) + msgs = window["messages"] + wiz.preview = {"frame": 0, "labels": np.zeros((8, 8), np.int32), + "rows": np.zeros((3, 4), np.float32), "contours": [], + "count": 3, "areas": np.zeros(3), "box": None} + assert wiz.commit() is None + assert any("too many to commit" in str(m.get("text", "")) + for m in msgs if isinstance(m, dict)), \ + "commit failed silently instead of saying why" + + class TestBrushActuallyPaints: """The regression guard for "I can't scribble". @@ -1064,6 +1284,25 @@ def test_switching_to_an_untrained_engine_clears_stale_outlines(self, monkeypatc "the outline group was not cleared, so the old engine's particles " "stay on screen") + def test_switching_to_an_untrained_engine_clears_the_RASTER_too(self, monkeypatch): + """The other drawing route, which this used to miss entirely. + + Above `_RASTER_ABOVE` instances the overlay is ONE mask, not outlines. + `show_preview_window` cleared only the outlines, so the previous + engine's mask survived the switch — and on the Scribble tab that covers + the image you have to paint on. The reported symptom was exactly "moved + to scribble, immediately unusable". + """ + import spyde.actions.particles_action as pa + monkeypatch.setattr(pa, "_push_groups", lambda p, u: None) + wiz = self._wiz() + cleared = [] + monkeypatch.setattr(pa.SegmentWizard, "_clear_raster_overlay", + lambda self: cleared.append(True)) + wiz.show_preview_window() + assert cleared, ("the raster mask was left on screen; on Scribble it " + "covers the frame the user has to paint on") + class TestRasterOverlayAboveThreshold: """Hundreds of vector contours make the app sluggish; draw one mask instead. @@ -1074,20 +1313,46 @@ class TestRasterOverlayAboveThreshold: crisp at any zoom, and each is an object the UI can hover. """ + # A REAL Plot2D, not a stub that records whatever it is handed. + # + # This class used to fake `set_overlay_mask`, and that fake is why the bug + # it was written to prevent shipped anyway: SpyDE reduced the mask to the + # overview grid (right for the renderer), the REAL `set_overlay_mask` + # rejected that shape against `image_width` (the full frame in tile mode), + # `_set_raster_overlay` swallowed the ValueError at DEBUG, and every + # large-frame preview fell back to N polygons — at 14028 instances, a hung + # renderer painted solid green. The stub asserted the mask SpyDE built and + # was green throughout, because it never enforced the contract that failed. + # + # So these tests now assert on the bytes that actually SHIP. Whichever layer + # does the reduction, the renderer's rule is the same and is the only thing + # worth pinning: `bytes.length === (base_width || image_width) * (…)`. @staticmethod def _p2d(base_w=0, base_h=0): - class _P2D: - def __init__(self): - self._state = {"base_width": base_w, "base_height": base_h, - "image_width": 4096, "image_height": 4096} - self.mask = "unset" + from anyplotlib.plot2d import Plot2D + full = np.zeros((4096, 4096), np.uint8) + if base_w: + p = Plot2D(full, tile="auto") + assert p._state.get("base_width"), "tile mode did not set a base grid" + else: + p = Plot2D(full) + return p - def set_overlay_mask(self, mask, color=None, alpha=None): - self.mask = mask + @staticmethod + def _p2d_small(n): + """A plot small enough that anyplotlib does NOT put it in tile mode.""" + from anyplotlib.plot2d import Plot2D + return Plot2D(np.zeros((n, n), np.uint8)) - def add_polygons(self, *a, **k): - return None - return _P2D() + @staticmethod + def _shipped(p2d): + """(bytes_len, expected_len) for the mask currently on *p2d*.""" + import base64 + st = p2d._state + b64 = st.get("overlay_mask_b64") or "" + want = (int(st.get("base_width") or 0) or int(st["image_width"])) * \ + (int(st.get("base_height") or 0) or int(st["image_height"])) + return len(base64.b64decode(b64)), want @staticmethod def _wiz(p2d): @@ -1109,26 +1374,38 @@ def _labels(): return lab def test_untiled_mask_is_the_native_frame_size(self): - p = self._p2d() - assert self._wiz(p)._set_raster_overlay( - self._labels(), (1536, 1536, 1024, 1024), (4096, 4096)) - assert p.mask.shape == (4096, 4096) + """A frame small enough to escape tile mode ships at its native size. - def test_tiled_mask_is_built_at_the_OVERVIEW_size(self): - """The trap this exists for. + Small ON PURPOSE: anyplotlib tiles anything with a >=1024 px edge, so + there is no such thing as an untiled 4096² plot and asking for one here + tested nothing. 512² is the branch where `base_width` stays 0 and the + renderer falls back to `image_width`. + """ + p = self._p2d_small(512) + lab = np.zeros((512, 512), np.int32) + lab[100:110, 100:110] = 1 + assert self._wiz(p)._set_raster_overlay(lab, None, (512, 512)) + assert not p._state.get("base_width"), "512² unexpectedly tiled" + sent, want = self._shipped(p) + assert sent == want == 512 * 512 + + def test_tiled_mask_SHIPS_at_the_OVERVIEW_size(self): + """The trap this exists for, asserted where it actually bites. In tile mode the renderer checks ``bytes.length === iw * ih`` where - ``iw = base_width || image_width`` — the OVERVIEW size. A - native-resolution mask fails that check and is dropped SILENTLY: no - error, no overlay, nothing in the log. So the mask must be reduced to - the overview grid, exactly. + ``iw = base_width || image_width`` — the OVERVIEW size — and on a + mismatch sets ``maskCache=null``: no error, no overlay, nothing in the + log. So what matters is not which layer reduces the mask but that the + bytes leaving Python are the size the renderer will accept. """ p = self._p2d(1024, 1024) assert self._wiz(p)._set_raster_overlay( - self._labels(), (1536, 1536, 1024, 1024), (4096, 4096)) - assert p.mask.shape == (1024, 1024), ( - f"mask is {p.mask.shape} but the renderer expects the overview " - f"grid (1024, 1024) — it would draw nothing at all") + self._labels(), (1536, 1536, 1024, 1024), (4096, 4096)), \ + "the raster overlay refused to draw on a tiled frame" + sent, want = self._shipped(p) + assert sent == want, ( + f"mask ships {sent} bytes but the renderer expects {want} — it " + f"would silently draw nothing at all") def test_the_reduction_keeps_small_particles(self): """Block ANY, not a subsample. @@ -1136,10 +1413,13 @@ def test_the_reduction_keeps_small_particles(self): Particles are often a few pixels across; striding a 4096² mask down to 1024² would drop three quarters of them at random. """ + import base64 p = self._p2d(1024, 1024) self._wiz(p)._set_raster_overlay( self._labels(), (1536, 1536, 1024, 1024), (4096, 4096)) - assert p.mask.sum() > 0, "every particle vanished in the reduction" + sent = np.frombuffer( + base64.b64decode(p._state["overlay_mask_b64"]), np.uint8) + assert sent.any(), "every particle vanished in the reduction" def test_below_the_threshold_nothing_is_rastered(self): """Few particles → keep the crisp, hoverable vector outlines.""" @@ -1152,5 +1432,5 @@ def test_below_the_threshold_nothing_is_rastered(self): contours = [np.zeros((3, 2), np.int16) for _ in range(3)] wiz.set_overlay(contours, None, labels=self._labels(), full_shape=(4096, 4096)) - assert p.mask in ("unset", None), ( + assert not (p._state.get("overlay_mask_b64") or ""), ( "a 3-particle frame was rastered; the outlines are better there") From c835fa0c0d4d994e48415b065dd9e81ea304a965 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 2 Aug 2026 11:01:48 -0500 Subject: [PATCH 36/38] fix(seg): the caret threw on first render, and three controls lied `ee74ad6` swapped the Confidence slider for two nanometre sliders and left four things broken at once. None was visible to pytest or tsc, and `segment_wizard.spec.ts` -- which asserts the caret is visible on its first line -- would have caught the worst of them on its own. It had not been run. `Field` WAS USED BUT NEVER IMPORTED, so the caret threw on mount and the window came up blank. One line; it was already exported from WizardShell. CONFIDENCE WENT NOWHERE. The commit's comment says it moved to Advanced. It did not: `min_score` kept its state, its payload field and a working backend filter with no control anywhere able to move it off 0. Restored under Advanced, which is where that comment always said it was -- and it matters, because it is the only control that cuts over-split film texture, which is small AND round and so survives every size and shape filter in there. THE nm SLIDERS ARE ONLY nm ON A LENGTH AXIS. They divide by the signal's scale, and a reciprocal-space signal reports a perfectly healthy positive scale in nm^-1 -- so the conversion ran and produced a merge radius wrong by the camera length while the caret still read "nm". The label now follows `face_units`: nm where the axis is a real-space length (via `_NM_PER`, so um and A work too), px where it is not. ADVANCED DID NOT FIT. Stacked in one column it measured 907 px in an 805 px MDI area, and the caret has no scroller of its own (the Threshold menu is absolutely positioned, so an overflow:auto ancestor clips it) -- so the size histogram and Commit Frame were unreachable, not merely cramped. Two columns is plan B7's own answer and the only one that neither deletes a control nor needs a scroller; the caret widens only while Advanced is open, so the collapsed face stays calm. ...which then exposed a placement bug worth its own paragraph: a side-placed caret anchors its RIGHT edge to the window's left, so at 520 px it walked off the left edge of the app entirely and its controls became unclickable. FloatingToolbar clamps side placements into the MDI area now -- overlapping the owning window is recoverable, being off-screen is not -- and the caret's measured width is mirrored into state so the clamp re-runs when a disclosure changes it. When there IS room the arithmetic is identical to before. `expectCaretFits` checks BOTH AXES now. Vertically-only is how the off-screen placement got through: the two-column caret fit vertically while half of it sat outside the viewport. The control-count guard goes 7 -> 9 for the two nm sliders. It stays exact on purpose -- it is what stops the face refilling one reasonable-looking addition at a time -- and the three filters that had no coverage now have some, each polling `data-seq` to prove the control reaches the backend rather than merely existing, which is exactly what a dead slider passes. --- .../src/components/FloatingToolbar.tsx | 33 +- .../renderer/src/components/SegmentWizard.tsx | 340 ++++++++++++------ electron/tests/segment_wizard.spec.ts | 113 +++++- 3 files changed, 372 insertions(+), 114 deletions(-) diff --git a/electron/src/renderer/src/components/FloatingToolbar.tsx b/electron/src/renderer/src/components/FloatingToolbar.tsx index ae96530a..f1c5d2a2 100644 --- a/electron/src/renderer/src/components/FloatingToolbar.tsx +++ b/electron/src/renderer/src/components/FloatingToolbar.tsx @@ -127,6 +127,9 @@ export function FloatingToolbar({ // caret's absolute positioning) used only to measure the caret's real size. const caretWrapRef = React.useRef(null) const caretBox = React.useRef<{ w: number; h: number } | null>(null) + /** The measured caret width, mirrored into state so the side-placement clamp + * re-runs when a caret changes width without changing placement. */ + const [caretW, setCaretW] = React.useState(240) const live = state.activeActions.get(windowId) ?? EMPTY // Keep the toolbar shown while a popout/caret is open or an action is live — @@ -160,6 +163,12 @@ export function FloatingToolbar({ next = wr.x + wr.w + CARET_GAP + cw <= area.w ? 'right' : 'left' } setPlacement(p => (p === next ? p : next)) + // The WIDTH has to be state, not just the ref: the side placements clamp + // with it (see `caretPos`), and the ref is written in a layout effect. If + // the placement itself does not change there is no re-render, so the + // clamp would keep using the previous caret's width — which for a caret + // that widens on a disclosure is exactly the case that needs it. + setCaretW(w => (w === cw ? w : cw)) } React.useLayoutEffect(() => { place.current() }) @@ -249,12 +258,30 @@ export function FloatingToolbar({ // Where the bar's TOP edge sits in window coords — carets are DOM children of // the bar, so the side placements are expressed relative to it. const barTopInWin = inside ? wr.h - BAR_H - BAR_GAP : wr.h + BAR_GAP + // Where a SIDE-placed caret's left edge wants to be, in MDI-area coords, then + // CLAMPED into the area. `left` anchors the caret's RIGHT edge to the + // window's left edge, so a caret wider than the room beside the window simply + // walked off the edge of the app and its controls became unclickable — + // Playwright's "element is outside of the viewport", and for a user a panel + // with its labels sliced off. Segment's two-column Advanced (520 px) is the + // first caret wide enough to hit it. Overlapping the owning window is + // recoverable; being off-screen is not, so the clamp wins. + // + // When there IS room this is arithmetically identical to the old + // marginLeft/marginRight pair — the clamp is a no-op and nothing moves. + const sideLeft = placement === 'right' + ? wr.x + wr.w + CARET_GAP + : wr.x - CARET_GAP - caretW + const clampedLeft = Math.max(0, Math.min(sideLeft, area.w - caretW)) const caretPos: React.CSSProperties = placement === 'below' ? { position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: CARET_GAP } - : placement === 'right' - ? { position: 'absolute', top: -barTopInWin, left: '50%', marginLeft: wr.w / 2 + CARET_GAP, transform: 'none' } - : { position: 'absolute', top: -barTopInWin, right: '50%', marginRight: wr.w / 2 + CARET_GAP, left: 'auto', transform: 'none' } + : { + position: 'absolute', top: -barTopInWin, left: '50%', + // The bar is centred on the window, so `left:50%` is the window's + // midline — walk from there to the clamped absolute position. + marginLeft: clampedLeft - (wr.x + wr.w / 2), transform: 'none', + } // The Segment brush strip sits ON the figure (plan B0: while painting you are // looking at the image), not in the caret. Same coordinate trick the side diff --git a/electron/src/renderer/src/components/SegmentWizard.tsx b/electron/src/renderer/src/components/SegmentWizard.tsx index 94a703d1..f3bd108c 100644 --- a/electron/src/renderer/src/components/SegmentWizard.tsx +++ b/electron/src/renderer/src/components/SegmentWizard.tsx @@ -12,6 +12,8 @@ * ┌ Segment Particles ──────── ✕ ┐ * │ [Classical] [Scribble] [Prompt]│ * │ Fewer ────●──── More │ + * │ Merge closer than ──●── 12 nm │ + * │ Ignore smaller than ─●── off │ * │ 6 particles on this frame │ * │ [ Find in all frames ] │ * │ ▸ Advanced │ @@ -19,12 +21,19 @@ * * Four things here are load-bearing, none of them cosmetic: * - * 1. **Sensitivity is the ONLY control on the default face.** Measured (plan - * §0.9), not taste: teaching the classifier faint contrast buys +1 true - * particle and 25 spurious ones, and `min_size=10` removes 24 of the 25. - * But the floor is applied by the BACKEND unconditionally, so the user does - * not have to know that — `min_size` is a recovery knob, not a tuning knob, - * and it lives in Advanced next to the floor warning that explains it. + * 1. **The default face carries the task, and its two shared controls are + * PHYSICAL.** `merge_nm` and `min_nm` are distances the eye can check against + * the scale bar, and they act on the measured instances rather than on any one + * method's parameters — so the face is identical on all three engines, and only + * Classical adds the sensitivity slider above them. Their nm→px conversion + * needs the DISPLAYED signal's scale, which `SegmentWizard.set_params` stashes + * on every dispatch; without it the backend silently reads nanometres as pixels. + * Everything else is Advanced. Measured (plan §0.9), not taste: teaching the + * classifier faint contrast buys +1 true particle and 25 spurious ones, and + * `min_size=10` removes 24 of the 25. But the floor is applied by the BACKEND + * unconditionally, so the user does not have to know that — `min_size` is a + * recovery knob, not a tuning knob, and it lives in Advanced next to the floor + * warning that explains it. * * 2. **The EFFECTIVE `min_size` is what is shown.** The backend floors it and * reports the floored value + a flag in every `seg_preview`; the caret snaps @@ -43,12 +52,20 @@ * missing example. On the Classical tab there is nothing to train, so the list * is not shown there. * - * 4. **Nothing was deleted, only demoted.** Every control that left the primary - * face is inside `▸ Advanced` and sends the identical action with the - * identical payload — `params()` is unchanged and the backend schema is - * untouched. Advanced is collapsed by default and remembers its state for the - * session (module-scope, not per-window: it is a preference about the UI, not - * about a dataset). The one place Advanced is tab-scoped is the classical + * 4. **Nothing was deleted, only demoted — and Advanced is TWO COLUMNS.** Every + * control that left the primary face is inside `▸ Advanced` and sends the + * identical action with the identical payload — `params()` is unchanged and + * the backend schema is untouched. Stacked in one column that block reached + * 907 px in an 805 px MDI area, and the caret cannot scroll (the Threshold + * menu is absolutely positioned and any `overflow:auto` ancestor clips it), + * so the histogram and Commit Frame were unreachable rather than merely + * cramped. Two columns — params you SET on the left, feedback the frame + * gives you on the right — is plan B7's own answer and the only one that + * neither deletes a control nor needs a scroller; the caret widens to + * `ADV_WIDTH` only while it is open. Advanced is collapsed by default and + * remembers its state for the session (module-scope, not per-window: it is a + * preference about the UI, not about a dataset). The one place it is + * tab-scoped is the classical * MASK block (threshold / pre-blur / rolling ball / local window / dark), and * that is correctness rather than tidiness: the scribble engine hands * `split_instances` a probability map thresholded at 0.5 and never reads @@ -60,7 +77,7 @@ * list. */ import React from 'react' -import { WizardShell, TabRow, Slider, Select, Check, NumInput, S } from './WizardShell' +import { WizardShell, TabRow, Slider, Select, Check, NumInput, Field, S } from './WizardShell' import { useWizardLifecycle, useDebouncedAction, useWizardEvent, CommitButton } from './wizardHooks' import type { SendAction } from './wizardHooks' import { ClassStrip } from './ClassStrip' @@ -159,6 +176,13 @@ interface Preview { /** `[y0, x0, h, w]` when the frame was too big to segment whole, else null — * the count then describes that window, not the frame. */ preview_box: [number, number, number, number] | null + /** Fraction of the previewed window called foreground, and the backend's + * verdict that this is a failed threshold rather than a result. */ + coverage: number + thresholdFailed: boolean + /** What the two FACE sliders are in: 'nm' on a real-space axis, 'px' when + * the signal's axis is not a length (reciprocal space, mrad, uncalibrated). */ + faceUnits: string /** Monotonic per-caret preview counter. The COUNT is not a reliable "did it * re-run" signal (two sensitivities can find the same number of particles), * so the caret publishes this as `data-seq` for the e2e to poll instead. */ @@ -286,6 +310,9 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo preview_box: (Array.isArray(d.preview_box) && d.preview_box.length === 4 ? (d.preview_box.map(Number) as [number, number, number, number]) : null), + coverage: Number(d.coverage ?? 0), + thresholdFailed: Boolean(d.threshold_failed), + faceUnits: String(d.face_units ?? 'nm'), seq: (prev?.seq ?? 0) + 1, })) // Never leave a number on screen that is not the one that ran. Snapping is @@ -377,6 +404,13 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo } const areaUnits = preview ? `${preview.units}²` : 'px²' + // The two face sliders are in nm ONLY when the signal's axis is a real-space + // length. On a reciprocal-space signal (axis in nm⁻¹) there is no distance to + // convert to, the backend falls back to pixels, and the label has to follow — + // a slider reading "50 nm" that acts on 50 px is a claim about the scale bar + // that is false. + const faceUnits = preview?.faceUnits ?? 'nm' + const fmtFace = (v: number) => (v ? `${v} ${faceUnits}` : 'off') // On a frame too large to segment whole the backend previews a centred crop, // so say "in this region" rather than "on this frame" — the number is true of // the box, not of the frame, and claiming otherwise would understate the count @@ -392,7 +426,12 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo { const n = Number(e.target.value); setMergeNm(n); tune() }} /> - {mergeNm ? `${mergeNm} nm` : 'off'} + {fmtFace(mergeNm)}
@@ -445,7 +484,7 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo min={0} max={200} step={1} value={minNm} style={{ flex: 1, minWidth: 40 }} onChange={(e) => { const n = Number(e.target.value); setMinNm(n); tune() }} /> - {minNm ? `${minNm} nm` : 'off'} + {fmtFace(minNm)}
@@ -483,9 +522,34 @@ export function SegmentWizard({ caretPos, windowId, sendAction, onClose, stripPo
{countText}
+ + {/* ── the threshold-failed verdict ───────────────────────────────── + A global threshold on a low-contrast frame has no bimodal histogram + to find, so it lands inside the noise and the split shatters the + support film into thousands of pieces. Reported as "14028 particles + in this region" that reads as a real (if bad) answer, and the + natural response is to reach for the sliders — none of which can fix + it. Measured on a stand-in for the reported frame: min_size alone + takes 4873 → 17 instances but coverage only 39% → 7%, and the + settings that DO yield 8 instances cover 52% of the frame, i.e. the + 8 bodies are the film. + + So the caret names the failure and points at the engine that does + work on this data (plan §0.9: the learned classifier is the primary + path, not threshold tuning). It does NOT silently re-tune. */} + {preview?.thresholdFailed && ( +
+ The threshold landed inside the noise — {preview.count.toLocaleString()} + {' '}pieces covering {Math.round(preview.coverage * 100)}% of the + {box ? ' region' : ' frame'}. That is the film being segmented, not + particles.{isScribble ? ' Paint a few examples and Train.' + : ' Try Scribble: paint a few examples and Train.'} +
+ )} {box && (
+ {/* ── Advanced: TWO COLUMNS (plan B7) ────────────────────────────── + Single-column, this ran 907 px tall in an 805 px MDI area, and the + caret has no scroller of its own (the Threshold menu is absolutely + positioned, so an `overflow:auto` ancestor clips it) — so the + histogram and Commit Frame were not merely awkward, they were + unreachable. Two columns is plan B7's own answer to that, and it + halves the height without demoting anything further or deleting + anything, which §0.9a rules out. + + The split is params | feedback: the left column is everything you + SET, the right is everything the frame TELLS you plus the button + that keeps it. Balance matters as much as the grouping — the + classical-only `detection` block is the tallest thing here, and + with it on the left the two columns come out close to even. */} {advanced && (
-
size filter
- - - - {/* The floor warning belongs HERE, under the field it explains — on - the primary face it was a large orange alarm about a parameter - the backend already fixed on the user's behalf. */} - {preview?.floored && ( -
- floored to {preview.minSize} px — at 0 the split returns - background speckle as particles -
- )} - - - - -
splitting
- - - - - - n.toFixed(1)} /> - - - - {/* CLASSICAL ONLY, and not merely for space: these build the - classical MASK. The scribble engine hands `split_instances` a - probability map thresholded at 0.5, so it never reads threshold / - sensitivity / gaussian / rb_kernel / invert / local_size — see - `spyde/particles/classical.py::split_instances`. Rendering them - on the Scribble tab would be six knobs that do nothing, which is - the overload complaint in miniature. They keep their stored - values and are still sent in every payload. */} - {method === 'classical' && ( - <> -
detection
- - { const n = Number(e.target.value); setMinScore(n); tune() }} /> + + {minScore ? `${Math.round(minScore * 100)}%` : 'off'} + +
+ +
size filter
+ + - - + {/* The floor warning belongs HERE, under the field it explains + — on the primary face it was a large orange alarm about a + parameter the backend already fixed on the user's behalf. */} + {preview?.floored && ( +
+ floored to {preview.minSize} px — at 0 the split returns + background speckle as particles +
+ )} + + - - - )} -
output
- - - - - - -
this frame
-
size {areaUnits}
- -
- {preview ? `med ${fmtArea(preview.median)} ${areaUnits}` : '—'} -
-
- {labelledFrames.length} frames labelled · {classes.length} classes - {labelledPixels > 0 ? ` · ${labelledPixels.toLocaleString()} px` : ''} - {` · frame ${frame}`} +
splitting
+ + + + + + n.toFixed(1)} /> + + + + {/* CLASSICAL ONLY, and not merely for space: these build the + classical MASK. The scribble engine hands `split_instances` + a probability map thresholded at 0.5, so it never reads + threshold / sensitivity / gaussian / rb_kernel / invert / + local_size — see + `spyde/particles/classical.py::split_instances`. Rendering + them on the Scribble tab would be six knobs that do nothing, + which is the overload complaint in miniature. They keep + their stored values and are still sent in every payload. */} + {method === 'classical' && ( + <> +
detection
+ +