#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Build readiness-based patch collages using bitcost only. Pipeline: 1. sample frames at a fixed FPS or exact count 2. compute bitcost score maps 3. build fixed/readiness groups from bitcost scores 4. select 2x2 patch blocks by bitcost score 5. pack selected patches into canvases per group """ import argparse import json import os import subprocess import sys from pathlib import Path from typing import Any, Dict, List import numpy as np try: import codec_dcvc_config as _dc # DCVC selection params from preprocessor_config.json except ImportError: sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) import codec_dcvc_config as _dc _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # codec_tools/ is one level up from pipeline/ _CODEC_TOOLS_DIR = os.path.join(_SCRIPT_DIR, "..") sys.path.insert(0, _CODEC_TOOLS_DIR) from codec_patch_gop.video_processor import bitcost_item_to_score_map, cv_reader_fetch_bitcost # type: ignore from codec_patch_gop.frame_utils import decode_frames_bgr as decode_frames_bgr_gop # type: ignore from codec_patch_gop.video_probe import ffprobe_video_codec_name, ffprobe_keyframe_frame_ids # type: ignore from codec_patch_gop.utils import smart_resize # type: ignore from pipeline.generate_codec_patch_smart_resize import ensure_dir, get_total_frames_fps, sha1_8 # type: ignore from pipeline.process_video_bitcost_mv_mask_collage import ( # type: ignore build_adaptive_groups, compute_group_block_budget, encode_video_with_ffmpeg, estimate_readiness_threshold, prepare_frames, process_group, resize_and_pad_frames_to_shape, resolve_group_frame_limits, resize_within_max_dim, sample_frame_ids_at_fps, sample_frame_ids_uniform_count, save_canvases, shift_frame_ids_off_keyframes, dedup_and_refill_frame_ids, ) def _suppress_bottom_edge(score_maps: List[np.ndarray]) -> List[np.ndarray]: """Attenuate the bottom-edge band of each per-frame bit-cost map. DCVC neural entropy systematically over-weights the bottom-center overlay band (subtitles / timestamps / watermarks) that HEVC h264 bits do not, which steals patch budget from real content. codec.dcvc.bottom_atten<1 multiplies the bottom codec.dcvc.bottom_band fraction of each frame's height by that factor (default 1.0 = off). Applied to the bit-cost BEFORE grouping/selection.""" atten = float(_dc.get("bottom_atten")) band = float(_dc.get("bottom_band")) if atten >= 1.0 or band <= 0.0 or not score_maps: return score_maps out: List[np.ndarray] = [] for sm in score_maps: sm = np.asarray(sm, dtype=np.float32).copy() h = sm.shape[0] k = max(1, int(round(h * band))) sm[h - k:, ...] *= atten out.append(sm) return out def bitcost_items_to_score_maps( bitcost_items: List[Dict[str, Any]], out_h: int, out_w: int, bitcost_grid: str, bitcost_pct: float, bitcost_log_scale: bool, codec_name: str, ) -> List[np.ndarray]: score_maps: List[np.ndarray] = [] for item in bitcost_items: try: score = bitcost_item_to_score_map( item, out_h=int(out_h), out_w=int(out_w), grid=str(bitcost_grid), pct=float(bitcost_pct), log_scale=bool(bitcost_log_scale), codec_name=codec_name, ) except TypeError: score = bitcost_item_to_score_map( item, out_h=int(out_h), out_w=int(out_w), grid=str(bitcost_grid), pct=float(bitcost_pct), log_scale=bool(bitcost_log_scale), ) score_maps.append(np.asarray(score, dtype=np.float32)) # Ablation: replace bit-cost with uniform random -> random patch selection # (control baseline: fill canvases with random patches, NOT DCVC-selected ones). if _dc.get("random_select", bool): _rng = np.random.default_rng(int(_dc.get("random_seed"))) score_maps = [_rng.random(sm.shape, dtype=np.float32) for sm in score_maps] return score_maps def ffprobe_video_stream_stats(video_path: str) -> Dict[str, Any]: cmd = [ "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,bit_rate,avg_frame_rate:format=bit_rate,duration", "-of", "json", str(video_path), ] try: r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) data = json.loads(r.stdout) except Exception: return { "stream_width": 0, "stream_height": 0, "stream_bit_rate": 0.0, "format_bit_rate": 0.0, "duration": 0.0, } stream = {} streams = data.get("streams") if isinstance(streams, list) and streams: if isinstance(streams[0], dict): stream = streams[0] fmt = data.get("format") if isinstance(data.get("format"), dict) else {} def _to_float(x: Any) -> float: try: return float(x) except Exception: return 0.0 return { "stream_width": int(stream.get("width", 0) or 0), "stream_height": int(stream.get("height", 0) or 0), "stream_bit_rate": _to_float(stream.get("bit_rate", 0.0)), "format_bit_rate": _to_float(fmt.get("bit_rate", 0.0)), "duration": _to_float(fmt.get("duration", 0.0)), } def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--video", required=True, type=str) ap.add_argument("--out_dir", required=True, type=str) ap.add_argument("--sample_fps", default=1.0, type=float) ap.add_argument("--num_sampled_frames", default=_dc.get("num_sampled_frames"), type=int, help="If >0, uniformly sample exactly this many frames across the full video and ignore --sample_fps.") ap.add_argument("--group_size", default=_dc.get("group_size"), type=int) ap.add_argument("--grouping_mode", default=_dc.get("grouping_mode"), choices=["fixed", "readiness"]) ap.add_argument("--images_per_group", default=_dc.get("images_per_group"), type=int) ap.add_argument("--patch", default=_dc.get("patch"), type=int) ap.add_argument("--max_dim", default=616, type=int) ap.add_argument("--max_pixels", default=_dc.get("max_pixels"), type=int, help="Max pixels for smart_resize (0 to use max_dim instead)") ap.add_argument("--no_resize", default=False, action=argparse.BooleanOptionalAction, help="Disable resize and only pad to patch-aligned size.") ap.add_argument("--decode_backsearch_max", default=_dc.get("decode_backsearch_max"), type=int) ap.add_argument("--bitcost_grid", default=_dc.get("bitcost_grid"), choices=["sub", "mb", "ctu", "adaptive"]) ap.add_argument("--bitcost_pct", default=_dc.get("bitcost_pct"), type=float) ap.add_argument("--bitcost_log_scale", default=True, action=argparse.BooleanOptionalAction) ap.add_argument("--avoid_keyframes", default=False, action=argparse.BooleanOptionalAction, help="Shift sampled frame ids away from exact keyframes/I-frames when possible.") ap.add_argument("--avoid_keyframe_offset", default=1, type=int, help="Frame offset step used when shifting sampled ids away from keyframes.") ap.add_argument("--canvas_format", default=_dc.get("canvas_format"), choices=["jpg", "png"], help="Format used to save output canvases.") ap.add_argument("--save_mask_video", default=False, action=argparse.BooleanOptionalAction) ap.add_argument("--video_fps", default=0.0, type=float, help="Output FPS for side-by-side mask video. 0 means use sample_fps.") ap.add_argument("--readiness_sum_threshold", default=0.0, type=float, help="Top-k candidate block-score sum threshold for adaptive grouping. <=0 means auto-estimate from fixed groups on this video.") ap.add_argument( "--readiness_sum_threshold_mode",default=_dc.get("readiness_sum_threshold_mode"), choices=["legacy", "fixed", "auto", "clamped_sqrt_bpppf"], help="How to determine the adaptive readiness-sum threshold. legacy preserves old behavior: use --readiness_sum_threshold if >0, else auto-estimate.", ) ap.add_argument( "--readiness_norm_sum_threshold", default=1050000.0, type=float, help="Normalized sum threshold used when --readiness_sum_threshold_mode=clamped_sqrt_bpppf.", ) ap.add_argument( "--bpppf_clamp_min", default=0.015, type=float, help="Lower clamp for bits_per_pixel_per_frame when using clamped_sqrt_bpppf threshold mode.", ) ap.add_argument( "--bpppf_clamp_max", default=0.09, type=float, help="Upper clamp for bits_per_pixel_per_frame when using clamped_sqrt_bpppf threshold mode.", ) ap.add_argument("--min_group_frames", default=_dc.get("min_group_frames"), type=int, help="Minimum sampled frames per adaptive group.") ap.add_argument("--max_group_frames", default=_dc.get("max_group_frames"), type=int, help="Maximum sampled frames per adaptive group.") ap.add_argument("--min_group_sec", default=0.0, type=float, help="Minimum real-time seconds per adaptive group. >0 overrides --min_group_frames.") ap.add_argument("--max_group_sec", default=0.0, type=float, help="Maximum real-time seconds per adaptive group. >0 overrides --max_group_frames.") ap.add_argument("--readiness_coverage_bins", default=_dc.get("readiness_coverage_bins"), type=int, help="Require selected high-score blocks to cover at least this many temporal bins.") ap.add_argument("--readiness_delta_ratio", default=_dc.get("readiness_delta_ratio"), type=float, help="If threshold is met and marginal gain ratio drops below this, cut the current group.") ap.add_argument("--stream_groups", default=False, action=argparse.BooleanOptionalAction, help="When using fixed grouping, process each group incrementally instead of decoding/scoring all sampled frames at once.") args = ap.parse_args() ensure_dir(args.out_dir) out_dir = Path(args.out_dir) total_frames, fps, h0, w0 = get_total_frames_fps(args.video) if total_frames <= 0: raise RuntimeError("cannot read video metadata") group_size = max(1, int(args.group_size)) if int(args.num_sampled_frames) > 0: frame_ids = sample_frame_ids_uniform_count(total_frames, int(args.num_sampled_frames)) else: frame_ids = sample_frame_ids_at_fps(total_frames, fps, args.sample_fps) keyframe_ids: List[int] = [] if bool(args.avoid_keyframes): keyframe_ids = ffprobe_keyframe_frame_ids(str(args.video), fps=float(fps), total_frames=int(total_frames)) frame_ids = shift_frame_ids_off_keyframes( frame_ids=frame_ids, keyframe_ids=keyframe_ids, total_frames=int(total_frames), offset=int(args.avoid_keyframe_offset), ) frame_ids = dedup_and_refill_frame_ids( frame_ids=frame_ids, total_frames=int(total_frames), keyframe_ids=keyframe_ids, ) codec_name = ffprobe_video_codec_name(args.video) ffprobe_stats = ffprobe_video_stream_stats(args.video) stream_groups = bool(args.stream_groups) grouping_mode = str(args.grouping_mode).lower().strip() min_group_frames, max_group_frames, group_limit_dbg = resolve_group_frame_limits( sample_fps=float(args.sample_fps), min_group_frames=int(args.min_group_frames), max_group_frames=int(args.max_group_frames), min_group_sec=float(args.min_group_sec), max_group_sec=float(args.max_group_sec), ) readiness_coverage_bins = int(max(1, int(args.readiness_coverage_bins))) readiness_sum_threshold = float(args.readiness_sum_threshold) readiness_sum_threshold_mode = str(args.readiness_sum_threshold_mode).lower().strip() stream_w = int(ffprobe_stats.get("stream_width", 0)) or int(w0) stream_h = int(ffprobe_stats.get("stream_height", 0)) or int(h0) video_bit_rate = float(ffprobe_stats.get("stream_bit_rate", 0.0)) if video_bit_rate <= 0.0: video_bit_rate = float(ffprobe_stats.get("format_bit_rate", 0.0)) fps_safe = float(fps) if float(fps) > 0 else 1.0 pixel_count = float(max(1, int(stream_w) * int(stream_h))) bits_per_frame = float(video_bit_rate) / float(max(fps_safe, 1e-6)) if video_bit_rate > 0 else 0.0 bits_per_pixel_per_frame = bits_per_frame / pixel_count if bits_per_frame > 0 else 0.0 clamp_min = float(max(0.0, float(args.bpppf_clamp_min))) clamp_max = float(max(0.0, float(args.bpppf_clamp_max))) clamped_bpppf = float(bits_per_pixel_per_frame) if clamp_min > 0.0: clamped_bpppf = max(clamped_bpppf, clamp_min) if clamp_max > 0.0: clamped_bpppf = min(clamped_bpppf, clamp_max) readiness_norm_sum_threshold = float(args.readiness_norm_sum_threshold) readiness_threshold_norm_factor = float(np.sqrt(clamped_bpppf)) if clamped_bpppf > 1e-12 else 1.0 frames_bgr: List[np.ndarray] = [] score_maps: List[np.ndarray] = [] h1 = w1 = hb = wb = s_full = resize_h = resize_w = pad_bottom = pad_right = 0 if grouping_mode == "readiness" or not stream_groups: frames_bgr = decode_frames_bgr_gop(args.video, frame_ids, backsearch_max=int(args.decode_backsearch_max), backend="auto") if len(frames_bgr) != len(frame_ids): raise RuntimeError(f"decoded {len(frames_bgr)} frames, expected {len(frame_ids)}") frames_bgr, resize_h, resize_w, pad_bottom, pad_right = prepare_frames( frames_bgr, patch=int(args.patch), max_dim=int(args.max_dim), max_pixels=int(args.max_pixels), no_resize=bool(args.no_resize), ) h1, w1 = frames_bgr[0].shape[:2] hb, wb, s_full, _summary_block_budget = compute_group_block_budget( h1=int(h1), w1=int(w1), patch=int(args.patch), images_per_group=int(args.images_per_group), ) bitcost_items = cv_reader_fetch_bitcost(args.video, frame_ids) if len(bitcost_items) != len(frame_ids): raise RuntimeError(f"bitcost fetch mismatch: {len(bitcost_items)} vs {len(frame_ids)}") score_maps = bitcost_items_to_score_maps( bitcost_items=bitcost_items, out_h=int(h1), out_w=int(w1), bitcost_grid=str(args.bitcost_grid), bitcost_pct=float(args.bitcost_pct), bitcost_log_scale=bool(args.bitcost_log_scale), codec_name=str(codec_name), ) score_maps = _suppress_bottom_edge(score_maps) if grouping_mode == "readiness": if readiness_sum_threshold_mode == "legacy": if readiness_sum_threshold <= 0.0: readiness_sum_threshold = estimate_readiness_threshold( masked_scores=score_maps, group_size=int(group_size), patch=int(args.patch), images_per_group=int(args.images_per_group), coverage_bins_target=int(readiness_coverage_bins), ) elif readiness_sum_threshold_mode == "auto": readiness_sum_threshold = estimate_readiness_threshold( masked_scores=score_maps, group_size=int(group_size), patch=int(args.patch), images_per_group=int(args.images_per_group), coverage_bins_target=int(readiness_coverage_bins), ) elif readiness_sum_threshold_mode == "clamped_sqrt_bpppf": readiness_sum_threshold = float(readiness_norm_sum_threshold) * float(readiness_threshold_norm_factor) elif readiness_sum_threshold_mode == "fixed": readiness_sum_threshold = float(readiness_sum_threshold) else: raise ValueError(f"unsupported readiness_sum_threshold_mode: {readiness_sum_threshold_mode}") # Optional post-scale of the (auto-)estimated threshold. Scale<1 lowers the # threshold so adaptive groups close sooner -> more, smaller groups -> more # canvases (spends more of the token budget, matching the HEVC baseline's # canvas count). codec.dcvc.threshold_scale (default 1.0 = unchanged). _thr_scale = float(_dc.get("threshold_scale")) if _thr_scale > 0 and _thr_scale != 1.0: readiness_sum_threshold = float(readiness_sum_threshold) * _thr_scale group_specs = build_adaptive_groups( frame_ids=[int(x) for x in frame_ids], masked_scores=score_maps, patch=int(args.patch), images_per_group=int(args.images_per_group), readiness_sum_threshold=float(readiness_sum_threshold), min_group_frames=int(min_group_frames), max_group_frames=int(max_group_frames), coverage_bins_target=int(readiness_coverage_bins), readiness_delta_ratio=float(args.readiness_delta_ratio), ) else: group_specs = [] for group_idx, start in enumerate(range(0, len(frame_ids), group_size)): end = min(len(frame_ids), start + group_size) group_specs.append({ "group_idx": int(group_idx), "start": int(start), "end": int(end), "frame_count": int(end - start), "stop_reason": "fixed_group_size", "readiness": {}, }) if stream_groups and grouping_mode != "fixed": print("[stream_groups] readiness mode still uses full-video buffering; falling back to non-stream behavior") if stream_groups and bool(args.no_resize): resize_h, resize_w = int(h0), int(w0) elif stream_groups and grouping_mode == "fixed": if int(args.max_pixels) > 0: resize_h, resize_w = smart_resize(h0, w0, factor=2 * int(args.patch), max_pixels=int(args.max_pixels)) else: resize_h, resize_w = resize_within_max_dim(h0, w0, max_dim=int(args.max_dim), factor=2 * int(args.patch)) h1 = int((int(resize_h) + (2 * int(args.patch) - 1)) // (2 * int(args.patch)) * (2 * int(args.patch))) w1 = int((int(resize_w) + (2 * int(args.patch) - 1)) // (2 * int(args.patch)) * (2 * int(args.patch))) pad_bottom = int(h1 - int(resize_h)) pad_right = int(w1 - int(resize_w)) hb, wb, s_full, _summary_block_budget = compute_group_block_budget( h1=int(h1), w1=int(w1), patch=int(args.patch), images_per_group=int(args.images_per_group), ) all_group_meta: List[Dict[str, Any]] = [] all_keep_patch_masks: List[np.ndarray] = [] all_canvases: List[np.ndarray] = [] all_patch_pos: List[np.ndarray] = [] all_src_pos: List[np.ndarray] = [] jpg_files: List[str] = [] global_img_offset = 0 for group_idx, spec in enumerate(group_specs): start = int(spec["start"]) end = int(spec["end"]) group_frames_count = end - start group_frame_ids = [int(x) for x in frame_ids[start:end]] if stream_groups and grouping_mode == "fixed": group_frames_bgr = decode_frames_bgr_gop( args.video, group_frame_ids, backsearch_max=int(args.decode_backsearch_max), backend="auto", ) if len(group_frames_bgr) != len(group_frame_ids): raise RuntimeError(f"group {group_idx}: decoded {len(group_frames_bgr)} frames, expected {len(group_frame_ids)}") group_frames_bgr, group_pad_bottom, group_pad_right = resize_and_pad_frames_to_shape( group_frames_bgr, resize_h=int(resize_h), resize_w=int(resize_w), factor=2 * int(args.patch), ) if group_idx == 0: pad_bottom, pad_right = int(group_pad_bottom), int(group_pad_right) h1, w1 = group_frames_bgr[0].shape[:2] group_bitcost_items = cv_reader_fetch_bitcost(args.video, group_frame_ids) if len(group_bitcost_items) != len(group_frame_ids): raise RuntimeError(f"group {group_idx}: bitcost fetch mismatch: {len(group_bitcost_items)} vs {len(group_frame_ids)}") group_scores = bitcost_items_to_score_maps( bitcost_items=group_bitcost_items, out_h=int(h1), out_w=int(w1), bitcost_grid=str(args.bitcost_grid), bitcost_pct=float(args.bitcost_pct), bitcost_log_scale=bool(args.bitcost_log_scale), codec_name=str(codec_name), ) else: group_frames_bgr = [fr.copy() for fr in frames_bgr[start:end]] group_scores = [sc.copy() for sc in score_maps[start:end]] if group_frames_count < 4: print(f"[Skip] Group {group_idx}: insufficient frames ({group_frames_count}/4), skipping entire group") continue elif group_frames_count < group_size: print(f"[Partial] Group {group_idx}: partial group with {group_frames_count}/{group_size} frames, processing available frames") meta, keep_patch_mask, images_rgb, patch_pos, src_pos, _img_ptr = process_group( group_idx=group_idx, group_frame_ids=group_frame_ids, group_frames_bgr=group_frames_bgr, group_scores=group_scores, images_per_group=int(args.images_per_group), patch=int(args.patch), ) if stream_groups and grouping_mode == "fixed": jpg_files.extend(save_canvases(images_rgb, str(out_dir), start_idx=int(global_img_offset), fmt=str(args.canvas_format), quality=95)) else: for canvas_rgb in images_rgb: all_canvases.append(canvas_rgb) patch_pos_global = patch_pos.copy() patch_pos_global[:, 0] += global_img_offset all_patch_pos.append(patch_pos_global) all_src_pos.append(src_pos) meta["global_img_offset"] = int(global_img_offset) meta["num_canvases"] = int(images_rgb.shape[0]) meta["group_start_idx"] = int(start) meta["group_end_idx"] = int(end) meta["group_frame_count"] = int(group_frames_count) meta["group_stop_reason"] = str(spec.get("stop_reason", "unknown")) meta["group_readiness"] = spec.get("readiness", {}) global_img_offset += int(images_rgb.shape[0]) all_group_meta.append(meta) if not (stream_groups and grouping_mode == "fixed"): all_keep_patch_masks.append(keep_patch_mask) if (not stream_groups or grouping_mode != "fixed") and not all_canvases: raise RuntimeError("no canvases produced") if not (stream_groups and grouping_mode == "fixed"): images_rgb_all = np.stack(all_canvases, axis=0).astype(np.uint8) jpg_files = save_canvases(images_rgb_all, str(out_dir), fmt=str(args.canvas_format), quality=95) total_images_out = int(images_rgb_all.shape[0]) else: total_images_out = int(global_img_offset) patch_position = np.concatenate(all_patch_pos, axis=0).astype(np.int32) src_patch_position_raw = np.concatenate(all_src_pos, axis=0).astype(np.int32) hb = int(h1 // int(args.patch)) wb = int(w1 // int(args.patch)) s_full = hb * wb raster_idx = ( patch_position[:, 0].astype(np.int64) * s_full + patch_position[:, 1].astype(np.int64) * wb + patch_position[:, 2].astype(np.int64) ) src_patch_position = np.zeros_like(src_patch_position_raw) src_patch_position[raster_idx] = src_patch_position_raw np.save(str(out_dir / "src_patch_position.npy"), src_patch_position, allow_pickle=False) np.save(str(out_dir / "frame_ids.npy"), np.asarray(frame_ids, dtype=np.int32), allow_pickle=False) if bool(args.save_mask_video) and all_keep_patch_masks: keep_patch_mask_full = np.concatenate(all_keep_patch_masks, axis=0).astype(np.uint8) out_fps = float(args.video_fps) if float(args.video_fps) > 0 else float(args.sample_fps) sbs_frames: List[np.ndarray] = [] masked_frames: List[np.ndarray] = [] p = int(args.patch) for fr, keep_grid in zip(frames_bgr, keep_patch_mask_full): masked = np.zeros_like(fr) ys, xs = np.where(keep_grid > 0) for ph, pw in zip(ys.tolist(), xs.tolist()): y0 = int(ph) * p x0 = int(pw) * p masked[y0:y0 + p, x0:x0 + p, :] = fr[y0:y0 + p, x0:x0 + p, :] sbs_frames.append(np.concatenate([fr, masked], axis=1)) masked_frames.append(masked) sbs_video_path = encode_video_with_ffmpeg(out_dir / "sampled_masked_sbs.mp4", out_fps, sbs_frames) masked_video_path = encode_video_with_ffmpeg(out_dir / "sampled_masked_only.mp4", out_fps, masked_frames) else: sbs_video_path = None masked_video_path = None summary = { "video": str(args.video), "video_hash": sha1_8(str(args.video)), "total_frames": int(total_frames), "orig_hw": [int(h0), int(w0)], "processed_hw": [int(h1), int(w1)], "resize_hw": [int(resize_h), int(resize_w)], "padded_hw": [int(h1), int(w1)], "pad_bottom_right": [int(pad_bottom), int(pad_right)], "fps": float(fps), "stream_hw": [int(stream_h), int(stream_w)], "video_bit_rate": float(video_bit_rate), "bits_per_frame": float(bits_per_frame), "bits_per_pixel_per_frame": float(bits_per_pixel_per_frame), "bits_per_pixel_per_frame_clamped": float(clamped_bpppf), "sample_fps": float(args.sample_fps), "num_sampled_frames": int(args.num_sampled_frames), "no_resize": bool(args.no_resize), "avoid_keyframes": bool(args.avoid_keyframes), "avoid_keyframe_offset": int(args.avoid_keyframe_offset), "keyframes_found": int(len(keyframe_ids)), "sampled_frames": int(len(frame_ids)), "group_size": int(args.group_size), "grouping_mode": str(grouping_mode), "images_per_group": int(args.images_per_group), "readiness_sum_threshold_mode": str(readiness_sum_threshold_mode), "readiness_sum_threshold": float(readiness_sum_threshold), "readiness_norm_sum_threshold": float(readiness_norm_sum_threshold), "readiness_threshold_norm_factor": float(readiness_threshold_norm_factor), "bpppf_clamp_min": float(args.bpppf_clamp_min), "bpppf_clamp_max": float(args.bpppf_clamp_max), "min_group_frames": int(min_group_frames), "max_group_frames": int(max_group_frames), "min_group_sec": float(args.min_group_sec), "max_group_sec": float(args.max_group_sec), "group_limit_debug": group_limit_dbg, "readiness_coverage_bins": int(readiness_coverage_bins), "readiness_delta_ratio": float(args.readiness_delta_ratio), "save_mask_video": bool(args.save_mask_video), "canvas_format": str(args.canvas_format), "video_fps": float(args.video_fps) if float(args.video_fps) > 0 else float(args.sample_fps), "mask_video_sbs": str(Path(sbs_video_path).name) if sbs_video_path is not None else None, "mask_video_only": str(Path(masked_video_path).name) if masked_video_path is not None else None, "patch": int(args.patch), "bitcost_grid": str(args.bitcost_grid), "codec_name": str(codec_name), "bitcost_pct": float(args.bitcost_pct), "bitcost_log_scale": bool(args.bitcost_log_scale), "score_source": "bitcost", "hb_wb": [int(hb), int(wb)], "S_full": int(s_full), "seq_len": int(len(frame_ids)), "num_groups": int(len(all_group_meta)), "tail_frames": int(len(frame_ids) % group_size) if grouping_mode == "fixed" else 0, "group_specs": [ { "group_idx": int(i), "start": int(spec.get("start", 0)), "end": int(spec.get("end", 0)), "frame_count": int(spec.get("frame_count", 0)), "stop_reason": str(spec.get("stop_reason", "unknown")), "readiness": spec.get("readiness", {}), } for i, spec in enumerate(group_specs) ], "total_images": int(total_images_out), "total_patches": int(patch_position.shape[0]), "canvas_files": jpg_files, "jpg_files": jpg_files, "out_dir": str(out_dir), "src_patch_position_sorted_by_t": True, "canvas_pixels_sorted_by_t": True, "groups": all_group_meta, } with open(out_dir / "meta.json", "w", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2) print(f"[done] sampled={len(frame_ids)} groups={len(all_group_meta)} out_dir={str(out_dir)}") if __name__ == "__main__": main()