cfb40 / scripts /test_flag_region_selection.py
andytaylor-smg's picture
some decent progress generalizing
137c6cf
#!/usr/bin/env python
"""
Test script for FLAG region selection (Step 1 of FLAG detection implementation).
This script allows interactive selection of the FLAG indicator region on the scorebug.
The FLAG region is where "1st & 10" / "FLAG" text appears on the scorebug.
Usage:
# Use default Tennessee video
python scripts/test_flag_region_selection.py
# Use a specific video
python scripts/test_flag_region_selection.py --video "full_videos/OSU vs Texas 01.10.25.mkv"
The script will:
1. Load sample frames from the specified video
2. Display the frame with the existing scorebug region highlighted
3. Allow user to click/drag to select the FLAG region
4. Save the selected region to output/{video_basename}_flag_config.json
5. Optionally update the session config if it exists
6. Display a cropped preview of the selected region
"""
import json
import logging
import sys
from pathlib import Path
from typing import Any, Optional, Tuple
import cv2
import numpy as np
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from ui.models import BBox
from ui.selector import RegionSelector
from video.frame_extractor import extract_sample_frames
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# Project paths
PROJECT_ROOT = Path(__file__).parent.parent
DATA_CONFIG_DIR = PROJECT_ROOT / "data" / "config"
OUTPUT_DIR = PROJECT_ROOT / "output"
FULL_VIDEOS_DIR = PROJECT_ROOT / "full_videos"
# Default test video
DEFAULT_VIDEO = FULL_VIDEOS_DIR / "OSU vs Tenn 12.21.24.mkv"
DEFAULT_START_TIME = 38 * 60 + 40 # 38:40 - where test segment starts
class FlagRegionSelectionSession:
"""Interactive session for selecting the FLAG indicator region with padding around scorebug."""
def __init__(
self,
frame: np.ndarray[Any, Any],
scorebug_bbox: BBox,
scale_factor: int = 3,
padding: int = 100,
):
"""
Initialize FLAG region selection session.
Args:
frame: Full video frame (BGR)
scorebug_bbox: Bounding box of the scorebug in the frame
scale_factor: How much to scale up the region for easier selection
padding: Pixels of padding around the scorebug to include in the view
"""
self.full_frame = frame
self.scorebug_bbox = scorebug_bbox
self.scale_factor = scale_factor
self.padding = padding
self.window_name = "Select FLAG Region (1st & 10 location)"
# Calculate padded region bounds (with boundary checks)
sb = scorebug_bbox
self.pad_x1 = max(0, sb.x - padding)
self.pad_y1 = max(0, sb.y - padding)
self.pad_x2 = min(frame.shape[1], sb.x2 + padding)
self.pad_y2 = min(frame.shape[0], sb.y2 + padding)
# Scorebug position within the padded region (for drawing reference outline)
self.scorebug_in_padded = BBox(
x=sb.x - self.pad_x1,
y=sb.y - self.pad_y1,
width=sb.width,
height=sb.height,
)
# Extract the padded region from the frame
self.padded_region = frame[self.pad_y1 : self.pad_y2, self.pad_x1 : self.pad_x2].copy()
padded_w, padded_h = self.padded_region.shape[1], self.padded_region.shape[0]
# Scale up the padded region for easier selection
self.scaled_region = cv2.resize(
self.padded_region,
(padded_w * scale_factor, padded_h * scale_factor),
interpolation=cv2.INTER_LINEAR,
)
# Setup selector
self.selector = RegionSelector(self.window_name, mode="two_click")
# State
self.should_quit = False
self.should_confirm = False
def _render_frame(self, display_frame: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
"""Add overlays and instructions to the display frame."""
# Draw scorebug boundary for reference (gray dashed appearance)
sb_scaled = self.scorebug_in_padded.scaled(self.scale_factor)
cv2.rectangle(
display_frame,
(sb_scaled.x, sb_scaled.y),
(sb_scaled.x2, sb_scaled.y2),
(128, 128, 128),
2,
)
# Label the scorebug region
cv2.putText(
display_frame,
"Scorebug",
(sb_scaled.x, sb_scaled.y - 5),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(128, 128, 128),
1,
)
# Draw clicked points
for i, point in enumerate(self.selector.points):
color = (0, 255, 0) if i == 0 else (0, 0, 255) # Green for first, red for second
cv2.circle(display_frame, point, 5, color, -1)
label = "Top-Left" if i == 0 else "Bottom-Right"
cv2.putText(display_frame, label, (point[0] + 10, point[1]), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
# Draw preview rectangle when dragging
if len(self.selector.points) == 1 and self.selector.current_point:
cv2.rectangle(display_frame, self.selector.points[0], self.selector.current_point, (0, 255, 255), 2)
# Draw final selection rectangle
if self.selector.selection_complete:
bbox = self.selector.get_bbox()
if bbox:
# Yellow highlight for FLAG region
cv2.rectangle(display_frame, (bbox.x, bbox.y), (bbox.x2, bbox.y2), (0, 255, 255), 3)
# Instructions at top
if len(self.selector.points) == 0:
instruction = "Step 1: Click TOP-LEFT corner of FLAG/down-distance area"
color = (0, 255, 255)
elif len(self.selector.points) == 1:
instruction = "Step 2: Click BOTTOM-RIGHT corner of FLAG/down-distance area"
color = (0, 255, 255)
else:
instruction = "Press ENTER to confirm, 'r' to reset, 'q' to quit"
color = (0, 255, 0)
cv2.putText(display_frame, instruction, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
return display_frame
def run(self) -> Optional[BBox]:
"""
Run the interactive selection loop.
Returns:
BBox in scaled coordinates, or None if cancelled
"""
cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
cv2.resizeWindow(
self.window_name,
self.scaled_region.shape[1],
self.scaled_region.shape[0] + 50,
)
cv2.setMouseCallback(self.window_name, self.selector.mouse_callback)
while True:
# Get display frame
display_frame = self.scaled_region.copy()
display_frame = self._render_frame(display_frame)
cv2.imshow(self.window_name, display_frame)
key = cv2.waitKey(30) & 0xFF
# Handle key presses
if key == ord("q"):
self.should_quit = True
elif key == ord("r"):
self.selector.reset()
elif key == 13: # Enter key
if self.selector.selection_complete:
self.should_confirm = True
# Check exit conditions
if self.should_quit:
cv2.destroyAllWindows()
return None
if self.should_confirm:
bbox = self.selector.get_bbox()
cv2.destroyAllWindows()
return bbox
return None
def get_scorebug_relative_bbox(self) -> Optional[BBox]:
"""
Get the selection converted to coordinates relative to the scorebug.
The selection is made in the scaled padded region. This method:
1. Unscales to original pixel coordinates
2. Converts from padded-region coordinates to scorebug-relative coordinates
Returns:
BBox with x_offset, y_offset relative to the scorebug top-left corner
"""
bbox = self.selector.get_bbox()
if bbox is None:
return None
# Unscale from the zoomed view
unscaled = bbox.unscaled(self.scale_factor)
# Convert from padded-region coords to scorebug-relative coords
# The scorebug starts at self.scorebug_in_padded within the padded region
scorebug_relative = BBox(
x=unscaled.x - self.scorebug_in_padded.x,
y=unscaled.y - self.scorebug_in_padded.y,
width=unscaled.width,
height=unscaled.height,
)
return scorebug_relative
def get_absolute_bbox(self) -> Optional[BBox]:
"""
Get the selection in absolute frame coordinates.
Returns:
BBox in absolute frame coordinates
"""
bbox = self.selector.get_bbox()
if bbox is None:
return None
# Unscale from the zoomed view
unscaled = bbox.unscaled(self.scale_factor)
# Add the padded region offset to get absolute coordinates
absolute = BBox(
x=unscaled.x + self.pad_x1,
y=unscaled.y + self.pad_y1,
width=unscaled.width,
height=unscaled.height,
)
return absolute
def load_saved_scorebug_config() -> Optional[Tuple[BBox, str]]:
"""
Load the scorebug region from an existing config file.
Returns:
Tuple of (scorebug_bbox, template_path) or None if not found
"""
# Look for any existing config file in output/
config_files = list(OUTPUT_DIR.glob("*_config.json"))
# Filter out playclock and timeout configs
main_configs = [f for f in config_files if "playclock" not in f.name and "timeout" not in f.name]
if not main_configs:
logger.warning("No existing config files found in output/")
return None
# Use the most recent one
config_path = max(main_configs, key=lambda p: p.stat().st_mtime)
logger.info("Loading scorebug config from: %s", config_path)
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
scorebug_bbox = BBox(
x=config["scorebug_x"],
y=config["scorebug_y"],
width=config["scorebug_width"],
height=config["scorebug_height"],
)
template_path = config.get("template_path", "")
return scorebug_bbox, template_path
def show_preview(frame: np.ndarray[Any, Any], flag_region_bbox: BBox, scorebug_bbox: BBox) -> None:
"""
Display a preview of the selected FLAG region.
Args:
frame: Full video frame
flag_region_bbox: Selected FLAG region (relative to scorebug)
scorebug_bbox: Scorebug bounding box
"""
# Calculate absolute coordinates
abs_x = scorebug_bbox.x + flag_region_bbox.x
abs_y = scorebug_bbox.y + flag_region_bbox.y
abs_x2 = abs_x + flag_region_bbox.width
abs_y2 = abs_y + flag_region_bbox.height
# Extract the FLAG region
flag_roi = frame[abs_y:abs_y2, abs_x:abs_x2].copy()
# Scale up for visibility
scale = 4
preview = cv2.resize(flag_roi, (flag_roi.shape[1] * scale, flag_roi.shape[0] * scale), interpolation=cv2.INTER_LINEAR)
# Also show the full frame with the region highlighted
frame_with_highlight = frame.copy()
# Draw scorebug outline (gray)
cv2.rectangle(
frame_with_highlight,
(scorebug_bbox.x, scorebug_bbox.y),
(scorebug_bbox.x2, scorebug_bbox.y2),
(128, 128, 128),
2,
)
# Draw FLAG region outline (yellow)
cv2.rectangle(
frame_with_highlight,
(abs_x, abs_y),
(abs_x2, abs_y2),
(0, 255, 255),
3,
)
# Add labels
cv2.putText(frame_with_highlight, "Scorebug", (scorebug_bbox.x, scorebug_bbox.y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (128, 128, 128), 2)
cv2.putText(frame_with_highlight, "FLAG Region", (abs_x, abs_y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
# Show both windows
cv2.namedWindow("FLAG Region Preview (4x scaled)", cv2.WINDOW_NORMAL)
cv2.resizeWindow("FLAG Region Preview (4x scaled)", preview.shape[1], preview.shape[0])
cv2.imshow("FLAG Region Preview (4x scaled)", preview)
cv2.namedWindow("Full Frame with FLAG Region", cv2.WINDOW_NORMAL)
cv2.resizeWindow("Full Frame with FLAG Region", 1280, 720)
cv2.imshow("Full Frame with FLAG Region", frame_with_highlight)
print("\n" + "=" * 50)
print("FLAG Region Preview")
print("=" * 50)
print(" Relative to scorebug:")
print(f" x_offset: {flag_region_bbox.x}")
print(f" y_offset: {flag_region_bbox.y}")
print(f" width: {flag_region_bbox.width}")
print(f" height: {flag_region_bbox.height}")
print(" Absolute in frame:")
print(f" x: {abs_x}, y: {abs_y}")
print(f" width: {flag_region_bbox.width}, height: {flag_region_bbox.height}")
print("=" * 50)
print("Press any key to close preview...")
cv2.waitKey(0)
cv2.destroyAllWindows()
def get_video_basename(video_path: str) -> str:
"""Get a clean basename from video path for config naming."""
basename = Path(video_path).stem
for char in [" ", ".", "-"]:
basename = basename.replace(char, "_")
while "__" in basename:
basename = basename.replace("__", "_")
return basename.strip("_")
def save_flag_region(flag_bbox: BBox, source_video: str, scorebug_template: str, video_path: str) -> Path:
"""
Save the FLAG region configuration to video-specific JSON file.
Also updates the session config if it exists.
Args:
flag_bbox: FLAG region bounding box (relative to scorebug)
source_video: Name of the source video
scorebug_template: Name of the scorebug template file
video_path: Full path to the video (used for naming)
Returns:
Path to the saved config file
"""
video_basename = get_video_basename(video_path)
config = {
"flag_region": {
"x_offset": flag_bbox.x,
"y_offset": flag_bbox.y,
"width": flag_bbox.width,
"height": flag_bbox.height,
},
"source_video": source_video,
"scorebug_template": scorebug_template,
}
# Save to video-specific file in output directory
output_path = OUTPUT_DIR / f"{video_basename}_flag_config.json"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
logger.info("Saved FLAG region config to: %s", output_path)
# Also update session config if it exists
session_config_path = OUTPUT_DIR / f"{video_basename}_config.json"
if session_config_path.exists():
try:
with open(session_config_path, "r", encoding="utf-8") as f:
session_config = json.load(f)
session_config["flag_x_offset"] = flag_bbox.x
session_config["flag_y_offset"] = flag_bbox.y
session_config["flag_width"] = flag_bbox.width
session_config["flag_height"] = flag_bbox.height
with open(session_config_path, "w", encoding="utf-8") as f:
json.dump(session_config, f, indent=2)
logger.info("Updated session config: %s", session_config_path)
except Exception as e:
logger.warning("Could not update session config: %s", e)
return output_path
def print_banner() -> None:
"""Print the script banner and introduction."""
print("\n" + "=" * 60)
print(" FLAG Region Selection Test")
print("=" * 60)
print("\nThis script helps select the FLAG/down-distance region on the scorebug.")
print("The FLAG indicator appears where '1st & 10' normally shows.")
print("-" * 60)
def print_instructions() -> None:
"""Print selection instructions and wait for user."""
print("\n" + "-" * 60)
print("INSTRUCTIONS:")
print(" 1. The scorebug region will be shown (scaled 3x)")
print(" 2. Click the TOP-LEFT corner of the down/distance area")
print(" (where '1st & 10' or 'FLAG' appears)")
print(" 3. Click the BOTTOM-RIGHT corner")
print(" 4. Press ENTER to confirm, 'r' to reset, 'q' to quit")
print("-" * 60)
input("\nPress Enter to start selection...")
def parse_args():
"""Parse command line arguments."""
import argparse
parser = argparse.ArgumentParser(description="Select FLAG region for a video")
parser.add_argument(
"--video",
type=str,
default=str(DEFAULT_VIDEO),
help="Path to video file (default: OSU vs Tenn 12.21.24.mkv)",
)
parser.add_argument(
"--start-time",
type=float,
default=DEFAULT_START_TIME,
help="Start time in seconds for frame extraction (default: 38:40)",
)
return parser.parse_args()
def load_scorebug_config_for_video(video_path: str) -> Optional[Tuple[BBox, str]]:
"""Load scorebug config for a specific video."""
video_basename = get_video_basename(video_path)
session_config_path = OUTPUT_DIR / f"{video_basename}_config.json"
if session_config_path.exists():
with open(session_config_path, "r", encoding="utf-8") as f:
config = json.load(f)
scorebug_bbox = BBox(
x=config["scorebug_x"],
y=config["scorebug_y"],
width=config["scorebug_width"],
height=config["scorebug_height"],
)
template_path = config.get("template_path", "")
logger.info("Loaded session config from: %s", session_config_path)
return scorebug_bbox, template_path
# Fall back to generic config
logger.warning("No session config found for video, trying generic config...")
return load_saved_scorebug_config()
def main() -> int:
"""Main entry point for FLAG region selection test."""
args = parse_args()
print_banner()
# Determine video path
video_path = Path(args.video)
if not video_path.exists():
# Try relative to project root
video_path = PROJECT_ROOT / args.video
if not video_path.exists():
print(f"\nERROR: Video not found: {args.video}")
return 1
print(f"Using video: {video_path.name}")
video_basename = get_video_basename(str(video_path))
print(f"Config basename: {video_basename}")
# Load scorebug config for this video
result = load_scorebug_config_for_video(str(video_path))
if result is None:
print("\nERROR: No existing scorebug config found.")
print("Please run main.py first to set up the scorebug region for this video.")
return 1
scorebug_bbox, template_path = result
print(f"\nLoaded scorebug region: {scorebug_bbox.to_tuple()}")
start_time = args.start_time
print(f"Starting at: {int(start_time) // 60}:{int(start_time) % 60:02d}")
# Extract sample frames
print("\nExtracting sample frames...")
frames = extract_sample_frames(str(video_path), start_time, num_frames=1, interval=0.0)
if not frames:
print("ERROR: Failed to extract frames from video")
return 1
timestamp, frame = frames[0]
print(f"Loaded frame at {timestamp:.1f}s")
# Show instructions and start selection
print_instructions()
# Run selection session (with 100px padding around scorebug)
session = FlagRegionSelectionSession(frame, scorebug_bbox, scale_factor=3, padding=100)
scaled_bbox = session.run()
if scaled_bbox is None:
print("\nSelection cancelled.")
return 0
# Get bbox relative to scorebug (for config) and absolute (for preview)
flag_bbox = session.get_scorebug_relative_bbox()
if flag_bbox is None:
print("\nNo valid selection made.")
return 0
print(f"\nSelected FLAG region (relative to scorebug): {flag_bbox.to_tuple()}")
# Show preview
show_preview(frame, flag_bbox, scorebug_bbox)
# Ask to save
print("\nSave this FLAG region? (y/n)")
response = input("> ").strip().lower()
if response == "y":
# Extract video name and template name for metadata
video_name = video_path.name
template_name = Path(template_path).name if template_path else "unknown"
save_path = save_flag_region(flag_bbox, video_name, template_name, str(video_path))
print(f"\n✓ FLAG region saved to: {save_path}")
else:
print("\nSelection not saved.")
return 0
if __name__ == "__main__":
sys.exit(main())