From 8e209c5d11eb2f10b59e6010b1f8338e8ed13dea Mon Sep 17 00:00:00 2001 From: Kurt Windisch Date: Thu, 5 Nov 2026 11:22:24 -0800 Subject: [PATCH 1/2] fix(crop): keep the whole picture on high-key frames Auto Crop kept a fragment of the picture on some film scanner frames. Thin areas of a negative are as bright as the clear rebate, and two stages read that brightness as the end of the film or of the picture. A film box with two supported sides now reaches within 5% of the frame at its other sides. Film that runs off the scan does. A box that stops further in ended where a threshold mask ran out on picture content. A tier occupancy run now counts only when clear rebate lies past each end. A high-key picture dips under the run threshold mid-frame, and the edge walk reads those frames correctly. On 678 frames from a real library, 17 crops changed. Of 13 frames with a hand-drawn crop, 12 moved to it: the worst edge error fell from 61% of the frame to under 1%, and all 12 ended within 4.5%. One moved 0.1% and stays about 11% off. The other 4 hold a saved auto crop. No crop changed on the 139 1:1 frames. Co-Authored-By: Claude Opus 5.5 --- docs/PIPELINE.md | 4 ++-- negpy/features/geometry/logic.py | 3 ++- negpy_core/src/analysis.rs | 18 ++++++++++++++++++ negpy_core/src/detect.rs | 15 +++++++++++++++ tests/test_autocrop_consensus.py | 20 ++++++++++++++++++++ tests/test_geometry_logic.py | 29 +++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 3 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 86c5b752f..c452b4f7b 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -14,10 +14,10 @@ These steps run in order, and each stage passes its buffer to the next. A helper `keystone_matrix_normalized` is the one definition of the quad. The pixel matrix, the GPU inverse, the uv grid, `map_coords_to_geometry`, autocrop's replay and detection key, and the GPU analysis replay all derive from it. Off-frame card-edge handles need `CoordinateMapping` to fit the grid projectively. * **Crop Empty Corners** (`geometry.crop_to_valid`): the largest axis-aligned, centered rect that rotation and keystone leave void-free, used as the ROI when no crop is set. `compute_geometry_crop_rect` ternary-searches the half-width with a nested binary search for the half-height. Distortion is excluded, because it scales to fill. * **Square to Film** (`geometry.skew`): sets fine rotation, tilt and swing from the frame's straight film and gate edges, never the picture. A length-weighted mode of long segments squares the frame. Each side, searched inside the detector's box or its snap window, is fitted as a line, and Gauss-Newton levels the lines through the app's own warp. A correction needs box-led lines and an opposite pair (left/right for tilt, top/bottom for swing). A keystone is kept only between 0.3% and 5%, and only where it explains the edges clearly better than rotation alone. -* **Autocrop**: finds the film edge from the density jump to the scanner bed. Detection runs once per edit in `ImageProcessor`, ahead of both engines, and stores the rect (`geometry.crop_rect`, origin in `crop_from_auto`) with its detection key (`autocrop_detection_key`: orientation, ratio, mode, rebate trim). A key change re-detects. Both engines only slice that rect, because a preview and a full-res export can stop on different edges. Crop Offset is not in the key; every render re-applies it. **Square First** (`autocrop_square`) runs the Square to Film fit (`skew.squared_geometry`) once per arm on a frame without its own skew and detects on the squared geometry. +* **Autocrop**: finds the film edge from the density jump to the scanner bed. A box with only two supported sides must reach within 5% of the frame edge at its other sides: film that runs off the scan does, and a threshold mask that ran out on picture content does not. Detection runs once per edit in `ImageProcessor`, ahead of both engines, and stores the rect (`geometry.crop_rect`, origin in `crop_from_auto`) with its detection key (`autocrop_detection_key`: orientation, ratio, mode, rebate trim). A key change re-detects. Both engines only slice that rect, because a preview and a full-res export can stop on different edges. Crop Offset is not in the key; every render re-applies it. **Square First** (`autocrop_square`) runs the Square to Film fit (`skew.squared_geometry`) once per arm on a frame without its own skew and detects on the squared geometry. The surround of a box must read as bed (uniform, near-clipping) or as a dark holder, else detection falls back to the full frame. Opaque edges are trimmed off the box, up to 20% a side; a run that uses the whole allowance trims nothing. A box under a quarter of the frame is not film. On `Free` the ratio comes from the detected frame, snapped to the nearest real film format. -* **Rebate Trim** (*Image only*): refines the film box inward to the exposed image by one of three routes. A uniformity probe works when bed, rebate and image tiers separate. A per-side edge walk (`measure_film_edges`) serves camera scans with thin rebates: a 3% pass, and a 10% pass adopted only when it is deeper and passes four consistency tests. A per-side ring measurement (`measure_film_border`) needs an opposite pair, because a bright sky at the frame edge reads like film base. 0 stops at the film edge, 1.0 at the image edge, and above 1.0 cuts into the picture. Crop All Frames scales its roll-pooled inset by the same factor, each side pooling on its own sample count. In a roll, a portrait frame resolves from its own crop, a frame with no film box is placed from the roll, and deskew is not pooled. +* **Rebate Trim** (*Image only*): refines the film box inward to the exposed image by one of three routes. A uniformity probe works when bed, rebate and image tiers separate, and each end of its occupancy run must have clear rebate past it (mean occupancy at most 0.25 over 2% of the side), because a high-key picture dips under the run threshold mid-frame. A per-side edge walk (`measure_film_edges`) serves camera scans with thin rebates: a 3% pass, and a 10% pass adopted only when it is deeper and passes four consistency tests. A per-side ring measurement (`measure_film_border`) needs an opposite pair, because a bright sky at the frame edge reads like film base. 0 stops at the film edge, 1.0 at the image edge, and above 1.0 cuts into the picture. Crop All Frames scales its roll-pooled inset by the same factor, each side pooling on its own sample count. In a roll, a portrait frame resolves from its own crop, a frame with no film box is placed from the roll, and deskew is not pooled. The crop selection becomes the analysis ROI. The pixels are cropped only after Toning, so Lab and the preview overlays see the full frame. The Analysis Buffer excludes the outer X%, and a freehand region (`analysis_rect`) sets exactly what the meters read. diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index dee666d3e..0c5f07633 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -235,7 +235,8 @@ def _refine_film_roi_by_tiers(lum: np.ndarray, film_roi: ROI) -> Optional[Tuple[ rebate/image midpoint, take the longest occupancy runs, then snap each edge to the strongest local luminance gradient. Returns (roi, row_occupancy, col_occupancy) in detection-image coords (profiles padded to full image length), or None when tiers - are not separable (caller falls back to the Sobel path). + are not separable or a run does not end at clear rebate (caller falls back to the + edge walk). """ return negpy_core.refine_film_roi_by_tiers(np.ascontiguousarray(lum, dtype=np.float32), tuple(int(v) for v in film_roi)) diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 48c000ae3..9d3de1912 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -1198,6 +1198,11 @@ pub fn roi_from_measured_border( // Smallest fraction of a film-box side the tier refinement may keep. A rebate never takes half // a side, so anything below this is a broken occupancy run, not a frame. const TIER_MIN_KEEP: f64 = 0.5; +// A run ends at the picture edge only when clear rebate lies past it: the lines within this +// fraction of the side hold at most this mean occupancy. A high-key picture dips under the run +// threshold with far more occupancy than that. +const TIER_EDGE_REACH: f64 = 0.02; +const TIER_EDGE_MAX_OCCUPANCY: f64 = 0.25; /// `_smooth_signal(signal, window)`: `np.convolve(signal, ones(window) / window, "same")` in /// NumPy's float32 arithmetic. A full-overlap output of a window up to 11 goes through NumPy's @@ -1320,6 +1325,17 @@ fn longest_run_above(profile: &[f64], threshold: f64) -> Option<(i64, i64)> { Some((best.0, best.1 + 1)) } +/// Whether the lines just past each end of the half-open run read as clear rebate. +fn run_ends_at_rebate(profile: &[f64], (start, end): (i64, i64)) -> bool { + let reach = py_round(TIER_EDGE_REACH * profile.len() as f64).max(3); + let clear = |band: &[f64]| { + band.is_empty() || band.iter().sum::() / band.len() as f64 <= TIER_EDGE_MAX_OCCUPANCY + }; + let before = (start - reach).max(0) as usize..start as usize; + let after = end as usize..((end + reach) as usize).min(profile.len()); + clear(&profile[before]) && clear(&profile[after]) +} + /// `_estimate_tier_levels(lum, film_roi)`: (bed, rebate, image, ring spread), or None. fn estimate_tier_levels( lum: &[f32], @@ -1387,6 +1403,8 @@ pub fn refine_film_roi_by_tiers( let (hl, hr) = longest_run_above(&col_occ, 0.55)?; if ((vb - vt) as f64) < TIER_MIN_KEEP * bh as f64 || ((hr - hl) as f64) < TIER_MIN_KEEP * bw as f64 + || !run_ends_at_rebate(&row_occ, (vt, vb)) + || !run_ends_at_rebate(&col_occ, (hl, hr)) { return None; } diff --git a/negpy_core/src/detect.rs b/negpy_core/src/detect.rs index cc88c0366..25795a91d 100644 --- a/negpy_core/src/detect.rs +++ b/negpy_core/src/detect.rs @@ -18,6 +18,10 @@ const ADAPTIVE_THRESHOLD_COUNT: f64 = 7.0; // Film fills most of a scan under every known holder; on a positive whose bed clips to white, a // dark subject can pass every other check on its own geometry. const MIN_FILM_BOX_COVERAGE: f64 = 0.25; +// How far inside the frame, as a fraction of it, a box with only two supported sides may leave +// another side. Film that runs off the scan shows two edges and reaches the frame at the other +// two, so a side further in is where a threshold mask ran out on picture content. +const MAX_UNSUPPORTED_INSET: f64 = 0.05; pub const SIDE_NAMES: [&str; 4] = ["top", "right", "bottom", "left"]; pub const CORNER_SIDES: [(&str, [usize; 2]); 4] = [ ("top_left", [0, 3]), @@ -851,6 +855,17 @@ pub fn select_consensus(cands: &[Candidate], h: usize, w: usize) -> Option= needed) .map(|i| SIDE_NAMES[i]) .collect(); + let insets = [ + roi.0 as f64 / scales[0], + (w as f64 - roi.3 as f64) / scales[2], + (h as f64 - roi.1 as f64) / scales[1], + roi.2 as f64 / scales[2], + ]; + let stops_short = + |i: usize| !sides.contains(&SIDE_NAMES[i]) && insets[i] > MAX_UNSUPPORTED_INSET; + if sides.len() < 3 && (0..4).any(stops_short) { + continue; + } let corners: Vec<&'static str> = (0..4) .filter(|&i| cluster.iter().filter(|c| c.corners[i]).count() >= needed) .map(|i| CORNER_SIDES[i].0) diff --git a/tests/test_autocrop_consensus.py b/tests/test_autocrop_consensus.py index 45207ab19..d61435127 100644 --- a/tests/test_autocrop_consensus.py +++ b/tests/test_autocrop_consensus.py @@ -51,6 +51,26 @@ def _candidate( ) +def _strip_box(roi, sides, threshold_index): + return (*_candidate(roi, threshold_index=threshold_index)[:7], sides, []) + + +def test_consensus_refuses_a_two_sided_box_that_stops_short_of_the_frame(): + # A film strip shows its two rails and runs off the scan, so the box's other sides reach the + # frame. Stopping mid-frame, the box ended where a threshold mask ran out on picture content. + short = [_strip_box((230, 358, 2, 538), ["left", "right"], i) for i in range(5)] + assert negpy_core.select_consensus(short, 360, 540) is None + + runs_off = [_strip_box((1, 358, 2, 538), ["left", "right"], i) for i in range(5)] + assert negpy_core.select_consensus(runs_off, 360, 540) is not None + + +def test_consensus_keeps_a_three_sided_box_with_one_disturbed_edge(): + # Light along one edge of a framed picture costs that side its evidence, not its place. + boxed = [_strip_box((60, 300, 90, 450), ["top", "bottom", "left"], i) for i in range(5)] + assert negpy_core.select_consensus(boxed, 360, 540) is not None + + def test_consensus_rejects_a_high_scoring_single_source_outlier(): stable = [ _candidate((79, 301, 99, 441), threshold_index=0), diff --git a/tests/test_geometry_logic.py b/tests/test_geometry_logic.py index 73e9bb476..4ba41d219 100644 --- a/tests/test_geometry_logic.py +++ b/tests/test_geometry_logic.py @@ -993,6 +993,35 @@ def test_tier_refinement_rejects_split_occupancy_sliver(): assert refined is not None and refined[0][3] - refined[0][2] >= 0.80 * 520 +_HIGH_KEY_FILM_ROI = (20, 460, 20, 700) + + +def _high_key_film(thin_rows: bool = True) -> np.ndarray: + """Clear rebate around a picture on a light bed. With `thin_rows`, half of each row of the + picture's lower part reads as bright as the rebate, so row occupancy dips under the run threshold.""" + rng = np.random.default_rng(4) + img = np.full((480, 720, 3), 1.0, dtype=np.float32) + img[20:460, 20:700] = 0.80 + img[60:420, 70:650] = 0.30 + if thin_rows: + img[280:420, 70:650] = np.where(rng.random((140, 580, 1)) < 0.5, 0.80, 0.30) + return img + + +def test_tier_refinement_refuses_a_run_that_ends_inside_the_picture(): + from negpy.features.geometry.logic import _detection_luma, _refine_film_roi_by_tiers + + lum = _detection_luma(_high_key_film()) + y1, y2, x1, x2 = _HIGH_KEY_FILM_ROI + assert _refine_film_roi_by_tiers(lum, _HIGH_KEY_FILM_ROI) is None + flipped = np.ascontiguousarray(np.flipud(lum)) + assert _refine_film_roi_by_tiers(flipped, (480 - y2, 480 - y1, x1, x2)) is None + + # Control: with clear rebate past the run, the same picture refines to its own edge. + refined = _refine_film_roi_by_tiers(_detection_luma(_high_key_film(thin_rows=False)), _HIGH_KEY_FILM_ROI) + assert refined is not None and abs(refined[0][1] - 420) <= 6 + + def test_autocrop_image_mode_single_bright_side_not_rebate(): # High-key full-bleed frame with a uniform bright strip on ONE side only (a # sunlit window edge) plus a dark subject. The bright strip must NOT be taken From d210ee906bd8ed3f89def687bd67ae95424d3709 Mon Sep 17 00:00:00 2001 From: Kurt Windisch Date: Thu, 5 Nov 2026 12:07:36 -0800 Subject: [PATCH 2/2] fix(crop): keep tier results on tilted frames and beside frame gaps The rebate check averaged occupancy past a run, so two good results were refused: a tilted edge spreads across lines, and a narrow frame gap leads into the next picture. A 2 degree tilt kept rebate in the crop, and a 10 px gap moved a 3:2 crop toward the next frame. The check now takes the clearest line within 3% of the side. On two real high-key frames, every line past a false end held at least 0.43 occupancy, and the true edge reached 0.005, so the 0.25 limit stays. The crops of all 678 frames in the regression set match the check this replaces. The film box rule now counts supported sides directly. Docs name the rule as fewer than three sides, and say that a picture area as bright as the rebate still reads as rebate. BOUNDS_VERSION and THUMBNAIL_RENDER_VERSION stay as they are. Saved crops and meter rects are frozen, and neither bump resets them. Co-Authored-By: Claude Opus 5.5 --- docs/PIPELINE.md | 4 ++-- negpy_core/src/analysis.rs | 15 +++++++++------ negpy_core/src/detect.rs | 31 +++++++++++++++++-------------- tests/test_geometry_logic.py | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index c452b4f7b..a25ade92e 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -14,10 +14,10 @@ These steps run in order, and each stage passes its buffer to the next. A helper `keystone_matrix_normalized` is the one definition of the quad. The pixel matrix, the GPU inverse, the uv grid, `map_coords_to_geometry`, autocrop's replay and detection key, and the GPU analysis replay all derive from it. Off-frame card-edge handles need `CoordinateMapping` to fit the grid projectively. * **Crop Empty Corners** (`geometry.crop_to_valid`): the largest axis-aligned, centered rect that rotation and keystone leave void-free, used as the ROI when no crop is set. `compute_geometry_crop_rect` ternary-searches the half-width with a nested binary search for the half-height. Distortion is excluded, because it scales to fill. * **Square to Film** (`geometry.skew`): sets fine rotation, tilt and swing from the frame's straight film and gate edges, never the picture. A length-weighted mode of long segments squares the frame. Each side, searched inside the detector's box or its snap window, is fitted as a line, and Gauss-Newton levels the lines through the app's own warp. A correction needs box-led lines and an opposite pair (left/right for tilt, top/bottom for swing). A keystone is kept only between 0.3% and 5%, and only where it explains the edges clearly better than rotation alone. -* **Autocrop**: finds the film edge from the density jump to the scanner bed. A box with only two supported sides must reach within 5% of the frame edge at its other sides: film that runs off the scan does, and a threshold mask that ran out on picture content does not. Detection runs once per edit in `ImageProcessor`, ahead of both engines, and stores the rect (`geometry.crop_rect`, origin in `crop_from_auto`) with its detection key (`autocrop_detection_key`: orientation, ratio, mode, rebate trim). A key change re-detects. Both engines only slice that rect, because a preview and a full-res export can stop on different edges. Crop Offset is not in the key; every render re-applies it. **Square First** (`autocrop_square`) runs the Square to Film fit (`skew.squared_geometry`) once per arm on a frame without its own skew and detects on the squared geometry. +* **Autocrop**: finds the film edge from the density jump to the scanner bed. A box with fewer than three supported sides must reach within 5% of the frame edge at its unsupported sides: film that runs off the scan does, and a threshold mask that ran out on picture content does not. Detection runs once per edit in `ImageProcessor`, ahead of both engines, and stores the rect (`geometry.crop_rect`, origin in `crop_from_auto`) with its detection key (`autocrop_detection_key`: orientation, ratio, mode, rebate trim). A key change re-detects. Both engines only slice that rect, because a preview and a full-res export can stop on different edges. Crop Offset is not in the key; every render re-applies it. **Square First** (`autocrop_square`) runs the Square to Film fit (`skew.squared_geometry`) once per arm on a frame without its own skew and detects on the squared geometry. The surround of a box must read as bed (uniform, near-clipping) or as a dark holder, else detection falls back to the full frame. Opaque edges are trimmed off the box, up to 20% a side; a run that uses the whole allowance trims nothing. A box under a quarter of the frame is not film. On `Free` the ratio comes from the detected frame, snapped to the nearest real film format. -* **Rebate Trim** (*Image only*): refines the film box inward to the exposed image by one of three routes. A uniformity probe works when bed, rebate and image tiers separate, and each end of its occupancy run must have clear rebate past it (mean occupancy at most 0.25 over 2% of the side), because a high-key picture dips under the run threshold mid-frame. A per-side edge walk (`measure_film_edges`) serves camera scans with thin rebates: a 3% pass, and a 10% pass adopted only when it is deeper and passes four consistency tests. A per-side ring measurement (`measure_film_border`) needs an opposite pair, because a bright sky at the frame edge reads like film base. 0 stops at the film edge, 1.0 at the image edge, and above 1.0 cuts into the picture. Crop All Frames scales its roll-pooled inset by the same factor, each side pooling on its own sample count. In a roll, a portrait frame resolves from its own crop, a frame with no film box is placed from the roll, and deskew is not pooled. +* **Rebate Trim** (*Image only*): refines the film box inward to the exposed image by one of three routes. A uniformity probe works when bed, rebate and image tiers separate, and each end of its occupancy run inside the box needs a clear line past it (occupancy at most 0.25 within 3% of the side), because a high-key picture dips under the run threshold mid-frame. A picture area as bright as the rebate reads as rebate. A per-side edge walk (`measure_film_edges`) serves camera scans with thin rebates: a 3% pass, and a 10% pass adopted only when it is deeper and passes four consistency tests. A per-side ring measurement (`measure_film_border`) needs an opposite pair, because a bright sky at the frame edge reads like film base. 0 stops at the film edge, 1.0 at the image edge, and above 1.0 cuts into the picture. Crop All Frames scales its roll-pooled inset by the same factor, each side pooling on its own sample count. In a roll, a portrait frame resolves from its own crop, a frame with no film box is placed from the roll, and deskew is not pooled. The crop selection becomes the analysis ROI. The pixels are cropped only after Toning, so Lab and the preview overlays see the full frame. The Analysis Buffer excludes the outer X%, and a freehand region (`analysis_rect`) sets exactly what the meters read. diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 9d3de1912..21a0ca1af 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -1198,10 +1198,11 @@ pub fn roi_from_measured_border( // Smallest fraction of a film-box side the tier refinement may keep. A rebate never takes half // a side, so anything below this is a broken occupancy run, not a frame. const TIER_MIN_KEEP: f64 = 0.5; -// A run ends at the picture edge only when clear rebate lies past it: the lines within this -// fraction of the side hold at most this mean occupancy. A high-key picture dips under the run -// threshold with far more occupancy than that. -const TIER_EDGE_REACH: f64 = 0.02; +// A run ends at the picture edge only when clear rebate lies past it: some line within this +// fraction of the side holds at most this occupancy. The clearest line, not the mean, because a +// tilted edge spreads across lines and a narrow frame gap leads into the next picture. A dip in +// a high-key picture keeps more occupancy than this on every line. +const TIER_EDGE_REACH: f64 = 0.03; const TIER_EDGE_MAX_OCCUPANCY: f64 = 0.25; /// `_smooth_signal(signal, window)`: `np.convolve(signal, ones(window) / window, "same")` in @@ -1325,11 +1326,13 @@ fn longest_run_above(profile: &[f64], threshold: f64) -> Option<(i64, i64)> { Some((best.0, best.1 + 1)) } -/// Whether the lines just past each end of the half-open run read as clear rebate. +/// Whether a line just past each end of the half-open run reads as clear rebate. An end at the +/// edge of the box has no lines past it and passes. fn run_ends_at_rebate(profile: &[f64], (start, end): (i64, i64)) -> bool { let reach = py_round(TIER_EDGE_REACH * profile.len() as f64).max(3); let clear = |band: &[f64]| { - band.is_empty() || band.iter().sum::() / band.len() as f64 <= TIER_EDGE_MAX_OCCUPANCY + band.is_empty() + || band.iter().copied().fold(f64::INFINITY, f64::min) <= TIER_EDGE_MAX_OCCUPANCY }; let before = (start - reach).max(0) as usize..start as usize; let after = end as usize..((end + reach) as usize).min(profile.len()); diff --git a/negpy_core/src/detect.rs b/negpy_core/src/detect.rs index 25795a91d..f2d1fb5a2 100644 --- a/negpy_core/src/detect.rs +++ b/negpy_core/src/detect.rs @@ -18,9 +18,9 @@ const ADAPTIVE_THRESHOLD_COUNT: f64 = 7.0; // Film fills most of a scan under every known holder; on a positive whose bed clips to white, a // dark subject can pass every other check on its own geometry. const MIN_FILM_BOX_COVERAGE: f64 = 0.25; -// How far inside the frame, as a fraction of it, a box with only two supported sides may leave -// another side. Film that runs off the scan shows two edges and reaches the frame at the other -// two, so a side further in is where a threshold mask ran out on picture content. +// How far inside the frame, as a fraction of it, a box with fewer than three supported sides may +// leave an unsupported side. Film that runs off the scan shows two edges and reaches the frame at +// the other two, so a side further in is where a threshold mask ran out on picture content. const MAX_UNSUPPORTED_INSET: f64 = 0.05; pub const SIDE_NAMES: [&str; 4] = ["top", "right", "bottom", "left"]; pub const CORNER_SIDES: [(&str, [usize; 2]); 4] = [ @@ -851,21 +851,24 @@ pub fn select_consensus(cands: &[Candidate], h: usize, w: usize) -> Option = (0..4) - .filter(|&i| cluster.iter().filter(|c| c.sides[i]).count() >= needed) - .map(|i| SIDE_NAMES[i]) - .collect(); + let supported: [bool; 4] = + std::array::from_fn(|i| cluster.iter().filter(|c| c.sides[i]).count() >= needed); + let (fh, fw) = (h as f64, w as f64); + // In SIDE_NAMES order, as `supported`. let insets = [ - roi.0 as f64 / scales[0], - (w as f64 - roi.3 as f64) / scales[2], - (h as f64 - roi.1 as f64) / scales[1], - roi.2 as f64 / scales[2], + roi.0 as f64 / fh, + (fw - roi.3 as f64) / fw, + (fh - roi.1 as f64) / fh, + roi.2 as f64 / fw, ]; - let stops_short = - |i: usize| !sides.contains(&SIDE_NAMES[i]) && insets[i] > MAX_UNSUPPORTED_INSET; - if sides.len() < 3 && (0..4).any(stops_short) { + let stops_short = |i: usize| !supported[i] && insets[i] > MAX_UNSUPPORTED_INSET; + if supported.iter().filter(|&&s| s).count() < 3 && (0..4).any(stops_short) { continue; } + let sides: Vec<&'static str> = (0..4) + .filter(|&i| supported[i]) + .map(|i| SIDE_NAMES[i]) + .collect(); let corners: Vec<&'static str> = (0..4) .filter(|&i| cluster.iter().filter(|c| c.corners[i]).count() >= needed) .map(|i| CORNER_SIDES[i].0) diff --git a/tests/test_geometry_logic.py b/tests/test_geometry_logic.py index 4ba41d219..bb3405d70 100644 --- a/tests/test_geometry_logic.py +++ b/tests/test_geometry_logic.py @@ -1022,6 +1022,25 @@ def test_tier_refinement_refuses_a_run_that_ends_inside_the_picture(): assert refined is not None and abs(refined[0][1] - 420) <= 6 +def test_tier_refinement_keeps_a_tilted_picture(): + from negpy.features.geometry.logic import _detection_luma, _refine_film_roi_by_tiers, apply_fine_rotation + + # A tilted edge spreads across rows, so the mean of the rows past the run stays high. + tilted = apply_fine_rotation(_high_key_film(thin_rows=False), 2.0) + refined = _refine_film_roi_by_tiers(_detection_luma(tilted), _HIGH_KEY_FILM_ROI) + assert refined is not None and refined[0][1] - refined[0][0] <= 400 + + +def test_tier_refinement_keeps_a_picture_beside_a_narrow_frame_gap(): + from negpy.features.geometry.logic import _detection_luma, _refine_film_roi_by_tiers + + # The next frame starts 8 px past the picture, inside the reach of the rebate check. + img = _high_key_film(thin_rows=False) + img[60:420, 20:62] = 0.30 + refined = _refine_film_roi_by_tiers(_detection_luma(img), _HIGH_KEY_FILM_ROI) + assert refined is not None and abs(refined[0][2] - 70) <= 8 + + def test_autocrop_image_mode_single_bright_side_not_rebate(): # High-key full-bleed frame with a uniform bright strip on ONE side only (a # sunlit window edge) plus a dark subject. The bright strip must NOT be taken