#!/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)
    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,
    "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 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.
    """
    (cx, cy), _, raw_angle = rect
    angle = normalize_angle(raw_angle, rect[1])
    h, w = blur.shape
    m = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
    unrot = cv2.warpAffine(blur, 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 rect
    col_profile = unrot[max(0, iy0):iy1, :].mean(axis=0)
    row_profile = unrot[:, max(0, ix0):ix1].mean(axis=1)
    sign = 1.0 if polarity == "brighter" else -1.0  # frame minus rebate

    def snap(profile, pos, edge_sign):
        c = int(round(pos))
        if c - band < 1 or c + band + 1 > len(profile) - 1:
            return pos
        g = np.gradient(profile.astype(np.float32))[c - band:c + band + 1]
        g = g * edge_sign
        k = int(np.argmax(g))
        if g[k] < 1.5:  # no transition: rebate or frame on both sides
            return pos
        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

    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 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}

    # 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)
        validated, reason = validate(rect, rectangularity, strip, w, h, params)
        if validated is not None:
            info["rect"], info["rectangularity"] = rect, rectangularity
            info["strategy"] = strategy
            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
        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))
