Merge branch 'dev' into updated-documentation

This commit is contained in:
Rui Alves 2024-10-14 09:43:08 +01:00
commit 6d596a372c
44 changed files with 1406 additions and 532 deletions

View File

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

View File

@ -518,8 +518,9 @@ semantic_search:
enabled: False enabled: False
# Optional: Re-index embeddings database from historical tracked objects (default: shown below) # Optional: Re-index embeddings database from historical tracked objects (default: shown below)
reindex: False reindex: False
# Optional: Set device used to run embeddings, options are AUTO, CPU, GPU. (default: shown below) # Optional: Set the model size used for embeddings. (default: shown below)
device: "AUTO" # NOTE: small model runs on CPU and large model runs on GPU
model_size: "small"
# Optional: Configuration for AI generated tracked object descriptions # Optional: Configuration for AI generated tracked object descriptions
# NOTE: Semantic Search must be enabled for this to do anything. # NOTE: Semantic Search must be enabled for this to do anything.

View File

@ -29,15 +29,26 @@ If you are enabling the Search feature for the first time, be advised that Friga
### Jina AI CLIP ### Jina AI CLIP
The vision model is able to embed both images and text into the same vector space, which allows `image -> image` and `text -> image` similarity searches. Frigate uses this model on tracked objects to encode the thumbnail image and store it in the database. When searching for tracked objects via text in the search box, Frigate will perform a `text -> image` similarity search against this embedding. When clicking "Find Similar" in the tracked object detail pane, Frigate will perform an `image -> image` similarity search to retrieve the closest matching thumbnails.
The text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Search page when clicking on the gray tracked object chip at the top left of each review item. See [the Generative AI docs](/configuration/genai.md) for more information on how to automatically generate tracked object descriptions.
Differently weighted CLIP models are available and can be selected by setting the `model_size` config option:
:::tip :::tip
The CLIP models are downloaded in ONNX format, which means they will be accelerated using GPU hardware when available. This depends on the Docker build that is used. See [the object detector docs](../configuration/object_detectors.md) for more information. The CLIP models are downloaded in ONNX format, which means they will be accelerated using GPU hardware when available. This depends on the Docker build that is used. See [the object detector docs](../configuration/object_detectors.md) for more information.
::: :::
The vision model is able to embed both images and text into the same vector space, which allows `image -> image` and `text -> image` similarity searches. Frigate uses this model on tracked objects to encode the thumbnail image and store it in the database. When searching for tracked objects via text in the search box, Frigate will perform a `text -> image` similarity search against this embedding. When clicking "Find Similar" in the tracked object detail pane, Frigate will perform an `image -> image` similarity search to retrieve the closest matching thumbnails. ```yaml
semantic_search:
enabled: True
model_size: small
```
The text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Search page when clicking on the gray tracked object chip at the top left of each review item. See [the Generative AI docs](/configuration/genai.md) for more information on how to automatically generate tracked object descriptions. - Configuring the `large` model employs the full Jina model and will automatically run on the GPU if applicable.
- Configuring the `small` model employs a quantized version of the model that uses much less RAM and runs faster on CPU with a very negligible difference in embedding quality.
## Usage ## Usage

View File

@ -11,9 +11,7 @@ class EventsSubLabelBody(BaseModel):
class EventsDescriptionBody(BaseModel): class EventsDescriptionBody(BaseModel):
description: Union[str, None] = Field( description: Union[str, None] = Field(title="The description of the event")
title="The description of the event", min_length=1
)
class EventsCreateBody(BaseModel): class EventsCreateBody(BaseModel):

View File

@ -473,12 +473,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
) )
thumb_result = context.search_thumbnail(search_event) thumb_result = context.search_thumbnail(search_event)
thumb_ids = dict( thumb_ids = {result[0]: result[1] for result in thumb_result}
zip(
[result[0] for result in thumb_result],
context.thumb_stats.normalize([result[1] for result in thumb_result]),
)
)
search_results = { search_results = {
event_id: {"distance": distance, "source": "thumbnail"} event_id: {"distance": distance, "source": "thumbnail"}
for event_id, distance in thumb_ids.items() for event_id, distance in thumb_ids.items()
@ -486,15 +481,18 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
else: else:
search_types = search_type.split(",") search_types = search_type.split(",")
# only save stats for multi-modal searches
save_stats = "thumbnail" in search_types and "description" in search_types
if "thumbnail" in search_types: if "thumbnail" in search_types:
thumb_result = context.search_thumbnail(query) thumb_result = context.search_thumbnail(query)
thumb_distances = context.thumb_stats.normalize(
[result[1] for result in thumb_result], save_stats
)
thumb_ids = dict( thumb_ids = dict(
zip( zip([result[0] for result in thumb_result], thumb_distances)
[result[0] for result in thumb_result],
context.thumb_stats.normalize(
[result[1] for result in thumb_result]
),
)
) )
search_results.update( search_results.update(
{ {
@ -505,12 +503,13 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
if "description" in search_types: if "description" in search_types:
desc_result = context.search_description(query) desc_result = context.search_description(query)
desc_ids = dict(
zip( desc_distances = context.desc_stats.normalize(
[result[0] for result in desc_result], [result[1] for result in desc_result], save_stats
context.desc_stats.normalize([result[1] for result in desc_result]),
)
) )
desc_ids = dict(zip([result[0] for result in desc_result], desc_distances))
for event_id, distance in desc_ids.items(): for event_id, distance in desc_ids.items():
if ( if (
event_id not in search_results event_id not in search_results
@ -927,27 +926,19 @@ def set_description(
new_description = body.description new_description = body.description
if new_description is None or len(new_description) == 0:
return JSONResponse(
content=(
{
"success": False,
"message": "description cannot be empty",
}
),
status_code=400,
)
event.data["description"] = new_description event.data["description"] = new_description
event.save() event.save()
# If semantic search is enabled, update the index # If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled: if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
context.update_description( if len(new_description) > 0:
event_id, context.update_description(
new_description, event_id,
) new_description,
)
else:
context.db.delete_embeddings_description(event_ids=[event_id])
response_message = ( response_message = (
f"Event {event_id} description is now blank" f"Event {event_id} description is now blank"
@ -1033,8 +1024,8 @@ def delete_event(request: Request, event_id: str):
# If semantic search is enabled, update the index # If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled: if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
context.db.delete_embeddings_thumbnail(id=[event_id]) context.db.delete_embeddings_thumbnail(event_ids=[event_id])
context.db.delete_embeddings_description(id=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id])
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " deleted"}), content=({"success": True, "message": "Event " + event_id + " deleted"}),
status_code=200, status_code=200,

View File

@ -581,12 +581,12 @@ class FrigateApp:
self.init_recording_manager() self.init_recording_manager()
self.init_review_segment_manager() self.init_review_segment_manager()
self.init_go2rtc() self.init_go2rtc()
self.start_detectors()
self.init_embeddings_manager()
self.bind_database() self.bind_database()
self.check_db_data_migrations() self.check_db_data_migrations()
self.init_inter_process_communicator() self.init_inter_process_communicator()
self.init_dispatcher() self.init_dispatcher()
self.start_detectors()
self.init_embeddings_manager()
self.init_embeddings_client() self.init_embeddings_client()
self.start_video_output_processor() self.start_video_output_processor()
self.start_ptz_autotracker() self.start_ptz_autotracker()

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,
@ -63,6 +64,9 @@ class Dispatcher:
self.onvif = onvif self.onvif = onvif
self.ptz_metrics = ptz_metrics self.ptz_metrics = ptz_metrics
self.comms = communicators self.comms = communicators
self.camera_activity = {}
self.model_state = {}
self.embeddings_reindex = {}
self._camera_settings_handlers: dict[str, Callable] = { self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command, "audio": self._on_audio_command,
@ -84,37 +88,25 @@ class Dispatcher:
for comm in self.comms: for comm in self.comms:
comm.subscribe(self._receive) comm.subscribe(self._receive)
self.camera_activity = {}
self.model_state = {}
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, command, payload):
try: try:
# example /cam_name/detect/set payload=ON|OFF if command_type == "set":
if topic.count("/") == 2:
camera_name = topic.split("/")[-3]
command = topic.split("/")[-2]
self._camera_settings_handlers[command](camera_name, payload) self._camera_settings_handlers[command](camera_name, payload)
elif topic.count("/") == 1: elif command_type == "ptz":
command = topic.split("/")[-2] self._on_ptz_command(camera_name, payload)
self._global_settings_handlers[command](payload) except KeyError:
except IndexError: logger.error(f"Invalid command type or handler: {command_type}")
logger.error(f"Received invalid set command: {topic}")
return def handle_restart():
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)
except IndexError:
logger.error(f"Received invalid ptz command: {topic}")
return
elif topic == "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 +114,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 +140,31 @@ 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:
model = payload["model"] def handle_update_model_state():
state = payload["state"] if payload:
self.model_state[model] = ModelStatusTypesEnum[state] model = payload["model"]
self.publish("model_state", json.dumps(self.model_state)) state = payload["state"]
elif topic == "modelState": self.model_state[model] = ModelStatusTypesEnum[state]
model_state = self.model_state.copy() self.publish("model_state", json.dumps(self.model_state))
self.publish("model_state", json.dumps(model_state))
elif topic == "onConnect": def handle_model_state():
self.publish("model_state", json.dumps(self.model_state.copy()))
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,51 @@ class Dispatcher:
} }
self.publish("camera_activity", json.dumps(camera_status)) self.publish("camera_activity", json.dumps(camera_status))
self.publish("model_state", json.dumps(self.model_state.copy()))
self.publish(
"embeddings_reindex_progress",
json.dumps(self.embeddings_reindex.copy()),
)
# 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, command, 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

@ -22,7 +22,7 @@ class EmbeddingsResponder:
def check_for_request(self, process: Callable) -> None: def check_for_request(self, process: Callable) -> None:
while True: # load all messages that are queued while True: # load all messages that are queued
has_message, _, _ = zmq.select([self.socket], [], [], 1) has_message, _, _ = zmq.select([self.socket], [], [], 0.1)
if not has_message: if not has_message:
break break
@ -54,8 +54,11 @@ class EmbeddingsRequestor:
def send_data(self, topic: str, data: any) -> str: def send_data(self, topic: str, data: any) -> str:
"""Sends data and then waits for reply.""" """Sends data and then waits for reply."""
self.socket.send_json((topic, data)) try:
return self.socket.recv_json() self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
def stop(self) -> None: def stop(self) -> None:
self.socket.close() self.socket.close()

View File

@ -39,7 +39,7 @@ class EventMetadataSubscriber(Subscriber):
super().__init__(topic) super().__init__(topic)
def check_for_update( def check_for_update(
self, timeout: float = None self, timeout: float = 1
) -> Optional[tuple[EventMetadataTypeEnum, str, RegenerateDescriptionEnum]]: ) -> Optional[tuple[EventMetadataTypeEnum, str, RegenerateDescriptionEnum]]:
return super().check_for_update(timeout) return super().check_for_update(timeout)

View File

@ -65,8 +65,11 @@ class InterProcessRequestor:
def send_data(self, topic: str, data: any) -> any: def send_data(self, topic: str, data: any) -> any:
"""Sends data and then waits for reply.""" """Sends data and then waits for reply."""
self.socket.send_json((topic, data)) try:
return self.socket.recv_json() self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
def stop(self) -> None: def stop(self) -> None:
self.socket.close() self.socket.close()

View File

@ -12,4 +12,6 @@ class SemanticSearchConfig(FrigateBaseModel):
reindex: Optional[bool] = Field( reindex: Optional[bool] = Field(
default=False, title="Reindex all detections on startup." default=False, title="Reindex all detections on startup."
) )
device: str = Field(default="AUTO", title="Device Type") model_size: str = Field(
default="small", title="The size of the embeddings model used."
)

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

@ -28,3 +28,26 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def delete_embeddings_description(self, event_ids: list[str]) -> None: def delete_embeddings_description(self, event_ids: list[str]) -> None:
ids = ",".join(["?" for _ in event_ids]) ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids) self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
def drop_embeddings_tables(self) -> None:
self.execute_sql("""
DROP TABLE vec_descriptions;
""")
self.execute_sql("""
DROP TABLE vec_thumbnails;
""")
def create_embeddings_tables(self) -> None:
"""Create vec0 virtual table for embeddings"""
self.execute_sql("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
id TEXT PRIMARY KEY,
thumbnail_embedding FLOAT[768] distance_metric=cosine
);
""")
self.execute_sql("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
id TEXT PRIMARY KEY,
description_embedding FLOAT[768] distance_metric=cosine
);
""")

View File

@ -157,10 +157,14 @@ class ModelConfig(BaseModel):
self._model_hash = file_hash.hexdigest() self._model_hash = file_hash.hexdigest()
def create_colormap(self, enabled_labels: set[str]) -> None: def create_colormap(self, enabled_labels: set[str]) -> None:
"""Get a list of colors for enabled labels.""" """Get a list of colors for enabled labels that aren't attributes."""
colors = generate_color_palette(len(enabled_labels)) enabled_trackable_labels = list(
filter(lambda label: label not in self._all_attributes, enabled_labels)
self._colormap = {label: color for label, color in zip(enabled_labels, colors)} )
colors = generate_color_palette(len(enabled_trackable_labels))
self._colormap = {
label: color for label, color in zip(enabled_trackable_labels, colors)
}
model_config = ConfigDict(extra="forbid", protected_namespaces=()) model_config = ConfigDict(extra="forbid", protected_namespaces=())

View File

@ -3,6 +3,7 @@ import os
import numpy as np import numpy as np
import openvino as ov import openvino as ov
import openvino.properties as props
from pydantic import Field from pydantic import Field
from typing_extensions import Literal from typing_extensions import Literal
@ -34,6 +35,8 @@ class OvDetector(DetectionApi):
logger.error(f"OpenVino model file {detector_config.model.path} not found.") logger.error(f"OpenVino model file {detector_config.model.path} not found.")
raise FileNotFoundError raise FileNotFoundError
os.makedirs("/config/model_cache/openvino", exist_ok=True)
self.ov_core.set_property({props.cache_dir: "/config/model_cache/openvino"})
self.interpreter = self.ov_core.compile_model( self.interpreter = self.ov_core.compile_model(
model=detector_config.model.path, device_name=detector_config.device model=detector_config.model.path, device_name=detector_config.device
) )

View File

@ -19,7 +19,6 @@ from frigate.models import Event
from frigate.util.builtin import serialize from frigate.util.builtin import serialize
from frigate.util.services import listen from frigate.util.services import listen
from .embeddings import Embeddings
from .maintainer import EmbeddingMaintainer from .maintainer import EmbeddingMaintainer
from .util import ZScoreNormalization from .util import ZScoreNormalization
@ -57,12 +56,6 @@ def manage_embeddings(config: FrigateConfig) -> None:
models = [Event] models = [Event]
db.bind(models) db.bind(models)
embeddings = Embeddings(config.semantic_search, db)
# Check if we need to re-index events
if config.semantic_search.reindex:
embeddings.reindex()
maintainer = EmbeddingMaintainer( maintainer = EmbeddingMaintainer(
db, db,
config, config,
@ -114,19 +107,25 @@ class EmbeddingsContext:
query_embedding = row[0] query_embedding = row[0]
else: else:
# If no embedding found, generate it and return it # If no embedding found, generate it and return it
query_embedding = serialize( data = self.requestor.send_data(
self.requestor.send_data( EmbeddingsRequestEnum.embed_thumbnail.value,
EmbeddingsRequestEnum.embed_thumbnail.value, {"id": str(query.id), "thumbnail": str(query.thumbnail)},
{"id": query.id, "thumbnail": query.thumbnail},
)
) )
if not data:
return []
query_embedding = serialize(data)
else: else:
query_embedding = serialize( data = self.requestor.send_data(
self.requestor.send_data( EmbeddingsRequestEnum.generate_search.value, query
EmbeddingsRequestEnum.generate_search.value, query
)
) )
if not data:
return []
query_embedding = serialize(data)
sql_query = """ sql_query = """
SELECT SELECT
id, id,
@ -155,12 +154,15 @@ class EmbeddingsContext:
def search_description( def search_description(
self, query_text: str, event_ids: list[str] = None self, query_text: str, event_ids: list[str] = None
) -> list[tuple[str, float]]: ) -> list[tuple[str, float]]:
query_embedding = serialize( data = self.requestor.send_data(
self.requestor.send_data( EmbeddingsRequestEnum.generate_search.value, query_text
EmbeddingsRequestEnum.generate_search.value, query_text
)
) )
if not data:
return []
query_embedding = serialize(data)
# Prepare the base SQL query # Prepare the base SQL query
sql_query = """ sql_query = """
SELECT SELECT

View File

@ -3,14 +3,20 @@
import base64 import base64
import io import io
import logging import logging
import os
import time import time
from numpy import ndarray
from PIL import Image from PIL import Image
from playhouse.shortcuts import model_to_dict 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 (
CONFIG_DIR,
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
@ -63,12 +69,14 @@ class Embeddings:
self.requestor = InterProcessRequestor() self.requestor = InterProcessRequestor()
# Create tables if they don't exist # Create tables if they don't exist
self._create_tables() self.db.create_embeddings_tables()
models = [ models = [
"jinaai/jina-clip-v1-text_model_fp16.onnx", "jinaai/jina-clip-v1-text_model_fp16.onnx",
"jinaai/jina-clip-v1-tokenizer", "jinaai/jina-clip-v1-tokenizer",
"jinaai/jina-clip-v1-vision_model_fp16.onnx", "jinaai/jina-clip-v1-vision_model_fp16.onnx"
if config.model_size == "large"
else "jinaai/jina-clip-v1-vision_model_quantized.onnx",
"jinaai/jina-clip-v1-preprocessor_config.json", "jinaai/jina-clip-v1-preprocessor_config.json",
] ]
@ -81,12 +89,6 @@ class Embeddings:
}, },
) )
def jina_text_embedding_function(outputs):
return outputs[0]
def jina_vision_embedding_function(outputs):
return outputs[0]
self.text_embedding = GenericONNXEmbedding( self.text_embedding = GenericONNXEmbedding(
model_name="jinaai/jina-clip-v1", model_name="jinaai/jina-clip-v1",
model_file="text_model_fp16.onnx", model_file="text_model_fp16.onnx",
@ -94,49 +96,34 @@ class Embeddings:
download_urls={ download_urls={
"text_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx", "text_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx",
}, },
embedding_function=jina_text_embedding_function, model_size=config.model_size,
model_type="text", model_type="text",
requestor=self.requestor,
device="CPU", device="CPU",
) )
self.vision_embedding = GenericONNXEmbedding( model_file = (
model_name="jinaai/jina-clip-v1", "vision_model_fp16.onnx"
model_file="vision_model_fp16.onnx", if self.config.model_size == "large"
download_urls={ else "vision_model_quantized.onnx"
"vision_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/vision_model_fp16.onnx",
"preprocessor_config.json": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/preprocessor_config.json",
},
embedding_function=jina_vision_embedding_function,
model_type="vision",
device=self.config.device,
) )
def _create_tables(self): download_urls = {
# Create vec0 virtual table for thumbnail embeddings model_file: f"https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/{model_file}",
self.db.execute_sql(""" "preprocessor_config.json": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/preprocessor_config.json",
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0( }
id TEXT PRIMARY KEY,
thumbnail_embedding FLOAT[768]
);
""")
# Create vec0 virtual table for description embeddings self.vision_embedding = GenericONNXEmbedding(
self.db.execute_sql(""" model_name="jinaai/jina-clip-v1",
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0( model_file=model_file,
id TEXT PRIMARY KEY, download_urls=download_urls,
description_embedding FLOAT[768] model_size=config.model_size,
); model_type="vision",
""") requestor=self.requestor,
device="GPU" if config.model_size == "large" else "CPU",
)
def _drop_tables(self): def upsert_thumbnail(self, event_id: str, thumbnail: bytes) -> ndarray:
self.db.execute_sql("""
DROP TABLE vec_descriptions;
""")
self.db.execute_sql("""
DROP TABLE vec_thumbnails;
""")
def upsert_thumbnail(self, event_id: str, thumbnail: bytes):
# Convert thumbnail bytes to PIL Image # Convert thumbnail bytes to PIL Image
image = Image.open(io.BytesIO(thumbnail)).convert("RGB") image = Image.open(io.BytesIO(thumbnail)).convert("RGB")
embedding = self.vision_embedding([image])[0] embedding = self.vision_embedding([image])[0]
@ -151,9 +138,31 @@ class Embeddings:
return embedding return embedding
def upsert_description(self, event_id: str, description: str): def batch_upsert_thumbnail(self, event_thumbs: dict[str, bytes]) -> list[ndarray]:
embedding = self.text_embedding([description])[0] images = [
Image.open(io.BytesIO(thumb)).convert("RGB")
for thumb in event_thumbs.values()
]
ids = list(event_thumbs.keys())
embeddings = self.vision_embedding(images)
items = []
for i in range(len(ids)):
items.append(ids[i])
items.append(serialize(embeddings[i]))
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
)
return embeddings
def upsert_description(self, event_id: str, description: str) -> ndarray:
embedding = self.text_embedding([description])[0]
self.db.execute_sql( self.db.execute_sql(
""" """
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding) INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
@ -164,20 +173,64 @@ class Embeddings:
return embedding return embedding
def reindex(self) -> None: def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray:
logger.info("Indexing event embeddings...") embeddings = self.text_embedding(list(event_descriptions.values()))
ids = list(event_descriptions.keys())
self._drop_tables() items = []
self._create_tables()
for i in range(len(ids)):
items.append(ids[i])
items.append(serialize(embeddings[i]))
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
)
return embeddings
def reindex(self) -> None:
logger.info("Indexing tracked object embeddings...")
self.db.drop_embeddings_tables()
logger.debug("Dropped embeddings tables.")
self.db.create_embeddings_tables()
logger.debug("Created embeddings tables.")
# Delete the saved stats file
if os.path.exists(os.path.join(CONFIG_DIR, ".search_stats.json")):
os.remove(os.path.join(CONFIG_DIR, ".search_stats.json"))
st = time.time() st = time.time()
# 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()
)
batch_size = 32
current_page = 1
totals = { totals = {
"thumb": 0, "thumbnails": 0,
"desc": 0, "descriptions": 0,
"processed_objects": total_events - 1 if total_events < batch_size else 0,
"total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing",
} }
batch_size = 100 self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
current_page = 1
events = ( events = (
Event.select() Event.select()
.where( .where(
@ -190,14 +243,45 @@ class Embeddings:
while len(events) > 0: while len(events) > 0:
event: Event event: Event
batch_thumbs = {}
batch_descs = {}
for event in events: for event in events:
thumbnail = base64.b64decode(event.thumbnail) batch_thumbs[event.id] = base64.b64decode(event.thumbnail)
self.upsert_thumbnail(event.id, thumbnail) totals["thumbnails"] += 1
totals["thumb"] += 1
if description := event.data.get("description", "").strip():
totals["desc"] += 1
self.upsert_description(event.id, description)
if description := event.data.get("description", "").strip():
batch_descs[event.id] = description
totals["descriptions"] += 1
totals["processed_objects"] += 1
# run batch embedding
self.batch_upsert_thumbnail(batch_thumbs)
if batch_descs:
self.batch_upsert_description(batch_descs)
# report progress every batch so we don't spam the logs
progress = (totals["processed_objects"] / total_events) * 100
logger.debug(
"Processed %d/%d events (%.2f%% complete) | Thumbnails: %d, Descriptions: %d",
totals["processed_objects"],
total_events,
progress,
totals["thumbnails"],
totals["descriptions"],
)
# Calculate time remaining
elapsed_time = time.time() - st
avg_time_per_event = elapsed_time / totals["processed_objects"]
remaining_events = total_events - totals["processed_objects"]
time_remaining = avg_time_per_event * remaining_events
totals["time_remaining"] = int(time_remaining)
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 +295,10 @@ 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, round(time.time() - st, 1),
) )
totals["status"] = "completed"
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)

View File

@ -2,10 +2,9 @@ import logging
import os import os
import warnings import warnings
from io import BytesIO from io import BytesIO
from typing import Callable, Dict, List, Optional, Union from typing import Dict, List, Optional, Union
import numpy as np import numpy as np
import onnxruntime as ort
import requests import requests
from PIL import Image from PIL import Image
@ -15,10 +14,11 @@ from PIL import Image
from transformers import AutoFeatureExtractor, AutoTokenizer from transformers import AutoFeatureExtractor, AutoTokenizer
from transformers.utils.logging import disable_progress_bar from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor
from frigate.const import MODEL_CACHE_DIR, UPDATE_MODEL_STATE from frigate.const import MODEL_CACHE_DIR, UPDATE_MODEL_STATE
from frigate.types import ModelStatusTypesEnum from frigate.types import ModelStatusTypesEnum
from frigate.util.downloader import ModelDownloader from frigate.util.downloader import ModelDownloader
from frigate.util.model import get_ort_providers from frigate.util.model import ONNXModelRunner
warnings.filterwarnings( warnings.filterwarnings(
"ignore", "ignore",
@ -39,34 +39,49 @@ class GenericONNXEmbedding:
model_name: str, model_name: str,
model_file: str, model_file: str,
download_urls: Dict[str, str], download_urls: Dict[str, str],
embedding_function: Callable[[List[np.ndarray]], np.ndarray], model_size: str,
model_type: str, model_type: str,
requestor: InterProcessRequestor,
tokenizer_file: Optional[str] = None, tokenizer_file: Optional[str] = None,
device: str = "AUTO", device: str = "AUTO",
): ):
self.model_name = model_name self.model_name = model_name
self.model_file = model_file self.model_file = model_file
self.tokenizer_file = tokenizer_file self.tokenizer_file = tokenizer_file
self.requestor = requestor
self.download_urls = download_urls self.download_urls = download_urls
self.embedding_function = embedding_function
self.model_type = model_type # 'text' or 'vision' self.model_type = model_type # 'text' or 'vision'
self.providers, self.provider_options = get_ort_providers( self.model_size = model_size
force_cpu=device == "CPU", requires_fp16=True, openvino_device=device self.device = device
)
self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name)
self.tokenizer = None self.tokenizer = None
self.feature_extractor = None self.feature_extractor = None
self.session = None self.runner = None
files_names = list(self.download_urls.keys()) + (
self.downloader = ModelDownloader( [self.tokenizer_file] if self.tokenizer_file else []
model_name=self.model_name,
download_path=self.download_path,
file_names=list(self.download_urls.keys())
+ ([self.tokenizer_file] if self.tokenizer_file else []),
download_func=self._download_model,
) )
self.downloader.ensure_model_files()
if not all(
os.path.exists(os.path.join(self.download_path, n)) for n in files_names
):
logger.debug(f"starting model download for {self.model_name}")
self.downloader = ModelDownloader(
model_name=self.model_name,
download_path=self.download_path,
file_names=files_names,
download_func=self._download_model,
)
self.downloader.ensure_model_files()
else:
self.downloader = None
ModelDownloader.mark_files_state(
self.requestor,
self.model_name,
files_names,
ModelStatusTypesEnum.downloaded,
)
self._load_model_and_tokenizer()
logger.debug(f"models are already downloaded for {self.model_name}")
def _download_model(self, path: str): def _download_model(self, path: str):
try: try:
@ -101,14 +116,17 @@ class GenericONNXEmbedding:
) )
def _load_model_and_tokenizer(self): def _load_model_and_tokenizer(self):
if self.session is None: if self.runner is None:
self.downloader.wait_for_download() if self.downloader:
self.downloader.wait_for_download()
if self.model_type == "text": if self.model_type == "text":
self.tokenizer = self._load_tokenizer() self.tokenizer = self._load_tokenizer()
else: else:
self.feature_extractor = self._load_feature_extractor() self.feature_extractor = self._load_feature_extractor()
self.session = self._load_model( self.runner = ONNXModelRunner(
os.path.join(self.download_path, self.model_file) os.path.join(self.download_path, self.model_file),
self.device,
self.model_size,
) )
def _load_tokenizer(self): def _load_tokenizer(self):
@ -125,15 +143,6 @@ class GenericONNXEmbedding:
f"{MODEL_CACHE_DIR}/{self.model_name}", f"{MODEL_CACHE_DIR}/{self.model_name}",
) )
def _load_model(self, path: str):
if os.path.exists(path):
return ort.InferenceSession(
path, providers=self.providers, provider_options=self.provider_options
)
else:
logger.warning(f"{self.model_name} model file {path} not found.")
return None
def _process_image(self, image): def _process_image(self, image):
if isinstance(image, str): if isinstance(image, str):
if image.startswith("http"): if image.startswith("http"):
@ -146,8 +155,7 @@ class GenericONNXEmbedding:
self, inputs: Union[List[str], List[Image.Image], List[str]] self, inputs: Union[List[str], List[Image.Image], List[str]]
) -> List[np.ndarray]: ) -> List[np.ndarray]:
self._load_model_and_tokenizer() self._load_model_and_tokenizer()
if self.runner is None or (
if self.session is None or (
self.tokenizer is None and self.feature_extractor is None self.tokenizer is None and self.feature_extractor is None
): ):
logger.error( logger.error(
@ -156,23 +164,37 @@ class GenericONNXEmbedding:
return [] return []
if self.model_type == "text": if self.model_type == "text":
processed_inputs = self.tokenizer( max_length = max(len(self.tokenizer.encode(text)) for text in inputs)
inputs, padding=True, truncation=True, return_tensors="np" processed_inputs = [
) self.tokenizer(
text,
padding="max_length",
truncation=True,
max_length=max_length,
return_tensors="np",
)
for text in inputs
]
else: else:
processed_images = [self._process_image(img) for img in inputs] processed_images = [self._process_image(img) for img in inputs]
processed_inputs = self.feature_extractor( processed_inputs = [
images=processed_images, return_tensors="np" self.feature_extractor(images=image, return_tensors="np")
) for image in processed_images
]
input_names = [input.name for input in self.session.get_inputs()] input_names = self.runner.get_input_names()
onnx_inputs = { onnx_inputs = {name: [] for name in input_names}
name: processed_inputs[name] input: dict[str, any]
for name in input_names for input in processed_inputs:
if name in processed_inputs for key, value in input.items():
} if key in input_names:
onnx_inputs[key].append(value[0])
outputs = self.session.run(None, onnx_inputs) for key in input_names:
embeddings = self.embedding_function(outputs) if onnx_inputs.get(key):
onnx_inputs[key] = np.stack(onnx_inputs[key])
else:
logger.warning(f"Expected input '{key}' not found in onnx_inputs")
embeddings = self.runner.run(onnx_inputs)[0]
return [embedding for embedding in embeddings] return [embedding for embedding in embeddings]

View File

@ -41,10 +41,14 @@ class EmbeddingMaintainer(threading.Thread):
config: FrigateConfig, config: FrigateConfig,
stop_event: MpEvent, stop_event: MpEvent,
) -> None: ) -> None:
threading.Thread.__init__(self) super().__init__(name="embeddings_maintainer")
self.name = "embeddings_maintainer"
self.config = config self.config = config
self.embeddings = Embeddings(config.semantic_search, db) self.embeddings = Embeddings(config.semantic_search, db)
# Check if we need to re-index events
if config.semantic_search.reindex:
self.embeddings.reindex()
self.event_subscriber = EventUpdateSubscriber() self.event_subscriber = EventUpdateSubscriber()
self.event_end_subscriber = EventEndSubscriber() self.event_end_subscriber = EventEndSubscriber()
self.event_metadata_subscriber = EventMetadataSubscriber( self.event_metadata_subscriber = EventMetadataSubscriber(
@ -76,26 +80,33 @@ class EmbeddingMaintainer(threading.Thread):
def _process_requests(self) -> None: def _process_requests(self) -> None:
"""Process embeddings requests""" """Process embeddings requests"""
def handle_request(topic: str, data: str) -> str: def _handle_request(topic: str, data: str) -> str:
if topic == EmbeddingsRequestEnum.embed_description.value: try:
return serialize( if topic == EmbeddingsRequestEnum.embed_description.value:
self.embeddings.upsert_description(data["id"], data["description"]), return serialize(
pack=False, self.embeddings.upsert_description(
) data["id"], data["description"]
elif topic == EmbeddingsRequestEnum.embed_thumbnail.value: ),
thumbnail = base64.b64decode(data["thumbnail"]) pack=False,
return serialize( )
self.embeddings.upsert_thumbnail(data["id"], thumbnail), elif topic == EmbeddingsRequestEnum.embed_thumbnail.value:
pack=False, thumbnail = base64.b64decode(data["thumbnail"])
) return serialize(
elif topic == EmbeddingsRequestEnum.generate_search.value: self.embeddings.upsert_thumbnail(data["id"], thumbnail),
return serialize(self.embeddings.text_embedding([data])[0], pack=False) pack=False,
)
elif topic == EmbeddingsRequestEnum.generate_search.value:
return serialize(
self.embeddings.text_embedding([data])[0], pack=False
)
except Exception as e:
logger.error(f"Unable to handle embeddings request {e}")
self.embeddings_responder.check_for_request(handle_request) self.embeddings_responder.check_for_request(_handle_request)
def _process_updates(self) -> None: def _process_updates(self) -> None:
"""Process event updates""" """Process event updates"""
update = self.event_subscriber.check_for_update() update = self.event_subscriber.check_for_update(timeout=0.1)
if update is None: if update is None:
return return
@ -124,7 +135,7 @@ class EmbeddingMaintainer(threading.Thread):
def _process_finalized(self) -> None: def _process_finalized(self) -> None:
"""Process the end of an event.""" """Process the end of an event."""
while True: while True:
ended = self.event_end_subscriber.check_for_update() ended = self.event_end_subscriber.check_for_update(timeout=0.1)
if ended == None: if ended == None:
break break
@ -161,9 +172,6 @@ class EmbeddingMaintainer(threading.Thread):
or set(event.zones) & set(camera_config.genai.required_zones) or set(event.zones) & set(camera_config.genai.required_zones)
) )
): ):
logger.debug(
f"Description generation for {event}, has_snapshot: {event.has_snapshot}"
)
if event.has_snapshot and camera_config.genai.use_snapshot: if event.has_snapshot and camera_config.genai.use_snapshot:
with open( with open(
os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"),
@ -217,7 +225,7 @@ class EmbeddingMaintainer(threading.Thread):
def _process_event_metadata(self): def _process_event_metadata(self):
# Check for regenerate description requests # Check for regenerate description requests
(topic, event_id, source) = self.event_metadata_subscriber.check_for_update( (topic, event_id, source) = self.event_metadata_subscriber.check_for_update(
timeout=1 timeout=0.1
) )
if topic is None: if topic is None:
@ -251,7 +259,7 @@ class EmbeddingMaintainer(threading.Thread):
camera_config = self.config.cameras[event.camera] camera_config = self.config.cameras[event.camera]
description = self.genai_client.generate_description( description = self.genai_client.generate_description(
camera_config, thumbnails, event.label camera_config, thumbnails, event
) )
if not description: if not description:

View File

@ -20,10 +20,11 @@ class ZScoreNormalization:
@property @property
def stddev(self): def stddev(self):
return math.sqrt(self.variance) return math.sqrt(self.variance) if self.variance > 0 else 0.0
def normalize(self, distances: list[float]): def normalize(self, distances: list[float], save_stats: bool):
self._update(distances) if save_stats:
self._update(distances)
if self.stddev == 0: if self.stddev == 0:
return distances return distances
return [ return [

View File

@ -8,11 +8,9 @@ from enum import Enum
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path from pathlib import Path
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR from frigate.const import CLIPS_DIR
from frigate.embeddings.embeddings import Embeddings from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,7 +23,7 @@ class EventCleanupType(str, Enum):
class EventCleanup(threading.Thread): class EventCleanup(threading.Thread):
def __init__( def __init__(
self, config: FrigateConfig, stop_event: MpEvent, db: SqliteQueueDatabase self, config: FrigateConfig, stop_event: MpEvent, db: SqliteVecQueueDatabase
): ):
super().__init__(name="event_cleanup") super().__init__(name="event_cleanup")
self.config = config self.config = config
@ -35,9 +33,6 @@ class EventCleanup(threading.Thread):
self.removed_camera_labels: list[str] = None self.removed_camera_labels: list[str] = None
self.camera_labels: dict[str, dict[str, any]] = {} self.camera_labels: dict[str, dict[str, any]] = {}
if self.config.semantic_search.enabled:
self.embeddings = Embeddings(self.config.semantic_search, self.db)
def get_removed_camera_labels(self) -> list[Event]: def get_removed_camera_labels(self) -> list[Event]:
"""Get a list of distinct labels for removed cameras.""" """Get a list of distinct labels for removed cameras."""
if self.removed_camera_labels is None: if self.removed_camera_labels is None:
@ -234,8 +229,8 @@ class EventCleanup(threading.Thread):
Event.delete().where(Event.id << chunk).execute() Event.delete().where(Event.id << chunk).execute()
if self.config.semantic_search.enabled: if self.config.semantic_search.enabled:
self.embeddings.delete_description(chunk) self.db.delete_embeddings_description(event_ids=chunk)
self.embeddings.delete_thumbnail(chunk) self.db.delete_embeddings_thumbnail(event_ids=chunk)
logger.debug(f"Deleted {len(events_to_delete)} embeddings") logger.debug(f"Deleted {len(events_to_delete)} embeddings")
logger.info("Exiting event cleanup...") logger.info("Exiting event cleanup...")

View File

@ -5,6 +5,7 @@ import os
from typing import Optional from typing import Optional
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
from frigate.models import Event
PROVIDERS = {} PROVIDERS = {}
@ -31,12 +32,12 @@ class GenAIClient:
self, self,
camera_config: CameraConfig, camera_config: CameraConfig,
thumbnails: list[bytes], thumbnails: list[bytes],
label: str, event: Event,
) -> Optional[str]: ) -> Optional[str]:
"""Generate a description for the frame.""" """Generate a description for the frame."""
prompt = camera_config.genai.object_prompts.get( prompt = camera_config.genai.object_prompts.get(
label, camera_config.genai.prompt event.label, camera_config.genai.prompt
).format(label=label) ).format(**event)
return self._send(prompt, thumbnails) return self._send(prompt, thumbnails)
def _init_provider(self): def _init_provider(self):

View File

@ -19,6 +19,13 @@ class FileLock:
self.path = path self.path = path
self.lock_file = f"{path}.lock" self.lock_file = f"{path}.lock"
# we have not acquired the lock yet so it should not exist
if os.path.exists(self.lock_file):
try:
os.remove(self.lock_file)
except Exception:
pass
def acquire(self): def acquire(self):
parent_dir = os.path.dirname(self.lock_file) parent_dir = os.path.dirname(self.lock_file)
os.makedirs(parent_dir, exist_ok=True) os.makedirs(parent_dir, exist_ok=True)
@ -56,14 +63,12 @@ class ModelDownloader:
self.download_complete = threading.Event() self.download_complete = threading.Event()
def ensure_model_files(self): def ensure_model_files(self):
for file in self.file_names: self.mark_files_state(
self.requestor.send_data( self.requestor,
UPDATE_MODEL_STATE, self.model_name,
{ self.file_names,
"model": f"{self.model_name}-{file}", ModelStatusTypesEnum.downloading,
"state": ModelStatusTypesEnum.downloading, )
},
)
self.download_thread = threading.Thread( self.download_thread = threading.Thread(
target=self._download_models, target=self._download_models,
name=f"_download_model_{self.model_name}", name=f"_download_model_{self.model_name}",
@ -92,6 +97,7 @@ class ModelDownloader:
}, },
) )
self.requestor.stop()
self.download_complete.set() self.download_complete.set()
@staticmethod @staticmethod
@ -119,5 +125,21 @@ class ModelDownloader:
if not silent: if not silent:
logger.info(f"Downloading complete: {url}") logger.info(f"Downloading complete: {url}")
@staticmethod
def mark_files_state(
requestor: InterProcessRequestor,
model_name: str,
files: list[str],
state: ModelStatusTypesEnum,
) -> None:
for file_name in files:
requestor.send_data(
UPDATE_MODEL_STATE,
{
"model": f"{model_name}-{file_name}",
"state": state,
},
)
def wait_for_download(self): def wait_for_download(self):
self.download_complete.wait() self.download_complete.wait()

View File

@ -1,44 +1,116 @@
"""Model Utils""" """Model Utils"""
import os import os
from typing import Any
import onnxruntime as ort import onnxruntime as ort
try:
import openvino as ov
except ImportError:
# openvino is not included
pass
def get_ort_providers( def get_ort_providers(
force_cpu: bool = False, openvino_device: str = "AUTO", requires_fp16: bool = False force_cpu: bool = False, openvino_device: str = "AUTO", requires_fp16: bool = False
) -> tuple[list[str], list[dict[str, any]]]: ) -> tuple[list[str], list[dict[str, any]]]:
if force_cpu: if force_cpu:
return (["CPUExecutionProvider"], [{}]) return (
["CPUExecutionProvider"],
[
{
"arena_extend_strategy": "kSameAsRequested",
}
],
)
providers = ort.get_available_providers() providers = []
options = [] options = []
for provider in providers: for provider in ort.get_available_providers():
if provider == "TensorrtExecutionProvider": if provider == "CUDAExecutionProvider":
os.makedirs("/config/model_cache/tensorrt/ort/trt-engines", exist_ok=True) providers.append(provider)
if not requires_fp16 or os.environ.get("USE_FP_16", "True") != "False":
options.append(
{
"trt_fp16_enable": requires_fp16,
"trt_timing_cache_enable": True,
"trt_engine_cache_enable": True,
"trt_timing_cache_path": "/config/model_cache/tensorrt/ort",
"trt_engine_cache_path": "/config/model_cache/tensorrt/ort/trt-engines",
}
)
else:
options.append({})
elif provider == "OpenVINOExecutionProvider":
os.makedirs("/config/model_cache/openvino/ort", exist_ok=True)
options.append( options.append(
{ {
"arena_extend_strategy": "kSameAsRequested",
}
)
elif provider == "TensorrtExecutionProvider":
# TensorrtExecutionProvider uses too much memory without options to control it
pass
elif provider == "OpenVINOExecutionProvider":
os.makedirs("/config/model_cache/openvino/ort", exist_ok=True)
providers.append(provider)
options.append(
{
"arena_extend_strategy": "kSameAsRequested",
"cache_dir": "/config/model_cache/openvino/ort", "cache_dir": "/config/model_cache/openvino/ort",
"device_type": openvino_device, "device_type": openvino_device,
} }
) )
elif provider == "CPUExecutionProvider":
providers.append(provider)
options.append(
{
"arena_extend_strategy": "kSameAsRequested",
}
)
else: else:
providers.append(provider)
options.append({}) options.append({})
return (providers, options) return (providers, options)
class ONNXModelRunner:
"""Run onnx models optimally based on available hardware."""
def __init__(self, model_path: str, device: str, requires_fp16: bool = False):
self.model_path = model_path
self.ort: ort.InferenceSession = None
self.ov: ov.Core = None
providers, options = get_ort_providers(device == "CPU", device, requires_fp16)
if "OpenVINOExecutionProvider" in providers:
# use OpenVINO directly
self.type = "ov"
self.ov = ov.Core()
self.ov.set_property(
{ov.properties.cache_dir: "/config/model_cache/openvino"}
)
self.interpreter = self.ov.compile_model(
model=model_path, device_name=device
)
else:
# Use ONNXRuntime
self.type = "ort"
self.ort = ort.InferenceSession(
model_path, providers=providers, provider_options=options
)
def get_input_names(self) -> list[str]:
if self.type == "ov":
input_names = []
for input in self.interpreter.inputs:
input_names.extend(input.names)
return input_names
elif self.type == "ort":
return [input.name for input in self.ort.get_inputs()]
def run(self, input: dict[str, Any]) -> Any:
if self.type == "ov":
infer_request = self.interpreter.create_infer_request()
input_tensor = list(input.values())
if len(input_tensor) == 1:
input_tensor = ov.Tensor(array=input_tensor[0])
else:
input_tensor = ov.Tensor(array=input_tensor)
infer_request.infer(input_tensor)
return [infer_request.get_output_tensor().data]
elif self.type == "ort":
return self.ort.run(None, input)

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

@ -1,3 +1,4 @@
import { useEmbeddingsReindexProgress } from "@/api/ws";
import { import {
StatusBarMessagesContext, StatusBarMessagesContext,
StatusMessage, StatusMessage,
@ -41,6 +42,23 @@ export default function Statusbar() {
}); });
}, [potentialProblems, addMessage, clearMessages]); }, [potentialProblems, addMessage, clearMessages]);
const { payload: reindexState } = useEmbeddingsReindexProgress();
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
`Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`,
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages]);
return ( return (
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground"> <div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
<div className="flex h-full items-center gap-2"> <div className="flex h-full items-center gap-2">

View File

@ -241,6 +241,8 @@ export default function ReviewFilterGroup({
mode="none" mode="none"
setMode={() => {}} setMode={() => {}}
setRange={() => {}} setRange={() => {}}
showExportPreview={false}
setShowExportPreview={() => {}}
/> />
)} )}
</div> </div>

View File

@ -7,6 +7,7 @@ import {
LuChevronUp, LuChevronUp,
LuTrash2, LuTrash2,
LuStar, LuStar,
LuSearch,
} from "react-icons/lu"; } from "react-icons/lu";
import { import {
FilterType, FilterType,
@ -161,8 +162,12 @@ export default function InputWithTags({
.map((word) => word.trim()) .map((word) => word.trim())
.lastIndexOf(words.filter((word) => word.trim() !== "").pop() || ""); .lastIndexOf(words.filter((word) => word.trim() !== "").pop() || "");
const currentWord = words[lastNonEmptyWordIndex]; const currentWord = words[lastNonEmptyWordIndex];
if (words.at(-1) === "") {
return current_suggestions;
}
return current_suggestions.filter((suggestion) => return current_suggestions.filter((suggestion) =>
suggestion.toLowerCase().includes(currentWord.toLowerCase()), suggestion.toLowerCase().startsWith(currentWord),
); );
}, },
[inputValue, suggestions, currentFilterType], [inputValue, suggestions, currentFilterType],
@ -636,7 +641,19 @@ export default function InputWithTags({
inputFocused ? "visible" : "hidden", inputFocused ? "visible" : "hidden",
)} )}
> >
{(Object.keys(filters).length > 0 || isSimilaritySearch) && ( {!currentFilterType && inputValue && (
<CommandGroup heading="Search">
<CommandItem
className="cursor-pointer"
onSelect={() => handleSearch(inputValue)}
>
<LuSearch className="mr-2 h-4 w-4" />
Search for "{inputValue}"
</CommandItem>
</CommandGroup>
)}
{(Object.keys(filters).filter((key) => key !== "query").length > 0 ||
isSimilaritySearch) && (
<CommandGroup heading="Active Filters"> <CommandGroup heading="Active Filters">
<div className="my-2 flex flex-wrap gap-2 px-2"> <div className="my-2 flex flex-wrap gap-2 px-2">
{isSimilaritySearch && ( {isSimilaritySearch && (

View File

@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@ -22,10 +23,13 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select"; import { SelectSeparator } from "../ui/select";
import { isDesktop, isIOS } from "react-device-detect"; import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay"; import SaveExportOverlay from "./SaveExportOverlay";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
const EXPORT_OPTIONS = [ const EXPORT_OPTIONS = [
"1", "1",
@ -44,8 +48,10 @@ type ExportDialogProps = {
currentTime: number; currentTime: number;
range?: TimeRange; range?: TimeRange;
mode: ExportMode; mode: ExportMode;
showPreview: boolean;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
setShowPreview: (showPreview: boolean) => void;
}; };
export default function ExportDialog({ export default function ExportDialog({
camera, camera,
@ -53,10 +59,13 @@ export default function ExportDialog({
currentTime, currentTime,
range, range,
mode, mode,
showPreview,
setRange, setRange,
setMode, setMode,
setShowPreview,
}: ExportDialogProps) { }: ExportDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const onStartExport = useCallback(() => { const onStartExport = useCallback(() => {
if (!range) { if (!range) {
toast.error("No valid time range selected", { position: "top-center" }); toast.error("No valid time range selected", { position: "top-center" });
@ -109,9 +118,16 @@ export default function ExportDialog({
return ( return (
<> <>
<ExportPreviewDialog
camera={camera}
range={range}
showPreview={showPreview}
setShowPreview={setShowPreview}
/>
<SaveExportOverlay <SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2" className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"} show={mode == "timeline"}
onPreview={() => setShowPreview(true)}
onSave={() => onStartExport()} onSave={() => onStartExport()}
onCancel={() => setMode("none")} onCancel={() => setMode("none")}
/> />
@ -525,3 +541,44 @@ function CustomTimeSelector({
</div> </div>
); );
} }
type ExportPreviewDialogProps = {
camera: string;
range?: TimeRange;
showPreview: boolean;
setShowPreview: (showPreview: boolean) => void;
};
export function ExportPreviewDialog({
camera,
range,
showPreview,
setShowPreview,
}: ExportPreviewDialogProps) {
if (!range) {
return null;
}
const source = `${baseUrl}vod/${camera}/start/${range.after}/end/${range.before}/index.m3u8`;
return (
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isMobile && "px-4",
)}
>
<DialogHeader>
<DialogTitle>Preview Export</DialogTitle>
<DialogDescription className="sr-only">
Preview Export
</DialogDescription>
</DialogHeader>
<GenericVideoPlayer source={source} />
</DialogContent>
</Dialog>
);
}

View File

@ -3,7 +3,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { ExportContent } from "./ExportDialog"; import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import { ExportMode } from "@/types/filter"; import { ExportMode } from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar"; import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select"; import { SelectSeparator } from "../ui/select";
@ -34,12 +34,14 @@ type MobileReviewSettingsDrawerProps = {
currentTime: number; currentTime: number;
range?: TimeRange; range?: TimeRange;
mode: ExportMode; mode: ExportMode;
showExportPreview: boolean;
reviewSummary?: ReviewSummary; reviewSummary?: ReviewSummary;
allLabels: string[]; allLabels: string[];
allZones: string[]; allZones: string[];
onUpdateFilter: (filter: ReviewFilter) => void; onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
setShowExportPreview: (showPreview: boolean) => void;
}; };
export default function MobileReviewSettingsDrawer({ export default function MobileReviewSettingsDrawer({
features = DEFAULT_DRAWER_FEATURES, features = DEFAULT_DRAWER_FEATURES,
@ -50,12 +52,14 @@ export default function MobileReviewSettingsDrawer({
currentTime, currentTime,
range, range,
mode, mode,
showExportPreview,
reviewSummary, reviewSummary,
allLabels, allLabels,
allZones, allZones,
onUpdateFilter, onUpdateFilter,
setRange, setRange,
setMode, setMode,
setShowExportPreview,
}: MobileReviewSettingsDrawerProps) { }: MobileReviewSettingsDrawerProps) {
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none"); const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
@ -282,6 +286,13 @@ export default function MobileReviewSettingsDrawer({
show={mode == "timeline"} show={mode == "timeline"}
onSave={() => onStartExport()} onSave={() => onStartExport()}
onCancel={() => setMode("none")} onCancel={() => setMode("none")}
onPreview={() => setShowExportPreview(true)}
/>
<ExportPreviewDialog
camera={camera}
range={range}
showPreview={showExportPreview}
setShowPreview={setShowExportPreview}
/> />
<Drawer <Drawer
modal={!(isIOS && drawerMode == "export")} modal={!(isIOS && drawerMode == "export")}

View File

@ -1,4 +1,4 @@
import { LuX } from "react-icons/lu"; import { LuVideo, LuX } from "react-icons/lu";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -6,12 +6,14 @@ import { cn } from "@/lib/utils";
type SaveExportOverlayProps = { type SaveExportOverlayProps = {
className: string; className: string;
show: boolean; show: boolean;
onPreview: () => void;
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
}; };
export default function SaveExportOverlay({ export default function SaveExportOverlay({
className, className,
show, show,
onPreview,
onSave, onSave,
onCancel, onCancel,
}: SaveExportOverlayProps) { }: SaveExportOverlayProps) {
@ -24,6 +26,22 @@ export default function SaveExportOverlay({
"mx-auto mt-5 text-center", "mx-auto mt-5 text-center",
)} )}
> >
<Button
className="flex items-center gap-1 text-primary"
size="sm"
onClick={onCancel}
>
<LuX />
Cancel
</Button>
<Button
className="flex items-center gap-1"
size="sm"
onClick={onPreview}
>
<LuVideo />
Preview Export
</Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
variant="select" variant="select"
@ -33,14 +51,6 @@ export default function SaveExportOverlay({
<FaCompactDisc /> <FaCompactDisc />
Save Export Save Export
</Button> </Button>
<Button
className="flex items-center gap-1 text-primary"
size="sm"
onClick={onCancel}
>
<LuX />
Cancel
</Button>
</div> </div>
</div> </div>
); );

View File

@ -6,7 +6,7 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { Button } from "../../ui/button"; import { Button } from "../../ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Textarea } from "../../ui/textarea"; import { Textarea } from "../../ui/textarea";
@ -21,7 +21,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
@ -62,8 +61,7 @@ import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { useResizeObserver } from "@/hooks/resize-observer"; import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer";
import { VideoResolutionType } from "@/types/live";
const SEARCH_TABS = [ const SEARCH_TABS = [
"details", "details",
@ -398,17 +396,19 @@ function ObjectDetailsTab({
draggable={false} draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.jpg`} src={`${apiHost}api/events/${search.id}/thumbnail.jpg`}
/> />
<Button {config?.semantic_search.enabled && (
onClick={() => { <Button
setSearch(undefined); onClick={() => {
setSearch(undefined);
if (setSimilarity) { if (setSimilarity) {
setSimilarity(); setSimilarity();
} }
}} }}
> >
Find Similar Find Similar
</Button> </Button>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -536,57 +536,59 @@ function ObjectSnapshotTab({
/> />
)} )}
</TransformComponent> </TransformComponent>
<Card className="p-1 text-sm md:p-2"> {search.plus_id !== "not_enabled" && search.end_time && (
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row"> <Card className="p-1 text-sm md:p-2">
<div className={cn("flex flex-col space-y-3")}> <CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div <div className={cn("flex flex-col space-y-3")}>
className={ <div
"text-lg font-semibold leading-none tracking-tight" className={
} "text-lg font-semibold leading-none tracking-tight"
> }
Submit To Frigate+ >
</div> Submit To Frigate+
<div className="text-sm text-muted-foreground">
Objects in locations you want to avoid are not false
positives. Submitting them as false positives will confuse
the model.
</div>
</div>
<div className="flex flex-row justify-center gap-2 md:justify-end">
{state == "reviewing" && (
<>
<Button
className="bg-success"
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
This is a {search?.label}
</Button>
<Button
className="text-white"
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
This is not a {search?.label}
</Button>
</>
)}
{state == "uploading" && <ActivityIndicator />}
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="text-success" />
Submitted
</div> </div>
)} <div className="text-sm text-muted-foreground">
</div> Objects in locations you want to avoid are not false
</CardContent> positives. Submitting them as false positives will confuse
</Card> the model.
</div>
</div>
<div className="flex flex-row justify-center gap-2 md:justify-end">
{state == "reviewing" && (
<>
<Button
className="bg-success"
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
This is a {search?.label}
</Button>
<Button
className="text-white"
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
This is not a {search?.label}
</Button>
</>
)}
{state == "uploading" && <ActivityIndicator />}
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="text-success" />
Submitted
</div>
)}
</div>
</CardContent>
</Card>
)}
</div> </div>
</TransformWrapper> </TransformWrapper>
</div> </div>
@ -597,99 +599,45 @@ function ObjectSnapshotTab({
type VideoTabProps = { type VideoTabProps = {
search: SearchResult; search: SearchResult;
}; };
function VideoTab({ search }: VideoTabProps) {
const [isLoading, setIsLoading] = useState(true);
const videoRef = useRef<HTMLVideoElement | null>(null);
const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]);
export function VideoTab({ search }: VideoTabProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: reviewItem } = useSWR<ReviewSegment>([ const { data: reviewItem } = useSWR<ReviewSegment>([
`review/event/${search.id}`, `review/event/${search.id}`,
]); ]);
const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]);
const containerRef = useRef<HTMLDivElement | null>(null); const source = `${baseUrl}vod/${search.camera}/start/${search.start_time}/end/${endTime}/index.m3u8`;
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const [videoResolution, setVideoResolution] = useState<VideoResolutionType>({
width: 0,
height: 0,
});
const videoAspectRatio = useMemo(() => {
return videoResolution.width / videoResolution.height || 16 / 9;
}, [videoResolution]);
const containerAspectRatio = useMemo(() => {
return containerWidth / containerHeight || 16 / 9;
}, [containerWidth, containerHeight]);
const videoDimensions = useMemo(() => {
if (!containerWidth || !containerHeight)
return { width: "100%", height: "100%" };
if (containerAspectRatio > videoAspectRatio) {
const height = containerHeight;
const width = height * videoAspectRatio;
return { width: `${width}px`, height: `${height}px` };
} else {
const width = containerWidth;
const height = width / videoAspectRatio;
return { width: `${width}px`, height: `${height}px` };
}
}, [containerWidth, containerHeight, videoAspectRatio, containerAspectRatio]);
return ( return (
<div ref={containerRef} className="relative flex h-full w-full flex-col"> <GenericVideoPlayer source={source}>
<div className="relative flex flex-grow items-center justify-center"> {reviewItem && (
{(isLoading || !reviewItem) && (
<ActivityIndicator className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2" />
)}
<div <div
className="relative flex items-center justify-center" className={cn(
style={videoDimensions} "absolute top-2 z-10 flex items-center",
> isIOS ? "right-8" : "right-2",
<HlsVideoPlayer
videoRef={videoRef}
currentSource={`${baseUrl}vod/${search.camera}/start/${search.start_time}/end/${endTime}/index.m3u8`}
hotKeys
visible
frigateControls={false}
fullscreen={false}
supportsFullscreen={false}
onPlaying={() => setIsLoading(false)}
setFullResolution={setVideoResolution}
/>
{!isLoading && reviewItem && (
<div
className={cn(
"absolute top-2 z-10 flex items-center",
isIOS ? "right-8" : "right-2",
)}
>
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (reviewItem?.id) {
const params = new URLSearchParams({
id: reviewItem.id,
}).toString();
navigate(`/review?${params}`);
}
}}
>
<FaHistory className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent side="left">View in History</TooltipContent>
</Tooltip>
</div>
)} )}
>
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (reviewItem?.id) {
const params = new URLSearchParams({
id: reviewItem.id,
}).toString();
navigate(`/review?${params}`);
}
}}
>
<FaHistory className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent side="left">View in History</TooltipContent>
</Tooltip>
</div> </div>
</div> )}
</div> </GenericVideoPlayer>
); );
} }

View File

@ -0,0 +1,52 @@
import React, { useState, useRef } from "react";
import { useVideoDimensions } from "@/hooks/use-video-dimensions";
import HlsVideoPlayer from "./HlsVideoPlayer";
import ActivityIndicator from "../indicators/activity-indicator";
type GenericVideoPlayerProps = {
source: string;
onPlaying?: () => void;
children?: React.ReactNode;
};
export function GenericVideoPlayer({
source,
onPlaying,
children,
}: GenericVideoPlayerProps) {
const [isLoading, setIsLoading] = useState(true);
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const { videoDimensions, setVideoResolution } =
useVideoDimensions(containerRef);
return (
<div ref={containerRef} className="relative flex h-full w-full flex-col">
<div className="relative flex flex-grow items-center justify-center">
{isLoading && (
<ActivityIndicator className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2" />
)}
<div
className="relative flex items-center justify-center"
style={videoDimensions}
>
<HlsVideoPlayer
videoRef={videoRef}
currentSource={source}
hotKeys
visible
frigateControls={false}
fullscreen={false}
supportsFullscreen={false}
onPlaying={() => {
setIsLoading(false);
onPlaying?.();
}}
setFullResolution={setVideoResolution}
/>
{!isLoading && children}
</div>
</div>
</div>
);
}

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

@ -0,0 +1,45 @@
import { useState, useMemo } from "react";
import { useResizeObserver } from "./resize-observer";
export type VideoResolutionType = {
width: number;
height: number;
};
export function useVideoDimensions(
containerRef: React.RefObject<HTMLDivElement>,
) {
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const [videoResolution, setVideoResolution] = useState<VideoResolutionType>({
width: 0,
height: 0,
});
const videoAspectRatio = useMemo(() => {
return videoResolution.width / videoResolution.height || 16 / 9;
}, [videoResolution]);
const containerAspectRatio = useMemo(() => {
return containerWidth / containerHeight || 16 / 9;
}, [containerWidth, containerHeight]);
const videoDimensions = useMemo(() => {
if (!containerWidth || !containerHeight)
return { width: "100%", height: "100%" };
if (containerAspectRatio > videoAspectRatio) {
const height = containerHeight;
const width = height * videoAspectRatio;
return { width: `${width}px`, height: `${height}px` };
} else {
const width = containerWidth;
const height = width / videoAspectRatio;
return { width: `${width}px`, height: `${height}px` };
}
}, [containerWidth, containerHeight, videoAspectRatio, containerAspectRatio]);
return {
videoDimensions,
setVideoResolution,
};
}

View File

@ -1,10 +1,16 @@
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";
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search"; import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
import { ModelState } from "@/types/ws"; import { ModelState } from "@/types/ws";
import { formatSecondsToDuration } from "@/utils/dateUtil";
import SearchView from "@/views/search/SearchView"; import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
@ -177,11 +183,30 @@ export default function Explore() {
const eventUpdate = useEventUpdate(); const eventUpdate = useEventUpdate();
useEffect(() => { useEffect(() => {
mutate(); if (eventUpdate) {
mutate();
}
// mutate / revalidate when event description updates come in // mutate / revalidate when event description updates come in
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventUpdate]); }, [eventUpdate]);
// embeddings reindex progress
const { payload: reindexState } = useEmbeddingsReindexProgress();
const embeddingsReindexing = useMemo(() => {
if (reindexState) {
switch (reindexState.status) {
case "indexing":
return true;
case "completed":
return false;
default:
return undefined;
}
}
}, [reindexState]);
// model states // model states
const { payload: textModelState } = useModelState( const { payload: textModelState } = useModelState(
@ -190,9 +215,12 @@ export default function Explore() {
const { payload: textTokenizerState } = useModelState( const { payload: textTokenizerState } = useModelState(
"jinaai/jina-clip-v1-tokenizer", "jinaai/jina-clip-v1-tokenizer",
); );
const { payload: visionModelState } = useModelState( const modelFile =
"jinaai/jina-clip-v1-vision_model_fp16.onnx", config?.semantic_search.model_size === "large"
); ? "jinaai/jina-clip-v1-vision_model_fp16.onnx"
: "jinaai/jina-clip-v1-vision_model_quantized.onnx";
const { payload: visionModelState } = useModelState(modelFile);
const { payload: visionFeatureExtractorState } = useModelState( const { payload: visionFeatureExtractorState } = useModelState(
"jinaai/jina-clip-v1-preprocessor_config.json", "jinaai/jina-clip-v1-preprocessor_config.json",
); );
@ -226,7 +254,8 @@ export default function Explore() {
if ( if (
config?.semantic_search.enabled && config?.semantic_search.enabled &&
(!textModelState || (!reindexState ||
!textModelState ||
!textTokenizerState || !textTokenizerState ||
!visionModelState || !visionModelState ||
!visionFeatureExtractorState) !visionFeatureExtractorState)
@ -238,59 +267,114 @@ 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
</div> finished reindexing.
<div className="flex w-96 flex-col gap-2 py-5"> </div>
<div className="flex flex-row items-center justify-center gap-2"> <div className="pt-5 text-center">
{renderModelStateIcon(visionModelState)} <AnimatedCircularProgressBar
Vision model min={0}
</div> max={reindexState.total_objects}
<div className="flex flex-row items-center justify-center gap-2"> value={reindexState.processed_objects}
{renderModelStateIcon(visionFeatureExtractorState)} gaugePrimaryColor="hsl(var(--selected))"
Vision model feature extractor gaugeSecondaryColor="hsl(var(--secondary))"
</div> />
<div className="flex flex-row items-center justify-center gap-2"> </div>
{renderModelStateIcon(textModelState)} <div className="flex w-96 flex-col gap-2 py-5">
Text model {reindexState.time_remaining !== null && (
</div> <div className="mb-3 flex flex-col items-center justify-center gap-1">
<div className="flex flex-row items-center justify-center gap-2"> <div className="text-primary-variant">
{renderModelStateIcon(textTokenizerState)} {reindexState.time_remaining === -1
Text tokenizer ? "Starting up..."
</div> : "Estimated time remaining:"}
</div> </div>
{(textModelState === "error" || {reindexState.time_remaining >= 0 &&
textTokenizerState === "error" || (formatSecondsToDuration(reindexState.time_remaining) ||
visionModelState === "error" || "Finishing shortly")}
visionFeatureExtractorState === "error") && ( </div>
<div className="my-3 max-w-96 text-center text-danger"> )}
An error has occurred. Check Frigate logs. <div className="flex flex-row items-center justify-center gap-3">
</div> <span className="text-primary-variant">
Thumbnails embedded:
</span>
{reindexState.thumbnails}
</div>
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Descriptions embedded:
</span>
{reindexState.descriptions}
</div>
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Tracked objects processed:
</span>
{reindexState.processed_objects} /{" "}
{reindexState.total_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 className="flex w-96 flex-col gap-2 py-5">
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(visionModelState)}
Vision model
</div>
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(visionFeatureExtractorState)}
Vision model feature extractor
</div>
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(textModelState)}
Text model
</div>
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(textTokenizerState)}
Text tokenizer
</div>
</div>
{(textModelState === "error" ||
textTokenizerState === "error" ||
visionModelState === "error" ||
visionFeatureExtractorState === "error") && (
<div className="my-3 max-w-96 text-center text-danger">
An error has occurred. Check Frigate logs.
</div>
)}
<div className="text-center text-primary-variant">
You may want to reindex the embeddings of your tracked objects
once the models are downloaded.
</div>
<div className="flex items-center text-primary-variant">
<Link
to="https://docs.frigate.video/configuration/semantic_search"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</>
)} )}
<div className="max-w-96 text-center">
You may want to reindex the embeddings of your tracked objects
once the models are downloaded.
</div>
<div className="flex max-w-96 items-center text-primary-variant">
<Link
to="https://docs.frigate.video/configuration/semantic_search"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -417,6 +417,7 @@ export interface FrigateConfig {
semantic_search: { semantic_search: {
enabled: boolean; enabled: boolean;
model_size: string;
}; };
snapshots: { snapshots: {

View File

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

View File

@ -229,6 +229,23 @@ export const getDurationFromTimestamps = (
return duration; return duration;
}; };
/**
*
* @param seconds - number of seconds to convert into hours, minutes and seconds
* @returns string - formatted duration in hours, minutes and seconds
*/
export const formatSecondsToDuration = (seconds: number): string => {
if (isNaN(seconds) || seconds < 0) {
return "Invalid duration";
}
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
return formatDuration(duration, {
format: ["hours", "minutes", "seconds"],
delimiter: ", ",
});
};
/** /**
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and * Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
* returns the offset of that timezone from UTC in minutes. * returns the offset of that timezone from UTC in minutes.

View File

@ -7,16 +7,31 @@ import {
FaCarSide, FaCarSide,
FaCat, FaCat,
FaCheckCircle, FaCheckCircle,
FaDhl,
FaDog, FaDog,
FaFedex, FaFedex,
FaFire, FaFire,
FaFootballBall, FaFootballBall,
FaHockeyPuck,
FaHorse,
FaMotorcycle, FaMotorcycle,
FaMouse, FaMouse,
FaRegTrashAlt,
FaUmbrella,
FaUps, FaUps,
FaUsps, FaUsps,
} from "react-icons/fa"; } from "react-icons/fa";
import { GiDeer, GiHummingbird, GiPolarBear, GiSailboat } from "react-icons/gi"; import {
GiDeer,
GiFox,
GiGoat,
GiHummingbird,
GiPolarBear,
GiPostStamp,
GiRabbit,
GiRaccoonHead,
GiSailboat,
} from "react-icons/gi";
import { LuBox, LuLassoSelect } from "react-icons/lu"; import { LuBox, LuLassoSelect } from "react-icons/lu";
import * as LuIcons from "react-icons/lu"; import * as LuIcons from "react-icons/lu";
import { MdRecordVoiceOver } from "react-icons/md"; import { MdRecordVoiceOver } from "react-icons/md";
@ -53,8 +68,12 @@ export function getIconForLabel(label: string, className?: string) {
case "bark": case "bark":
case "dog": case "dog":
return <FaDog key={label} className={className} />; return <FaDog key={label} className={className} />;
case "fire_alarm": case "fox":
return <FaFire key={label} className={className} />; return <GiFox key={label} className={className} />;
case "goat":
return <GiGoat key={label} className={className} />;
case "horse":
return <FaHorse key={label} className={className} />;
case "motorcycle": case "motorcycle":
return <FaMotorcycle key={label} className={className} />; return <FaMotorcycle key={label} className={className} />;
case "mouse": case "mouse":
@ -63,8 +82,20 @@ export function getIconForLabel(label: string, className?: string) {
return <LuBox key={label} className={className} />; return <LuBox key={label} className={className} />;
case "person": case "person":
return <BsPersonWalking key={label} className={className} />; return <BsPersonWalking key={label} className={className} />;
case "rabbit":
return <GiRabbit key={label} className={className} />;
case "raccoon":
return <GiRaccoonHead key={label} className={className} />;
case "robot_lawnmower":
return <FaHockeyPuck key={label} className={className} />;
case "sports_ball": case "sports_ball":
return <FaFootballBall key={label} className={className} />; return <FaFootballBall key={label} className={className} />;
case "squirrel":
return <LuIcons.LuSquirrel key={label} className={className} />;
case "umbrella":
return <FaUmbrella key={label} className={className} />;
case "waste_bin":
return <FaRegTrashAlt key={label} className={className} />;
// audio // audio
case "crying": case "crying":
case "laughter": case "laughter":
@ -72,9 +103,21 @@ export function getIconForLabel(label: string, className?: string) {
case "speech": case "speech":
case "yell": case "yell":
return <MdRecordVoiceOver key={label} className={className} />; return <MdRecordVoiceOver key={label} className={className} />;
case "fire_alarm":
return <FaFire key={label} className={className} />;
// sub labels // sub labels
case "amazon": case "amazon":
return <FaAmazon key={label} className={className} />; return <FaAmazon key={label} className={className} />;
case "an_post":
case "dpd":
case "gls":
case "nzpost":
case "postnl":
case "postnord":
case "purolator":
return <GiPostStamp key={label} className={className} />;
case "dhl":
return <FaDhl key={label} className={className} />;
case "fedex": case "fedex":
return <FaFedex key={label} className={className} />; return <FaFedex key={label} className={className} />;
case "ups": case "ups":

View File

@ -531,9 +531,37 @@ function PtzControlPanel({
); );
useKeyboardListener( useKeyboardListener(
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "-"], [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"+",
"-",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
],
(key, modifiers) => { (key, modifiers) => {
if (modifiers.repeat) { if (modifiers.repeat || !key) {
return;
}
if (["1", "2", "3", "4", "5", "6", "7", "8", "9"].includes(key)) {
const presetNumber = parseInt(key);
if (
ptz &&
(ptz.presets?.length ?? 0) > 0 &&
presetNumber <= ptz.presets.length
) {
sendPtz(`preset_${ptz.presets[presetNumber - 1]}`);
}
return; return;
} }

View File

@ -140,6 +140,7 @@ export function RecordingView({
const [exportMode, setExportMode] = useState<ExportMode>("none"); const [exportMode, setExportMode] = useState<ExportMode>("none");
const [exportRange, setExportRange] = useState<TimeRange>(); const [exportRange, setExportRange] = useState<TimeRange>();
const [showExportPreview, setShowExportPreview] = useState(false);
// move to next clip // move to next clip
@ -412,6 +413,7 @@ export function RecordingView({
latestTime={timeRange.before} latestTime={timeRange.before}
mode={exportMode} mode={exportMode}
range={exportRange} range={exportRange}
showPreview={showExportPreview}
setRange={(range) => { setRange={(range) => {
setExportRange(range); setExportRange(range);
@ -420,6 +422,7 @@ export function RecordingView({
} }
}} }}
setMode={setExportMode} setMode={setExportMode}
setShowPreview={setShowExportPreview}
/> />
)} )}
{isDesktop && ( {isDesktop && (
@ -473,11 +476,13 @@ export function RecordingView({
latestTime={timeRange.before} latestTime={timeRange.before}
mode={exportMode} mode={exportMode}
range={exportRange} range={exportRange}
showExportPreview={showExportPreview}
allLabels={reviewFilterList.labels} allLabels={reviewFilterList.labels}
allZones={reviewFilterList.zones} allZones={reviewFilterList.zones}
onUpdateFilter={updateFilter} onUpdateFilter={updateFilter}
setRange={setExportRange} setRange={setExportRange}
setMode={setExportMode} setMode={setExportMode}
setShowExportPreview={setShowExportPreview}
/> />
</div> </div>
</div> </div>

View File

@ -187,13 +187,19 @@ export default function SearchView({
} }
}, [searchResults, searchDetail]); }, [searchResults, searchDetail]);
// confidence score - probably needs tweaking // confidence score
const zScoreToConfidence = (score: number) => { const zScoreToConfidence = (score: number) => {
// Sigmoid function: 1 / (1 + e^x) // Normalizing is not needed for similarity searches
const confidence = 1 / (1 + Math.exp(score)); // Sigmoid function for normalized: 1 / (1 + e^x)
// Cosine for similarity
if (searchFilter) {
const notNormalized = searchFilter?.search_type?.includes("similarity");
return Math.round(confidence * 100); const confidence = notNormalized ? 1 - score : 1 / (1 + Math.exp(score));
return Math.round(confidence * 100);
}
}; };
const hasExistingSearch = useMemo( const hasExistingSearch = useMemo(
@ -387,7 +393,11 @@ export default function SearchView({
> >
<SearchThumbnail <SearchThumbnail
searchResult={value} searchResult={value}
findSimilar={() => setSimilaritySearch(value)} findSimilar={() => {
if (config?.semantic_search.enabled) {
setSimilaritySearch(value);
}
}}
onClick={() => onSelectSearch(value, index)} onClick={() => onSelectSearch(value, index)}
/> />
{(searchTerm || {(searchTerm ||

View File

@ -11,11 +11,17 @@ import { usePersistence } from "@/hooks/use-persistence";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useCameraActivity } from "@/hooks/use-camera-activity"; import { useCameraActivity } from "@/hooks/use-camera-activity";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ObjectType } from "@/types/ws"; import { ObjectType } from "@/types/ws";
import useDeepMemo from "@/hooks/use-deep-memo"; import useDeepMemo from "@/hooks/use-deep-memo";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { LuInfo } from "react-icons/lu";
type ObjectSettingsViewProps = { type ObjectSettingsViewProps = {
selectedCamera?: string; selectedCamera?: string;
@ -35,6 +41,30 @@ export default function ObjectSettingsView({
param: "bbox", param: "bbox",
title: "Bounding boxes", title: "Bounding boxes",
description: "Show bounding boxes around tracked objects", description: "Show bounding boxes around tracked objects",
info: (
<>
<p className="mb-2">
<strong>Object Bounding Box Colors</strong>
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
At startup, different colors will be assigned to each object label
</li>
<li>
A dark blue thin line indicates that object is not detected at
this current point in time
</li>
<li>
A gray thin line indicates that object is detected as being
stationary
</li>
<li>
A thick line indicates that object is the subject of autotracking
(when enabled)
</li>
</ul>
</>
),
}, },
{ {
param: "timestamp", param: "timestamp",
@ -55,12 +85,34 @@ export default function ObjectSettingsView({
param: "motion", param: "motion",
title: "Motion boxes", title: "Motion boxes",
description: "Show boxes around areas where motion is detected", description: "Show boxes around areas where motion is detected",
info: (
<>
<p className="mb-2">
<strong>Motion Boxes</strong>
</p>
<p>
Red boxes will be overlaid on areas of the frame where motion is
currently being detected
</p>
</>
),
}, },
{ {
param: "regions", param: "regions",
title: "Regions", title: "Regions",
description: description:
"Show a box of the region of interest sent to the object detector", "Show a box of the region of interest sent to the object detector",
info: (
<>
<p className="mb-2">
<strong>Region Boxes</strong>
</p>
<p>
Bright green boxes will be overlaid on areas of interest in the
frame that are being sent to the object detector.
</p>
</>
),
}, },
]; ];
@ -145,19 +197,34 @@ export default function ObjectSettingsView({
<div className="flex w-full flex-col space-y-6"> <div className="flex w-full flex-col space-y-6">
<div className="mt-2 space-y-6"> <div className="mt-2 space-y-6">
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{DEBUG_OPTIONS.map(({ param, title, description }) => ( {DEBUG_OPTIONS.map(({ param, title, description, info }) => (
<div <div
key={param} key={param}
className="flex w-full flex-row items-center justify-between" className="flex w-full flex-row items-center justify-between"
> >
<div className="mb-2 flex flex-col"> <div className="mb-2 flex flex-col">
<Label <div className="flex items-center gap-2">
className="mb-2 w-full cursor-pointer capitalize text-primary" <Label
htmlFor={param} className="mb-0 cursor-pointer capitalize text-primary"
> htmlFor={param}
{title} >
</Label> {title}
<div className="text-xs text-muted-foreground"> </Label>
{info && (
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80">
{info}
</PopoverContent>
</Popover>
)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{description} {description}
</div> </div>
</div> </div>
@ -240,7 +307,7 @@ function ObjectList(objects?: ObjectType[]) {
{getIconForLabel(obj.label, "size-5 text-white")} {getIconForLabel(obj.label, "size-5 text-white")}
</div> </div>
<div className="ml-3 text-lg"> <div className="ml-3 text-lg">
{capitalizeFirstLetter(obj.label)} {capitalizeFirstLetter(obj.label.replaceAll("_", " "))}
</div> </div>
</div> </div>
<div className="flex w-8/12 flex-row items-end justify-end"> <div className="flex w-8/12 flex-row items-end justify-end">