#!/usr/bin/env python3
"""Cut Corners frame detector.

Finds the exposed image area of a 35mm film scan (frame + surrounding
rebate/border), returns the 4 corners as a Lightroom-compatible crop:
normalized CropLeft/Right/Top/Bottom in [0..1] plus CropAngle (degrees,
positive = clockwise, applied around the crop center in pixel space).

Scans may show scanner background outside the film strip (e.g. blown white
above/below the strip) on any subset of sides; it is detected and excluded,
and all size/position rules are evaluated relative to the film area.

Usage:
    detect.py <image> <params.json>

Writes <image>.result.json. Exit code 0 even for a "skip" verdict (the
verdict is in the JSON); non-zero only for hard errors (bad image, bad
params), with the error message in the result file when possible.

params.json keys (all optional unless noted):
    film_type        color_neg | bw_neg | slide | inverted | auto  (default auto)
    edge_band        fraction of each film-area edge where corners are
                     expected (default 0.10)
    inset            fraction to shrink the detected rect by (default 0.005)
    border           margin of rebate kept outside the detected frame, as a
                     fraction of the frame's short side; uniform in pixels on
                     all four sides, capped to the film area. 0 = off (the
                     default); when set, inset is ignored
    aspect_target    long/short ratio target (default 1.5)
    aspect_tolerance relative tolerance on aspect (default 0.08)
    min_area         min frame area as a fraction of the film area (default 0.64)
    max_area         max frame area as a fraction of the whole scan (default 0.99)
    manual_threshold 1-255 replaces the adaptive threshold for explicit film
                     types (0 = adaptive, the default; ignored in auto mode,
                     which has no threshold direction to apply it to)
    dark_edge_mask   also treat dark border-connected regions as scanner
                     background: for scans made in a film holder / carrier
                     whose dark edges are not near-saturated (default false)
    enforce_aspect   pull the aspect onto aspect_target by moving the one
                     edge that has no intensity transition under it, when the
                     other three do (default false)
    hint_cx, hint_cy, hint_w, hint_h, hint_angle
                     roll-consensus rescue hint: the roll's median frame rect,
                     film-area-relative (matching the "rect" result field).
                     hint_w and hint_h > 0 activate it; then only the rescue
                     strategy runs — the hint is snapped to this scan's edges
                     and re-validated (defaults 0 = off)
    debug            write <image>.debug.jpg with detection overlay (default false)

    detect.py --diag <out.json> writes runtime versions instead (for the
    plugin's Run Diagnostics button).
"""

import json
import math
import sys

import cv2
import numpy as np


DEFAULTS = {
    "film_type": "auto",
    "edge_band": 0.10,
    "inset": 0.005,
    "border": 0.0,
    "aspect_target": 1.5,
    "aspect_tolerance": 0.08,
    "min_area": 0.64,
    "max_area": 0.99,
    "manual_threshold": 0,
    "dark_edge_mask": False,
    "enforce_aspect": False,
    "hint_cx": 0.0,
    "hint_cy": 0.0,
    "hint_w": 0.0,
    "hint_h": 0.0,
    "hint_angle": 0.0,
    "debug": False,
}

# Whether the frame (image area) is darker or brighter than the rebate.
# Negatives: bright clear/orange base, dark exposed frame.
# Slides / already-inverted positives: dark dense rebate, bright frame.
FRAME_POLARITY = {
    "color_neg": "darker",
    "bw_neg": "darker",
    "slide": "brighter",
    "inverted": "brighter",
}


def load_params(path):
    with open(path) as fh:
        user = json.load(fh)
    params = dict(DEFAULTS)
    params.update({k: user[k] for k in DEFAULTS if k in user})
    if params["film_type"] not in (*FRAME_POLARITY, "auto"):
        raise ValueError("unknown film_type: %r" % params["film_type"])
    return params


def frame_gray(img, film_type):
    """Grayscale view in which the frame/rebate contrast is strongest."""
    if film_type == "color_neg":
        # The orange base is brightest in the red channel, and any exposure
        # only adds density, so red gives the most base/frame headroom.
        return img[:, :, 2]
    return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)


def extremes_mask(blur):
    """Near-saturated pixels: scanner background, sprocket holes, blown
    specks. Never frame candidates — sprocket holes in particular share the
    frame's polarity on positives and would otherwise bridge into it."""
    return (blur >= 242) | (blur <= 12)


def background_mask(extreme):
    """Scanner background visible outside the film strip.

    Blown backlight (white) or scanner bed (black) is near-saturated and
    connected to the scan border; film — even clear base or dense rebate —
    rarely is. Components touching the border at either extreme are
    background, on whichever sides they appear.
    """
    em = extreme.astype(np.uint8) * 255
    n, labels, stats, _ = cv2.connectedComponentsWithStats(em, connectivity=8)
    h, w = extreme.shape
    bg = np.zeros((h, w), bool)
    for i in range(1, n):
        x, y, bw, bh = stats[i, :4]
        if x == 0 or y == 0 or x + bw == w or y + bh == h:
            bg |= labels == i
    return bg


def strip_bbox(bg):
    """Bounding box (x0, y0, x1, y1) of everything that isn't background."""
    h, w = bg.shape
    fg = ~bg
    cols = np.where(fg.any(axis=0))[0]
    rows = np.where(fg.any(axis=1))[0]
    if len(cols) == 0 or len(rows) == 0:
        return (0, 0, w, h)
    return (int(cols[0]), int(rows[0]), int(cols[-1] + 1), int(rows[-1] + 1))


def border_stats(gray, band=0.03):
    """Mean/std of the thin strips along all four edges (the rebate sample)."""
    h, w = gray.shape
    bh, bw = max(2, int(h * band)), max(2, int(w * band))
    strips = np.concatenate([
        gray[:bh, :].ravel(), gray[-bh:, :].ravel(),
        gray[:, :bw].ravel(), gray[:, -bw:].ravel(),
    ])
    return float(strips.mean()), float(strips.std())


def rebate_tone(gray, polarity, edge_band):
    """Tone of the film border within the film area.

    The edge band can still contain mixed layers, so the rebate is found
    directionally: it is the darkest band near the edges when the frame is
    brighter (positives), and the brightest when the frame is darker
    (negatives).
    """
    h, w = gray.shape
    bh, bw = max(2, int(h * edge_band)), max(2, int(w * edge_band))
    band = np.concatenate([
        gray[:bh, :].ravel(), gray[-bh:, :].ravel(),
        gray[:, :bw].ravel(), gray[:, -bw:].ravel(),
    ])
    if polarity == "darker":
        return float(np.percentile(band, 90))
    return float(np.percentile(band, 10))


def polarity_mask(blur, sample, rebate, polarity):
    """Threshold in the direction the film type dictates.

    Percentile statistics come from `sample` — film-area pixels with the
    near-saturated extremes excluded — so scanner background outside the
    strip cannot skew the threshold.
    """
    if polarity == "darker":
        # Frame darker than rebate: threshold midway between rebate tone and
        # the dark end of the histogram.
        lo = float(np.percentile(sample, 2))
        thresh = lo + (rebate - lo) * 0.55
        return blur < thresh
    else:
        hi = float(np.percentile(sample, 98))
        thresh = rebate + (hi - rebate) * 0.45
        return blur > thresh


def auto_mask(blur, border_mean, border_std):
    """No polarity assumption: anything far from the characterized border tone."""
    dist = np.abs(blur.astype(np.float32) - border_mean)
    return dist > max(18.0, border_std * 2.5)


def consolidate(mask):
    m = (mask.astype(np.uint8)) * 255
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9))
    m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, kernel, iterations=3)
    # Large opening: removes protrusions narrower than ~2% of the preview
    # (sprocket-hole remnants, rebate bridges) that would inflate the fitted
    # rectangle; the frame itself is far too thick to be affected.
    open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31))
    m = cv2.morphologyEx(m, cv2.MORPH_OPEN, open_kernel)
    return m


def best_rect(mask, min_area_px, max_area_px):
    """Most frame-like contour as (rotated rect, convex-hull area), or None.

    All contours at every nesting level are considered: with scanner
    background or layered borders the frame is nested inside other regions
    (background -> rebate -> frame), so the *innermost* sufficiently
    rectangular candidate in the valid size range wins, not the largest one.

    Hull area (not raw contour area) is used for the rectangularity check:
    bright/dark subject matter touching the frame edge bites concave bays out
    of the mask, but the frame itself is convex, so the hull restores it while
    irregular blobs (light leaks, partial frames) stay non-rectangular.
    """
    contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    candidates = []
    for contour in contours:
        if len(contour) < 4:
            continue
        rect = cv2.minAreaRect(contour)
        rect_area = rect[1][0] * rect[1][1]
        if rect_area <= 0 or not (min_area_px <= rect_area <= max_area_px):
            continue
        hull_area = float(cv2.contourArea(cv2.convexHull(contour)))
        candidates.append((hull_area / rect_area, rect_area, rect, hull_area))
    if not candidates:
        return None
    rectangular = [c for c in candidates if c[0] >= 0.85]
    if rectangular:
        best = min(rectangular, key=lambda c: c[1])
    else:
        best = max(candidates, key=lambda c: c[0])
    return best[2], best[3]


def refine_rect(blur, extreme, rect, rebate):
    """Trim rect edges whose rows/cols look like film border, not frame.

    Edge markings, sprocket-hole halos, and stock printing in the rebate can
    survive morphology while connected to the frame, stretching the fitted
    rectangle past the true frame edge toward the strip boundary. A dragged-in
    border row is dominated by rebate-toned or saturated pixels; genuine frame
    content — even blown skies or deep shadows — does not match the rebate
    tone across most of a row. Walk each edge inward past border-like
    rows/cols. Trim-only; never expands the crop.
    """
    (cx, cy), (rw, rh), raw_angle = rect
    angle = normalize_angle(raw_angle, (rw, rh))
    h, w = blur.shape
    borderlike = ((np.abs(blur.astype(np.int16) - rebate) < 25) | extreme)
    m = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
    unrot = cv2.warpAffine(borderlike.astype(np.uint8) * 255, m, (w, h),
                           flags=cv2.INTER_NEAREST)
    pts = cv2.transform(cv2.boxPoints(rect).reshape(-1, 1, 2), m).reshape(-1, 2)
    x0 = max(0, int(round(pts[:, 0].min())))
    x1 = min(w, int(round(pts[:, 0].max())))
    y0 = max(0, int(round(pts[:, 1].min())))
    y1 = min(h, int(round(pts[:, 1].max())))
    if x1 - x0 < 20 or y1 - y0 < 20:
        return rect
    sub = unrot[y0:y1, x0:x1] > 127
    rows = sub.mean(axis=1)
    cols = sub.mean(axis=0)

    def walk(fracs, limit=0.12, borderish=0.45, run=12):
        """Trim each end to where sustained frame content begins: the first
        position with `run` consecutive non-border-like rows. Thin JPEG
        transition halos between border layers cannot fake a sustained run,
        and untrimmed edges stay untrimmed. No qualifying position within
        the trim limit means no trim (conservative)."""
        n = len(fracs)
        frameish = fracs < borderish
        max_trim = int(n * limit)
        lo = 0
        for i in range(0, max_trim):
            if frameish[i:i + run].all():
                lo = i
                break
        hi = n
        for j in range(n, n - max_trim, -1):
            if frameish[max(0, j - run):j].all():
                hi = j
                break
        if hi <= lo:
            return 0, n
        return lo, hi

    top, bottom = walk(rows)
    left, right = walk(cols)
    if (top, bottom, left, right) == (0, len(rows), 0, len(cols)):
        return rect
    nx0, ny0 = x0 + left, y0 + top
    nx1, ny1 = x0 + right, y0 + bottom
    center = np.array([[[(nx0 + nx1) / 2.0, (ny0 + ny1) / 2.0]]], np.float32)
    c = cv2.transform(center, cv2.invertAffineTransform(m)).reshape(2)
    return ((float(c[0]), float(c[1])),
            (float(nx1 - nx0), float(ny1 - ny0)), angle)


def edge_profiles(channel, rect):
    """Straighten `rect` and take the intensity profile across each pair of
    edges, averaged along them with the corners excluded.

    Shared by snap_edges and snap_aspect so both measure an edge in exactly the
    same signal. Returns (m, angle, col_profile, row_profile, (x0, x1, y0, y1))
    with the extremes in the straightened space, or None when the rect is too
    small to profile.
    """
    (cx, cy), _, raw_angle = rect
    angle = normalize_angle(raw_angle, rect[1])
    h, w = channel.shape
    m = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
    unrot = cv2.warpAffine(channel, m, (w, h), flags=cv2.INTER_LINEAR)
    pts = cv2.transform(cv2.boxPoints(rect).reshape(-1, 1, 2), m).reshape(-1, 2)
    x0, x1 = float(pts[:, 0].min()), float(pts[:, 0].max())
    y0, y1 = float(pts[:, 1].min()), float(pts[:, 1].max())
    mx, my = int((x1 - x0) * 0.1), int((y1 - y0) * 0.1)
    ix0, ix1 = int(x0) + mx, int(x1) - mx
    iy0, iy1 = int(y0) + my, int(y1) - my
    if ix1 - ix0 < 20 or iy1 - iy0 < 20:
        return None
    return (m, angle,
            unrot[max(0, iy0):iy1, :].mean(axis=0),
            unrot[:, max(0, ix0):ix1].mean(axis=1),
            (x0, x1, y0, y1))


def snap_edges(blur, rect, polarity, band=8):
    """Snap each rect edge to the strongest rebate->frame transition within
    ±band px of it.

    Mask morphology and minAreaRect quantize edges by a few pixels; the
    intensity profile across the true edge does not. Each edge's
    perpendicular profile (averaged along the edge, corners excluded) is
    searched for the gradient extremum in the polarity's direction, with
    parabolic interpolation for sub-pixel position. An edge with no clear
    transition nearby stays put.
    """
    prof = edge_profiles(blur, rect)
    if prof is None:
        return rect
    m, angle, col_profile, row_profile, (x0, x1, y0, y1) = prof
    sign = 1.0 if polarity == "brighter" else -1.0  # frame minus rebate

    def snap(profile, pos, edge_sign):
        found = find_transition(profile, pos, edge_sign, band)
        return pos if found is None else found[0]

    nx0 = snap(col_profile, x0, sign)
    nx1 = snap(col_profile, x1, -sign)
    ny0 = snap(row_profile, y0, sign)
    ny1 = snap(row_profile, y1, -sign)
    if (nx0, nx1, ny0, ny1) == (x0, x1, y0, y1):
        return rect
    center = np.array([[[(nx0 + nx1) / 2.0, (ny0 + ny1) / 2.0]]], np.float32)
    c = cv2.transform(center, cv2.invertAffineTransform(m)).reshape(2)
    return ((float(c[0]), float(c[1])),
            (float(nx1 - nx0), float(ny1 - ny0)), angle)


def find_transition(profile, pos, edge_sign, band):
    """Strongest transition within ±band of pos, as (sub-pixel position,
    gradient magnitude), or None when there is none worth snapping to."""
    c = int(round(pos))
    if c - band < 1 or c + band + 1 > len(profile) - 1:
        return None
    g = np.gradient(profile.astype(np.float32))[c - band:c + band + 1] * edge_sign
    k = int(np.argmax(g))
    if g[k] < 1.5:  # no transition: rebate or frame on both sides
        return None
    strength = float(g[k])
    if 0 < k < len(g) - 1:
        denom = g[k - 1] - 2.0 * g[k] + g[k + 1]
        if abs(denom) > 1e-9:
            k += min(0.5, max(-0.5, 0.5 * (g[k - 1] - g[k + 1]) / denom))
    return c - band + k, strength


# A frame whose aspect is off by more than this cannot be explained by one
# drifted edge, so aspect snapping declines rather than dragging a genuinely
# misdetected rect onto the target and making it look correct.
ASPECT_SNAP_LIMIT = 0.08
# Aspect errors below this are the film's own: stock aspect, mount aperture and
# aspect_target genuinely disagree at the half-percent level, and nothing about
# a correction that small is worth the risk of moving a correct edge.
ASPECT_DEAD_ZONE = 0.01
# How far either side of the aspect-implied position to accept the real edge.
ASPECT_SNAP_BAND = 10
# A frame edge has frame behind it for at least this many px, clearing the
# outside tone by this much. Both are what separate an edge from a ridge.
ASPECT_STEP_REACH = 22
ASPECT_STEP_MARGIN = 12.0


def edge_is_step(profile, pos, low_edge, sign):
    """Is `pos` a frame boundary rather than a ridge in the rebate?

    The failure enforce_aspect exists to fix is an edge snapped onto a bright
    streak in the rebate — light leaking past a slide mount's aperture,
    halation, stock printing. Those have a transition as steep as the frame's
    own (19.9 vs 18.8 DN/px on the scan this was built from), so gradient
    strength cannot tell them apart. What can: a frame edge is a *step* — the
    tone behind it stays on the frame side of the rebate for as far as we care
    to look — while a streak is a *ridge* that falls back within a dozen px.
    Compared on a low percentile rather than the minimum, so a dark band just
    inside the frame (a shadowed foreground) does not read as a ridge.
    """
    p = int(round(pos))
    if low_edge:
        outside = profile[max(0, p - 8):max(1, p - 2)]
        inside = profile[p + 3:p + 3 + ASPECT_STEP_REACH]
    else:
        outside = profile[p + 3:p + 9]
        inside = profile[max(0, p - 3 - ASPECT_STEP_REACH):max(1, p - 3)]
    if len(outside) == 0 or len(inside) == 0:
        return False
    held = float(np.percentile(inside, 10 if sign > 0 else 90))
    return (held - float(outside.mean())) * sign > ASPECT_STEP_MARGIN


def snap_aspect(channel, rect, polarity, params):
    """Relocate the one edge that is not on the frame, using aspect_target to
    say where to look.

    A rebate streak parallel to a frame edge can hold the fitted rect's edge a
    few percent out: the mask keeps it, `refine_rect`'s border test does not
    recognize it (its tone is nowhere near the characterized rebate) and
    `snap_edges` snaps *to* it, because it is a real transition. The aspect
    error is then the only thing left that knows something is wrong, and it can
    still sit inside `aspect_tolerance`.

    So: for each edge in turn, ask where it would have to be for the aspect to
    hit the target, look for a real transition near that position, and require
    the destination to be a step where the edge's current position is not.
    Deliberately the target only *proposes* — the edge lands on the transition
    actually found there, which is why the corrected aspect comes out near the
    target rather than exactly on it. Enforcing the number itself would put the
    edge wherever the arithmetic said, frame or no frame.

    Declines when no edge fails the step test (the aspect is telling the truth
    about this frame) and when no candidate destination is a step (a dissolved
    edge — the roll-consensus rescue is the tool for that, not this).

    Returns (rect, moved).
    """
    prof = edge_profiles(channel, rect)
    if prof is None:
        return rect, False
    m, angle, col_profile, row_profile, (x0, x1, y0, y1) = prof
    rw, rh = x1 - x0, y1 - y0
    if rw <= 0 or rh <= 0:
        return rect, False
    target = params["aspect_target"]
    aspect = max(rw, rh) / min(rw, rh)
    if abs(aspect - target) <= target * ASPECT_DEAD_ZONE:
        return rect, False
    sign = 1.0 if polarity == "brighter" else -1.0

    # Each edge, with the profile it lives in, whether it is the low edge of
    # its axis, and the extents that result from moving it to `pos`.
    edges = (
        ("left", col_profile, True, x0, lambda pos: (x1 - pos, rh)),
        ("right", col_profile, False, x1, lambda pos: (pos - x0, rh)),
        ("top", row_profile, True, y0, lambda pos: (rw, y1 - pos)),
        ("bottom", row_profile, False, y1, lambda pos: (rw, pos - y0)),
    )

    best = None
    for name, profile, low_edge, cur, extent in edges:
        if edge_is_step(profile, cur, low_edge, sign):
            continue  # this edge is on the frame; it is not the problem
        horizontal = name in ("left", "right")
        # The span this edge controls, sized to hit the target against the
        # other axis, keeping the long/short assignment the detection found.
        held, span = (rh, rw) if horizontal else (rw, rh)
        want = held * target if (rw >= rh) == horizontal else held / target
        if abs(want - span) > ASPECT_SNAP_LIMIT * span:
            continue
        proposed = (x1 - want if name == "left" else
                    x0 + want if name == "right" else
                    y1 - want if name == "top" else y0 + want)
        found = find_transition(profile, proposed,
                                sign if low_edge else -sign, ASPECT_SNAP_BAND)
        if found is None:
            continue
        pos, strength = found
        if not edge_is_step(profile, pos, low_edge, sign):
            continue
        if best is None or strength > best[1]:
            best = (name, strength, pos, extent(pos))

    if best is None:
        return rect, False
    name, _strength, pos, (nrw, nrh) = best
    if nrw <= 0 or nrh <= 0:
        return rect, False

    # The offset is along the straightened axes, so apply it in the same
    # rotated space the profiles were taken in and map the center back.
    # Rotation about the center leaves the center fixed, so (cx, cy) is where
    # the rect is centered in that space too.
    (cx, cy), _, _ = rect
    dx = ((pos - x0) / 2.0 if name == "left" else
          (pos - x1) / 2.0 if name == "right" else 0.0)
    dy = ((pos - y0) / 2.0 if name == "top" else
          (pos - y1) / 2.0 if name == "bottom" else 0.0)
    c = cv2.transform(np.array([[[cx + dx, cy + dy]]], np.float32),
                      cv2.invertAffineTransform(m)).reshape(2)
    return ((float(c[0]), float(c[1])), (float(nrw), float(nrh)), angle), True


def normalize_rect(rect):
    """Canonical rect description: angle in (-45, 45], dims to match.

    minAreaRect's (w, h, angle) is 90°-ambiguous — (w, h, a) and
    (h, w, a+90) describe the same rectangle — so rects cannot be averaged
    or aspect-checked until the angle is normalized *and* the dims are
    swapped for every quarter-turn that normalization removed.
    """
    center, (rw, rh), angle = rect
    a = angle % 180.0
    if a > 135.0:
        a -= 180.0
    if a > 45.0:
        a -= 90.0
        rw, rh = rh, rw
    return (center, (rw, rh), a)


def normalize_angle(angle, size):
    """Map OpenCV minAreaRect angle conventions onto straighten degrees in (-45, 45].

    OpenCV >= 4.5 returns angle in [0, 90) with width/height assignment that
    flips at 45; older versions return (-90, 0]. Normalizing through mod 90
    handles both.
    """
    a = angle % 90.0
    if a > 45.0:
        a -= 90.0
    return a


def sweep_rects(blur, polarity, exclude, min_area_px, max_area_px, params):
    """Fallback: sweep thresholds, collect frame-like rects, take the median.

    Mirrors the iterative approach of the reference implementation; robust
    when the single adaptive threshold lands on the wrong side of a gradient.
    Only rects that already look like a frame (aspect within tolerance) get
    a vote — thresholds that lump rebate onto the frame must not skew the
    median — and at least 3 must agree.
    """
    target, tol = params["aspect_target"], params["aspect_tolerance"]
    rects = []
    for t in range(10, 246, 5):
        raw = (blur < t) if polarity == "darker" else (blur > t)
        found = best_rect(consolidate(raw & ~exclude), min_area_px, max_area_px)
        if not found:
            continue
        long_side, short_side = max(found[0][1]), min(found[0][1])
        if short_side <= 0:
            continue
        aspect = long_side / short_side
        if target * (1 - tol) <= aspect <= target * (1 + tol):
            rects.append(found[0])
    if len(rects) < 3:
        return None
    def med(vals):
        return float(np.median(np.asarray(vals)))
    return (
        (med([r[0][0] for r in rects]), med([r[0][1] for r in rects])),
        (med([r[1][0] for r in rects]), med([r[1][1] for r in rects])),
        med([normalize_angle(r[2], r[1]) for r in rects]),
    )


def detect(img, params):
    """Try detection strategies in order until one yields a rect that passes
    validation: the adaptive mask for the film type, then (for slides) an
    HSV-saturation mask, then a threshold sweep, then the other mask family
    (auto for explicit film types, polarity for auto mode). A roll-consensus
    hint (hint_* params) replaces the whole chain with a single rescue
    strategy.

    Returns (info, validated, reason). info always carries strip/mask (the
    primary strategy's mask) and the winning strategy name for debugging;
    validated is the (box, angle, center, tight_dims) tuple from validate(),
    None exactly when reason is set. The reported reason is the primary strategy's
    failure — the most meaningful one for the skip report.
    """
    h, w = img.shape[:2]
    film_type = params["film_type"]
    gray = frame_gray(img, film_type)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)

    extreme = extremes_mask(blur)
    bg = background_mask(extreme)
    if params["dark_edge_mask"]:
        # Film holders / carriers are dark but rarely near-saturated, so the
        # extremes mask misses them; anything that dark and connected to the
        # scan border is scan surround, not film.
        bg |= background_mask(blur <= 48)
    strip = strip_bbox(bg)
    sub = gray[strip[1]:strip[3], strip[0]:strip[2]]
    sub_extreme = extreme[strip[1]:strip[3], strip[0]:strip[2]]
    sample = blur[strip[1]:strip[3], strip[0]:strip[2]][~sub_extreme]
    if sample.size == 0:
        sample = blur.ravel()

    border_mean, border_std = border_stats(sub)
    if film_type == "auto":
        polarity = "darker" if np.median(sub) < border_mean else "brighter"
        rebate = border_mean
    else:
        polarity = FRAME_POLARITY[film_type]
        rebate = rebate_tone(sub, polarity, params["edge_band"])

    def primary_mask():
        if film_type == "auto":
            return auto_mask(blur, border_mean, border_std)
        # The manual threshold is the escape hatch for rolls the adaptive
        # statistics misread; it replaces only the primary strategy, so the
        # sweep and alternate fallbacks still run if it finds nothing.
        t = params["manual_threshold"]
        if t and 0 < t < 256:
            return (blur < t) if polarity == "darker" else (blur > t)
        return polarity_mask(blur, sample, rebate, polarity)

    def alternate_mask():
        if film_type == "auto":
            return polarity_mask(
                blur, sample, rebate_tone(sub, polarity, params["edge_band"]),
                polarity)
        return auto_mask(blur, border_mean, border_std)

    strip_area = (strip[2] - strip[0]) * (strip[3] - strip[1])
    min_area_px = params["min_area"] * strip_area
    max_area_px = params["max_area"] * w * h

    info = {"strip": strip, "mask": None, "rect": None,
            "rectangularity": 0.0, "strategy": None, "aspect_snapped": False}

    # bg is a subset of extreme unless dark_edge_mask extended it, in which
    # case holder pixels must not become frame candidates either.
    exclude = extreme | bg

    def from_mask(raw):
        m = consolidate(raw & ~exclude)
        if info["mask"] is None:
            info["mask"] = m  # primary strategy's mask, for the debug overlay
        found = best_rect(m, min_area_px, max_area_px)
        if found is None:
            return None
        rect, hull_area = found
        return rect, hull_area / (rect[1][0] * rect[1][1])

    def flatfield_mask():
        # Uneven backlight defeats every global threshold. The unevenness is
        # smooth and separable to first order, so estimate it from the rebate
        # bands alone — per-column from the top/bottom bands, per-row from the
        # left/right ones — never from the frame, whose content would bias a
        # whole-image estimate and get flattened along with the backlight.
        # Last in the fallback chain: only runs on scans everything else
        # already failed.
        x0, y0, x1, y1 = strip
        region = blur[y0:y1, x0:x1].astype(np.float32)
        rh, rw = region.shape
        if rh < 40 or rw < 40:
            return polarity_mask(blur, sample, rebate, polarity)
        bh, bw2 = max(2, int(rh * 0.04)), max(2, int(rw * 0.04))
        cols = np.median(np.vstack([region[:bh], region[-bh:]]), axis=0)
        rows = np.median(np.hstack([region[:, :bw2], region[:, -bw2:]]), axis=1)
        cols = cv2.GaussianBlur(cols.reshape(1, -1), (0, 0), rw * 0.02).ravel()
        rows = cv2.GaussianBlur(rows.reshape(1, -1), (0, 0), rh * 0.02).ravel()
        mid = max(1.0, float(np.median(np.concatenate([cols, rows]))))
        illum = np.maximum(rows[:, None] * cols[None, :] / mid, 1.0)
        flat = np.full((h, w), 128, np.uint8)
        flat[y0:y1, x0:x1] = np.clip(region / illum * 128.0, 0, 255)
        f_sub = flat[y0:y1, x0:x1]
        f_sample = f_sub[~sub_extreme]
        if f_sample.size == 0:
            f_sample = f_sub.ravel()
        f_rebate = rebate_tone(f_sub, polarity, params["edge_band"])
        return polarity_mask(flat, f_sample, f_rebate, polarity)

    def saturation_view():
        # Dense/dark slide content collapses into the black rebate in
        # luminance, but the rebate is neutral while the frame is colorful:
        # HSV saturation separates them even when value does not. Sprocket
        # holes and blown mount edges are near-saturated in *value*, so they
        # are already in `exclude`, and blown whites are low-S anyway. Real
        # slides only — an inverted negative's rebate becomes a saturated
        # blue-orange complement, which breaks the neutral-border assumption.
        sat = cv2.GaussianBlur(
            cv2.cvtColor(img, cv2.COLOR_BGR2HSV)[:, :, 1], (5, 5), 0)
        sat_sub = sat[strip[1]:strip[3], strip[0]:strip[2]]
        sat_sample = sat_sub[~sub_extreme]
        if sat_sample.size == 0:
            sat_sample = sat.ravel()
        sat_rebate = rebate_tone(sat_sub, "brighter", params["edge_band"])
        if float(np.percentile(sat_sample, 98)) - sat_rebate < 15:
            return None  # faded or near-monochrome: no saturation contrast
        return sat, sat_rebate, sat_sample

    # Each candidate carries the channel it was found in, so refine/snap read
    # the same signal: a dark slide frame looks border-like in luminance and
    # would be wrongly trimmed, and its edges have no luminance gradient to
    # snap to.
    luma = (blur, rebate, polarity)

    def candidates():
        if params["hint_w"] > 0 and params["hint_h"] > 0:
            # Roll-consensus rescue: the caller already ran the full strategy
            # chain with these params and it failed, so re-running it is pure
            # waste — denormalize the roll's median rect against this scan's
            # film area and let snap_edges find the true edges nearby. A frame
            # whose edge dissolved (light leak, blank frame) simply stays at
            # the hint, which is where the roll says the frame is — intended,
            # not a bug. 0.85 rectangularity is the floor a contour-based
            # pass must clear: it passes validate (geometry is vouched for by
            # the roll; there is no contour to measure) while keeping the
            # reported confidence honestly below a measured perfect fill.
            sx0, sy0 = strip[0], strip[1]
            sw = strip[2] - sx0
            sh = strip[3] - sy0
            rect = ((sx0 + params["hint_cx"] * sw, sy0 + params["hint_cy"] * sh),
                    (params["hint_w"] * sw, params["hint_h"] * sh),
                    params["hint_angle"])
            yield "rescue", (rect, 0.85), luma
            return
        yield "primary", from_mask(primary_mask()), luma
        if film_type == "slide":
            view = saturation_view()
            if view is not None:
                sat, sat_rebate, sat_sample = view
                yield "saturation", from_mask(
                    polarity_mask(sat, sat_sample, sat_rebate, "brighter")
                ), (sat, sat_rebate, "brighter")
        rect = sweep_rects(blur, polarity, exclude, min_area_px, max_area_px,
                           params)
        yield "sweep", (None if rect is None else (rect, 0.9)), luma
        yield "alternate", from_mask(alternate_mask()), luma
        yield "flatfield", from_mask(flatfield_mask()), luma

    first_reason = None
    for strategy, cand, (channel, ch_rebate, ch_polarity) in candidates():
        if cand is None:
            continue
        rect, rectangularity = cand
        if strategy == "rescue":
            # No refine_rect: its border-likeness walk is exactly what tends
            # to misfire on the pathological frames being rescued, and the
            # hint geometry is already tight. A wider snap band absorbs the
            # few pixels the frame placement drifts as film advances; it also
            # bounds how far the result can stray from the hint.
            rect = snap_edges(channel, rect, ch_polarity,
                              band=max(8, int(0.015 * min(w, h))))
        else:
            rect = refine_rect(channel, extreme, rect, ch_rebate)
            rect = snap_edges(channel, rect, ch_polarity)
        snapped = False
        if params["enforce_aspect"]:
            rect, snapped = snap_aspect(channel, rect, ch_polarity, params)
        validated, reason = validate(rect, rectangularity, strip, w, h, params)
        if validated is not None:
            info["rect"], info["rectangularity"] = rect, rectangularity
            info["strategy"] = strategy
            info["aspect_snapped"] = snapped
            return info, validated, None
        if first_reason is None:
            first_reason = reason
    return info, None, first_reason or "no frame-sized region found"


def max_margin(center, dims, raw_angle, strip):
    """Largest uniform outward margin that keeps every corner inside the film
    area. Each expanded corner coordinate is linear in the margin, so evaluate
    the corners at margin 0 and margin 1 and solve the resulting linear
    inequalities against the strip bounds, keeping the tightest one."""
    sx0, sy0, sx1, sy1 = strip
    (cx, cy), (rw, rh) = center, dims
    base = cv2.boxPoints(((cx, cy), (rw, rh), raw_angle))
    grown = cv2.boxPoints(((cx, cy), (rw + 2.0, rh + 2.0), raw_angle))
    bound = float("inf")
    for (x, y), (gx, gy) in zip(base, grown):
        for v, dv, lo, hi in ((x, gx - x, sx0, sx1), (y, gy - y, sy0, sy1)):
            if dv > 1e-9:
                bound = min(bound, (hi - v) / dv)
            elif dv < -1e-9:
                bound = min(bound, (lo - v) / dv)
    return max(0.0, bound)


def validate(rect, rectangularity, strip, w, h, params):
    """Return (corners, reason). reason is None when the rect passes; the
    corners tuple is (box, angle, center, tight_rect), where tight_rect is
    the normalized frame rect before any inset/border adjustment — what the
    roll-consensus median aggregates, so margins must never compound into
    it."""
    (cx, cy), (rw, rh), raw_angle = rect
    angle = normalize_angle(raw_angle, (rw, rh))
    if abs(angle) > 44.0:
        return None, "rotation beyond Lightroom's ±45° straighten range"

    long_side, short_side = max(rw, rh), min(rw, rh)
    if short_side <= 0:
        return None, "degenerate rectangle"

    aspect = long_side / short_side
    target, tol = params["aspect_target"], params["aspect_tolerance"]
    if not (target * (1 - tol) <= aspect <= target * (1 + tol)):
        return None, "aspect %.3f outside %.2f±%.0f%%" % (aspect, target, tol * 100)

    # Lower bound vs the film area (scanner bands shrink the frame's share of
    # the scan); upper bound vs the whole scan (rejects whole-image rects).
    sx0, sy0, sx1, sy1 = strip
    strip_area = (sx1 - sx0) * (sy1 - sy0)
    area_strip = (rw * rh) / strip_area
    area_image = (rw * rh) / (w * h)
    if area_strip < params["min_area"]:
        return None, "frame %.0f%% of film area, below %d%%" % (
            area_strip * 100, params["min_area"] * 100)
    if area_image > params["max_area"]:
        return None, "frame %.0f%% of scan, above %d%%" % (
            area_image * 100, params["max_area"] * 100)

    if rectangularity < 0.85:
        return None, "region not rectangular (fill %.0f%%)" % (rectangularity * 100)

    # Inset or border, then corner positions for the edge-band check (relative
    # to the film area, not the scan, so scanner bands don't disqualify
    # frames). The border margin is capped to the film area rather than
    # letting the downstream [0,1] clamps handle it: the crop is stored as two
    # opposite corners of a *tilted* polygon, and clamping those independently
    # would distort the rectangle instead of trimming it.
    tight = normalize_rect(rect)
    border = params["border"]
    if border > 0:
        margin = min(border * min(rw, rh),
                     max_margin((cx, cy), (rw, rh), raw_angle, strip))
        rw, rh = rw + 2 * margin, rh + 2 * margin
    else:
        inset = params["inset"]
        rw, rh = rw * (1 - 2 * inset), rh * (1 - 2 * inset)
    box = cv2.boxPoints(((cx, cy), (rw, rh), raw_angle))

    band = params["edge_band"]
    sw, sh = sx1 - sx0, sy1 - sy0
    for x, y in box:
        if not (-2 <= x <= w + 2 and -2 <= y <= h + 2):
            return None, "corner outside image bounds"
        rx, ry = (x - sx0) / sw, (y - sy0) / sh
        if band < rx < 1 - band and band < ry < 1 - band:
            return None, "corner (%.0f, %.0f) deeper than %d%% edge band of film area" % (
                x, y, band * 100)

    return (box, angle, (cx, cy), tight), None


def to_lightroom(box, angle, w, h):
    """Pick the two stored crop corners, normalize.

    Lightroom stores CropLeft/Top and CropRight/Bottom as two opposite
    corners of the *tilted* crop polygon — the ones that become top-left
    and bottom-right once the image is straightened by CropAngle about the
    image center — in unrotated-image normalized coordinates (model
    confirmed against darktable's lightroom.c importer, which rotates the
    stored pair by -angle to obtain the straightened crop). They are NOT
    the polygon's axis-aligned bounds; for a tilted frame those differ by
    ~sin(angle) x frame size, which shows up as rebate on two sides.
    """
    cx, cy = w / 2.0, h / 2.0
    rad = math.radians(-angle)
    cos_a, sin_a = math.cos(rad), math.sin(rad)

    def straightened_sum(p):
        dx, dy = p[0] - cx, p[1] - cy
        return (cx + dx * cos_a - dy * sin_a) + (cy + dx * sin_a + dy * cos_a)

    pts = [(float(x), float(y)) for x, y in box]
    tl = min(pts, key=straightened_sum)
    br = max(pts, key=straightened_sum)
    return {
        "CropLeft": max(0.0, min(1.0, tl[0] / w)),
        "CropTop": max(0.0, min(1.0, tl[1] / h)),
        "CropRight": max(0.0, min(1.0, br[0] / w)),
        "CropBottom": max(0.0, min(1.0, br[1] / h)),
        "CropAngle": float(angle),
    }


def confidence_score(box, angle, rectangularity, params, dims=None):
    """Blend of cues in [0,1]; informational, skip decisions are rule-based.

    dims, when given, is the frame's tight (pre-inset/border) size — the
    border margin pulls the emitted box's aspect toward 1.0, which must not
    read as a worse detection.
    """
    if dims is not None:
        side_a, side_b = dims
    else:
        side_a = math.hypot(box[1][0] - box[0][0], box[1][1] - box[0][1])
        side_b = math.hypot(box[2][0] - box[1][0], box[2][1] - box[1][1])
    aspect = max(side_a, side_b) / max(1.0, min(side_a, side_b))
    aspect_err = abs(aspect - params["aspect_target"]) / (
        params["aspect_target"] * params["aspect_tolerance"])
    angle_err = min(1.0, abs(angle) / 10.0)
    return round(float(max(0.0, min(1.0,
        0.5 * rectangularity + 0.35 * (1 - min(1.0, aspect_err)) + 0.15 * (1 - angle_err)))), 3)


def write_debug(img, info, box, crop, path):
    """Overlay: red = candidate mask, blue = film-area bbox, green = detected
    frame, magenta = final unrotated crop. Written for failures too."""
    vis = img.copy()
    if info is not None:
        if info["mask"] is not None:  # the rescue strategy never builds one
            contours, _ = cv2.findContours(info["mask"], cv2.RETR_LIST,
                                           cv2.CHAIN_APPROX_SIMPLE)
            cv2.drawContours(vis, contours, -1, (0, 0, 255), 2)
        sx0, sy0, sx1, sy1 = info["strip"]
        cv2.rectangle(vis, (sx0, sy0), (sx1 - 1, sy1 - 1), (255, 128, 0), 2)
    if box is not None:
        cv2.polylines(vis, [np.int32(box)], True, (0, 255, 0), 3)
    if crop is not None:
        # Reconstruct the crop polygon Lightroom will show: straighten the
        # stored corner pair about the image center, form the axis-aligned
        # rect, rotate it back. It should land on the green box exactly.
        h, w = img.shape[:2]
        cx, cy = w / 2.0, h / 2.0
        rad = math.radians(-crop["CropAngle"])
        cos_a, sin_a = math.cos(rad), math.sin(rad)

        def rot(x, y, sa):
            dx, dy = x - cx, y - cy
            return (cx + dx * cos_a - dy * sa, cy + dx * sa + dy * cos_a)

        tx, ty = rot(crop["CropLeft"] * w, crop["CropTop"] * h, sin_a)
        bx, by = rot(crop["CropRight"] * w, crop["CropBottom"] * h, sin_a)
        pts = [rot(x, y, -sin_a)
               for x, y in ((tx, ty), (bx, ty), (bx, by), (tx, by))]
        cv2.polylines(vis, [np.int32(pts)], True, (255, 0, 255), 1)
    cv2.imwrite(path, vis)


def run(image_path, params_path):
    params = load_params(params_path)
    img = cv2.imread(image_path)
    if img is None:
        raise IOError("cannot read image: %s" % image_path)
    h, w = img.shape[:2]

    result = {"skip": True, "reason": None, "confidence": 0.0,
              "width": w, "height": h, "film_type": params["film_type"]}

    info, validated, reason = detect(img, params)
    result["strip"] = [int(v) for v in info["strip"]]

    box, crop = None, None
    if validated is not None:
        box, angle, _center, tight = validated
        crop = to_lightroom(box, angle, w, h)
        result.update(crop)
        result["skip"] = False
        result["strategy"] = info["strategy"]
        if info["strategy"] == "rescue":
            result["rescued"] = True
        if info["aspect_snapped"]:
            result["aspect_snapped"] = True
        result["corners"] = [[round(float(x), 2), round(float(y), 2)]
                             for x, y in box]
        result["confidence"] = confidence_score(
            box, angle, info["rectangularity"], params, dims=tight[1])
        # The tight frame rect, film-area-relative: preview sizes differ
        # between photos, so this — not pixels — is what a roll median can
        # aggregate and feed back as a rescue hint.
        (tcx, tcy), (tw, th), tangle = tight
        sx0, sy0, sx1, sy1 = info["strip"]
        sw, sh = max(1, sx1 - sx0), max(1, sy1 - sy0)
        result["rect"] = {
            "cx": round((tcx - sx0) / sw, 5), "cy": round((tcy - sy0) / sh, 5),
            "w": round(tw / sw, 5), "h": round(th / sh, 5),
            "angle": round(float(tangle), 3),
        }

    if reason is not None:
        result["reason"] = reason
    if params["debug"]:
        write_debug(img, info, box, crop, image_path + ".debug.jpg")
    return result


def diag(out_path):
    """Runtime report for the plugin's Run Diagnostics button."""
    info = {
        "python": sys.version.split()[0],
        "opencv": cv2.__version__,
        "numpy": np.__version__,
        "frozen": bool(getattr(sys, "frozen", False)),
        "executable": sys.executable,
    }
    with open(out_path, "w") as fh:
        json.dump(info, fh, indent=1)


def main(argv):
    if len(argv) == 3 and argv[1] == "--diag":
        diag(argv[2])
        return 0
    if len(argv) != 3:
        print(__doc__, file=sys.stderr)
        return 2
    image_path, params_path = argv[1], argv[2]
    out_path = image_path + ".result.json"
    try:
        result = run(image_path, params_path)
    except Exception as exc:  # report through the result file so Lua can show it
        with open(out_path, "w") as fh:
            json.dump({"skip": True, "error": str(exc)}, fh)
        return 1
    with open(out_path, "w") as fh:
        json.dump(result, fh, indent=1)
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
