import numpy as np def _smooth(values: np.ndarray, k: int = 3) -> np.ndarray: """Moving average over the sampled motion. Kills 1-frame spikes.""" if k < 2 or len(values) < k: return values return np.convolve(values, np.ones(k) / k, mode="same") def pick_key_events(motion: dict, fps: float, max_events: int = 10, min_gap_sec: float = 1.5, pct_floor: float = 60.0) -> list[float]: """motion: {frame_idx: score}. Returns sorted list of timestamps (sec). pct_floor: only frames above this percentile of (smoothed) motion can be events — tune up for "only the biggest moments", down for "more chatter". """ if not motion: return [0.5] frames = sorted(motion) scores = _smooth(np.array([motion[f] for f in frames], dtype=float)) total_sec = frames[-1] / fps if fps else 0.0 # how many events does a clip this long deserve? cap = max(1, min(max_events, int(total_sec / min_gap_sec) or 1)) floor = np.percentile(scores, pct_floor) # local maxima above the floor = genuine "moments" peaks = [] n = len(frames) for i in range(n): s = scores[i] if s < floor: continue left = scores[i - 1] if i > 0 else -np.inf right = scores[i + 1] if i < n - 1 else -np.inf if s >= left and s >= right: peaks.append((frames[i], s)) if not peaks: # flat clip -> evenly spaced if total_sec <= 0: return [0.5] return [round((i + 0.5) * total_sec / cap, 2) for i in range(cap)] # strongest first, then suppress neighbours within min_gap (spread them out) peaks.sort(key=lambda kv: kv[1], reverse=True) min_gap_frames = int(min_gap_sec * fps) chosen: list[int] = [] for frame_idx, _ in peaks: if all(abs(frame_idx - c) >= min_gap_frames for c in chosen): chosen.append(frame_idx) if len(chosen) >= cap: break chosen.sort() return [round(c / fps, 2) for c in chosen] def events_to_frames(timestamps: list[float], fps: float) -> list[int]: return [int(round(ts * fps)) for ts in timestamps]