diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py
index e70801812..b51b7a20f 100644
--- a/frigate/data_processing/real_time/face.py
+++ b/frigate/data_processing/real_time/face.py
@@ -227,6 +227,8 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
scale_factor = MAX_DETECTION_HEIGHT / input.shape[0]
new_width = int(scale_factor * input.shape[1])
input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT))
+ else:
+ scale_factor = 1
self.face_detector.setInputSize((input.shape[1], input.shape[0]))
faces = self.face_detector.detect(input)
@@ -241,10 +243,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
continue
raw_bbox = potential_face[0:4].astype(np.uint16)
- x: int = max(raw_bbox[0], 0)
- y: int = max(raw_bbox[1], 0)
- w: int = raw_bbox[2]
- h: int = raw_bbox[3]
+ x: int = int(max(raw_bbox[0], 0) / scale_factor)
+ y: int = int(max(raw_bbox[1], 0) / scale_factor)
+ w: int = int(raw_bbox[2] / scale_factor)
+ h: int = int(raw_bbox[3] / scale_factor)
bbox = (x, y, x + w, y + h)
if face is None or area(bbox) > area(face):
diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json
index 4ddd9244e..14b88f707 100644
--- a/web/public/locales/en/common.json
+++ b/web/public/locales/en/common.json
@@ -64,6 +64,7 @@
"button": {
"apply": "Apply",
"reset": "Reset",
+ "done": "Done",
"enabled": "Enabled",
"enable": "Enable",
"disabled": "Disabled",
@@ -94,7 +95,8 @@
"play": "Play",
"unselect": "Unselect",
"export": "Export",
- "deleteNow": "Delete Now"
+ "deleteNow": "Delete Now",
+ "next": "Next"
},
"menu": {
"system": "System",
diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json
index b95f744d7..4028690e3 100644
--- a/web/public/locales/en/views/faceLibrary.json
+++ b/web/public/locales/en/views/faceLibrary.json
@@ -1,4 +1,7 @@
{
+ "description": {
+ "addFace": "Walk through adding a new face to the Face Library."
+ },
"documentTitle": "Face Library - Frigate",
"uploadFaceImage": {
"title": "Upload Face Image",
@@ -6,7 +9,8 @@
},
"createFaceLibrary": {
"title": "Create Face Library",
- "desc": "Create a new face library"
+ "desc": "Create a new face library",
+ "nextSteps": "It is recommended to use the Train tab to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle."
},
"train": {
"title": "Train",
@@ -19,12 +23,13 @@
"uploadImage": "Upload Image",
"reprocessFace": "Reprocess Face"
},
+ "readTheDocs": "Read the documentation to view more details on refining images for the Face Library",
"trainFaceAs": "Train Face as:",
"trainFaceAsPerson": "Train Face as Person",
"toast": {
"success": {
"uploadedImage": "Successfully uploaded image.",
- "addFaceLibrary": "Successfully add face library.",
+ "addFaceLibrary": "{{name}} has successfully been added to the Face Library!",
"deletedFace": "Successfully deleted face.",
"trainedFace": "Successfully trained face.",
"updatedFaceScore": "Successfully updated face score."
diff --git a/web/src/components/indicators/StepIndicator.tsx b/web/src/components/indicators/StepIndicator.tsx
new file mode 100644
index 000000000..641ae32ca
--- /dev/null
+++ b/web/src/components/indicators/StepIndicator.tsx
@@ -0,0 +1,28 @@
+import { cn } from "@/lib/utils";
+
+type StepIndicatorProps = {
+ steps: string[];
+ currentStep: number;
+};
+export default function StepIndicator({
+ steps,
+ currentStep,
+}: StepIndicatorProps) {
+ return (
+
+ {steps.map((name, idx) => (
+
+
+ {idx + 1}
+
+
{name}
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/input/ImageEntry.tsx b/web/src/components/input/ImageEntry.tsx
new file mode 100644
index 000000000..afb399177
--- /dev/null
+++ b/web/src/components/input/ImageEntry.tsx
@@ -0,0 +1,58 @@
+import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { zodResolver } from "@hookform/resolvers/zod";
+import React, { useCallback } from "react";
+import { useForm } from "react-hook-form";
+
+import { z } from "zod";
+
+type ImageEntryProps = {
+ onSave: (file: File) => void;
+ children?: React.ReactNode;
+};
+export default function ImageEntry({ onSave, children }: ImageEntryProps) {
+ const formSchema = z.object({
+ file: z.instanceof(FileList, { message: "Please select an image file." }),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ });
+ const fileRef = form.register("file");
+
+ // upload handler
+
+ const onSubmit = useCallback(
+ (data: z.infer) => {
+ if (!data["file"] || Object.keys(data.file).length == 0) {
+ return;
+ }
+
+ onSave(data["file"]["0"]);
+ },
+ [onSave],
+ );
+
+ return (
+
+
+ );
+}
diff --git a/web/src/components/input/TextEntry.tsx b/web/src/components/input/TextEntry.tsx
new file mode 100644
index 000000000..c9fa8a8a9
--- /dev/null
+++ b/web/src/components/input/TextEntry.tsx
@@ -0,0 +1,68 @@
+import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { zodResolver } from "@hookform/resolvers/zod";
+import React, { useCallback } from "react";
+import { useForm } from "react-hook-form";
+
+import { z } from "zod";
+
+type TextEntryProps = {
+ defaultValue?: string;
+ placeholder?: string;
+ allowEmpty?: boolean;
+ onSave: (text: string) => void;
+ children?: React.ReactNode;
+};
+export default function TextEntry({
+ defaultValue,
+ placeholder,
+ allowEmpty,
+ onSave,
+ children,
+}: TextEntryProps) {
+ const formSchema = z.object({
+ text: z.string(),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: { text: defaultValue },
+ });
+ const fileRef = form.register("text");
+
+ // upload handler
+
+ const onSubmit = useCallback(
+ (data: z.infer) => {
+ if (!allowEmpty && !data["text"]) {
+ return;
+ }
+ onSave(data["text"]);
+ },
+ [onSave, allowEmpty],
+ );
+
+ return (
+
+
+ );
+}
diff --git a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx
new file mode 100644
index 000000000..659ac4c88
--- /dev/null
+++ b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx
@@ -0,0 +1,168 @@
+import StepIndicator from "@/components/indicators/StepIndicator";
+import ImageEntry from "@/components/input/ImageEntry";
+import TextEntry from "@/components/input/TextEntry";
+import {
+ MobilePage,
+ MobilePageContent,
+ MobilePageDescription,
+ MobilePageHeader,
+ MobilePageTitle,
+} from "@/components/mobile/MobilePage";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+import axios from "axios";
+import { useCallback, useState } from "react";
+import { isDesktop } from "react-device-detect";
+import { useTranslation } from "react-i18next";
+import { LuExternalLink } from "react-icons/lu";
+import { Link } from "react-router-dom";
+import { toast } from "sonner";
+
+const STEPS = ["Enter Face Name", "Upload Face Image", "Next Steps"];
+
+type CreateFaceWizardDialogProps = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ onFinish: () => void;
+};
+export default function CreateFaceWizardDialog({
+ open,
+ setOpen,
+ onFinish,
+}: CreateFaceWizardDialogProps) {
+ const { t } = useTranslation("views/faceLibrary");
+
+ // wizard
+
+ const [step, setStep] = useState(0);
+ const [name, setName] = useState("");
+
+ const handleReset = useCallback(() => {
+ setStep(0);
+ setName("");
+ setOpen(false);
+ }, [setOpen]);
+
+ // data handling
+
+ const onUploadImage = useCallback(
+ (file: File) => {
+ const formData = new FormData();
+ formData.append("file", file);
+ axios
+ .post(`faces/${name}/register`, formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ })
+ .then((resp) => {
+ if (resp.status == 200) {
+ setStep(2);
+ toast.success(t("toast.success.uploadedImage"), {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Unknown error";
+ toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), {
+ position: "top-center",
+ });
+ });
+ },
+ [name, t],
+ );
+
+ // layout
+
+ const Overlay = isDesktop ? Dialog : MobilePage;
+ const Content = isDesktop ? DialogContent : MobilePageContent;
+ const Header = isDesktop ? DialogHeader : MobilePageHeader;
+ const Title = isDesktop ? DialogTitle : MobilePageTitle;
+ const Description = isDesktop ? DialogDescription : MobilePageDescription;
+
+ return (
+ {
+ if (!open) {
+ handleReset();
+ }
+ }}
+ >
+
+
+ {t("button.addFace")}
+ {isDesktop && {t("description.addFace")}}
+
+
+ {step == 0 && (
+ {
+ setName(name);
+ setStep(1);
+ }}
+ >
+
+
+
+
+ )}
+ {step == 1 && (
+
+
+
+
+
+ )}
+ {step == 2 && (
+
+ {t("toast.success.addFaceLibrary", { name })}
+
+ {t("createFaceLibrary.nextSteps")}
+
+
+
+ {t("readTheDocs")}
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx
index a25c023ea..6fc1f9ad3 100644
--- a/web/src/components/overlay/dialog/TextEntryDialog.tsx
+++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx
@@ -1,3 +1,4 @@
+import TextEntry from "@/components/input/TextEntry";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -7,15 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useCallback, useEffect } from "react";
-import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import { z } from "zod";
-
type TextEntryDialogProps = {
open: boolean;
title: string;
@@ -35,35 +29,7 @@ export default function TextEntryDialog({
defaultValue = "",
allowEmpty = false,
}: TextEntryDialogProps) {
- const formSchema = z.object({
- text: z.string(),
- });
-
- const { t } = useTranslation("components/dialog");
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- defaultValues: { text: defaultValue },
- });
- const fileRef = form.register("text");
-
- // upload handler
-
- const onSubmit = useCallback(
- (data: z.infer) => {
- if (!allowEmpty && !data["text"]) {
- return;
- }
- onSave(data["text"]);
- },
- [onSave, allowEmpty],
- );
-
- useEffect(() => {
- if (open) {
- form.reset({ text: defaultValue });
- }
- }, [open, defaultValue, form]);
+ const { t } = useTranslation("common");
return (
);
diff --git a/web/src/components/overlay/dialog/UploadImageDialog.tsx b/web/src/components/overlay/dialog/UploadImageDialog.tsx
index 6a01a7fab..7fab82eea 100644
--- a/web/src/components/overlay/dialog/UploadImageDialog.tsx
+++ b/web/src/components/overlay/dialog/UploadImageDialog.tsx
@@ -1,3 +1,4 @@
+import ImageEntry from "@/components/input/ImageEntry";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -7,12 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useCallback } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
+import { useTranslation } from "react-i18next";
type UploadImageDialogProps = {
open: boolean;
@@ -28,27 +24,7 @@ export default function UploadImageDialog({
setOpen,
onSave,
}: UploadImageDialogProps) {
- const formSchema = z.object({
- file: z.instanceof(FileList, { message: "Please select an image file." }),
- });
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- });
- const fileRef = form.register("file");
-
- // upload handler
-
- const onSubmit = useCallback(
- (data: z.infer) => {
- if (!data["file"] || Object.keys(data.file).length == 0) {
- return;
- }
-
- onSave(data["file"]["0"]);
- },
- [onSave],
- );
+ const { t } = useTranslation("common");
return (
);
diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx
index 33fbb69d1..afa196f35 100644
--- a/web/src/pages/FaceLibrary.tsx
+++ b/web/src/pages/FaceLibrary.tsx
@@ -1,7 +1,8 @@
import { baseUrl } from "@/api/baseUrl";
+import TimeAgo from "@/components/dynamic/TimeAgo";
import AddFaceIcon from "@/components/icons/AddFaceIcon";
import ActivityIndicator from "@/components/indicators/activity-indicator";
-import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
+import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import { Button } from "@/components/ui/button";
import {
@@ -25,6 +26,7 @@ import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { isDesktop } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
@@ -115,42 +117,16 @@ export default function FaceLibrary() {
[pageToggle, refreshFaces, t],
);
- const onAddName = useCallback(
- (name: string) => {
- axios
- .post(`faces/${name}/create`, {
- headers: {
- "Content-Type": "multipart/form-data",
- },
- })
- .then((resp) => {
- if (resp.status == 200) {
- setAddFace(false);
- refreshFaces();
- toast.success(t("toast.success.addFaceLibrary"), {
- position: "top-center",
- });
- }
- })
- .catch((error) => {
- const errorMessage =
- error.response?.data?.message ||
- error.response?.data?.detail ||
- "Unknown error";
- toast.error(t("toast.error.addFaceLibraryFailed", { errorMessage }), {
- position: "top-center",
- });
- });
- },
- [refreshFaces, t],
- );
-
// face multiselect
const [selectedFaces, setSelectedFaces] = useState([]);
const onClickFace = useCallback(
- (imageId: string) => {
+ (imageId: string, ctrl: boolean) => {
+ if (selectedFaces.length == 0 && !ctrl) {
+ return;
+ }
+
const index = selectedFaces.indexOf(imageId);
if (index != -1) {
@@ -172,33 +148,42 @@ export default function FaceLibrary() {
[selectedFaces, setSelectedFaces],
);
- const onDelete = useCallback(() => {
- axios
- .post(`/faces/train/delete`, { ids: selectedFaces })
- .then((resp) => {
- setSelectedFaces([]);
+ const onDelete = useCallback(
+ (name: string, ids: string[]) => {
+ axios
+ .post(`/faces/${name}/delete`, { ids })
+ .then((resp) => {
+ setSelectedFaces([]);
- if (resp.status == 200) {
- toast.success(t("toast.success.deletedFace"), {
+ if (resp.status == 200) {
+ toast.success(t("toast.success.deletedFace"), {
+ position: "top-center",
+ });
+
+ if (faceImages.length == 1) {
+ // face has been deleted
+ setPageToggle("");
+ }
+
+ refreshFaces();
+ }
+ })
+ .catch((error) => {
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Unknown error";
+ toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
- refreshFaces();
- }
- })
- .catch((error) => {
- const errorMessage =
- error.response?.data?.message ||
- error.response?.data?.detail ||
- "Unknown error";
- toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
- position: "top-center",
});
- });
- }, [selectedFaces, refreshFaces, t]);
+ },
+ [faceImages, refreshFaces, setPageToggle, t],
+ );
// keyboard
- useKeyboardListener(["a"], (key, modifiers) => {
+ useKeyboardListener(["a", "Escape"], (key, modifiers) => {
if (modifiers.repeat || !modifiers.down) {
return;
}
@@ -209,6 +194,9 @@ export default function FaceLibrary() {
setSelectedFaces([...trainImages]);
}
break;
+ case "Escape":
+ setSelectedFaces([]);
+ break;
}
});
@@ -228,12 +216,10 @@ export default function FaceLibrary() {
onSave={onUploadImage}
/>
-
@@ -283,21 +269,24 @@ export default function FaceLibrary() {
{selectedFaces?.length > 0 ? (
-
) : (
setAddFace(true)}>
- {t("button.addFace")}
+ {isDesktop && t("button.addFace")}
{pageToggle != "train" && (
setUpload(true)}>
- {t("button.uploadImage")}
+ {isDesktop && t("button.uploadImage")}
)}
@@ -317,7 +306,7 @@ export default function FaceLibrary() {
))}
@@ -329,7 +318,7 @@ type TrainingGridProps = {
attemptImages: string[];
faceNames: string[];
selectedFaces: string[];
- onClickFace: (image: string) => void;
+ onClickFace: (image: string, ctrl: boolean) => void;
onRefresh: () => void;
};
function TrainingGrid({
@@ -349,7 +338,7 @@ function TrainingGrid({
faceNames={faceNames}
threshold={config.face_recognition.recognition_threshold}
selected={selectedFaces.includes(image)}
- onClick={() => onClickFace(image)}
+ onClick={(meta) => onClickFace(image, meta)}
onRefresh={onRefresh}
/>
))}
@@ -362,7 +351,7 @@ type FaceAttemptProps = {
faceNames: string[];
threshold: number;
selected: boolean;
- onClick: () => void;
+ onClick: (meta: boolean) => void;
onRefresh: () => void;
};
function FaceAttempt({
@@ -378,6 +367,7 @@ function FaceAttempt({
const parts = image.split("-");
return {
+ timestamp: Number.parseFloat(parts[0]),
eventId: `${parts[0]}-${parts[1]}`,
name: parts[2],
score: parts[3],
@@ -439,10 +429,13 @@ function FaceAttempt({
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
- onClick={onClick}
+ onClick={(e) => onClick(e.metaKey || e.ctrlKey)}
>
-
-

+
+

+
+
+
@@ -500,9 +493,9 @@ function FaceAttempt({
type FaceGridProps = {
faceImages: string[];
pageToggle: string;
- onRefresh: () => void;
+ onDelete: (name: string, ids: string[]) => void;
};
-function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
+function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
return (
{faceImages.map((image: string) => (
@@ -510,7 +503,7 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
key={image}
name={pageToggle}
image={image}
- onRefresh={onRefresh}
+ onDelete={onDelete}
/>
))}
@@ -520,31 +513,10 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
type FaceImageProps = {
name: string;
image: string;
- onRefresh: () => void;
+ onDelete: (name: string, ids: string[]) => void;
};
-function FaceImage({ name, image, onRefresh }: FaceImageProps) {
+function FaceImage({ name, image, onDelete }: FaceImageProps) {
const { t } = useTranslation(["views/faceLibrary"]);
- const onDelete = useCallback(() => {
- axios
- .post(`/faces/${name}/delete`, { ids: [image] })
- .then((resp) => {
- if (resp.status == 200) {
- toast.success(t("toast.success.deletedFace"), {
- position: "top-center",
- });
- onRefresh();
- }
- })
- .catch((error) => {
- const errorMessage =
- error.response?.data?.message ||
- error.response?.data?.detail ||
- "Unknown error";
- toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
- position: "top-center",
- });
- });
- }, [name, image, onRefresh, t]);
return (
@@ -561,7 +533,7 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
onDelete(name, [image])}
/>
{t("button.deleteFaceAttempts")}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
index d79a7c953..8cf87f206 100644
--- a/web/src/pages/LoginPage.tsx
+++ b/web/src/pages/LoginPage.tsx
@@ -1,20 +1,24 @@
import { UserAuthForm } from "@/components/auth/AuthForm";
import Logo from "@/components/Logo";
import { ThemeProvider } from "@/context/theme-provider";
+import "@/utils/i18n";
+import { LanguageProvider } from "@/context/language-provider";
function LoginPage() {
return (
-