Add UI for managing face recognitions (#15757)

* Add ability to view attempts

* Improve UI

* Cleanup

* Correctly refresh ui when item is deleted

* Select correct library by default

* Add min score

* Cleanup
This commit is contained in:
Nicolas Mowen 2024-12-31 15:56:01 -06:00 committed by Blake Blackshear
parent 8763390dfe
commit 172e7d494f
4 changed files with 183 additions and 32 deletions

View File

@ -23,17 +23,23 @@ class SemanticSearchConfig(FrigateBaseModel):
class FaceRecognitionConfig(FrigateBaseModel): class FaceRecognitionConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable face recognition.") enabled: bool = Field(default=False, title="Enable face recognition.")
min_score: float = Field(
title="Minimum face distance score required to save the attempt.",
default=0.8,
gt=0.0,
le=1.0,
)
threshold: float = Field( threshold: float = Field(
default=170, default=0.9,
title="minimum face distance score required to be considered a match.", title="Minimum face distance score required to be considered a match.",
gt=0.0, gt=0.0,
le=1.0, le=1.0,
) )
min_area: int = Field( min_area: int = Field(
default=500, title="Min area of face box to consider running face recognition." default=500, title="Min area of face box to consider running face recognition."
) )
debug_save_images: bool = Field( save_attempts: bool = Field(
default=False, title="Save images of face detections for debugging." default=True, title="Save images of face detections for training."
) )

View File

@ -515,13 +515,19 @@ class EmbeddingMaintainer(threading.Thread):
f"Detected best face for person as: {sub_label} with probability {score} and overall face score {face_score}" f"Detected best face for person as: {sub_label} with probability {score} and overall face score {face_score}"
) )
if self.config.face_recognition.debug_save_images: if self.config.face_recognition.save_attempts:
# write face to library # write face to library
folder = os.path.join(FACE_DIR, "debug") folder = os.path.join(FACE_DIR, "debug")
file = os.path.join(folder, f"{id}-{sub_label}-{score}-{face_score}.webp") file = os.path.join(folder, f"{id}-{sub_label}-{score}-{face_score}.webp")
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
cv2.imwrite(file, face_frame) cv2.imwrite(file, face_frame)
if score < self.config.face_recognition.threshold:
logger.debug(
f"Recognized face distance {score} is less than threshold {self.config.face_recognition.threshold}"
)
return
if id in self.detected_faces and face_score <= self.detected_faces[id]: if id in self.detected_faces and face_score <= self.detected_faces[id]:
logger.debug( logger.debug(
f"Recognized face distance {score} and overall score {face_score} is less than previous overall face score ({self.detected_faces.get(id)})." f"Recognized face distance {score} and overall score {face_score} is less than previous overall face score ({self.detected_faces.get(id)})."

View File

@ -166,7 +166,7 @@ class FaceClassificationModel:
self.landmark_detector.loadModel("/config/model_cache/facedet/landmarkdet.yaml") self.landmark_detector.loadModel("/config/model_cache/facedet/landmarkdet.yaml")
self.recognizer: cv2.face.LBPHFaceRecognizer = ( self.recognizer: cv2.face.LBPHFaceRecognizer = (
cv2.face.LBPHFaceRecognizer_create( cv2.face.LBPHFaceRecognizer_create(
radius=2, threshold=(1 - config.threshold) * 1000 radius=2, threshold=(1 - config.min_score) * 1000
) )
) )
self.label_map: dict[int, str] = {} self.label_map: dict[int, str] = {}

View File

@ -23,7 +23,8 @@ export default function FaceLibrary() {
const { data: faceData, mutate: refreshFaces } = useSWR("faces"); const { data: faceData, mutate: refreshFaces } = useSWR("faces");
const faces = useMemo<string[]>( const faces = useMemo<string[]>(
() => (faceData ? Object.keys(faceData) : []), () =>
faceData ? Object.keys(faceData).filter((face) => face != "debug") : [],
[faceData], [faceData],
); );
const faceImages = useMemo<string[]>( const faceImages = useMemo<string[]>(
@ -31,13 +32,24 @@ export default function FaceLibrary() {
[pageToggle, faceData], [pageToggle, faceData],
); );
const faceAttempts = useMemo<string[]>(
() => faceData?.["debug"] || [],
[faceData],
);
useEffect(() => { useEffect(() => {
if (!pageToggle && faces) { if (!pageToggle) {
if (faceAttempts.length > 0) {
setPageToggle("attempts");
} else if (faces) {
setPageToggle(faces[0]);
}
} else if (pageToggle == "attempts" && faceAttempts.length == 0) {
setPageToggle(faces[0]); setPageToggle(faces[0]);
} }
// we need to listen on the value of the faces list // we need to listen on the value of the faces list
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [faces]); }, [faceAttempts, faces]);
// upload // upload
@ -58,7 +70,7 @@ export default function FaceLibrary() {
setUpload(false); setUpload(false);
refreshFaces(); refreshFaces();
toast.success( toast.success(
"Successfully uploaded iamge. View the file in the /exports folder.", "Successfully uploaded image. View the file in the /exports folder.",
{ position: "top-center" }, { position: "top-center" },
); );
} }
@ -105,10 +117,24 @@ export default function FaceLibrary() {
} }
}} }}
> >
{faceAttempts.length > 0 && (
<>
<ToggleGroupItem
value="attempts"
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == "attempts" ? "" : "*:text-muted-foreground"}`}
data-nav-item="attempts"
aria-label="Select attempts"
>
<div>Attempts</div>
</ToggleGroupItem>
<div>|</div>
</>
)}
{Object.values(faces).map((item) => ( {Object.values(faces).map((item) => (
<ToggleGroupItem <ToggleGroupItem
key={item} key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item} value={item}
data-nav-item={item} data-nav-item={item}
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
@ -121,37 +147,61 @@ export default function FaceLibrary() {
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
{pageToggle && ( {pageToggle &&
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll"> (pageToggle == "attempts" ? (
{faceImages.map((image: string) => ( <AttemptsGrid attemptImages={faceAttempts} onRefresh={refreshFaces} />
<FaceImage key={image} name={pageToggle} image={image} /> ) : (
))} <FaceGrid
<Button faceImages={faceImages}
key="upload" pageToggle={pageToggle}
className="size-40" setUpload={setUpload}
onClick={() => setUpload(true)} onRefresh={refreshFaces}
> />
<LuImagePlus className="size-10" /> ))}
</Button>
</div>
)}
</div> </div>
); );
} }
type FaceImageProps = { type AttemptsGridProps = {
name: string; attemptImages: string[];
image: string; onRefresh: () => void;
}; };
function FaceImage({ name, image }: FaceImageProps) { function AttemptsGrid({ attemptImages, onRefresh }: AttemptsGridProps) {
return (
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll">
{attemptImages.map((image: string) => (
<FaceAttempt key={image} image={image} onRefresh={onRefresh} />
))}
</div>
);
}
type FaceAttemptProps = {
image: string;
onRefresh: () => void;
};
function FaceAttempt({ image, onRefresh }: FaceAttemptProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const data = useMemo(() => {
const parts = image.split("-");
return {
eventId: `${parts[0]}-${parts[1]}`,
name: parts[2],
score: parts[3],
};
}, [image]);
const onDelete = useCallback(() => { const onDelete = useCallback(() => {
axios axios
.post(`/faces/${name}/delete`, { ids: [image] }) .post(`/faces/debug/delete`, { ids: [image] })
.then((resp) => { .then((resp) => {
if (resp.status == 200) { if (resp.status == 200) {
toast.error(`Successfully deleted face.`, { position: "top-center" }); toast.success(`Successfully deleted face.`, {
position: "top-center",
});
onRefresh();
} }
}) })
.catch((error) => { .catch((error) => {
@ -165,7 +215,96 @@ function FaceImage({ name, image }: FaceImageProps) {
}); });
} }
}); });
}, [name, image]); }, [image, onRefresh]);
return (
<div
className="relative h-min"
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
>
{hovered && (
<div className="absolute right-1 top-1">
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => onDelete()}
>
<LuTrash className="size-4 fill-destructive text-destructive" />
</Chip>
</div>
)}
<div className="rounded-md bg-secondary">
<img
className="h-40 rounded-md"
src={`${baseUrl}clips/faces/debug/${image}`}
/>
<div className="p-2">{`${data.name}: ${data.score}`}</div>
</div>
</div>
);
}
type FaceGridProps = {
faceImages: string[];
pageToggle: string;
setUpload: (upload: boolean) => void;
onRefresh: () => void;
};
function FaceGrid({
faceImages,
pageToggle,
setUpload,
onRefresh,
}: FaceGridProps) {
return (
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll">
{faceImages.map((image: string) => (
<FaceImage
key={image}
name={pageToggle}
image={image}
onRefresh={onRefresh}
/>
))}
<Button key="upload" className="size-40" onClick={() => setUpload(true)}>
<LuImagePlus className="size-10" />
</Button>
</div>
);
}
type FaceImageProps = {
name: string;
image: string;
onRefresh: () => void;
};
function FaceImage({ name, image, onRefresh }: FaceImageProps) {
const [hovered, setHovered] = useState(false);
const onDelete = useCallback(() => {
axios
.post(`/faces/${name}/delete`, { ids: [image] })
.then((resp) => {
if (resp.status == 200) {
toast.success(`Successfully deleted face.`, {
position: "top-center",
});
onRefresh();
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(`Failed to delete: ${error.response.data.message}`, {
position: "top-center",
});
} else {
toast.error(`Failed to delete: ${error.message}`, {
position: "top-center",
});
}
});
}, [name, image, onRefresh]);
return ( return (
<div <div