Spaces:
Sleeping
Sleeping
Commit ·
47d79b8
1
Parent(s): 4a4dfa0
removing dead code
Browse files- src/detection/__init__.py +1 -2
- src/detection/scorebug.py +0 -15
- src/detection/timeout_calibrator.py +0 -46
- src/readers/__init__.py +1 -5
- src/readers/playclock.py +0 -160
- src/tracking/__init__.py +0 -2
- src/tracking/models.py +1 -55
- src/ui/__init__.py +0 -2
- src/ui/api.py +0 -57
- src/utils/__init__.py +0 -2
- src/utils/logging.py +0 -33
- src/video/__init__.py +1 -2
- src/video/frame_extractor.py +0 -22
src/detection/__init__.py
CHANGED
|
@@ -10,7 +10,7 @@ from .models import (
|
|
| 10 |
TimeoutRegionConfig,
|
| 11 |
TimeoutReading,
|
| 12 |
)
|
| 13 |
-
from .scorebug import DetectScoreBug
|
| 14 |
from .timeouts import DetectTimeouts
|
| 15 |
|
| 16 |
__all__ = [
|
|
@@ -20,7 +20,6 @@ __all__ = [
|
|
| 20 |
"TimeoutReading",
|
| 21 |
# Scorebug detection
|
| 22 |
"DetectScoreBug",
|
| 23 |
-
"create_template_from_frame",
|
| 24 |
# Timeout tracking
|
| 25 |
"DetectTimeouts",
|
| 26 |
]
|
|
|
|
| 10 |
TimeoutRegionConfig,
|
| 11 |
TimeoutReading,
|
| 12 |
)
|
| 13 |
+
from .scorebug import DetectScoreBug
|
| 14 |
from .timeouts import DetectTimeouts
|
| 15 |
|
| 16 |
__all__ = [
|
|
|
|
| 20 |
"TimeoutReading",
|
| 21 |
# Scorebug detection
|
| 22 |
"DetectScoreBug",
|
|
|
|
| 23 |
# Timeout tracking
|
| 24 |
"DetectTimeouts",
|
| 25 |
]
|
src/detection/scorebug.py
CHANGED
|
@@ -405,18 +405,3 @@ class DetectScoreBug:
|
|
| 405 |
cv2.putText(vis_frame, text, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
| 406 |
|
| 407 |
return vis_frame
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
def create_template_from_frame(frame: np.ndarray[Any, Any], bbox: Tuple[int, int, int, int], output_path: str) -> None:
|
| 411 |
-
"""
|
| 412 |
-
Extract a region from a frame to use as a template.
|
| 413 |
-
|
| 414 |
-
Args:
|
| 415 |
-
frame: Source frame
|
| 416 |
-
bbox: Bounding box (x, y, width, height)
|
| 417 |
-
output_path: Path to save the template image
|
| 418 |
-
"""
|
| 419 |
-
x, y, w, h = bbox
|
| 420 |
-
template = frame[y : y + h, x : x + w]
|
| 421 |
-
cv2.imwrite(output_path, template)
|
| 422 |
-
logger.info("Created template: %s (size: %dx%d)", output_path, w, h)
|
|
|
|
| 405 |
cv2.putText(vis_frame, text, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
| 406 |
|
| 407 |
return vis_frame
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/detection/timeout_calibrator.py
CHANGED
|
@@ -228,52 +228,6 @@ def _validate_oval_pattern(ovals: List[OvalLocation]) -> bool:
|
|
| 228 |
return True
|
| 229 |
|
| 230 |
|
| 231 |
-
def check_oval_brightness(
|
| 232 |
-
frame: np.ndarray[Any, Any],
|
| 233 |
-
region_bbox: Tuple[int, int, int, int],
|
| 234 |
-
oval: OvalLocation,
|
| 235 |
-
brightness_threshold_ratio: float = 0.5,
|
| 236 |
-
) -> Tuple[bool, float]:
|
| 237 |
-
"""
|
| 238 |
-
Check if a specific oval is still bright (timeout available).
|
| 239 |
-
|
| 240 |
-
Args:
|
| 241 |
-
frame: Full video frame (BGR format)
|
| 242 |
-
region_bbox: Bounding box of the timeout region (x, y, width, height)
|
| 243 |
-
oval: OvalLocation to check
|
| 244 |
-
brightness_threshold_ratio: Ratio of baseline brightness below which oval is considered "dark"
|
| 245 |
-
|
| 246 |
-
Returns:
|
| 247 |
-
Tuple of (is_bright, current_brightness)
|
| 248 |
-
- is_bright: True if oval is still bright (timeout available), False if dark (used)
|
| 249 |
-
- current_brightness: Current mean brightness value
|
| 250 |
-
"""
|
| 251 |
-
rx, ry, _, _ = region_bbox
|
| 252 |
-
|
| 253 |
-
# Calculate absolute position in frame
|
| 254 |
-
abs_x = rx + oval.x
|
| 255 |
-
abs_y = ry + oval.y
|
| 256 |
-
|
| 257 |
-
# Validate bounds
|
| 258 |
-
frame_h, frame_w = frame.shape[:2]
|
| 259 |
-
if abs_x < 0 or abs_y < 0 or abs_x + oval.width > frame_w or abs_y + oval.height > frame_h:
|
| 260 |
-
logger.warning("Oval position out of bounds")
|
| 261 |
-
return False, 0.0
|
| 262 |
-
|
| 263 |
-
# Extract the oval region
|
| 264 |
-
oval_roi = frame[abs_y : abs_y + oval.height, abs_x : abs_x + oval.width]
|
| 265 |
-
|
| 266 |
-
# Convert to grayscale and calculate mean brightness
|
| 267 |
-
gray = cv2.cvtColor(oval_roi, cv2.COLOR_BGR2GRAY)
|
| 268 |
-
current_brightness = float(np.mean(np.asarray(gray)))
|
| 269 |
-
|
| 270 |
-
# Compare to baseline
|
| 271 |
-
threshold = oval.baseline_brightness * brightness_threshold_ratio
|
| 272 |
-
is_bright = current_brightness >= threshold
|
| 273 |
-
|
| 274 |
-
return is_bright, current_brightness
|
| 275 |
-
|
| 276 |
-
|
| 277 |
def visualize_calibration(
|
| 278 |
frame: np.ndarray[Any, Any],
|
| 279 |
calibrated_region: CalibratedTimeoutRegion,
|
|
|
|
| 228 |
return True
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
def visualize_calibration(
|
| 232 |
frame: np.ndarray[Any, Any],
|
| 233 |
calibrated_region: CalibratedTimeoutRegion,
|
src/readers/__init__.py
CHANGED
|
@@ -17,10 +17,7 @@ from .models import (
|
|
| 17 |
TemplatePlayClockReading,
|
| 18 |
)
|
| 19 |
from .flags import FlagReader
|
| 20 |
-
from .playclock import
|
| 21 |
-
ReadPlayClock,
|
| 22 |
-
backfill_missing_readings,
|
| 23 |
-
)
|
| 24 |
|
| 25 |
|
| 26 |
class FlagConfig(Protocol): # pylint: disable=too-few-public-methods
|
|
@@ -66,5 +63,4 @@ __all__ = [
|
|
| 66 |
"create_flag_reader",
|
| 67 |
# Play clock reading
|
| 68 |
"ReadPlayClock",
|
| 69 |
-
"backfill_missing_readings",
|
| 70 |
]
|
|
|
|
| 17 |
TemplatePlayClockReading,
|
| 18 |
)
|
| 19 |
from .flags import FlagReader
|
| 20 |
+
from .playclock import ReadPlayClock
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class FlagConfig(Protocol): # pylint: disable=too-few-public-methods
|
|
|
|
| 63 |
"create_flag_reader",
|
| 64 |
# Play clock reading
|
| 65 |
"ReadPlayClock",
|
|
|
|
| 66 |
]
|
src/readers/playclock.py
CHANGED
|
@@ -381,163 +381,3 @@ class ReadPlayClock:
|
|
| 381 |
region = frame[y : y + h, x : x + w].copy()
|
| 382 |
|
| 383 |
return self.read(region)
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
# =============================================================================
|
| 387 |
-
# Gap Interpolation
|
| 388 |
-
# =============================================================================
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
def _find_gap_extent(readings: List[PlayClockReading], start_idx: int) -> int:
|
| 392 |
-
"""Find the end index of a gap starting at start_idx."""
|
| 393 |
-
gap_end = start_idx
|
| 394 |
-
while gap_end < len(readings) and (not readings[gap_end].detected or readings[gap_end].value is None):
|
| 395 |
-
gap_end += 1
|
| 396 |
-
return gap_end
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
def _validate_gap_boundaries(readings: List[PlayClockReading], gap_start: int, gap_end: int, max_gap_seconds: int) -> Tuple[bool, int, int, int]:
|
| 400 |
-
"""
|
| 401 |
-
Validate that a gap can be backfilled.
|
| 402 |
-
|
| 403 |
-
Returns:
|
| 404 |
-
Tuple of (is_valid, left_value, right_value, seconds_gap).
|
| 405 |
-
is_valid is False if gap cannot be backfilled.
|
| 406 |
-
"""
|
| 407 |
-
# Check if we have valid readings on both sides
|
| 408 |
-
if gap_start == 0:
|
| 409 |
-
logger.debug("Gap at start of sequence (index 0), no left side for backfill")
|
| 410 |
-
return False, 0, 0, 0
|
| 411 |
-
|
| 412 |
-
if gap_end >= len(readings):
|
| 413 |
-
logger.debug("Gap at end of sequence (index %d), no right side for backfill", gap_start)
|
| 414 |
-
return False, 0, 0, 0
|
| 415 |
-
|
| 416 |
-
# Get the clock values on either side
|
| 417 |
-
left_value = readings[gap_start - 1].value
|
| 418 |
-
right_value = readings[gap_end].value
|
| 419 |
-
|
| 420 |
-
if left_value is None or right_value is None:
|
| 421 |
-
logger.debug("Adjacent values are None, cannot backfill")
|
| 422 |
-
return False, 0, 0, 0
|
| 423 |
-
|
| 424 |
-
# Left should be higher than right in a countdown
|
| 425 |
-
if left_value <= right_value:
|
| 426 |
-
logger.debug("Invalid countdown: left=%d not greater than right=%d", left_value, right_value)
|
| 427 |
-
return False, 0, 0, 0
|
| 428 |
-
|
| 429 |
-
seconds_gap = left_value - right_value - 1
|
| 430 |
-
|
| 431 |
-
# Check if gap in seconds is within our limit
|
| 432 |
-
if seconds_gap > max_gap_seconds:
|
| 433 |
-
logger.debug(
|
| 434 |
-
"Gap of %d seconds (left=%d, right=%d) exceeds max_gap_seconds=%d, skipping backfill",
|
| 435 |
-
seconds_gap,
|
| 436 |
-
left_value,
|
| 437 |
-
right_value,
|
| 438 |
-
max_gap_seconds,
|
| 439 |
-
)
|
| 440 |
-
return False, 0, 0, 0
|
| 441 |
-
|
| 442 |
-
return True, left_value, right_value, seconds_gap
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
def _backfill_interpolate(result: List[PlayClockReading], gap_start: int, gap_frame_count: int, left_value: int, right_value: int) -> None:
|
| 446 |
-
"""Backfill a gap using nearest value interpolation (no missing clock values)."""
|
| 447 |
-
logger.debug("No missing clock values between %d and %d, using nearest value interpolation", left_value, right_value)
|
| 448 |
-
for j in range(gap_frame_count):
|
| 449 |
-
# Use left value for first half, right value for second half
|
| 450 |
-
backfill_value = left_value if j < gap_frame_count / 2 else right_value
|
| 451 |
-
result[gap_start + j] = PlayClockReading(
|
| 452 |
-
detected=True,
|
| 453 |
-
value=backfill_value,
|
| 454 |
-
confidence=0.0,
|
| 455 |
-
raw_text=f"BACKFILLED_{backfill_value}",
|
| 456 |
-
)
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
def _backfill_with_missing_values(result: List[PlayClockReading], gap_start: int, gap_end: int, left_value: int, right_value: int) -> None:
|
| 460 |
-
"""Backfill a gap by distributing missing clock values across frames."""
|
| 461 |
-
gap_frame_count = gap_end - gap_start
|
| 462 |
-
missing_values = list(range(left_value - 1, right_value, -1))
|
| 463 |
-
|
| 464 |
-
logger.info(
|
| 465 |
-
"Backfilling gap: frames %d-%d, clock values %d to %d, missing values: %s",
|
| 466 |
-
gap_start,
|
| 467 |
-
gap_end - 1,
|
| 468 |
-
left_value,
|
| 469 |
-
right_value,
|
| 470 |
-
missing_values,
|
| 471 |
-
)
|
| 472 |
-
|
| 473 |
-
for j in range(gap_frame_count):
|
| 474 |
-
# Calculate which missing value this frame should have
|
| 475 |
-
position = j / gap_frame_count
|
| 476 |
-
value_index = min(int(position * len(missing_values)), len(missing_values) - 1)
|
| 477 |
-
backfill_value = missing_values[value_index]
|
| 478 |
-
|
| 479 |
-
result[gap_start + j] = PlayClockReading(
|
| 480 |
-
detected=True,
|
| 481 |
-
value=backfill_value,
|
| 482 |
-
confidence=0.0,
|
| 483 |
-
raw_text=f"BACKFILLED_{backfill_value}",
|
| 484 |
-
)
|
| 485 |
-
logger.debug(" Backfilled index %d with value %d", gap_start + j, backfill_value)
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
def backfill_missing_readings(readings: List[PlayClockReading], max_gap_seconds: int = 3) -> List[PlayClockReading]:
|
| 489 |
-
"""
|
| 490 |
-
Backfill missing play clock readings when there's a clear countdown sequence with gaps.
|
| 491 |
-
|
| 492 |
-
This function fills in missing readings when:
|
| 493 |
-
1. The gap in clock VALUES is 1-3 seconds (configurable via max_gap_seconds)
|
| 494 |
-
2. There's at least one valid reading on EACH side of the gap
|
| 495 |
-
3. The readings form a valid countdown sequence
|
| 496 |
-
|
| 497 |
-
Since the video may be sampled at higher than 1fps, each clock value may appear
|
| 498 |
-
multiple times. This function looks at the actual clock values, not frame indices.
|
| 499 |
-
|
| 500 |
-
Examples (showing clock values, with ? for undetected):
|
| 501 |
-
- [6, 6, 5, 5, ?, ?, 3, 3] → missing value is 4, backfill the gaps
|
| 502 |
-
- [10, 9, ?, ?, ?] → no backfill (no right side continuation)
|
| 503 |
-
- [6, 6, ?, ?, ?, ?, ?, 0, 0] → no backfill if gap > max_gap_seconds
|
| 504 |
-
|
| 505 |
-
Args:
|
| 506 |
-
readings: List of PlayClockReading objects (in chronological order)
|
| 507 |
-
max_gap_seconds: Maximum number of missing SECONDS to backfill (default: 3)
|
| 508 |
-
|
| 509 |
-
Returns:
|
| 510 |
-
New list with backfilled readings (original list is not modified)
|
| 511 |
-
"""
|
| 512 |
-
if not readings:
|
| 513 |
-
return readings
|
| 514 |
-
|
| 515 |
-
result = list(readings)
|
| 516 |
-
i = 0
|
| 517 |
-
|
| 518 |
-
while i < len(result):
|
| 519 |
-
# Skip if this reading is valid
|
| 520 |
-
if result[i].detected and result[i].value is not None:
|
| 521 |
-
i += 1
|
| 522 |
-
continue
|
| 523 |
-
|
| 524 |
-
# Found a missing reading - find the extent of the gap
|
| 525 |
-
gap_start = i
|
| 526 |
-
gap_end = _find_gap_extent(result, i)
|
| 527 |
-
gap_frame_count = gap_end - gap_start
|
| 528 |
-
|
| 529 |
-
# Validate that we can backfill this gap
|
| 530 |
-
is_valid, left_value, right_value, seconds_gap = _validate_gap_boundaries(result, gap_start, gap_end, max_gap_seconds)
|
| 531 |
-
if not is_valid:
|
| 532 |
-
i = gap_end
|
| 533 |
-
continue
|
| 534 |
-
|
| 535 |
-
# Perform the backfill
|
| 536 |
-
if seconds_gap <= 0:
|
| 537 |
-
_backfill_interpolate(result, gap_start, gap_frame_count, left_value, right_value)
|
| 538 |
-
else:
|
| 539 |
-
_backfill_with_missing_values(result, gap_start, gap_end, left_value, right_value)
|
| 540 |
-
|
| 541 |
-
i = gap_end
|
| 542 |
-
|
| 543 |
-
return result
|
|
|
|
| 381 |
region = frame[y : y + h, x : x + w].copy()
|
| 382 |
|
| 383 |
return self.read(region)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tracking/__init__.py
CHANGED
|
@@ -29,7 +29,6 @@ from .models import (
|
|
| 29 |
NormalTrackerState,
|
| 30 |
PlayEvent,
|
| 31 |
PlayState,
|
| 32 |
-
PlayTrackingState,
|
| 33 |
SpecialPlayHandoff,
|
| 34 |
SpecialPlayPhase,
|
| 35 |
SpecialTrackerState,
|
|
@@ -52,7 +51,6 @@ __all__ = [
|
|
| 52 |
"NormalTrackerState",
|
| 53 |
"PlayEvent",
|
| 54 |
"PlayState",
|
| 55 |
-
"PlayTrackingState",
|
| 56 |
"SpecialPlayHandoff",
|
| 57 |
"SpecialPlayPhase",
|
| 58 |
"SpecialTrackerState",
|
|
|
|
| 29 |
NormalTrackerState,
|
| 30 |
PlayEvent,
|
| 31 |
PlayState,
|
|
|
|
| 32 |
SpecialPlayHandoff,
|
| 33 |
SpecialPlayPhase,
|
| 34 |
SpecialTrackerState,
|
|
|
|
| 51 |
"NormalTrackerState",
|
| 52 |
"PlayEvent",
|
| 53 |
"PlayState",
|
|
|
|
| 54 |
"SpecialPlayHandoff",
|
| 55 |
"SpecialPlayPhase",
|
| 56 |
"SpecialTrackerState",
|
src/tracking/models.py
CHANGED
|
@@ -15,7 +15,6 @@ from typing import Optional, List, Tuple
|
|
| 15 |
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
|
| 18 |
-
|
| 19 |
# =============================================================================
|
| 20 |
# Utility Functions
|
| 21 |
# =============================================================================
|
|
@@ -141,61 +140,8 @@ class ClockResetStats(BaseModel):
|
|
| 141 |
special: int = Field(0, description="Class C: Special play (injury/punt/FG/XP)")
|
| 142 |
|
| 143 |
|
| 144 |
-
class PlayTrackingState(BaseModel):
|
| 145 |
-
"""Internal mutable state for play tracking.
|
| 146 |
-
|
| 147 |
-
Tracks the current detection state, detected plays, and all intermediate
|
| 148 |
-
tracking variables used during play boundary detection.
|
| 149 |
-
"""
|
| 150 |
-
|
| 151 |
-
# Current detection state
|
| 152 |
-
state: PlayState = Field(PlayState.IDLE, description="Current state of the play detection state machine")
|
| 153 |
-
plays: List[PlayEvent] = Field(default_factory=list, description="List of all detected plays")
|
| 154 |
-
|
| 155 |
-
# Tracking counters and values
|
| 156 |
-
play_count: int = Field(0, description="Total number of plays detected so far")
|
| 157 |
-
last_clock_value: Optional[int] = Field(None, description="Last observed play clock value")
|
| 158 |
-
last_clock_timestamp: Optional[float] = Field(None, description="Timestamp of last clock reading")
|
| 159 |
-
clock_stable_count: int = Field(0, description="Number of consecutive frames with same clock value")
|
| 160 |
-
|
| 161 |
-
# Current play tracking
|
| 162 |
-
current_play_start_time: Optional[float] = Field(None, description="Start time of the current play being tracked")
|
| 163 |
-
current_play_start_method: Optional[str] = Field(None, description="Method used to detect current play start")
|
| 164 |
-
current_play_start_clock: Optional[int] = Field(None, description="Clock value when current play started")
|
| 165 |
-
last_scorebug_timestamp: Optional[float] = Field(None, description="Timestamp of last scorebug detection")
|
| 166 |
-
direct_end_time: Optional[float] = Field(None, description="Direct end time observation (for comparison)")
|
| 167 |
-
countdown_history: List[Tuple[float, int]] = Field(default_factory=list, description="List of (timestamp, clock_value) for countdown tracking")
|
| 168 |
-
first_40_timestamp: Optional[float] = Field(None, description="When we first saw 40 in current play (for turnover detection)")
|
| 169 |
-
current_play_clock_base: int = Field(40, description="Clock base for current play (40 for normal, 25 for special teams)")
|
| 170 |
-
current_play_type: str = Field("normal", description="Type of current play being tracked: 'normal' or 'special'")
|
| 171 |
-
|
| 172 |
-
# Timeout tracking for clock reset classification
|
| 173 |
-
last_home_timeouts: Optional[int] = Field(None, description="Last observed home team timeouts")
|
| 174 |
-
last_away_timeouts: Optional[int] = Field(None, description="Last observed away team timeouts")
|
| 175 |
-
last_timeout_confidence: float = Field(0.0, description="Confidence of last timeout reading")
|
| 176 |
-
clock_reset_stats: ClockResetStats = Field(default_factory=ClockResetStats, description="Statistics about clock reset classifications")
|
| 177 |
-
|
| 178 |
-
# Pending timeout resolution (for delayed timeout detection)
|
| 179 |
-
# When 40→25 detected, we store the "before" state and check again after delay
|
| 180 |
-
pending_timeout_check_time: Optional[float] = Field(None, description="Timestamp when we should resolve pending timeout check")
|
| 181 |
-
pending_timeout_transition_time: Optional[float] = Field(None, description="Timestamp when the 40→25 transition occurred")
|
| 182 |
-
timeout_home_at_40: Optional[int] = Field(None, description="Home timeouts when clock was at 40 (before 40→25)")
|
| 183 |
-
timeout_away_at_40: Optional[int] = Field(None, description="Away timeouts when clock was at 40 (before 40→25)")
|
| 184 |
-
timeout_conf_at_40: float = Field(0.0, description="Timeout confidence when clock was at 40")
|
| 185 |
-
|
| 186 |
-
# Pending abnormal drop timeout check (silent background check)
|
| 187 |
-
# When an "abnormal clock drop" is detected (40→25 in <2s), we reject the play but silently
|
| 188 |
-
# monitor for timeout indicator changes. If a timeout is detected within 4-10s, we retroactively create a timeout play.
|
| 189 |
-
pending_abnormal_drop_timestamp: Optional[float] = Field(None, description="When the 40→25 occurred for abnormal drop")
|
| 190 |
-
pending_abnormal_drop_check_start: Optional[float] = Field(None, description="When to start checking for timeout (4s after)")
|
| 191 |
-
pending_abnormal_drop_check_end: Optional[float] = Field(None, description="When to stop checking for timeout (10s after)")
|
| 192 |
-
pending_abnormal_drop_home_at_40: Optional[int] = Field(None, description="Home timeouts before the abnormal drop")
|
| 193 |
-
pending_abnormal_drop_away_at_40: Optional[int] = Field(None, description="Away timeouts before the abnormal drop")
|
| 194 |
-
pending_abnormal_drop_conf_at_40: float = Field(0.0, description="Confidence of timeout reading before abnormal drop")
|
| 195 |
-
|
| 196 |
-
|
| 197 |
# =============================================================================
|
| 198 |
-
#
|
| 199 |
# =============================================================================
|
| 200 |
|
| 201 |
|
|
|
|
| 15 |
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
|
|
|
|
| 18 |
# =============================================================================
|
| 19 |
# Utility Functions
|
| 20 |
# =============================================================================
|
|
|
|
| 140 |
special: int = Field(0, description="Class C: Special play (injury/punt/FG/XP)")
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
# =============================================================================
|
| 144 |
+
# State models for restructured tracker architecture
|
| 145 |
# =============================================================================
|
| 146 |
|
| 147 |
|
src/ui/__init__.py
CHANGED
|
@@ -22,7 +22,6 @@ from .sessions import (
|
|
| 22 |
|
| 23 |
# Public API
|
| 24 |
from .api import (
|
| 25 |
-
calibrate_timeout_region,
|
| 26 |
extract_sample_frames_for_selection,
|
| 27 |
get_video_path_from_user,
|
| 28 |
print_banner,
|
|
@@ -47,7 +46,6 @@ __all__ = [
|
|
| 47 |
"ScorebugSelectionSession",
|
| 48 |
"TimeoutSelectionSession",
|
| 49 |
# Public API
|
| 50 |
-
"calibrate_timeout_region",
|
| 51 |
"extract_sample_frames_for_selection",
|
| 52 |
"get_video_path_from_user",
|
| 53 |
"print_banner",
|
|
|
|
| 22 |
|
| 23 |
# Public API
|
| 24 |
from .api import (
|
|
|
|
| 25 |
extract_sample_frames_for_selection,
|
| 26 |
get_video_path_from_user,
|
| 27 |
print_banner,
|
|
|
|
| 46 |
"ScorebugSelectionSession",
|
| 47 |
"TimeoutSelectionSession",
|
| 48 |
# Public API
|
|
|
|
| 49 |
"extract_sample_frames_for_selection",
|
| 50 |
"get_video_path_from_user",
|
| 51 |
"print_banner",
|
src/ui/api.py
CHANGED
|
@@ -13,8 +13,6 @@ import cv2
|
|
| 13 |
import numpy as np
|
| 14 |
|
| 15 |
from video.frame_extractor import extract_sample_frames
|
| 16 |
-
from detection.timeout_calibrator import calibrate_timeout_ovals, visualize_calibration
|
| 17 |
-
from detection.models import CalibratedTimeoutRegion
|
| 18 |
|
| 19 |
from .models import BBox
|
| 20 |
from .sessions import FlagRegionSelectionSession, PlayClockSelectionSession, ScorebugSelectionSession, TimeoutSelectionSession
|
|
@@ -192,61 +190,6 @@ def select_flag_region(selected_frame: Tuple[float, np.ndarray[Any, Any]], score
|
|
| 192 |
return bbox.to_tuple() if bbox else None
|
| 193 |
|
| 194 |
|
| 195 |
-
def calibrate_timeout_region(
|
| 196 |
-
frame: np.ndarray[Any, Any],
|
| 197 |
-
region_bbox: Tuple[int, int, int, int],
|
| 198 |
-
team: str,
|
| 199 |
-
timestamp: float = 0.0,
|
| 200 |
-
show_visualization: bool = True,
|
| 201 |
-
) -> Optional[CalibratedTimeoutRegion]:
|
| 202 |
-
"""
|
| 203 |
-
Calibrate a timeout region by detecting oval positions.
|
| 204 |
-
|
| 205 |
-
This function should be called after region selection to find the precise
|
| 206 |
-
locations of the 3 timeout indicator ovals within the region.
|
| 207 |
-
|
| 208 |
-
Args:
|
| 209 |
-
frame: Video frame (BGR format) where all 3 timeouts should be visible.
|
| 210 |
-
region_bbox: Timeout region bounding box (x, y, width, height).
|
| 211 |
-
team: "home" or "away".
|
| 212 |
-
timestamp: Video timestamp for reference.
|
| 213 |
-
show_visualization: Whether to display the calibration result.
|
| 214 |
-
|
| 215 |
-
Returns:
|
| 216 |
-
CalibratedTimeoutRegion with discovered oval positions, or None if calibration failed.
|
| 217 |
-
"""
|
| 218 |
-
logger.info("Calibrating %s timeout region at timestamp %.1fs", team, timestamp)
|
| 219 |
-
|
| 220 |
-
# Run calibration
|
| 221 |
-
calibrated = calibrate_timeout_ovals(frame, region_bbox, team, timestamp)
|
| 222 |
-
|
| 223 |
-
if calibrated is None:
|
| 224 |
-
logger.error("Calibration failed for %s timeout region", team)
|
| 225 |
-
return None
|
| 226 |
-
|
| 227 |
-
if len(calibrated.ovals) == 0:
|
| 228 |
-
logger.error("No ovals detected in %s timeout region", team)
|
| 229 |
-
return None
|
| 230 |
-
|
| 231 |
-
# Log results
|
| 232 |
-
logger.info("Calibrated %s timeout region: %d ovals found", team, len(calibrated.ovals))
|
| 233 |
-
for i, oval in enumerate(calibrated.ovals):
|
| 234 |
-
logger.info(" Oval %d: pos=(%d,%d) size=%dx%d brightness=%.1f", i + 1, oval.x, oval.y, oval.width, oval.height, oval.baseline_brightness)
|
| 235 |
-
|
| 236 |
-
# Show visualization if requested
|
| 237 |
-
if show_visualization:
|
| 238 |
-
vis_frame = visualize_calibration(frame, calibrated)
|
| 239 |
-
window_name = f"{team.upper()} Timeout Calibration"
|
| 240 |
-
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
|
| 241 |
-
cv2.resizeWindow(window_name, 800, 600)
|
| 242 |
-
cv2.imshow(window_name, vis_frame)
|
| 243 |
-
print(f"\n{team.upper()} timeout calibration complete. Press any key to continue...")
|
| 244 |
-
cv2.waitKey(0)
|
| 245 |
-
cv2.destroyWindow(window_name)
|
| 246 |
-
|
| 247 |
-
return calibrated
|
| 248 |
-
|
| 249 |
-
|
| 250 |
def get_video_path_from_user(project_root: Path) -> Optional[str]:
|
| 251 |
"""
|
| 252 |
Prompt user to enter video file path.
|
|
|
|
| 13 |
import numpy as np
|
| 14 |
|
| 15 |
from video.frame_extractor import extract_sample_frames
|
|
|
|
|
|
|
| 16 |
|
| 17 |
from .models import BBox
|
| 18 |
from .sessions import FlagRegionSelectionSession, PlayClockSelectionSession, ScorebugSelectionSession, TimeoutSelectionSession
|
|
|
|
| 190 |
return bbox.to_tuple() if bbox else None
|
| 191 |
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
def get_video_path_from_user(project_root: Path) -> Optional[str]:
|
| 194 |
"""
|
| 195 |
Prompt user to enter video file path.
|
src/utils/__init__.py
CHANGED
|
@@ -18,7 +18,6 @@ from .logging import (
|
|
| 18 |
log_flag_plays,
|
| 19 |
log_play_complete,
|
| 20 |
log_play_created,
|
| 21 |
-
log_clock_transition,
|
| 22 |
)
|
| 23 |
|
| 24 |
__all__ = [
|
|
@@ -37,5 +36,4 @@ __all__ = [
|
|
| 37 |
"log_flag_plays",
|
| 38 |
"log_play_complete",
|
| 39 |
"log_play_created",
|
| 40 |
-
"log_clock_transition",
|
| 41 |
]
|
|
|
|
| 18 |
log_flag_plays,
|
| 19 |
log_play_complete,
|
| 20 |
log_play_created,
|
|
|
|
| 21 |
)
|
| 22 |
|
| 23 |
__all__ = [
|
|
|
|
| 36 |
"log_flag_plays",
|
| 37 |
"log_play_complete",
|
| 38 |
"log_play_created",
|
|
|
|
| 39 |
]
|
src/utils/logging.py
CHANGED
|
@@ -99,36 +99,3 @@ def log_play_created(
|
|
| 99 |
play.end_time,
|
| 100 |
duration,
|
| 101 |
)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def log_clock_transition(
|
| 105 |
-
timestamp: float,
|
| 106 |
-
time_at_40: float,
|
| 107 |
-
action: str,
|
| 108 |
-
logger_instance: logging.Logger,
|
| 109 |
-
to_clock: int = 25,
|
| 110 |
-
) -> None:
|
| 111 |
-
"""
|
| 112 |
-
Log 40→25 clock transition events.
|
| 113 |
-
|
| 114 |
-
Replaces 5-6 line logging blocks like:
|
| 115 |
-
logger.info(
|
| 116 |
-
"40→25 transition at %.1fs (%.1fs at 40). Handing off for timeout check.",
|
| 117 |
-
timestamp, time_at_40,
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
Args:
|
| 121 |
-
timestamp: When the transition occurred
|
| 122 |
-
time_at_40: How long clock was at 40 before transitioning
|
| 123 |
-
action: Description of what's happening ("handoff for timeout check",
|
| 124 |
-
"mid-play, signaling handoff", etc.)
|
| 125 |
-
logger_instance: Logger to use for output
|
| 126 |
-
to_clock: Target clock value (default 25)
|
| 127 |
-
"""
|
| 128 |
-
logger_instance.info(
|
| 129 |
-
"40→%d transition at %.1fs (%.1fs at 40). %s.",
|
| 130 |
-
to_clock,
|
| 131 |
-
timestamp,
|
| 132 |
-
time_at_40,
|
| 133 |
-
action,
|
| 134 |
-
)
|
|
|
|
| 99 |
play.end_time,
|
| 100 |
duration,
|
| 101 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/video/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Video processing modules for frame extraction and FFmpeg operations."""
|
| 2 |
|
| 3 |
-
from .frame_extractor import extract_sample_frames, get_video_duration
|
| 4 |
from .frame_reader import ThreadedFrameReader
|
| 5 |
from .ffmpeg_ops import (
|
| 6 |
extract_clip_stream_copy,
|
|
@@ -13,7 +13,6 @@ from .ffmpeg_ops import (
|
|
| 13 |
__all__ = [
|
| 14 |
"extract_sample_frames",
|
| 15 |
"get_video_duration",
|
| 16 |
-
"get_video_fps",
|
| 17 |
"ThreadedFrameReader",
|
| 18 |
"extract_clip_stream_copy",
|
| 19 |
"extract_clip_reencode",
|
|
|
|
| 1 |
"""Video processing modules for frame extraction and FFmpeg operations."""
|
| 2 |
|
| 3 |
+
from .frame_extractor import extract_sample_frames, get_video_duration
|
| 4 |
from .frame_reader import ThreadedFrameReader
|
| 5 |
from .ffmpeg_ops import (
|
| 6 |
extract_clip_stream_copy,
|
|
|
|
| 13 |
__all__ = [
|
| 14 |
"extract_sample_frames",
|
| 15 |
"get_video_duration",
|
|
|
|
| 16 |
"ThreadedFrameReader",
|
| 17 |
"extract_clip_stream_copy",
|
| 18 |
"extract_clip_reencode",
|
src/video/frame_extractor.py
CHANGED
|
@@ -15,28 +15,6 @@ import numpy as np
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
|
| 18 |
-
def get_video_fps(video_path: str) -> float:
|
| 19 |
-
"""
|
| 20 |
-
Get the frames per second (FPS) of a video file.
|
| 21 |
-
|
| 22 |
-
Args:
|
| 23 |
-
video_path: Path to video file.
|
| 24 |
-
|
| 25 |
-
Returns:
|
| 26 |
-
FPS as a float.
|
| 27 |
-
|
| 28 |
-
Raises:
|
| 29 |
-
ValueError: If video cannot be opened.
|
| 30 |
-
"""
|
| 31 |
-
cap = cv2.VideoCapture(video_path)
|
| 32 |
-
if not cap.isOpened():
|
| 33 |
-
raise ValueError(f"Could not open video: {video_path}")
|
| 34 |
-
|
| 35 |
-
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 36 |
-
cap.release()
|
| 37 |
-
return fps
|
| 38 |
-
|
| 39 |
-
|
| 40 |
def get_video_duration(video_path: str) -> Optional[float]:
|
| 41 |
"""
|
| 42 |
Get duration of a video file using ffprobe.
|
|
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def get_video_duration(video_path: str) -> Optional[float]:
|
| 19 |
"""
|
| 20 |
Get duration of a video file using ffprobe.
|