From 23c332387127bd03220c52f9da4a88ebc5ef7fba Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 27 Mar 2025 12:29:34 -0500 Subject: [PATCH] Dynamic embeddings reindexing (#17418) * reindex with api endpoint and zmq * threading * frontend * require admin role --- frigate/api/classification.py | 46 +++++ frigate/comms/embeddings_updater.py | 1 + frigate/embeddings/__init__.py | 3 + frigate/embeddings/embeddings.py | 29 +++ frigate/embeddings/maintainer.py | 3 + web/public/locales/en/views/settings.json | 12 +- .../settings/ClassificationSettingsView.tsx | 172 +++++++++++------- 7 files changed, 193 insertions(+), 73 deletions(-) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 975a41c9d..4a6969cd3 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -298,3 +298,49 @@ def reprocess_license_plate(request: Request, event_id: str): content=response, status_code=200, ) + + +@router.put("/reindex", dependencies=[Depends(require_role(["admin"]))]) +def reindex_embeddings(request: Request): + if not request.app.frigate_config.semantic_search.enabled: + message = ( + "Cannot reindex tracked object embeddings, Semantic Search is not enabled." + ) + logger.error(message) + return JSONResponse( + content=( + { + "success": False, + "message": message, + } + ), + status_code=400, + ) + + context: EmbeddingsContext = request.app.embeddings + response = context.reindex_embeddings() + + if response == "started": + return JSONResponse( + content={ + "success": True, + "message": "Embeddings reindexing has started.", + }, + status_code=202, # 202 Accepted + ) + elif response == "in_progress": + return JSONResponse( + content={ + "success": False, + "message": "Embeddings reindexing is already in progress.", + }, + status_code=409, # 409 Conflict + ) + else: + return JSONResponse( + content={ + "success": False, + "message": "Failed to start reindexing.", + }, + status_code=500, + ) diff --git a/frigate/comms/embeddings_updater.py b/frigate/comms/embeddings_updater.py index fc35c4665..6c26af3d1 100644 --- a/frigate/comms/embeddings_updater.py +++ b/frigate/comms/embeddings_updater.py @@ -17,6 +17,7 @@ class EmbeddingsRequestEnum(Enum): register_face = "register_face" reprocess_face = "reprocess_face" reprocess_plate = "reprocess_plate" + reindex = "reindex" class EmbeddingsResponder: diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index e0673565b..c60465845 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -250,3 +250,6 @@ class EmbeddingsContext: return self.requestor.send_data( EmbeddingsRequestEnum.reprocess_plate.value, {"event": event} ) + + def reindex_embeddings(self) -> dict[str, any]: + return self.requestor.send_data(EmbeddingsRequestEnum.reindex.value, {}) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index 7e866d1fe..d2053f5ee 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -3,6 +3,7 @@ import datetime import logging import os +import threading import time from numpy import ndarray @@ -74,6 +75,10 @@ class Embeddings: self.metrics = metrics self.requestor = InterProcessRequestor() + self.reindex_lock = threading.Lock() + self.reindex_thread = None + self.reindex_running = False + # Create tables if they don't exist self.db.create_embeddings_tables() @@ -368,3 +373,27 @@ class Embeddings: totals["status"] = "completed" self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals) + + def start_reindex(self) -> bool: + """Start reindexing in a separate thread if not already running.""" + with self.reindex_lock: + if self.reindex_running: + logger.warning("Reindex embeddings is already running.") + return False + + # Mark as running and start the thread + self.reindex_running = True + self.reindex_thread = threading.Thread( + target=self._reindex_wrapper, daemon=True + ) + self.reindex_thread.start() + return True + + def _reindex_wrapper(self) -> None: + """Wrapper to run reindex and reset running flag when done.""" + try: + self.reindex() + finally: + with self.reindex_lock: + self.reindex_running = False + self.reindex_thread = None diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 9b90f6f2c..85b0e6d54 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -206,6 +206,9 @@ class EmbeddingMaintainer(threading.Thread): self.embeddings.embed_description("", data, upsert=False), pack=False, ) + elif topic == EmbeddingsRequestEnum.reindex.value: + response = self.embeddings.start_reindex() + return "started" if response else "in_progress" processors = [self.realtime_processors, self.post_processors] for processor_list in processors: diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 4a7693416..f6c5b2e99 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -87,9 +87,15 @@ "title": "Semantic Search", "desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.", "readTheDocumentation": "Read the Documentation", - "reindexOnStartup": { - "label": "Re-Index On Startup", - "desc": "Re-indexing will reprocess all thumbnails and descriptions (if enabled) and apply the embeddings on each startup. Don't forget to disable the option after restarting!" + "reindexNow": { + "label": "Reindex Now", + "desc": "Reindexing will regenerate embeddings for all tracked object. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.", + "confirmTitle": "Confirm Reindexing", + "confirmDesc": "Are you sure you want to reindex all tracked object embeddings? This process will run in the background but it may max out your CPU and take a fair amount of time. You can watch the progress on the Explore page.", + "confirmButton": "Reindex", + "success": "Reindexing started successfully.", + "alreadyInProgress": "Reindexing is already in progress.", + "error": "Failed to start reindexing: {{errorMessage}}" }, "modelSize": { "label": "Model Size", diff --git a/web/src/views/settings/ClassificationSettingsView.tsx b/web/src/views/settings/ClassificationSettingsView.tsx index 24c3a9107..d12008f8f 100644 --- a/web/src/views/settings/ClassificationSettingsView.tsx +++ b/web/src/views/settings/ClassificationSettingsView.tsx @@ -21,11 +21,21 @@ import { SelectTrigger, } from "@/components/ui/select"; import { Trans, useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { buttonVariants } from "@/components/ui/button"; type ClassificationSettings = { search: { enabled?: boolean; - reindex?: boolean; model_size?: SearchModelSize; }; face: { @@ -48,39 +58,22 @@ export default function ClassificationSettingsView({ useSWR("config"); const [changedValue, setChangedValue] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isReindexDialogOpen, setIsReindexDialogOpen] = useState(false); const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; const [classificationSettings, setClassificationSettings] = useState({ - search: { - enabled: undefined, - reindex: undefined, - model_size: undefined, - }, - face: { - enabled: undefined, - model_size: undefined, - }, - lpr: { - enabled: undefined, - }, + search: { enabled: undefined, model_size: undefined }, + face: { enabled: undefined, model_size: undefined }, + lpr: { enabled: undefined }, }); const [origSearchSettings, setOrigSearchSettings] = useState({ - search: { - enabled: undefined, - reindex: undefined, - model_size: undefined, - }, - face: { - enabled: undefined, - model_size: undefined, - }, - lpr: { - enabled: undefined, - }, + search: { enabled: undefined, model_size: undefined }, + face: { enabled: undefined, model_size: undefined }, + lpr: { enabled: undefined }, }); useEffect(() => { @@ -89,32 +82,26 @@ export default function ClassificationSettingsView({ setClassificationSettings({ search: { enabled: config.semantic_search.enabled, - reindex: config.semantic_search.reindex, model_size: config.semantic_search.model_size, }, face: { enabled: config.face_recognition.enabled, model_size: config.face_recognition.model_size, }, - lpr: { - enabled: config.lpr.enabled, - }, + lpr: { enabled: config.lpr.enabled }, }); } setOrigSearchSettings({ search: { enabled: config.semantic_search.enabled, - reindex: config.semantic_search.reindex, model_size: config.semantic_search.model_size, }, face: { enabled: config.face_recognition.enabled, model_size: config.face_recognition.model_size, }, - lpr: { - enabled: config.lpr.enabled, - }, + lpr: { enabled: config.lpr.enabled }, }); } // we know that these deps are correct @@ -125,10 +112,7 @@ export default function ClassificationSettingsView({ newConfig: Partial, ) => { setClassificationSettings((prevConfig) => ({ - search: { - ...prevConfig.search, - ...newConfig.search, - }, + search: { ...prevConfig.search, ...newConfig.search }, face: { ...prevConfig.face, ...newConfig.face }, lpr: { ...prevConfig.lpr, ...newConfig.lpr }, })); @@ -141,10 +125,8 @@ export default function ClassificationSettingsView({ axios .put( - `config/set?semantic_search.enabled=${classificationSettings.search.enabled ? "True" : "False"}&semantic_search.reindex=${classificationSettings.search.reindex ? "True" : "False"}&semantic_search.model_size=${classificationSettings.search.model_size}&face_recognition.enabled=${classificationSettings.face.enabled ? "True" : "False"}&face_recognition.model_size=${classificationSettings.face.model_size}&lpr.enabled=${classificationSettings.lpr.enabled ? "True" : "False"}`, - { - requires_restart: 0, - }, + `config/set?semantic_search.enabled=${classificationSettings.search.enabled ? "True" : "False"}&semantic_search.model_size=${classificationSettings.search.model_size}&face_recognition.enabled=${classificationSettings.face.enabled ? "True" : "False"}&face_recognition.model_size=${classificationSettings.face.model_size}&lpr.enabled=${classificationSettings.lpr.enabled ? "True" : "False"}`, + { requires_restart: 0 }, ) .then((res) => { if (res.status === 200) { @@ -156,9 +138,7 @@ export default function ClassificationSettingsView({ } else { toast.error( t("classification.toast.error", { errorMessage: res.statusText }), - { - position: "top-center", - }, + { position: "top-center" }, ); } }) @@ -169,9 +149,7 @@ export default function ClassificationSettingsView({ "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), - { - position: "top-center", - }, + { position: "top-center" }, ); }) .finally(() => { @@ -191,6 +169,43 @@ export default function ClassificationSettingsView({ removeMessage("search_settings", "search_settings"); }, [origSearchSettings, removeMessage]); + const onReindex = useCallback(() => { + setIsLoading(true); + + axios + .put("/reindex") + .then((res) => { + if (res.status === 202) { + toast.success(t("classification.semanticSearch.reindexNow.success"), { + position: "top-center", + }); + } else { + toast.error( + t("classification.semanticSearch.reindexNow.error", { + errorMessage: res.statusText, + }), + { position: "top-center" }, + ); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("classification.semanticSearch.reindexNow.error", { + errorMessage, + }), + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + setIsReindexDialogOpen(false); + }); + }, [t]); + useEffect(() => { if (changedValue) { addMessage( @@ -262,28 +277,18 @@ export default function ClassificationSettingsView({ -
-
- { - handleClassificationConfigChange({ - search: { reindex: isChecked }, - }); - }} - /> -
- -
-
+
+
- classification.semanticSearch.reindexOnStartup.desc + classification.semanticSearch.reindexNow.desc
@@ -316,9 +321,7 @@ export default function ClassificationSettingsView({ value={classificationSettings.search.model_size} onValueChange={(value) => handleClassificationConfigChange({ - search: { - model_size: value as SearchModelSize, - }, + search: { model_size: value as SearchModelSize }, }) } > @@ -346,6 +349,35 @@ export default function ClassificationSettingsView({
+ + + + + {t("classification.semanticSearch.reindexNow.confirmTitle")} + + + + classification.semanticSearch.reindexNow.confirmDesc + + + + + setIsReindexDialogOpen(false)}> + {t("button.cancel", { ns: "common" })} + + + {t("classification.semanticSearch.reindexNow.confirmButton")} + + + + +