mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-05-12 01:16:02 +02:00
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:
parent
8763390dfe
commit
172e7d494f
@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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)})."
|
||||||
|
@ -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] = {}
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user