cfb40 / scripts /diagnose_special_play_end.py
andytaylor-smg's picture
in a good spot, I think
5d257ae
#!/usr/bin/env python3
"""
Diagnose special play end timing issue.
Issue 2 from oregon_plan.md:
Special plays always end after 10 seconds (MAX_SPECIAL_PLAY_DURATION), even when
the scorebug disappears earlier. This causes clips to include unnecessary footage.
This script investigates specific timestamps where special plays should have ended
earlier based on scorebug disappearance.
Examples to investigate:
- 42:00 (2520s)
- 1:02:05 (3725s)
- 1:16:28 (4588s)
- 1:25:47 (5147s)
- 1:56:06 (6966s)
"""
import argparse
import json
import logging
import os
import sys
from pathlib import Path
from typing import Dict, Any, List, Optional
import cv2
import numpy as np
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from detection.scorebug import DetectScoreBug
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Timestamps from Issue 2 (special plays running too long)
ISSUE_TIMESTAMPS = [
{"time": 2520, "label": "42:00 - special play runs too long"},
{"time": 3725, "label": "1:02:05 - special play runs too long"},
{"time": 4588, "label": "1:16:28 - special play runs too long"},
{"time": 5147, "label": "1:25:47 - special play runs too long"},
{"time": 6966, "label": "1:56:06 - special play runs too long"},
]
def analyze_scorebug_around_timestamp(
video_path: str,
template_path: str,
scorebug_bbox: tuple,
timestamp: float,
window_before: float = 5.0,
window_after: float = 15.0,
frame_interval: float = 0.5,
) -> List[Dict[str, Any]]:
"""
Analyze scorebug detection around a specific timestamp.
Args:
video_path: Path to video file
template_path: Path to scorebug template
scorebug_bbox: Fixed scorebug region (x, y, w, h)
timestamp: Center timestamp to analyze
window_before: Seconds before timestamp to analyze
window_after: Seconds after timestamp to analyze
frame_interval: Interval between frames to analyze
Returns:
List of frame analysis results
"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise RuntimeError(f"Could not open video: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS)
# Initialize scorebug detector with template
detector = DetectScoreBug(template_path=template_path, fixed_region=scorebug_bbox, use_split_detection=True)
results = []
start_time = timestamp - window_before
end_time = timestamp + window_after
current_time = start_time
while current_time <= end_time:
frame_num = int(current_time * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
ret, frame = cap.read()
if not ret or frame is None:
logger.warning("Could not read frame at %.1fs", current_time)
current_time += frame_interval
continue
# Detect scorebug
detection = detector.detect(frame)
# Relative time from the timestamp marker
rel_time = current_time - timestamp
results.append(
{
"timestamp": current_time,
"relative_time": rel_time,
"scorebug_detected": detection.detected,
"confidence": detection.confidence,
"method": detection.method,
"left_confidence": getattr(detection, "left_confidence", None),
"right_confidence": getattr(detection, "right_confidence", None),
}
)
current_time += frame_interval
cap.release()
return results
def find_scorebug_disappearance(results: List[Dict[str, Any]]) -> Optional[float]:
"""
Find when scorebug first disappears (goes from detected to not detected).
Returns:
Timestamp of first disappearance, or None if never disappears
"""
prev_detected = None
for r in results:
if prev_detected is True and r["scorebug_detected"] is False:
return r["timestamp"]
prev_detected = r["scorebug_detected"]
return None
def print_analysis_table(results: List[Dict[str, Any]], label: str, timestamp: float) -> None:
"""Print formatted analysis table."""
print(f"\n{'='*80}")
print(f" {label}")
print(f" Marker timestamp: {timestamp:.1f}s")
print(f"{'='*80}")
print(f"{'RelTime':>8} | {'Time':>8} | {'Detected':>8} | {'Conf':>6} | {'Left':>6} | {'Right':>6} | Method")
print("-" * 80)
# Track state changes
prev_detected = None
for r in results:
detected_str = "✓" if r["scorebug_detected"] else "✗"
left_str = f"{r['left_confidence']:.3f}" if r["left_confidence"] is not None else "-"
right_str = f"{r['right_confidence']:.3f}" if r["right_confidence"] is not None else "-"
# Highlight state changes
state_change = ""
if prev_detected is not None and prev_detected != r["scorebug_detected"]:
if r["scorebug_detected"]:
state_change = " <-- APPEARED"
else:
state_change = " <-- DISAPPEARED"
print(f"{r['relative_time']:>+8.1f} | {r['timestamp']:>8.1f} | {detected_str:>8} | {r['confidence']:>6.3f} | {left_str:>6} | {right_str:>6} | {r['method']}{state_change}")
prev_detected = r["scorebug_detected"]
# Summary
disappear_time = find_scorebug_disappearance(results)
if disappear_time:
print(f"\nFirst scorebug disappearance: {disappear_time:.1f}s (relative: {disappear_time - timestamp:+.1f}s)")
# Calculate when special play SHOULD end vs when it does end
special_play_should_end = disappear_time
special_play_actual_end = timestamp + 10.0 # MAX_SPECIAL_PLAY_DURATION
print(f"Special play SHOULD end at: {special_play_should_end:.1f}s")
print(f"Special play ACTUALLY ends at: {special_play_actual_end:.1f}s (timestamp + 10s)")
print(f"Excess footage: {special_play_actual_end - special_play_should_end:.1f}s")
else:
print("\nScorybug never disappears in analysis window - still detected at end")
def load_session_config(config_path: str) -> Dict[str, Any]:
"""Load session config to get scorebug bbox."""
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
def main():
parser = argparse.ArgumentParser(description="Diagnose special play end timing")
parser.add_argument("--video", required=True, help="Path to video file")
parser.add_argument("--config", required=True, help="Path to session config JSON")
parser.add_argument("--timestamp", type=float, help="Single timestamp to analyze (overrides defaults)")
parser.add_argument("--window-before", type=float, default=5.0, help="Seconds before timestamp to analyze")
parser.add_argument("--window-after", type=float, default=15.0, help="Seconds after timestamp to analyze")
parser.add_argument("--frame-interval", type=float, default=0.5, help="Frame interval for analysis")
parser.add_argument("--output-dir", default="output/debug/special_play_end", help="Output directory for debug images")
args = parser.parse_args()
# Load config to get scorebug region
config = load_session_config(args.config)
scorebug_bbox = (
config["scorebug_x"],
config["scorebug_y"],
config["scorebug_width"],
config["scorebug_height"],
)
template_path = config["template_path"]
logger.info("Video: %s", args.video)
logger.info("Template: %s", template_path)
logger.info("Scorebug bbox: %s", scorebug_bbox)
# Determine timestamps to analyze
if args.timestamp:
timestamps = [{"time": args.timestamp, "label": f"Custom timestamp {args.timestamp:.1f}s"}]
else:
timestamps = ISSUE_TIMESTAMPS
# Analyze each timestamp
for ts_info in timestamps:
ts = ts_info["time"]
label = ts_info["label"]
logger.info("Analyzing %s...", label)
results = analyze_scorebug_around_timestamp(
video_path=args.video,
template_path=template_path,
scorebug_bbox=scorebug_bbox,
timestamp=ts,
window_before=args.window_before,
window_after=args.window_after,
frame_interval=args.frame_interval,
)
print_analysis_table(results, label, ts)
print("\n" + "=" * 80)
print("SUMMARY")
print("=" * 80)
print("""
Key observations from this analysis:
1. If scorebug confidence stays high (>0.5) for the full 10 seconds, the template
matcher may be too lenient or the scorebug actually stays visible.
2. If scorebug disappears but special play still runs to 10s, there may be a bug
in how scorebug_detected is being passed to the special play tracker.
3. Check if the special play tracker's 'last_scorebug_timestamp' is being updated.
The fix may require:
- Adjusting template matching thresholds
- Adding explicit scorebug disappearance tracking
- Using a "grace period" before declaring scorebug disappeared (debounce)
""")
if __name__ == "__main__":
main()