Review improvements (#11879)

* Update segment even when number of active objects is the same

* add score to frigate+ chip

* Add support for selecting zones

* Add api support for filtering on zones

* Adjust UI

* Update filtering logic

* Clean up
This commit is contained in:
Nicolas Mowen 2024-06-11 09:19:17 -05:00 committed by GitHub
parent b3eab17f2c
commit c9d253a320
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 200 additions and 17 deletions

View File

@ -450,6 +450,7 @@ Reviews from the database. Accepts the following query string parameters:
| `after` | int | Epoch time |
| `cameras` | str | , separated list of cameras |
| `labels` | str | , separated list of labels |
| `zones` | str | , separated list of zones |
| `reviewed` | int | Include items that have been reviewed (0 or 1) |
| `limit` | int | Limit the number of events returned |
| `severity` | str | Limit items to severity (alert, detection, significant_motion) |

View File

@ -22,6 +22,7 @@ ReviewBp = Blueprint("reviews", __name__)
def review():
cameras = request.args.get("cameras", "all")
labels = request.args.get("labels", "all")
zones = request.args.get("zones", "all")
reviewed = request.args.get("reviewed", type=int, default=0)
limit = request.args.get("limit", type=int, default=None)
severity = request.args.get("severity", None)
@ -60,6 +61,20 @@ def review():
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
if zones != "all":
# use matching so segments with multiple zones
# still match on a search where any zone matches
zone_clauses = []
filtered_zones = zones.split(",")
for zone in filtered_zones:
zone_clauses.append(
(ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*')
)
zone_clause = reduce(operator.or_, zone_clauses)
clauses.append((zone_clause))
if reviewed == 0:
clauses.append((ReviewSegment.has_been_reviewed == False))
@ -96,6 +111,7 @@ def review_summary():
cameras = request.args.get("cameras", "all")
labels = request.args.get("labels", "all")
zones = request.args.get("zones", "all")
clauses = [(ReviewSegment.start_time > day_ago)]
@ -118,6 +134,20 @@ def review_summary():
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
if zones != "all":
# use matching so segments with multiple zones
# still match on a search where any zone matches
zone_clauses = []
filtered_zones = zones.split(",")
for zone in filtered_zones:
zone_clauses.append(
(ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*')
)
zone_clause = reduce(operator.or_, zone_clauses)
clauses.append((zone_clause))
last_24 = (
ReviewSegment.select(
fn.SUM(

View File

@ -242,6 +242,8 @@ class ReviewSegmentMaintainer(threading.Thread):
active_objects = get_active_objects(frame_time, camera_config, objects)
if len(active_objects) > 0:
should_update = False
if frame_time > segment.last_update:
segment.last_update = frame_time
@ -270,12 +272,16 @@ class ReviewSegmentMaintainer(threading.Thread):
)
):
segment.severity = SeverityEnum.alert
should_update = True
# keep zones up to date
if len(object["current_zones"]) > 0:
segment.zones.update(object["current_zones"])
if len(active_objects) > segment.frame_active_count:
should_update = True
if should_update:
try:
frame_id = f"{camera_config.name}{frame_time}"
yuv_frame = self.frame_manager.get(

View File

@ -30,6 +30,7 @@ import MobileReviewSettingsDrawer, {
} from "../overlay/MobileReviewSettingsDrawer";
import useOptimisticState from "@/hooks/use-optimistic-state";
import FilterSwitch from "./FilterSwitch";
import { FilterList } from "@/types/filter";
const REVIEW_FILTERS = [
"cameras",
@ -53,7 +54,7 @@ type ReviewFilterGroupProps = {
reviewSummary?: ReviewSummary;
filter?: ReviewFilter;
motionOnly: boolean;
filterLabels?: string[];
filterList?: FilterList;
onUpdateFilter: (filter: ReviewFilter) => void;
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
};
@ -64,15 +65,15 @@ export default function ReviewFilterGroup({
reviewSummary,
filter,
motionOnly,
filterLabels,
filterList,
onUpdateFilter,
setMotionOnly,
}: ReviewFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const allLabels = useMemo<string[]>(() => {
if (filterLabels) {
return filterLabels;
if (filterList?.labels) {
return filterList.labels;
}
if (!config) {
@ -99,14 +100,43 @@ export default function ReviewFilterGroup({
});
return [...labels].sort();
}, [config, filterLabels, filter]);
}, [config, filterList, filter]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
return filterList.zones;
}
if (!config) {
return [];
}
const zones = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
cameras.forEach((camera) => {
if (camera == "birdseye") {
return;
}
const cameraConfig = config.cameras[camera];
cameraConfig.review.alerts.required_zones.forEach((zone) => {
zones.add(zone);
});
cameraConfig.review.detections.required_zones.forEach((zone) => {
zones.add(zone);
});
});
return [...zones].sort();
}, [config, filterList, filter]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
}),
[config, allLabels],
[config, allLabels, allZones],
);
const groups = useMemo(() => {
@ -189,12 +219,17 @@ export default function ReviewFilterGroup({
selectedLabels={filter?.labels}
currentSeverity={currentSeverity}
showAll={filter?.showAll == true}
allZones={filterValues.zones}
selectedZones={filter?.zones}
setShowAll={(showAll) => {
onUpdateFilter({ ...filter, showAll });
}}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
updateZoneFilter={(newZones) =>
onUpdateFilter({ ...filter, zones: newZones })
}
/>
)}
{isMobile && mobileSettingsFeatures.length > 0 && (
@ -204,6 +239,7 @@ export default function ReviewFilterGroup({
currentSeverity={currentSeverity}
reviewSummary={reviewSummary}
allLabels={allLabels}
allZones={allZones}
onUpdateFilter={onUpdateFilter}
// not applicable as exports are not used
camera=""
@ -495,21 +531,30 @@ type GeneralFilterButtonProps = {
selectedLabels: string[] | undefined;
currentSeverity?: ReviewSeverity;
showAll: boolean;
allZones: string[];
selectedZones?: string[];
setShowAll: (showAll: boolean) => void;
updateLabelFilter: (labels: string[] | undefined) => void;
updateZoneFilter: (zones: string[] | undefined) => void;
};
function GeneralFilterButton({
allLabels,
selectedLabels,
currentSeverity,
showAll,
allZones,
selectedZones,
setShowAll,
updateLabelFilter,
updateZoneFilter,
}: GeneralFilterButtonProps) {
const [open, setOpen] = useState(false);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
selectedLabels,
);
const [currentZones, setCurrentZones] = useState<string[] | undefined>(
selectedZones,
);
const trigger = (
<Button
@ -534,6 +579,11 @@ function GeneralFilterButton({
currentLabels={currentLabels}
currentSeverity={currentSeverity}
showAll={showAll}
allZones={allZones}
selectedZones={selectedZones}
currentZones={currentZones}
setCurrentZones={setCurrentZones}
updateZoneFilter={updateZoneFilter}
setShowAll={setShowAll}
updateLabelFilter={updateLabelFilter}
setCurrentLabels={setCurrentLabels}
@ -584,9 +634,14 @@ type GeneralFilterContentProps = {
currentLabels: string[] | undefined;
currentSeverity?: ReviewSeverity;
showAll?: boolean;
allZones?: string[];
selectedZones?: string[];
currentZones?: string[];
setShowAll?: (showAll: boolean) => void;
updateLabelFilter: (labels: string[] | undefined) => void;
setCurrentLabels: (labels: string[] | undefined) => void;
updateZoneFilter?: (zones: string[] | undefined) => void;
setCurrentZones?: (zones: string[] | undefined) => void;
onClose: () => void;
};
export function GeneralFilterContent({
@ -595,9 +650,14 @@ export function GeneralFilterContent({
currentLabels,
currentSeverity,
showAll,
allZones,
selectedZones,
currentZones,
setShowAll,
updateLabelFilter,
setCurrentLabels,
updateZoneFilter,
setCurrentZones,
onClose,
}: GeneralFilterContentProps) {
return (
@ -622,7 +682,7 @@ export function GeneralFilterContent({
<DropdownMenuSeparator />
</div>
)}
<div className="my-2.5 flex items-center justify-between">
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
@ -640,7 +700,6 @@ export function GeneralFilterContent({
}}
/>
</div>
<DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5">
{allLabels.map((item) => (
<FilterSwitch
@ -666,6 +725,53 @@ export function GeneralFilterContent({
))}
</div>
</div>
{allZones && setCurrentZones && (
<>
<DropdownMenuSeparator />
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
All Zones
</Label>
<Switch
className="ml-1"
id="allZones"
checked={currentZones == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentZones(undefined);
}
}}
/>
</div>
<div className="my-2.5 flex flex-col gap-2.5">
{allZones.map((item) => (
<FilterSwitch
label={item.replaceAll("_", " ")}
isChecked={currentZones?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedZones = currentZones ? [...currentZones] : [];
updatedZones.push(item);
setCurrentZones(updatedZones);
} else {
const updatedZones = currentZones ? [...currentZones] : [];
// can not deselect the last item
if (updatedZones.length > 1) {
updatedZones.splice(updatedZones.indexOf(item), 1);
setCurrentZones(updatedZones);
}
}
}}
/>
))}
</div>
</>
)}
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
@ -675,6 +781,10 @@ export function GeneralFilterContent({
updateLabelFilter(currentLabels);
}
if (updateZoneFilter && selectedZones != currentZones) {
updateZoneFilter(currentZones);
}
onClose();
}}
>

View File

@ -36,6 +36,7 @@ type MobileReviewSettingsDrawerProps = {
mode: ExportMode;
reviewSummary?: ReviewSummary;
allLabels: string[];
allZones: string[];
onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
@ -51,6 +52,7 @@ export default function MobileReviewSettingsDrawer({
mode,
reviewSummary,
allLabels,
allZones,
onUpdateFilter,
setRange,
setMode,
@ -104,6 +106,9 @@ export default function MobileReviewSettingsDrawer({
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
filter?.labels,
);
const [currentZones, setCurrentZones] = useState<string[] | undefined>(
filter?.zones,
);
if (!isMobile) {
return;
@ -222,7 +227,7 @@ export default function MobileReviewSettingsDrawer({
);
} else if (drawerMode == "filter") {
content = (
<div className="scrollbar-container flex h-auto w-full flex-col overflow-y-auto">
<div className="scrollbar-container flex h-auto w-full flex-col overflow-y-auto overflow-x-hidden">
<div className="relative mb-2 h-8 w-full">
<div
className="absolute left-0 text-selected"
@ -240,6 +245,13 @@ export default function MobileReviewSettingsDrawer({
currentLabels={currentLabels}
currentSeverity={currentSeverity}
showAll={filter?.showAll == true}
allZones={allZones}
selectedZones={filter?.zones}
currentZones={currentZones}
setCurrentZones={setCurrentZones}
updateZoneFilter={(newZones) =>
onUpdateFilter({ ...filter, zones: newZones })
}
setShowAll={(showAll) => {
onUpdateFilter({ ...filter, showAll });
}}

View File

@ -136,6 +136,7 @@ export default function Events() {
const params = {
cameras: reviewSearchParams["cameras"],
labels: reviewSearchParams["labels"],
zones: reviewSearchParams["zones"],
reviewed: 1,
before: reviewSearchParams["before"] || last24Hours.before,
after: reviewSearchParams["after"] || last24Hours.after,
@ -221,6 +222,7 @@ export default function Events() {
timezone: timezone,
cameras: reviewSearchParams["cameras"] ?? null,
labels: reviewSearchParams["labels"] ?? null,
zones: reviewSearchParams["zones"] ?? null,
},
],
{

View File

@ -293,7 +293,7 @@ export default function SubmitPlus() {
<TooltipTrigger asChild>
<div className="mx-3 pb-1 text-sm text-white">
<Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
className={`z-0 flex items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
>
{[event.label].map((object) => {
return getIconForLabel(
@ -301,6 +301,9 @@ export default function SubmitPlus() {
"size-3 text-white",
);
})}
<div className="text-xs">
{Math.round(event.data.score * 100)}%
</div>
</Chip>
</div>
</TooltipTrigger>

View File

@ -3,3 +3,8 @@
export type FilterType = { [searchKey: string]: any };
export type ExportMode = "select" | "timeline" | "none";
export type FilterList = {
labels?: string[];
zones?: string[];
};

View File

@ -32,6 +32,7 @@ export type SegmentedReviewData =
export type ReviewFilter = {
cameras?: string[];
labels?: string[];
zones?: string[];
before?: number;
after?: number;
showReviewed?: 0 | 1;

View File

@ -49,6 +49,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { FilterList } from "@/types/filter";
type EventViewProps = {
reviewItems?: SegmentedReviewData;
@ -203,8 +204,9 @@ export default function EventView({
// review filter info
const reviewLabels = useMemo(() => {
const reviewFilterList = useMemo<FilterList>(() => {
const uniqueLabels = new Set<string>();
const uniqueZones = new Set<string>();
reviewItems?.all?.forEach((rev) => {
rev.data.objects.forEach((obj) =>
@ -213,7 +215,11 @@ export default function EventView({
rev.data.audio.forEach((aud) => uniqueLabels.add(aud));
});
return [...uniqueLabels];
reviewItems?.all?.forEach((rev) => {
rev.data.zones.forEach((zone) => uniqueZones.add(zone));
});
return { labels: [...uniqueLabels], zones: [...uniqueZones] };
}, [reviewItems]);
if (!config) {
@ -282,7 +288,7 @@ export default function EventView({
reviewSummary={reviewSummary}
filter={filter}
motionOnly={motionOnly}
filterLabels={reviewLabels}
filterList={reviewFilterList}
onUpdateFilter={updateFilter}
setMotionOnly={setMotionOnly}
/>

View File

@ -111,7 +111,7 @@ export function RecordingView({
() => chunkedTimeRange[selectedRangeIdx],
[selectedRangeIdx, chunkedTimeRange],
);
const reviewLabels = useMemo(() => {
const reviewFilterList = useMemo(() => {
const uniqueLabels = new Set<string>();
reviewItems?.forEach((rev) => {
@ -121,7 +121,13 @@ export function RecordingView({
rev.data.audio.forEach((aud) => uniqueLabels.add(aud));
});
return [...uniqueLabels];
const uniqueZones = new Set<string>();
reviewItems?.forEach((rev) => {
rev.data.zones.forEach((zone) => uniqueZones.add(zone));
});
return { labels: [...uniqueLabels], zones: [...uniqueZones] };
}, [reviewItems]);
// export
@ -391,7 +397,7 @@ export function RecordingView({
reviewSummary={reviewSummary}
filter={filter}
motionOnly={false}
filterLabels={reviewLabels}
filterList={reviewFilterList}
onUpdateFilter={updateFilter}
setMotionOnly={() => {}}
/>
@ -434,7 +440,8 @@ export function RecordingView({
latestTime={timeRange.before}
mode={exportMode}
range={exportRange}
allLabels={reviewLabels}
allLabels={reviewFilterList.labels}
allZones={reviewFilterList.zones}
onUpdateFilter={updateFilter}
setRange={setExportRange}
setMode={setExportMode}