Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
67a408ebba
Merge 2b6fcf38c0 into c244e6582a 2026-04-22 19:39:59 +00:00
7 changed files with 74 additions and 183 deletions

View File

@ -39,10 +39,6 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail. [Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.
## [kiosk-monitor](https://github.com/extremeshok/kiosk-monitor)
[kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) is a Raspberry Pi watchdog that runs Chromium fullscreen on a Frigate dashboard (optionally with VLC on a second monitor for an RTSP camera stream), auto-restarts on frozen screens or unreachable URLs, and ships a Birdseye-aware Chromium helper that auto-sizes the grid to the display.
## [Periscope](https://github.com/maksz42/periscope) ## [Periscope](https://github.com/maksz42/periscope)
[Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS. [Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS.

View File

@ -24,12 +24,8 @@ 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, load_event_snapshot_image from frigate.util.file import get_event_thumbnail_bytes
from frigate.util.image import ( from frigate.util.image import get_image_from_recording
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
@ -717,7 +713,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. Crops each event's clean snapshot around the object bounding box 3. Retrieves thumbnails for selected events (with 33% center crop applied)
4. Selects 24 most visually distinct thumbnails 4. Selects 24 most visually distinct thumbnails
5. Saves to dataset directory 5. Saves to dataset directory
@ -836,80 +832,29 @@ 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 a training image for each event. Extract thumbnails from events and save to disk.
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 crops output_dir: Directory to save thumbnails
Returns: Returns:
List of paths to successfully extracted images List of paths to successfully extracted thumbnail images
""" """
image_paths = [] thumbnail_paths = []
for idx, event in enumerate(events): for idx, event in enumerate(events):
try: try:
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) 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"]
@ -917,6 +862,7 @@ def _load_event_classification_crop(event: Event) -> np.ndarray | None:
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:
@ -932,10 +878,20 @@ def _load_event_classification_crop(event: Event) -> np.ndarray | None:
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
cropped = img[y1 : y1 + crop_height, x1 : x1 + crop_width] x2 = x1 + crop_width
if cropped.size == 0: y2 = y1 + crop_height
return None
return cropped cropped = img[y1:y2, x1:x2]
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

@ -711,11 +711,8 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
else: else:
format_entries = None format_entries = None
def run(rtsp_transport: Optional[str] = None) -> sp.CompletedProcess: ffprobe_cmd = [
cmd = [ffmpeg.ffprobe_path] ffmpeg.ffprobe_path,
if rtsp_transport:
cmd += ["-rtsp_transport", rtsp_transport]
cmd += [
"-timeout", "-timeout",
"1000000", "1000000",
"-print_format", "-print_format",
@ -723,32 +720,14 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
"-show_entries", "-show_entries",
f"stream={stream_entries}", f"stream={stream_entries}",
] ]
# Add format entries for detailed mode
if detailed and format_entries: if detailed and format_entries:
cmd.extend(["-show_entries", f"format={format_entries}"]) ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"])
cmd.extend(["-loglevel", "error", clean_path])
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() ffprobe_cmd.extend(["-loglevel", "error", clean_path])
# For RTSP: retry with explicit TCP transport if the first attempt failed return sp.run(ffprobe_cmd, capture_output=True)
# (default UDP may be blocked)
if result.returncode != 0 and clean_path.startswith("rtsp://"):
result = run(rtsp_transport="tcp")
return result
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess: def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
@ -845,23 +824,11 @@ 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
) )
try: stdout, _ = await proc.communicate()
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

6
web/package-lock.json generated
View File

@ -9642,9 +9642,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.18.1", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {

View File

@ -415,7 +415,7 @@
"audioCodecGood": "Audio codec is {{codec}}.", "audioCodecGood": "Audio codec is {{codec}}.",
"resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.", "resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.",
"resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.", "resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.",
"resolutionUnknown": "The resolution of this stream could not be probed. You should manually set the detect resolution in Settings or your config.", "resolutionUnknown": "The resolution of this stream could not be probed. This will cause issues on startup. You should manually set the detect resolution in Settings or your config.",
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.", "noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.", "audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
"audioCodecRequired": "An audio stream is required to support audio detection.", "audioCodecRequired": "An audio stream is required to support audio detection.",

View File

@ -17,9 +17,6 @@ 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";
@ -130,11 +127,6 @@ 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 ||
@ -160,15 +152,7 @@ export function AnimatedEventCard({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={cn( 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"
"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 () => {
@ -176,13 +160,7 @@ 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", "Escape"], (key, modifiers) => { useKeyboardListener(["m"], (key, modifiers) => {
if (!modifiers.down) { if (!modifiers.down) {
return true; return true;
} }
@ -407,12 +407,6 @@ export default function LiveCameraView({
return true; return true;
} }
break; break;
case "Escape":
if (!fullscreen) {
navigate(-1);
return true;
}
break;
} }
return false; return false;