Compare commits

...

4 Commits

Author SHA1 Message Date
Eric W
dd1c8fdf33
Merge 317d1acfe1 into 1a6d04fde7 2026-04-23 15:14:12 +00:00
Josh Hawkins
1a6d04fde7
use object-anchored snapshot crops for classification wizard examples (#22985) 2026-04-23 08:53:48 -05:00
Josh Hawkins
4a1b7a1629
enforce python-level timeout on ffprobe subprocesses (#22984) 2026-04-23 07:16:22 -06:00
Eric W
317d1acfe1 Fix motion activity endpoint returning invalid timestamps after pandas 3.0 upgrade
pandas 3.0 changed DatetimeIndex internal storage from datetime64[ns]
(nanoseconds) to datetime64[us] (microseconds). The motion activity
endpoint in review.py converted DatetimeIndex to epoch seconds using:

    df.index = df.index.astype(int) // (10**9)

This assumed nanosecond resolution, dividing by 10^9 to get seconds.
With microsecond resolution the division produces values ~1000x too
small (e.g. 1774785 instead of 1774785600), causing every entry to
have a start_time near zero. The frontend timeline could not match
these timestamps to the visible range, so motion indicator bars
disappeared entirely — despite the underlying recording data being
correct.

Replace the resolution-dependent integer division with pandas
Timedelta arithmetic:

    df.index = (df.index - _EPOCH) // _ONE_SECOND

This is resolution-independent (produces correct results on
datetime64[s], [ms], [us], and [ns]), ~148x faster than the
per-element .timestamp() alternative, produces native Python int
types that serialize cleanly to JSON, and is backwards-compatible
with older pandas versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 09:38:56 -04:00
3 changed files with 129 additions and 55 deletions

View File

@ -40,6 +40,11 @@ from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Pre-computed constants for resolution-independent datetime-to-epoch conversion
# (pandas 3.0+ stores datetime64 as microseconds, not nanoseconds)
_EPOCH = pd.Timestamp("1970-01-01")
_ONE_SECOND = pd.Timedelta("1s")
router = APIRouter(tags=[Tags.review]) router = APIRouter(tags=[Tags.review])
@ -659,7 +664,7 @@ def motion_activity(
df.iloc[i : i + chunk, 0] = 0.0 df.iloc[i : i + chunk, 0] = 0.0
# change types for output # change types for output
df.index = df.index.astype(int) // (10**9) df.index = (df.index - _EPOCH) // _ONE_SECOND
normalized = df.reset_index().to_dict("records") normalized = df.reset_index().to_dict("records")
return JSONResponse(content=normalized) return JSONResponse(content=normalized)

View File

@ -24,8 +24,12 @@ from frigate.log import redirect_output_to_logger, suppress_stderr_during
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.types import ModelStatusTypesEnum from frigate.types import ModelStatusTypesEnum
from frigate.util.downloader import ModelDownloader from frigate.util.downloader import ModelDownloader
from frigate.util.file import get_event_thumbnail_bytes from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
from frigate.util.image import get_image_from_recording from frigate.util.image import (
calculate_region,
get_image_from_recording,
relative_box_to_absolute,
)
from frigate.util.process import FrigateProcess from frigate.util.process import FrigateProcess
BATCH_SIZE = 16 BATCH_SIZE = 16
@ -713,7 +717,7 @@ def collect_object_classification_examples(
This function: This function:
1. Queries events for the specified label 1. Queries events for the specified label
2. Selects 100 balanced events across different cameras and times 2. Selects 100 balanced events across different cameras and times
3. Retrieves thumbnails for selected events (with 33% center crop applied) 3. Crops each event's clean snapshot around the object bounding box
4. Selects 24 most visually distinct thumbnails 4. Selects 24 most visually distinct thumbnails
5. Saves to dataset directory 5. Saves to dataset directory
@ -832,29 +836,80 @@ def _select_balanced_events(
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]: def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
""" """
Extract thumbnails from events and save to disk. Extract a training image for each event.
Preferred path: load the full-frame clean snapshot and crop around the
stored bounding box with the same calculate_region(..., max(w, h), 1.0)
call the live ObjectClassificationProcessor uses, so wizard examples
are framed like inference-time inputs.
Fallback: if no clean snapshot exists (snapshots disabled, or only a
legacy annotated JPG is on disk), center-crop the stored thumbnail
using a step ladder sized from the box/region area ratio.
Args: Args:
events: List of Event objects events: List of Event objects
output_dir: Directory to save thumbnails output_dir: Directory to save crops
Returns: Returns:
List of paths to successfully extracted thumbnail images List of paths to successfully extracted images
""" """
thumbnail_paths = [] image_paths = []
for idx, event in enumerate(events): for idx, event in enumerate(events):
try: try:
thumbnail_bytes = get_event_thumbnail_bytes(event) img = _load_event_classification_crop(event)
if img is None:
continue
resized = cv2.resize(img, (224, 224))
output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg")
cv2.imwrite(output_path, resized)
image_paths.append(output_path)
except Exception as e:
logger.debug(f"Failed to extract image for event {event.id}: {e}")
continue
return image_paths
def _load_event_classification_crop(event: Event) -> np.ndarray | None:
"""Prefer a snapshot-based object crop; fall back to a center-cropped thumbnail."""
if event.data and "box" in event.data:
snapshot, _ = load_event_snapshot_image(event, clean_only=True)
if snapshot is not None:
abs_box = relative_box_to_absolute(snapshot.shape, event.data["box"])
if abs_box is not None:
xmin, ymin, xmax, ymax = abs_box
box_w = xmax - xmin
box_h = ymax - ymin
if box_w > 0 and box_h > 0:
x1, y1, x2, y2 = calculate_region(
snapshot.shape,
xmin,
ymin,
xmax,
ymax,
max(box_w, box_h),
1.0,
)
cropped = snapshot[y1:y2, x1:x2]
if cropped.size > 0:
return cropped
thumbnail_bytes = get_event_thumbnail_bytes(event)
if not thumbnail_bytes:
return None
if thumbnail_bytes:
nparr = np.frombuffer(thumbnail_bytes, np.uint8) nparr = np.frombuffer(thumbnail_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None or img.size == 0:
return None
if img is not None:
height, width = img.shape[:2] height, width = img.shape[:2]
crop_size = 1.0 crop_size = 1.0
if event.data and "box" in event.data and "region" in event.data: if event.data and "box" in event.data and "region" in event.data:
box = event.data["box"] box = event.data["box"]
region = event.data["region"] region = event.data["region"]
@ -862,7 +917,6 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
if len(box) == 4 and len(region) == 4: if len(box) == 4 and len(region) == 4:
box_w, box_h = box[2], box[3] box_w, box_h = box[2], box[3]
region_w, region_h = region[2], region[3] region_w, region_h = region[2], region[3]
box_area = (box_w * box_h) / (region_w * region_h) box_area = (box_w * box_h) / (region_w * region_h)
if box_area < 0.05: if box_area < 0.05:
@ -878,20 +932,10 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
crop_width = int(width * crop_size) crop_width = int(width * crop_size)
crop_height = int(height * crop_size) crop_height = int(height * crop_size)
x1 = (width - crop_width) // 2 x1 = (width - crop_width) // 2
y1 = (height - crop_height) // 2 y1 = (height - crop_height) // 2
x2 = x1 + crop_width cropped = img[y1 : y1 + crop_height, x1 : x1 + crop_width]
y2 = y1 + crop_height if cropped.size == 0:
return None
cropped = img[y1:y2, x1:x2] return cropped
resized = cv2.resize(cropped, (224, 224))
output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg")
cv2.imwrite(output_path, resized)
thumbnail_paths.append(output_path)
except Exception as e:
logger.debug(f"Failed to extract thumbnail for event {event.id}: {e}")
continue
return thumbnail_paths

View File

@ -726,7 +726,20 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
if detailed and format_entries: if detailed and format_entries:
cmd.extend(["-show_entries", f"format={format_entries}"]) cmd.extend(["-show_entries", f"format={format_entries}"])
cmd.extend(["-loglevel", "error", clean_path]) cmd.extend(["-loglevel", "error", clean_path])
return sp.run(cmd, capture_output=True) try:
return sp.run(cmd, capture_output=True, timeout=6)
except sp.TimeoutExpired as e:
logger.info(
"ffprobe timed out while probing %s (transport=%s)",
clean_camera_user_pass(path),
rtsp_transport or "default",
)
return sp.CompletedProcess(
args=cmd,
returncode=1,
stdout=e.stdout or b"",
stderr=(e.stderr or b"") + b"\nffprobe timed out",
)
result = run() result = run()
@ -832,11 +845,23 @@ async def get_video_properties(
"-show_streams", "-show_streams",
url, url,
] ]
proc = None
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
) )
stdout, _ = await proc.communicate() try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=6)
except asyncio.TimeoutError:
logger.info(
"ffprobe timed out while probing %s (transport=%s)",
clean_camera_user_pass(url),
rtsp_transport or "default",
)
proc.kill()
await proc.wait()
return False, 0, 0, None, -1
if proc.returncode != 0: if proc.returncode != 0:
return False, 0, 0, None, -1 return False, 0, 0, None, -1