#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Energy-based sampling and window selection algorithms.""" import math from typing import Tuple, List, Dict, Any import numpy as np from .video_probe import ffprobe_packets_pb_energy_bins, ffprobe_sum_pkt_size def pick_peak_frame_ids_from_pkt_size( video_path: str, fps: float, total_frames: int, bin_sec: float = 0.5, peaks_cap: int = 8, peaks_per_sec: float = 0.5, neighbor: int = 1, smooth_bins: int = 1, ) -> Tuple[List[int], Dict[str, Any]]: """Select peak frame ids using PB packet-size energy bins (exclude keyframes).""" fps_use = float(fps) if (fps and fps > 0) else 30.0 total_frames = int(max(0, total_frames)) centers, energy, dbg_bins = ffprobe_packets_pb_energy_bins( video_path=str(video_path), bin_sec=float(bin_sec), smooth_bins=int(smooth_bins), ) dbg: Dict[str, Any] = { "bin_sec": float(bin_sec), "peaks_cap": int(peaks_cap), "peaks_per_sec": float(peaks_per_sec), "neighbor": int(neighbor), "smooth_bins": int(smooth_bins), "bins": dbg_bins, "peak_bins": [], "peak_times_sec": [], "peak_frame_ids": [], } if energy.size == 0 or centers.size == 0: dbg["error"] = "no_energy" return [], dbg duration_sec = float(total_frames) / float(fps_use) if total_frames > 0 else float(dbg_bins.get("duration_est_sec", 0.0)) duration_sec = max(duration_sec, 0.0) k = int(max(1, round(duration_sec * float(peaks_per_sec)))) if duration_sec > 0 else 1 k = int(min(int(peaks_cap), k, int(energy.size))) idx = np.argsort(-energy)[:k] idx = [int(i) for i in idx.tolist()] dbg["peak_bins"] = idx peak_times = [float(centers[i]) for i in idx] dbg["peak_times_sec"] = peak_times out_set: set = set() nb = int(max(0, int(neighbor))) for t in peak_times: fid0 = int(round(float(t) * float(fps_use))) if total_frames > 0: fid0 = max(0, min(total_frames - 1, fid0)) for d in range(-nb, nb + 1): fid = int(fid0 + d) if total_frames > 0: fid = max(0, min(total_frames - 1, fid)) out_set.add(int(fid)) out = sorted(out_set) dbg["peak_frame_ids"] = out return out, dbg def build_variable_length_gops_by_energy( video_path: str, total_frames: int, fps: float, target_num_gops: int, bin_sec: float = 0.5, smooth_bins: int = 1, min_span_sec: float = 1.5, max_span_sec: float = 6.0, ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """Build variable-length GOPs by cumulative PB packet-size energy. V2.2 implementation: - compute PB-only packet-size energy bins - accumulate smoothed energy over time - cut a GOP when cumulative energy reaches a target threshold, subject to min/max span constraints - prefer a local low-energy valley near the threshold crossing so cut points land on more stable boundaries - each GOP records a start / end / anchor time """ total_frames = int(max(0, total_frames)) fps_use = float(fps) if (fps and fps > 0) else 30.0 target_num_gops = int(max(1, int(target_num_gops))) bin_sec = float(bin_sec) if (bin_sec and bin_sec > 1e-6) else 0.5 smooth_bins = int(max(0, int(smooth_bins))) min_span_sec = float(max(0.25, float(min_span_sec))) max_span_sec = float(max(min_span_sec, float(max_span_sec))) dbg: Dict[str, Any] = { "mode": "variable_gop_cumulative_energy", "target_num_gops": int(target_num_gops), "bin_sec": float(bin_sec), "smooth_bins": int(smooth_bins), "min_span_sec": float(min_span_sec), "max_span_sec": float(max_span_sec), "segments": [], "bins": {}, } if total_frames <= 0: seg = { "gop_idx": 0, "start_bin": 0, "end_bin": 0, "start_sec": 0.0, "end_sec": 0.0, "anchor_sec": 0.0, "energy_sum": 0.0, } dbg["segments"] = [seg] dbg["segment_count"] = 1 dbg["cut_energy_threshold"] = 0.0 return [seg], dbg centers, energy, bins_dbg = ffprobe_packets_pb_energy_bins( video_path=str(video_path), bin_sec=float(bin_sec), smooth_bins=int(smooth_bins), ) dbg["bins"] = bins_dbg duration_sec = float(total_frames) / float(fps_use) if total_frames > 0 else 0.0 duration_sec = max(duration_sec, 0.0) if energy.size == 0 or centers.size == 0: segs: List[Dict[str, Any]] = [] n = int(max(1, target_num_gops)) sec_edges = np.linspace(0.0, max(duration_sec, bin_sec), n + 1, dtype=np.float64).tolist() for gi in range(n): st_sec = float(sec_edges[gi]) ed_sec = float(sec_edges[gi + 1]) anchor_sec = 0.5 * (st_sec + ed_sec) segs.append({ "gop_idx": int(gi), "start_bin": int(gi), "end_bin": int(gi), "start_sec": float(st_sec), "end_sec": float(ed_sec), "anchor_sec": float(anchor_sec), "energy_sum": 0.0, }) dbg["segment_count"] = int(len(segs)) dbg["cut_energy_threshold"] = 0.0 dbg["fallback"] = "uniform_time" dbg["segments"] = segs return segs, dbg energy = np.asarray(energy, dtype=np.float64) energy = np.maximum(energy, 0.0) nb = int(len(energy)) total_energy = float(energy.sum()) if (not np.isfinite(total_energy)) or total_energy <= 1e-12: total_energy = float(nb) energy = np.ones((nb,), dtype=np.float64) cut_energy_threshold = float(total_energy) / float(max(1, target_num_gops)) min_bins = int(max(1, math.ceil(float(min_span_sec) / float(bin_sec)))) max_bins = int(max(min_bins, math.ceil(float(max_span_sec) / float(bin_sec)))) dbg["total_energy"] = float(total_energy) dbg["cut_energy_threshold"] = float(cut_energy_threshold) dbg["min_bins"] = int(min_bins) dbg["max_bins"] = int(max_bins) dbg["cut_points"] = [] segs: List[Dict[str, Any]] = [] start_bin = 0 acc = 0.0 gi = 0 valley_search_radius = int(max(1, min(3, max_bins // 3))) def _best_cut_bin(start_idx: int, current_idx: int) -> int: lo = int(max(int(start_idx) + int(min_bins) - 1, int(current_idx) - int(valley_search_radius))) hi = int(min(int(start_idx) + int(max_bins) - 1, int(nb) - 1, int(current_idx) + int(valley_search_radius))) if hi < lo: return int(current_idx) best = int(current_idx) best_key = (float(energy[best]), abs(int(best) - int(current_idx))) for bi in range(int(lo), int(hi) + 1): cand_key = (float(energy[bi]), abs(int(bi) - int(current_idx))) if cand_key < best_key: best = int(bi) best_key = cand_key return int(best) i = 0 while i < nb: acc += float(energy[i]) span_bins = int(i - start_bin + 1) reach_min = bool(span_bins >= min_bins) reach_max = bool(span_bins >= max_bins) reach_energy = bool(acc >= cut_energy_threshold) should_cut = False if reach_max: should_cut = True elif reach_min and reach_energy: should_cut = True if should_cut: cut_bin = _best_cut_bin(int(start_bin), int(i)) st_sec = float(start_bin) * float(bin_sec) ed_sec = min(duration_sec, float(cut_bin + 1) * float(bin_sec)) anchor_bin = int((start_bin + cut_bin) // 2) anchor_sec = float(centers[max(0, min(nb - 1, anchor_bin))]) seg_energy = float(energy[start_bin:cut_bin + 1].sum()) segs.append({ "gop_idx": int(gi), "start_bin": int(start_bin), "end_bin": int(cut_bin), "start_sec": float(st_sec), "end_sec": float(ed_sec), "anchor_sec": float(anchor_sec), "energy_sum": float(seg_energy), }) dbg["cut_points"].append({ "gop_idx": int(gi), "threshold_bin": int(i), "cut_bin": int(cut_bin), "cut_sec": float(ed_sec), }) gi += 1 start_bin = int(cut_bin + 1) acc = 0.0 i = int(cut_bin + 1) continue i += 1 if start_bin < nb: st_sec = float(start_bin) * float(bin_sec) ed_sec = min(duration_sec, float(nb) * float(bin_sec)) anchor_bin = int((start_bin + (nb - 1)) // 2) anchor_sec = float(centers[max(0, min(nb - 1, anchor_bin))]) tail_energy = float(energy[start_bin:nb].sum()) segs.append({ "gop_idx": int(gi), "start_bin": int(start_bin), "end_bin": int(nb - 1), "start_sec": float(st_sec), "end_sec": float(ed_sec), "anchor_sec": float(anchor_sec), "energy_sum": float(tail_energy), }) # Merge last segment if too short if len(segs) >= 2: last = segs[-1] span_last = float(last["end_sec"]) - float(last["start_sec"]) if span_last < float(min_span_sec) * 0.5: prev = segs[-2] prev["end_bin"] = int(last["end_bin"]) prev["end_sec"] = float(last["end_sec"]) prev["energy_sum"] = float(prev["energy_sum"]) + float(last["energy_sum"]) prev_anchor_bin = int((int(prev["start_bin"]) + int(prev["end_bin"])) // 2) prev["anchor_sec"] = float(centers[max(0, min(nb - 1, prev_anchor_bin))]) segs = segs[:-1] for gi, seg in enumerate(segs): seg["gop_idx"] = int(gi) dbg["segment_count"] = int(len(segs)) dbg["segments"] = [ { "gop_idx": int(seg["gop_idx"]), "start_bin": int(seg["start_bin"]), "end_bin": int(seg["end_bin"]), "start_sec": float(seg["start_sec"]), "end_sec": float(seg["end_sec"]), "anchor_sec": float(seg["anchor_sec"]), "energy_sum": float(seg["energy_sum"]), } for seg in segs ] return segs, dbg def sample_frame_ids_by_energy_cdf( video_path: str, total_frames: int, fps: float, target_count: int, bin_sec: float = 0.5, smooth_bins: int = 1, uniform_mix: float = 0.15, max_per_bin: int = 16, ) -> Tuple[List[int], Dict[str, Any]]: """Sample frame ids with density proportional to PB packet-size energy. Intuition: - high-energy time bins get more sampled frames - low-energy time bins get fewer sampled frames - a small uniform prior prevents static-but-important segments from being starved This effectively builds a new non-uniform timeline where "information-dense" regions are sampled more densely. """ total_frames = int(max(0, total_frames)) target_count = int(max(0, target_count)) fps_use = float(fps) if (fps and fps > 0) else 30.0 bin_sec = float(bin_sec) if (bin_sec and bin_sec > 1e-6) else 0.5 smooth_bins = int(max(0, int(smooth_bins))) uniform_mix = float(max(0.0, min(1.0, float(uniform_mix)))) max_per_bin = int(max(1, int(max_per_bin))) dbg: Dict[str, Any] = { "mode": "pkt_energy_cdf", "target_count": int(target_count), "bin_sec": float(bin_sec), "smooth_bins": int(smooth_bins), "uniform_mix": float(uniform_mix), "max_per_bin": int(max_per_bin), "bins": {}, "bin_count": 0, "selected_before_pad": 0, "selected_after_pad": 0, "per_bin_counts": [], } if total_frames <= 0 or target_count <= 0: dbg["error"] = "empty_input" return [], dbg centers, energy, bins_dbg = ffprobe_packets_pb_energy_bins( video_path=str(video_path), bin_sec=float(bin_sec), smooth_bins=int(smooth_bins), ) dbg["bins"] = bins_dbg if energy.size == 0 or centers.size == 0: dbg["error"] = "no_energy" if total_frames == 1: return [0] * target_count, dbg out = np.linspace(0, total_frames - 1, target_count, dtype=np.int32).tolist() dbg["selected_before_pad"] = int(len(out)) dbg["selected_after_pad"] = int(len(out)) return [int(x) for x in out], dbg nb = int(len(energy)) dbg["bin_count"] = int(nb) energy = np.asarray(energy, dtype=np.float64) energy = np.maximum(energy, 0.0) mean_e = float(energy.mean()) if energy.size > 0 else 0.0 if (not np.isfinite(mean_e)) or mean_e <= 0.0: mean_e = 1.0 # Mix in a uniform prior so static-but-important regions still get sampled. weights = (1.0 - uniform_mix) * energy + uniform_mix * mean_e weights = np.maximum(weights, 1e-12) weights = weights / float(weights.sum()) ideal = weights * float(target_count) counts = np.floor(ideal).astype(np.int32) frac = ideal - counts.astype(np.float64) # Respect a per-bin cap so a single bursty region does not absorb everything. counts = np.minimum(counts, int(max_per_bin)).astype(np.int32) cur = int(counts.sum()) if cur < target_count: order = np.argsort(-frac) for idx in order.tolist(): if cur >= target_count: break if int(counts[idx]) >= int(max_per_bin): continue counts[idx] += 1 cur += 1 if cur < target_count: order = np.argsort(-weights) ptr = 0 while cur < target_count and len(order) > 0: idx = int(order[ptr % len(order)]) if int(counts[idx]) < int(max_per_bin): counts[idx] += 1 cur += 1 ptr += 1 if ptr > int(target_count * 8 + nb * 8): break frame_ids: List[int] = [] per_bin_counts: List[Tuple[int, int]] = [] for bi in range(nb): c = int(counts[bi]) if c <= 0: continue per_bin_counts.append((int(bi), int(c))) t0 = float(bi) * float(bin_sec) t1 = float(bi + 1) * float(bin_sec) f0 = int(math.floor(t0 * fps_use)) f1 = int(math.ceil(t1 * fps_use)) - 1 if total_frames > 0: f0 = max(0, min(total_frames - 1, f0)) f1 = max(0, min(total_frames - 1, f1)) if f1 < f0: f1 = f0 if c == 1 or f1 == f0: picked = [int(round((f0 + f1) * 0.5))] else: picked = np.linspace(f0, f1, c, dtype=np.int32).tolist() frame_ids.extend(int(x) for x in picked) frame_ids = sorted(int(x) for x in frame_ids) dbg["per_bin_counts"] = [{"bin": int(b), "count": int(c)} for (b, c) in per_bin_counts] dbg["selected_before_pad"] = int(len(frame_ids)) if len(frame_ids) == 0: if total_frames == 1: frame_ids = [0] * target_count else: frame_ids = np.linspace(0, total_frames - 1, target_count, dtype=np.int32).tolist() if len(frame_ids) < target_count: if total_frames == 1: extra = [0] * (target_count - len(frame_ids)) else: extra = np.linspace(0, total_frames - 1, target_count - len(frame_ids), dtype=np.int32).tolist() frame_ids = sorted([int(x) for x in frame_ids] + [int(x) for x in extra]) if len(frame_ids) > target_count: idxs = np.linspace(0, len(frame_ids) - 1, target_count, dtype=np.int32).tolist() frame_ids = [int(frame_ids[int(i)]) for i in idxs] dbg["selected_after_pad"] = int(len(frame_ids)) return [int(x) for x in frame_ids], dbg def pick_windows_by_energy( video_path: str, total_frames: int, fps: float, window_len_frames: int, num_candidates: int, top_k: int, ) -> Tuple[List[Tuple[int, int, int]], Dict[str, Any]]: """Return list of selected windows: [(start_f, end_f, score_pkt_sum), ...] plus debug dict. """ total_frames = int(total_frames) window_len_frames = int(window_len_frames) num_candidates = int(num_candidates) top_k = int(top_k) dbg: Dict[str, Any] = { "total_frames": total_frames, "fps": float(fps), "window_len_frames": window_len_frames, "num_candidates": num_candidates, "top_k": top_k, "candidates": [], "selected": [], } if total_frames <= 0: return [(0, 0, 0)], dbg if window_len_frames <= 0 or window_len_frames >= total_frames: # treat whole video as one window return [(0, total_frames - 1, 0)], dbg # candidate window starts evenly spaced in [0, total_frames - window_len] max_start = max(0, total_frames - window_len_frames) if num_candidates <= 1: starts = [0] else: starts = np.linspace(0, max_start, num_candidates, dtype=np.int32).tolist() fps_use = fps if fps and fps > 0 else 30.0 dur_sec = float(window_len_frames) / float(fps_use) cand = [] for st in starts: st = int(st) ed = int(min(total_frames - 1, st + window_len_frames - 1)) start_sec = float(st) / float(fps_use) score = ffprobe_sum_pkt_size(video_path, start_sec, dur_sec) cand.append((st, ed, int(score))) cand_sorted = sorted(cand, key=lambda x: x[2], reverse=True) dbg["candidates"] = cand_sorted chosen = cand_sorted[: max(1, top_k)] dbg["selected"] = chosen return chosen, dbg def allocate_frames_across_windows( windows: List[Tuple[int, int, int]], seq_len: int, ) -> List[int]: """Allocate seq_len frames across windows proportional to scores (fallback equal), sample uniformly within each window, then merge & pad/truncate to seq_len. """ seq_len = int(seq_len) if seq_len <= 0: return [] # if only one window if len(windows) == 1: st, ed, _ = windows[0] return np.linspace(st, ed, seq_len, dtype=np.int32).tolist() scores = np.array([max(0, w[2]) for w in windows], dtype=np.float64) if scores.sum() <= 0: # equal allocation weights = np.ones_like(scores) / float(len(scores)) else: weights = scores / scores.sum() # initial per-window counts (at least 1) counts = np.maximum(1, np.floor(weights * seq_len).astype(int)) # adjust to sum == seq_len while counts.sum() > seq_len: i = int(np.argmax(counts)) if counts[i] > 1: counts[i] -= 1 else: break while counts.sum() < seq_len: i = int(np.argmax(weights)) counts[i] += 1 frame_ids: List[int] = [] for (st, ed, _), c in zip(windows, counts.tolist()): if c <= 0: continue frame_ids.extend(np.linspace(int(st), int(ed), int(c), dtype=np.int32).tolist()) frame_ids = sorted([int(x) for x in frame_ids]) # dedup but keep order (dedup can reduce length) dedup = [] last = None for x in frame_ids: if last is None or x != last: dedup.append(x) last = x frame_ids = dedup # pad/truncate to seq_len if len(frame_ids) == 0: frame_ids = [0] * seq_len if len(frame_ids) < seq_len: frame_ids = (frame_ids + [frame_ids[-1]] * (seq_len - len(frame_ids)))[:seq_len] else: frame_ids = frame_ids[:seq_len] return frame_ids def enforce_time_coverage_frame_ids( frame_ids: List[int], total_frames: int, fps: float, seq_len: int, stride_sec: float = 1.0, ) -> List[int]: """Best-effort ensure at least one sampled frame per time-bin. We create mandatory frame ids at roughly the center of each `stride_sec` bin, merge with existing `frame_ids`, then truncate/pad to `seq_len`. Note: If duration_bins > seq_len, it is impossible to cover all bins. In that case we cover as many earliest bins as possible. """ seq_len = int(seq_len) if seq_len <= 0: return [] fps_use = float(fps) if (fps and fps > 0) else 30.0 total_frames = int(max(0, total_frames)) if total_frames <= 0: return frame_ids[:seq_len] stride = float(stride_sec) if (stride_sec and stride_sec > 0) else 1.0 duration_sec = float(total_frames) / float(fps_use) n_bins = int(math.floor(duration_sec / stride + 1e-9)) n_bins = max(1, n_bins) # If bins exceed seq_len, we can only cover the first `seq_len` bins. n_bins_cover = min(int(n_bins), int(seq_len)) mandatory: List[int] = [] for b in range(n_bins_cover): t_center = (float(b) + 0.5) * stride fid = int(round(t_center * fps_use)) if total_frames > 0: fid = max(0, min(total_frames - 1, fid)) mandatory.append(fid) merged = sorted(set(mandatory + list(frame_ids))) if len(merged) < seq_len: # pad with duplicates of the last frame merged = merged + [merged[-1]] * (seq_len - len(merged)) elif len(merged) > seq_len: # truncate, but try to keep the mandatory ones kept = [] mset = set(mandatory) for x in merged: if len(kept) >= seq_len: break kept.append(x) merged = kept return [int(x) for x in merged[:seq_len]]