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):
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(
default=170,
title="minimum face distance score required to be considered a match.",
default=0.9,
title="Minimum face distance score required to be considered a match.",
gt=0.0,
le=1.0,
)
min_area: int = Field(
default=500, title="Min area of face box to consider running face recognition."
)
debug_save_images: bool = Field(
default=False, title="Save images of face detections for debugging."
save_attempts: bool = Field(
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}"
)
if self.config.face_recognition.debug_save_images:
if self.config.face_recognition.save_attempts:
# write face to library
folder = os.path.join(FACE_DIR, "debug")
file = os.path.join(folder, f"{id}-{sub_label}-{score}-{face_score}.webp")
os.makedirs(folder, exist_ok=True)
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]:
logger.debug(
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.recognizer: cv2.face.LBPHFaceRecognizer = (
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] = {}

View File

@ -23,7 +23,8 @@ export default function FaceLibrary() {
const { data: faceData, mutate: refreshFaces } = useSWR("faces");
const faces = useMemo<string[]>(
() => (faceData ? Object.keys(faceData) : []),
() =>
faceData ? Object.keys(faceData).filter((face) => face != "debug") : [],
[faceData],
);
const faceImages = useMemo<string[]>(
@ -31,13 +32,24 @@ export default function FaceLibrary() {
[pageToggle, faceData],
);
const faceAttempts = useMemo<string[]>(
() => faceData?.["debug"] || [],
[faceData],
);
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]);
}
// we need to listen on the value of the faces list
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [faces]);
}, [faceAttempts, faces]);
// upload
@ -58,7 +70,7 @@ export default function FaceLibrary() {
setUpload(false);
refreshFaces();
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" },
);
}
@ -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) => (
<ToggleGroupItem
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}
data-nav-item={item}
aria-label={`Select ${item}`}
@ -121,37 +147,61 @@ export default function FaceLibrary() {
</div>
</ScrollArea>
</div>
{pageToggle && (
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll">
{faceImages.map((image: string) => (
<FaceImage key={image} name={pageToggle} image={image} />
))}
<Button
key="upload"
className="size-40"
onClick={() => setUpload(true)}
>
<LuImagePlus className="size-10" />
</Button>
</div>
)}
{pageToggle &&
(pageToggle == "attempts" ? (
<AttemptsGrid attemptImages={faceAttempts} onRefresh={refreshFaces} />
) : (
<FaceGrid
faceImages={faceImages}
pageToggle={pageToggle}
setUpload={setUpload}
onRefresh={refreshFaces}
/>
))}
</div>
);
}
type FaceImageProps = {
name: string;
image: string;
type AttemptsGridProps = {
attemptImages: 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 data = useMemo(() => {
const parts = image.split("-");
return {
eventId: `${parts[0]}-${parts[1]}`,
name: parts[2],
score: parts[3],
};
}, [image]);
const onDelete = useCallback(() => {
axios
.post(`/faces/${name}/delete`, { ids: [image] })
.post(`/faces/debug/delete`, { ids: [image] })
.then((resp) => {
if (resp.status == 200) {
toast.error(`Successfully deleted face.`, { position: "top-center" });
toast.success(`Successfully deleted face.`, {
position: "top-center",
});
onRefresh();
}
})
.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 (
<div