mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-21 12:36:42 +03:00
Compare commits
No commits in common. "12f6092a5bce34666375d98a43674fa6f5b456fd" and "73f0ca663bf59c990dab691754014dd291936f3c" have entirely different histories.
12f6092a5b
...
73f0ca663b
@ -3,18 +3,18 @@ id: license_plate_recognition
|
|||||||
title: License Plate Recognition (LPR)
|
title: License Plate Recognition (LPR)
|
||||||
---
|
---
|
||||||
|
|
||||||
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
||||||
|
|
||||||
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
||||||
|
|
||||||
When a plate is recognized, the details are:
|
When a plate is recognized, the details are:
|
||||||
|
|
||||||
- Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
- Added as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||||
- Viewable in the Details pane in Review/History.
|
- Viewable in the Review Item Details pane in Review (sub labels).
|
||||||
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
||||||
- Filterable through the More Filters menu in Explore.
|
- Filterable through the More Filters menu in Explore.
|
||||||
- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
||||||
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`.
|
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if known) and `plate`.
|
||||||
|
|
||||||
## Model Requirements
|
## Model Requirements
|
||||||
|
|
||||||
@ -31,7 +31,6 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle`
|
|||||||
## Minimum System Requirements
|
## Minimum System Requirements
|
||||||
|
|
||||||
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
|
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
License plate recognition is disabled by default. Enable it in your config file:
|
License plate recognition is disabled by default. Enable it in your config file:
|
||||||
@ -74,8 +73,8 @@ Fine-tune the LPR feature using these optional parameters at the global level of
|
|||||||
- Default: `small`
|
- Default: `small`
|
||||||
- This can be `small` or `large`.
|
- This can be `small` or `large`.
|
||||||
- The `small` model is fast and identifies groups of Latin and Chinese characters.
|
- The `small` model is fast and identifies groups of Latin and Chinese characters.
|
||||||
- The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. It is significantly slower than the `small` model.
|
- The `large` model identifies Latin characters only, but uses an enhanced text detector and is more capable at finding characters on multi-line plates. It is significantly slower than the `small` model. Note that using the `large` model does not improve _text recognition_, but it may improve _text detection_.
|
||||||
- If your country or region does not use multi-line plates, you should use the `small` model as performance is much better for single-line plates.
|
- For most users, the `small` model is recommended.
|
||||||
|
|
||||||
### Recognition
|
### Recognition
|
||||||
|
|
||||||
@ -306,7 +305,7 @@ With this setup:
|
|||||||
- Review items will always be classified as a `detection`.
|
- Review items will always be classified as a `detection`.
|
||||||
- Snapshots will always be saved.
|
- Snapshots will always be saved.
|
||||||
- Zones and object masks are **not** used.
|
- Zones and object masks are **not** used.
|
||||||
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a [known](#matching) plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a known plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
|
||||||
- License plate snapshots are saved at the highest-scoring moment and appear in Explore.
|
- License plate snapshots are saved at the highest-scoring moment and appear in Explore.
|
||||||
- Debug view will not show `license_plate` bounding boxes.
|
- Debug view will not show `license_plate` bounding boxes.
|
||||||
|
|
||||||
|
|||||||
@ -141,7 +141,7 @@ Triggers are best configured through the Frigate UI.
|
|||||||
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
|
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
|
||||||
5. Save the trigger to update the configuration and store the embedding in the database.
|
5. Save the trigger to update the configuration and store the embedding in the database.
|
||||||
|
|
||||||
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification. Additionally, the UI will show the last date/time and tracked object ID that activated your trigger. The last triggered timestamp is not saved to the database or persisted through restarts of Frigate.
|
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification.
|
||||||
|
|
||||||
### Usage and Best Practices
|
### Usage and Best Practices
|
||||||
|
|
||||||
|
|||||||
@ -1781,8 +1781,9 @@ def create_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
logger.error(
|
||||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1806,8 +1807,8 @@ def create_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception("Error creating trigger embedding")
|
logger.error(e.with_traceback())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -1916,8 +1917,9 @@ def update_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
logger.error(
|
||||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1956,8 +1958,9 @@ def update_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
logger.error(
|
||||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1969,8 +1972,8 @@ def update_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception("Error updating trigger embedding")
|
logger.error(e.with_traceback())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -2030,8 +2033,9 @@ def delete_trigger_embedding(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.error(e.with_traceback())
|
||||||
|
logger.error(
|
||||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2043,8 +2047,8 @@ def delete_trigger_embedding(
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.exception("Error deleting trigger embedding")
|
logger.error(e.with_traceback())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
|
import { MdImageSearch } from "react-icons/md";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@ -29,8 +31,11 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { BsFillLightningFill } from "react-icons/bs";
|
||||||
import BlurredIconButton from "../button/BlurredIconButton";
|
import BlurredIconButton from "../button/BlurredIconButton";
|
||||||
|
import { PiPath } from "react-icons/pi";
|
||||||
|
|
||||||
type SearchResultActionsProps = {
|
type SearchResultActionsProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
@ -93,6 +98,7 @@ export default function SearchResultActions({
|
|||||||
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
||||||
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
||||||
>
|
>
|
||||||
|
<LuDownload className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -104,6 +110,7 @@ export default function SearchResultActions({
|
|||||||
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
||||||
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
||||||
>
|
>
|
||||||
|
<LuCamera className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -113,31 +120,44 @@ export default function SearchResultActions({
|
|||||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||||
onClick={showTrackingDetails}
|
onClick={showTrackingDetails}
|
||||||
>
|
>
|
||||||
|
<PiPath className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{config?.semantic_search?.enabled &&
|
{config?.semantic_search?.enabled && isContextMenu && (
|
||||||
searchResult.data.type == "object" && (
|
<MenuItem
|
||||||
<MenuItem
|
aria-label={t("itemMenu.findSimilar.aria")}
|
||||||
aria-label={t("itemMenu.findSimilar.aria")}
|
onClick={findSimilar}
|
||||||
onClick={findSimilar}
|
>
|
||||||
>
|
<MdImageSearch className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{config?.semantic_search?.enabled &&
|
{config?.semantic_search?.enabled &&
|
||||||
searchResult.data.type == "object" && (
|
searchResult.data.type == "object" && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.addTrigger.aria")}
|
aria-label={t("itemMenu.addTrigger.aria")}
|
||||||
onClick={addTrigger}
|
onClick={addTrigger}
|
||||||
>
|
>
|
||||||
|
<BsFillLightningFill className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{config?.semantic_search?.enabled &&
|
||||||
|
searchResult.data.type == "object" && (
|
||||||
|
<MenuItem
|
||||||
|
aria-label={t("itemMenu.findSimilar.aria")}
|
||||||
|
onClick={findSimilar}
|
||||||
|
>
|
||||||
|
<MdImageSearch className="mr-2 size-4" />
|
||||||
|
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
>
|
>
|
||||||
|
<LuTrash2 className="mr-2 size-4" />
|
||||||
<span>{t("button.delete", { ns: "common" })}</span>
|
<span>{t("button.delete", { ns: "common" })}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@ -21,6 +20,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
|
import { LuPlus, LuScanFace } from "react-icons/lu";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import React, { ReactNode, useMemo, useState } from "react";
|
import React, { ReactNode, useMemo, useState } from "react";
|
||||||
@ -89,26 +89,27 @@ export default function FaceSelectionDialog({
|
|||||||
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
|
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
||||||
isMobile && "gap-2 pb-4",
|
isMobile && "gap-2 pb-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<SelectorItem
|
||||||
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
|
onClick={() => setNewFace(true)}
|
||||||
|
>
|
||||||
|
<LuPlus />
|
||||||
|
{t("createFaceLibrary.new")}
|
||||||
|
</SelectorItem>
|
||||||
{faceNames.sort().map((faceName) => (
|
{faceNames.sort().map((faceName) => (
|
||||||
<SelectorItem
|
<SelectorItem
|
||||||
key={faceName}
|
key={faceName}
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
onClick={() => onTrainAttempt(faceName)}
|
onClick={() => onTrainAttempt(faceName)}
|
||||||
>
|
>
|
||||||
|
<LuScanFace />
|
||||||
{faceName}
|
{faceName}
|
||||||
</SelectorItem>
|
</SelectorItem>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<SelectorItem
|
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
|
||||||
onClick={() => setNewFace(true)}
|
|
||||||
>
|
|
||||||
{t("createFaceLibrary.new")}
|
|
||||||
</SelectorItem>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectorContent>
|
</SelectorContent>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|||||||
@ -171,18 +171,6 @@ export default function ImagePicker({
|
|||||||
alt={selectedImage?.label || "Selected image"}
|
alt={selectedImage?.label || "Selected image"}
|
||||||
className="size-16 rounded object-cover"
|
className="size-16 rounded object-cover"
|
||||||
onLoad={() => handleImageLoad(selectedImageId || "")}
|
onLoad={() => handleImageLoad(selectedImageId || "")}
|
||||||
onError={(e) => {
|
|
||||||
// If trigger thumbnail fails to load, fall back to event thumbnail
|
|
||||||
if (!selectedImage) {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
if (
|
|
||||||
target.src.includes("clips/triggers") &&
|
|
||||||
selectedImageId
|
|
||||||
) {
|
|
||||||
target.src = `${apiHost}api/events/${selectedImageId}/thumbnail.webp`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
@ -90,16 +89,9 @@ export function TrackingDetails({
|
|||||||
}, [manualOverride, currentTime, annotationOffset]);
|
}, [manualOverride, currentTime, annotationOffset]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
const [_selectedZone, setSelectedZone] = useState("");
|
const [_selectedZone, setSelectedZone] = useState("");
|
||||||
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
||||||
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
|
||||||
const [lineBottomOffsetPx, setLineBottomOffsetPx] = useState<number>(32);
|
|
||||||
const [lineTopOffsetPx, setLineTopOffsetPx] = useState<number>(8);
|
|
||||||
const [blueLineHeightPx, setBlueLineHeightPx] = useState<number>(0);
|
|
||||||
|
|
||||||
const [timelineSize] = useResizeObserver(timelineContainerRef);
|
|
||||||
|
|
||||||
const aspectRatio = useMemo(() => {
|
const aspectRatio = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@ -233,6 +225,7 @@ export function TrackingDetails({
|
|||||||
if (effectiveTime === undefined || event.start_time === undefined) {
|
if (effectiveTime === undefined || event.start_time === undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If an event has not ended yet, fall back to last timestamp in eventSequence
|
// If an event has not ended yet, fall back to last timestamp in eventSequence
|
||||||
let eventEnd = event.end_time;
|
let eventEnd = event.end_time;
|
||||||
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
|
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
|
||||||
@ -245,58 +238,57 @@ export function TrackingDetails({
|
|||||||
if (eventEnd == null) {
|
if (eventEnd == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
|
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
|
||||||
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
|
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
|
||||||
|
|
||||||
// Dynamically compute pixel offsets so the timeline line starts at the
|
// Calculate how far down the blue line should extend based on effectiveTime
|
||||||
// first row midpoint and ends at the last row midpoint. For accuracy,
|
const calculateLineHeight = useCallback(() => {
|
||||||
// measure the center Y of each lifecycle row and interpolate the current
|
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
|
||||||
// effective time into a pixel position; then set the blue line height
|
return 0;
|
||||||
// so it reaches the center dot at the same time the dot becomes active.
|
}
|
||||||
useEffect(() => {
|
|
||||||
if (!timelineContainerRef.current || !eventSequence) return;
|
|
||||||
|
|
||||||
const containerRect = timelineContainerRef.current.getBoundingClientRect();
|
const currentTime = effectiveTime ?? 0;
|
||||||
const validRefs = rowRefs.current.filter((r) => r !== null);
|
|
||||||
if (validRefs.length === 0) return;
|
|
||||||
|
|
||||||
const centers = validRefs.map((n) => {
|
// Find which events have been passed
|
||||||
const r = n.getBoundingClientRect();
|
let lastPassedIndex = -1;
|
||||||
return r.top + r.height / 2 - containerRect.top;
|
for (let i = 0; i < eventSequence.length; i++) {
|
||||||
});
|
if (currentTime >= (eventSequence[i].timestamp ?? 0)) {
|
||||||
|
lastPassedIndex = i;
|
||||||
const topOffset = Math.max(0, centers[0]);
|
} else {
|
||||||
const bottomOffset = Math.max(
|
break;
|
||||||
0,
|
|
||||||
containerRect.height - centers[centers.length - 1],
|
|
||||||
);
|
|
||||||
|
|
||||||
setLineTopOffsetPx(Math.round(topOffset));
|
|
||||||
setLineBottomOffsetPx(Math.round(bottomOffset));
|
|
||||||
|
|
||||||
const eff = effectiveTime ?? 0;
|
|
||||||
const timestamps = eventSequence.map((s) => s.timestamp ?? 0);
|
|
||||||
|
|
||||||
let pixelPos = centers[0];
|
|
||||||
if (eff <= timestamps[0]) {
|
|
||||||
pixelPos = centers[0];
|
|
||||||
} else if (eff >= timestamps[timestamps.length - 1]) {
|
|
||||||
pixelPos = centers[centers.length - 1];
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < timestamps.length - 1; i++) {
|
|
||||||
const t1 = timestamps[i];
|
|
||||||
const t2 = timestamps[i + 1];
|
|
||||||
if (eff >= t1 && eff <= t2) {
|
|
||||||
const ratio = t2 > t1 ? (eff - t1) / (t2 - t1) : 0;
|
|
||||||
pixelPos = centers[i] + ratio * (centers[i + 1] - centers[i]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bluePx = Math.round(Math.max(0, pixelPos - topOffset));
|
// No events passed yet
|
||||||
setBlueLineHeightPx(bluePx);
|
if (lastPassedIndex < 0) return 0;
|
||||||
}, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]);
|
|
||||||
|
// All events passed
|
||||||
|
if (lastPassedIndex >= eventSequence.length - 1) return 100;
|
||||||
|
|
||||||
|
// Calculate percentage based on item position, not time
|
||||||
|
// Each item occupies an equal visual space regardless of time gaps
|
||||||
|
const itemPercentage = 100 / (eventSequence.length - 1);
|
||||||
|
|
||||||
|
// Find progress between current and next event for smooth transition
|
||||||
|
const currentEvent = eventSequence[lastPassedIndex];
|
||||||
|
const nextEvent = eventSequence[lastPassedIndex + 1];
|
||||||
|
const currentTimestamp = currentEvent.timestamp ?? 0;
|
||||||
|
const nextTimestamp = nextEvent.timestamp ?? 0;
|
||||||
|
|
||||||
|
// Calculate interpolation between the two events
|
||||||
|
const timeBetween = nextTimestamp - currentTimestamp;
|
||||||
|
const timeElapsed = currentTime - currentTimestamp;
|
||||||
|
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
|
||||||
|
|
||||||
|
// Base position plus interpolated progress to next item
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
|
||||||
|
);
|
||||||
|
}, [eventSequence, effectiveTime, isWithinEventRange]);
|
||||||
|
|
||||||
|
const blueLineHeight = calculateLineHeight();
|
||||||
|
|
||||||
const videoSource = useMemo(() => {
|
const videoSource = useMemo(() => {
|
||||||
// event.start_time and event.end_time are in DETECT stream time
|
// event.start_time and event.end_time are in DETECT stream time
|
||||||
@ -553,21 +545,12 @@ export function TrackingDetails({
|
|||||||
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="-pb-2 relative mx-0">
|
||||||
className="-pb-2 relative mx-0"
|
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
||||||
ref={timelineContainerRef}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute -top-2 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground"
|
|
||||||
style={{ bottom: lineBottomOffsetPx }}
|
|
||||||
/>
|
|
||||||
{isWithinEventRange && (
|
{isWithinEventRange && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-6 z-[5] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
||||||
style={{
|
style={{ height: `${blueLineHeight}%` }}
|
||||||
top: `${lineTopOffsetPx}px`,
|
|
||||||
height: `${blueLineHeightPx}px`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -620,26 +603,20 @@ export function TrackingDetails({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<LifecycleIconRow
|
||||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||||
ref={(el) => {
|
item={item}
|
||||||
rowRefs.current[idx] = el;
|
isActive={isActive}
|
||||||
}}
|
formattedEventTimestamp={formattedEventTimestamp}
|
||||||
>
|
ratio={ratio}
|
||||||
<LifecycleIconRow
|
areaPx={areaPx}
|
||||||
item={item}
|
areaPct={areaPct}
|
||||||
isActive={isActive}
|
onClick={() => handleLifecycleClick(item)}
|
||||||
formattedEventTimestamp={formattedEventTimestamp}
|
setSelectedZone={setSelectedZone}
|
||||||
ratio={ratio}
|
getZoneColor={getZoneColor}
|
||||||
areaPx={areaPx}
|
effectiveTime={effectiveTime}
|
||||||
areaPct={areaPct}
|
isTimelineActive={isWithinEventRange}
|
||||||
onClick={() => handleLifecycleClick(item)}
|
/>
|
||||||
setSelectedZone={setSelectedZone}
|
|
||||||
getZoneColor={getZoneColor}
|
|
||||||
effectiveTime={effectiveTime}
|
|
||||||
isTimelineActive={isWithinEventRange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -57,6 +57,7 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
LuFolderCheck,
|
LuFolderCheck,
|
||||||
LuImagePlus,
|
LuImagePlus,
|
||||||
|
LuPencil,
|
||||||
LuRefreshCw,
|
LuRefreshCw,
|
||||||
LuScanFace,
|
LuScanFace,
|
||||||
LuTrash2,
|
LuTrash2,
|
||||||
@ -579,7 +580,9 @@ function LibrarySelector({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setRenameFace(face);
|
setRenameFace(face);
|
||||||
}}
|
}}
|
||||||
></Button>
|
>
|
||||||
|
<LuPencil className="size-4 text-primary" />
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent>{t("button.renameFace")}</TooltipContent>
|
<TooltipContent>{t("button.renameFace")}</TooltipContent>
|
||||||
@ -595,7 +598,9 @@ function LibrarySelector({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setConfirmDelete(face);
|
setConfirmDelete(face);
|
||||||
}}
|
}}
|
||||||
></Button>
|
>
|
||||||
|
<LuTrash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent>{t("button.deleteFace")}</TooltipContent>
|
<TooltipContent>{t("button.deleteFace")}</TooltipContent>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaFolderPlus } from "react-icons/fa";
|
import { FaFolderPlus } from "react-icons/fa";
|
||||||
import { MdModelTraining } from "react-icons/md";
|
import { MdModelTraining } from "react-icons/md";
|
||||||
|
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
@ -351,9 +352,11 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={handleEditClick}>
|
<DropdownMenuItem onClick={handleEditClick}>
|
||||||
|
<LuPencil className="mr-2 size-4" />
|
||||||
<span>{t("button.edit", { ns: "common" })}</span>
|
<span>{t("button.edit", { ns: "common" })}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||||
|
<LuTrash2 className="mr-2 size-4" />
|
||||||
<span>{t("button.delete", { ns: "common" })}</span>
|
<span>{t("button.delete", { ns: "common" })}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@ -201,17 +201,12 @@ export default function TriggerView({
|
|||||||
.then((configResponse) => {
|
.then((configResponse) => {
|
||||||
if (configResponse.status === 200) {
|
if (configResponse.status === 200) {
|
||||||
updateConfig();
|
updateConfig();
|
||||||
const displayName =
|
|
||||||
friendly_name && friendly_name !== ""
|
|
||||||
? `${friendly_name} (${name})`
|
|
||||||
: name;
|
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
isEdit
|
isEdit
|
||||||
? "triggers.toast.success.updateTrigger"
|
? "triggers.toast.success.updateTrigger"
|
||||||
: "triggers.toast.success.createTrigger",
|
: "triggers.toast.success.createTrigger",
|
||||||
{ name: displayName },
|
{ name },
|
||||||
),
|
),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -356,19 +351,8 @@ export default function TriggerView({
|
|||||||
.then((configResponse) => {
|
.then((configResponse) => {
|
||||||
if (configResponse.status === 200) {
|
if (configResponse.status === 200) {
|
||||||
updateConfig();
|
updateConfig();
|
||||||
const friendly =
|
|
||||||
config?.cameras?.[selectedCamera]?.semantic_search
|
|
||||||
?.triggers?.[name]?.friendly_name;
|
|
||||||
|
|
||||||
const displayName =
|
|
||||||
friendly && friendly !== ""
|
|
||||||
? `${friendly} (${name})`
|
|
||||||
: name;
|
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("triggers.toast.success.deleteTrigger", {
|
t("triggers.toast.success.deleteTrigger", { name }),
|
||||||
name: displayName,
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
},
|
},
|
||||||
@ -397,7 +381,7 @@ export default function TriggerView({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t, updateConfig, selectedCamera, setUnsavedChanges, config],
|
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -859,14 +843,7 @@ export default function TriggerView({
|
|||||||
/>
|
/>
|
||||||
<DeleteTriggerDialog
|
<DeleteTriggerDialog
|
||||||
show={showDelete}
|
show={showDelete}
|
||||||
triggerName={
|
triggerName={selectedTrigger?.name ?? ""}
|
||||||
selectedTrigger
|
|
||||||
? selectedTrigger.friendly_name &&
|
|
||||||
selectedTrigger.friendly_name !== ""
|
|
||||||
? `${selectedTrigger.friendly_name} (${selectedTrigger.name})`
|
|
||||||
: selectedTrigger.name
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowDelete(false);
|
setShowDelete(false);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user