Review summary popup (#20383)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* Add title to prompt

* Add popup for genai review summary

* Add animation
This commit is contained in:
Nicolas Mowen 2025-10-07 18:11:04 -06:00 committed by GitHub
parent 33f0c23389
commit 7a8f93e9f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 150 additions and 1 deletions

View File

@ -4,6 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field
class ReviewMetadata(BaseModel):
model_config = ConfigDict(extra="ignore", protected_namespaces=())
title: str = Field(description="A concise title for the activity.")
scene: str = Field(
description="A comprehensive description of the setting and entities, including relevant context and plausible inferences if supported by visual evidence."
)

View File

@ -87,6 +87,7 @@ When forming your description:
- **Weigh all evidence holistically**: Start by checking if the activity matches the normal patterns above. If it does, assign Level 0. Only consider Level 1 if the activity clearly deviates from normal patterns or shows genuine security concerns that warrant attention.
Your response MUST be a flat JSON object with:
- `title` (string): A concise, one-sentence title that captures the main activity. Include any verified recognized objects (from the "Verified recognized objects" list below) and key detected objects. Examples: "Joe walking dog in backyard", "Unknown person testing car doors at night".
- `scene` (string): A narrative description of what happens across the sequence from start to finish. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign.
- `confidence` (float): 0-1 confidence in your analysis. Higher confidence when objects/actions are clearly visible and context is unambiguous. Lower confidence when the sequence is unclear, objects are partially obscured, or context is ambiguous.
- `potential_threat_level` (integer): 0, 1, or 2 as defined below. Your threat level must be consistent with your scene description and the guidance above.
@ -167,7 +168,7 @@ Sequence details:
timeline_summary_prompt = f"""
You are a security officer.
Time range: {time_range}.
Input: JSON list with "scene", "confidence", "potential_threat_level" (1-2), "other_concerns".
Input: JSON list with "title", "scene", "confidence", "potential_threat_level" (1-2), "other_concerns".
Task: Write a concise, human-presentable security report in markdown format.

View File

@ -0,0 +1,117 @@
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { ReviewSegment, ThreatLevel } from "@/types/review";
import { useEffect, useMemo, useState } from "react";
import { isDesktop } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { MdAutoAwesome } from "react-icons/md";
type GenAISummaryChipProps = {
review?: ReviewSegment;
onClick: () => void;
};
export function GenAISummaryChip({ review, onClick }: GenAISummaryChipProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(review?.data?.metadata != undefined);
}, [review]);
return (
<div
className={cn(
"absolute left-1/2 top-8 z-30 flex max-w-[90vw] -translate-x-[50%] cursor-pointer select-none items-center gap-2 rounded-full bg-card p-2 text-sm transition-all duration-500",
isVisible ? "translate-y-0 opacity-100" : "-translate-y-4 opacity-0",
)}
onClick={onClick}
>
<MdAutoAwesome className="shrink-0" />
<span className="truncate">{review?.data.metadata?.title}</span>
</div>
);
}
type GenAISummaryDialogProps = {
review?: ReviewSegment;
onOpen?: (open: boolean) => void;
};
export function GenAISummaryDialog({
review,
onOpen,
}: GenAISummaryDialogProps) {
const { t } = useTranslation(["views/explore"]);
// data
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
const aiThreatLevel = useMemo(() => {
if (
!aiAnalysis ||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
) {
return "None";
}
let concerns = "";
switch (aiAnalysis.potential_threat_level) {
case ThreatLevel.SUSPICIOUS:
concerns = `${t("suspiciousActivity", { ns: "views/events" })}\n`;
break;
case ThreatLevel.DANGER:
concerns = `${t("threateningActivity", { ns: "views/events" })}\n`;
break;
}
(aiAnalysis.other_concerns ?? []).forEach((c) => {
concerns += `${c}\n`;
});
return concerns || "None";
}, [aiAnalysis, t]);
// layout
const [open, setOpen] = useState(false);
const Overlay = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
const Content = isDesktop ? DialogContent : DrawerContent;
useEffect(() => {
if (onOpen) {
onOpen(open);
}
}, [open, onOpen]);
if (!aiAnalysis) {
return null;
}
return (
<Overlay open={open} onOpenChange={setOpen}>
<Trigger asChild>
<GenAISummaryChip review={review} onClick={() => setOpen(true)} />
</Trigger>
<Content
className={cn(
"gap-2",
isDesktop
? "sm:rounded-lg md:rounded-2xl"
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl",
)}
>
{t("aiAnalysis.title")}
<div className="text-sm text-primary/40">
{t("details.description.label")}
</div>
<div className="text-sm">{aiAnalysis.scene}</div>
<div className="text-sm text-primary/40">
{t("details.score.label")}
</div>
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
<div className="text-sm text-primary/40">{t("concerns.label")}</div>
<div className="text-sm">{aiThreatLevel}</div>
</Content>
</Overlay>
);
}

View File

@ -19,6 +19,7 @@ export type ReviewData = {
significant_motion_areas: number[];
zones: string[];
metadata?: {
title: string;
scene: string;
confidence: number;
potential_threat_level?: number;

View File

@ -66,6 +66,7 @@ import {
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
type RecordingViewProps = {
startCamera: string;
@ -458,6 +459,29 @@ export function RecordingView({
[visiblePreviewObserver.current],
);
const activeReviewItem = useMemo(() => {
if (!config?.cameras?.[mainCamera].review.genai?.enabled_in_config) {
return undefined;
}
return mainCameraReviewItems.find(
(rev) =>
rev.start_time < currentTime &&
rev.end_time &&
currentTime < rev.end_time,
);
}, [config, currentTime, mainCameraReviewItems, mainCamera]);
const onAnalysisOpen = useCallback(
(open: boolean) => {
if (open) {
mainControllerRef.current?.pause();
} else {
mainControllerRef.current?.play();
}
},
[mainControllerRef],
);
return (
<div ref={contentRef} className="flex size-full flex-col pt-2">
<Toaster closeButton={true} />
@ -654,6 +678,11 @@ export function RecordingView({
: Math.max(1, getCameraAspect(mainCamera) ?? 0),
}}
>
<GenAISummaryDialog
review={activeReviewItem}
onOpen={onAnalysisOpen}
/>
<DynamicVideoPlayer
className={grow}
camera={mainCamera}