Skip to content

Commit c0fa0d8

Browse files
bsheppclaude
andcommitted
Expose rolling_ball's hidden interpolation orders as proper kwargs
decompose_rolling_ball inlined a down→process→up round-trip with order=1 on the way down and order=3 on the way up, both hardcoded. That asymmetry shaped every radius>150 result in the existing 39,731-combination sweep but was invisible to any parameter recording — the redundancy analysis could not tell whether two rolling_ball outputs differed because of radius or because of the fixed-but-unrecorded interpolation pair. Add roundtrip_resample(arr, factor, down_order, up_order, transform=None) in src/utils/preprocessing.py as a reusable helper. Refactor decompose_rolling_ball to take down_order, up_order, target_effective_radius, and large_radius_threshold as kwargs, with defaults that preserve current behavior bit-for-bit so existing sweep results stay valid. Register down_order and up_order in the decomposition registry so they show up in any future sweep's recorded params. All 166 tests pass. Verified directly that the round-trip path (radius=200) produces the expected shape and that the newly exposed kwargs change the output (L2 distance ~1126 between default and symmetric orders). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6ca835c commit c0fa0d8

2 files changed

Lines changed: 107 additions & 26 deletions

File tree

src/decomposition/methods_extended.py

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -460,48 +460,75 @@ def decompose_anisotropic_diffusion(
460460
@register_decomposition(
461461
name="rolling_ball",
462462
category="morphological",
463-
default_params={"radius": 50},
464-
param_ranges={"radius": [10, 25, 50, 100, 200]},
463+
default_params={
464+
"radius": 50,
465+
"down_order": 1,
466+
"up_order": 3,
467+
"target_effective_radius": 75,
468+
"large_radius_threshold": 150,
469+
},
470+
param_ranges={
471+
"radius": [10, 25, 50, 100, 200],
472+
"down_order": [0, 1, 2, 3],
473+
"up_order": [1, 3, 5],
474+
},
465475
preserves="features smaller than ball radius",
466476
destroys="background curvature, large-scale variation",
467477
)
468-
def decompose_rolling_ball(dem: np.ndarray, radius: int = 50) -> tuple:
478+
def decompose_rolling_ball(
479+
dem: np.ndarray,
480+
radius: int = 50,
481+
down_order: int = 1,
482+
up_order: int = 3,
483+
target_effective_radius: int = 75,
484+
large_radius_threshold: int = 150,
485+
) -> tuple:
469486
"""
470487
Rolling ball background subtraction.
471488
472489
Simulates a ball rolling under the surface. Common in microscopy.
473490
Effective for removing large-scale curvature while preserving local features.
474491
475-
For large radii (>150), uses a downsampling approach to reduce memory usage
476-
while maintaining equivalent results for trend extraction.
477-
"""
478-
from scipy.ndimage import zoom
492+
For large radii (> large_radius_threshold), runs on a downsampled copy of
493+
the DEM to bound memory and CPU. The downsample factor is chosen so the
494+
effective ball radius on the small grid is ~target_effective_radius.
495+
496+
Args:
497+
radius: Rolling ball radius in pixels. Smaller = finer features kept.
498+
down_order: scipy.ndimage.zoom interpolation order used on the
499+
downsample step of the round-trip. Default 1 (linear). Previously
500+
hardcoded; exposed so it can be swept and recorded.
501+
up_order: scipy.ndimage.zoom interpolation order used on the upsample
502+
step of the round-trip. Default 3 (cubic). Asymmetric with
503+
down_order by historical default — exposed so callers can pick
504+
symmetric pairs if they want.
505+
target_effective_radius: When downsampling, the ball radius is rescaled
506+
so that the small-grid radius is approximately this value. Lower =
507+
coarser intermediate grid = faster but blurrier trend. Default 75.
508+
large_radius_threshold: Radii above this trigger the downsample
509+
round-trip; radii at or below run directly on the full grid.
510+
Default 150.
511+
"""
512+
from ..utils.preprocessing import roundtrip_resample
479513

480514
dem_filled = fill_nans(dem)
481515
original_shape = dem_filled.shape
482516

483-
# For large radii, use downsampling to reduce memory
484-
# Threshold chosen based on memory constraints
485-
large_radius_threshold = 150
486-
487517
if radius > large_radius_threshold:
488-
# Downsample factor: aim for effective radius of ~75
489-
downsample_factor = radius / 75
490-
491-
# Downsample the DEM
492-
dem_small = zoom(dem_filled, 1.0 / downsample_factor, order=1)
518+
downsample_factor = radius / target_effective_radius
493519
effective_radius = int(radius / downsample_factor)
494520

495-
# Run rolling ball on smaller DEM
496-
trend_small = _rolling_ball_core(dem_small, effective_radius)
497-
498-
# Upsample the trend back to original size
499-
trend = zoom(trend_small, downsample_factor, order=3)
500-
501-
# Handle size mismatch from zoom rounding
502-
if trend.shape != original_shape:
503-
# Crop or pad to match
504-
trend = _match_shape(trend, original_shape)
521+
# Run rolling ball on a coarse copy, then round-trip back to full size.
522+
# The down/up interpolation orders are deliberately separable kwargs
523+
# so the asymmetry shows up in any parameter sweep that records them.
524+
trend = roundtrip_resample(
525+
dem_filled,
526+
factor=downsample_factor,
527+
down_order=down_order,
528+
up_order=up_order,
529+
transform=lambda small: _rolling_ball_core(small, effective_radius),
530+
target_shape=original_shape,
531+
)
505532
else:
506533
trend = _rolling_ball_core(dem_filled, radius)
507534

src/utils/preprocessing.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import numpy as np
8+
from scipy.ndimage import zoom
89

910

1011
def fill_nans(dem: np.ndarray) -> np.ndarray:
@@ -20,3 +21,56 @@ def fill_nans(dem: np.ndarray) -> np.ndarray:
2021
Array with NaN values replaced by the mean of non-NaN values.
2122
"""
2223
return np.nan_to_num(dem, nan=np.nanmean(dem))
24+
25+
26+
def roundtrip_resample(
27+
arr: np.ndarray,
28+
factor: float,
29+
down_order: int = 1,
30+
up_order: int = 3,
31+
transform=None,
32+
target_shape: tuple = None,
33+
) -> np.ndarray:
34+
"""Downsample arr by 1/factor, optionally transform on the coarse grid,
35+
then upsample back by factor.
36+
37+
Used by decompositions that operate on a coarser grid for memory or
38+
speed reasons (e.g. rolling_ball at large radii). Returning the
39+
round-tripped array as a single utility makes the two interpolation
40+
orders visible to callers — they used to be hardcoded inside callers,
41+
invisible to any parameter sweep or reproducibility log.
42+
43+
Args:
44+
arr: 2-D input array.
45+
factor: Downsample factor (>1 means coarser intermediate grid).
46+
down_order: scipy.ndimage.zoom interpolation order on the way down.
47+
Default 1 (linear).
48+
up_order: scipy.ndimage.zoom interpolation order on the way up.
49+
Default 3 (cubic). down_order and up_order are deliberately
50+
independent — callers can pick asymmetric pairs that match
51+
their tolerance for smoothing vs. ringing in each direction.
52+
transform: Optional callable applied to the downsampled array
53+
before upsampling, signature transform(small_arr) -> small_arr.
54+
Lets callers do work on the coarse grid (e.g. morphological
55+
opening at a smaller effective radius) while keeping the
56+
resample bookkeeping in one place.
57+
target_shape: Optional (h, w) to crop/pad the output to. Useful
58+
when zoom rounding produces an off-by-one mismatch with the
59+
input shape and the caller needs an exact size match.
60+
61+
Returns:
62+
Resampled 2-D array. Shape matches target_shape if given,
63+
otherwise approximately matches the original (may differ by 1
64+
pixel per axis due to zoom rounding at non-integer factors).
65+
"""
66+
small = zoom(arr, 1.0 / factor, order=down_order)
67+
if transform is not None:
68+
small = transform(small)
69+
upscaled = zoom(small, factor, order=up_order)
70+
if target_shape is not None and upscaled.shape != target_shape:
71+
out = np.zeros(target_shape, dtype=upscaled.dtype)
72+
min_h = min(upscaled.shape[0], target_shape[0])
73+
min_w = min(upscaled.shape[1], target_shape[1])
74+
out[:min_h, :min_w] = upscaled[:min_h, :min_w]
75+
return out
76+
return upscaled

0 commit comments

Comments
 (0)