mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	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:
		
							parent
							
								
									060659044e
								
							
						
					
					
						commit
						08cf0def6e
					
				@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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"
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
@ -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">
 | 
			
		||||
 | 
			
		||||
@ -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}
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,7 @@
 | 
			
		||||
export type FaceLibraryData = {
 | 
			
		||||
  [faceName: string]: string[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type RecognizedFaceData = {
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
  eventId: string;
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user