Compare commits

..

4 Commits

Author SHA1 Message Date
Nicolas Mowen
cd2a30de0f
Merge 4bc7462012 into 213a1fbd00 2025-11-20 13:58:23 +00:00
Nicolas Mowen
4bc7462012 Improve enrichments grouping 2025-11-20 06:58:19 -07:00
Josh Hawkins
a29e41617e add helper for swr keys and ensure search is updated after frigate+ submission
swr keys may be strings OR arrays. Previous logic only matched string keys, so explore grid (which uses array keys) was not updated after mutations
2025-11-20 06:25:00 -06:00
Josh Hawkins
7ebb700ce6 restore frigate+ submission inside FrigatePlusDialog with legacy behavior
when the snapshot tab was refacatored to remove the buttons, they were never re-added to FrigatePlusDialog
2025-11-20 06:23:37 -06:00
5 changed files with 296 additions and 87 deletions

View File

@ -362,7 +362,7 @@ def stats_snapshot(
stats["embeddings"]["review_description_speed"] = round( stats["embeddings"]["review_description_speed"] = round(
embeddings_metrics.review_desc_speed.value * 1000, 2 embeddings_metrics.review_desc_speed.value * 1000, 2
) )
stats["embeddings"]["review_descriptions"] = round( stats["embeddings"]["review_description_events_per_second"] = round(
embeddings_metrics.review_desc_dps.value, 2 embeddings_metrics.review_desc_dps.value, 2
) )
@ -370,7 +370,7 @@ def stats_snapshot(
stats["embeddings"]["object_description_speed"] = round( stats["embeddings"]["object_description_speed"] = round(
embeddings_metrics.object_desc_speed.value * 1000, 2 embeddings_metrics.object_desc_speed.value * 1000, 2
) )
stats["embeddings"]["object_descriptions"] = round( stats["embeddings"]["object_description_events_per_second"] = round(
embeddings_metrics.object_desc_dps.value, 2 embeddings_metrics.object_desc_dps.value, 2
) )
@ -378,7 +378,7 @@ def stats_snapshot(
stats["embeddings"][f"{key}_classification_speed"] = round( stats["embeddings"][f"{key}_classification_speed"] = round(
embeddings_metrics.classification_speeds[key].value * 1000, 2 embeddings_metrics.classification_speeds[key].value * 1000, 2
) )
stats["embeddings"][f"{key}_classification"] = round( stats["embeddings"][f"{key}_classification_events_per_second"] = round(
embeddings_metrics.classification_cps[key].value, 2 embeddings_metrics.classification_cps[key].value, 2
) )

View File

@ -169,6 +169,7 @@
"enrichments": { "enrichments": {
"title": "Enrichments", "title": "Enrichments",
"infPerSecond": "Inferences Per Second", "infPerSecond": "Inferences Per Second",
"averageInf": "Average Inference Time",
"embeddings": { "embeddings": {
"image_embedding": "Image Embedding", "image_embedding": "Image Embedding",
"text_embedding": "Text Embedding", "text_embedding": "Text Embedding",
@ -180,7 +181,13 @@
"plate_recognition_speed": "Plate Recognition Speed", "plate_recognition_speed": "Plate Recognition Speed",
"text_embedding_speed": "Text Embedding Speed", "text_embedding_speed": "Text Embedding Speed",
"yolov9_plate_detection_speed": "YOLOv9 Plate Detection Speed", "yolov9_plate_detection_speed": "YOLOv9 Plate Detection Speed",
"yolov9_plate_detection": "YOLOv9 Plate Detection" "yolov9_plate_detection": "YOLOv9 Plate Detection",
"review_description": "Review Description",
"review_description_speed": "Review Description Speed",
"review_description_events_per_second": "Review Description",
"object_description": "Object Description",
"object_description_speed": "Object Description Speed",
"object_description_events_per_second": "Object Description"
} }
} }
} }

View File

@ -807,6 +807,15 @@ function ObjectDetailsTab({
} }
}, [search]); }, [search]);
const isEventsKey = useCallback((key: unknown): boolean => {
const candidate = Array.isArray(key) ? key[0] : key;
const EVENTS_KEY_PATTERNS = ["events", "events/search", "events/explore"];
return (
typeof candidate === "string" &&
EVENTS_KEY_PATTERNS.some((p) => candidate.includes(p))
);
}, []);
const updateDescription = useCallback(() => { const updateDescription = useCallback(() => {
if (!search) { if (!search) {
return; return;
@ -821,11 +830,7 @@ function ObjectDetailsTab({
}); });
} }
mutate( mutate(
(key) => (key) => isEventsKey(key),
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => (currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) => mapSearchResults(currentData, (event) =>
event.id === search.id event.id === search.id
@ -838,6 +843,7 @@ function ObjectDetailsTab({
revalidate: false, revalidate: false,
}, },
); );
setSearch({ ...search, data: { ...search.data, description: desc } });
}) })
.catch((error) => { .catch((error) => {
const errorMessage = const errorMessage =
@ -854,7 +860,7 @@ function ObjectDetailsTab({
); );
setDesc(search.data.description); setDesc(search.data.description);
}); });
}, [desc, search, mutate, t, mapSearchResults]); }, [desc, search, mutate, t, mapSearchResults, isEventsKey, setSearch]);
const regenerateDescription = useCallback( const regenerateDescription = useCallback(
(source: "snapshot" | "thumbnails") => { (source: "snapshot" | "thumbnails") => {
@ -921,11 +927,7 @@ function ObjectDetailsTab({
}); });
mutate( mutate(
(key) => (key) => isEventsKey(key),
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => (currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) => mapSearchResults(currentData, (event) =>
event.id === search.id event.id === search.id
@ -972,7 +974,7 @@ function ObjectDetailsTab({
); );
}); });
}, },
[search, apiHost, mutate, setSearch, t, mapSearchResults], [search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
); );
// recognized plate // recognized plate
@ -996,11 +998,7 @@ function ObjectDetailsTab({
}); });
mutate( mutate(
(key) => (key) => isEventsKey(key),
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => (currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) => mapSearchResults(currentData, (event) =>
event.id === search.id event.id === search.id
@ -1047,7 +1045,7 @@ function ObjectDetailsTab({
); );
}); });
}, },
[search, apiHost, mutate, setSearch, t, mapSearchResults], [search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
); );
// speech transcription // speech transcription
@ -1103,12 +1101,9 @@ function ObjectDetailsTab({
}); });
setState("submitted"); setState("submitted");
setSearch({ ...search, plus_id: "new_upload" });
mutate( mutate(
(key) => (key) => isEventsKey(key),
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => (currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) => mapSearchResults(currentData, (event) =>
event.id === search.id event.id === search.id
@ -1122,7 +1117,7 @@ function ObjectDetailsTab({
}, },
); );
}, },
[search, mutate, mapSearchResults], [search, mutate, mapSearchResults, setSearch, isEventsKey],
); );
const popoverContainerRef = useRef<HTMLDivElement | null>(null); const popoverContainerRef = useRef<HTMLDivElement | null>(null);

View File

@ -6,31 +6,68 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile, isSafari } from "react-device-detect";
import { ObjectSnapshotTab } from "../detail/SearchDetailDialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useCallback, useEffect, useState } from "react";
import axios from "axios";
import { useTranslation, Trans } from "react-i18next";
import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FaCheckCircle } from "react-icons/fa";
import { Card, CardContent } from "@/components/ui/card";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { baseUrl } from "@/api/baseUrl";
import { getTranslatedLabel } from "@/utils/i18n";
import useImageLoaded from "@/hooks/use-image-loaded";
type FrigatePlusDialogProps = { export type FrigatePlusDialogProps = {
upload?: Event; upload?: Event;
dialog?: boolean; dialog?: boolean;
onClose: () => void; onClose: () => void;
onEventUploaded: () => void; onEventUploaded: () => void;
}; };
export function FrigatePlusDialog({ export function FrigatePlusDialog({
upload, upload,
dialog = true, dialog = true,
onClose, onClose,
onEventUploaded, onEventUploaded,
}: FrigatePlusDialogProps) { }: FrigatePlusDialogProps) {
if (!upload) { const { t, i18n } = useTranslation(["components/dialog"]);
return;
} type SubmissionState = "reviewing" | "uploading" | "submitted";
if (dialog) { const [state, setState] = useState<SubmissionState>(
upload?.plus_id ? "submitted" : "reviewing",
);
useEffect(() => {
setState(upload?.plus_id ? "submitted" : "reviewing");
}, [upload?.plus_id]);
const onSubmitToPlus = useCallback(
async (falsePositive: boolean) => {
if (!upload) return;
falsePositive
? axios.put(`events/${upload.id}/false_positive`)
: axios.post(`events/${upload.id}/plus`, { include_annotation: 1 });
setState("submitted");
onEventUploaded();
},
[upload, onEventUploaded],
);
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const showCard =
!!upload &&
upload.data.type === "object" &&
upload.plus_id !== "not_enabled" &&
upload.end_time &&
upload.label !== "on_demand";
if (!dialog || !upload) return null;
return ( return (
<Dialog <Dialog open={true} onOpenChange={(open) => (!open ? onClose() : null)}>
open={upload != undefined}
onOpenChange={(open) => (!open ? onClose() : null)}
>
<DialogContent <DialogContent
className={cn( className={cn(
"scrollbar-container overflow-y-auto", "scrollbar-container overflow-y-auto",
@ -45,12 +82,123 @@ export function FrigatePlusDialog({
Submit this snapshot to Frigate+ Submit this snapshot to Frigate+
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ObjectSnapshotTab
search={upload} <div className="relative size-full">
onEventUploaded={onEventUploaded} <ImageLoadingIndicator
className="absolute inset-0 aspect-video min-h-[60dvh] w-full"
imgLoaded={imgLoaded}
/> />
<div className={imgLoaded ? "visible" : "invisible"}>
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
<div className="flex flex-col space-y-3">
<TransformComponent
wrapperStyle={{ width: "100%", height: "100%" }}
contentStyle={{
position: "relative",
width: "100%",
height: "100%",
}}
>
{upload.id && (
<div className="relative mx-auto">
<img
ref={imgRef}
className="mx-auto max-h-[60dvh] rounded-lg bg-black object-contain"
src={`${baseUrl}api/events/${upload.id}/snapshot.jpg`}
alt={`${upload.label}`}
loading={isSafari ? "eager" : "lazy"}
onLoad={onImgLoad}
/>
</div>
)}
</TransformComponent>
{showCard && (
<Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className="flex flex-col space-y-3">
<div className="text-lg leading-none">
{t("explore.plus.submitToPlus.label")}
</div>
<div className="text-sm text-muted-foreground">
{t("explore.plus.submitToPlus.desc")}
</div>
</div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
{state === "reviewing" && (
<>
<div>
{i18n.language === "en" ? (
/^[aeiou]/i.test(upload.label || "") ? (
<Trans
ns="components/dialog"
values={{ label: upload.label }}
>
explore.plus.review.question.ask_an
</Trans>
) : (
<Trans
ns="components/dialog"
values={{ label: upload.label }}
>
explore.plus.review.question.ask_a
</Trans>
)
) : (
<Trans
ns="components/dialog"
values={{
untranslatedLabel: upload.label,
translatedLabel: getTranslatedLabel(
upload.label,
),
}}
>
explore.plus.review.question.ask_full
</Trans>
)}
</div>
<div className="flex w-full flex-row gap-2">
<Button
className="flex-1 bg-success"
aria-label={t("button.yes", { ns: "common" })}
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
{t("button.no", { ns: "common" })}
</Button>
</div>
</>
)}
{state === "uploading" && <ActivityIndicator />}
{state === "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")}
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
</TransformWrapper>
</div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}
} }

View File

@ -67,13 +67,14 @@ export default function EnrichmentMetrics({
// features stats // features stats
const embeddingInferenceTimeSeries = useMemo(() => { const groupedEnrichmentMetrics = useMemo(() => {
if (!statsHistory) { if (!statsHistory) {
return []; return [];
} }
const series: { const series: {
[key: string]: { [key: string]: {
rawKey: string;
name: string; name: string;
metrics: Threshold; metrics: Threshold;
data: { x: number; y: number }[]; data: { x: number; y: number }[];
@ -90,6 +91,7 @@ export default function EnrichmentMetrics({
if (!(key in series)) { if (!(key in series)) {
series[key] = { series[key] = {
rawKey,
name: t("enrichments.embeddings." + rawKey), name: t("enrichments.embeddings." + rawKey),
metrics: getThreshold(rawKey), metrics: getThreshold(rawKey),
data: [], data: [],
@ -99,7 +101,57 @@ export default function EnrichmentMetrics({
series[key].data.push({ x: statsIdx + 1, y: stat }); series[key].data.push({ x: statsIdx + 1, y: stat });
}); });
}); });
return Object.values(series);
// Group series by category (extract base name from raw key)
const grouped: {
[category: string]: {
categoryName: string;
speedSeries?: {
name: string;
metrics: Threshold;
data: { x: number; y: number }[];
};
eventsSeries?: {
name: string;
metrics: Threshold;
data: { x: number; y: number }[];
};
};
} = {};
Object.values(series).forEach((s) => {
// Extract base category name from raw key
// All metrics follow the pattern: {base}_speed and {base}_events_per_second
let categoryKey = s.rawKey;
let isSpeed = false;
if (s.rawKey.endsWith("_speed")) {
categoryKey = s.rawKey.replace("_speed", "");
isSpeed = true;
} else if (s.rawKey.endsWith("_events_per_second")) {
categoryKey = s.rawKey.replace("_events_per_second", "");
isSpeed = false;
}
// Get translated category name
const categoryName = t("enrichments.embeddings." + categoryKey);
if (!(categoryKey in grouped)) {
grouped[categoryKey] = {
categoryName,
speedSeries: undefined,
eventsSeries: undefined,
};
}
if (isSpeed) {
grouped[categoryKey].speedSeries = s;
} else {
grouped[categoryKey].eventsSeries = s;
}
});
return Object.values(grouped);
}, [statsHistory, t, getThreshold]); }, [statsHistory, t, getThreshold]);
return ( return (
@ -110,36 +162,43 @@ export default function EnrichmentMetrics({
</div> </div>
<div <div
className={cn( className={cn(
"mt-4 grid w-full grid-cols-1 gap-2 sm:grid-cols-3", "mt-4 grid w-full grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4",
embeddingInferenceTimeSeries && "sm:grid-cols-4",
)} )}
> >
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<> <>
{embeddingInferenceTimeSeries.map((series) => ( {groupedEnrichmentMetrics.map((group) => (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl"> <div
<div className="mb-5 smart-capitalize">{series.name}</div> key={group.categoryName}
{series.name.endsWith("Speed") ? ( className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl"
>
<div className="mb-5 smart-capitalize">
{group.categoryName}
</div>
<div className="space-y-4">
{group.speedSeries && (
<ThresholdBarGraph <ThresholdBarGraph
key={series.name} key={`${group.categoryName}-speed`}
graphId={`${series.name}-inference`} graphId={`${group.categoryName}-inference`}
name={series.name} name={t("enrichments.averageInf")}
unit="ms" unit="ms"
threshold={series.metrics} threshold={group.speedSeries.metrics}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[group.speedSeries]}
/> />
) : ( )}
{group.eventsSeries && (
<EventsPerSecondsLineGraph <EventsPerSecondsLineGraph
key={series.name} key={`${group.categoryName}-events`}
graphId={`${series.name}-fps`} graphId={`${group.categoryName}-fps`}
unit="" unit=""
name={t("enrichments.infPerSecond")} name={t("enrichments.infPerSecond")}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[group.eventsSeries]}
/> />
)} )}
</div> </div>
</div>
))} ))}
</> </>
) : ( ) : (