Compare commits

..

4 Commits

Author SHA1 Message Date
Josh Hawkins
b218221a60
Merge b5a360be39 into 1a6d04fde7 2026-04-23 16:01:28 +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
Nicolas Mowen
8eace9c3e7
WebUI tweaks (#22980)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Use escape key to go back to main camera dashboard

* Add icon showing when review item is needing review
2026-04-22 21:37:17 -05:00
4 changed files with 154 additions and 57 deletions

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

View File

@ -17,6 +17,9 @@ import { useUserPersistence } from "@/hooks/use-user-persistence";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { FaExclamationTriangle } from "react-icons/fa";
import { MdOutlinePersonSearch } from "react-icons/md";
import { ThreatLevel } from "@/types/review";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
@ -127,6 +130,11 @@ export function AnimatedEventCard({
true, true,
); );
const threatLevel = useMemo<ThreatLevel>(
() => (event.data.metadata?.potential_threat_level ?? 0) as ThreatLevel,
[event],
);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if ( if (
!config || !config ||
@ -152,7 +160,15 @@ export function AnimatedEventCard({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className="pointer-events-none absolute left-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100" className={cn(
"absolute left-2 top-1 z-40 transition-opacity",
threatLevel === ThreatLevel.SECURITY_CONCERN &&
"pointer-events-auto bg-severity_alert opacity-100 hover:bg-severity_alert",
threatLevel === ThreatLevel.NEEDS_REVIEW &&
"pointer-events-auto bg-severity_detection opacity-100 hover:bg-severity_detection",
threatLevel === ThreatLevel.NORMAL &&
"pointer-events-none bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
)}
size="xs" size="xs"
aria-label={t("markAsReviewed")} aria-label={t("markAsReviewed")}
onClick={async () => { onClick={async () => {
@ -160,7 +176,13 @@ export function AnimatedEventCard({
updateEvents(); updateEvents();
}} }}
> >
{threatLevel === ThreatLevel.SECURITY_CONCERN ? (
<FaExclamationTriangle className="size-3 text-white" />
) : threatLevel === ThreatLevel.NEEDS_REVIEW ? (
<MdOutlinePersonSearch className="size-3 text-white" />
) : (
<FaCircleCheck className="size-3 text-white" /> <FaCircleCheck className="size-3 text-white" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("markAsReviewed")}</TooltipContent> <TooltipContent>{t("markAsReviewed")}</TooltipContent>

View File

@ -389,7 +389,7 @@ export default function LiveCameraView({
return "mse"; return "mse";
}, [lowBandwidth, mic, webRTC, isRestreamed]); }, [lowBandwidth, mic, webRTC, isRestreamed]);
useKeyboardListener(["m"], (key, modifiers) => { useKeyboardListener(["m", "Escape"], (key, modifiers) => {
if (!modifiers.down) { if (!modifiers.down) {
return true; return true;
} }
@ -407,6 +407,12 @@ export default function LiveCameraView({
return true; return true;
} }
break; break;
case "Escape":
if (!fullscreen) {
navigate(-1);
return true;
}
break;
} }
return false; return false;