Add embeddings reindex progress to the UI (#14268)

* refactor dispatcher

* add reindex to dictionary

* add circular progress bar component

* Add progress to UI when embeddings are reindexing

* readd comments to dispatcher for clarity

* Only report progress every 10 events so we don't spam the logs and websocket

* clean up
This commit is contained in:
Josh Hawkins 2024-10-10 14:28:43 -05:00 committed by GitHub
parent 8ade85edec
commit f67ec241d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 397 additions and 99 deletions

View File

@ -212,6 +212,7 @@ rcond
RDONLY RDONLY
rebranded rebranded
referer referer
reindex
Reolink Reolink
restream restream
restreamed restreamed

View File

@ -15,6 +15,7 @@ from frigate.const import (
INSERT_PREVIEW, INSERT_PREVIEW,
REQUEST_REGION_GRID, REQUEST_REGION_GRID,
UPDATE_CAMERA_ACTIVITY, UPDATE_CAMERA_ACTIVITY,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_EVENT_DESCRIPTION, UPDATE_EVENT_DESCRIPTION,
UPDATE_MODEL_STATE, UPDATE_MODEL_STATE,
UPSERT_REVIEW_SEGMENT, UPSERT_REVIEW_SEGMENT,
@ -86,35 +87,27 @@ class Dispatcher:
self.camera_activity = {} self.camera_activity = {}
self.model_state = {} self.model_state = {}
self.embeddings_reindex = {}
def _receive(self, topic: str, payload: str) -> Optional[Any]: def _receive(self, topic: str, payload: str) -> Optional[Any]:
"""Handle receiving of payload from communicators.""" """Handle receiving of payload from communicators."""
if topic.endswith("set"):
def handle_camera_command(command_type, camera_name, payload):
try: try:
# example /cam_name/detect/set payload=ON|OFF if command_type == "set":
if topic.count("/") == 2: self._camera_settings_handlers[camera_name](camera_name, payload)
camera_name = topic.split("/")[-3] elif command_type == "ptz":
command = topic.split("/")[-2]
self._camera_settings_handlers[command](camera_name, payload)
elif topic.count("/") == 1:
command = topic.split("/")[-2]
self._global_settings_handlers[command](payload)
except IndexError:
logger.error(f"Received invalid set command: {topic}")
return
elif topic.endswith("ptz"):
try:
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
camera_name = topic.split("/")[-2]
self._on_ptz_command(camera_name, payload) self._on_ptz_command(camera_name, payload)
except IndexError: except KeyError:
logger.error(f"Received invalid ptz command: {topic}") logger.error(f"Invalid command type: {command_type}")
return
elif topic == "restart": def handle_restart():
restart_frigate() restart_frigate()
elif topic == INSERT_MANY_RECORDINGS:
def handle_insert_many_recordings():
Recordings.insert_many(payload).execute() Recordings.insert_many(payload).execute()
elif topic == REQUEST_REGION_GRID:
def handle_request_region_grid():
camera = payload camera = payload
grid = get_camera_regions_grid( grid = get_camera_regions_grid(
camera, camera,
@ -122,24 +115,25 @@ class Dispatcher:
max(self.config.model.width, self.config.model.height), max(self.config.model.width, self.config.model.height),
) )
return grid return grid
elif topic == INSERT_PREVIEW:
def handle_insert_preview():
Previews.insert(payload).execute() Previews.insert(payload).execute()
elif topic == UPSERT_REVIEW_SEGMENT:
( def handle_upsert_review_segment():
ReviewSegment.insert(payload) ReviewSegment.insert(payload).on_conflict(
.on_conflict(
conflict_target=[ReviewSegment.id], conflict_target=[ReviewSegment.id],
update=payload, update=payload,
)
.execute()
)
elif topic == CLEAR_ONGOING_REVIEW_SEGMENTS:
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
ReviewSegment.end_time == None
).execute() ).execute()
elif topic == UPDATE_CAMERA_ACTIVITY:
def handle_clear_ongoing_review_segments():
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
ReviewSegment.end_time.is_null(True)
).execute()
def handle_update_camera_activity():
self.camera_activity = payload self.camera_activity = payload
elif topic == UPDATE_EVENT_DESCRIPTION:
def handle_update_event_description():
event: Event = Event.get(Event.id == payload["id"]) event: Event = Event.get(Event.id == payload["id"])
event.data["description"] = payload["description"] event.data["description"] = payload["description"]
event.save() event.save()
@ -147,15 +141,30 @@ class Dispatcher:
"event_update", "event_update",
json.dumps({"id": event.id, "description": event.data["description"]}), json.dumps({"id": event.id, "description": event.data["description"]}),
) )
elif topic == UPDATE_MODEL_STATE:
def handle_update_model_state():
model = payload["model"] model = payload["model"]
state = payload["state"] state = payload["state"]
self.model_state[model] = ModelStatusTypesEnum[state] self.model_state[model] = ModelStatusTypesEnum[state]
self.publish("model_state", json.dumps(self.model_state)) self.publish("model_state", json.dumps(self.model_state))
elif topic == "modelState":
model_state = self.model_state.copy() def handle_model_state():
self.publish("model_state", json.dumps(model_state)) self.publish("model_state", json.dumps(self.model_state.copy()))
elif topic == "onConnect":
def handle_update_embeddings_reindex_progress():
self.embeddings_reindex = payload
self.publish(
"embeddings_reindex_progress",
json.dumps(payload),
)
def handle_embeddings_reindex_progress():
self.publish(
"embeddings_reindex_progress",
json.dumps(self.embeddings_reindex.copy()),
)
def handle_on_connect():
camera_status = self.camera_activity.copy() camera_status = self.camera_activity.copy()
for camera in camera_status.keys(): for camera in camera_status.keys():
@ -170,6 +179,46 @@ class Dispatcher:
} }
self.publish("camera_activity", json.dumps(camera_status)) self.publish("camera_activity", json.dumps(camera_status))
# Dictionary mapping topic to handlers
topic_handlers = {
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
REQUEST_REGION_GRID: handle_request_region_grid,
INSERT_PREVIEW: handle_insert_preview,
UPSERT_REVIEW_SEGMENT: handle_upsert_review_segment,
CLEAR_ONGOING_REVIEW_SEGMENTS: handle_clear_ongoing_review_segments,
UPDATE_CAMERA_ACTIVITY: handle_update_camera_activity,
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
UPDATE_MODEL_STATE: handle_update_model_state,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
"restart": handle_restart,
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
"modelState": handle_model_state,
"onConnect": handle_on_connect,
}
if topic.endswith("set") or topic.endswith("ptz"):
try:
parts = topic.split("/")
if len(parts) == 3 and topic.endswith("set"):
# example /cam_name/detect/set payload=ON|OFF
camera_name = parts[-3]
command = parts[-2]
handle_camera_command("set", camera_name, payload)
elif len(parts) == 2 and topic.endswith("set"):
command = parts[-2]
self._global_settings_handlers[command](payload)
elif len(parts) == 2 and topic.endswith("ptz"):
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
camera_name = parts[-2]
handle_camera_command("ptz", camera_name, payload)
except IndexError:
logger.error(
f"Received invalid {topic.split('/')[-1]} command: {topic}"
)
return
elif topic in topic_handlers:
return topic_handlers[topic]()
else: else:
self.publish(topic, payload, retain=False) self.publish(topic, payload, retain=False)

View File

@ -85,6 +85,7 @@ CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
UPDATE_CAMERA_ACTIVITY = "update_camera_activity" UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
UPDATE_EVENT_DESCRIPTION = "update_event_description" UPDATE_EVENT_DESCRIPTION = "update_event_description"
UPDATE_MODEL_STATE = "update_model_state" UPDATE_MODEL_STATE = "update_model_state"
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
# Stats Values # Stats Values

View File

@ -10,7 +10,7 @@ from playhouse.shortcuts import model_to_dict
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config.semantic_search import SemanticSearchConfig from frigate.config.semantic_search import SemanticSearchConfig
from frigate.const import UPDATE_MODEL_STATE from frigate.const import UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_MODEL_STATE
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.models import Event from frigate.models import Event
from frigate.types import ModelStatusTypesEnum from frigate.types import ModelStatusTypesEnum
@ -165,19 +165,36 @@ class Embeddings:
return embedding return embedding
def reindex(self) -> None: def reindex(self) -> None:
logger.info("Indexing event embeddings...") logger.info("Indexing tracked object embeddings...")
self._drop_tables() self._drop_tables()
self._create_tables() self._create_tables()
st = time.time() st = time.time()
totals = { totals = {
"thumb": 0, "thumbnails": 0,
"desc": 0, "descriptions": 0,
"processed_objects": 0,
"total_objects": 0,
} }
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
# Get total count of events to process
total_events = (
Event.select()
.where(
(Event.has_clip == True | Event.has_snapshot == True)
& Event.thumbnail.is_null(False)
)
.count()
)
totals["total_objects"] = total_events
batch_size = 100 batch_size = 100
current_page = 1 current_page = 1
processed_events = 0
events = ( events = (
Event.select() Event.select()
.where( .where(
@ -193,11 +210,29 @@ class Embeddings:
for event in events: for event in events:
thumbnail = base64.b64decode(event.thumbnail) thumbnail = base64.b64decode(event.thumbnail)
self.upsert_thumbnail(event.id, thumbnail) self.upsert_thumbnail(event.id, thumbnail)
totals["thumb"] += 1 totals["thumbnails"] += 1
if description := event.data.get("description", "").strip(): if description := event.data.get("description", "").strip():
totals["desc"] += 1 totals["descriptions"] += 1
self.upsert_description(event.id, description) self.upsert_description(event.id, description)
totals["processed_objects"] += 1
# report progress every 10 events so we don't spam the logs
if (totals["processed_objects"] % 10) == 0:
progress = (processed_events / total_events) * 100
logger.debug(
"Processed %d/%d events (%.2f%% complete) | Thumbnails: %d, Descriptions: %d",
processed_events,
total_events,
progress,
totals["thumbnails"],
totals["descriptions"],
)
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
# Move to the next page
current_page += 1 current_page += 1
events = ( events = (
Event.select() Event.select()
@ -211,7 +246,8 @@ class Embeddings:
logger.info( logger.info(
"Embedded %d thumbnails and %d descriptions in %s seconds", "Embedded %d thumbnails and %d descriptions in %s seconds",
totals["thumb"], totals["thumbnails"],
totals["desc"], totals["descriptions"],
time.time() - st, time.time() - st,
) )
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)

View File

@ -2,6 +2,7 @@ import { baseUrl } from "./baseUrl";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket"; import useWebSocket, { ReadyState } from "react-use-websocket";
import { import {
EmbeddingsReindexProgressType,
FrigateCameraState, FrigateCameraState,
FrigateEvent, FrigateEvent,
FrigateReview, FrigateReview,
@ -302,6 +303,42 @@ export function useModelState(
return { payload: data ? data[model] : undefined }; return { payload: data ? data[model] : undefined };
} }
export function useEmbeddingsReindexProgress(
revalidateOnFocus: boolean = true,
): {
payload: EmbeddingsReindexProgressType;
} {
const {
value: { payload },
send: sendCommand,
} = useWs("embeddings_reindex_progress", "embeddingsReindexProgress");
const data = useDeepMemo(JSON.parse(payload as string));
useEffect(() => {
let listener = undefined;
if (revalidateOnFocus) {
sendCommand("embeddingsReindexProgress");
listener = () => {
if (document.visibilityState == "visible") {
sendCommand("embeddingsReindexProgress");
}
};
addEventListener("visibilitychange", listener);
}
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
}
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data };
}
export function useMotionActivity(camera: string): { payload: string } { export function useMotionActivity(camera: string): { payload: string } {
const { const {
value: { payload }, value: { payload },

View File

@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
interface Props {
max: number;
value: number;
min: number;
gaugePrimaryColor: string;
gaugeSecondaryColor: string;
className?: string;
}
export default function AnimatedCircularProgressBar({
max = 100,
min = 0,
value = 0,
gaugePrimaryColor,
gaugeSecondaryColor,
className,
}: Props) {
const circumference = 2 * Math.PI * 45;
const percentPx = circumference / 100;
const currentPercent = Math.floor(((value - min) / (max - min)) * 100);
return (
<div
className={cn("relative size-40 text-2xl font-semibold", className)}
style={
{
"--circle-size": "100px",
"--circumference": circumference,
"--percent-to-px": `${percentPx}px`,
"--gap-percent": "5",
"--offset-factor": "0",
"--transition-length": "1s",
"--transition-step": "200ms",
"--delay": "0s",
"--percent-to-deg": "3.6deg",
transform: "translateZ(0)",
} as React.CSSProperties
}
>
<svg
fill="none"
className="size-full"
strokeWidth="2"
viewBox="0 0 100 100"
>
{currentPercent <= 90 && currentPercent >= 0 && (
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100"
style={
{
stroke: gaugeSecondaryColor,
"--stroke-percent": 90 - currentPercent,
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transform:
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
transition: "all var(--transition-length) ease var(--delay)",
transformOrigin:
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
)}
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100"
style={
{
stroke: gaugePrimaryColor,
"--stroke-percent": currentPercent,
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transition:
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
transitionProperty: "stroke-dasharray,transform",
transform:
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
transformOrigin:
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
</svg>
<span
data-current-value={currentPercent}
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
>
{currentPercent}%
</span>
</div>
);
}

View File

@ -1,5 +1,10 @@
import { useEventUpdate, useModelState } from "@/api/ws"; import {
useEmbeddingsReindexProgress,
useEventUpdate,
useModelState,
} from "@/api/ws";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useTimezone } from "@/hooks/use-date-utils"; import { useTimezone } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -182,6 +187,18 @@ export default function Explore() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventUpdate]); }, [eventUpdate]);
// embeddings reindex progress
const { payload: reindexProgress } = useEmbeddingsReindexProgress();
const embeddingsReindexing = useMemo(
() =>
reindexProgress
? reindexProgress.total_objects - reindexProgress.processed_objects > 0
: undefined,
[reindexProgress],
);
// model states // model states
const { payload: textModelState } = useModelState( const { payload: textModelState } = useModelState(
@ -238,17 +255,57 @@ export default function Explore() {
return ( return (
<> <>
{config?.semantic_search.enabled && !allModelsLoaded ? ( {config?.semantic_search.enabled &&
(!allModelsLoaded || embeddingsReindexing) ? (
<div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2"> <div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2">
<div className="flex flex-col items-center justify-center space-y-3 rounded-lg bg-background/50 p-5"> <div className="flex max-w-96 flex-col items-center justify-center space-y-3 rounded-lg bg-background/50 p-5">
<div className="my-5 flex flex-col items-center gap-2 text-xl"> <div className="my-5 flex flex-col items-center gap-2 text-xl">
<TbExclamationCircle className="mb-3 size-10" /> <TbExclamationCircle className="mb-3 size-10" />
<div>Search Unavailable</div> <div>Search Unavailable</div>
</div> </div>
<div className="max-w-96 text-center"> {embeddingsReindexing && (
Frigate is downloading the necessary embeddings models to support <>
semantic searching. This may take several minutes depending on the <div className="text-center text-primary-variant">
speed of your network connection. Search can be used after tracked object embeddings have
finished reindexing.
</div>
<div className="pt-5 text-center">
<AnimatedCircularProgressBar
min={0}
max={reindexProgress.total_objects}
value={reindexProgress.processed_objects}
gaugePrimaryColor="hsl(var(--selected))"
gaugeSecondaryColor="hsl(var(--secondary))"
/>
</div>
<div className="flex w-96 flex-col gap-2 py-5">
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Thumbnails embedded:
</span>
{reindexProgress.thumbnails}
</div>
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Descriptions embedded:
</span>
{reindexProgress.descriptions}
</div>
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Tracked objects processed:
</span>
{reindexProgress.processed_objects}
</div>
</div>
</>
)}
{!allModelsLoaded && (
<>
<div className="text-center text-primary-variant">
Frigate is downloading the necessary embeddings models to
support semantic searching. This may take several minutes
depending on the speed of your network connection.
</div> </div>
<div className="flex w-96 flex-col gap-2 py-5"> <div className="flex w-96 flex-col gap-2 py-5">
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
@ -276,11 +333,11 @@ export default function Explore() {
An error has occurred. Check Frigate logs. An error has occurred. Check Frigate logs.
</div> </div>
)} )}
<div className="max-w-96 text-center"> <div className="text-center text-primary-variant">
You may want to reindex the embeddings of your tracked objects You may want to reindex the embeddings of your tracked objects
once the models are downloaded. once the models are downloaded.
</div> </div>
<div className="flex max-w-96 items-center text-primary-variant"> <div className="flex items-center text-primary-variant">
<Link <Link
to="https://docs.frigate.video/configuration/semantic_search" to="https://docs.frigate.video/configuration/semantic_search"
target="_blank" target="_blank"
@ -291,6 +348,8 @@ export default function Explore() {
<LuExternalLink className="ml-2 inline-flex size-3" /> <LuExternalLink className="ml-2 inline-flex size-3" />
</Link> </Link>
</div> </div>
</>
)}
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -62,4 +62,11 @@ export type ModelState =
| "downloaded" | "downloaded"
| "error"; | "error";
export type EmbeddingsReindexProgressType = {
thumbnails: number;
descriptions: number;
processed_objects: number;
total_objects: number;
};
export type ToggleableSetting = "ON" | "OFF"; export type ToggleableSetting = "ON" | "OFF";