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():
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):
face_dir = os.path.join(FACE_DIR, name)

View File

@ -506,6 +506,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
if self.config.face_recognition.save_attempts:
# write face to library
folder = os.path.join(FACE_DIR, "train")
os.makedirs(folder, exist_ok=True)
new_file = os.path.join(
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 useOptimisticState from "@/hooks/use-optimistic-state";
import { cn } from "@/lib/utils";
import { RecognizedFaceData } from "@/types/face";
import { FaceLibraryData, RecognizedFaceData } from "@/types/face";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
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 { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
@ -55,11 +55,11 @@ export default function FaceLibrary() {
const [page, setPage] = useState<string>();
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null);
// face data
const { data: faceData, mutate: refreshFaces } = useSWR("faces");
const { data: faceData, mutate: refreshFaces } =
useSWR<FaceLibraryData>("faces");
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">
<ScrollArea className="w-full whitespace-nowrap">
<div ref={tabsRef} 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((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>
<LibrarySelector
pageToggle={pageToggle}
faceData={faceData}
faces={faces}
trainImages={trainImages}
setPageToggle={setPageToggle}
/>
{selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2">
<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 = {
config: FrigateConfig;
attemptImages: string[];
@ -536,7 +588,7 @@ function FaceAttempt({
<div className="flex flex-row items-start justify-end gap-5 md:gap-4">
<Tooltip>
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger asChild>
<TooltipTrigger>
<AddFaceIcon className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
</TooltipTrigger>
@ -579,7 +631,12 @@ type FaceGridProps = {
};
function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
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) => (
<FaceImage
key={image}
@ -602,7 +659,12 @@ function FaceImage({ name, image, onDelete }: FaceImageProps) {
return (
<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}`} />
</div>
<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";
const allSettingsViews = [
"uiSettings",
"classificationSettings",
"cameraSettings",
"ui",
"classification",
"cameras",
"masksAndZones",
"motionTuner",
"debug",
@ -61,7 +61,7 @@ type SettingsType = (typeof allSettingsViews)[number];
export default function 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 tabsRef = useRef<HTMLDivElement | null>(null);
@ -73,7 +73,7 @@ export default function Settings() {
const isAdmin = useIsAdmin();
const allowedViewsForViewer: SettingsType[] = ["uiSettings", "debug"];
const allowedViewsForViewer: SettingsType[] = ["ui", "debug"];
const visibleSettingsViews = !isAdmin
? allowedViewsForViewer
: allSettingsViews;
@ -135,10 +135,7 @@ export default function Settings() {
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
setSelectedCamera(firstEnabledCamera.name);
} else if (
!cameraEnabledStates[selectedCamera] &&
page !== "cameraSettings"
) {
} else if (!cameraEnabledStates[selectedCamera] && page !== "cameras") {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
@ -167,8 +164,8 @@ export default function Settings() {
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings
if (!isAdmin && !["uiSettings", "debug"].includes(page)) {
setPage("uiSettings");
if (!isAdmin && !["ui", "debug"].includes(page)) {
setPage("ui");
} else {
setPage(page as SettingsType);
}
@ -203,8 +200,8 @@ export default function Settings() {
onValueChange={(value: SettingsType) => {
if (value) {
// Restrict viewer navigation
if (!isAdmin && !["uiSettings", "debug"].includes(value)) {
setPageToggle("uiSettings");
if (!isAdmin && !["ui", "debug"].includes(value)) {
setPageToggle("ui");
} else {
setPageToggle(value);
}
@ -214,7 +211,7 @@ export default function Settings() {
{visibleSettingsViews.map((item) => (
<ToggleGroupItem
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}
data-nav-item={item}
aria-label={t("selectItem", {
@ -230,7 +227,7 @@ export default function Settings() {
</div>
</ScrollArea>
{(page == "debug" ||
page == "cameraSettings" ||
page == "cameras" ||
page == "masksAndZones" ||
page == "motionTuner") && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
@ -251,14 +248,14 @@ export default function Settings() {
)}
</div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "uiSettings" && <UiSettingsView />}
{page == "classificationSettings" && (
{page == "ui" && <UiSettingsView />}
{page == "classification" && (
<ClassificationSettingsView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} />
)}
{page == "cameraSettings" && (
{page == "cameras" && (
<CameraSettingsView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
@ -363,7 +360,7 @@ function CameraSelectButton({
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "cameraSettings";
const isCameraSettingsPage = currentPage === "cameras";
return (
<FilterSwitch
key={item.name}

View File

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