Skip to content

Trainable segmentation: paint, train, and label images, movies and 4-D scans - #154

Open
CSSFrancis wants to merge 2 commits into
mainfrom
feat/trainable-segmentation
Open

CSSFrancis wants to merge 2 commits into
mainfrom
feat/trainable-segmentation

Conversation

@CSSFrancis

Copy link
Copy Markdown
Member

The core of #117 / #122, rebuilt on main: trainable segmentation that runs on a single image, on every frame of an in-situ movie, and on the real-space navigator of a 4-D scan (where a region is a set of scan positions). ~3k lines including tests, against the quarry's 34k.

Drift correction, the other half of #117, already landed as #130; nothing here touches it beyond reusing spyde.drift.frame_source to read frames.

What is in

spyde/segmentation/ — the compute package. Nothing in it knows about sessions or plots, and nothing materialises a stack.

module what lines
features.py separable Gaussian bank (σ = 1, 2, 4) + a 4×-decimated σ = 25 background reference and the contrast against it; one robust normalisation per field 220
labels.py scribbles as flat indices with class and stroke id, last write wins, JSON round-trip 190
classifier.py one 1×1-conv head; trains on the stroke bounding boxes only, predicts band-by-band with a halo, smooths the logits; pickles as numpy state and rebuilds lazily 330
instances.py boundary route = connected components + a flat watershed that gives the seam back (no distance transform); otherwise the classical distance-transform watershed; one min_size floor 120
measure.py scikit-image regionprops_table, calibrated once; intensity stats over finite pixels only (drift borders) 110
fields.py one accessor for the three dataset shapes 70

spyde/signals/regions.pyRegions(RaggedStore): one row per instance, indexed by field. space records whether a pixel is an image pixel or a scan position, so per-region diffraction on a 4-D scan is well-defined later. The label movie is not stored: it is a lazy view recomputed from the classifier, the way a drift-corrected node is warped.

spyde/actions/segment_action.py + SegmentWizard.tsx — the caret: class strip (particle / background / boundary / erase), brush size, Train, Run; the split parameters behind Advanced. Strokes go anyplotlib brush → pointer_up → label store; a trained classifier retrains on every new stroke and repaints its mask. One result door for every shape (open_result_tree), tree.regions attached through attach_container.

What was left behind, deliberately

  • the 1,300-line numba region-property kernels (props/contours/hull/intensity): scikit-image does it, numba was never a declared dependency, and feat(seg): the fast segmentation engine, non-rigid drift, and a test suite that can fail #122's own CI note says it miscompiles on cp310/cp311 and macOS
  • the two dead engines (scribble.py ScribbleClassifier, scribble_cnn.py), the classical engine remnants, the SAM bootstrap
  • tracking, the overlay editor, the table dock, the dual-lane batch dispatcher, the autolabel test door, the nm↔px slider layer
  • the early-placeholder result window and its mutate-in-place store

Two things the reviewers flagged and this PR adopts: the container is named Regions rather than Particles (right for grains on a scan and for a still image; lifecycle.wait_for_particles had no callers and is now wait_for_regions), and the per-stroke weighting the quarry documented but never implemented (hasattr(store, "stroke_ids") was always False) is real here.

Two findings worth your eyes

  1. Band / crop alignment. The decimated background reference only equals its full-field value if a band or a training crop starts on the decimation grid and the upsample uses the exact factor. The quarry did neither, so training saw features prediction never produced. Pinned by test_a_band_reproduces_the_full_field_exactly (array_equal, not allclose) and test_a_stroke_crop_features_match_the_full_field.

  2. Mask dilation on small particles. On the synthetic movie (particles r = 3–9 px, soft 1 px edge) the classifier's mask is larger than the disc, and the logit smoothing widens it further — measured area / true area at frame 0:

    logit σ 0 1 2 3 (default)
    area / truth 1.82 2.03 2.57 3.28

    Every particle is still found within 0.6 px and the unpainted faint probe is found from what the classifier learnt. The σ = 3 default is the quarry's, measured on real 15 px / CNR 0.17 data where it halved fragmentation; the fixture is the opposite regime. Finer discriminative scales (0.7, 1.4, 2.8) bring the fixture to 1.25. I kept the real-data defaults and left both as constructor parameters, not caret controls — this wants a measurement on your data before moving.

Verification

  • test_segmentation.py (25), test_regions.py (2), test_segment_wizard.py (11, movie / image / 4-D navigator through the real brush widget), conformance + schema + drift + lifecycle files green, tsc clean
  • run in the app: electron/tests/segment_wizard.spec.ts — a real Shift+drag paints (the class picked on the caret is the one painted), Train overlays the mask, Run opens the label movie with a regions-per-frame navigator. Screenshots below.

The core of the quarry branch (#117 / #122), rebuilt on main in 3k lines
instead of 34k: paint a few strokes, train a linear per-pixel classifier
over a small feature bank, and label every field — one image, each frame
of an in-situ movie, or the real-space navigator of a 4-D scan, where a
region is a set of scan positions.

spyde/segmentation/ is the compute package (no session, no plots):
features (separable Gaussian bank + a 4x-decimated sigma=25 background
reference), labels (scribbles as flat indices with a stroke id, so every
stroke weighs the same in the fit), classifier (one 1x1-conv head,
trained on the stroke bounding boxes only, predicted band by band with a
halo, logits smoothed), instances (boundary-stroke route = connected
components + a flat-elevation watershed to give the seam back; otherwise
the distance-transform watershed), measure (scikit-image regionprops,
calibrated once), fields (one accessor for the three dataset shapes,
reading one frame at a time through spyde.drift.frame_source).

Regions(RaggedStore) is the result table: one row per instance indexed
by field, with `space` recording whether a pixel is an image pixel or a
scan position. The label movie is a lazy view recomputed from the
classifier, the way a drift-corrected node is warped — nothing is stored.

What was left behind, deliberately: the 1,300-line numba region-property
kernels (scikit-image does it; numba was never a declared dependency and
miscompiles on two Python versions), the two dead engines, tracking, the
overlay editor, the table dock, the dual-lane batch dispatcher, the
autolabel test door, the nm/px slider conversions.

A band and a training crop now start on the decimation grid and the
background reference resamples by its exact factor, so a band reproduces
the full field bit for bit; without that, training saw features that
prediction never produced.

Verified in the app (electron/tests/segment_wizard.spec.ts): a real
Shift+drag paints, Train overlays the mask, Run opens the label movie
with a regions-per-frame navigator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant