mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
14 Commits
bd91ae4e50
...
2a8eacedb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8eacedb8 | ||
|
|
21da0c979f | ||
|
|
d451cef1d5 | ||
|
|
50f4f686a6 | ||
|
|
5e23230df5 | ||
|
|
15522b916e | ||
|
|
516d84cc31 | ||
|
|
114def0617 | ||
|
|
71f0472ff5 | ||
|
|
36fb27ef56 | ||
|
|
9937a7cc3d | ||
|
|
7aac6b4f21 | ||
|
|
338b681ed0 | ||
|
|
685f2c5030 |
@ -21,7 +21,7 @@ FROM deps AS frigate-tensorrt
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||
|
||||
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||
pip3 uninstall -y onnxruntime tensorflow-cpu \
|
||||
pip3 uninstall -y onnxruntime \
|
||||
&& pip3 install -U /deps/trt-wheels/*.whl
|
||||
|
||||
COPY --from=rootfs / /
|
||||
|
||||
@ -13,7 +13,6 @@ nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
|
||||
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
|
||||
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
|
||||
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
|
||||
tensorflow==2.19.*; platform_machine == 'x86_64'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
|
||||
@ -10,7 +10,6 @@ Object classification allows you to train a custom MobileNetV2 classification mo
|
||||
Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
|
||||
|
||||
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
||||
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
|
||||
|
||||
## Classes
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ State classification allows you to train a custom MobileNetV2 classification mod
|
||||
State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
|
||||
|
||||
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
||||
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
|
||||
|
||||
## Classes
|
||||
|
||||
|
||||
@ -804,3 +804,42 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
content={"success": True, "message": "Example generation completed"},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/classification/{name}",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete a classification model",
|
||||
description="""Deletes a specific classification model and all its associated data.
|
||||
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
def delete_classification_model(request: Request, name: str):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if name not in config.classification.custom:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"{name} is not a known classification model.",
|
||||
}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Delete the classification model's data directory
|
||||
model_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
|
||||
|
||||
if os.path.exists(model_dir):
|
||||
shutil.rmtree(model_dir)
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Successfully deleted classification model {name}.",
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
@ -186,6 +186,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
thumbs,
|
||||
camera_config.review.genai,
|
||||
list(self.config.model.merged_labelmap.values()),
|
||||
self.config.model.all_attributes,
|
||||
),
|
||||
).start()
|
||||
|
||||
@ -414,6 +415,7 @@ def run_analysis(
|
||||
thumbs: list[bytes],
|
||||
genai_config: GenAIReviewConfig,
|
||||
labelmap_objects: list[str],
|
||||
attribute_labels: list[str],
|
||||
) -> None:
|
||||
start = datetime.datetime.now().timestamp()
|
||||
analytics_data = {
|
||||
@ -441,7 +443,11 @@ def run_analysis(
|
||||
continue
|
||||
elif label in labelmap_objects:
|
||||
object_type = label.replace("_", " ").title()
|
||||
unified_objects.append(object_type)
|
||||
|
||||
if label in attribute_labels:
|
||||
unified_objects.append(f"{object_type} (delivery/service)")
|
||||
else:
|
||||
unified_objects.append(object_type)
|
||||
|
||||
analytics_data["unified_objects"] = unified_objects
|
||||
|
||||
|
||||
@ -114,7 +114,7 @@ Your response MUST be a flat JSON object with:
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
Each line represents a detection state, not necessarily unique individuals. Objects with names in parentheses (e.g., "Name (person)") are verified identities. Objects without names (e.g., "Person") are detected but not identified.
|
||||
Each line represents a detection state, not necessarily unique individuals. Parentheses indicate object type or category, use only the name/label in your response, not the parentheses.
|
||||
|
||||
**CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.**
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ class OpenAIClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using OpenAI."""
|
||||
|
||||
provider: OpenAI
|
||||
context_size: Optional[int] = None
|
||||
|
||||
def _init_provider(self):
|
||||
"""Initialize the client."""
|
||||
@ -69,5 +70,33 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Get the context window size for OpenAI."""
|
||||
# OpenAI GPT-4 Vision models have 128K token context window
|
||||
return 128000
|
||||
if self.context_size is not None:
|
||||
return self.context_size
|
||||
|
||||
try:
|
||||
models = self.provider.models.list()
|
||||
for model in models.data:
|
||||
if model.id == self.genai_config.model:
|
||||
if hasattr(model, "max_model_len") and model.max_model_len:
|
||||
self.context_size = model.max_model_len
|
||||
logger.debug(
|
||||
f"Retrieved context size {self.context_size} for model {self.genai_config.model}"
|
||||
)
|
||||
return self.context_size
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Failed to fetch model context size from API: {e}, using default"
|
||||
)
|
||||
|
||||
# Default to 128K for ChatGPT models, 8K for others
|
||||
model_name = self.genai_config.model.lower()
|
||||
if "gpt" in model_name:
|
||||
self.context_size = 128000
|
||||
else:
|
||||
self.context_size = 8192
|
||||
|
||||
logger.debug(
|
||||
f"Using default context size {self.context_size} for model {self.genai_config.model}"
|
||||
)
|
||||
return self.context_size
|
||||
|
||||
@ -384,10 +384,10 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
new_object_config["genai"] = {}
|
||||
|
||||
for key in global_genai.keys():
|
||||
if key not in ["enabled", "model", "provider", "base_url", "api_key"]:
|
||||
new_object_config["genai"][key] = global_genai[key]
|
||||
else:
|
||||
if key in ["model", "provider", "base_url", "api_key"]:
|
||||
new_genai_config[key] = global_genai[key]
|
||||
else:
|
||||
new_object_config["genai"][key] = global_genai[key]
|
||||
|
||||
config["genai"] = new_genai_config
|
||||
|
||||
|
||||
@ -5,12 +5,15 @@
|
||||
"renameCategory": "Rename Class",
|
||||
"deleteCategory": "Delete Class",
|
||||
"deleteImages": "Delete Images",
|
||||
"trainModel": "Train Model"
|
||||
"trainModel": "Train Model",
|
||||
"addClassification": "Add Classification",
|
||||
"deleteModels": "Delete Models"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Deleted Class",
|
||||
"deletedImage": "Deleted Images",
|
||||
"deletedModel": "Successfully deleted {{count}} model(s)",
|
||||
"categorizedImage": "Successfully Classified Image",
|
||||
"trainedModel": "Successfully trained model.",
|
||||
"trainingModel": "Successfully started model training."
|
||||
@ -18,6 +21,7 @@
|
||||
"error": {
|
||||
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||
"trainingFailed": "Failed to start model training: {{errorMessage}}"
|
||||
}
|
||||
@ -26,6 +30,11 @@
|
||||
"title": "Delete Class",
|
||||
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
|
||||
},
|
||||
"deleteModel": {
|
||||
"title": "Delete Classification Model",
|
||||
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
||||
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Delete Dataset Images",
|
||||
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
||||
@ -52,6 +61,10 @@
|
||||
},
|
||||
"categorizeImageAs": "Classify Image As:",
|
||||
"categorizeImage": "Classify Image",
|
||||
"menu": {
|
||||
"objects": "Objects",
|
||||
"states": "States"
|
||||
},
|
||||
"noModels": {
|
||||
"object": {
|
||||
"title": "No Object Classification Models",
|
||||
@ -86,6 +99,7 @@
|
||||
"classificationSubLabel": "Sub Label",
|
||||
"classificationAttribute": "Attribute",
|
||||
"classes": "Classes",
|
||||
"states": "States",
|
||||
"classesTip": "Learn about classes",
|
||||
"classesStateDesc": "Define the different states your camera area can be in. For example: 'open' and 'closed' for a garage door.",
|
||||
"classesObjectDesc": "Define the different categories to classify detected objects into. For example: 'delivery_person', 'resident', 'stranger' for person classification.",
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"type": {
|
||||
"details": "details",
|
||||
"snapshot": "snapshot",
|
||||
"thumbnail": "thumbnail",
|
||||
"video": "video",
|
||||
"object_lifecycle": "object lifecycle"
|
||||
},
|
||||
@ -41,7 +42,7 @@
|
||||
"noImageFound": "No image found for this timestamp.",
|
||||
"createObjectMask": "Create Object Mask",
|
||||
"adjustAnnotationSettings": "Adjust annotation settings",
|
||||
"scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
|
||||
"scrollViewTips": "Click to view the significant moments of this object's lifecycle.",
|
||||
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
|
||||
"count": "{{first}} of {{second}}",
|
||||
"trackedPoint": "Tracked Point",
|
||||
|
||||
@ -99,7 +99,8 @@
|
||||
"stateRequiresTwoClasses": "Les modèles d'état nécessitent au moins deux classes.",
|
||||
"objectLabelRequired": "Veuillez sélectionner une étiquette d'objet.",
|
||||
"objectTypeRequired": "Veuillez sélectionner un type de classification."
|
||||
}
|
||||
},
|
||||
"states": "États"
|
||||
},
|
||||
"step2": {
|
||||
"description": "Sélectionnez les caméras et définissez la zone à surveiller pour chaque caméra. Le modèle classifiera l'état de ces zones.",
|
||||
|
||||
@ -145,7 +145,10 @@
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"back": "Vai indietro"
|
||||
"back": "Vai indietro",
|
||||
"hide": "Nascondi {{item}}",
|
||||
"show": "Mostra {{item}}",
|
||||
"ID": "ID"
|
||||
},
|
||||
"menu": {
|
||||
"configuration": "Configurazione",
|
||||
@ -265,7 +268,7 @@
|
||||
"title": "Ruolo",
|
||||
"admin": "Amministratore",
|
||||
"viewer": "Spettatore",
|
||||
"desc": "Gli Amministratori hanno accesso completo a tutte le funzionalità dell'interfaccia di Frigate. Gli Spettatori sono limitati alla sola visualizzazione delle telecamere, rivedono gli oggetti e le registrazioni storiche nell'interfaccia utente."
|
||||
"desc": "Gli amministratori hanno accesso completo a tutte le funzionalità dell'interfaccia utente di Frigate. Gli spettatori possono visualizzare solo le telecamere, gli elementi di revisione e i filmati storici nell'interfaccia utente."
|
||||
},
|
||||
"accessDenied": {
|
||||
"desc": "Non hai i permessi per visualizzare questa pagina.",
|
||||
@ -291,5 +294,13 @@
|
||||
"readTheDocumentation": "Leggi la documentazione",
|
||||
"information": {
|
||||
"pixels": "{{area}}px"
|
||||
},
|
||||
"list": {
|
||||
"two": "{{0}} e {{1}}",
|
||||
"many": "{{items}}, e {{last}}"
|
||||
},
|
||||
"field": {
|
||||
"optional": "Opzionale",
|
||||
"internalID": "L'ID interno che Frigate utilizza nella configurazione e nel database"
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@
|
||||
"export": "Esporta",
|
||||
"selectOrExport": "Seleziona o esporta",
|
||||
"toast": {
|
||||
"success": "Esportazione avviata correttamente. Visualizza il file nella cartella /exports.",
|
||||
"success": "Esportazione avviata correttamente. Visualizza il file nella pagina delle esportazioni.",
|
||||
"error": {
|
||||
"failed": "Impossibile avviare l'esportazione: {{error}}",
|
||||
"endTimeMustAfterStartTime": "L'ora di fine deve essere successiva all'ora di inizio",
|
||||
@ -129,6 +129,7 @@
|
||||
"search": {
|
||||
"placeholder": "Cerca per etichetta o sottoetichetta..."
|
||||
},
|
||||
"noImages": "Nessuna miniatura trovata per questa fotocamera"
|
||||
"noImages": "Nessuna miniatura trovata per questa fotocamera",
|
||||
"unknownLabel": "Immagine di attivazione salvata"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,137 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "Modelli di classificazione",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Elimina immagini di classificazione",
|
||||
"renameCategory": "Rinomina classe",
|
||||
"deleteCategory": "Elimina classe",
|
||||
"deleteImages": "Elimina immagini",
|
||||
"trainModel": "Modello di addestramento"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Classe eliminata",
|
||||
"deletedImage": "Immagini eliminate",
|
||||
"categorizedImage": "Immagine classificata con successo",
|
||||
"trainedModel": "Modello addestrato con successo.",
|
||||
"trainingModel": "Avviato con successo l'addestramento del modello."
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Impossibile eliminare: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Impossibile eliminare la classe: {{errorMessage}}",
|
||||
"categorizeFailed": "Impossibile categorizzare l'immagine: {{errorMessage}}",
|
||||
"trainingFailed": "Impossibile avviare l'addestramento del modello: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
"title": "Elimina classe",
|
||||
"desc": "Vuoi davvero eliminare la classe {{name}}? Questa operazione eliminerà definitivamente tutte le immagini associate e richiederà un nuovo addestramento del modello."
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Elimina immagini della base dati",
|
||||
"desc": "Vuoi davvero eliminare {{count}} immagini da {{dataset}}? Questa azione non può essere annullata e richiederà un nuovo addestramento del modello."
|
||||
},
|
||||
"deleteTrainImages": {
|
||||
"title": "Elimina le immagini di addestramento",
|
||||
"desc": "Vuoi davvero eliminare {{count}} immagini? Questa azione non può essere annullata."
|
||||
},
|
||||
"renameCategory": {
|
||||
"title": "Rinomina classe",
|
||||
"desc": "Inserisci un nuovo nome per {{name}}. Sarà necessario riaddestrare il modello affinché la modifica del nome abbia effetto."
|
||||
},
|
||||
"description": {
|
||||
"invalidName": "Nome non valido. I nomi possono contenere solo lettere, numeri, spazi, apostrofi, caratteri di sottolineatura e trattini."
|
||||
},
|
||||
"train": {
|
||||
"title": "Classificazioni recenti",
|
||||
"titleShort": "Recente",
|
||||
"aria": "Seleziona classificazioni recenti"
|
||||
},
|
||||
"categories": "Classi",
|
||||
"createCategory": {
|
||||
"new": "Crea nuova classe"
|
||||
},
|
||||
"categorizeImageAs": "Classifica immagine come:",
|
||||
"categorizeImage": "Classifica immagine",
|
||||
"noModels": {
|
||||
"object": {
|
||||
"title": "Nessun modello di classificazione degli oggetti",
|
||||
"description": "Crea un modello personalizzato per classificare gli oggetti rilevati.",
|
||||
"buttonText": "Crea modello oggetto"
|
||||
},
|
||||
"state": {
|
||||
"title": "Nessun modello di classificazione dello stato",
|
||||
"description": "Crea un modello personalizzato per monitorare e classificare i cambiamenti di stato in aree specifiche della telecamera.",
|
||||
"buttonText": "Crea modello di stato"
|
||||
}
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Crea nuova classificazione",
|
||||
"steps": {
|
||||
"nameAndDefine": "Nome e definizione",
|
||||
"stateArea": "Area di stato",
|
||||
"chooseExamples": "Scegli esempi"
|
||||
},
|
||||
"step1": {
|
||||
"description": "I modelli di stato monitorano le aree fisse delle telecamere per rilevare eventuali cambiamenti (ad esempio, porta aperta/chiusa). I modelli di oggetti aggiungono classificazioni agli oggetti rilevati (ad esempio, animali noti, addetti alle consegne, ecc.).",
|
||||
"name": "Nome",
|
||||
"namePlaceholder": "Inserisci il nome del modello...",
|
||||
"type": "Tipo",
|
||||
"typeState": "Stato",
|
||||
"typeObject": "Oggetto",
|
||||
"objectLabel": "Etichetta oggetto",
|
||||
"objectLabelPlaceholder": "Seleziona il tipo di oggetto...",
|
||||
"classificationType": "Tipo di classificazione",
|
||||
"classificationTypeTip": "Scopri i tipi di classificazione",
|
||||
"classificationTypeDesc": "Le sottoetichette aggiungono testo aggiuntivo all'etichetta dell'oggetto (ad esempio, \"Persona: UPS\"). Gli attributi sono metadati ricercabili, archiviati separatamente nei metadati dell'oggetto.",
|
||||
"classificationSubLabel": "Etichetta secondaria",
|
||||
"classificationAttribute": "Attributo",
|
||||
"classes": "Classi",
|
||||
"classesTip": "Scopri di più sulle classi",
|
||||
"classesStateDesc": "Definisci i diversi stati in cui può trovarsi l'area della tua telecamera. Ad esempio: \"aperto\" e \"chiuso\" per una porta del garage.",
|
||||
"classesObjectDesc": "Definisci le diverse categorie in cui classificare gli oggetti rilevati. Ad esempio: \"corriere\", \"residente\", \"straniero\" per la classificazione delle persone.",
|
||||
"classPlaceholder": "Inserisci il nome della classe...",
|
||||
"errors": {
|
||||
"nameRequired": "Il nome del modello è obbligatorio",
|
||||
"nameLength": "Il nome del modello deve contenere al massimo 64 caratteri",
|
||||
"nameOnlyNumbers": "Il nome del modello non può contenere solo numeri",
|
||||
"classRequired": "È richiesta almeno 1 classe",
|
||||
"classesUnique": "I nomi delle classi devono essere univoci",
|
||||
"stateRequiresTwoClasses": "I modelli di stato richiedono almeno 2 classi",
|
||||
"objectLabelRequired": "Seleziona un'etichetta per l'oggetto",
|
||||
"objectTypeRequired": "Seleziona un tipo di classificazione"
|
||||
},
|
||||
"states": "Stati"
|
||||
},
|
||||
"step2": {
|
||||
"description": "Seleziona le telecamere e definisci l'area da monitorare per ciascuna telecamera. Il modello classificherà lo stato di queste aree.",
|
||||
"cameras": "Telecamere",
|
||||
"selectCamera": "Seleziona telecamera",
|
||||
"noCameras": "Fai clic su + per aggiungere telecamere",
|
||||
"selectCameraPrompt": "Selezionare una telecamera dall'elenco per definire la sua area di monitoraggio"
|
||||
},
|
||||
"step3": {
|
||||
"selectImagesPrompt": "Seleziona tutte le immagini con: {{className}}",
|
||||
"selectImagesDescription": "Clicca sulle immagini per selezionarle. Clicca su Continua quando hai finito con questa classe.",
|
||||
"generating": {
|
||||
"title": "Generazione di immagini campione",
|
||||
"description": "Frigate sta estraendo immagini rappresentative dalle registrazioni. L'operazione potrebbe richiedere qualche istante..."
|
||||
},
|
||||
"training": {
|
||||
"title": "Modello di addestramento",
|
||||
"description": "Il tuo modello è in fase di addestramento in sottofondo. Chiudi questa finestra di dialogo e il tuo modello inizierà a funzionare non appena l'addestramento sarà completato."
|
||||
},
|
||||
"retryGenerate": "Riprova generazione",
|
||||
"noImages": "Nessuna immagine campione generata",
|
||||
"classifying": "Classificazione e addestramento...",
|
||||
"trainingStarted": "Addestramento iniziato con successo",
|
||||
"errors": {
|
||||
"noCameras": "Nessuna telecamera configurata",
|
||||
"noObjectLabel": "Nessuna etichetta oggetto selezionata",
|
||||
"generateFailed": "Impossibile generare esempi: {{error}}",
|
||||
"generationFailed": "Generazione fallita. Per favore riprova.",
|
||||
"classifyFailed": "Impossibile classificare le immagini: {{error}}"
|
||||
},
|
||||
"generateSuccess": "Immagini campione generate correttamente"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,10 +44,17 @@
|
||||
"trackedObject_one": "oggetto",
|
||||
"trackedObject_other": "oggetti",
|
||||
"noObjectDetailData": "Non sono disponibili dati dettagliati sull'oggetto.",
|
||||
"label": "Dettaglio"
|
||||
"label": "Dettaglio",
|
||||
"settings": "Impostazioni di visualizzazione dettagliata",
|
||||
"alwaysExpandActive": {
|
||||
"title": "Espandi sempre attivo",
|
||||
"desc": "Espandere sempre i dettagli dell'oggetto dell'elemento di revisione attivo quando disponibili."
|
||||
}
|
||||
},
|
||||
"objectTrack": {
|
||||
"trackedPoint": "Punto tracciato",
|
||||
"clickToSeek": "Premi per cercare in questo momento"
|
||||
}
|
||||
},
|
||||
"zoomIn": "Ingrandisci",
|
||||
"zoomOut": "Rimpicciolisci"
|
||||
}
|
||||
|
||||
@ -201,11 +201,15 @@
|
||||
},
|
||||
"hideObjectDetails": {
|
||||
"label": "Nascondi il percorso dell'oggetto"
|
||||
},
|
||||
"viewTrackingDetails": {
|
||||
"label": "Visualizza i dettagli di tracciamento",
|
||||
"aria": "Mostra i dettagli di tracciamento"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"desc": "L'eliminazione di questo oggetto tracciato rimuove l'istantanea, eventuali incorporamenti salvati e tutte le voci associate al ciclo di vita dell'oggetto. Il filmato registrato di questo oggetto tracciato nella vista Storico <em>NON</em> verrà eliminato.<br /><br />Vuoi davvero procedere?",
|
||||
"desc": "L'eliminazione di questo oggetto tracciato rimuove l'istantanea, eventuali incorporamenti salvati e tutte le voci associate ai dettagli di tracciamento. Il filmato registrato di questo oggetto tracciato nella vista Storico <em>NON</em> verrà eliminato.<br /><br />Vuoi davvero procedere?",
|
||||
"title": "Conferma eliminazione"
|
||||
}
|
||||
},
|
||||
@ -230,5 +234,53 @@
|
||||
},
|
||||
"concerns": {
|
||||
"label": "Preoccupazioni"
|
||||
},
|
||||
"trackingDetails": {
|
||||
"title": "Dettagli di tracciamento",
|
||||
"noImageFound": "Nessuna immagine trovata per questo orario.",
|
||||
"createObjectMask": "Crea maschera oggetto",
|
||||
"adjustAnnotationSettings": "Regola le impostazioni di annotazione",
|
||||
"scrollViewTips": "Scorri per visualizzare i momenti più significativi del ciclo di vita di questo oggetto.",
|
||||
"autoTrackingTips": "Le posizioni dei riquadri di delimitazione saranno imprecise per le telecamere con tracciamento automatico.",
|
||||
"count": "{{first}} di {{second}}",
|
||||
"trackedPoint": "Punto tracciato",
|
||||
"lifecycleItemDesc": {
|
||||
"visible": "{{label}} rilevato",
|
||||
"entered_zone": "{{label}} è entrato in {{zones}}",
|
||||
"active": "{{label}} è diventato attivo",
|
||||
"stationary": "{{label}} è diventato stazionario",
|
||||
"attribute": {
|
||||
"faceOrLicense_plate": "{{attribute}} rilevato per {{label}}",
|
||||
"other": "{{label}} riconosciuto come {{attribute}}"
|
||||
},
|
||||
"gone": "{{label}} lasciato",
|
||||
"heard": "{{label}} sentito",
|
||||
"external": "{{label}} rilevato",
|
||||
"header": {
|
||||
"zones": "Zone",
|
||||
"ratio": "Rapporto",
|
||||
"area": "Area"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
"title": "Impostazioni di annotazione",
|
||||
"showAllZones": {
|
||||
"title": "Mostra tutte le zone",
|
||||
"desc": "Mostra sempre le zone nei fotogrammi in cui gli oggetti sono entrati in una zona."
|
||||
},
|
||||
"offset": {
|
||||
"label": "Differenza annotazione",
|
||||
"desc": "Questi dati provengono dal flusso di rilevamento della telecamera, ma vengono sovrapposti alle immagini del flusso di registrazione. È improbabile che i due flussi siano perfettamente sincronizzati. Di conseguenza, il riquadro di delimitazione e il filmato non saranno perfettamente allineati. È possibile utilizzare questa impostazione per spostare le annotazioni in avanti o indietro nel tempo per allinearle meglio al filmato registrato.",
|
||||
"millisecondsToOffset": "Millisecondi per compensare il rilevamento delle annotazioni. <em>Predefinito: 0</em>",
|
||||
"tips": "SUGGERIMENTO: Immagina un video evento con una persona che cammina da sinistra a destra. Se il riquadro di delimitazione della cronologia dell'evento si trova costantemente a sinistra della persona, il valore dovrebbe essere diminuito. Allo stesso modo, se una persona cammina da sinistra a destra e il riquadro di delimitazione si trova costantemente davanti alla persona, il valore dovrebbe essere aumentato.",
|
||||
"toast": {
|
||||
"success": "La differenza dell'annotazione per {{camera}} è stato salvato nel file di configurazione. Riavvia Frigate per applicare le modifiche."
|
||||
}
|
||||
}
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Diapositiva precedente",
|
||||
"next": "Diapositiva successiva"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,5 +13,11 @@
|
||||
"error": {
|
||||
"renameExportFailed": "Impossibile rinominare l'esportazione: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"shareExport": "Condividi esportazione",
|
||||
"downloadVideo": "Scarica video",
|
||||
"editName": "Modifica nome",
|
||||
"deleteExport": "Elimina esportazione"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"selectItem": "Seleziona {{item}}",
|
||||
"description": {
|
||||
"addFace": "Procedura per aggiungere una nuova raccolta alla Libreria dei Volti.",
|
||||
"addFace": "Aggiungi una nuova raccolta alla Libreria dei Volti caricando la tua prima immagine.",
|
||||
"placeholder": "Inserisci un nome per questa raccolta",
|
||||
"invalidName": "Nome non valido. I nomi possono contenere solo lettere, numeri, spazi, apostrofi, caratteri di sottolineatura e trattini."
|
||||
},
|
||||
|
||||
@ -494,7 +494,11 @@
|
||||
"label": "Riproduci video di avvisi",
|
||||
"desc": "Per impostazione predefinita, gli avvisi recenti nella schermata dal vivo vengono riprodotti come brevi video in ciclo. Disattiva questa opzione per visualizzare solo un'immagine statica degli avvisi recenti su questo dispositivo/browser."
|
||||
},
|
||||
"title": "Schermata dal vivo"
|
||||
"title": "Schermata dal vivo",
|
||||
"displayCameraNames": {
|
||||
"label": "Mostra sempre i nomi delle telecamere",
|
||||
"desc": "Mostra sempre i nomi delle telecamere in una scheda nel cruscotto della visualizzazione dal vivo multi telecamera."
|
||||
}
|
||||
},
|
||||
"title": "Impostazioni generali",
|
||||
"storedLayouts": {
|
||||
@ -745,7 +749,7 @@
|
||||
"triggers": {
|
||||
"documentTitle": "Inneschi",
|
||||
"management": {
|
||||
"title": "Gestione inneschi",
|
||||
"title": "Inneschi",
|
||||
"desc": "Gestisci gli inneschi per {{camera}}. Utilizza il tipo miniatura per attivare miniature simili all'oggetto tracciato selezionato e il tipo descrizione per attivare descrizioni simili al testo specificato."
|
||||
},
|
||||
"addTrigger": "Aggiungi innesco",
|
||||
@ -766,7 +770,9 @@
|
||||
},
|
||||
"actions": {
|
||||
"alert": "Contrassegna come avviso",
|
||||
"notification": "Invia notifica"
|
||||
"notification": "Invia notifica",
|
||||
"sub_label": "Aggiungi sottoetichetta",
|
||||
"attribute": "Aggiungi attributo"
|
||||
},
|
||||
"dialog": {
|
||||
"createTrigger": {
|
||||
@ -784,25 +790,28 @@
|
||||
"form": {
|
||||
"name": {
|
||||
"title": "Nome",
|
||||
"placeholder": "Inserisci il nome dell'innesco",
|
||||
"placeholder": "Assegna un nome a questo innesco",
|
||||
"error": {
|
||||
"minLength": "Il nome deve essere lungo almeno 2 caratteri.",
|
||||
"invalidCharacters": "Il nome può contenere solo lettere, numeri, caratteri di sottolineatura e trattini.",
|
||||
"minLength": "Il campo deve contenere almeno 2 caratteri.",
|
||||
"invalidCharacters": "Il campo può contenere solo lettere, numeri, caratteri di sottolineatura e trattini.",
|
||||
"alreadyExists": "Per questa telecamera esiste già un innesco con questo nome."
|
||||
}
|
||||
},
|
||||
"description": "Inserisci un nome o una descrizione univoca per identificare questo innesco"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Abilita o disabilita questo innesco"
|
||||
},
|
||||
"type": {
|
||||
"title": "Tipo",
|
||||
"placeholder": "Seleziona il tipo di innesco"
|
||||
"placeholder": "Seleziona il tipo di innesco",
|
||||
"description": "Si attiva quando viene rilevata una descrizione di un oggetto simile tracciato",
|
||||
"thumbnail": "Attiva quando viene rilevata una miniatura di un oggetto simile tracciato"
|
||||
},
|
||||
"content": {
|
||||
"title": "Contenuto",
|
||||
"imagePlaceholder": "Seleziona un'immagine",
|
||||
"imagePlaceholder": "Seleziona una miniatura",
|
||||
"textPlaceholder": "Inserisci il contenuto del testo",
|
||||
"imageDesc": "Seleziona un'immagine per attivare questa azione quando viene rilevata un'immagine simile.",
|
||||
"imageDesc": "Vengono visualizzate solo le 100 miniature più recenti. Se non riesci a trovare la miniatura desiderata, controlla gli oggetti precedenti in Esplora e imposta un innesco dal menu.",
|
||||
"textDesc": "Inserisci il testo per attivare questa azione quando viene rilevata una descrizione simile dell'oggetto tracciato.",
|
||||
"error": {
|
||||
"required": "Il contenuto è obbligatorio."
|
||||
@ -813,11 +822,12 @@
|
||||
"error": {
|
||||
"min": "La soglia deve essere almeno 0",
|
||||
"max": "La soglia deve essere al massimo 1"
|
||||
}
|
||||
},
|
||||
"desc": "Imposta la soglia di similarità per questo innesco. Una soglia più alta indica che è necessaria una corrispondenza più vicina per attivare l'innesco."
|
||||
},
|
||||
"actions": {
|
||||
"title": "Azioni",
|
||||
"desc": "Per impostazione predefinita, Frigate invia un messaggio MQTT per tutti gli inneschi. Scegli un'azione aggiuntiva da eseguire quando questo innesco si attiva.",
|
||||
"desc": "Per impostazione predefinita, Frigate invia un messaggio MQTT per tutti gli inneschi. Le sottoetichette aggiungono il nome dell'innesco all'etichetta dell'oggetto. Gli attributi sono metadati ricercabili, memorizzati separatamente nei metadati dell'oggetto tracciato.",
|
||||
"error": {
|
||||
"min": "È necessario selezionare almeno un'azione."
|
||||
}
|
||||
@ -844,6 +854,23 @@
|
||||
"semanticSearch": {
|
||||
"title": "La ricerca semantica è disabilitata",
|
||||
"desc": "Per utilizzare gli inneschi, è necessario abilitare la ricerca semantica."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Crea innesco",
|
||||
"step1": {
|
||||
"description": "Configura le impostazioni di base per il tuo innesco."
|
||||
},
|
||||
"step2": {
|
||||
"description": "Imposta il contenuto che attiverà questa azione."
|
||||
},
|
||||
"step3": {
|
||||
"description": "Configura la soglia e le azioni per questo innesco."
|
||||
},
|
||||
"steps": {
|
||||
"nameAndType": "Nome e tipo",
|
||||
"configureData": "Configurare i dati",
|
||||
"thresholdAndActions": "Soglia e azioni"
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
|
||||
@ -101,8 +101,8 @@
|
||||
},
|
||||
"timeRange": "Tidsrom",
|
||||
"subLabels": {
|
||||
"label": "Under-Etiketter",
|
||||
"all": "Alle under-Etiketter"
|
||||
"label": "Underetiketter",
|
||||
"all": "Alle underetiketter"
|
||||
},
|
||||
"score": "Poengsum",
|
||||
"estimatedSpeed": "Estimert hastighet ({{unit}})",
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
},
|
||||
"displayCameraNames": {
|
||||
"label": "Vis alltid kameranavn",
|
||||
"desc": "Vis alltid kameranavnene i en etikett i dashbordet for direktevisning med flere kameraer."
|
||||
"desc": "Vis alltid kameranavnene i en merkelapp i dashbordet for direktevisning med flere kameraer."
|
||||
}
|
||||
},
|
||||
"storedLayouts": {
|
||||
|
||||
@ -99,7 +99,8 @@
|
||||
"stateRequiresTwoClasses": "Toestandsmodellen vereisen minimaal 2 klassen",
|
||||
"objectLabelRequired": "Selecteer een objectlabel",
|
||||
"objectTypeRequired": "Selecteer een classificatietype"
|
||||
}
|
||||
},
|
||||
"states": "Staten"
|
||||
},
|
||||
"step2": {
|
||||
"description": "Selecteer camera’s en definieer voor elke camera het te monitoren gebied. Het model zal de toestand van deze gebieden classificeren.",
|
||||
|
||||
@ -1 +1,136 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "Modele de clasificare",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Șterge imaginile de clasificare",
|
||||
"renameCategory": "Redenumește clasa",
|
||||
"deleteCategory": "Șterge clasa",
|
||||
"deleteImages": "Șterge imaginile",
|
||||
"trainModel": "Antrenează modelul"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Clasă ștearsă",
|
||||
"deletedImage": "Imagini șterse",
|
||||
"categorizedImage": "Imagine clasificată cu succes",
|
||||
"trainedModel": "Model antrenat cu succes.",
|
||||
"trainingModel": "Antrenamentul modelului a fost pornit cu succes."
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Ștergerea a eșuat: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Ștergerea clasei a eșuat: {{errorMessage}}",
|
||||
"categorizeFailed": "Categorisirea imaginii a eșuat: {{errorMessage}}",
|
||||
"trainingFailed": "Pornirea antrenamentului modelului a eșuat: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
"title": "Șterge clasa",
|
||||
"desc": "Sigur doriți să ștergeți clasa {{name}}? Aceasta va șterge permanent toate imaginile asociate și va necesita reantrenarea modelului."
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Șterge imaginile setului de date",
|
||||
"desc": "Sigur doriți să ștergeți {{count}} imagini din {{dataset}}? Această acțiune nu poate fi anulată și va necesita reantrenarea modelului."
|
||||
},
|
||||
"deleteTrainImages": {
|
||||
"title": "Șterge imaginile de antrenament",
|
||||
"desc": "Sigur doriți să ștergeți {{count}} imagini? Această acțiune nu poate fi anulată."
|
||||
},
|
||||
"renameCategory": {
|
||||
"title": "Redenumește clasa",
|
||||
"desc": "Introduceți un nume nou pentru {{name}}. Va trebui să reantrenați modelul pentru ca modificarea numelui să aibă efect."
|
||||
},
|
||||
"description": {
|
||||
"invalidName": "Nume invalid. Numele pot include doar litere, cifre, spații, apostrofuri, underscore-uri și liniuțe."
|
||||
},
|
||||
"train": {
|
||||
"title": "Clasificări recente",
|
||||
"titleShort": "Recente",
|
||||
"aria": "Selectează clasificările recente"
|
||||
},
|
||||
"categories": "Clase",
|
||||
"createCategory": {
|
||||
"new": "Creează clasă nouă"
|
||||
},
|
||||
"categorizeImageAs": "Clasifică imaginea ca:",
|
||||
"categorizeImage": "Clasifică imaginea",
|
||||
"noModels": {
|
||||
"object": {
|
||||
"title": "Nu există modele de clasificare a obiectelor",
|
||||
"description": "Creează un model personalizat pentru a clasifica obiectele detectate.",
|
||||
"buttonText": "Creează model de obiect"
|
||||
},
|
||||
"state": {
|
||||
"title": "Nu există modele de clasificare a stării",
|
||||
"description": "Creează un model personalizat pentru a monitoriza și clasifica schimbările de stare în anumite zone ale camerei.",
|
||||
"buttonText": "Creează model de stare"
|
||||
}
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Creează clasificare nouă",
|
||||
"steps": {
|
||||
"nameAndDefine": "Numire și definire",
|
||||
"stateArea": "Zona de stare",
|
||||
"chooseExamples": "Alege exemple"
|
||||
},
|
||||
"step1": {
|
||||
"description": "Modelele de stare monitorizează zone fixe ale camerei pentru schimbări (de exemplu, ușă deschisă/închisă). Modelele de obiect adaugă clasificări obiectelor detectate (de exemplu, animale cunoscute, curieri etc.).",
|
||||
"name": "Nume",
|
||||
"namePlaceholder": "Introduceți numele modelului...",
|
||||
"type": "Tip",
|
||||
"typeState": "Stare",
|
||||
"typeObject": "Obiect",
|
||||
"objectLabel": "Etichetă obiect",
|
||||
"objectLabelPlaceholder": "Selectează tipul obiectului...",
|
||||
"classificationType": "Tip de clasificare",
|
||||
"classificationTypeTip": "Află despre tipurile de clasificare",
|
||||
"classificationTypeDesc": "Subetichetele adaugă text suplimentar la eticheta obiectului (de exemplu, 'Persoană: UPS'). Atributele sunt metadate căutabile, stocate separat în metadatele obiectului.",
|
||||
"classificationSubLabel": "Subeticheta",
|
||||
"classificationAttribute": "Atribut",
|
||||
"classes": "Clase",
|
||||
"classesTip": "Află despre clase",
|
||||
"classesStateDesc": "Definește diferitele stări în care poate fi zona camerei tale. De exemplu: 'deschis' și 'închis' pentru o ușă de garaj.",
|
||||
"classesObjectDesc": "Definește diferitele categorii în care să fie clasificate obiectele detectate. De exemplu: 'curier', 'rezident', 'necunoscut' pentru clasificarea persoanelor.",
|
||||
"classPlaceholder": "Introduceți numele clasei...",
|
||||
"errors": {
|
||||
"nameRequired": "Numele modelului este obligatoriu",
|
||||
"nameLength": "Numele modelului trebuie să aibă 64 de caractere sau mai puțin",
|
||||
"nameOnlyNumbers": "Numele modelului nu poate conține doar cifre",
|
||||
"classRequired": "Este necesară cel puțin 1 clasă",
|
||||
"classesUnique": "Numele claselor trebuie să fie unice",
|
||||
"stateRequiresTwoClasses": "Modelele de stare necesită cel puțin 2 clase",
|
||||
"objectLabelRequired": "Vă rugăm să selectați o etichetă de obiect",
|
||||
"objectTypeRequired": "Vă rugăm să selectați un tip de clasificare"
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"description": "Selectați camerele și definiți zona de monitorizat pentru fiecare cameră. Modelul va clasifica starea acestor zone.",
|
||||
"cameras": "Camere",
|
||||
"selectCamera": "Selectează camera",
|
||||
"noCameras": "Apasă pe + pentru a adăuga camere",
|
||||
"selectCameraPrompt": "Selectați o cameră din listă pentru a defini aria sa de monitorizare"
|
||||
},
|
||||
"step3": {
|
||||
"selectImagesPrompt": "Selectați toate imaginile cu: {{className}}",
|
||||
"selectImagesDescription": "Apăsați pe imagini pentru a le selecta. Apăsați pe Continuare când ați terminat cu această clasă.",
|
||||
"generating": {
|
||||
"title": "Generare imagini de exemplu",
|
||||
"description": "Frigate preia imagini reprezentative din înregistrările tale. Aceasta poate dura câteva momente..."
|
||||
},
|
||||
"training": {
|
||||
"title": "Antrenare model",
|
||||
"description": "Modelul tău este antrenat în fundal. Închide această fereastră și modelul va începe să ruleze imediat ce antrenamentul este finalizat."
|
||||
},
|
||||
"retryGenerate": "Reîncearcă generarea",
|
||||
"noImages": "Nu s-au generat imagini de exemplu",
|
||||
"classifying": "Clasificare și antrenare...",
|
||||
"trainingStarted": "Antrenamentul a început cu succes",
|
||||
"errors": {
|
||||
"noCameras": "Nu există camere configurate",
|
||||
"noObjectLabel": "Nu a fost selectată nicio etichetă de obiect",
|
||||
"generateFailed": "Generarea exemplelor a eșuat: {{error}}",
|
||||
"generationFailed": "Generarea a eșuat. Vă rugăm să încercați din nou.",
|
||||
"classifyFailed": "Clasificarea imaginilor a eșuat: {{error}}"
|
||||
},
|
||||
"generateSuccess": "Imaginile de exemplu au fost generate cu succes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,10 +43,17 @@
|
||||
"trackedObject_one": "obiect",
|
||||
"trackedObject_other": "obiecte",
|
||||
"noObjectDetailData": "Nicio dată de detaliu obiect disponibilă.",
|
||||
"label": "Detaliu"
|
||||
"label": "Detaliu",
|
||||
"settings": "Setări vizualizare detaliată",
|
||||
"alwaysExpandActive": {
|
||||
"title": "Extinde întotdeauna activul",
|
||||
"desc": "Extinde întotdeauna detaliile obiectului elementului activ de revizuire, atunci când sunt disponibile."
|
||||
}
|
||||
},
|
||||
"objectTrack": {
|
||||
"trackedPoint": "Punct urmărit",
|
||||
"clickToSeek": "Apasă pentru a naviga la acest moment"
|
||||
}
|
||||
},
|
||||
"zoomIn": "Mărește",
|
||||
"zoomOut": "Micșorează"
|
||||
}
|
||||
|
||||
@ -270,7 +270,7 @@
|
||||
},
|
||||
"offset": {
|
||||
"label": "Compensare adnotare",
|
||||
"desc": "Aceste date provin din fluxul de detectare al camerei, dar sunt suprapuse pe imaginile din fluxul de înregistrare. Este puțin probabil ca cele două fluxuri să fie perfect sincronizate. Ca urmare, caseta de delimitare și înregistrarea nu se vor alinia perfect. Totuși, câmpul <code>annotation_offset</code> poate fi folosit pentru a ajusta acest lucru.",
|
||||
"desc": "Aceste date provin din fluxul de detectare al camerei tale, dar sunt suprapuse pe imaginile din fluxul de înregistrare. Este puțin probabil ca cele două fluxuri să fie perfect sincronizate. Drept urmare, caseta delimitatoare și materialul video nu se vor alinia perfect. Poți folosi această setare pentru a decală adnotările înainte sau înapoi în timp, pentru a le alinia mai bine cu materialul înregistrat.",
|
||||
"millisecondsToOffset": "Millisecunde pentru a decalca adnotările de detectare. <em>Implicit: 0</em>",
|
||||
"tips": "SFAT: Imaginează-ți că există un clip al unui eveniment cu o persoană care merge de la stânga la dreapta. Dacă caseta delimitatoare a cronologiei evenimentului este constant în stânga persoanei, atunci valoarea ar trebui să fie scăzută. În mod similar, dacă o persoană merge de la stânga la dreapta și caseta delimitatoare este constant în fața persoanei, atunci valoarea ar trebui să fie crescută.",
|
||||
"toast": {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"description": {
|
||||
"addFace": "Adaugă o colecție nouă în Biblioteca de fețe încărcând prima ta imagine.",
|
||||
"placeholder": "Introduceti un nume pentru aceasta colectie",
|
||||
"invalidName": "Nume invalid. Numele poate include doar litere, cifre, spații, apostrofuri, linii de subliniere și cratime."
|
||||
"invalidName": "Nume invalid. Numele pot include doar litere, cifre, spații, apostrofuri, underscore-uri și liniuțe."
|
||||
},
|
||||
"details": {
|
||||
"person": "Persoană",
|
||||
|
||||
@ -99,7 +99,8 @@
|
||||
"stateRequiresTwoClasses": "Моделі станів вимагають щонайменше 2 класів",
|
||||
"objectLabelRequired": "Будь ласка, виберіть мітку об'єкта",
|
||||
"objectTypeRequired": "Будь ласка, виберіть тип класифікації"
|
||||
}
|
||||
},
|
||||
"states": "Штати"
|
||||
},
|
||||
"step2": {
|
||||
"description": "Виберіть камери та визначте область для моніторингу для кожної камери. Модель класифікуватиме стан цих областей.",
|
||||
|
||||
@ -394,7 +394,9 @@ export default function Step1NameAndDefine({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.classes")}
|
||||
{watchedModelType === "state"
|
||||
? t("wizard.step1.states")
|
||||
: t("wizard.step1.classes")}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@ -13,6 +13,9 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
|
||||
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
|
||||
const TOLERANCE = 0.01;
|
||||
|
||||
type ObjectTrackOverlayProps = {
|
||||
camera: string;
|
||||
showBoundingBoxes?: boolean;
|
||||
@ -166,41 +169,45 @@ export default function ObjectTrackOverlay({
|
||||
}) || [];
|
||||
|
||||
// show full path once current time has reached the object's start time
|
||||
const combinedPoints = [...savedPathPoints, ...eventSequencePoints]
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.filter(
|
||||
(point) =>
|
||||
currentTime >= (eventData?.start_time ?? 0) &&
|
||||
point.timestamp >= (eventData?.start_time ?? 0) &&
|
||||
point.timestamp <= (eventData?.end_time ?? Infinity),
|
||||
);
|
||||
// event.start_time is in DETECT stream time, so convert it to record stream time for comparison
|
||||
const eventStartTimeRecord =
|
||||
(eventData?.start_time ?? 0) + annotationOffset / 1000;
|
||||
|
||||
const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
const combinedPoints = allPoints.filter(
|
||||
(point) =>
|
||||
currentTime >= eventStartTimeRecord - TOLERANCE &&
|
||||
point.timestamp <= effectiveCurrentTime + TOLERANCE,
|
||||
);
|
||||
|
||||
// Get color for this object
|
||||
const label = eventData?.label || "unknown";
|
||||
const color = getObjectColor(label, objectId);
|
||||
|
||||
// Get current zones
|
||||
// zones (with tolerance for browsers with seek precision by-design issues)
|
||||
const currentZones =
|
||||
timelineData
|
||||
?.filter(
|
||||
(event: TrackingDetailsSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime,
|
||||
event.timestamp <= effectiveCurrentTime + TOLERANCE,
|
||||
)
|
||||
.sort(
|
||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.zones || [];
|
||||
|
||||
// Get current bounding box
|
||||
const currentBox = timelineData
|
||||
?.filter(
|
||||
(event: TrackingDetailsSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime && event.data.box,
|
||||
)
|
||||
.sort(
|
||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.box;
|
||||
// bounding box (with tolerance for browsers with seek precision by-design issues)
|
||||
const boxCandidates = timelineData?.filter(
|
||||
(event: TrackingDetailsSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime + TOLERANCE &&
|
||||
event.data.box,
|
||||
);
|
||||
const currentBox = boxCandidates?.sort(
|
||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.box;
|
||||
|
||||
return {
|
||||
objectId,
|
||||
@ -221,6 +228,7 @@ export default function ObjectTrackOverlay({
|
||||
getObjectColor,
|
||||
config,
|
||||
camera,
|
||||
annotationOffset,
|
||||
]);
|
||||
|
||||
// Collect all zones across all objects
|
||||
@ -274,9 +282,10 @@ export default function ObjectTrackOverlay({
|
||||
|
||||
const handlePointClick = useCallback(
|
||||
(timestamp: number) => {
|
||||
onSeekToTime?.(timestamp, false);
|
||||
// Convert detect stream timestamp to record stream timestamp before seeking
|
||||
onSeekToTime?.(timestamp + annotationOffset / 1000, false);
|
||||
},
|
||||
[onSeekToTime],
|
||||
[onSeekToTime, annotationOffset],
|
||||
);
|
||||
|
||||
const zonePolygons = useMemo(() => {
|
||||
|
||||
@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
<div className="w-full flex-1 landscape:flex">
|
||||
<Slider
|
||||
value={[annotationOffset]}
|
||||
min={-1500}
|
||||
max={1500}
|
||||
min={-2500}
|
||||
max={2500}
|
||||
step={50}
|
||||
onValueChange={handleChange}
|
||||
/>
|
||||
|
||||
@ -1,577 +0,0 @@
|
||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "../../ui/sheet";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { useApiHost } from "@/api";
|
||||
import {
|
||||
ReviewDetailPaneType,
|
||||
ReviewSegment,
|
||||
ThreatLevel,
|
||||
} from "@/types/review";
|
||||
import { Event } from "@/types/event";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
||||
import TrackingDetails from "./TrackingDetails";
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||
import { FaArrowsRotate } from "react-icons/fa6";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { shareOrCopy } from "@/utils/browserUtil";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
MobilePageDescription,
|
||||
MobilePageHeader,
|
||||
MobilePageTitle,
|
||||
} from "@/components/mobile/MobilePage";
|
||||
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
|
||||
type ReviewDetailDialogProps = {
|
||||
review?: ReviewSegment;
|
||||
setReview: (review: ReviewSegment | undefined) => void;
|
||||
};
|
||||
export default function ReviewDetailDialog({
|
||||
review,
|
||||
setReview,
|
||||
}: ReviewDetailDialogProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// upload
|
||||
|
||||
const [upload, setUpload] = useState<Event>();
|
||||
|
||||
// data
|
||||
|
||||
const { data: events } = useSWR<Event[]>(
|
||||
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
|
||||
);
|
||||
|
||||
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
|
||||
|
||||
const aiThreatLevel = useMemo(() => {
|
||||
if (
|
||||
!aiAnalysis ||
|
||||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
|
||||
) {
|
||||
return "None";
|
||||
}
|
||||
|
||||
let concerns = "";
|
||||
switch (aiAnalysis.potential_threat_level) {
|
||||
case ThreatLevel.SUSPICIOUS:
|
||||
concerns = `• ${t("suspiciousActivity", { ns: "views/events" })}\n`;
|
||||
break;
|
||||
case ThreatLevel.DANGER:
|
||||
concerns = `• ${t("threateningActivity", { ns: "views/events" })}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
(aiAnalysis.other_concerns ?? []).forEach((c) => {
|
||||
concerns += `• ${c}\n`;
|
||||
});
|
||||
|
||||
return concerns || "None";
|
||||
}, [aiAnalysis, t]);
|
||||
|
||||
const hasMismatch = useMemo(() => {
|
||||
if (!review || !events) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return events.length != review?.data.detections.length;
|
||||
}, [review, events]);
|
||||
|
||||
const missingObjects = useMemo(() => {
|
||||
if (!review || !events) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const detectedIds = review.data.detections;
|
||||
const missing = Array.from(
|
||||
new Set(
|
||||
events
|
||||
.filter((event) => !detectedIds.includes(event.id))
|
||||
.map((event) => event.label),
|
||||
),
|
||||
);
|
||||
|
||||
return missing;
|
||||
}, [review, events]);
|
||||
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
review?.start_time ?? 0,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
config?.ui.timezone,
|
||||
);
|
||||
|
||||
// content
|
||||
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
||||
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
|
||||
|
||||
// dialog and mobile page
|
||||
|
||||
const [isOpen, setIsOpen] = useState(review != undefined);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
// short timeout to allow the mobile page animation
|
||||
// to complete before updating the state
|
||||
setTimeout(() => {
|
||||
setReview(undefined);
|
||||
setSelectedEvent(undefined);
|
||||
setPane("overview");
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[setReview, setIsOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(review != undefined);
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [review]);
|
||||
|
||||
// keyboard listener
|
||||
|
||||
useKeyboardListener(["Esc"], (key, modifiers) => {
|
||||
if (key == "Esc" && modifiers.down && !modifiers.repeat) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const Overlay = isDesktop ? Sheet : MobilePage;
|
||||
const Content = isDesktop ? SheetContent : MobilePageContent;
|
||||
const Header = isDesktop ? SheetHeader : MobilePageHeader;
|
||||
const Title = isDesktop ? SheetTitle : MobilePageTitle;
|
||||
const Description = isDesktop ? SheetDescription : MobilePageDescription;
|
||||
|
||||
if (!review) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay
|
||||
open={isOpen ?? false}
|
||||
onOpenChange={handleOpenChange}
|
||||
enableHistoryBack={true}
|
||||
>
|
||||
<FrigatePlusDialog
|
||||
upload={upload}
|
||||
onClose={() => setUpload(undefined)}
|
||||
onEventUploaded={() => {
|
||||
if (upload) {
|
||||
upload.plus_id = "new_upload";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Content
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop && pane == "overview"
|
||||
? "sm:max-w-xl"
|
||||
: "pt-2 sm:max-w-4xl",
|
||||
isMobile && "px-4",
|
||||
)}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
{pane == "overview" && (
|
||||
<Header className="justify-center">
|
||||
<Title>{t("details.item.title")}</Title>
|
||||
<Description className="sr-only">
|
||||
{t("details.item.desc")}
|
||||
</Description>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute flex gap-2 lg:flex-col",
|
||||
isDesktop && "right-1 top-8",
|
||||
isMobile && "right-0 top-3",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("details.item.button.share")}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
shareOrCopy(`${baseUrl}review?id=${review.id}`)
|
||||
}
|
||||
>
|
||||
<FaShareAlt className="size-4 text-secondary-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("details.item.button.share")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<DownloadVideoButton
|
||||
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
|
||||
camera={review.camera}
|
||||
startTime={review.start_time}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.download", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Header>
|
||||
)}
|
||||
{pane == "overview" && (
|
||||
<div className="flex flex-col gap-5 md:mt-3">
|
||||
{aiAnalysis != undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
|
||||
isDesktop && "m-2 w-[90%]",
|
||||
)}
|
||||
>
|
||||
{t("aiAnalysis.title")}
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.description.label")}
|
||||
</div>
|
||||
<div className="text-sm">{aiAnalysis.scene}</div>
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.score.label")}
|
||||
</div>
|
||||
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("concerns.label")}
|
||||
</div>
|
||||
<div className="text-sm">{aiThreatLevel}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.camera")}
|
||||
</div>
|
||||
<div className="text-sm smart-capitalize">
|
||||
<CameraNameLabel camera={review.camera} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.timestamp")}
|
||||
</div>
|
||||
<div className="text-sm">{formattedDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.objects")}
|
||||
</div>
|
||||
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
|
||||
{events?.map((event) => {
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-row items-center gap-2 smart-capitalize"
|
||||
>
|
||||
{getIconForLabel(
|
||||
event.label,
|
||||
"size-3 text-primary",
|
||||
)}
|
||||
{event.sub_label ??
|
||||
event.label.replaceAll("_", " ")}{" "}
|
||||
({Math.round(event.data.top_score * 100)}%)
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
}}
|
||||
>
|
||||
<LuSearch className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("details.item.button.viewInExplore")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{review.data.zones.length > 0 && (
|
||||
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.zones")}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
|
||||
{review.data.zones.map((zone) => {
|
||||
return (
|
||||
<div
|
||||
key={zone}
|
||||
className="flex flex-row items-center gap-2 smart-capitalize"
|
||||
>
|
||||
{zone.replaceAll("_", " ")}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasMismatch && (
|
||||
<div className="p-4 text-center text-sm">
|
||||
{(() => {
|
||||
const detectedCount = Math.abs(
|
||||
(events?.length ?? 0) -
|
||||
(review?.data.detections.length ?? 0),
|
||||
);
|
||||
|
||||
return t("details.item.tips.mismatch", {
|
||||
count: detectedCount,
|
||||
});
|
||||
})()}
|
||||
{missingObjects.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<Trans
|
||||
ns="views/explore"
|
||||
values={{
|
||||
objects: missingObjects
|
||||
.map((x) => getTranslatedLabel(x))
|
||||
.join(", "),
|
||||
}}
|
||||
>
|
||||
details.item.tips.hasMissingObjects
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex size-full flex-col gap-2">
|
||||
{events?.map((event) => (
|
||||
<EventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
setPane={setPane}
|
||||
setSelectedEvent={setSelectedEvent}
|
||||
setUpload={setUpload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pane == "details" && selectedEvent && (
|
||||
<div className="mt-0 flex size-full flex-col gap-2">
|
||||
<TrackingDetails event={selectedEvent} setPane={setPane} />
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type EventItemProps = {
|
||||
event: Event;
|
||||
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
||||
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
|
||||
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
|
||||
};
|
||||
|
||||
function EventItem({
|
||||
event,
|
||||
setPane,
|
||||
setSelectedEvent,
|
||||
setUpload,
|
||||
}: EventItemProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const imgRef = useRef(null);
|
||||
|
||||
const [hovered, setHovered] = useState(isMobile);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative mr-auto",
|
||||
!event.has_snapshot && "flex flex-row items-center justify-center",
|
||||
)}
|
||||
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
||||
key={event.id}
|
||||
>
|
||||
{event.has_snapshot && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
|
||||
</>
|
||||
)}
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={cn(
|
||||
"select-none rounded-lg object-contain transition-opacity",
|
||||
)}
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={
|
||||
event.has_snapshot
|
||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||
}
|
||||
/>
|
||||
{hovered && (
|
||||
<div>
|
||||
<div
|
||||
className={cn("absolute right-1 top-1 flex items-center gap-2")}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
download
|
||||
href={
|
||||
event.has_snapshot
|
||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||
}
|
||||
>
|
||||
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
|
||||
<FaDownload className="size-4 text-white" />
|
||||
</Chip>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.download", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{event.has_snapshot &&
|
||||
event.plus_id == undefined &&
|
||||
event.data.type == "object" &&
|
||||
config?.plus.enabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Chip
|
||||
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
||||
onClick={() => {
|
||||
setUpload?.(event);
|
||||
}}
|
||||
>
|
||||
<FrigatePlusIcon className="size-4 text-white" />
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.submitToPlus.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{event.has_clip && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Chip
|
||||
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
||||
onClick={() => {
|
||||
setPane("details");
|
||||
setSelectedEvent(event);
|
||||
}}
|
||||
>
|
||||
<FaArrowsRotate className="size-4 text-white" />
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.viewTrackingDetails.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{event.has_snapshot && config?.semantic_search.enabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Chip
|
||||
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
||||
onClick={() => {
|
||||
navigate(
|
||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FaImages className="size-4 text-white" />
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -31,10 +31,9 @@ import {
|
||||
FaDownload,
|
||||
FaHistory,
|
||||
FaImage,
|
||||
FaRegListAlt,
|
||||
FaVideo,
|
||||
} from "react-icons/fa";
|
||||
import TrackingDetails from "./TrackingDetails";
|
||||
import { TrackingDetails } from "./TrackingDetails";
|
||||
import { DetailStreamProvider } from "@/context/detail-stream-context";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CgTranscript } from "react-icons/cg";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { PiPath } from "react-icons/pi";
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
const SEARCH_TABS = [
|
||||
"details",
|
||||
"snapshot",
|
||||
"video",
|
||||
"tracking_details",
|
||||
] as const;
|
||||
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
|
||||
type SearchDetailDialogProps = {
|
||||
@ -109,6 +104,7 @@ export default function SearchDetailDialog({
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const apiHost = useApiHost();
|
||||
|
||||
// tabs
|
||||
|
||||
@ -149,16 +145,6 @@ export default function SearchDetailDialog({
|
||||
|
||||
const views = [...SEARCH_TABS];
|
||||
|
||||
if (!search.has_snapshot) {
|
||||
const index = views.indexOf("snapshot");
|
||||
views.splice(index, 1);
|
||||
}
|
||||
|
||||
if (!search.has_clip) {
|
||||
const index = views.indexOf("video");
|
||||
views.splice(index, 1);
|
||||
}
|
||||
|
||||
if (search.data.type != "object" || !search.has_clip) {
|
||||
const index = views.indexOf("tracking_details");
|
||||
views.splice(index, 1);
|
||||
@ -173,10 +159,50 @@ export default function SearchDetailDialog({
|
||||
}
|
||||
|
||||
if (!searchTabs.includes(pageToggle)) {
|
||||
setSearchPage("details");
|
||||
setSearchPage("snapshot");
|
||||
}
|
||||
}, [pageToggle, searchTabs, setSearchPage]);
|
||||
|
||||
// Tabs component for reuse
|
||||
const tabsComponent = (
|
||||
<ScrollArea className="w-full whitespace-nowrap">
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SearchTab) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.values(searchTabs).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
data-nav-item={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
{item == "snapshot" && <FaImage className="size-4" />}
|
||||
{item == "tracking_details" && <PiPath className="size-4" />}
|
||||
<div className="smart-capitalize">
|
||||
{item === "snapshot"
|
||||
? search?.has_snapshot
|
||||
? t("type.snapshot")
|
||||
: t("type.thumbnail")
|
||||
: t(`type.${item}`)}
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
if (!search) {
|
||||
return;
|
||||
}
|
||||
@ -190,92 +216,188 @@ export default function SearchDetailDialog({
|
||||
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
enableHistoryBack={true}
|
||||
<DetailStreamProvider
|
||||
isDetailMode={true}
|
||||
currentTime={(search as unknown as Event)?.start_time ?? 0}
|
||||
camera={(search as unknown as Event)?.camera ?? ""}
|
||||
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
|
||||
>
|
||||
<Content
|
||||
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",
|
||||
)}
|
||||
<Overlay
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
enableHistoryBack={true}
|
||||
>
|
||||
<Header>
|
||||
<Title>{t("trackedObjectDetails")}</Title>
|
||||
<Description className="sr-only">
|
||||
{t("trackedObjectDetails")}
|
||||
</Description>
|
||||
</Header>
|
||||
<ScrollArea
|
||||
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
|
||||
<Content
|
||||
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",
|
||||
isDesktop &&
|
||||
page == "tracking_details" &&
|
||||
"lg:max-w-[75%] xl:max-w-[80%]",
|
||||
isMobile && "px-4",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SearchTab) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.values(searchTabs).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
data-nav-item={item}
|
||||
aria-label={`Select ${item}`}
|
||||
<Header>
|
||||
<Title>{t("trackedObjectDetails")}</Title>
|
||||
<Description className="sr-only">
|
||||
{t("trackedObjectDetails")}
|
||||
</Description>
|
||||
</Header>
|
||||
{isDesktop ? (
|
||||
page === "tracking_details" ? (
|
||||
<TrackingDetails
|
||||
className="size-full"
|
||||
event={search as unknown as Event}
|
||||
tabs={tabsComponent}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full gap-4 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-container flex-[3] overflow-y-hidden",
|
||||
page === "snapshot" && !search.has_snapshot && "flex-[2]",
|
||||
)}
|
||||
>
|
||||
{item == "details" && <FaRegListAlt className="size-4" />}
|
||||
{item == "snapshot" && <FaImage className="size-4" />}
|
||||
{item == "video" && <FaVideo className="size-4" />}
|
||||
{item == "tracking_details" && <PiPath className="size-4" />}
|
||||
<div className="smart-capitalize">{t(`type.${item}`)}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{page == "details" && (
|
||||
<ObjectDetailsTab
|
||||
search={search}
|
||||
config={config}
|
||||
setSearch={setSearch}
|
||||
setSimilarity={setSimilarity}
|
||||
setInputFocused={setInputFocused}
|
||||
/>
|
||||
)}
|
||||
{page == "snapshot" && (
|
||||
<ObjectSnapshotTab
|
||||
search={
|
||||
{
|
||||
...search,
|
||||
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
|
||||
} as unknown as Event
|
||||
}
|
||||
onEventUploaded={() => {
|
||||
search.plus_id = "new_upload";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{page == "video" && <VideoTab search={search} />}
|
||||
{page == "tracking_details" && (
|
||||
<TrackingDetails
|
||||
className="w-full overflow-x-hidden"
|
||||
event={search as unknown as Event}
|
||||
fullscreen={true}
|
||||
setPane={() => {}}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
</Overlay>
|
||||
{page === "snapshot" && search.has_snapshot && (
|
||||
<ObjectSnapshotTab
|
||||
search={
|
||||
{
|
||||
...search,
|
||||
plus_id: config?.plus?.enabled
|
||||
? search.plus_id
|
||||
: "not_enabled",
|
||||
} as unknown as Event
|
||||
}
|
||||
onEventUploaded={() => {
|
||||
search.plus_id = "new_upload";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{page === "snapshot" && !search.has_snapshot && (
|
||||
<img
|
||||
className="size-full select-none rounded-lg object-contain transition-opacity"
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
|
||||
{tabsComponent}
|
||||
<div className="scrollbar-container flex-1 overflow-y-auto">
|
||||
{page == "snapshot" && (
|
||||
<ObjectDetailsTab
|
||||
search={search}
|
||||
config={config}
|
||||
setSearch={setSearch}
|
||||
setSimilarity={setSimilarity}
|
||||
setInputFocused={setInputFocused}
|
||||
showThumbnail={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea
|
||||
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SearchTab) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.values(searchTabs).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
data-nav-item={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
{item == "snapshot" && <FaImage className="size-4" />}
|
||||
{item == "tracking_details" && (
|
||||
<PiPath className="size-4" />
|
||||
)}
|
||||
<div className="smart-capitalize">
|
||||
{t(`type.${item}`)}
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{page == "snapshot" && (
|
||||
<>
|
||||
{search.has_snapshot && (
|
||||
<ObjectSnapshotTab
|
||||
search={
|
||||
{
|
||||
...search,
|
||||
plus_id: config?.plus?.enabled
|
||||
? search.plus_id
|
||||
: "not_enabled",
|
||||
} as unknown as Event
|
||||
}
|
||||
onEventUploaded={() => {
|
||||
search.plus_id = "new_upload";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{page == "snapshot" && !search.has_snapshot && (
|
||||
<img
|
||||
className="w-full select-none rounded-lg object-contain transition-opacity"
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||
/>
|
||||
)}
|
||||
<Heading as="h3" className="mt-2 smart-capitalize">
|
||||
{t("type.details")}
|
||||
</Heading>
|
||||
<ObjectDetailsTab
|
||||
search={search}
|
||||
config={config}
|
||||
setSearch={setSearch}
|
||||
setSimilarity={setSimilarity}
|
||||
setInputFocused={setInputFocused}
|
||||
showThumbnail={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{page == "tracking_details" && (
|
||||
<TrackingDetails event={search as unknown as Event} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Content>
|
||||
</Overlay>
|
||||
</DetailStreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -285,6 +407,7 @@ type ObjectDetailsTabProps = {
|
||||
setSearch: (search: SearchResult | undefined) => void;
|
||||
setSimilarity?: () => void;
|
||||
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showThumbnail?: boolean;
|
||||
};
|
||||
function ObjectDetailsTab({
|
||||
search,
|
||||
@ -292,6 +415,7 @@ function ObjectDetailsTab({
|
||||
setSearch,
|
||||
setSimilarity,
|
||||
setInputFocused,
|
||||
showThumbnail = true,
|
||||
}: ObjectDetailsTabProps) {
|
||||
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
|
||||
|
||||
@ -873,66 +997,71 @@ function ObjectDetailsTab({
|
||||
<div className="text-sm">{formattedDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 pl-6">
|
||||
<img
|
||||
className="aspect-video select-none rounded-lg object-contain transition-opacity"
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||
/>
|
||||
<div
|
||||
className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")}
|
||||
>
|
||||
{config?.semantic_search.enabled &&
|
||||
setSimilarity != undefined &&
|
||||
search.data.type == "object" && (
|
||||
<Button
|
||||
{showThumbnail && (
|
||||
<div className="flex w-full flex-col gap-2 pl-6">
|
||||
<img
|
||||
className="aspect-video select-none rounded-lg object-contain transition-opacity"
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row gap-2",
|
||||
isMobile && "flex-col",
|
||||
)}
|
||||
>
|
||||
{config?.semantic_search.enabled &&
|
||||
setSimilarity != undefined &&
|
||||
search.data.type == "object" && (
|
||||
<Button
|
||||
className="w-full"
|
||||
aria-label={t("itemMenu.findSimilar.aria")}
|
||||
onClick={() => {
|
||||
setSearch(undefined);
|
||||
setSimilarity();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<LuSearch />
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
{hasFace && (
|
||||
<FaceSelectionDialog
|
||||
className="w-full"
|
||||
aria-label={t("itemMenu.findSimilar.aria")}
|
||||
onClick={() => {
|
||||
setSearch(undefined);
|
||||
setSimilarity();
|
||||
}}
|
||||
faceNames={faceNames}
|
||||
onTrainAttempt={onTrainFace}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<LuSearch />
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
{hasFace && (
|
||||
<FaceSelectionDialog
|
||||
className="w-full"
|
||||
faceNames={faceNames}
|
||||
onTrainAttempt={onTrainFace}
|
||||
>
|
||||
<Button className="w-full">
|
||||
<div className="flex gap-1">
|
||||
<TbFaceId />
|
||||
{t("trainFace", { ns: "views/faceLibrary" })}
|
||||
</div>
|
||||
</Button>
|
||||
</FaceSelectionDialog>
|
||||
)}
|
||||
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
||||
search?.label == "speech" &&
|
||||
search?.end_time && (
|
||||
<Button className="w-full" onClick={onTranscribe}>
|
||||
<div className="flex gap-1">
|
||||
<CgTranscript />
|
||||
{t("itemMenu.audioTranscription.label")}
|
||||
</div>
|
||||
</Button>
|
||||
<Button className="w-full">
|
||||
<div className="flex gap-1">
|
||||
<TbFaceId />
|
||||
{t("trainFace", { ns: "views/faceLibrary" })}
|
||||
</div>
|
||||
</Button>
|
||||
</FaceSelectionDialog>
|
||||
)}
|
||||
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
||||
search?.label == "speech" &&
|
||||
search?.end_time && (
|
||||
<Button className="w-full" onClick={onTranscribe}>
|
||||
<div className="flex gap-1">
|
||||
<CgTranscript />
|
||||
{t("itemMenu.audioTranscription.label")}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{config?.cameras[search.camera].objects.genai.enabled &&
|
||||
@ -1167,7 +1296,7 @@ export function ObjectSnapshotTab({
|
||||
search.label != "on_demand" && (
|
||||
<Card className="p-1 text-sm md:p-2">
|
||||
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
|
||||
<div className={cn("flex flex-col space-y-3")}>
|
||||
<div className={cn("flex max-w-sm flex-col space-y-3")}>
|
||||
<div className={"text-lg leading-none"}>
|
||||
{t("explore.plus.submitToPlus.label")}
|
||||
</div>
|
||||
@ -1176,7 +1305,7 @@ export function ObjectSnapshotTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end">
|
||||
{state == "reviewing" && (
|
||||
<>
|
||||
<div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -57,7 +57,7 @@ export default function DetailStream({
|
||||
elementRef: scrollRef,
|
||||
});
|
||||
|
||||
const effectiveTime = currentTime + annotationOffset / 1000;
|
||||
const effectiveTime = currentTime - annotationOffset / 1000;
|
||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||
const [controlsExpanded, setControlsExpanded] = useState(false);
|
||||
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
||||
@ -213,6 +213,7 @@ export default function DetailStream({
|
||||
config={config}
|
||||
onSeek={onSeekCheckPlaying}
|
||||
effectiveTime={effectiveTime}
|
||||
annotationOffset={annotationOffset}
|
||||
isActive={activeReviewId == id}
|
||||
onActivate={() => setActiveReviewId(id)}
|
||||
onOpenUpload={(e) => setUpload(e)}
|
||||
@ -278,6 +279,7 @@ type ReviewGroupProps = {
|
||||
onActivate?: () => void;
|
||||
onOpenUpload?: (e: Event) => void;
|
||||
effectiveTime?: number;
|
||||
annotationOffset: number;
|
||||
alwaysExpandActive?: boolean;
|
||||
};
|
||||
|
||||
@ -290,11 +292,14 @@ function ReviewGroup({
|
||||
onActivate,
|
||||
onOpenUpload,
|
||||
effectiveTime,
|
||||
annotationOffset,
|
||||
alwaysExpandActive = false,
|
||||
}: ReviewGroupProps) {
|
||||
const { t } = useTranslation("views/events");
|
||||
const [open, setOpen] = useState(false);
|
||||
const start = review.start_time ?? 0;
|
||||
// review.start_time is in detect time, convert to record for seeking
|
||||
const startRecord = start + annotationOffset / 1000;
|
||||
|
||||
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
|
||||
useEffect(() => {
|
||||
@ -371,7 +376,7 @@ function ReviewGroup({
|
||||
)}
|
||||
onClick={() => {
|
||||
onActivate?.();
|
||||
onSeek(start);
|
||||
onSeek(startRecord);
|
||||
}}
|
||||
>
|
||||
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
|
||||
@ -450,6 +455,7 @@ function ReviewGroup({
|
||||
key={event.id}
|
||||
event={event}
|
||||
effectiveTime={effectiveTime}
|
||||
annotationOffset={annotationOffset}
|
||||
onSeek={onSeek}
|
||||
onOpenUpload={onOpenUpload}
|
||||
/>
|
||||
@ -483,12 +489,14 @@ function ReviewGroup({
|
||||
type EventListProps = {
|
||||
event: Event;
|
||||
effectiveTime?: number;
|
||||
annotationOffset: number;
|
||||
onSeek: (ts: number, play?: boolean) => void;
|
||||
onOpenUpload?: (e: Event) => void;
|
||||
};
|
||||
function EventList({
|
||||
event,
|
||||
effectiveTime,
|
||||
annotationOffset,
|
||||
onSeek,
|
||||
onOpenUpload,
|
||||
}: EventListProps) {
|
||||
@ -505,14 +513,17 @@ function EventList({
|
||||
if (event) {
|
||||
setSelectedObjectIds([]);
|
||||
setSelectedObjectIds([event.id]);
|
||||
onSeek(event.start_time);
|
||||
// event.start_time is detect time, convert to record
|
||||
const recordTime = event.start_time + annotationOffset / 1000;
|
||||
onSeek(recordTime);
|
||||
} else {
|
||||
setSelectedObjectIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimelineClick = (ts: number, play?: boolean) => {
|
||||
handleObjectSelect(event);
|
||||
setSelectedObjectIds([]);
|
||||
setSelectedObjectIds([event.id]);
|
||||
onSeek(ts, play);
|
||||
};
|
||||
|
||||
@ -554,7 +565,6 @@ function EventList({
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSeek(event.start_time);
|
||||
handleObjectSelect(event);
|
||||
}}
|
||||
role="button"
|
||||
@ -568,7 +578,6 @@ function EventList({
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSeek(event.start_time);
|
||||
handleObjectSelect(event);
|
||||
}}
|
||||
role="button"
|
||||
@ -607,6 +616,7 @@ function EventList({
|
||||
eventId={event.id}
|
||||
onSeek={handleTimelineClick}
|
||||
effectiveTime={effectiveTime}
|
||||
annotationOffset={annotationOffset}
|
||||
startTime={event.start_time}
|
||||
endTime={event.end_time}
|
||||
/>
|
||||
@ -621,6 +631,7 @@ type LifecycleItemProps = {
|
||||
isActive?: boolean;
|
||||
onSeek?: (timestamp: number, play?: boolean) => void;
|
||||
effectiveTime?: number;
|
||||
annotationOffset: number;
|
||||
isTimelineActive?: boolean;
|
||||
};
|
||||
|
||||
@ -629,6 +640,7 @@ function LifecycleItem({
|
||||
isActive,
|
||||
onSeek,
|
||||
effectiveTime,
|
||||
annotationOffset,
|
||||
isTimelineActive = false,
|
||||
}: LifecycleItemProps) {
|
||||
const { t } = useTranslation("views/events");
|
||||
@ -682,7 +694,8 @@ function LifecycleItem({
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
onSeek?.(item.timestamp, false);
|
||||
const recordTimestamp = item.timestamp + annotationOffset / 1000;
|
||||
onSeek?.(recordTimestamp, false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
|
||||
@ -751,12 +764,14 @@ function ObjectTimeline({
|
||||
eventId,
|
||||
onSeek,
|
||||
effectiveTime,
|
||||
annotationOffset,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
eventId: string;
|
||||
onSeek: (ts: number, play?: boolean) => void;
|
||||
effectiveTime?: number;
|
||||
annotationOffset: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}) {
|
||||
@ -857,6 +872,7 @@ function ObjectTimeline({
|
||||
onSeek={onSeek}
|
||||
isActive={isActive}
|
||||
effectiveTime={effectiveTime}
|
||||
annotationOffset={annotationOffset}
|
||||
isTimelineActive={isWithinEventRange}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
|
||||
isDetailMode: boolean;
|
||||
currentTime: number;
|
||||
camera: string;
|
||||
initialSelectedObjectIds?: string[];
|
||||
}
|
||||
|
||||
export function DetailStreamProvider({
|
||||
@ -29,8 +30,11 @@ export function DetailStreamProvider({
|
||||
isDetailMode,
|
||||
currentTime,
|
||||
camera,
|
||||
initialSelectedObjectIds,
|
||||
}: DetailStreamProviderProps) {
|
||||
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]);
|
||||
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>(
|
||||
() => initialSelectedObjectIds ?? [],
|
||||
);
|
||||
|
||||
const toggleObjectSelection = (id: string | undefined) => {
|
||||
if (id === undefined) {
|
||||
|
||||
@ -2,7 +2,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -10,13 +10,35 @@ import {
|
||||
CustomClassificationModelConfig,
|
||||
FrigateConfig,
|
||||
} from "@/types/frigateConfig";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolderPlus } from "react-icons/fa";
|
||||
import { MdModelTraining } from "react-icons/md";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import BlurredIconButton from "@/components/button/BlurredIconButton";
|
||||
|
||||
const allModelTypes = ["objects", "states"] as const;
|
||||
type ModelType = (typeof allModelTypes)[number];
|
||||
@ -126,7 +148,7 @@ export default function ModelSelectionView({
|
||||
onClick={() => setNewModel(true)}
|
||||
>
|
||||
<FaFolderPlus />
|
||||
Add Classification
|
||||
{t("button.addClassification")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -142,6 +164,7 @@ export default function ModelSelectionView({
|
||||
key={config.name}
|
||||
config={config}
|
||||
onClick={() => onClick(config)}
|
||||
onDelete={() => refreshConfig()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -179,12 +202,53 @@ function NoModelsView({
|
||||
type ModelCardProps = {
|
||||
config: CustomClassificationModelConfig;
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
function ModelCard({ config, onClick }: ModelCardProps) {
|
||||
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||
const { t } = useTranslation(["views/classificationModel"]);
|
||||
|
||||
const { data: dataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const bypassDialogRef = useRef(false);
|
||||
|
||||
useKeyboardListener(["Shift"], (_, modifiers) => {
|
||||
bypassDialogRef.current = modifiers.shift;
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
await axios
|
||||
.delete(`classification/${config.name}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(t("toast.success.deletedModel", { count: 1 }), {
|
||||
position: "top-center",
|
||||
});
|
||||
onDelete();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("toast.error.deleteModelFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [config, onDelete, t]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
if (bypassDialogRef.current) {
|
||||
handleDelete();
|
||||
} else {
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
}, [handleDelete]);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
if (!dataset) {
|
||||
return undefined;
|
||||
@ -204,22 +268,66 @@ function ModelCard({ config, onClick }: ModelCardProps) {
|
||||
}, [dataset]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={config.name}
|
||||
className={cn(
|
||||
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
|
||||
"outline-transparent duration-500",
|
||||
)}
|
||||
onClick={() => onClick()}
|
||||
>
|
||||
<img
|
||||
className="size-full"
|
||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
|
||||
/>
|
||||
<ImageShadowOverlay />
|
||||
<div className="absolute bottom-2 left-3 text-lg smart-capitalize">
|
||||
{config.name}
|
||||
<>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteModel.title")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteModel.single", { name: config.name })}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
className="size-full"
|
||||
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
|
||||
/>
|
||||
<ImageShadowOverlay />
|
||||
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
|
||||
{config.name}
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 z-40">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<BlurredIconButton>
|
||||
<FiMoreVertical className="size-5 text-white" />
|
||||
</BlurredIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||
<LuTrash2 className="mr-2 size-4" />
|
||||
<span>
|
||||
{bypassDialogRef.current
|
||||
? t("button.deleteNow", { ns: "common" })
|
||||
: t("button.delete", { ns: "common" })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -893,7 +893,7 @@ function ObjectTrainGrid({
|
||||
// selection
|
||||
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
||||
const [dialogTab, setDialogTab] = useState<SearchTab>("details");
|
||||
const [dialogTab, setDialogTab] = useState<SearchTab>("snapshot");
|
||||
|
||||
// handlers
|
||||
|
||||
|
||||
@ -214,7 +214,7 @@ export default function SearchView({
|
||||
// detail
|
||||
|
||||
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
||||
const [page, setPage] = useState<SearchTab>("details");
|
||||
const [page, setPage] = useState<SearchTab>("snapshot");
|
||||
|
||||
// search interaction
|
||||
|
||||
@ -222,7 +222,7 @@ export default function SearchView({
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const onSelectSearch = useCallback(
|
||||
(item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
|
||||
(item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => {
|
||||
if (selectedObjects.length > 1 || ctrl) {
|
||||
const index = selectedObjects.indexOf(item.id);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user