This commit is contained in:
Nicolas Mowen 2025-12-04 04:00:04 +00:00 committed by GitHub
commit 96665deef5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 645 additions and 636 deletions

View File

@ -111,3 +111,9 @@ review:
## Review Reports ## Review Reports
Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review. Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review.
### Requesting Reports Programmatically
Review reports can be requested via the [API](/integrations/api#review-summarization) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps.
For Home Assistant users, there is a built-in service (`frigate.generate_review_summary`) that makes it easy to request review reports as part of automations or scripts. This allows you to automatically generate daily summaries, vacation reports, or custom time period reports based on your specific needs.

View File

@ -107,7 +107,7 @@ Fine-tune the LPR feature using these optional parameters at the global level of
### Normalization Rules ### Normalization Rules
- **`replace_rules`**: List of regex replacement rules to normalize detected plates. These rules are applied sequentially. Each rule must have a `pattern` (which can be a string or a regex, prepended by `r`) and `replacement` (a string, which also supports [backrefs](https://docs.python.org/3/library/re.html#re.sub) like `\1`). These rules are useful for dealing with common OCR issues like noise characters, separators, or confusions (e.g., 'O'→'0'). - **`replace_rules`**: List of regex replacement rules to normalize detected plates. These rules are applied sequentially and are applied _before_ the `format` regex, if specified. Each rule must have a `pattern` (which can be a string or a regex, prepended by `r`) and `replacement` (a string, which also supports [backrefs](https://docs.python.org/3/library/re.html#re.sub) like `\1`). These rules are useful for dealing with common OCR issues like noise characters, separators, or confusions (e.g., 'O'→'0').
These rules must be defined at the global level of your `lpr` config. These rules must be defined at the global level of your `lpr` config.

View File

@ -164,12 +164,6 @@ A Tensorflow Lite is provided in the container at `/openvino-model/ssdlite_mobil
<details> <details>
<summary>YOLOv9 Setup & Config</summary> <summary>YOLOv9 Setup & Config</summary>
:::warning
If you are using a Frigate+ YOLOv9 model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
:::
After placing the downloaded files for the tflite model and labels in your config folder, you can use the following configuration: After placing the downloaded files for the tflite model and labels in your config folder, you can use the following configuration:
```yaml ```yaml
@ -408,7 +402,7 @@ The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv
:::warning :::warning
If you are using a Frigate+ YOLOv9 model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model. If you are using a Frigate+ model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
::: :::
@ -748,7 +742,7 @@ The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv
:::warning :::warning
If you are using a Frigate+ YOLOv9 model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model. If you are using a Frigate+ model, you should not define any of the below `model` parameters in your config except for `path`. See [the Frigate+ model docs](/plus/first_model#step-3-set-your-model-id-in-the-config) for more information on setting up your model.
::: :::

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,6 @@ class EventsDescriptionBody(BaseModel):
class EventsCreateBody(BaseModel): class EventsCreateBody(BaseModel):
source_type: Optional[str] = "api"
sub_label: Optional[str] = None sub_label: Optional[str] = None
score: Optional[float] = 0 score: Optional[float] = 0
duration: Optional[int] = 30 duration: Optional[int] = 30

View File

@ -346,7 +346,7 @@ def events(
"/events/explore", "/events/explore",
response_model=list[EventResponse], response_model=list[EventResponse],
dependencies=[Depends(allow_any_authenticated())], dependencies=[Depends(allow_any_authenticated())],
summary="Get summary of objects.", summary="Get summary of objects",
description="""Gets a summary of objects from the database. description="""Gets a summary of objects from the database.
Returns a list of objects with a max of `limit` objects for each label. Returns a list of objects with a max of `limit` objects for each label.
""", """,
@ -439,7 +439,7 @@ def events_explore(
"/event_ids", "/event_ids",
response_model=list[EventResponse], response_model=list[EventResponse],
dependencies=[Depends(allow_any_authenticated())], dependencies=[Depends(allow_any_authenticated())],
summary="Get events by ids.", summary="Get events by ids",
description="""Gets events by a list of ids. description="""Gets events by a list of ids.
Returns a list of events. Returns a list of events.
""", """,
@ -473,7 +473,7 @@ async def event_ids(ids: str, request: Request):
@router.get( @router.get(
"/events/search", "/events/search",
dependencies=[Depends(allow_any_authenticated())], dependencies=[Depends(allow_any_authenticated())],
summary="Search events.", summary="Search events",
description="""Searches for events in the database. description="""Searches for events in the database.
Returns a list of events. Returns a list of events.
""", """,
@ -924,7 +924,7 @@ def events_summary(
"/events/{event_id}", "/events/{event_id}",
response_model=EventResponse, response_model=EventResponse,
dependencies=[Depends(allow_any_authenticated())], dependencies=[Depends(allow_any_authenticated())],
summary="Get event by id.", summary="Get event by id",
description="Gets an event by its id.", description="Gets an event by its id.",
) )
async def event(event_id: str, request: Request): async def event(event_id: str, request: Request):
@ -968,7 +968,7 @@ def set_retain(event_id: str):
"/events/{event_id}/plus", "/events/{event_id}/plus",
response_model=EventUploadPlusResponse, response_model=EventUploadPlusResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Send event to Frigate+.", summary="Send event to Frigate+",
description="""Sends an event to Frigate+. description="""Sends an event to Frigate+.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
""", """,
@ -1207,7 +1207,7 @@ async def false_positive(request: Request, event_id: str):
"/events/{event_id}/retain", "/events/{event_id}/retain",
response_model=GenericResponse, response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Stop event from being retained indefinitely.", summary="Stop event from being retained indefinitely",
description="""Stops an event from being retained indefinitely. description="""Stops an event from being retained indefinitely.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
NOTE: This is a legacy endpoint and is not supported in the frontend. NOTE: This is a legacy endpoint and is not supported in the frontend.
@ -1236,7 +1236,7 @@ async def delete_retain(event_id: str, request: Request):
"/events/{event_id}/sub_label", "/events/{event_id}/sub_label",
response_model=GenericResponse, response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Set event sub label.", summary="Set event sub label",
description="""Sets an event's sub label. description="""Sets an event's sub label.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
""", """,
@ -1295,7 +1295,7 @@ async def set_sub_label(
"/events/{event_id}/recognized_license_plate", "/events/{event_id}/recognized_license_plate",
response_model=GenericResponse, response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Set event license plate.", summary="Set event license plate",
description="""Sets an event's license plate. description="""Sets an event's license plate.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
""", """,
@ -1355,7 +1355,7 @@ async def set_plate(
"/events/{event_id}/description", "/events/{event_id}/description",
response_model=GenericResponse, response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Set event description.", summary="Set event description",
description="""Sets an event's description. description="""Sets an event's description.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
""", """,
@ -1411,7 +1411,7 @@ async def set_description(
"/events/{event_id}/description/regenerate", "/events/{event_id}/description/regenerate",
response_model=GenericResponse, response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Regenerate event description.", summary="Regenerate event description",
description="""Regenerates an event's description. description="""Regenerates an event's description.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
""", """,
@ -1463,8 +1463,8 @@ async def regenerate_description(
@router.post( @router.post(
"/description/generate", "/description/generate",
response_model=GenericResponse, response_model=GenericResponse,
# dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Generate description embedding.", summary="Generate description embedding",
description="""Generates an embedding for an event's description. description="""Generates an embedding for an event's description.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
""", """,
@ -1529,7 +1529,7 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
"/events/{event_id}", "/events/{event_id}",
response_model=GenericResponse, response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Delete event.", summary="Delete event",
description="""Deletes an event from the database. description="""Deletes an event from the database.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
""", """,
@ -1544,7 +1544,7 @@ async def delete_event(request: Request, event_id: str):
"/events/", "/events/",
response_model=EventMultiDeleteResponse, response_model=EventMultiDeleteResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Delete events.", summary="Delete events",
description="""Deletes a list of events from the database. description="""Deletes a list of events from the database.
Returns a success message or an error if the events are not found. Returns a success message or an error if the events are not found.
""", """,
@ -1578,7 +1578,7 @@ async def delete_events(request: Request, body: EventsDeleteBody):
"/events/{camera_name}/{label}/create", "/events/{camera_name}/{label}/create",
response_model=EventCreateResponse, response_model=EventCreateResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Create manual event.", summary="Create manual event",
description="""Creates a manual event in the database. description="""Creates a manual event in the database.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
NOTES: NOTES:
@ -1620,7 +1620,7 @@ def create_event(
body.score, body.score,
body.sub_label, body.sub_label,
body.duration, body.duration,
body.source_type, "api",
body.draw, body.draw,
), ),
EventMetadataTypeEnum.manual_event_create.value, EventMetadataTypeEnum.manual_event_create.value,
@ -1642,7 +1642,7 @@ def create_event(
"/events/{event_id}/end", "/events/{event_id}/end",
response_model=GenericResponse, response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="End manual event.", summary="End manual event",
description="""Ends a manual event. description="""Ends a manual event.
Returns a success message or an error if the event is not found. Returns a success message or an error if the event is not found.
NOTE: This should only be used for manual events. NOTE: This should only be used for manual events.
@ -1652,10 +1652,27 @@ async def end_event(request: Request, event_id: str, body: EventsEndBody):
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request) await require_camera_access(event.camera, request=request)
if body.end_time is not None and body.end_time < event.start_time:
return JSONResponse(
content=(
{
"success": False,
"message": f"end_time ({body.end_time}) cannot be before start_time ({event.start_time}).",
}
),
status_code=400,
)
end_time = body.end_time or datetime.datetime.now().timestamp() end_time = body.end_time or datetime.datetime.now().timestamp()
request.app.event_metadata_updater.publish( request.app.event_metadata_updater.publish(
(event_id, end_time), EventMetadataTypeEnum.manual_event_end.value (event_id, end_time), EventMetadataTypeEnum.manual_event_end.value
) )
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": f"Event {event_id} not found."}),
status_code=404,
)
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content=( content=(
@ -1674,7 +1691,7 @@ async def end_event(request: Request, event_id: str, body: EventsEndBody):
"/trigger/embedding", "/trigger/embedding",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Create trigger embedding.", summary="Create trigger embedding",
description="""Creates a trigger embedding for a specific trigger. description="""Creates a trigger embedding for a specific trigger.
Returns a success message or an error if the trigger is not found. Returns a success message or an error if the trigger is not found.
""", """,
@ -1832,7 +1849,7 @@ def create_trigger_embedding(
"/trigger/embedding/{camera_name}/{name}", "/trigger/embedding/{camera_name}/{name}",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Update trigger embedding.", summary="Update trigger embedding",
description="""Updates a trigger embedding for a specific trigger. description="""Updates a trigger embedding for a specific trigger.
Returns a success message or an error if the trigger is not found. Returns a success message or an error if the trigger is not found.
""", """,
@ -1997,7 +2014,7 @@ def update_trigger_embedding(
"/trigger/embedding/{camera_name}/{name}", "/trigger/embedding/{camera_name}/{name}",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Delete trigger embedding.", summary="Delete trigger embedding",
description="""Deletes a trigger embedding for a specific trigger. description="""Deletes a trigger embedding for a specific trigger.
Returns a success message or an error if the trigger is not found. Returns a success message or an error if the trigger is not found.
""", """,
@ -2071,7 +2088,7 @@ def delete_trigger_embedding(
"/triggers/status/{camera_name}", "/triggers/status/{camera_name}",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Get triggers status.", summary="Get triggers status",
description="""Gets the status of all triggers for a specific camera. description="""Gets the status of all triggers for a specific camera.
Returns a success message or an error if the camera is not found. Returns a success message or an error if the camera is not found.
""", """,

View File

@ -177,7 +177,7 @@
"generateSuccess": "Successfully generated sample images", "generateSuccess": "Successfully generated sample images",
"missingStatesWarning": { "missingStatesWarning": {
"title": "Missing State Examples", "title": "Missing State Examples",
"description": "You haven't selected examples for all states. The model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model." "description": "It's recommended to select examples for all states for best results. You can continue without selecting all states, but the model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model."
} }
} }
} }

View File

@ -500,7 +500,7 @@
"name": { "name": {
"title": "Name", "title": "Name",
"inputPlaceHolder": "Enter a name…", "inputPlaceHolder": "Enter a name…",
"tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone." "tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone on this camera."
}, },
"inertia": { "inertia": {
"title": "Inertia", "title": "Inertia",

View File

@ -6,6 +6,10 @@ import { ReactNode } from "react";
axios.defaults.baseURL = `${baseUrl}api/`; axios.defaults.baseURL = `${baseUrl}api/`;
// Module-level flag to prevent multiple simultaneous redirects
// (eg, when multiple SWR queries fail with 401 at once)
let isRedirectingToLogin = false;
type ApiProviderType = { type ApiProviderType = {
children?: ReactNode; children?: ReactNode;
options?: Record<string, unknown>; options?: Record<string, unknown>;
@ -31,7 +35,8 @@ export function ApiProvider({ children, options }: ApiProviderType) {
) { ) {
// redirect to the login page if not already there // redirect to the login page if not already there
const loginPage = error.response.headers.get("location") ?? "login"; const loginPage = error.response.headers.get("location") ?? "login";
if (window.location.href !== loginPage) { if (window.location.href !== loginPage && !isRedirectingToLogin) {
isRedirectingToLogin = true;
window.location.href = loginPage; window.location.href = loginPage;
} }
} }

View File

@ -407,30 +407,6 @@ export default function Step3ChooseExamples({
return allClasses.every((className) => statesWithExamples.has(className)); return allClasses.every((className) => statesWithExamples.has(className));
}, [step1Data.modelType, allClasses, statesWithExamples]); }, [step1Data.modelType, allClasses, statesWithExamples]);
// For state models on the last class, require all images to be classified
// But allow proceeding even if not all states have examples (with warning)
const canProceed = useMemo(() => {
if (step1Data.modelType === "state" && isLastClass) {
// Check if all 24 images will be classified after current selections are applied
const totalImages = unknownImages.slice(0, 24).length;
// Count images that will be classified (either already classified or currently selected)
const allImages = unknownImages.slice(0, 24);
const willBeClassified = allImages.filter((img) => {
return imageClassifications[img] || selectedImages.has(img);
}).length;
return willBeClassified >= totalImages;
}
return true;
}, [
step1Data.modelType,
isLastClass,
unknownImages,
imageClassifications,
selectedImages,
]);
const hasUnclassifiedImages = useMemo(() => { const hasUnclassifiedImages = useMemo(() => {
if (!unknownImages) return false; if (!unknownImages) return false;
const allImages = unknownImages.slice(0, 24); const allImages = unknownImages.slice(0, 24);
@ -594,9 +570,7 @@ export default function Step3ChooseExamples({
} }
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
disabled={ disabled={!hasGenerated || isGenerating || isProcessing}
!hasGenerated || isGenerating || isProcessing || !canProceed
}
> >
{isProcessing && <ActivityIndicator className="size-4" />} {isProcessing && <ActivityIndicator className="size-4" />}
{t("button.continue", { ns: "common" })} {t("button.continue", { ns: "common" })}

View File

@ -559,6 +559,7 @@ export function TrackingDetails({
isDetailMode={true} isDetailMode={true}
camera={event.camera} camera={event.camera}
currentTimeOverride={currentTime} currentTimeOverride={currentTime}
enableGapControllerRecovery={true}
/> />
{isVideoLoading && ( {isVideoLoading && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />

View File

@ -5,7 +5,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import Hls from "hls.js"; import Hls, { HlsConfig } from "hls.js";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import VideoControls from "./VideoControls"; import VideoControls from "./VideoControls";
@ -57,6 +57,7 @@ type HlsVideoPlayerProps = {
isDetailMode?: boolean; isDetailMode?: boolean;
camera?: string; camera?: string;
currentTimeOverride?: number; currentTimeOverride?: number;
enableGapControllerRecovery?: boolean;
}; };
export default function HlsVideoPlayer({ export default function HlsVideoPlayer({
@ -81,6 +82,7 @@ export default function HlsVideoPlayer({
isDetailMode = false, isDetailMode = false,
camera, camera,
currentTimeOverride, currentTimeOverride,
enableGapControllerRecovery = false,
}: HlsVideoPlayerProps) { }: HlsVideoPlayerProps) {
const { t } = useTranslation("components/player"); const { t } = useTranslation("components/player");
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -170,11 +172,23 @@ export default function HlsVideoPlayer({
return; return;
} }
hlsRef.current = new Hls({ // Base HLS configuration
const baseConfig: Partial<HlsConfig> = {
maxBufferLength: 10, maxBufferLength: 10,
maxBufferSize: 20 * 1000 * 1000, maxBufferSize: 20 * 1000 * 1000,
startPosition: currentSource.startPosition, startPosition: currentSource.startPosition,
}); };
const hlsConfig = { ...baseConfig };
if (enableGapControllerRecovery) {
hlsConfig.highBufferWatchdogPeriod = 1; // Check for stalls every 1 second (default: 3)
hlsConfig.nudgeOffset = 0.2; // Nudge playhead forward 0.2s when stalled (default: 0.1)
hlsConfig.nudgeMaxRetry = 5; // Try up to 5 nudges before giving up (default: 3)
hlsConfig.maxBufferHole = 0.5; // Tolerate up to 0.5s gaps between fragments (default: 0.1)
}
hlsRef.current = new Hls(hlsConfig);
hlsRef.current.attachMedia(videoRef.current); hlsRef.current.attachMedia(videoRef.current);
hlsRef.current.loadSource(currentSource.playlist); hlsRef.current.loadSource(currentSource.playlist);
videoRef.current.playbackRate = currentPlaybackRate; videoRef.current.playbackRate = currentPlaybackRate;
@ -187,7 +201,13 @@ export default function HlsVideoPlayer({
hlsRef.current.destroy(); hlsRef.current.destroy();
} }
}; };
}, [videoRef, hlsRef, useHlsCompat, currentSource]); }, [
videoRef,
hlsRef,
useHlsCompat,
currentSource,
enableGapControllerRecovery,
]);
// state handling // state handling