Skip to content

feat: peak_local_max with footprint, labels and num_peaks_per_label - #54

Merged
Paullo9 merged 20 commits into
mainfrom
issue-12-peak-footprint-labels
Oct 31, 2026
Merged

Paullo9 merged 20 commits into
mainfrom
issue-12-peak-footprint-labels

Conversation

@Paullo9

@Paullo9 Paullo9 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Requested by Paul · project thread

Part of #12 (the peak_local_max footprint/labels bullet). Still open in #12: spline shifts in constant and wrap modes at orders 2 to 5, and the masked-registration tie rule.

This PR sits on top of #53 (issue-12-profile-multichannel), which sits on #52 and #51. The stack already touches docs/parity.md, docs/open-items.md and the consumer check, so this branch builds on it instead of becoming another PR that edits those files. Retarget it once #53 merges.

Summary

PeakLocalMax.find gains three parameters: footprint: PeakFootprint?, labels: [Int]? and numPeaksPerLabel: Int?. They sit between numPeaks and pNorm and all have defaults, so every existing call compiles unchanged. The behaviour follows scikit-image 0.26.0.

footprint

  • It replaces the (2·min_distance + 1)² square in the maximum filter. The origin is at size / 2, the mode is nearest, and the footprint is not flipped. A full rectangle takes the separable path.
  • min_distance still spaces the peaks and sets the default border.
  • An all-false footprint of more than one cell is refused, as SciPy refuses it.

labels

  • Each positive label is searched inside its own bounding box. The box is taken from the labels left after the border cut.
  • Inside the box, pixels outside the label (judged by the uncut labels) are set to the lowest finite value.
  • The trivial-image rule applies per label: if every pixel equals its filtered maximum, only the pixels removed by a binary opening of the label mask (default cross) are kept.
  • Each label keeps at most numPeaksPerLabel peaks, collected in label order. When more than numPeaks remain, they are sorted and spaced again over the whole image.
  • symmetry together with labels is refused.

Tests

  • tools/peak-footprint-labels/pins.py (arm64, NumPy 2.5.4, SciPy 1.18.1, scikit-image 0.26.0) writes peak-footprint-labels-pins.json with 198 cases, float32 and float64. Coordinates must match exactly, in order. The cases cover:
    • asymmetric, centreless and one-corner footprints, which a flip or a wrong origin would change
    • negative labels and gaps in label numbers
    • a constant label one pixel wide
    • labels side by side under num_peaks
    • num_peaks_per_label=0 and min_distance=0
  • The refusals are unit tests in ImagoFiltersTests.
  • Red first: PeakFootprintLabelsTests failed to compile on the base (cannot find 'PeakFootprint').
  • Green: the new pins, the existing 166 + 7 peak pins and the unit gate (--skip ImagoKitParityTests) all pass.
  • A reviewer checked the numerics and the public API before the commit, including scratch runs of SciPy for the footprint origin (no flip). It asked for four changes, all made:
    • refuse the all-false footprint
    • add the flip-sensitive and label edge-case pins
    • use the separable path for a full rectangle
    • move the refusal tests to the unit target
  • One edge case is not pinned: a −inf pixel with labels and threshold_abs=-inf (background pixels then pass the threshold). The JSON fixture format carries no infinities; the behaviour follows from the same code path.

🤖 Generated with Claude Code

Paullo9 and others added 11 commits October 31, 2026 02:01
RollingBall.background (radius and kernel overloads) takes nanSafe: Bool = false, the
nansafe=True of scikit-image 0.26.0 rolling_ball: a NaN anywhere in the kernel rectangle,
cells outside the ball included, makes the pixel NaN, and so does -inf under such a cell.
Without nanSafe a NaN is still refused. 21 cases pinned by tools/rolling-ball-nansafe/pins.py
at assert_allclose dtype defaults (the wheel ships no tests).

Done: rolling_ball nansafe from #10. Next: the EDT return_indices gap of #10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DistanceTransform.euclideanWithIndices returns the distances and, per pixel, the row and
column of the nearest background pixel: SciPy 1.18.1 distance_transform_edt with
return_indices=True, 2D. The feature transform is the Voronoi sweep of SciPy ported term by
term, with the fused multiply-adds of its arm64 build, which decide exact ties under a
non-dyadic sampling. 33 cases pinned by tools/edt-indices/pins.py: indices exact, distances
1e-12. The sampling and shape guards are shared with euclidean.

Done: EDT return_indices from #10. Next: watershed compactness and watershed_line of #10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An integer and .nan literal list leaves the generic sample type to inference; a typed
array makes the check compile the same way on every toolchain.

Done: consumer check typed. Next: the EDT return_indices PR stacked on this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Segmentation.watershed takes compactness: Double = 0 and watershedLine: Bool = false,
scikit-image 0.26.0 segmentation.watershed with compactness= and watershed_line=, 2D: the
pixel is labelled when it leaves the queue, each queued copy keeps its marker, the compact
cost is image + compactness * distance to the marker (fused as the arm64 build computes it)
raised to the popped value, no line is drawn with compactness, and a line pixel leaves the
mask with label 0 but still passes the flood on. 59 cases pinned by
tools/watershed-compact/pins.py, labels exact. A negative or non-finite compactness is
refused. The plain path is unchanged.

Done: watershed compactness and watershed_line from #10. Next: the docs/parity.md and
open-items rows for it once the two PRs below it merge; the N-D and denoise gaps need a decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#44 landed on main as a squash. docs/open-items.md conflicted on the 0.8.0 gaps line: kept this branch wording, which drops "without return_indices" now that euclideanWithIndices is in. The diff against main equals the diff this PR had against its old base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The base branch was resynced with main after #44 landed; this brings main in through it, with no conflicts. The PR now targets main and still holds #45 until #45 merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WindowType gains triang, parzen, bohman, barthann, cosine, exponential(tau:), taylor(nbar:sll:norm:),
lanczos, kaiserBesselDerived(beta:), generalCosine(a:), generalGaussian(p:sig:), generalHamming(alpha:),
dpss(nw:) and chebwin(at:), so window1D and window2D now cover every family of SciPy 1.18.1 get_window
(symmetric, as scikit-image 0.26.0 filters.window asks for it). dpss is the largest eigenvector of the
SciPy tridiagonal matrix by bisection and inverse iteration (the LAPACK stebz and stein that SciPy calls),
with norm approximate; chebwin sums its DFT directly from one cosine table. kaiserBesselDerived refuses an
odd length, dpss an NW outside (0, M/2), taylor an nbar below 1, as SciPy does.

Pinned by tools/window-families/pins.py: 162 2D cases, 34 1D cases (length 2 for every family, lengths
1023 to 4096 for dpss and chebwin) and 4 refusals. The 1D tolerance is the one
scipy/signal/tests/test_windows.py uses for each family; the 2D window has no upstream test and takes the
float64 assert_allclose defaults widened by that family tolerance.

Migration: WindowType is a public enum without a default case; a switch over it with no default must
handle the new cases or add one.

Done: the window families of #12. Next: butterworth and difference_of_gaussians with channel_axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…axis)

BandpassFilters.butterworth (both overloads) and differenceOfGaussians gain channels: Int = 1. Above 1
the image is read channel-last, as scikit-image 0.26.0 channel_axis=-1, and each channel is filtered
alone: scikit-image filters only the spatial axes (butterworth broadcasts its mask over the channels and
crops away the npad it adds to the channel axis; difference_of_gaussians has sigma 0 there). One channel
takes the old path unchanged. A channel count that does not fit the pixel count is refused (.shape).

Pinned by tools/bandpass-channels/pins.py: 16 butterworth and 9 difference_of_gaussians cases, Float32 and
Float64, at the single-channel filter gate (assert_allclose dtype defaults; the wheel ships no tests). The
pin script also checks that channel_axis=0 and each channel alone give the same answer.

Done: the channel_axis bullet of #12. Next: profile_line multichannel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LineProfile.sample(channels:) and unreduced(channels:) take one Micrograph per channel (scikit-image 0.26.0
profile_line on a channel-last 3D image) and profile each channel on the same perpendicular lines, which is
what scikit-image does: one map_coordinates per channel, then reduce_func(pixels, axis=1). The answer is one
LineProfile per channel, or [channel][sample][k] unreduced. No channel, or channels of different sizes,
throw the new LineProfileError.channelShape.

Pinned by tools/profile-multichannel/pins.py: 360 cases (orders 0, 1, 3, 5; the five modes; widths 1 to
16; the five reductions and reduce_func None), float32 and float64, at assert_allclose dtype defaults per
element (the wheel ships no tests). Also corrects the header note on the float32 width sum (it is the
pairwise sum of the flipped row, as the code says).

Migration: LineProfileError is a public enum; an exhaustive switch over it must handle .channelShape.

Done: the profile_line multichannel bullet of #12. Next: peak_local_max with footprint and labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PeakLocalMax.find gains footprint: PeakFootprint?, labels: [Int]? and numPeaksPerLabel: Int?, between
numPeaks and pNorm, all defaulted, so every existing call compiles unchanged. As scikit-image 0.26.0:
the footprint replaces the (2 min_distance + 1) square in the maximum filter (origin at size / 2, mode
nearest, no flip; a full rectangle takes the separable path), while min_distance still spaces the peaks
and sets the default border. With labels each positive label is searched in the bounding box of what the
border cut leaves of it, other pixels set to the lowest finite value, with the trivial-image rule that
keeps the pixels the binary opening of the label mask removes; at most numPeaksPerLabel per label, in
label order, then a global re-sort and re-spacing when more than numPeaks remain. An all-false footprint
of more than one cell is refused as SciPy refuses it; symmetry with labels is refused.

Pinned by tools/peak-footprint-labels/pins.py: 198 cases, coordinates exact and in order (asymmetric,
centreless and corner footprints, negative and gapped labels, a constant one-pixel-wide label, labels
side by side under num_peaks, min_distance 0). Refusals in ImagoFiltersTests.

Done: the peak_local_max footprint and labels bullet of #12. Next: spline shifts constant and wrap at
orders 2 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9 Paullo9 self-assigned this Oct 31, 2026
@Paullo9
Paullo9 changed the base branch from issue-12-profile-multichannel to main October 31, 2026 11:56
Paullo9 and others added 9 commits October 31, 2026 13:40
The watershed row says compactness and watershed_line are held (#46, tools/watershed-compact); the rolling_ball row
names the kernel overflow fix (#47, tools/rolling-ball-kernel-overflow); tools/README.md lists tools/edt-indices and
tools/watershed-compact; open-items no longer says watershed lacks compactness. Every row on main stays. The rest of
this chain inherits these lines.

Done: chain base re-synced with main and its docs brought up to main. Next: merge it up the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/parity.md: the butterworth and difference_of_gaussians rows keep this branch, which adds channels:; the window row next to them keeps the all-families text from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/open-items.md: main dropped the profile_line multichannel item and this
branch closes the peak_local_max footprint/labels item, so the study-row line
goes away entirely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9
Paullo9 merged commit dd4522f into main Oct 31, 2026
8 checks passed
@Paullo9
Paullo9 deleted the issue-12-peak-footprint-labels branch October 31, 2026 22:01
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