#!/usr/bin/env python3
"""
football_touch_ensemble_streaming.py

Streaming football-touch detector using:
- 1 player detector
- 2 primary football-ball detectors
- 1 optional third ball detector as tiebreaker

Ensemble logic per frame:
1. Run Ball Model A + Ball Model B.
2. If their best candidates spatially agree:
      accept a confidence-weighted fused ball position.
3. If they disagree:
      run Ball Model C.
4. Choose the agreeing pair with the strongest consensus.
5. If no two models agree:
      reject the ball for that frame.

Then:
- track the accepted ball temporally
- find nearest player
- estimate touch from player proximity + velocity/direction change
- immediately save each confirmed touch image
- immediately rewrite JSON atomically
- show live progress

Install:
    pip install ultralytics opencv-python-headless numpy tqdm

Usage:
    python football_touch_ensemble_streaming.py \
        --video /path/to/half.mp4 \
        --config football_touch_ensemble_config.json

Model files are intentionally configurable. Download your chosen .pt files and
set their paths in the config.
"""

import argparse
import json
import math
import os
from collections import deque
from dataclasses import dataclass
from pathlib import Path

import cv2
import numpy as np
from tqdm import tqdm
from ultralytics import YOLO


@dataclass
class Detection:
    model_name: str
    frame_idx: int
    box: tuple
    center: tuple
    confidence: float


@dataclass
class FrameData:
    frame_idx: int
    timestamp: float
    image: object
    persons: list
    ball: object
    ball_consensus: object


def LoadConfig(path):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def EnsureDir(path):
    Path(path).mkdir(parents=True, exist_ok=True)


def BoxCenter(box):
    x1, y1, x2, y2 = box
    return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)


def Distance(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])


def IoU(box_a, box_b):
    ax1, ay1, ax2, ay2 = box_a
    bx1, by1, bx2, by2 = box_b

    ix1 = max(ax1, bx1)
    iy1 = max(ay1, by1)
    ix2 = min(ax2, bx2)
    iy2 = min(ay2, by2)

    iw = max(0, ix2 - ix1)
    ih = max(0, iy2 - iy1)
    intersection = iw * ih

    area_a = max(0, ax2 - ax1) * max(0, ay2 - ay1)
    area_b = max(0, bx2 - bx1) * max(0, by2 - by1)

    union = area_a + area_b - intersection

    if union <= 0:
        return 0.0

    return intersection / union


def PointToBoxDistance(point, box):
    px, py = point
    x1, y1, x2, y2 = box

    dx = max(x1 - px, 0, px - x2)
    dy = max(y1 - py, 0, py - y2)

    return math.hypot(dx, dy)


def ExpandBox(box, ratio, width, height):
    x1, y1, x2, y2 = box
    bw = x2 - x1
    bh = y2 - y1

    return (
        max(0, int(x1 - bw * ratio)),
        max(0, int(y1 - bh * ratio)),
        min(width - 1, int(x2 + bw * ratio)),
        min(height - 1, int(y2 + bh * ratio)),
    )


def FormatTimestamp(seconds):
    minutes = int(seconds // 60)
    remain = seconds - minutes * 60
    sec = int(remain)
    ms = int(round((remain - sec) * 1000))

    if ms >= 1000:
        sec += 1
        ms = 0

    if sec >= 60:
        minutes += 1
        sec = 0

    return f"{minutes:02d}:{sec:02d}.{ms:03d}"


def WeightedAverage(values, weights):
    total = sum(weights)
    if total <= 0:
        return sum(values) / len(values)
    return sum(v * w for v, w in zip(values, weights)) / total


def FuseDetections(a, b):
    weights = [
        max(a.confidence, 0.001),
        max(b.confidence, 0.001),
    ]

    cx = WeightedAverage(
        [a.center[0], b.center[0]],
        weights,
    )

    cy = WeightedAverage(
        [a.center[1], b.center[1]],
        weights,
    )

    x1 = int(round(WeightedAverage(
        [a.box[0], b.box[0]],
        weights,
    )))
    y1 = int(round(WeightedAverage(
        [a.box[1], b.box[1]],
        weights,
    )))
    x2 = int(round(WeightedAverage(
        [a.box[2], b.box[2]],
        weights,
    )))
    y2 = int(round(WeightedAverage(
        [a.box[3], b.box[3]],
        weights,
    )))

    return Detection(
        model_name=f"{a.model_name}+{b.model_name}",
        frame_idx=a.frame_idx,
        box=(x1, y1, x2, y2),
        center=(cx, cy),
        confidence=(a.confidence + b.confidence) / 2.0,
    )


class BallTracker:
    def __init__(self, cfg):
        self.cfg = cfg
        self.previous = None
        self.current = None

    def Predict(self):
        if self.current is None:
            return None

        if self.previous is None:
            return self.current

        return (
            self.current[0] + (self.current[0] - self.previous[0]),
            self.current[1] + (self.current[1] - self.previous[1]),
        )

    def Validate(self, detection, width, height):
        if detection is None:
            return None

        tracking = self.cfg["tracking"]

        predicted = self.Predict()

        if predicted is not None:
            diagonal = math.hypot(width, height)

            max_jump = tracking.get("max_jump_pixels")
            if max_jump is None:
                max_jump = diagonal * float(
                    tracking["max_jump_diagonal_ratio"]
                )

            if Distance(detection.center, predicted) > float(max_jump):
                return None

        self.previous = self.current
        self.current = detection.center

        return detection


class EnsembleBallDetector:
    def __init__(self, cfg):
        self.cfg = cfg
        ensemble_cfg = cfg["ball_ensemble"]

        self.models = {}

        for key in ["model_a", "model_b", "model_c"]:
            model_cfg = ensemble_cfg[key]

            if model_cfg.get("enabled", True):
                print(
                    f"Loading {key}: "
                    f"{model_cfg['name']} "
                    f"({model_cfg['path']})"
                )
                self.models[key] = YOLO(model_cfg["path"])

    def _DetectWithModel(self, key, frame, frame_idx):
        model_cfg = self.cfg["ball_ensemble"][key]

        if key not in self.models:
            return []

        model = self.models[key]

        result = model.predict(
            frame,
            verbose=False,
            conf=float(model_cfg["confidence"]),
            imgsz=int(model_cfg["image_size"]),
            device=model_cfg.get(
                "device",
                self.cfg["runtime"].get("device"),
            ),
        )[0]

        class_id = int(model_cfg["ball_class_id"])

        detections = []

        if result.boxes is not None:
            for obj in result.boxes:
                detected_class = int(obj.cls[0].item())

                if detected_class != class_id:
                    continue

                confidence = float(obj.conf[0].item())
                xyxy = obj.xyxy[0].cpu().numpy()
                box = tuple(map(int, xyxy))

                detections.append(
                    Detection(
                        model_name=model_cfg["name"],
                        frame_idx=frame_idx,
                        box=box,
                        center=BoxCenter(box),
                        confidence=confidence,
                    )
                )

        return detections

    def _PlausibleCandidates(self, detections):
        validation = self.cfg["ball_validation"]

        result = []

        for det in detections:
            x1, y1, x2, y2 = det.box

            width = max(1, x2 - x1)
            height = max(1, y2 - y1)
            aspect = width / height

            if width < int(validation["min_width_pixels"]):
                continue

            if height < int(validation["min_height_pixels"]):
                continue

            if width > int(validation["max_width_pixels"]):
                continue

            if height > int(validation["max_height_pixels"]):
                continue

            if aspect < float(validation["min_aspect_ratio"]):
                continue

            if aspect > float(validation["max_aspect_ratio"]):
                continue

            result.append(det)

        return result

    def _BestPair(self, list_a, list_b, cfg_a, cfg_b):
        consensus = self.cfg["ball_ensemble"]["consensus"]

        max_distance = float(
            consensus["max_center_distance_pixels"]
        )

        min_iou = float(
            consensus["minimum_iou"]
        )

        candidates = []

        for a in list_a:
            for b in list_b:
                center_distance = Distance(
                    a.center,
                    b.center,
                )

                iou = IoU(
                    a.box,
                    b.box,
                )

                agrees = (
                    center_distance <= max_distance
                    or iou >= min_iou
                )

                if not agrees:
                    continue

                quality = (
                    (a.confidence * float(cfg_a["vote_weight"]))
                    + (b.confidence * float(cfg_b["vote_weight"]))
                    - center_distance
                    * float(consensus["distance_penalty"])
                )

                candidates.append(
                    (
                        quality,
                        center_distance,
                        iou,
                        a,
                        b,
                    )
                )

        if not candidates:
            return None

        candidates.sort(
            key=lambda x: x[0],
            reverse=True,
        )

        return candidates[0]

    def Detect(self, frame, frame_idx):
        ensemble_cfg = self.cfg["ball_ensemble"]

        cfg_a = ensemble_cfg["model_a"]
        cfg_b = ensemble_cfg["model_b"]
        cfg_c = ensemble_cfg["model_c"]

        detections_a = self._PlausibleCandidates(
            self._DetectWithModel(
                "model_a",
                frame,
                frame_idx,
            )
        )

        detections_b = self._PlausibleCandidates(
            self._DetectWithModel(
                "model_b",
                frame,
                frame_idx,
            )
        )

        pair_ab = self._BestPair(
            detections_a,
            detections_b,
            cfg_a,
            cfg_b,
        )

        if pair_ab is not None:
            quality, center_distance, iou, a, b = pair_ab

            return (
                FuseDetections(a, b),
                {
                    "accepted": True,
                    "models": [
                        a.model_name,
                        b.model_name,
                    ],
                    "third_model_used": False,
                    "center_distance": center_distance,
                    "iou": iou,
                    "quality": quality,
                },
            )

        # No agreement between first two models.
        if not cfg_c.get("enabled", True):
            return None, {
                "accepted": False,
                "reason": "primary_models_disagree",
                "third_model_used": False,
            }

        detections_c = self._PlausibleCandidates(
            self._DetectWithModel(
                "model_c",
                frame,
                frame_idx,
            )
        )

        pair_ac = self._BestPair(
            detections_a,
            detections_c,
            cfg_a,
            cfg_c,
        )

        pair_bc = self._BestPair(
            detections_b,
            detections_c,
            cfg_b,
            cfg_c,
        )

        possible = []

        if pair_ac is not None:
            possible.append(("A+C", pair_ac))

        if pair_bc is not None:
            possible.append(("B+C", pair_bc))

        if not possible:
            return None, {
                "accepted": False,
                "reason": "no_two_models_agree",
                "third_model_used": True,
            }

        possible.sort(
            key=lambda x: x[1][0],
            reverse=True,
        )

        pair_name, best = possible[0]
        quality, center_distance, iou, a, b = best

        return (
            FuseDetections(a, b),
            {
                "accepted": True,
                "models": [
                    a.model_name,
                    b.model_name,
                ],
                "pair": pair_name,
                "third_model_used": True,
                "center_distance": center_distance,
                "iou": iou,
                "quality": quality,
            },
        )


class PlayerDetector:
    def __init__(self, cfg):
        model_cfg = cfg["player_model"]

        print(
            f"Loading player detector: "
            f"{model_cfg['path']}"
        )

        self.cfg = cfg
        self.model_cfg = model_cfg
        self.model = YOLO(model_cfg["path"])

    def Detect(self, frame, frame_idx):
        cfg = self.model_cfg

        result = self.model.predict(
            frame,
            verbose=False,
            conf=float(cfg["confidence"]),
            classes=[int(cfg["person_class_id"])],
            imgsz=int(cfg["image_size"]),
            device=cfg.get(
                "device",
                self.cfg["runtime"].get("device"),
            ),
        )[0]

        persons = []

        if result.boxes is not None:
            for obj in result.boxes:
                confidence = float(obj.conf[0].item())
                xyxy = obj.xyxy[0].cpu().numpy()
                box = tuple(map(int, xyxy))

                persons.append(
                    Detection(
                        model_name="player_detector",
                        frame_idx=frame_idx,
                        box=box,
                        center=BoxCenter(box),
                        confidence=confidence,
                    )
                )

        return persons


def FindNearestPlayer(ball_center, persons, width, height, cfg):
    ratio = float(
        cfg["touch_detection"][
            "player_box_expansion_ratio"
        ]
    )

    best = None
    best_distance = float("inf")

    for player in persons:
        expanded = ExpandBox(
            player.box,
            ratio,
            width,
            height,
        )

        distance = PointToBoxDistance(
            ball_center,
            expanded,
        )

        if distance < best_distance:
            best = player
            best_distance = distance

    return best, best_distance


def Velocity(a, b, fps):
    if a is None or b is None:
        return None

    return Distance(
        a.center,
        b.center,
    ) * fps


def CalculateTouch(buffer, fps, width, height, cfg):
    touch_cfg = cfg["touch_detection"]

    before = int(
        touch_cfg["velocity_before_frames"]
    )

    after = int(
        touch_cfg["velocity_after_frames"]
    )

    required = before + 1 + after

    if len(buffer) < required:
        return None

    frames = list(buffer)[-required:]
    center_index = before
    center = frames[center_index]

    if center.ball is None:
        return None

    player, player_distance = FindNearestPlayer(
        center.ball.center,
        center.persons,
        width,
        height,
        cfg,
    )

    if player is None:
        return None

    diagonal = math.hypot(width, height)

    max_distance = touch_cfg.get(
        "max_ball_player_distance_pixels"
    )

    if max_distance is None:
        max_distance = diagonal * float(
            touch_cfg[
                "max_ball_player_distance_diagonal_ratio"
            ]
        )

    if player_distance > float(max_distance):
        return None

    before_vel = []

    for i in range(1, center_index + 1):
        velocity = Velocity(
            frames[i - 1].ball,
            frames[i].ball,
            fps,
        )

        if velocity is not None:
            before_vel.append(velocity)

    after_vel = []

    for i in range(
        center_index + 1,
        len(frames),
    ):
        velocity = Velocity(
            frames[i - 1].ball,
            frames[i].ball,
            fps,
        )

        if velocity is not None:
            after_vel.append(velocity)

    if not before_vel or not after_vel:
        return None

    speed_before = float(np.mean(before_vel))
    speed_after = float(np.mean(after_vel))

    velocity_change = abs(
        speed_after - speed_before
    )

    proximity_score = max(
        0.0,
        1.0 - player_distance / float(max_distance),
    )

    velocity_score = min(
        1.0,
        velocity_change
        / float(
            touch_cfg[
                "velocity_change_normalizer_px_per_sec"
            ]
        ),
    )

    direction_score = 0.0

    if (
        center_index >= 1
        and center_index + 1 < len(frames)
        and frames[center_index - 1].ball is not None
        and frames[center_index + 1].ball is not None
    ):
        p0 = frames[center_index - 1].ball.center
        p1 = center.ball.center
        p2 = frames[center_index + 1].ball.center

        angle_1 = math.atan2(
            p1[1] - p0[1],
            p1[0] - p0[0],
        )

        angle_2 = math.atan2(
            p2[1] - p1[1],
            p2[0] - p1[0],
        )

        delta = abs(
            angle_2 - angle_1
        )

        while delta > math.pi:
            delta = abs(
                delta - 2 * math.pi
            )

        direction_score = min(
            1.0,
            delta
            / float(
                touch_cfg[
                    "direction_change_normalizer_radians"
                ]
            ),
        )

    weights = touch_cfg["score_weights"]

    touch_score = (
        proximity_score
        * float(weights["proximity"])
        + velocity_score
        * float(weights["velocity_change"])
        + direction_score
        * float(weights["direction_change"])
    )

    if touch_score < float(
        touch_cfg["minimum_touch_score"]
    ):
        return None

    return {
        "frame": center,
        "player": player,
        "ball": center.ball,
        "consensus": center.ball_consensus,
        "touch_score": touch_score,
        "player_distance": player_distance,
        "speed_before": speed_before,
        "speed_after": speed_after,
        "velocity_change": velocity_change,
    }


def SaveImage(touch, output_path, cfg):
    image = touch["frame"].image.copy()

    x1, y1, x2, y2 = touch["player"].box

    bx = int(
        round(
            touch["ball"].center[0]
        )
    )

    by = int(
        round(
            touch["ball"].center[1]
        )
    )

    output_cfg = cfg["output"]

    player_color = tuple(
        int(v)
        for v in output_cfg["player_box_bgr"]
    )

    ball_color = tuple(
        int(v)
        for v in output_cfg["ball_circle_bgr"]
    )

    cv2.rectangle(
        image,
        (x1, y1),
        (x2, y2),
        player_color,
        int(
            output_cfg[
                "player_box_thickness"
            ]
        ),
    )

    cv2.circle(
        image,
        (bx, by),
        int(
            output_cfg[
                "ball_circle_radius"
            ]
        ),
        ball_color,
        int(
            output_cfg[
                "ball_circle_thickness"
            ]
        ),
    )

    models = "+".join(
        touch["consensus"].get(
            "models",
            [],
        )
    )

    label = (
        f"Frame {touch['frame'].frame_idx} | "
        f"{FormatTimestamp(touch['frame'].timestamp)} | "
        f"touch={touch['touch_score']:.3f} | "
        f"ball={models}"
    )

    cv2.putText(
        image,
        label,
        (max(10, x1), max(30, y1 - 15)),
        cv2.FONT_HERSHEY_SIMPLEX,
        float(
            output_cfg[
                "label_font_scale"
            ]
        ),
        player_color,
        int(
            output_cfg[
                "label_thickness"
            ]
        ),
        cv2.LINE_AA,
    )

    cv2.imwrite(
        str(output_path),
        image,
        [
            cv2.IMWRITE_JPEG_QUALITY,
            int(output_cfg["jpeg_quality"]),
        ],
    )


def SaveJsonAtomic(path, payload, indent):
    temp_path = str(path) + ".tmp"

    with open(
        temp_path,
        "w",
        encoding="utf-8",
    ) as f:
        json.dump(
            payload,
            f,
            indent=indent,
        )
        f.flush()
        os.fsync(f.fileno())

    os.replace(
        temp_path,
        path,
    )


def Main():
    parser = argparse.ArgumentParser()

    parser.add_argument(
        "--video",
        required=True,
    )

    parser.add_argument(
        "--config",
        required=True,
    )

    parser.add_argument(
        "--output-dir",
        default=None,
    )

    args = parser.parse_args()

    cfg = LoadConfig(
        args.config
    )

    video_path = str(
        Path(args.video)
        .expanduser()
        .resolve()
    )

    output_dir = (
        Path(args.output_dir)
        if args.output_dir
        else Path(
            cfg["output"]["directory"]
        )
    )

    images_dir = (
        output_dir
        / cfg["output"][
            "images_subdirectory"
        ]
    )

    EnsureDir(
        output_dir
    )

    EnsureDir(
        images_dir
    )

    json_path = (
        output_dir
        / cfg["output"]["json_filename"]
    )

    print(
        "Loading ensemble models..."
    )

    ball_detector = EnsembleBallDetector(
        cfg
    )

    player_detector = PlayerDetector(
        cfg
    )

    tracker = BallTracker(
        cfg
    )

    cap = cv2.VideoCapture(
        video_path
    )

    if not cap.isOpened():
        raise RuntimeError(
            f"Cannot open video: {video_path}"
        )

    fps = float(
        cap.get(
            cv2.CAP_PROP_FPS
        )
    )

    total_frames = int(
        cap.get(
            cv2.CAP_PROP_FRAME_COUNT
        )
    )

    width = int(
        cap.get(
            cv2.CAP_PROP_FRAME_WIDTH
        )
    )

    height = int(
        cap.get(
            cv2.CAP_PROP_FRAME_HEIGHT
        )
    )

    start_seconds = float(
        cfg["video"].get(
            "start_seconds",
            0.0,
        )
    )

    end_seconds = cfg["video"].get(
        "end_seconds"
    )

    start_frame = max(
        0,
        int(
            round(
                start_seconds * fps
            )
        ),
    )

    end_frame = (
        total_frames - 1
        if end_seconds is None
        else min(
            total_frames - 1,
            int(
                round(
                    float(end_seconds)
                    * fps
                )
            ),
        )
    )

    every = max(
        1,
        int(
            cfg["video"].get(
                "process_every_n_frames",
                1,
            )
        ),
    )

    cap.set(
        cv2.CAP_PROP_POS_FRAMES,
        start_frame,
    )

    before = int(
        cfg["touch_detection"][
            "velocity_before_frames"
        ]
    )

    after = int(
        cfg["touch_detection"][
            "velocity_after_frames"
        ]
    )

    buffer = deque(
        maxlen=before + 1 + after
    )

    minimum_gap_frames = max(
        1,
        int(
            round(
                float(
                    cfg[
                        "touch_detection"
                    ][
                        "minimum_gap_between_touches_seconds"
                    ]
                )
                * fps
            )
        ),
    )

    last_saved_touch = -999999

    payload = {
        "video": video_path,
        "fps": fps,
        "resolution": {
            "width": width,
            "height": height,
        },
        "frame_count": total_frames,
        "status": "processing",
        "processed_frame": start_frame,
        "touch_count": 0,
        "touches": [],
    }

    SaveJsonAtomic(
        json_path,
        payload,
        int(
            cfg["output"][
                "json_indent"
            ]
        ),
    )

    progress = tqdm(
        total=(
            end_frame
            - start_frame
            + 1
        ),
        desc="Processing",
        unit="frame",
        dynamic_ncols=True,
    )

    frame_idx = start_frame

    while frame_idx <= end_frame:
        ok, frame = cap.read()

        if not ok:
            break

        if (
            frame_idx - start_frame
        ) % every != 0:
            frame_idx += 1
            progress.update(1)
            continue

        ball, consensus = (
            ball_detector.Detect(
                frame,
                frame_idx,
            )
        )

        ball = tracker.Validate(
            ball,
            width,
            height,
        )

        persons = []

        # Only run player detector when ball consensus exists.
        # This reduces GPU load substantially.
        if ball is not None:
            persons = (
                player_detector.Detect(
                    frame,
                    frame_idx,
                )
            )

        buffer.append(
            FrameData(
                frame_idx=frame_idx,
                timestamp=frame_idx / fps,
                image=frame.copy(),
                persons=persons,
                ball=ball,
                ball_consensus=consensus,
            )
        )

        touch = CalculateTouch(
            buffer,
            fps,
            width,
            height,
            cfg,
        )

        if touch is not None:
            touch_frame = (
                touch["frame"].frame_idx
            )

            if (
                touch_frame
                - last_saved_touch
                >= minimum_gap_frames
            ):
                last_saved_touch = touch_frame

                sequence = (
                    len(
                        payload["touches"]
                    )
                    + 1
                )

                timestamp = (
                    FormatTimestamp(
                        touch[
                            "frame"
                        ].timestamp
                    )
                )

                image_name = (
                    f"touch_{sequence:05d}_"
                    f"frame_{touch_frame}_"
                    f"{timestamp.replace(':', '-')}.jpg"
                )

                image_path = (
                    images_dir
                    / image_name
                )

                SaveImage(
                    touch,
                    image_path,
                    cfg,
                )

                x1, y1, x2, y2 = (
                    touch[
                        "player"
                    ].box
                )

                record = {
                    "frame": touch_frame,
                    "timestamp": timestamp,
                    "timestamp_seconds": round(
                        touch[
                            "frame"
                        ].timestamp,
                        6,
                    ),
                    "player_box": {
                        "x1": int(x1),
                        "y1": int(y1),
                        "x2": int(x2),
                        "y2": int(y2),
                    },
                    "ball_center": {
                        "x": round(
                            float(
                                touch[
                                    "ball"
                                ].center[0]
                            ),
                            2,
                        ),
                        "y": round(
                            float(
                                touch[
                                    "ball"
                                ].center[1]
                            ),
                            2,
                        ),
                    },
                    "touch_score": round(
                        float(
                            touch[
                                "touch_score"
                            ]
                        ),
                        6,
                    ),
                    "ball_consensus": (
                        touch[
                            "consensus"
                        ]
                    ),
                    "image": str(
                        image_path
                    ),
                }

                payload[
                    "touches"
                ].append(
                    record
                )

                payload[
                    "touch_count"
                ] = len(
                    payload[
                        "touches"
                    ]
                )

                payload[
                    "processed_frame"
                ] = frame_idx

                SaveJsonAtomic(
                    json_path,
                    payload,
                    int(
                        cfg[
                            "output"
                        ][
                            "json_indent"
                        ]
                    ),
                )

                tqdm.write(
                    f"TOUCH #{sequence} | "
                    f"frame={touch_frame} | "
                    f"time={timestamp} | "
                    f"score={touch['touch_score']:.3f} | "
                    f"models={touch['consensus'].get('models')}"
                )

        update_every = int(
            cfg[
                "runtime"
            ].get(
                "json_progress_update_every_frames",
                300,
            )
        )

        if (
            update_every > 0
            and frame_idx
            % update_every == 0
        ):
            payload[
                "processed_frame"
            ] = frame_idx

            SaveJsonAtomic(
                json_path,
                payload,
                int(
                    cfg[
                        "output"
                    ][
                        "json_indent"
                    ]
                ),
            )

        frame_idx += 1
        progress.update(1)

    progress.close()
    cap.release()

    payload["status"] = "completed"

    SaveJsonAtomic(
        json_path,
        payload,
        int(
            cfg[
                "output"
            ][
                "json_indent"
            ]
        ),
    )

    print(
        f"Completed. "
        f"Touches={payload['touch_count']}"
    )


if __name__ == "__main__":
    Main()
