Initial vector similarity implementation

This commit is contained in:
Jason Hunter 2023-12-18 02:12:53 -05:00
parent 21609631f9
commit 3b529ede36
29 changed files with 475 additions and 6 deletions

View File

@ -10,7 +10,7 @@
"features": { "features": {
"ghcr.io/devcontainers/features/common-utils:1": {} "ghcr.io/devcontainers/features/common-utils:1": {}
}, },
"forwardPorts": [5000, 5001, 5173, 1935, 8554, 8555], "forwardPorts": [5000, 5001, 5173, 1935, 8000, 8554, 8555],
"portsAttributes": { "portsAttributes": {
"5000": { "5000": {
"label": "NGINX", "label": "NGINX",
@ -28,6 +28,10 @@
"label": "RTMP", "label": "RTMP",
"onAutoForward": "silent" "onAutoForward": "silent"
}, },
"8000": {
"label": "Chroma",
"onAutoForward": "silent"
},
"8554": { "8554": {
"label": "gortc RTSP", "label": "gortc RTSP",
"onAutoForward": "silent" "onAutoForward": "silent"

View File

@ -8,7 +8,7 @@ ARG SLIM_BASE=debian:11-slim
FROM ${BASE_IMAGE} AS base FROM ${BASE_IMAGE} AS base
FROM --platform=${BUILDPLATFORM} debian:11 AS base_host FROM --platform=${BUILDPLATFORM} ${BASE_IMAGE} AS base_host
FROM ${SLIM_BASE} AS slim-base FROM ${SLIM_BASE} AS slim-base
@ -176,6 +176,9 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
ENV NVIDIA_VISIBLE_DEVICES=all ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
# Turn off Chroma Telemetry: https://docs.trychroma.com/telemetry#opting-out
ENV ANONYMIZED_TELEMETRY=False
ENV PATH="/usr/lib/btbn-ffmpeg/bin:/usr/local/go2rtc/bin:/usr/local/nginx/sbin:${PATH}" ENV PATH="/usr/lib/btbn-ffmpeg/bin:/usr/local/go2rtc/bin:/usr/local/nginx/sbin:${PATH}"
# Install dependencies # Install dependencies

View File

@ -27,3 +27,9 @@ unidecode == 1.3.*
# Openvino Library - Custom built with MYRIAD support # Openvino Library - Custom built with MYRIAD support
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64' openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64' openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
# Embeddings
onnxruntime == 1.16.*
onnx_clip == 4.0.*
pysqlite3-binary == 0.5.2
chromadb == 0.4.20
google-generativeai == 0.3.*

View File

@ -0,0 +1 @@
chroma

View File

@ -0,0 +1 @@
chroma-pipeline

View File

@ -0,0 +1,4 @@
#!/command/with-contenv bash
# shellcheck shell=bash
exec logutil-service /dev/shm/logs/chroma

View File

@ -0,0 +1 @@
longrun

View File

@ -0,0 +1,28 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Take down the S6 supervision tree when the service exits
set -o errexit -o nounset -o pipefail
# Logs should be sent to stdout so that s6 can collect them
declare exit_code_container
exit_code_container=$(cat /run/s6-linux-init-container-results/exitcode)
readonly exit_code_container
readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"
readonly service="ChromaDB"
echo "[INFO] Service ${service} exited with code ${exit_code_service} (by signal ${exit_code_signal})"
if [[ "${exit_code_service}" -eq 256 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo $((128 + exit_code_signal)) >/run/s6-linux-init-container-results/exitcode
fi
elif [[ "${exit_code_service}" -ne 0 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo "${exit_code_service}" >/run/s6-linux-init-container-results/exitcode
fi
fi
exec /run/s6/basedir/bin/halt

View File

@ -0,0 +1 @@
chroma-log

View File

@ -0,0 +1,16 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Start the Frigate service
set -o errexit -o nounset -o pipefail
# Logs should be sent to stdout so that s6 can collect them
# Tell S6-Overlay not to restart this service
s6-svc -O .
echo "[INFO] Starting ChromaDB..."
# Replace the bash process with the Frigate process, redirecting stderr to stdout
exec 2>&1
exec /usr/local/chroma run --path /config/chroma --host 127.0.0.1

View File

@ -0,0 +1 @@
120000

View File

@ -0,0 +1 @@
longrun

View File

@ -4,7 +4,7 @@
set -o errexit -o nounset -o pipefail set -o errexit -o nounset -o pipefail
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx) dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/chroma)
mkdir -p "${dirs[@]}" mkdir -p "${dirs[@]}"
chown nobody:nogroup "${dirs[@]}" chown nobody:nogroup "${dirs[@]}"

View File

@ -0,0 +1,14 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-s
__import__("pysqlite3")
import re
import sys
sys.modules["sqlite3"] = sys.modules.pop("pysqlite3")
from chromadb.cli.cli import app
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
sys.exit(app())

View File

@ -1,3 +1,8 @@
__import__("pysqlite3")
import sys
sys.modules["sqlite3"] = sys.modules.pop("pysqlite3")
import faulthandler import faulthandler
import threading import threading

View File

@ -31,6 +31,7 @@ from frigate.const import (
MODEL_CACHE_DIR, MODEL_CACHE_DIR,
RECORD_DIR, RECORD_DIR,
) )
from frigate.embeddings.processor import EmbeddingProcessor
from frigate.events.audio import listen_to_audio from frigate.events.audio import listen_to_audio
from frigate.events.cleanup import EventCleanup from frigate.events.cleanup import EventCleanup
from frigate.events.external import ExternalEventProcessor from frigate.events.external import ExternalEventProcessor
@ -273,6 +274,9 @@ class FrigateApp:
# Queue for timeline events # Queue for timeline events
self.timeline_queue: Queue = mp.Queue() self.timeline_queue: Queue = mp.Queue()
# Queue for embeddings process
self.embeddings_queue: Queue = mp.Queue()
# Queue for inter process communication # Queue for inter process communication
self.inter_process_queue: Queue = mp.Queue() self.inter_process_queue: Queue = mp.Queue()
@ -584,6 +588,12 @@ class FrigateApp:
) )
self.timeline_processor.start() self.timeline_processor.start()
def start_embeddings_processor(self) -> None:
self.embeddings_processor = EmbeddingProcessor(
self.config, self.embeddings_queue, self.stop_event
)
self.embeddings_processor.start()
def start_event_processor(self) -> None: def start_event_processor(self) -> None:
self.event_processor = EventProcessor( self.event_processor = EventProcessor(
self.config, self.config,
@ -591,6 +601,7 @@ class FrigateApp:
self.event_queue, self.event_queue,
self.event_processed_queue, self.event_processed_queue,
self.timeline_queue, self.timeline_queue,
self.embeddings_queue,
self.stop_event, self.stop_event,
) )
self.event_processor.start() self.event_processor.start()
@ -700,6 +711,7 @@ class FrigateApp:
self.init_external_event_processor() self.init_external_event_processor()
self.init_web_server() self.init_web_server()
self.start_timeline_processor() self.start_timeline_processor()
self.start_embeddings_processor()
self.start_event_processor() self.start_event_processor()
self.start_event_cleanup() self.start_event_cleanup()
self.start_record_cleanup() self.start_record_cleanup()
@ -742,6 +754,7 @@ class FrigateApp:
self.record_cleanup.join() self.record_cleanup.join()
self.stats_emitter.join() self.stats_emitter.join()
self.frigate_watchdog.join() self.frigate_watchdog.join()
self.embeddings_processor.join()
self.db.stop() self.db.stop()
while len(self.detection_shms) > 0: while len(self.detection_shms) > 0:
@ -758,6 +771,7 @@ class FrigateApp:
self.audio_recordings_info_queue, self.audio_recordings_info_queue,
self.log_queue, self.log_queue,
self.inter_process_queue, self.inter_process_queue,
self.embeddings_queue,
]: ]:
if queue is not None: if queue is not None:
while not queue.empty(): while not queue.empty():

View File

@ -680,6 +680,23 @@ class SnapshotsConfig(FrigateBaseModel):
) )
class SemanticSearchConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable semantic search.")
class GeminiConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable Google Gemini captioning.")
override_existing: bool = Field(
default=False, title="Override existing sub labels."
)
api_key: str = Field(default="", title="Google AI Studio API Key.")
prompt: str = Field(
default="Describe the {label} in this image with as much detail as possible. Do not describe the background.",
title="Default caption prompt.",
)
object_prompts: Dict[str, str] = Field(default={}, title="Object specific prompts.")
class ColorConfig(FrigateBaseModel): class ColorConfig(FrigateBaseModel):
red: int = Field(default=255, ge=0, le=255, title="Red") red: int = Field(default=255, ge=0, le=255, title="Red")
green: int = Field(default=255, ge=0, le=255, title="Green") green: int = Field(default=255, ge=0, le=255, title="Green")
@ -783,6 +800,9 @@ class CameraConfig(FrigateBaseModel):
onvif: OnvifConfig = Field( onvif: OnvifConfig = Field(
default_factory=OnvifConfig, title="Camera Onvif Configuration." default_factory=OnvifConfig, title="Camera Onvif Configuration."
) )
gemini: GeminiConfig = Field(
default_factory=GeminiConfig, title="Google Gemini Configuration."
)
ui: CameraUiConfig = Field( ui: CameraUiConfig = Field(
default_factory=CameraUiConfig, title="Camera UI Modifications." default_factory=CameraUiConfig, title="Camera UI Modifications."
) )
@ -1051,6 +1071,12 @@ class FrigateConfig(FrigateBaseModel):
snapshots: SnapshotsConfig = Field( snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, title="Global snapshots configuration." default_factory=SnapshotsConfig, title="Global snapshots configuration."
) )
semantic_search: SemanticSearchConfig = Field(
default_factory=SemanticSearchConfig, title="Semantic Search configuration."
)
gemini: GeminiConfig = Field(
default_factory=GeminiConfig, title="Global Google Gemini Configuration."
)
live: CameraLiveConfig = Field( live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig, title="Live playback settings." default_factory=CameraLiveConfig, title="Live playback settings."
) )
@ -1090,6 +1116,10 @@ class FrigateConfig(FrigateBaseModel):
config.mqtt.user = config.mqtt.user.format(**FRIGATE_ENV_VARS) config.mqtt.user = config.mqtt.user.format(**FRIGATE_ENV_VARS)
config.mqtt.password = config.mqtt.password.format(**FRIGATE_ENV_VARS) config.mqtt.password = config.mqtt.password.format(**FRIGATE_ENV_VARS)
# Gemini API Key substitutions
if config.gemini.api_key:
config.gemini.api_key = config.gemini.api_key.format(**FRIGATE_ENV_VARS)
# set default min_score for object attributes # set default min_score for object attributes
for attribute in ALL_ATTRIBUTE_LABELS: for attribute in ALL_ATTRIBUTE_LABELS:
if not config.objects.filters.get(attribute): if not config.objects.filters.get(attribute):
@ -1110,6 +1140,7 @@ class FrigateConfig(FrigateBaseModel):
"detect": ..., "detect": ...,
"ffmpeg": ..., "ffmpeg": ...,
"timestamp_style": ..., "timestamp_style": ...,
"gemini": ...,
}, },
exclude_unset=True, exclude_unset=True,
) )
@ -1176,6 +1207,13 @@ class FrigateConfig(FrigateBaseModel):
camera_config.onvif.password = camera_config.onvif.password.format( camera_config.onvif.password = camera_config.onvif.password.format(
**FRIGATE_ENV_VARS **FRIGATE_ENV_VARS
) )
# Gemini substitution
if camera_config.gemini.api_key:
camera_config.gemini.api_key = camera_config.gemini.api_key.format(
**FRIGATE_ENV_VARS
)
# set config pre-value # set config pre-value
camera_config.record.enabled_in_config = camera_config.record.enabled camera_config.record.enabled_in_config = camera_config.record.enabled
camera_config.audio.enabled_in_config = camera_config.audio.enabled camera_config.audio.enabled_in_config = camera_config.audio.enabled

View File

@ -1,6 +1,7 @@
CONFIG_DIR = "/config" CONFIG_DIR = "/config"
DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db" DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db"
MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache" MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
DEFAULT_CHROMA_DB_PATH = f"{CONFIG_DIR}/chroma"
BASE_DIR = "/media/frigate" BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
RECORD_DIR = f"{BASE_DIR}/recordings" RECORD_DIR = f"{BASE_DIR}/recordings"

View File

View File

@ -0,0 +1,62 @@
"""CLIP Embeddings for Frigate."""
import os
from typing import Tuple, Union
import onnxruntime as ort
from chromadb import EmbeddingFunction, Embeddings
from chromadb.api.types import (
Documents,
Images,
is_document,
is_image,
)
from onnx_clip import OnnxClip
from frigate.const import MODEL_CACHE_DIR
class Clip(OnnxClip):
"""Override load models to download to cache directory."""
@staticmethod
def _load_models(
model: str,
silent: bool,
) -> Tuple[ort.InferenceSession, ort.InferenceSession]:
"""
These models are a part of the container. Treat as as such.
"""
if model == "ViT-B/32":
IMAGE_MODEL_FILE = "clip_image_model_vitb32.onnx"
TEXT_MODEL_FILE = "clip_text_model_vitb32.onnx"
elif model == "RN50":
IMAGE_MODEL_FILE = "clip_image_model_rn50.onnx"
TEXT_MODEL_FILE = "clip_text_model_rn50.onnx"
else:
raise ValueError(f"Unexpected model {model}. No `.onnx` file found.")
models = []
for model_file in [IMAGE_MODEL_FILE, TEXT_MODEL_FILE]:
path = os.path.join(MODEL_CACHE_DIR, "clip", model_file)
models.append(OnnxClip._load_model(path, silent))
return models[0], models[1]
class ClipEmbedding(EmbeddingFunction):
"""Embedding function for CLIP model used in Chroma."""
def __init__(self, model: str = "ViT-B/32"):
"""Initialize CLIP Embedding function."""
self.model = Clip(model)
def __call__(self, input: Union[Documents, Images]) -> Embeddings:
embeddings: Embeddings = []
for item in input:
if is_image(item):
result = self.model.get_image_embeddings([item])
embeddings.append(result[0, :].tolist())
elif is_document(item):
result = self.model.get_text_embeddings([item])
embeddings.append(result[0, :].tolist())
return embeddings

View File

@ -0,0 +1,12 @@
"""Embedding function for ONNX MiniLM-L6 model used in Chroma."""
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
from frigate.const import MODEL_CACHE_DIR
class MiniLMEmbedding(ONNXMiniLM_L6_V2):
"""Override DOWNLOAD_PATH to download to cache directory."""
DOWNLOAD_PATH = f"{MODEL_CACHE_DIR}/all-MiniLM-L6-v2"

View File

@ -0,0 +1,170 @@
"""Create a Chroma vector database for semantic search."""
import base64
import io
import logging
import queue
import threading
from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent
import google.generativeai as genai
import numpy as np
from chromadb import Collection
from chromadb import HttpClient as ChromaClient
from chromadb.config import Settings
from peewee import DoesNotExist
from PIL import Image
from playhouse.shortcuts import model_to_dict
from frigate.config import FrigateConfig
from frigate.models import Event
from .functions.clip import ClipEmbedding
from .functions.minilm_l6_v2 import MiniLMEmbedding
logger = logging.getLogger(__name__)
class EmbeddingProcessor(threading.Thread):
"""Handle gemini queue and post event updates."""
def __init__(
self,
config: FrigateConfig,
queue: Queue,
stop_event: MpEvent,
) -> None:
threading.Thread.__init__(self)
self.name = "chroma"
self.config = config
self.queue = queue
self.stop_event = stop_event
self.chroma: ChromaClient = None
self.thumbnail: Collection = None
self.description: Collection = None
self.gemini: genai.GenerativeModel = None
def run(self) -> None:
"""Maintain a Chroma vector database for semantic search."""
# Exit if disabled
if not self.config.semantic_search.enabled:
return
# Create the database
self.chroma = ChromaClient(settings=Settings(anonymized_telemetry=False))
# Create/Load the collection(s)
self.thumbnail = self.chroma.get_or_create_collection(
name="event_thumbnail", embedding_function=ClipEmbedding()
)
self.description = self.chroma.get_or_create_collection(
name="event_description", embedding_function=MiniLMEmbedding()
)
## Initialize Gemini
if self.config.gemini.enabled:
genai.configure(api_key=self.config.gemini.api_key)
self.gemini = genai.GenerativeModel("gemini-pro-vision")
# Process events
while not self.stop_event.is_set():
try:
(
event_id,
camera,
) = self.queue.get(timeout=1)
except queue.Empty:
continue
camera_config = self.config.cameras[camera]
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
continue
# Extract valid event metadata
metadata = {
k: v
for k, v in model_to_dict(event).items()
if k not in ["id", "thumbnail"]
and v is not None
and isinstance(v, (str, int, float, bool))
}
thumbnail = base64.b64decode(event.thumbnail)
# Encode the thumbnail
self._embed_thumbnail(event.id, thumbnail, metadata)
# Skip if we aren't generating descriptions with Gemini
if not camera_config.gemini.enabled or (
not camera_config.gemini.override_existing
and event.data.get("description") is not None
):
continue
# Generate the description. Call happens in a thread since it is network bound.
threading.Thread(
target=self._embed_description,
name=f"_embed_description_{event.id}",
daemon=True,
args=(
event,
thumbnail,
metadata,
),
).start()
def _embed_thumbnail(self, event_id: str, thumbnail: bytes, metadata: dict) -> None:
"""Embed the thumbnail for an event."""
# Encode the thumbnail
img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB"))
self.thumbnail.add(
images=[img],
metadatas=[metadata],
ids=[event_id],
)
def _embed_description(
self, event: Event, thumbnail: bytes, metadata: dict
) -> None:
"""Embed the description for an event."""
content = {
"mime_type": "image/jpeg",
"data": thumbnail,
}
# Fetch the prompt from the config and format the string replacing variables from the event
prompt = self.config.gemini.object_prompts.get(
event.label, self.config.gemini.prompt
).format(**metadata)
response = self.gemini.generate_content(
[content, prompt],
generation_config=genai.types.GenerationConfig(
candidate_count=1,
),
)
try:
description = response.text.strip()
except ValueError:
# No description was generated
return
# Update the event to add the description
event.data["description"] = description
event.save()
# Encode the description
self.description.add(
documents=[description],
metadatas=[metadata],
ids=[event.id],
)
logger.info("Generated description for %s: %s", event.id, description)

View File

@ -62,6 +62,7 @@ class EventProcessor(threading.Thread):
event_queue: Queue, event_queue: Queue,
event_processed_queue: Queue, event_processed_queue: Queue,
timeline_queue: Queue, timeline_queue: Queue,
embeddings_queue: Queue,
stop_event: MpEvent, stop_event: MpEvent,
): ):
threading.Thread.__init__(self) threading.Thread.__init__(self)
@ -71,6 +72,7 @@ class EventProcessor(threading.Thread):
self.event_queue = event_queue self.event_queue = event_queue
self.event_processed_queue = event_processed_queue self.event_processed_queue = event_processed_queue
self.timeline_queue = timeline_queue self.timeline_queue = timeline_queue
self.embeddings_queue = embeddings_queue
self.events_in_process: Dict[str, Event] = {} self.events_in_process: Dict[str, Event] = {}
self.stop_event = stop_event self.stop_event = stop_event
@ -240,6 +242,7 @@ class EventProcessor(threading.Thread):
if event_type == "end": if event_type == "end":
del self.events_in_process[event_data["id"]] del self.events_in_process[event_data["id"]]
self.event_processed_queue.put((event_data["id"], camera)) self.event_processed_queue.put((event_data["id"], camera))
self.embeddings_queue.put((event_data["id"], camera))
def handle_external_detection(self, event_type: str, event_data: Event) -> None: def handle_external_detection(self, event_type: str, event_data: Event) -> None:
if event_type == "new": if event_type == "new":

View File

@ -17,6 +17,9 @@ import cv2
import numpy as np import numpy as np
import pytz import pytz
import requests import requests
from chromadb import Collection, QueryResult
from chromadb import HttpClient as ChromaClient
from chromadb.config import Settings
from flask import ( from flask import (
Blueprint, Blueprint,
Flask, Flask,
@ -42,6 +45,8 @@ from frigate.const import (
MAX_SEGMENT_DURATION, MAX_SEGMENT_DURATION,
RECORD_DIR, RECORD_DIR,
) )
from frigate.embeddings.functions.clip import ClipEmbedding
from frigate.embeddings.functions.minilm_l6_v2 import MiniLMEmbedding
from frigate.events.external import ExternalEventProcessor from frigate.events.external import ExternalEventProcessor
from frigate.models import Event, Previews, Recordings, Regions, Timeline from frigate.models import Event, Previews, Recordings, Regions, Timeline
from frigate.object_processing import TrackedObject from frigate.object_processing import TrackedObject
@ -103,6 +108,13 @@ def create_app(
app.plus_api = plus_api app.plus_api = plus_api
app.camera_error_image = None app.camera_error_image = None
app.hwaccel_errors = [] app.hwaccel_errors = []
app.chroma = ChromaClient(settings=Settings(anonymized_telemetry=False))
app.thumbnail_collection = app.chroma.get_or_create_collection(
name="event_thumbnail", embedding_function=ClipEmbedding()
)
app.description_collection = app.chroma.get_or_create_collection(
name="event_description", embedding_function=MiniLMEmbedding()
)
app.register_blueprint(bp) app.register_blueprint(bp)
@ -998,6 +1010,7 @@ def events():
is_submitted = request.args.get("is_submitted", type=int) is_submitted = request.args.get("is_submitted", type=int)
min_length = request.args.get("min_length", type=float) min_length = request.args.get("min_length", type=float)
max_length = request.args.get("max_length", type=float) max_length = request.args.get("max_length", type=float)
search = request.args.get("search", type=str) or None
clauses = [] clauses = []
@ -1019,16 +1032,24 @@ def events():
Event.data, Event.data,
] ]
# Start collecting filters for the embeddings metadata.
# We won't be able to do all the filters like we do against the DB
# because the table might have been modified after, but we should
# do what we can.
embeddings_filters = []
if camera != "all": if camera != "all":
clauses.append((Event.camera == camera)) clauses.append((Event.camera == camera))
if cameras != "all": if cameras != "all":
camera_list = cameras.split(",") camera_list = cameras.split(",")
clauses.append((Event.camera << camera_list)) clauses.append((Event.camera << camera_list))
embeddings_filters.append({"camera": {"$in": camera_list}})
if labels != "all": if labels != "all":
label_list = labels.split(",") label_list = labels.split(",")
clauses.append((Event.label << label_list)) clauses.append((Event.label << label_list))
embeddings_filters.append({"label": {"$in": label_list}})
if sub_labels != "all": if sub_labels != "all":
# use matching so joined sub labels are included # use matching so joined sub labels are included
@ -1071,9 +1092,11 @@ def events():
if after: if after:
clauses.append((Event.start_time > after)) clauses.append((Event.start_time > after))
embeddings_filters.append({"start_time": {"$gt": after}})
if before: if before:
clauses.append((Event.start_time < before)) clauses.append((Event.start_time < before))
embeddings_filters.append({"start_time": {"$lt": before}})
if time_range != DEFAULT_TIME_RANGE: if time_range != DEFAULT_TIME_RANGE:
# get timezone arg to ensure browser times are used # get timezone arg to ensure browser times are used
@ -1141,6 +1164,40 @@ def events():
if len(clauses) == 0: if len(clauses) == 0:
clauses.append((True)) clauses.append((True))
# Handle semantic search
event_order = None
if search is not None:
where = None
if len(embeddings_filters) > 1:
where = {"$and": embeddings_filters}
elif len(embeddings_filters) == 1:
where = embeddings_filters[0]
# Grab the ids of the events that match based on CLIP embeddings
thumbnails: Collection = current_app.thumbnail_collection
thumb_result: QueryResult = thumbnails.query(
query_texts=[search],
n_results=int(limit),
where=where,
)
thumb_ids = dict(zip(thumb_result["ids"][0], thumb_result["distances"][0]))
# Grab the ids of the events that match based on MiniLM embeddings
descriptions: Collection = current_app.description_collection
desc_result: QueryResult = descriptions.query(
query_texts=[search],
n_results=int(limit),
where=where,
)
desc_ids = dict(zip(desc_result["ids"][0], desc_result["distances"][0]))
event_order = {
k: min(i for i in (thumb_ids.get(k), desc_ids.get(k)) if i is not None)
for k in thumb_ids.keys() | desc_ids
}
clauses.append((Event.id << list(event_order.keys())))
events = ( events = (
Event.select(*selected_columns) Event.select(*selected_columns)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
@ -1149,8 +1206,16 @@ def events():
.dicts() .dicts()
.iterator() .iterator()
) )
events = list(events)
return jsonify(list(events)) if event_order is not None:
events = [
{**events, "search_similarity": event_order[events["id"]]}
for events in events
]
events = sorted(events, key=lambda x: x["search_similarity"])
return jsonify(events)
@bp.route("/events/<camera_name>/<label>/create", methods=["POST"]) @bp.route("/events/<camera_name>/<label>/create", methods=["POST"])
@ -2326,6 +2391,7 @@ def logs(service: str):
"frigate": "/dev/shm/logs/frigate/current", "frigate": "/dev/shm/logs/frigate/current",
"go2rtc": "/dev/shm/logs/go2rtc/current", "go2rtc": "/dev/shm/logs/go2rtc/current",
"nginx": "/dev/shm/logs/nginx/current", "nginx": "/dev/shm/logs/nginx/current",
"chroma": "/dev/shm/logs/chroma/current",
} }
service_location = log_locations.get(service) service_location = log_locations.get(service)

View File

@ -71,7 +71,7 @@ export default function TextField({
) : null} ) : null}
<div className="relative w-full"> <div className="relative w-full">
<input <input
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0" className="h-6 mt-6 w-full bg-transparent border-0 focus:outline-none focus:ring-0"
onBlur={handleBlur} onBlur={handleBlur}
onFocus={handleFocus} onFocus={handleFocus}
onInput={handleChange} onInput={handleChange}

View File

@ -27,6 +27,7 @@ import Button from '../components/Button';
import Dialog from '../components/Dialog'; import Dialog from '../components/Dialog';
import MultiSelect from '../components/MultiSelect'; import MultiSelect from '../components/MultiSelect';
import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil'; import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil';
import TextField from '../components/TextField';
import TimeAgo from '../components/TimeAgo'; import TimeAgo from '../components/TimeAgo';
import Timepicker from '../components/TimePicker'; import Timepicker from '../components/TimePicker';
import TimelineSummary from '../components/TimelineSummary'; import TimelineSummary from '../components/TimelineSummary';
@ -186,6 +187,17 @@ export default function Events({ path, ...props }) {
} }
}; };
let searchTimeout;
const onChangeSearchText = (text) => {
if (searchParams?.search == text) {
return;
}
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
onFilter('search', text);
}, 500);
};
const onToggleNamedFilter = (name, item) => { const onToggleNamedFilter = (name, item) => {
let items; let items;
@ -367,6 +379,9 @@ export default function Events({ path, ...props }) {
return ( return (
<div className="space-y-4 p-2 px-4 w-full"> <div className="space-y-4 p-2 px-4 w-full">
<Heading>Events</Heading> <Heading>Events</Heading>
<div className="flex flex-wrap gap-2 items-center">
<TextField label="Search" onChangeText={(text) => onChangeSearchText(text)} />
</div>
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
<MultiSelect <MultiSelect
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800" className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
@ -801,7 +816,9 @@ function Event({
{event.label.replaceAll('_', ' ')} {event.label.replaceAll('_', ' ')}
{event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null} {event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null}
</div> </div>
{event?.data?.description ? (
<div className="text-sm flex flex-col grow pb-2">{event.data.description}</div>
) : null}
<div className="text-sm flex"> <div className="text-sm flex">
<Clock className="h-5 w-5 mr-2 inline" /> <Clock className="h-5 w-5 mr-2 inline" />
{formatUnixTimestampToDateTime(event.start_time, { ...config.ui })} {formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}