Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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. 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.

Expand Down
3 changes: 2 additions & 1 deletion negpy/features/geometry/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
21 changes: 21 additions & 0 deletions negpy_core/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,12 @@ 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: 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
/// NumPy's float32 arithmetic. A full-overlap output of a window up to 11 goes through NumPy's
Expand Down Expand Up @@ -1320,6 +1326,19 @@ fn longest_run_above(profile: &[f64], threshold: f64) -> Option<(i64, i64)> {
Some((best.0, best.1 + 1))
}

/// 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().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());
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],
Expand Down Expand Up @@ -1387,6 +1406,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;
}
Expand Down
20 changes: 19 additions & 1 deletion negpy_core/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 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] = [
("top_left", [0, 3]),
Expand Down Expand Up @@ -847,8 +851,22 @@ pub fn select_consensus(cands: &[Candidate], h: usize, w: usize) -> Option<Conse
}
// _supported_by_majority: values present in at least half the cluster.
let needed = ((0.5 * cluster.len() as f64).ceil() as usize).max(1);
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 / fh,
(fw - roi.3 as f64) / fw,
(fh - roi.1 as f64) / fh,
roi.2 as f64 / fw,
];
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| cluster.iter().filter(|c| c.sides[i]).count() >= needed)
.filter(|&i| supported[i])
.map(|i| SIDE_NAMES[i])
.collect();
let corners: Vec<&'static str> = (0..4)
Expand Down
20 changes: 20 additions & 0 deletions tests/test_autocrop_consensus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
48 changes: 48 additions & 0 deletions tests/test_geometry_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,54 @@ 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_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
Expand Down
Loading