Face tweaks (#17290)

* Ensure doesn't fail due to missing dir

* Remove redundant settings from tabs

* Adjust selection method for mobile

* Fix button descendent error

* Ensure train is option on mobile

* Cleanup face images

* Cleanup
This commit is contained in:
Nicolas Mowen 2025-03-21 11:47:32 -06:00 committed by GitHub
parent 060659044e
commit 08cf0def6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 137 additions and 70 deletions

View File

@ -30,6 +30,9 @@ router = APIRouter(tags=[Tags.events])
def get_faces(): def get_faces():
face_dict: dict[str, list[str]] = {} face_dict: dict[str, list[str]] = {}
if not os.path.exists(FACE_DIR):
return JSONResponse(status_code=200, content={})
for name in os.listdir(FACE_DIR): for name in os.listdir(FACE_DIR):
face_dir = os.path.join(FACE_DIR, name) face_dir = os.path.join(FACE_DIR, name)

View File

@ -506,6 +506,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
if self.config.face_recognition.save_attempts: if self.config.face_recognition.save_attempts:
# write face to library # write face to library
folder = os.path.join(FACE_DIR, "train") folder = os.path.join(FACE_DIR, "train")
os.makedirs(folder, exist_ok=True)
new_file = os.path.join( new_file = os.path.join(
folder, f"{id}-{sub_label}-{score}-{face_score}.webp" folder, f"{id}-{sub_label}-{score}-{face_score}.webp"
) )

View File

@ -32,11 +32,11 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RecognizedFaceData } from "@/types/face"; import { FaceLibraryData, RecognizedFaceData } from "@/types/face";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu"; import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
@ -55,11 +55,11 @@ export default function FaceLibrary() {
const [page, setPage] = useState<string>(); const [page, setPage] = useState<string>();
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null);
// face data // face data
const { data: faceData, mutate: refreshFaces } = useSWR("faces"); const { data: faceData, mutate: refreshFaces } =
useSWR<FaceLibraryData>("faces");
const faces = useMemo<string[]>( const faces = useMemo<string[]>(
() => () =>
@ -233,50 +233,13 @@ export default function FaceLibrary() {
/> />
<div className="relative mb-2 flex h-11 w-full items-center justify-between"> <div className="relative mb-2 flex h-11 w-full items-center justify-between">
<ScrollArea className="w-full whitespace-nowrap"> <LibrarySelector
<div ref={tabsRef} className="flex flex-row"> pageToggle={pageToggle}
<ToggleGroup faceData={faceData}
className="*:rounded-md *:px-3 *:py-4" faces={faces}
type="single" trainImages={trainImages}
size="sm" setPageToggle={setPageToggle}
value={pageToggle} />
onValueChange={(value: string) => {
if (value) {
setPageToggle(value);
}
}}
>
{trainImages.length > 0 && (
<>
<ToggleGroupItem
value="train"
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == "train" ? "" : "*:text-muted-foreground"}`}
data-nav-item="train"
aria-label={t("train.aria")}
>
<div>{t("train.title")}</div>
</ToggleGroupItem>
<div>|</div>
</>
)}
{Object.values(faces).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={t("selectItem", { item })}
>
<div className="capitalize">
{item} ({faceData[item].length})
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{selectedFaces?.length > 0 ? ( {selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Button <Button
@ -323,6 +286,95 @@ export default function FaceLibrary() {
); );
} }
type LibrarySelectorProps = {
pageToggle: string | undefined;
faceData?: FaceLibraryData;
faces: string[];
trainImages: string[];
setPageToggle: (toggle: string | undefined) => void;
};
function LibrarySelector({
pageToggle,
faceData,
faces,
trainImages,
setPageToggle,
}: LibrarySelectorProps) {
const { t } = useTranslation(["views/faceLibrary"]);
return isDesktop ? (
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: string) => {
if (value) {
setPageToggle(value);
}
}}
>
{trainImages.length > 0 && (
<>
<ToggleGroupItem
value="train"
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == "train" ? "" : "*:text-muted-foreground"}`}
data-nav-item="train"
aria-label={t("train.aria")}
>
<div>{t("train.title")}</div>
</ToggleGroupItem>
<div>|</div>
</>
)}
{Object.values(faces).map((face) => (
<ToggleGroupItem
key={face}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == face ? "" : "*:text-muted-foreground"}`}
value={face}
data-nav-item={face}
aria-label={t("selectItem", { item: face })}
>
<div className="capitalize">
{face} ({faceData?.[face].length})
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize">{pageToggle}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{trainImages.length > 0 && (
<DropdownMenuItem
className="capitalize"
aria-label={t("train.aria")}
onClick={() => setPageToggle("train")}
>
<div>{t("train.title")}</div>
</DropdownMenuItem>
)}
{Object.values(faces).map((face) => (
<DropdownMenuItem
className="capitalize"
onClick={() => setPageToggle(face)}
>
{face} ({faceData?.[face].length})
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
type TrainingGridProps = { type TrainingGridProps = {
config: FrigateConfig; config: FrigateConfig;
attemptImages: string[]; attemptImages: string[];
@ -536,7 +588,7 @@ function FaceAttempt({
<div className="flex flex-row items-start justify-end gap-5 md:gap-4"> <div className="flex flex-row items-start justify-end gap-5 md:gap-4">
<Tooltip> <Tooltip>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<TooltipTrigger> <TooltipTrigger>
<AddFaceIcon className="size-5 cursor-pointer text-primary-variant hover:text-primary" /> <AddFaceIcon className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
</TooltipTrigger> </TooltipTrigger>
@ -579,7 +631,12 @@ type FaceGridProps = {
}; };
function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) { function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
return ( return (
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll"> <div
className={cn(
"scrollbar-container gap-2 overflow-y-scroll",
isDesktop ? "flex flex-wrap" : "grid grid-cols-2",
)}
>
{faceImages.map((image: string) => ( {faceImages.map((image: string) => (
<FaceImage <FaceImage
key={image} key={image}
@ -602,7 +659,12 @@ function FaceImage({ name, image, onDelete }: FaceImageProps) {
return ( return (
<div className="relative flex flex-col rounded-lg"> <div className="relative flex flex-col rounded-lg">
<div className="w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground"> <div
className={cn(
"w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground",
isMobile && "flex justify-center",
)}
>
<img className="h-40" src={`${baseUrl}clips/faces/${name}/${image}`} /> <img className="h-40" src={`${baseUrl}clips/faces/${name}/${image}`} />
</div> </div>
<div className="rounded-b-lg bg-card p-2"> <div className="rounded-b-lg bg-card p-2">

View File

@ -47,9 +47,9 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const allSettingsViews = [ const allSettingsViews = [
"uiSettings", "ui",
"classificationSettings", "classification",
"cameraSettings", "cameras",
"masksAndZones", "masksAndZones",
"motionTuner", "motionTuner",
"debug", "debug",
@ -61,7 +61,7 @@ type SettingsType = (typeof allSettingsViews)[number];
export default function Settings() { export default function Settings() {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [page, setPage] = useState<SettingsType>("uiSettings"); const [page, setPage] = useState<SettingsType>("ui");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null); const tabsRef = useRef<HTMLDivElement | null>(null);
@ -73,7 +73,7 @@ export default function Settings() {
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const allowedViewsForViewer: SettingsType[] = ["uiSettings", "debug"]; const allowedViewsForViewer: SettingsType[] = ["ui", "debug"];
const visibleSettingsViews = !isAdmin const visibleSettingsViews = !isAdmin
? allowedViewsForViewer ? allowedViewsForViewer
: allSettingsViews; : allSettingsViews;
@ -135,10 +135,7 @@ export default function Settings() {
const firstEnabledCamera = const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
setSelectedCamera(firstEnabledCamera.name); setSelectedCamera(firstEnabledCamera.name);
} else if ( } else if (!cameraEnabledStates[selectedCamera] && page !== "cameras") {
!cameraEnabledStates[selectedCamera] &&
page !== "cameraSettings"
) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page // Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera = const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
@ -167,8 +164,8 @@ export default function Settings() {
useSearchEffect("page", (page: string) => { useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) { if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings // Restrict viewer to UI settings
if (!isAdmin && !["uiSettings", "debug"].includes(page)) { if (!isAdmin && !["ui", "debug"].includes(page)) {
setPage("uiSettings"); setPage("ui");
} else { } else {
setPage(page as SettingsType); setPage(page as SettingsType);
} }
@ -203,8 +200,8 @@ export default function Settings() {
onValueChange={(value: SettingsType) => { onValueChange={(value: SettingsType) => {
if (value) { if (value) {
// Restrict viewer navigation // Restrict viewer navigation
if (!isAdmin && !["uiSettings", "debug"].includes(value)) { if (!isAdmin && !["ui", "debug"].includes(value)) {
setPageToggle("uiSettings"); setPageToggle("ui");
} else { } else {
setPageToggle(value); setPageToggle(value);
} }
@ -214,7 +211,7 @@ export default function Settings() {
{visibleSettingsViews.map((item) => ( {visibleSettingsViews.map((item) => (
<ToggleGroupItem <ToggleGroupItem
key={item} key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "uiSettings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "ui" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item} value={item}
data-nav-item={item} data-nav-item={item}
aria-label={t("selectItem", { aria-label={t("selectItem", {
@ -230,7 +227,7 @@ export default function Settings() {
</div> </div>
</ScrollArea> </ScrollArea>
{(page == "debug" || {(page == "debug" ||
page == "cameraSettings" || page == "cameras" ||
page == "masksAndZones" || page == "masksAndZones" ||
page == "motionTuner") && ( page == "motionTuner") && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2"> <div className="ml-2 flex flex-shrink-0 items-center gap-2">
@ -251,14 +248,14 @@ export default function Settings() {
)} )}
</div> </div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24"> <div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "uiSettings" && <UiSettingsView />} {page == "ui" && <UiSettingsView />}
{page == "classificationSettings" && ( {page == "classification" && (
<ClassificationSettingsView setUnsavedChanges={setUnsavedChanges} /> <ClassificationSettingsView setUnsavedChanges={setUnsavedChanges} />
)} )}
{page == "debug" && ( {page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} /> <ObjectSettingsView selectedCamera={selectedCamera} />
)} )}
{page == "cameraSettings" && ( {page == "cameras" && (
<CameraSettingsView <CameraSettingsView
selectedCamera={selectedCamera} selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges} setUnsavedChanges={setUnsavedChanges}
@ -363,7 +360,7 @@ function CameraSelectButton({
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{allCameras.map((item) => { {allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name]; const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "cameraSettings"; const isCameraSettingsPage = currentPage === "cameras";
return ( return (
<FilterSwitch <FilterSwitch
key={item.name} key={item.name}

View File

@ -1,3 +1,7 @@
export type FaceLibraryData = {
[faceName: string]: string[];
};
export type RecognizedFaceData = { export type RecognizedFaceData = {
timestamp: number; timestamp: number;
eventId: string; eventId: string;