mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 03:09:02 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba4a6a53d7 | ||
|
|
e90079ab2f | ||
|
|
edcf0b0d2c | ||
|
|
578d6391fb | ||
|
|
c9c9c1793d | ||
|
|
a3b10c308b | ||
|
|
540290bba7 | ||
|
|
b02d928056 | ||
|
|
057351e492 | ||
|
|
c7af51376b | ||
|
|
74cfa61952 | ||
|
|
81899ddf57 | ||
|
|
33e2b23f2e | ||
|
|
b5193c9978 | ||
|
|
93443773f4 | ||
|
|
57eb7db5c9 | ||
|
|
eb73277322 | ||
|
|
d3853688ed | ||
|
|
5fd8fc881a | ||
|
|
7000712454 | ||
|
|
72068b6823 | ||
|
|
04233c0574 | ||
|
|
1584400916 | ||
|
|
0d1189f26a | ||
|
|
29eff13a4c | ||
|
|
1162c01b3e | ||
|
|
27b7ef0a7a | ||
|
|
01a7ec1060 | ||
|
|
95b5b89ed9 | ||
|
|
a182385618 | ||
|
|
088e1ad7ef | ||
|
|
011ad8eda7 | ||
|
|
4171efcd79 | ||
|
|
0ea8924727 | ||
|
|
1a1994ca17 | ||
|
|
819e8de172 | ||
|
|
ea246384bf | ||
|
|
d8f70b7fed | ||
|
|
434ef358a2 | ||
|
|
fe269b77b8 | ||
|
|
77831304a7 | ||
|
|
1a6d04fde7 | ||
|
|
4a1b7a1629 | ||
|
|
8eace9c3e7 | ||
|
|
8fc1e97df5 | ||
|
|
0a332cada9 | ||
|
|
ba499201e6 | ||
|
|
c244e6582a | ||
|
|
fff3594553 | ||
|
|
25bfb2c481 |
@@ -26,7 +26,7 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
|
||||
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue:
|
||||
- Link to discussion with maintainers (**required** for large/pinned features):
|
||||
- Link to discussion with maintainers (**required** for any large or "planned" features):
|
||||
|
||||
## For new features
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR description against template
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate'];
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
run: npm run e2e
|
||||
working-directory: ./web
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
close-issue-message: ""
|
||||
days-before-stale: 30
|
||||
days-before-close: 3
|
||||
exempt-draft-pr: true
|
||||
exempt-issue-labels: "pinned,security"
|
||||
exempt-pr-labels: "pinned,security,dependencies"
|
||||
exempt-draft-pr: false
|
||||
exempt-issue-labels: "planned,security"
|
||||
exempt-pr-labels: "planned,security,dependencies"
|
||||
operations-per-run: 120
|
||||
- name: Print outputs
|
||||
env:
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue
|
||||
|
||||
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
|
||||
|
||||
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
|
||||
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
|
||||
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
|
||||
3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ joserfc == 1.2.*
|
||||
cryptography == 44.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.20
|
||||
python-multipart == 0.0.26
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
|
||||
@@ -32,11 +32,14 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
|
||||
FROM deps AS deps-prelim
|
||||
|
||||
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
|
||||
RUN apt-get update && \
|
||||
# install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching
|
||||
# -dev package must also come from trixie or apt refuses to satisfy it.
|
||||
RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y libnuma1 && \
|
||||
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
|
||||
# Install C++ standard library headers for HIPRTC kernel compilation fallback
|
||||
apt-get install -qq -y libstdc++-12-dev && \
|
||||
apt-get install -qq -y -t trixie libstdc++-14-dev && \
|
||||
rm -f /etc/apt/sources.list.d/trixie.list && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
|
||||
@@ -171,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al
|
||||
- If it is difficult to make out details in a persons face it will not be helpful in training.
|
||||
- Avoid images with extreme under/over-exposure.
|
||||
- Avoid blurry / pixelated images.
|
||||
- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images.
|
||||
- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images.
|
||||
- Using images of people wearing hats / sunglasses may confuse the model.
|
||||
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
||||
|
||||
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.
|
||||
|
||||
## [kiosk-monitor](https://github.com/extremeshok/kiosk-monitor)
|
||||
|
||||
[kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) is a Raspberry Pi watchdog that runs Chromium fullscreen on a Frigate dashboard (optionally with VLC on a second monitor for an RTSP camera stream), auto-restarts on frozen screens or unreachable URLs, and ships a Birdseye-aware Chromium helper that auto-sizes the grid to the display.
|
||||
|
||||
## [Periscope](https://github.com/maksz42/periscope)
|
||||
|
||||
[Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS.
|
||||
|
||||
Generated
+3
-3
@@ -10897,9 +10897,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/range-parser": {
|
||||
|
||||
+49
-2
@@ -36,6 +36,7 @@ from frigate.api.defs.response.chat_response import (
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import events
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.jobs.vlm_watch import (
|
||||
get_vlm_watch_job,
|
||||
@@ -401,9 +402,38 @@ def get_tools() -> JSONResponse:
|
||||
return JSONResponse(content={"tools": tools})
|
||||
|
||||
|
||||
def _resolve_zones(
|
||||
zones: List[str],
|
||||
config: FrigateConfig,
|
||||
target_cameras: List[str],
|
||||
) -> List[str]:
|
||||
"""Map zone names to their canonical config keys, case-insensitively.
|
||||
|
||||
LLMs frequently echo a user's casing ("Front Yard") instead of the
|
||||
configured key ("front_yard"). The downstream zone filter is a SQLite GLOB
|
||||
over the JSON-encoded zones column, which is case-sensitive — so an
|
||||
unnormalized name silently returns zero matches. Build a lookup over the
|
||||
relevant cameras' configured zones and substitute when we find a match;
|
||||
unknown names pass through so behavior matches what the model asked for.
|
||||
"""
|
||||
if not zones:
|
||||
return zones
|
||||
|
||||
lookup: Dict[str, str] = {}
|
||||
for camera_id in target_cameras:
|
||||
camera_config = config.cameras.get(camera_id)
|
||||
if camera_config is None:
|
||||
continue
|
||||
for zone_name in camera_config.zones.keys():
|
||||
lookup.setdefault(zone_name.lower(), zone_name)
|
||||
|
||||
return [lookup.get(z.lower(), z) for z in zones]
|
||||
|
||||
|
||||
async def _execute_search_objects(
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
config: FrigateConfig,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Execute the search_objects tool.
|
||||
@@ -437,6 +467,11 @@ async def _execute_search_objects(
|
||||
# Convert zones array to comma-separated string if provided
|
||||
zones = arguments.get("zones")
|
||||
if isinstance(zones, list):
|
||||
camera_arg = arguments.get("camera")
|
||||
target_cameras = (
|
||||
[camera_arg] if camera_arg and camera_arg != "all" else allowed_cameras
|
||||
)
|
||||
zones = _resolve_zones(zones, config, target_cameras)
|
||||
zones = ",".join(zones)
|
||||
elif zones is None:
|
||||
zones = "all"
|
||||
@@ -528,6 +563,11 @@ async def _execute_find_similar_objects(
|
||||
sub_labels = arguments.get("sub_labels")
|
||||
zones = arguments.get("zones")
|
||||
|
||||
if zones:
|
||||
zones = _resolve_zones(
|
||||
zones, request.app.frigate_config, cameras or list(allowed_cameras)
|
||||
)
|
||||
|
||||
similarity_mode = arguments.get("similarity_mode", "fused")
|
||||
if similarity_mode not in ("visual", "semantic", "fused"):
|
||||
similarity_mode = "fused"
|
||||
@@ -655,7 +695,9 @@ async def execute_tool(
|
||||
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
|
||||
|
||||
if tool_name == "search_objects":
|
||||
return await _execute_search_objects(arguments, allowed_cameras)
|
||||
return await _execute_search_objects(
|
||||
arguments, allowed_cameras, request.app.frigate_config
|
||||
)
|
||||
|
||||
if tool_name == "find_similar_objects":
|
||||
result = await _execute_find_similar_objects(
|
||||
@@ -835,7 +877,9 @@ async def _execute_tool_internal(
|
||||
This is used by the chat completion endpoint to execute tools.
|
||||
"""
|
||||
if tool_name == "search_objects":
|
||||
response = await _execute_search_objects(arguments, allowed_cameras)
|
||||
response = await _execute_search_objects(
|
||||
arguments, allowed_cameras, request.app.frigate_config
|
||||
)
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_str = response.body.decode("utf-8")
|
||||
@@ -899,6 +943,9 @@ async def _execute_start_camera_watch(
|
||||
|
||||
await require_camera_access(camera, request=request)
|
||||
|
||||
if zones:
|
||||
zones = _resolve_zones(zones, config, [camera])
|
||||
|
||||
genai_manager = request.app.genai_manager
|
||||
chat_client = genai_manager.chat_client
|
||||
if chat_client is None or not chat_client.supports_vision:
|
||||
|
||||
@@ -754,6 +754,15 @@ def events_search(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if search_event.camera not in allowed_cameras:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Event not found",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
thumb_result = context.search_thumbnail(search_event)
|
||||
thumb_ids = {result[0]: result[1] for result in thumb_result}
|
||||
search_results = {
|
||||
|
||||
+135
-3
@@ -5,13 +5,15 @@ import logging
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
import zipfile
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pathvalidate import sanitize_filepath
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename, sanitize_filepath
|
||||
from peewee import DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -361,6 +363,136 @@ def get_export_case(case_id: str):
|
||||
)
|
||||
|
||||
|
||||
_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
||||
|
||||
|
||||
class _StreamingZipBuffer:
|
||||
"""File-like sink for ZipFile that exposes written bytes via drain().
|
||||
|
||||
ZipFile writes synchronously into this buffer; the generator drains the
|
||||
queue between writes so StreamingResponse can yield bytes without
|
||||
materializing the whole archive in memory.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: deque[bytes] = deque()
|
||||
self._offset = 0
|
||||
|
||||
def write(self, data: bytes) -> int:
|
||||
if data:
|
||||
self._queue.append(bytes(data))
|
||||
self._offset += len(data)
|
||||
return len(data)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self._offset
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
def drain(self) -> Iterator[bytes]:
|
||||
while self._queue:
|
||||
yield self._queue.popleft()
|
||||
|
||||
|
||||
def _unique_archive_name(export: Export, used: set[str]) -> str:
|
||||
base = sanitize_filename(export.name) if export.name else None
|
||||
if not base:
|
||||
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
|
||||
|
||||
candidate = f"{base}.mp4"
|
||||
counter = 1
|
||||
while candidate in used:
|
||||
candidate = f"{base}_{counter}.mp4"
|
||||
counter += 1
|
||||
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
|
||||
"""Yield bytes of a zip archive built from the given exports' mp4 files."""
|
||||
buffer = _StreamingZipBuffer()
|
||||
used_names: set[str] = set()
|
||||
|
||||
# ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win.
|
||||
with zipfile.ZipFile(
|
||||
buffer,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_STORED,
|
||||
allowZip64=True,
|
||||
) as archive:
|
||||
for export in exports:
|
||||
source = Path(export.video_path)
|
||||
if not source.exists():
|
||||
continue
|
||||
|
||||
arcname = _unique_archive_name(export, used_names)
|
||||
|
||||
with (
|
||||
archive.open(arcname, mode="w", force_zip64=True) as entry,
|
||||
source.open("rb") as src,
|
||||
):
|
||||
while True:
|
||||
chunk = src.read(_ZIP_STREAM_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
entry.write(chunk)
|
||||
yield from buffer.drain()
|
||||
|
||||
yield from buffer.drain()
|
||||
|
||||
yield from buffer.drain()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cases/{case_id}/download",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Download export case as zip",
|
||||
description="Streams a zip archive containing every completed export's mp4 for the given case.",
|
||||
)
|
||||
def download_export_case(
|
||||
case_id: str,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
try:
|
||||
case = ExportCase.get(ExportCase.id == case_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export case not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
exports = list(
|
||||
Export.select()
|
||||
.where(
|
||||
Export.export_case == case_id,
|
||||
~Export.in_progress,
|
||||
Export.camera << allowed_cameras,
|
||||
)
|
||||
.order_by(Export.date.asc())
|
||||
)
|
||||
|
||||
if not exports:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "No exports available to download."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
archive_base = sanitize_filename(case.name) if case.name else ""
|
||||
if not archive_base:
|
||||
archive_base = case_id
|
||||
|
||||
return StreamingResponse(
|
||||
_stream_case_archive(exports),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/cases/{case_id}",
|
||||
response_model=GenericResponse,
|
||||
|
||||
+18
-8
@@ -1368,12 +1368,17 @@ def preview_gif(
|
||||
file_start = f"preview_{camera_name}-"
|
||||
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
selected_previews = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
continue
|
||||
|
||||
@@ -1550,12 +1555,17 @@ def preview_mp4(
|
||||
file_start = f"preview_{camera_name}-"
|
||||
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
selected_previews = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
continue
|
||||
|
||||
|
||||
@@ -148,12 +148,17 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
|
||||
file_start = f"preview_{camera_name}-"
|
||||
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
selected_previews = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
continue
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.recordings])
|
||||
|
||||
|
||||
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/recordings/storage", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_recordings_storage_usage(request: Request):
|
||||
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
||||
"storage"
|
||||
|
||||
@@ -549,6 +549,14 @@ class WebPushClient(Communicator):
|
||||
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
|
||||
|
||||
for user in self.web_pushers:
|
||||
if not self._user_has_camera_access(user, camera):
|
||||
logger.debug(
|
||||
"Skipping notification for user %s - no access to camera %s",
|
||||
user,
|
||||
camera,
|
||||
)
|
||||
continue
|
||||
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
|
||||
@@ -20,6 +20,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
ffmpeg = "ffmpeg"
|
||||
live = "live"
|
||||
motion = "motion" # includes motion and motion masks
|
||||
mqtt = "mqtt"
|
||||
notifications = "notifications"
|
||||
objects = "objects"
|
||||
object_genai = "object_genai"
|
||||
@@ -33,6 +34,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
lpr = "lpr"
|
||||
snapshots = "snapshots"
|
||||
timestamp_style = "timestamp_style"
|
||||
ui = "ui"
|
||||
zones = "zones"
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||
CACHE_DIR = "/tmp/cache"
|
||||
REPLAY_CAMERA_PREFIX = "_replay_"
|
||||
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
|
||||
REPLAY_DIR = os.path.join(CLIPS_DIR, "replay")
|
||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||
PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
|
||||
@@ -133,6 +133,61 @@ class FaceRecognizer(ABC):
|
||||
return 0.0
|
||||
|
||||
|
||||
def build_class_mean(
|
||||
embs: list[np.ndarray],
|
||||
trim: float = 0.15,
|
||||
outlier_threshold: float = 0.30,
|
||||
min_keep_frac: float = 0.7,
|
||||
max_iters: int = 3,
|
||||
) -> np.ndarray:
|
||||
"""Build a class-mean embedding with two-layer outlier protection.
|
||||
|
||||
Layer 1 (iterative, vector-wise): drop whole embeddings whose cosine
|
||||
similarity to the current class mean is below ``outlier_threshold``.
|
||||
Catches mislabeled or corrupted training samples (wrong face in the
|
||||
folder, full-frame screenshots, extreme crops) that per-dimension
|
||||
trimming cannot detect.
|
||||
|
||||
Layer 2 (per-dimension): ``scipy.stats.trim_mean`` on the retained set
|
||||
to smooth per-component noise (lighting, expression, alignment jitter).
|
||||
|
||||
Collections with fewer than 5 images bypass outlier rejection — too few
|
||||
samples to establish a reliable class center.
|
||||
"""
|
||||
arr = np.stack(embs, axis=0)
|
||||
|
||||
if len(arr) < 5:
|
||||
return np.asarray(stats.trim_mean(arr, trim, axis=0))
|
||||
|
||||
keep = np.ones(len(arr), dtype=bool)
|
||||
floor = max(5, int(np.ceil(min_keep_frac * len(arr))))
|
||||
|
||||
for _ in range(max_iters):
|
||||
mean = stats.trim_mean(arr[keep], trim, axis=0)
|
||||
m_norm = mean / (np.linalg.norm(mean) + 1e-9)
|
||||
e_norms = arr / (np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9)
|
||||
cos = e_norms @ m_norm
|
||||
new_keep = cos >= outlier_threshold
|
||||
|
||||
if new_keep.sum() < floor:
|
||||
top = np.argsort(-cos)[:floor]
|
||||
new_keep = np.zeros(len(arr), dtype=bool)
|
||||
new_keep[top] = True
|
||||
|
||||
if np.array_equal(new_keep, keep):
|
||||
break
|
||||
keep = new_keep
|
||||
|
||||
dropped = int((~keep).sum())
|
||||
|
||||
if dropped:
|
||||
logger.debug(
|
||||
f"Vector-wise outlier filter dropped {dropped}/{len(arr)} embeddings"
|
||||
)
|
||||
|
||||
return np.asarray(stats.trim_mean(arr[keep], trim, axis=0))
|
||||
|
||||
|
||||
def similarity_to_confidence(
|
||||
cosine_similarity: float,
|
||||
median: float = 0.3,
|
||||
@@ -229,7 +284,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
|
||||
for name, embs in face_embeddings_map.items():
|
||||
if embs:
|
||||
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
|
||||
self.mean_embs[name] = build_class_mean(embs)
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
@@ -340,7 +395,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
|
||||
for name, embs in face_embeddings_map.items():
|
||||
if embs:
|
||||
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
|
||||
self.mean_embs[name] = build_class_mean(embs)
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
||||
MIN_RECORDING_DURATION = 10
|
||||
MAX_IMAGE_TOKENS = 24000
|
||||
MAX_FRAMES_PER_SECOND = 1
|
||||
|
||||
|
||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
@@ -60,14 +62,22 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
def calculate_frame_count(
|
||||
self,
|
||||
camera: str,
|
||||
duration: float,
|
||||
image_source: ImageSourceEnum = ImageSourceEnum.preview,
|
||||
height: int = 480,
|
||||
) -> int:
|
||||
"""Calculate optimal number of frames based on context size, image source, and resolution.
|
||||
"""Calculate optimal number of frames based on event duration, context size,
|
||||
image source, and resolution.
|
||||
|
||||
Token usage varies by resolution: larger images (ultra-wide aspect ratios) use more tokens.
|
||||
Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin.
|
||||
Capped at 20 frames.
|
||||
Per-image token cost is asked of the GenAI provider so providers that know
|
||||
their model's true cost (e.g. llama.cpp can probe the loaded mmproj) can
|
||||
diverge from the default ~1-token-per-1250-pixels heuristic. The frame
|
||||
budget is bounded by:
|
||||
- remaining context window after prompt + response reservations
|
||||
- a fixed MAX_IMAGE_TOKENS ceiling
|
||||
- MAX_FRAMES_PER_SECOND x duration, to avoid drowning short events in
|
||||
near-duplicate frames where the model latches onto the redundant middle
|
||||
and skips the start/end action
|
||||
"""
|
||||
client = self.genai_manager.description_client
|
||||
|
||||
@@ -105,14 +115,15 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
width = target_width
|
||||
height = int(target_width / aspect_ratio)
|
||||
|
||||
pixels_per_image = width * height
|
||||
tokens_per_image = pixels_per_image / 1250
|
||||
tokens_per_image = client.estimate_image_tokens(width, height)
|
||||
prompt_tokens = 3800
|
||||
response_tokens = 300
|
||||
available_tokens = context_size - prompt_tokens - response_tokens
|
||||
max_frames = int(available_tokens / tokens_per_image)
|
||||
|
||||
return min(max(max_frames, 3), 20)
|
||||
context_budget = context_size - prompt_tokens - response_tokens
|
||||
image_token_budget = min(context_budget, MAX_IMAGE_TOKENS)
|
||||
max_frames_by_tokens = int(image_token_budget / tokens_per_image)
|
||||
max_frames_by_duration = int(duration * MAX_FRAMES_PER_SECOND)
|
||||
max_frames = min(max_frames_by_tokens, max_frames_by_duration)
|
||||
return max(max_frames, 3)
|
||||
|
||||
def process_data(
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
@@ -355,12 +366,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
file_start = f"preview_{camera}-"
|
||||
start_file = f"{file_start}{start_time}.webp"
|
||||
end_file = f"{file_start}{end_time}.webp"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
all_frames: list[str] = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
if len(all_frames):
|
||||
all_frames[0] = os.path.join(preview_dir, file)
|
||||
@@ -376,7 +392,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
all_frames.append(os.path.join(preview_dir, file))
|
||||
|
||||
frame_count = len(all_frames)
|
||||
desired_frame_count = self.calculate_frame_count(camera)
|
||||
desired_frame_count = self.calculate_frame_count(
|
||||
camera, duration=end_time - start_time
|
||||
)
|
||||
|
||||
if frame_count <= desired_frame_count:
|
||||
return all_frames
|
||||
@@ -400,7 +418,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
"""Get frames from recordings at specified timestamps."""
|
||||
duration = end_time - start_time
|
||||
desired_frame_count = self.calculate_frame_count(
|
||||
camera, ImageSourceEnum.recordings, height
|
||||
camera, duration, ImageSourceEnum.recordings, height
|
||||
)
|
||||
|
||||
# Calculate evenly spaced timestamps throughout the duration
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
||||
|
||||
ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=160)]
|
||||
|
||||
|
||||
class ReviewMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", protected_namespaces=())
|
||||
|
||||
observations: list[ObservationItem] = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=15,
|
||||
description=(
|
||||
"Enumerate the significant observations across all frames, in "
|
||||
"chronological order, BEFORE composing the scene narrative. "
|
||||
"Include the very start of the activity — for example, a vehicle "
|
||||
"entering the frame or pulling into the driveway — even if it "
|
||||
"lasts only a few frames and the rest of the clip is dominated "
|
||||
"by a longer activity. Include each arrival, departure, motion "
|
||||
"event, object handled, and notable change in position or state. "
|
||||
"Each item is a single concrete fact written as a complete "
|
||||
"sentence. Do not summarize, interpret, or assign meaning here — "
|
||||
"that belongs in the scene field."
|
||||
),
|
||||
)
|
||||
title: str = Field(
|
||||
description="A short title characterizing what took place and where, under 10 words."
|
||||
max_length=80,
|
||||
description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.",
|
||||
)
|
||||
scene: str = Field(
|
||||
description="A chronological narrative of what happens from start to finish."
|
||||
min_length=150,
|
||||
max_length=600,
|
||||
description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.",
|
||||
)
|
||||
shortSummary: str = Field(
|
||||
description="A brief 2-sentence summary of the scene, suitable for notifications."
|
||||
min_length=70,
|
||||
max_length=120,
|
||||
description="A brief 2-sentence summary of the scene, suitable for notifications.",
|
||||
)
|
||||
confidence: float = Field(
|
||||
ge=0.0,
|
||||
description="Confidence in the analysis, from 0 to 1.",
|
||||
le=1.0,
|
||||
description="Confidence in the analysis as a decimal between 0.0 and 1.0, where 0.0 means no confidence and 1.0 means complete confidence. Express ONLY as a decimal.",
|
||||
)
|
||||
potential_threat_level: int = Field(
|
||||
ge=0,
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from json.decoder import JSONDecodeError
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -52,6 +53,14 @@ class EmbeddingProcess(FrigateProcess):
|
||||
self.stop_event,
|
||||
)
|
||||
maintainer.start()
|
||||
maintainer.join()
|
||||
|
||||
# If the maintainer thread exited but no shutdown was requested, it
|
||||
# crashed. Surface as a non-zero exit so the watchdog restarts us
|
||||
# instead of treating the silent thread death as a clean shutdown.
|
||||
if not self.stop_event.is_set():
|
||||
logger.error("Embeddings maintainer thread exited unexpectedly")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class EmbeddingsContext:
|
||||
|
||||
@@ -517,10 +517,16 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
except DoesNotExist:
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, ObjectDescriptionProcessor):
|
||||
processor.cleanup_event(event_id)
|
||||
continue
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, ObjectDescriptionProcessor):
|
||||
processor.cleanup_event(event_id)
|
||||
continue
|
||||
|
||||
# Extract valid thumbnail
|
||||
|
||||
@@ -205,6 +205,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.transcription_thread.start()
|
||||
|
||||
self.was_enabled = camera.enabled
|
||||
self.was_audio_enabled = camera.audio.enabled
|
||||
|
||||
def detect_audio(self, audio: np.ndarray) -> None:
|
||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||
@@ -363,6 +364,17 @@ class AudioEventMaintainer(threading.Thread):
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
audio_enabled = self.camera_config.audio.enabled
|
||||
if audio_enabled != self.was_audio_enabled:
|
||||
if not audio_enabled:
|
||||
self.logger.debug(
|
||||
f"Disabling audio detections for {self.camera_config.name}, ending events"
|
||||
)
|
||||
self.requestor.send_data(
|
||||
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
|
||||
)
|
||||
self.was_audio_enabled = audio_enabled
|
||||
|
||||
self.read_audio()
|
||||
|
||||
if self.audio_listener:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -9,6 +10,7 @@ from typing import Any, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
||||
from frigate.const import CLIPS_DIR
|
||||
@@ -151,9 +153,6 @@ Each line represents a detection state, not necessarily unique individuals. The
|
||||
if "other_concerns" in schema.get("required", []):
|
||||
schema["required"].remove("other_concerns")
|
||||
|
||||
# OpenAI strict mode requires additionalProperties: false on all objects
|
||||
schema["additionalProperties"] = False
|
||||
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
@@ -181,7 +180,36 @@ Each line represents a detection state, not necessarily unique individuals. The
|
||||
|
||||
try:
|
||||
metadata = ReviewMetadata.model_validate_json(clean_json)
|
||||
except ValidationError as ve:
|
||||
# Constraint violations (length, item count, ranges) are logged
|
||||
# at debug and the response is kept anyway — a slightly
|
||||
# off-spec answer is still usable, and dropping the whole
|
||||
# response loses the narrative content the model produced.
|
||||
for err in ve.errors():
|
||||
loc = ".".join(str(p) for p in err["loc"]) or "<root>"
|
||||
logger.debug(
|
||||
"Review metadata soft validation: %s — %s (input: %r)",
|
||||
loc,
|
||||
err["msg"],
|
||||
err.get("input"),
|
||||
)
|
||||
try:
|
||||
raw = json.loads(clean_json)
|
||||
except json.JSONDecodeError as je:
|
||||
logger.error("Failed to parse review description JSON: %s", je)
|
||||
return None
|
||||
# observations and confidence are required on the model; fill an empty default
|
||||
# if the response omitted it so attribute access stays safe.
|
||||
raw.setdefault("observations", [])
|
||||
raw.setdefault("confidence", 0.0)
|
||||
metadata = ReviewMetadata.model_construct(**raw)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to parse review description as the response did not match expected format. {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Normalize confidence if model returned a percentage (e.g. 85 instead of 0.85)
|
||||
if metadata.confidence > 1.0:
|
||||
metadata.confidence = min(metadata.confidence / 100.0, 1.0)
|
||||
@@ -194,10 +222,7 @@ Each line represents a detection state, not necessarily unique individuals. The
|
||||
metadata.time = review_data["start"]
|
||||
return metadata
|
||||
except Exception as e:
|
||||
# rarely LLMs can fail to follow directions on output format
|
||||
logger.warning(
|
||||
f"Failed to parse review description as the response did not match expected format. {e}"
|
||||
)
|
||||
logger.error(f"Failed to post-process review metadata: {e}")
|
||||
return None
|
||||
else:
|
||||
logger.debug(
|
||||
@@ -344,6 +369,14 @@ Guidelines:
|
||||
"""Get the context window size for this provider in tokens."""
|
||||
return 4096
|
||||
|
||||
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||
"""Estimate prompt tokens consumed by a single image of the given dimensions.
|
||||
|
||||
Default heuristic: ~1 token per 1250 pixels. Providers that can measure or
|
||||
know their model's exact image-token cost should override.
|
||||
"""
|
||||
return (width * height) / 1250
|
||||
|
||||
def embed(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
|
||||
+64
-20
@@ -136,22 +136,44 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
)
|
||||
elif role == "assistant":
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="model", parts=[types.Part.from_text(text=content)]
|
||||
)
|
||||
)
|
||||
parts: list[types.Part] = []
|
||||
if content:
|
||||
parts.append(types.Part.from_text(text=content))
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
func = tc.get("function") or {}
|
||||
tc_name = func.get("name") or ""
|
||||
tc_args: Any = func.get("arguments")
|
||||
if isinstance(tc_args, str):
|
||||
try:
|
||||
tc_args = json.loads(tc_args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tc_args = {}
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
)
|
||||
if not parts:
|
||||
parts.append(types.Part.from_text(text=" "))
|
||||
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||
elif role == "tool":
|
||||
# Handle tool response
|
||||
function_response = {
|
||||
"name": msg.get("name", ""),
|
||||
"response": content,
|
||||
}
|
||||
response_payload = (
|
||||
content if isinstance(content, dict) else {"result": content}
|
||||
)
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="function",
|
||||
parts=[
|
||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
||||
types.Part.from_function_response(
|
||||
name=msg.get("name")
|
||||
or msg.get("tool_call_id")
|
||||
or "",
|
||||
response=response_payload,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -343,22 +365,44 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
)
|
||||
elif role == "assistant":
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="model", parts=[types.Part.from_text(text=content)]
|
||||
)
|
||||
)
|
||||
parts: list[types.Part] = []
|
||||
if content:
|
||||
parts.append(types.Part.from_text(text=content))
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
func = tc.get("function") or {}
|
||||
tc_name = func.get("name") or ""
|
||||
tc_args: Any = func.get("arguments")
|
||||
if isinstance(tc_args, str):
|
||||
try:
|
||||
tc_args = json.loads(tc_args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tc_args = {}
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
)
|
||||
if not parts:
|
||||
parts.append(types.Part.from_text(text=" "))
|
||||
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||
elif role == "tool":
|
||||
# Handle tool response
|
||||
function_response = {
|
||||
"name": msg.get("name", ""),
|
||||
"response": content,
|
||||
}
|
||||
response_payload = (
|
||||
content if isinstance(content, dict) else {"result": content}
|
||||
)
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="function",
|
||||
parts=[
|
||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
||||
types.Part.from_function_response(
|
||||
name=msg.get("name")
|
||||
or msg.get("tool_call_id")
|
||||
or "",
|
||||
response=response_payload,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
+101
-2
@@ -42,6 +42,9 @@ class LlamaCppClient(GenAIClient):
|
||||
_supports_vision: bool
|
||||
_supports_audio: bool
|
||||
_supports_tools: bool
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_text_baseline_tokens: int | None
|
||||
_media_marker: str
|
||||
|
||||
def _init_provider(self) -> str | None:
|
||||
"""Initialize the client and query model metadata from the server."""
|
||||
@@ -52,6 +55,9 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_vision = False
|
||||
self._supports_audio = False
|
||||
self._supports_tools = False
|
||||
self._image_token_cache = {}
|
||||
self._text_baseline_tokens = None
|
||||
self._media_marker = "<__media__>"
|
||||
|
||||
base_url = (
|
||||
self.genai_config.base_url.rstrip("/")
|
||||
@@ -137,6 +143,13 @@ class LlamaCppClient(GenAIClient):
|
||||
chat_caps = props.get("chat_template_caps", {})
|
||||
self._supports_tools = chat_caps.get("supports_tools", False)
|
||||
|
||||
# Media marker for multimodal embeddings; the server randomizes this
|
||||
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
|
||||
# from /props rather than hardcoding "<__media__>".
|
||||
media_marker = props.get("media_marker")
|
||||
if isinstance(media_marker, str) and media_marker:
|
||||
self._media_marker = media_marker
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||
configured_model,
|
||||
@@ -272,6 +285,91 @@ class LlamaCppClient(GenAIClient):
|
||||
return self._context_size
|
||||
return 4096
|
||||
|
||||
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||
"""Probe the llama.cpp server to learn the model's image-token cost at the
|
||||
requested dimensions.
|
||||
|
||||
llama.cpp's image tokenization is a deterministic function of dimensions and
|
||||
the loaded mmproj, so the result is cached per (width, height) for the
|
||||
lifetime of the process. Falls back to the base pixel heuristic if the
|
||||
server is unreachable or the response is malformed.
|
||||
"""
|
||||
if self.provider is None:
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
cached = self._image_token_cache.get((width, height))
|
||||
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
baseline = self._probe_baseline_tokens()
|
||||
with_image = self._probe_image_prompt_tokens(width, height)
|
||||
tokens = max(1, with_image - baseline)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"llama.cpp image-token probe failed for %dx%d (%s); using heuristic",
|
||||
width,
|
||||
height,
|
||||
e,
|
||||
)
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
self._image_token_cache[(width, height)] = tokens
|
||||
logger.debug(
|
||||
"llama.cpp model '%s' uses ~%d tokens for %dx%d images",
|
||||
self.genai_config.model,
|
||||
tokens,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
return tokens
|
||||
|
||||
def _probe_baseline_tokens(self) -> int:
|
||||
"""Return prompt_tokens for a minimal text-only request. Cached after first call."""
|
||||
if self._text_baseline_tokens is not None:
|
||||
return self._text_baseline_tokens
|
||||
|
||||
self._text_baseline_tokens = self._probe_prompt_tokens(
|
||||
[{"type": "text", "text": "."}]
|
||||
)
|
||||
return self._text_baseline_tokens
|
||||
|
||||
def _probe_image_prompt_tokens(self, width: int, height: int) -> int:
|
||||
"""Return prompt_tokens for a single synthetic image plus minimal text."""
|
||||
img = Image.new("RGB", (width, height), (128, 128, 128))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=60)
|
||||
encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
return self._probe_prompt_tokens(
|
||||
[
|
||||
{"type": "text", "text": "."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int:
|
||||
"""POST a 1-token chat completion and return reported prompt_tokens.
|
||||
|
||||
Uses a generous timeout to absorb a cold model load on the first probe
|
||||
when the server lazily loads models on demand (e.g. llama-swap).
|
||||
"""
|
||||
payload = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": 1,
|
||||
}
|
||||
response = requests.post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return int(response.json()["usage"]["prompt_tokens"])
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -376,10 +474,11 @@ class LlamaCppClient(GenAIClient):
|
||||
jpeg_bytes = _to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||
# prompt_string must contain <__media__> placeholder for image tokenization
|
||||
# prompt_string must contain the server's media marker placeholder.
|
||||
# The marker is randomized per server startup (read from /props).
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": "<__media__>\n",
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded], # type: ignore[dict-item]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,8 +73,17 @@ class OpenAIClient(GenAIClient):
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
if response_format:
|
||||
# OpenAI strict mode requires additionalProperties: false on the schema
|
||||
if response_format.get("type") == "json_schema" and response_format.get(
|
||||
"json_schema", {}
|
||||
).get("strict"):
|
||||
schema = response_format.get("json_schema", {}).get("schema")
|
||||
if isinstance(schema, dict):
|
||||
schema["additionalProperties"] = False
|
||||
request_params["response_format"] = response_format
|
||||
|
||||
result = self.provider.chat.completions.create(**request_params)
|
||||
|
||||
if (
|
||||
result is not None
|
||||
and hasattr(result, "choices")
|
||||
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any, Optional
|
||||
@@ -19,6 +18,7 @@ import numpy as np
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
|
||||
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
||||
from frigate.output.ws_auth import ws_has_camera_access
|
||||
from frigate.util.image import (
|
||||
SharedMemoryFrameManager,
|
||||
copy_yuv_to_position,
|
||||
@@ -236,12 +236,14 @@ class BroadcastThread(threading.Thread):
|
||||
converter: FFMpegConverter,
|
||||
websocket_server: Any,
|
||||
stop_event: MpEvent,
|
||||
config: FrigateConfig,
|
||||
):
|
||||
super().__init__()
|
||||
self.camera = camera
|
||||
self.converter = converter
|
||||
self.websocket_server = websocket_server
|
||||
self.stop_event = stop_event
|
||||
self.config = config
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@@ -256,6 +258,7 @@ class BroadcastThread(threading.Thread):
|
||||
if (
|
||||
not ws.terminated
|
||||
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
||||
and ws_has_camera_access(ws, self.camera, self.config)
|
||||
):
|
||||
try:
|
||||
ws.send(buf, binary=True)
|
||||
@@ -806,7 +809,11 @@ class Birdseye:
|
||||
config.birdseye.restream,
|
||||
)
|
||||
self.broadcaster = BroadcastThread(
|
||||
"birdseye", self.converter, websocket_server, stop_event
|
||||
"birdseye",
|
||||
self.converter,
|
||||
websocket_server,
|
||||
stop_event,
|
||||
config,
|
||||
)
|
||||
self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event)
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
@@ -874,7 +881,7 @@ class Birdseye:
|
||||
coordinates = self.birdseye_manager.get_camera_coordinates()
|
||||
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
|
||||
if self._idle_interval:
|
||||
now = time.monotonic()
|
||||
now = datetime.datetime.now().timestamp()
|
||||
is_idle = len(self.birdseye_manager.camera_layout) == 0
|
||||
if (
|
||||
is_idle
|
||||
|
||||
@@ -7,7 +7,8 @@ import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import CameraConfig, FfmpegConfig
|
||||
from frigate.config import CameraConfig, FfmpegConfig, FrigateConfig
|
||||
from frigate.output.ws_auth import ws_has_camera_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,12 +103,14 @@ class BroadcastThread(threading.Thread):
|
||||
converter: FFMpegConverter,
|
||||
websocket_server: Any,
|
||||
stop_event: MpEvent,
|
||||
config: FrigateConfig,
|
||||
):
|
||||
super().__init__()
|
||||
self.camera = camera
|
||||
self.converter = converter
|
||||
self.websocket_server = websocket_server
|
||||
self.stop_event = stop_event
|
||||
self.config = config
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@@ -122,6 +125,7 @@ class BroadcastThread(threading.Thread):
|
||||
if (
|
||||
not ws.terminated
|
||||
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
||||
and ws_has_camera_access(ws, self.camera, self.config)
|
||||
):
|
||||
try:
|
||||
ws.send(buf, binary=True)
|
||||
@@ -135,7 +139,11 @@ class BroadcastThread(threading.Thread):
|
||||
|
||||
class JsmpegCamera:
|
||||
def __init__(
|
||||
self, config: CameraConfig, stop_event: MpEvent, websocket_server: Any
|
||||
self,
|
||||
config: CameraConfig,
|
||||
frigate_config: FrigateConfig,
|
||||
stop_event: MpEvent,
|
||||
websocket_server: Any,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps)
|
||||
@@ -154,7 +162,11 @@ class JsmpegCamera:
|
||||
config.live.quality,
|
||||
)
|
||||
self.broadcaster = BroadcastThread(
|
||||
config.name or "", self.converter, websocket_server, stop_event
|
||||
config.name or "",
|
||||
self.converter,
|
||||
websocket_server,
|
||||
stop_event,
|
||||
frigate_config,
|
||||
)
|
||||
|
||||
self.converter.start()
|
||||
|
||||
@@ -32,6 +32,7 @@ from frigate.const import (
|
||||
from frigate.output.birdseye import Birdseye
|
||||
from frigate.output.camera import JsmpegCamera
|
||||
from frigate.output.preview import PreviewRecorder
|
||||
from frigate.output.ws_auth import ws_has_camera_access
|
||||
from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
@@ -102,7 +103,7 @@ class OutputProcess(FrigateProcess):
|
||||
) -> None:
|
||||
camera_config = self.config.cameras[camera]
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||
camera_config, self.stop_event, websocket_server
|
||||
camera_config, self.config, self.stop_event, websocket_server
|
||||
)
|
||||
preview_recorders[camera] = PreviewRecorder(camera_config)
|
||||
preview_write_times[camera] = 0
|
||||
@@ -262,6 +263,7 @@ class OutputProcess(FrigateProcess):
|
||||
# send camera frame to ffmpeg process if websockets are connected
|
||||
if any(
|
||||
ws.environ["PATH_INFO"].endswith(camera)
|
||||
and ws_has_camera_access(ws, camera, self.config)
|
||||
for ws in websocket_server.manager
|
||||
):
|
||||
# write to the converter for the camera if clients are listening to the specific camera
|
||||
@@ -275,6 +277,7 @@ class OutputProcess(FrigateProcess):
|
||||
self.config.birdseye.restream
|
||||
or any(
|
||||
ws.environ["PATH_INFO"].endswith("birdseye")
|
||||
and ws_has_camera_access(ws, "birdseye", self.config)
|
||||
for ws in websocket_server.manager
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Authorization helpers for JSMPEG websocket clients."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.models import User
|
||||
|
||||
|
||||
def _get_valid_ws_roles(ws: Any, config: FrigateConfig) -> list[str]:
|
||||
role_header = ws.environ.get("HTTP_REMOTE_ROLE", "")
|
||||
roles = [
|
||||
role.strip()
|
||||
for role in role_header.split(config.proxy.separator)
|
||||
if role.strip()
|
||||
]
|
||||
return [role for role in roles if role in config.auth.roles]
|
||||
|
||||
|
||||
def ws_has_camera_access(ws: Any, camera_name: str, config: FrigateConfig) -> bool:
|
||||
"""Return True when a websocket client is authorized for the camera path."""
|
||||
roles = _get_valid_ws_roles(ws, config)
|
||||
|
||||
if not roles:
|
||||
return False
|
||||
|
||||
roles_dict = config.auth.roles
|
||||
|
||||
# Birdseye is a composite stream, so only users with unrestricted access
|
||||
# should receive it.
|
||||
if camera_name == "birdseye":
|
||||
return any(role == "admin" or not roles_dict.get(role) for role in roles)
|
||||
|
||||
all_camera_names = set(config.cameras.keys())
|
||||
|
||||
for role in roles:
|
||||
if role == "admin" or not roles_dict.get(role):
|
||||
return True
|
||||
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
if camera_name in allowed_cameras:
|
||||
return True
|
||||
|
||||
return False
|
||||
+147
-26
@@ -28,7 +28,7 @@ from frigate.ffmpeg_presets import (
|
||||
EncodeTypeEnum,
|
||||
parse_preset_hardware_acceleration_encode,
|
||||
)
|
||||
from frigate.models import Export, Previews, Recordings
|
||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -347,6 +347,122 @@ class RecordingExporter(threading.Thread):
|
||||
# return in iso format
|
||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _chapter_metadata_path(self) -> str:
|
||||
return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt")
|
||||
|
||||
def _build_chapter_metadata_file(self, recordings: list) -> Optional[str]:
|
||||
"""Write an FFmpeg metadata file with chapters for review items in range.
|
||||
|
||||
Chapter offsets are computed in *output time*: the VOD endpoint
|
||||
concatenates recording clips back-to-back, so wall-clock gaps
|
||||
between recordings collapse in the produced video. We walk the
|
||||
same recording rows that feed the playlist and convert each
|
||||
review item's wall-clock boundaries into output-time offsets.
|
||||
Returns ``None`` when there are no recordings, no review items,
|
||||
or any chapter would have zero output duration.
|
||||
"""
|
||||
if not recordings:
|
||||
return None
|
||||
|
||||
windows: list[tuple[float, float, float]] = []
|
||||
output_offset = 0.0
|
||||
for rec in recordings:
|
||||
clipped_start = max(float(rec.start_time), float(self.start_time))
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
windows.append((clipped_start, clipped_end, output_offset))
|
||||
output_offset += clipped_end - clipped_start
|
||||
|
||||
if not windows:
|
||||
return None
|
||||
|
||||
try:
|
||||
review_rows = list(
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.start_time,
|
||||
ReviewSegment.end_time,
|
||||
ReviewSegment.severity,
|
||||
ReviewSegment.data,
|
||||
)
|
||||
.where(
|
||||
ReviewSegment.start_time.between(self.start_time, self.end_time)
|
||||
| ReviewSegment.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > ReviewSegment.start_time)
|
||||
& (self.end_time < ReviewSegment.end_time)
|
||||
)
|
||||
)
|
||||
.where(ReviewSegment.camera == self.camera)
|
||||
.order_by(ReviewSegment.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query review segments for export %s", self.export_id
|
||||
)
|
||||
return None
|
||||
|
||||
if not review_rows:
|
||||
return None
|
||||
|
||||
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
|
||||
|
||||
def wall_to_output(t: float) -> float:
|
||||
t = max(float(self.start_time), min(float(self.end_time), t))
|
||||
for w_start, w_end, w_offset in windows:
|
||||
if t < w_start:
|
||||
return w_offset
|
||||
if t <= w_end:
|
||||
return w_offset + (t - w_start)
|
||||
return total_output
|
||||
|
||||
chapter_blocks: list[str] = []
|
||||
for review in review_rows:
|
||||
start_out = wall_to_output(float(review.start_time))
|
||||
end_out = wall_to_output(float(review.end_time))
|
||||
|
||||
# Drop chapters that fall entirely in a recording gap, or are
|
||||
# too short to be navigable in a player.
|
||||
if end_out - start_out < 1.0:
|
||||
continue
|
||||
|
||||
data = review.data or {}
|
||||
labels: list[str] = []
|
||||
for obj in data.get("objects") or []:
|
||||
label = str(obj).split("-")[0]
|
||||
if label and label not in labels:
|
||||
labels.append(label)
|
||||
|
||||
title = str(review.severity).capitalize()
|
||||
if labels:
|
||||
title = f"{title}: {', '.join(labels)}"
|
||||
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
f"START={int(start_out * 1000)}\n"
|
||||
f"END={int(end_out * 1000)}\n"
|
||||
f"title={title}"
|
||||
)
|
||||
|
||||
if not chapter_blocks:
|
||||
return None
|
||||
|
||||
meta_path = self._chapter_metadata_path()
|
||||
try:
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
f.write(";FFMETADATA1\n")
|
||||
f.write("\n".join(chapter_blocks))
|
||||
f.write("\n")
|
||||
except OSError:
|
||||
logger.exception(
|
||||
"Failed to write chapter metadata file for export %s", self.export_id
|
||||
)
|
||||
return None
|
||||
|
||||
return meta_path
|
||||
|
||||
def save_thumbnail(self, id: str) -> str:
|
||||
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
||||
|
||||
@@ -451,6 +567,24 @@ class RecordingExporter(threading.Thread):
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
recordings = list(
|
||||
Recordings.select(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(self.start_time, self.end_time)
|
||||
| Recordings.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > Recordings.start_time)
|
||||
& (self.end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == self.camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
|
||||
playlist_lines: list[str] = []
|
||||
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
||||
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
@@ -458,32 +592,13 @@ class RecordingExporter(threading.Thread):
|
||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
|
||||
)
|
||||
else:
|
||||
# get full set of recordings
|
||||
export_recordings = (
|
||||
Recordings.select(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(self.start_time, self.end_time)
|
||||
| Recordings.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > Recordings.start_time)
|
||||
& (self.end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == self.camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
# Use pagination to process records in chunks
|
||||
# Chunk the recording rows into pages so each playlist line
|
||||
# references a bounded sub-range rather than the full export.
|
||||
page_size = 1000
|
||||
num_pages = (export_recordings.count() + page_size - 1) // page_size
|
||||
|
||||
for page in range(1, num_pages + 1):
|
||||
playlist = export_recordings.paginate(page, page_size)
|
||||
for i in range(0, len(recordings), page_size):
|
||||
chunk = recordings[i : i + page_size]
|
||||
playlist_lines.append(
|
||||
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'"
|
||||
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'"
|
||||
)
|
||||
|
||||
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
||||
@@ -504,8 +619,12 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
).split(" ")
|
||||
else:
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
chapter_args = (
|
||||
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
|
||||
)
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||
).split(" ")
|
||||
|
||||
# add metadata
|
||||
@@ -691,6 +810,8 @@ class RecordingExporter(threading.Thread):
|
||||
ffmpeg_cmd, playlist_lines, step="encoding_retry"
|
||||
)
|
||||
|
||||
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
|
||||
|
||||
@@ -23,6 +23,26 @@ class TestHttpApp(BaseTestHttp):
|
||||
response_json = response.json()
|
||||
assert response_json == self.test_stats
|
||||
|
||||
def test_recordings_storage_requires_admin(self):
|
||||
stats = Mock(spec=StatsEmitter)
|
||||
stats.get_latest_stats.return_value = self.test_stats
|
||||
app = super().create_app(stats)
|
||||
app.storage_maintainer = Mock()
|
||||
app.storage_maintainer.calculate_camera_usages.return_value = {
|
||||
"front_door": {"usage": 2.0},
|
||||
}
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get(
|
||||
"/recordings/storage",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.get("/recordings/storage")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["front_door"]["usage_percent"] == 25.0
|
||||
|
||||
def test_config_set_in_memory_replaces_objects_track_list(self):
|
||||
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
||||
"track": ["person", "car"],
|
||||
|
||||
@@ -219,6 +219,25 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert len(events) == 1
|
||||
assert events[0]["id"] == event_id
|
||||
|
||||
def test_similarity_search_hides_unauthorized_anchor_event(self):
|
||||
mock_embeddings = Mock()
|
||||
self.app.frigate_config.semantic_search.enabled = True
|
||||
self.app.embeddings = mock_embeddings
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event("hidden.anchor", camera="back_door")
|
||||
response = client.get(
|
||||
"/events/search",
|
||||
params={
|
||||
"search_type": "similarity",
|
||||
"event_id": "hidden.anchor",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["message"] == "Event not found"
|
||||
mock_embeddings.search_thumbnail.assert_not_called()
|
||||
|
||||
def test_get_good_event(self):
|
||||
id = "123456.random"
|
||||
|
||||
|
||||
@@ -145,9 +145,12 @@ class TestExecuteFindSimilarObjects(unittest.TestCase):
|
||||
embeddings=embeddings,
|
||||
frigate_config=SimpleNamespace(
|
||||
semantic_search=SimpleNamespace(enabled=semantic_enabled),
|
||||
cameras={"driveway": object()},
|
||||
auth=SimpleNamespace(roles={"admin": [], "viewer": ["driveway"]}),
|
||||
proxy=SimpleNamespace(separator=","),
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(app=app)
|
||||
return SimpleNamespace(app=app, headers={})
|
||||
|
||||
def test_semantic_search_disabled_returns_error(self):
|
||||
req = self._make_request(semantic_enabled=False)
|
||||
@@ -180,7 +183,7 @@ class TestExecuteFindSimilarObjects(unittest.TestCase):
|
||||
_execute_find_similar_objects(
|
||||
req,
|
||||
{"event_id": "anchor", "cameras": ["nonexistent_cam"]},
|
||||
allowed_cameras=["nonexistent_cam"],
|
||||
allowed_cameras=["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["results"], [])
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for JSMPEG websocket authorization."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.output.ws_auth import ws_has_camera_access
|
||||
|
||||
|
||||
class TestWsHasCameraAccess(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = FrigateConfig(
|
||||
mqtt={"host": "mqtt"},
|
||||
auth={"roles": {"limited_user": ["front_door"]}},
|
||||
cameras={
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _make_ws(self, role: str):
|
||||
return SimpleNamespace(environ={"HTTP_REMOTE_ROLE": role})
|
||||
|
||||
def test_restricted_role_only_gets_allowed_camera(self):
|
||||
ws = self._make_ws("limited_user")
|
||||
self.assertTrue(ws_has_camera_access(ws, "front_door", self.config))
|
||||
self.assertFalse(ws_has_camera_access(ws, "back_door", self.config))
|
||||
|
||||
def test_unrestricted_role_can_access_any_camera(self):
|
||||
ws = self._make_ws("viewer")
|
||||
self.assertTrue(ws_has_camera_access(ws, "front_door", self.config))
|
||||
self.assertTrue(ws_has_camera_access(ws, "back_door", self.config))
|
||||
|
||||
def test_birdseye_requires_unrestricted_access(self):
|
||||
self.assertTrue(
|
||||
ws_has_camera_access(self._make_ws("admin"), "birdseye", self.config)
|
||||
)
|
||||
self.assertTrue(
|
||||
ws_has_camera_access(self._make_ws("viewer"), "birdseye", self.config)
|
||||
)
|
||||
self.assertFalse(
|
||||
ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config)
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for camera monitoring notification authorization."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
|
||||
|
||||
class TestCameraMonitoringNotifications(unittest.TestCase):
|
||||
def test_send_camera_monitoring_filters_by_camera_access(self):
|
||||
client = WebPushClient.__new__(WebPushClient)
|
||||
client.config = SimpleNamespace(
|
||||
cameras={"front_door": SimpleNamespace(friendly_name=None)}
|
||||
)
|
||||
client.web_pushers = {"allowed": [], "denied": []}
|
||||
client.user_cameras = {"allowed": {"front_door"}, "denied": set()}
|
||||
client.check_registrations = MagicMock()
|
||||
client.cleanup_registrations = MagicMock()
|
||||
client.send_push_notification = MagicMock()
|
||||
|
||||
client.send_camera_monitoring(
|
||||
{"camera": "front_door", "message": "Monitoring condition met"}
|
||||
)
|
||||
|
||||
self.assertEqual(client.send_push_notification.call_count, 1)
|
||||
self.assertEqual(
|
||||
client.send_push_notification.call_args.kwargs["user"], "allowed"
|
||||
)
|
||||
@@ -24,8 +24,12 @@ from frigate.log import redirect_output_to_logger, suppress_stderr_during
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.downloader import ModelDownloader
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.image import get_image_from_recording
|
||||
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||
from frigate.util.image import (
|
||||
calculate_region,
|
||||
get_image_from_recording,
|
||||
relative_box_to_absolute,
|
||||
)
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
BATCH_SIZE = 16
|
||||
@@ -713,7 +717,7 @@ def collect_object_classification_examples(
|
||||
This function:
|
||||
1. Queries events for the specified label
|
||||
2. Selects 100 balanced events across different cameras and times
|
||||
3. Retrieves thumbnails for selected events (with 33% center crop applied)
|
||||
3. Crops each event's clean snapshot around the object bounding box
|
||||
4. Selects 24 most visually distinct thumbnails
|
||||
5. Saves to dataset directory
|
||||
|
||||
@@ -832,66 +836,106 @@ def _select_balanced_events(
|
||||
|
||||
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
|
||||
"""
|
||||
Extract thumbnails from events and save to disk.
|
||||
Extract a training image for each event.
|
||||
|
||||
Preferred path: load the full-frame clean snapshot and crop around the
|
||||
stored bounding box with the same calculate_region(..., max(w, h), 1.0)
|
||||
call the live ObjectClassificationProcessor uses, so wizard examples
|
||||
are framed like inference-time inputs.
|
||||
|
||||
Fallback: if no clean snapshot exists (snapshots disabled, or only a
|
||||
legacy annotated JPG is on disk), center-crop the stored thumbnail
|
||||
using a step ladder sized from the box/region area ratio.
|
||||
|
||||
Args:
|
||||
events: List of Event objects
|
||||
output_dir: Directory to save thumbnails
|
||||
output_dir: Directory to save crops
|
||||
|
||||
Returns:
|
||||
List of paths to successfully extracted thumbnail images
|
||||
List of paths to successfully extracted images
|
||||
"""
|
||||
thumbnail_paths = []
|
||||
image_paths = []
|
||||
|
||||
for idx, event in enumerate(events):
|
||||
try:
|
||||
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||
img = _load_event_classification_crop(event)
|
||||
if img is None:
|
||||
continue
|
||||
|
||||
if thumbnail_bytes:
|
||||
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
|
||||
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
|
||||
if img is not None:
|
||||
height, width = img.shape[:2]
|
||||
|
||||
crop_size = 1.0
|
||||
if event.data and "box" in event.data and "region" in event.data:
|
||||
box = event.data["box"]
|
||||
region = event.data["region"]
|
||||
|
||||
if len(box) == 4 and len(region) == 4:
|
||||
box_w, box_h = box[2], box[3]
|
||||
region_w, region_h = region[2], region[3]
|
||||
|
||||
box_area = (box_w * box_h) / (region_w * region_h)
|
||||
|
||||
if box_area < 0.05:
|
||||
crop_size = 0.4
|
||||
elif box_area < 0.10:
|
||||
crop_size = 0.5
|
||||
elif box_area < 0.20:
|
||||
crop_size = 0.65
|
||||
elif box_area < 0.35:
|
||||
crop_size = 0.80
|
||||
else:
|
||||
crop_size = 0.95
|
||||
|
||||
crop_width = int(width * crop_size)
|
||||
crop_height = int(height * crop_size)
|
||||
|
||||
x1 = (width - crop_width) // 2
|
||||
y1 = (height - crop_height) // 2
|
||||
x2 = x1 + crop_width
|
||||
y2 = y1 + crop_height
|
||||
|
||||
cropped = img[y1:y2, x1:x2]
|
||||
resized = cv2.resize(cropped, (224, 224))
|
||||
output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg")
|
||||
cv2.imwrite(output_path, resized)
|
||||
thumbnail_paths.append(output_path)
|
||||
resized = cv2.resize(img, (224, 224))
|
||||
output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg")
|
||||
cv2.imwrite(output_path, resized)
|
||||
image_paths.append(output_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to extract thumbnail for event {event.id}: {e}")
|
||||
logger.debug(f"Failed to extract image for event {event.id}: {e}")
|
||||
continue
|
||||
|
||||
return thumbnail_paths
|
||||
return image_paths
|
||||
|
||||
|
||||
def _load_event_classification_crop(event: Event) -> np.ndarray | None:
|
||||
"""Prefer a snapshot-based object crop; fall back to a center-cropped thumbnail."""
|
||||
if event.data and "box" in event.data:
|
||||
snapshot, _ = load_event_snapshot_image(event, clean_only=True)
|
||||
if snapshot is not None:
|
||||
abs_box = relative_box_to_absolute(snapshot.shape, event.data["box"])
|
||||
if abs_box is not None:
|
||||
xmin, ymin, xmax, ymax = abs_box
|
||||
box_w = xmax - xmin
|
||||
box_h = ymax - ymin
|
||||
if box_w > 0 and box_h > 0:
|
||||
x1, y1, x2, y2 = calculate_region(
|
||||
snapshot.shape,
|
||||
xmin,
|
||||
ymin,
|
||||
xmax,
|
||||
ymax,
|
||||
max(box_w, box_h),
|
||||
1.0,
|
||||
)
|
||||
cropped = snapshot[y1:y2, x1:x2]
|
||||
if cropped.size > 0:
|
||||
return cropped
|
||||
|
||||
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||
if not thumbnail_bytes:
|
||||
return None
|
||||
|
||||
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
|
||||
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if img is None or img.size == 0:
|
||||
return None
|
||||
|
||||
height, width = img.shape[:2]
|
||||
crop_size = 1.0
|
||||
|
||||
if event.data and "box" in event.data and "region" in event.data:
|
||||
box = event.data["box"]
|
||||
region = event.data["region"]
|
||||
|
||||
if len(box) == 4 and len(region) == 4:
|
||||
box_w, box_h = box[2], box[3]
|
||||
region_w, region_h = region[2], region[3]
|
||||
box_area = (box_w * box_h) / (region_w * region_h)
|
||||
|
||||
if box_area < 0.05:
|
||||
crop_size = 0.4
|
||||
elif box_area < 0.10:
|
||||
crop_size = 0.5
|
||||
elif box_area < 0.20:
|
||||
crop_size = 0.65
|
||||
elif box_area < 0.35:
|
||||
crop_size = 0.80
|
||||
else:
|
||||
crop_size = 0.95
|
||||
|
||||
crop_width = int(width * crop_size)
|
||||
crop_height = int(height * crop_size)
|
||||
x1 = (width - crop_width) // 2
|
||||
y1 = (height - crop_height) // 2
|
||||
cropped = img[y1 : y1 + crop_height, x1 : x1 + crop_width]
|
||||
if cropped.size == 0:
|
||||
return None
|
||||
|
||||
return cropped
|
||||
|
||||
+48
-15
@@ -711,23 +711,44 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
|
||||
else:
|
||||
format_entries = None
|
||||
|
||||
ffprobe_cmd = [
|
||||
ffmpeg.ffprobe_path,
|
||||
"-timeout",
|
||||
"1000000",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_entries",
|
||||
f"stream={stream_entries}",
|
||||
]
|
||||
def run(rtsp_transport: Optional[str] = None) -> sp.CompletedProcess:
|
||||
cmd = [ffmpeg.ffprobe_path]
|
||||
if rtsp_transport:
|
||||
cmd += ["-rtsp_transport", rtsp_transport]
|
||||
cmd += [
|
||||
"-timeout",
|
||||
"1000000",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_entries",
|
||||
f"stream={stream_entries}",
|
||||
]
|
||||
if detailed and format_entries:
|
||||
cmd.extend(["-show_entries", f"format={format_entries}"])
|
||||
cmd.extend(["-loglevel", "error", clean_path])
|
||||
try:
|
||||
return sp.run(cmd, capture_output=True, timeout=6)
|
||||
except sp.TimeoutExpired as e:
|
||||
logger.info(
|
||||
"ffprobe timed out while probing %s (transport=%s)",
|
||||
clean_camera_user_pass(path),
|
||||
rtsp_transport or "default",
|
||||
)
|
||||
return sp.CompletedProcess(
|
||||
args=cmd,
|
||||
returncode=1,
|
||||
stdout=e.stdout or b"",
|
||||
stderr=(e.stderr or b"") + b"\nffprobe timed out",
|
||||
)
|
||||
|
||||
# Add format entries for detailed mode
|
||||
if detailed and format_entries:
|
||||
ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"])
|
||||
result = run()
|
||||
|
||||
ffprobe_cmd.extend(["-loglevel", "error", clean_path])
|
||||
# For RTSP: retry with explicit TCP transport if the first attempt failed
|
||||
# (default UDP may be blocked)
|
||||
if result.returncode != 0 and clean_path.startswith("rtsp://"):
|
||||
result = run(rtsp_transport="tcp")
|
||||
|
||||
return sp.run(ffprobe_cmd, capture_output=True)
|
||||
return result
|
||||
|
||||
|
||||
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
||||
@@ -824,11 +845,23 @@ async def get_video_properties(
|
||||
"-show_streams",
|
||||
url,
|
||||
]
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=6)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(
|
||||
"ffprobe timed out while probing %s (transport=%s)",
|
||||
clean_camera_user_pass(url),
|
||||
rtsp_transport or "default",
|
||||
)
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return False, 0, 0, None, -1
|
||||
|
||||
if proc.returncode != 0:
|
||||
return False, 0, 0, None, -1
|
||||
|
||||
|
||||
+35
-11
@@ -24,7 +24,7 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.const import PROCESS_PRIORITY_HIGH
|
||||
from frigate.log import LogPipe
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
from frigate.util.image import (
|
||||
FrameManager,
|
||||
@@ -34,6 +34,23 @@ from frigate.util.process import FrigateProcess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# all built-in record presets use this segment_time
|
||||
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||
|
||||
|
||||
def _get_record_segment_time(config: CameraConfig) -> int:
|
||||
"""Extract -segment_time from the camera's record output args."""
|
||||
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||
|
||||
if record_args and record_args[0].startswith("preset"):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
try:
|
||||
idx = record_args.index("-segment_time")
|
||||
return int(record_args[idx + 1])
|
||||
except (ValueError, IndexError):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
@@ -164,6 +181,12 @@ class CameraWatchdog(threading.Thread):
|
||||
self.latest_cache_segment_time: float = 0
|
||||
self.record_enable_time: datetime | None = None
|
||||
|
||||
# `valid` segments are published with the segment's start time, so the
|
||||
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
||||
# staleness threshold so it's never tighter than that worst case.
|
||||
segment_time = _get_record_segment_time(self.config)
|
||||
self.record_stale_threshold = max(120, 2 * segment_time + 30)
|
||||
|
||||
# Stall tracking (based on last processed frame)
|
||||
self._stall_timestamps: deque[float] = deque()
|
||||
self._stall_active: bool = False
|
||||
@@ -317,16 +340,16 @@ class CameraWatchdog(threading.Thread):
|
||||
if camera != self.config.name:
|
||||
continue
|
||||
|
||||
if topic.endswith(RecordingsDataTypeEnum.valid.value):
|
||||
self.logger.debug(
|
||||
f"Latest valid recording segment time on {camera}: {segment_time}"
|
||||
)
|
||||
self.latest_valid_segment_time = segment_time
|
||||
elif topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
||||
if topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
||||
self.logger.warning(
|
||||
f"Invalid recording segment detected for {camera} at {segment_time}"
|
||||
)
|
||||
self.latest_invalid_segment_time = segment_time
|
||||
elif topic.endswith(RecordingsDataTypeEnum.valid.value):
|
||||
self.logger.debug(
|
||||
f"Latest valid recording segment time on {camera}: {segment_time}"
|
||||
)
|
||||
self.latest_valid_segment_time = segment_time
|
||||
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
|
||||
if segment_time is not None:
|
||||
self.latest_cache_segment_time = segment_time
|
||||
@@ -413,16 +436,17 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# ensure segments are still being created and that they have valid video data
|
||||
# Skip checks during grace period to allow segments to start being created
|
||||
stale_window = timedelta(seconds=self.record_stale_threshold)
|
||||
cache_stale = not in_grace_period and now_utc > (
|
||||
latest_cache_dt + timedelta(seconds=120)
|
||||
latest_cache_dt + stale_window
|
||||
)
|
||||
valid_stale = not in_grace_period and now_utc > (
|
||||
latest_valid_dt + timedelta(seconds=120)
|
||||
latest_valid_dt + stale_window
|
||||
)
|
||||
invalid_stale_condition = (
|
||||
self.latest_invalid_segment_time > 0
|
||||
and not in_grace_period
|
||||
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
|
||||
and now_utc > (latest_invalid_dt + stale_window)
|
||||
and self.latest_valid_segment_time
|
||||
<= self.latest_invalid_segment_time
|
||||
)
|
||||
@@ -439,7 +463,7 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
|
||||
self.logger.error(
|
||||
f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..."
|
||||
f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..."
|
||||
)
|
||||
p["process"] = start_or_restart_ffmpeg(
|
||||
p["cmd"],
|
||||
|
||||
+4
-1
@@ -28,6 +28,7 @@ class MonitoredProcess:
|
||||
restart_timestamps: deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=MAX_RESTARTS)
|
||||
)
|
||||
clean_exit_logged: bool = False
|
||||
|
||||
def is_restarting_too_fast(self, now: float) -> bool:
|
||||
while (
|
||||
@@ -72,7 +73,9 @@ class FrigateWatchdog(threading.Thread):
|
||||
|
||||
exitcode = entry.process.exitcode
|
||||
if exitcode == 0:
|
||||
logger.info("Process %s exited cleanly, not restarting", entry.name)
|
||||
if not entry.clean_exit_logged:
|
||||
logger.info("Process %s exited cleanly, not restarting", entry.name)
|
||||
entry.clean_exit_logged = True
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze keyframe and timestamp structure of Frigate recording segments.
|
||||
|
||||
This is a diagnostic tool for investigating seek precision / GOP behavior on
|
||||
recorded segments. It does not modify anything.
|
||||
|
||||
ffprobe is only available inside the Frigate container, at
|
||||
/usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe
|
||||
This script auto-resolves that path from the DEFAULT_FFMPEG_VERSION env var
|
||||
(or falls back to scanning /usr/lib/ffmpeg/*/bin/ffprobe). Pass --ffprobe to
|
||||
override if needed.
|
||||
|
||||
All recording segments on the filesystem are in UTC. The --timestamp flag
|
||||
expects a UTC Unix timestamp.
|
||||
|
||||
Typical use:
|
||||
# Inside the Frigate container (or wherever recordings are mounted)
|
||||
python3 analyze_recording_keyframes.py <camera_name>
|
||||
|
||||
# Analyze 10 most recent segments
|
||||
python3 analyze_recording_keyframes.py <camera_name> --count 10
|
||||
|
||||
# Locate the segment that contains a specific UTC Unix timestamp and
|
||||
# show it plus surrounding segments
|
||||
python3 analyze_recording_keyframes.py <camera> --timestamp 1713471234.567
|
||||
|
||||
# Custom recordings directory
|
||||
python3 analyze_recording_keyframes.py <camera> --recordings-dir /media/frigate/recordings
|
||||
|
||||
# Override the ffprobe path explicitly
|
||||
python3 analyze_recording_keyframes.py <camera> --ffprobe /usr/lib/ffmpeg/7.0/bin/ffprobe
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from statistics import mean, median, stdev
|
||||
|
||||
|
||||
def resolve_ffprobe_path(override: str | None) -> str:
|
||||
"""Resolve the ffprobe binary path.
|
||||
|
||||
Inside the Frigate container, ffprobe lives at
|
||||
/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe — the exact version
|
||||
depends on the image build and is exposed as an env var.
|
||||
"""
|
||||
if override:
|
||||
return override
|
||||
version = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
|
||||
if version:
|
||||
path = f"/usr/lib/ffmpeg/{version}/bin/ffprobe"
|
||||
if Path(path).is_file():
|
||||
return path
|
||||
# Fall back to scanning the Frigate ffmpeg install root.
|
||||
for candidate in sorted(Path("/usr/lib/ffmpeg").glob("*/bin/ffprobe")):
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
print(
|
||||
"Could not locate ffprobe. Pass --ffprobe <path> or set "
|
||||
"DEFAULT_FFMPEG_VERSION.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def find_recent_segments(recordings_dir: Path, camera: str, count: int) -> list[Path]:
|
||||
"""Return the N most recent .mp4 segments for the given camera.
|
||||
|
||||
Expected layout: <recordings_dir>/<YYYY-MM-DD>/<HH>/<camera>/<MM>.<SS>.mp4
|
||||
"""
|
||||
pattern = f"*/*/{camera}/*.mp4"
|
||||
segments = sorted(recordings_dir.glob(pattern))
|
||||
return segments[-count:]
|
||||
|
||||
|
||||
def find_segments_near_timestamp(
|
||||
recordings_dir: Path, camera: str, target_ts: float, count: int
|
||||
) -> tuple[list[Path], Path | None]:
|
||||
"""Return `count` segments centered on the one containing `target_ts`.
|
||||
|
||||
Also returns the specific segment that should contain the timestamp, so
|
||||
callers can highlight it in output.
|
||||
"""
|
||||
pattern = f"*/*/{camera}/*.mp4"
|
||||
with_ts: list[tuple[float, Path]] = []
|
||||
for seg in sorted(recordings_dir.glob(pattern)):
|
||||
ts = filename_to_timestamp(seg)
|
||||
if ts is not None:
|
||||
with_ts.append((ts, seg))
|
||||
|
||||
if not with_ts:
|
||||
return [], None
|
||||
|
||||
# Largest filename_ts that is <= target_ts — that's the segment that
|
||||
# should contain the timestamp (Frigate catalogs segments by filename).
|
||||
target_idx = -1
|
||||
for i, (ts, _) in enumerate(with_ts):
|
||||
if ts <= target_ts:
|
||||
target_idx = i
|
||||
else:
|
||||
break
|
||||
|
||||
if target_idx < 0:
|
||||
# target_ts is before the earliest segment we have — just return the
|
||||
# first `count` segments so the user can see what's available.
|
||||
window = with_ts[:count]
|
||||
return [seg for _, seg in window], None
|
||||
|
||||
half = count // 2
|
||||
start = max(0, target_idx - half)
|
||||
end = min(len(with_ts), start + count)
|
||||
start = max(0, end - count)
|
||||
|
||||
window = with_ts[start:end]
|
||||
return [seg for _, seg in window], with_ts[target_idx][1]
|
||||
|
||||
|
||||
def filename_to_timestamp(segment: Path) -> float | None:
|
||||
"""Parse the wall-clock time from Frigate's segment path layout."""
|
||||
try:
|
||||
date = segment.parent.parent.parent.name # YYYY-MM-DD
|
||||
hour = segment.parent.parent.name # HH
|
||||
mm_ss = segment.stem # MM.SS
|
||||
minute, second = mm_ss.split(".")
|
||||
dt = datetime.datetime.strptime(
|
||||
f"{date} {hour}:{minute}:{second}",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
).replace(tzinfo=datetime.timezone.utc)
|
||||
return dt.timestamp()
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def run_ffprobe(ffprobe: str, args: list[str]) -> dict:
|
||||
"""Run ffprobe and return parsed JSON, or empty dict on failure."""
|
||||
result = subprocess.run(
|
||||
[ffprobe, "-v", "error", *args, "-of", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" ffprobe error: {result.stderr.strip()}", file=sys.stderr)
|
||||
return {}
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def get_format_info(ffprobe: str, segment: Path) -> tuple[dict, dict]:
|
||||
"""Return (format_dict, stream_dict) for the first video stream."""
|
||||
data = run_ffprobe(
|
||||
ffprobe,
|
||||
[
|
||||
"-show_entries",
|
||||
"format=duration,start_time",
|
||||
"-show_entries",
|
||||
"stream=codec_name,profile,r_frame_rate,width,height",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
str(segment),
|
||||
],
|
||||
)
|
||||
fmt = data.get("format", {})
|
||||
streams = data.get("streams") or [{}]
|
||||
return fmt, streams[0]
|
||||
|
||||
|
||||
def get_video_packets(ffprobe: str, segment: Path) -> list[dict]:
|
||||
"""Return video packets with pts_time and flags."""
|
||||
data = run_ffprobe(
|
||||
ffprobe,
|
||||
[
|
||||
"-select_streams",
|
||||
"v",
|
||||
"-show_entries",
|
||||
"packet=pts_time,dts_time,flags",
|
||||
str(segment),
|
||||
],
|
||||
)
|
||||
return data.get("packets", [])
|
||||
|
||||
|
||||
def analyze(ffprobe: str, segment: Path, highlight: bool = False) -> None:
|
||||
marker = " <-- contains target timestamp" if highlight else ""
|
||||
print(f"\n=== {segment} ==={marker}")
|
||||
|
||||
fmt, stream = get_format_info(ffprobe, segment)
|
||||
duration = float(fmt.get("duration", 0) or 0)
|
||||
start_time = float(fmt.get("start_time", 0) or 0)
|
||||
codec = stream.get("codec_name", "?")
|
||||
profile = stream.get("profile", "?")
|
||||
width = stream.get("width", "?")
|
||||
height = stream.get("height", "?")
|
||||
fps = stream.get("r_frame_rate", "?/1")
|
||||
|
||||
filename_ts = filename_to_timestamp(segment)
|
||||
filename_iso = (
|
||||
datetime.datetime.fromtimestamp(
|
||||
filename_ts, tz=datetime.timezone.utc
|
||||
).isoformat()
|
||||
if filename_ts is not None
|
||||
else "?"
|
||||
)
|
||||
|
||||
print(f" Codec: {codec} ({profile}) {width}x{height} {fps}")
|
||||
print(f" Filename time: {filename_ts} ({filename_iso})")
|
||||
print(f" Format duration: {duration:.3f}s")
|
||||
print(f" Format start: {start_time:.3f}s (PTS offset of first packet)")
|
||||
|
||||
packets = get_video_packets(ffprobe, segment)
|
||||
if not packets:
|
||||
print(" (no video packets)")
|
||||
return
|
||||
|
||||
keyframe_times: list[float] = []
|
||||
first_pts: float | None = None
|
||||
last_pts: float | None = None
|
||||
|
||||
for pkt in packets:
|
||||
pts_str = pkt.get("pts_time")
|
||||
if pts_str is None or pts_str == "N/A":
|
||||
continue
|
||||
pts = float(pts_str)
|
||||
if first_pts is None:
|
||||
first_pts = pts
|
||||
last_pts = pts
|
||||
if "K" in pkt.get("flags", ""):
|
||||
keyframe_times.append(pts)
|
||||
|
||||
total_packets = len(packets)
|
||||
kf_count = len(keyframe_times)
|
||||
|
||||
print(f" Video packets: {total_packets}")
|
||||
print(f" Keyframes: {kf_count}")
|
||||
if first_pts is not None and last_pts is not None:
|
||||
print(
|
||||
f" Packet PTS: first={first_pts:.3f}s last={last_pts:.3f}s "
|
||||
f"span={last_pts - first_pts:.3f}s"
|
||||
)
|
||||
|
||||
if keyframe_times:
|
||||
print(
|
||||
f" Keyframe PTS: first={keyframe_times[0]:.3f}s "
|
||||
f"last={keyframe_times[-1]:.3f}s"
|
||||
)
|
||||
formatted = ", ".join(f"{t:.3f}" for t in keyframe_times)
|
||||
print(f" Keyframe times: [{formatted}]")
|
||||
|
||||
if len(keyframe_times) >= 2:
|
||||
gaps = [b - a for a, b in zip(keyframe_times, keyframe_times[1:])]
|
||||
avg_fps_estimate = (
|
||||
total_packets / (last_pts - first_pts)
|
||||
if last_pts and first_pts is not None and last_pts > first_pts
|
||||
else 0
|
||||
)
|
||||
print(
|
||||
f" GOP gaps (s): min={min(gaps):.3f} max={max(gaps):.3f} "
|
||||
f"mean={mean(gaps):.3f} median={median(gaps):.3f}"
|
||||
)
|
||||
if len(gaps) > 1:
|
||||
print(f" stdev={stdev(gaps):.3f}")
|
||||
print(
|
||||
f" Est. mean GOP: ~{mean(gaps) * avg_fps_estimate:.1f} frames"
|
||||
if avg_fps_estimate
|
||||
else ""
|
||||
)
|
||||
if max(gaps) > 5:
|
||||
print(
|
||||
" !! Max GOP > 5s — consistent with adaptive/smart codec "
|
||||
"(even if 'Smart Codec' is off in the UI, some cameras still "
|
||||
"produce irregular GOPs under specific encoder profiles)"
|
||||
)
|
||||
elif kf_count == 1:
|
||||
print(" !! Only one keyframe in segment — very long GOP")
|
||||
|
||||
# Report how well filename time aligns with first-packet PTS.
|
||||
# (Filename time is what Frigate uses as recording.start_time in the DB.)
|
||||
if filename_ts is not None and first_pts is not None:
|
||||
print(
|
||||
f" Notes: first packet PTS is {first_pts:.3f}s into the file; "
|
||||
f"Frigate treats filename time as PTS=0 for seek math."
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("camera", help="Camera name (matches the recordings subfolder)")
|
||||
parser.add_argument(
|
||||
"--count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of most recent segments to analyze (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--recordings-dir",
|
||||
default="/media/frigate/recordings",
|
||||
help="Path to the recordings directory (default: /media/frigate/recordings)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ffprobe",
|
||||
default=None,
|
||||
help=(
|
||||
"Full path to the ffprobe binary. Defaults to the Frigate-bundled "
|
||||
"binary at /usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timestamp",
|
||||
type=float,
|
||||
default=None,
|
||||
help=(
|
||||
"Unix timestamp (UTC seconds, decimals allowed) to locate. The "
|
||||
"script finds the segment that should contain this time and "
|
||||
"analyzes it plus surrounding segments (count controls the "
|
||||
"window). All on-disk segments are stored in UTC, so pass a UTC "
|
||||
"Unix timestamp."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
ffprobe = resolve_ffprobe_path(args.ffprobe)
|
||||
|
||||
recordings_dir = Path(args.recordings_dir)
|
||||
if not recordings_dir.is_dir():
|
||||
print(
|
||||
f"Recordings directory not found: {recordings_dir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
target_segment: Path | None = None
|
||||
if args.timestamp is not None:
|
||||
segments, target_segment = find_segments_near_timestamp(
|
||||
recordings_dir, args.camera, args.timestamp, args.count
|
||||
)
|
||||
target_iso = datetime.datetime.fromtimestamp(
|
||||
args.timestamp, tz=datetime.timezone.utc
|
||||
).isoformat()
|
||||
mode = f"around timestamp {args.timestamp} ({target_iso})"
|
||||
else:
|
||||
segments = find_recent_segments(recordings_dir, args.camera, args.count)
|
||||
mode = "most recent"
|
||||
|
||||
if not segments:
|
||||
print(
|
||||
f"No segments found for camera '{args.camera}' under {recordings_dir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if args.timestamp is not None and target_segment is None:
|
||||
print(
|
||||
f"!! Target timestamp {args.timestamp} is before the earliest "
|
||||
f"segment on disk; showing the earliest available segments instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(
|
||||
f"Analyzing {len(segments)} {mode} segment(s) for camera "
|
||||
f"'{args.camera}' under {recordings_dir} (ffprobe: {ffprobe})"
|
||||
)
|
||||
for segment in segments:
|
||||
analyze(ffprobe, segment, highlight=(segment == target_segment))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,783 @@
|
||||
"""
|
||||
Face recognition investigation script.
|
||||
|
||||
Standalone replica of Frigate's ArcFace pipeline (see
|
||||
frigate/data_processing/common/face/model.py and
|
||||
frigate/embeddings/onnx/face_embedding.py) for analyzing a face collection
|
||||
outside the running service. Useful for:
|
||||
|
||||
- Diagnosing why a person's collection produces false positives
|
||||
- Finding outlier/contaminating training images
|
||||
- Inspecting the effect of the shipped vector-wise outlier filter
|
||||
|
||||
Layout:
|
||||
- Core pipeline: LandmarkAligner, ArcFaceEmbedder, arcface_preprocess,
|
||||
similarity_to_confidence, blur_reduction — all mirroring the production
|
||||
code exactly
|
||||
- Default run: summarize positive and negative sets against a baseline
|
||||
trim_mean class representation
|
||||
- Optional diagnostics (flags): vector-outlier filter behavior, degenerate
|
||||
"tiny crop" embedding clustering, and multi-identity contamination
|
||||
|
||||
Usage:
|
||||
python3 face_investigate.py \\
|
||||
--positive <positive_folder> \\
|
||||
--negative <negative_folder> \\
|
||||
[--model-cache /path/to/model_cache] \\
|
||||
[--vector-outlier] [--degenerate] [--contamination]
|
||||
|
||||
The positive folder should contain training images for a single identity
|
||||
(same layout as FACE_DIR/<name>/*.webp). The negative folder should contain
|
||||
runtime crops to test against — a mix of true matches and misfires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
from scipy import stats
|
||||
|
||||
ARCFACE_INPUT_SIZE = 112
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replicated Frigate pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _process_image_frigate(image: np.ndarray) -> Image.Image:
|
||||
"""Mirror BaseEmbedding._process_image for an ndarray input.
|
||||
|
||||
NOTE: Frigate passes the output of `cv2.imread` (BGR) directly in. PIL's
|
||||
`Image.fromarray` does NOT reorder channels, so the embedder effectively
|
||||
receives a BGR-ordered tensor. We replicate that faithfully here. (Tested
|
||||
— swapping to RGB produces near-identical embeddings; this model is
|
||||
robust to channel order.)
|
||||
"""
|
||||
return Image.fromarray(image)
|
||||
|
||||
|
||||
def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray:
|
||||
"""Mirror ArcfaceEmbedding._preprocess_inputs."""
|
||||
pil = _process_image_frigate(image_bgr)
|
||||
|
||||
width, height = pil.size
|
||||
if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE:
|
||||
if width > height:
|
||||
new_height = int(((height / width) * ARCFACE_INPUT_SIZE) // 4 * 4)
|
||||
pil = pil.resize((ARCFACE_INPUT_SIZE, new_height))
|
||||
else:
|
||||
new_width = int(((width / height) * ARCFACE_INPUT_SIZE) // 4 * 4)
|
||||
pil = pil.resize((new_width, ARCFACE_INPUT_SIZE))
|
||||
|
||||
og = np.array(pil).astype(np.float32)
|
||||
og_h, og_w, channels = og.shape
|
||||
|
||||
frame = np.zeros(
|
||||
(ARCFACE_INPUT_SIZE, ARCFACE_INPUT_SIZE, channels), dtype=np.float32
|
||||
)
|
||||
x_center = (ARCFACE_INPUT_SIZE - og_w) // 2
|
||||
y_center = (ARCFACE_INPUT_SIZE - og_h) // 2
|
||||
frame[y_center : y_center + og_h, x_center : x_center + og_w] = og
|
||||
|
||||
frame = (frame / 127.5) - 1.0
|
||||
frame = np.transpose(frame, (2, 0, 1))
|
||||
frame = np.expand_dims(frame, axis=0)
|
||||
return frame
|
||||
|
||||
|
||||
class LandmarkAligner:
|
||||
"""Mirror FaceRecognizer.align_face."""
|
||||
|
||||
def __init__(self, landmark_model_path: str):
|
||||
if not os.path.exists(landmark_model_path):
|
||||
raise FileNotFoundError(landmark_model_path)
|
||||
self.detector = cv2.face.createFacemarkLBF()
|
||||
self.detector.loadModel(landmark_model_path)
|
||||
|
||||
def align(
|
||||
self, image: np.ndarray, out_w: int, out_h: int
|
||||
) -> tuple[np.ndarray, dict]:
|
||||
land_image = (
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
||||
)
|
||||
_, lands = self.detector.fit(
|
||||
land_image, np.array([(0, 0, land_image.shape[1], land_image.shape[0])])
|
||||
)
|
||||
landmarks = lands[0][0]
|
||||
|
||||
leftEyePts = landmarks[42:48]
|
||||
rightEyePts = landmarks[36:42]
|
||||
leftEyeCenter = leftEyePts.mean(axis=0).astype("int")
|
||||
rightEyeCenter = rightEyePts.mean(axis=0).astype("int")
|
||||
|
||||
dY = rightEyeCenter[1] - leftEyeCenter[1]
|
||||
dX = rightEyeCenter[0] - leftEyeCenter[0]
|
||||
angle = np.degrees(np.arctan2(dY, dX)) - 180
|
||||
dist = float(np.sqrt((dX**2) + (dY**2)))
|
||||
|
||||
desiredRightEyeX = 1.0 - 0.35
|
||||
desiredDist = (desiredRightEyeX - 0.35) * out_w
|
||||
scale = desiredDist / dist if dist > 0 else 1.0
|
||||
|
||||
eyesCenter = (
|
||||
int((leftEyeCenter[0] + rightEyeCenter[0]) // 2),
|
||||
int((leftEyeCenter[1] + rightEyeCenter[1]) // 2),
|
||||
)
|
||||
M = cv2.getRotationMatrix2D(eyesCenter, angle, scale)
|
||||
tX = out_w * 0.5
|
||||
tY = out_h * 0.35
|
||||
M[0, 2] += tX - eyesCenter[0]
|
||||
M[1, 2] += tY - eyesCenter[1]
|
||||
|
||||
aligned = cv2.warpAffine(
|
||||
image, M, (out_w, out_h), flags=cv2.INTER_CUBIC
|
||||
)
|
||||
info = dict(
|
||||
angle=float(angle),
|
||||
eye_dist_px=dist,
|
||||
scale=float(scale),
|
||||
landmarks=landmarks,
|
||||
)
|
||||
return aligned, info
|
||||
|
||||
|
||||
class ArcFaceEmbedder:
|
||||
def __init__(self, model_path: str):
|
||||
self.session = ort.InferenceSession(
|
||||
model_path, providers=["CPUExecutionProvider"]
|
||||
)
|
||||
self.input_name = self.session.get_inputs()[0].name
|
||||
|
||||
def embed(self, image_bgr: np.ndarray) -> np.ndarray:
|
||||
tensor = arcface_preprocess(image_bgr)
|
||||
out = self.session.run(None, {self.input_name: tensor})[0]
|
||||
return out.squeeze()
|
||||
|
||||
|
||||
def similarity_to_confidence(
|
||||
cos_sim: float,
|
||||
median: float = 0.3,
|
||||
range_width: float = 0.6,
|
||||
slope_factor: float = 12,
|
||||
) -> float:
|
||||
slope = slope_factor / range_width
|
||||
return float(1.0 / (1.0 + np.exp(-slope * (cos_sim - median))))
|
||||
|
||||
|
||||
def laplacian_variance(image: np.ndarray) -> float:
|
||||
return float(cv2.Laplacian(image, cv2.CV_64F).var())
|
||||
|
||||
|
||||
def blur_reduction(variance: float) -> float:
|
||||
if variance < 120:
|
||||
return 0.06
|
||||
elif variance < 160:
|
||||
return 0.04
|
||||
elif variance < 200:
|
||||
return 0.02
|
||||
elif variance < 250:
|
||||
return 0.01
|
||||
return 0.0
|
||||
|
||||
|
||||
def cosine(a: np.ndarray, b: np.ndarray) -> float:
|
||||
denom = np.linalg.norm(a) * np.linalg.norm(b)
|
||||
if denom == 0:
|
||||
return 0.0
|
||||
return float(np.dot(a, b) / denom)
|
||||
|
||||
|
||||
def l2(v: np.ndarray) -> np.ndarray:
|
||||
return v / (np.linalg.norm(v) + 1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class FaceSample:
|
||||
path: str
|
||||
shape: tuple[int, int]
|
||||
embedding: np.ndarray
|
||||
blur_var: float
|
||||
align_info: dict
|
||||
|
||||
|
||||
def load_folder(
|
||||
folder: str, aligner: LandmarkAligner, embedder: ArcFaceEmbedder
|
||||
) -> list[FaceSample]:
|
||||
samples: list[FaceSample] = []
|
||||
names = sorted(os.listdir(folder))
|
||||
for name in names:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
path = os.path.join(folder, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
img = cv2.imread(path)
|
||||
if img is None:
|
||||
print(f" [skip unreadable] {name}")
|
||||
continue
|
||||
aligned, info = aligner.align(img, img.shape[1], img.shape[0])
|
||||
emb = embedder.embed(aligned)
|
||||
samples.append(
|
||||
FaceSample(
|
||||
path=path,
|
||||
shape=(img.shape[1], img.shape[0]),
|
||||
embedding=emb,
|
||||
blur_var=laplacian_variance(img),
|
||||
align_info=info,
|
||||
)
|
||||
)
|
||||
return samples
|
||||
|
||||
|
||||
def trimmed_mean(embs: Iterable[np.ndarray], trim: float = 0.15) -> np.ndarray:
|
||||
arr = np.stack(list(embs), axis=0)
|
||||
return stats.trim_mean(arr, trim, axis=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Baseline analyses (always run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def summarize_positive(samples: list[FaceSample], mean_emb: np.ndarray) -> None:
|
||||
"""Summary of training set: per-sample cos to class mean, intra-class stats.
|
||||
|
||||
Outliers with cos far below the rest are likely degrading the mean —
|
||||
they'd be the first candidates the shipped vector-outlier filter drops.
|
||||
"""
|
||||
print("\n" + "=" * 78)
|
||||
print(f"POSITIVE SET ANALYSIS ({len(samples)} images)")
|
||||
print("=" * 78)
|
||||
|
||||
rows = []
|
||||
for s in samples:
|
||||
cs = cosine(s.embedding, mean_emb)
|
||||
conf = similarity_to_confidence(cs)
|
||||
red = blur_reduction(s.blur_var)
|
||||
rows.append(
|
||||
dict(
|
||||
name=os.path.basename(s.path),
|
||||
shape=f"{s.shape[0]}x{s.shape[1]}",
|
||||
eye_px=s.align_info["eye_dist_px"],
|
||||
angle=s.align_info["angle"] + 180,
|
||||
blur=s.blur_var,
|
||||
cos=cs,
|
||||
conf=conf,
|
||||
red=red,
|
||||
adj_conf=max(0.0, conf - red),
|
||||
)
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: r["cos"])
|
||||
sims = np.array([r["cos"] for r in rows])
|
||||
print(
|
||||
f"\nCosine-to-trimmed-mean: mean={sims.mean():.3f} std={sims.std():.3f} "
|
||||
f"min={sims.min():.3f} max={sims.max():.3f}"
|
||||
)
|
||||
|
||||
print("\n-- Worst matches (bottom 10, most likely hurting the mean) --")
|
||||
print(
|
||||
f"{'cos':>6} {'conf':>6} {'blur':>7} {'eyes':>6} "
|
||||
f"{'angle':>6} {'shape':>9} name"
|
||||
)
|
||||
for r in rows[:10]:
|
||||
print(
|
||||
f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} "
|
||||
f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}"
|
||||
)
|
||||
|
||||
print("\n-- Best matches (top 5) --")
|
||||
for r in rows[-5:][::-1]:
|
||||
print(
|
||||
f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} "
|
||||
f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}"
|
||||
)
|
||||
|
||||
# Pairwise analysis — flags embeddings poorly correlated with the rest
|
||||
print("\n-- Pairwise intra-class similarity (mean cos vs. other positives) --")
|
||||
embs = np.stack([s.embedding for s in samples], axis=0)
|
||||
norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)
|
||||
sim_matrix = norms @ norms.T
|
||||
np.fill_diagonal(sim_matrix, np.nan)
|
||||
mean_pairwise = np.nanmean(sim_matrix, axis=1)
|
||||
names = [os.path.basename(s.path) for s in samples]
|
||||
ordered = sorted(zip(names, mean_pairwise), key=lambda t: t[1])
|
||||
print(f"{'mean_cos':>9} name")
|
||||
for nm, mp in ordered[:10]:
|
||||
print(f"{mp:9.3f} {nm}")
|
||||
print(f"\n overall mean pairwise cos: {np.nanmean(sim_matrix):.3f}")
|
||||
print(f" median pairwise cos: {np.nanmedian(sim_matrix):.3f}")
|
||||
|
||||
|
||||
def summarize_negative(
|
||||
neg_samples: list[FaceSample],
|
||||
mean_emb: np.ndarray,
|
||||
pos_samples: list[FaceSample],
|
||||
) -> None:
|
||||
"""Score each negative against the class mean, then show its top-3
|
||||
nearest positives. High-scoring negatives that match specific outlier
|
||||
positives hint at training-set contamination.
|
||||
"""
|
||||
print("\n" + "=" * 78)
|
||||
print(f"NEGATIVE SET ANALYSIS ({len(neg_samples)} images)")
|
||||
print("=" * 78)
|
||||
print(
|
||||
f"\n{'cos':>6} {'conf':>6} {'red':>5} {'adj':>5} "
|
||||
f"{'blur':>7} {'eyes':>6} {'shape':>9} name"
|
||||
)
|
||||
for s in neg_samples:
|
||||
cs = cosine(s.embedding, mean_emb)
|
||||
conf = similarity_to_confidence(cs)
|
||||
red = blur_reduction(s.blur_var)
|
||||
print(
|
||||
f"{cs:6.3f} {conf:6.3f} {red:5.2f} {max(0, conf - red):5.2f} "
|
||||
f"{s.blur_var:7.1f} {s.align_info['eye_dist_px']:6.1f} "
|
||||
f"{s.shape[0]}x{s.shape[1]:<5} {os.path.basename(s.path)}"
|
||||
)
|
||||
|
||||
print("\n-- For each negative, top-3 most similar positives --")
|
||||
pos_embs = np.stack([p.embedding for p in pos_samples])
|
||||
pos_norm = pos_embs / (np.linalg.norm(pos_embs, axis=1, keepdims=True) + 1e-9)
|
||||
for s in neg_samples:
|
||||
v = s.embedding / (np.linalg.norm(s.embedding) + 1e-9)
|
||||
sims = pos_norm @ v
|
||||
idx = np.argsort(-sims)[:3]
|
||||
print(f"\n {os.path.basename(s.path)}:")
|
||||
for i in idx:
|
||||
print(
|
||||
f" {sims[i]:6.3f} {os.path.basename(pos_samples[i].path)} "
|
||||
f"blur={pos_samples[i].blur_var:.1f} "
|
||||
f"eyes={pos_samples[i].align_info['eye_dist_px']:.1f}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional diagnostics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def vector_outlier_test(
|
||||
pos: list[FaceSample], neg: list[FaceSample], base_trim: float = 0.15
|
||||
) -> None:
|
||||
"""Measure the shipped vector-wise outlier filter at various thresholds.
|
||||
|
||||
The production filter at `build_class_mean` in
|
||||
frigate/data_processing/common/face/model.py uses T=0.30. This test
|
||||
sweeps T so you can see which images would be dropped on a new collection
|
||||
and how that affects the negative scores.
|
||||
|
||||
Algorithm: iteratively recompute trim_mean on the kept set, drop any
|
||||
embedding with cos < T to that mean, repeat until converged. Floor at
|
||||
50% of the collection to avoid collapse.
|
||||
"""
|
||||
print("\n" + "=" * 78)
|
||||
print("VECTOR-WISE OUTLIER PRE-FILTER — layered on trim_mean(0.15)")
|
||||
print("=" * 78)
|
||||
|
||||
all_embs = np.stack([s.embedding for s in pos])
|
||||
|
||||
def iterative_mean(
|
||||
embs: np.ndarray,
|
||||
threshold: float,
|
||||
iters: int = 3,
|
||||
min_keep_frac: float = 0.5,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
keep = np.ones(len(embs), dtype=bool)
|
||||
floor = max(5, int(np.ceil(min_keep_frac * len(embs))))
|
||||
for _ in range(iters):
|
||||
m = stats.trim_mean(embs[keep], base_trim, axis=0)
|
||||
m_norm = m / (np.linalg.norm(m) + 1e-9)
|
||||
e_norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)
|
||||
cos_to_mean = e_norms @ m_norm
|
||||
new_keep = cos_to_mean >= threshold
|
||||
if new_keep.sum() < floor:
|
||||
top_idx = np.argsort(-cos_to_mean)[:floor]
|
||||
new_keep = np.zeros_like(new_keep)
|
||||
new_keep[top_idx] = True
|
||||
if np.array_equal(new_keep, keep):
|
||||
break
|
||||
keep = new_keep
|
||||
final = stats.trim_mean(embs[keep], base_trim, axis=0)
|
||||
return final, keep
|
||||
|
||||
provisional = stats.trim_mean(all_embs, base_trim, axis=0)
|
||||
p_norm = provisional / (np.linalg.norm(provisional) + 1e-9)
|
||||
e_norms_all = all_embs / (np.linalg.norm(all_embs, axis=1, keepdims=True) + 1e-9)
|
||||
cos_to_prov = e_norms_all @ p_norm
|
||||
print("\nDistribution of cos(positive, provisional trim_mean):")
|
||||
print(
|
||||
f" min={cos_to_prov.min():.3f} p10={np.percentile(cos_to_prov, 10):.3f} "
|
||||
f"p25={np.percentile(cos_to_prov, 25):.3f} "
|
||||
f"median={np.median(cos_to_prov):.3f} "
|
||||
f"p75={np.percentile(cos_to_prov, 75):.3f} max={cos_to_prov.max():.3f}"
|
||||
)
|
||||
|
||||
baseline_mean = stats.trim_mean(all_embs, base_trim, axis=0)
|
||||
baseline_pos = np.array([cosine(p.embedding, baseline_mean) for p in pos])
|
||||
baseline_neg = (
|
||||
np.array([cosine(n.embedding, baseline_mean) for n in neg])
|
||||
if neg
|
||||
else np.array([])
|
||||
)
|
||||
baseline_conf_neg = np.array(
|
||||
[similarity_to_confidence(c) for c in baseline_neg]
|
||||
)
|
||||
|
||||
print(
|
||||
f"\nBaseline (trim_mean only, {len(pos)} images):"
|
||||
f"\n pos cos min={baseline_pos.min():.3f} "
|
||||
f"mean={baseline_pos.mean():.3f} max={baseline_pos.max():.3f}"
|
||||
)
|
||||
if len(neg):
|
||||
print(
|
||||
f" neg cos min={baseline_neg.min():.3f} "
|
||||
f"mean={baseline_neg.mean():.3f} max={baseline_neg.max():.3f}"
|
||||
)
|
||||
print(
|
||||
f" neg conf min={baseline_conf_neg.min():.3f} "
|
||||
f"mean={baseline_conf_neg.mean():.3f} max={baseline_conf_neg.max():.3f}"
|
||||
)
|
||||
print(
|
||||
f" margin (pos.min - neg.max): "
|
||||
f"{baseline_pos.min() - baseline_neg.max():+.3f}"
|
||||
)
|
||||
|
||||
print("\nIterative (refine mean → drop vectors with cos<T → repeat):")
|
||||
print(
|
||||
f"\n{'T':>5} {'kept':>6} {'pos min':>7} {'pos mean':>8} "
|
||||
f"{'neg max':>7} {'neg mean':>8} {'neg conf.max':>12} {'margin':>7}"
|
||||
)
|
||||
for T in [0.15, 0.20, 0.25, 0.28, 0.30, 0.33, 0.36, 0.40]:
|
||||
mean, keep = iterative_mean(all_embs, T)
|
||||
pos_sims = np.array([cosine(p.embedding, mean) for p in pos])
|
||||
neg_sims = (
|
||||
np.array([cosine(n.embedding, mean) for n in neg])
|
||||
if neg
|
||||
else np.array([])
|
||||
)
|
||||
neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims])
|
||||
margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0)
|
||||
print(
|
||||
f"{T:5.2f} {int(keep.sum()):>3}/{len(pos):<2} "
|
||||
f"{pos_sims.min():7.3f} {pos_sims.mean():8.3f} "
|
||||
f"{neg_sims.max() if len(neg_sims) else float('nan'):7.3f} "
|
||||
f"{neg_sims.mean() if len(neg_sims) else float('nan'):8.3f} "
|
||||
f"{neg_conf.max() if len(neg_conf) else float('nan'):12.3f} "
|
||||
f"{margin:+7.3f}"
|
||||
)
|
||||
|
||||
# Show which images get dropped at the shipped threshold + neighbors
|
||||
for T_show in (0.25, 0.30, 0.33):
|
||||
_, keep = iterative_mean(all_embs, T_show)
|
||||
print(
|
||||
f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:"
|
||||
)
|
||||
final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0)
|
||||
m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9)
|
||||
for i, (p, k) in enumerate(zip(pos, keep)):
|
||||
if not k:
|
||||
e_n = p.embedding / (np.linalg.norm(p.embedding) + 1e-9)
|
||||
cos_final = float(e_n @ m_n)
|
||||
print(
|
||||
f" cos_to_clean_mean={cos_final:6.3f} "
|
||||
f"shape={p.shape[0]}x{p.shape[1]} "
|
||||
f"eyes={p.align_info['eye_dist_px']:6.1f} "
|
||||
f"blur={p.blur_var:7.1f} "
|
||||
f"{os.path.basename(p.path)}"
|
||||
)
|
||||
|
||||
|
||||
def degenerate_embedding_test(
|
||||
pos: list[FaceSample], neg: list[FaceSample]
|
||||
) -> None:
|
||||
"""Detect whether negatives and low-quality positives share a degenerate
|
||||
'tiny/noisy face' region of the embedding space.
|
||||
|
||||
Signal: if neg-to-neg cos is higher than pos-to-pos cos, the negatives
|
||||
aren't really per-identity embeddings — they're dominated by upsample /
|
||||
low-resolution artifacts that all map to a similar corner of embedding
|
||||
space regardless of who the face belongs to.
|
||||
|
||||
Also rebuilds the mean using only high-intra-similarity positives to
|
||||
show whether a cleaner training set separates the negatives.
|
||||
"""
|
||||
print("\n" + "=" * 78)
|
||||
print("DEGENERATE-EMBEDDING TEST")
|
||||
print("=" * 78)
|
||||
|
||||
pos_embs = np.stack([l2(s.embedding) for s in pos])
|
||||
neg_embs = np.stack([l2(s.embedding) for s in neg])
|
||||
|
||||
nn = neg_embs @ neg_embs.T
|
||||
np.fill_diagonal(nn, np.nan)
|
||||
pp = pos_embs @ pos_embs.T
|
||||
np.fill_diagonal(pp, np.nan)
|
||||
pn = pos_embs @ neg_embs.T
|
||||
|
||||
print(
|
||||
f"\n neg<->neg mean cos : {np.nanmean(nn):.3f} "
|
||||
f"(how tightly negatives cluster together)"
|
||||
)
|
||||
print(
|
||||
f" pos<->pos mean cos : {np.nanmean(pp):.3f} "
|
||||
f"(how tightly positives cluster)"
|
||||
)
|
||||
print(
|
||||
f" pos<->neg mean cos : {pn.mean():.3f} "
|
||||
f"(cross-class — should be low for a clean class)"
|
||||
)
|
||||
if np.nanmean(nn) > np.nanmean(pp):
|
||||
print(
|
||||
"\n >> neg<->neg > pos<->pos: negatives cluster more tightly than\n"
|
||||
" positives. This is the degenerate-embedding signature —\n"
|
||||
" upsampled tiny crops share a common 'face-like blob' region\n"
|
||||
" regardless of identity."
|
||||
)
|
||||
|
||||
mean_intra = np.nanmean(pp, axis=1)
|
||||
for thresh in (0.30, 0.33, 0.36):
|
||||
keep = mean_intra >= thresh
|
||||
if keep.sum() < 5:
|
||||
continue
|
||||
clean_embs = [pos[i].embedding for i in range(len(pos)) if keep[i]]
|
||||
clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0)
|
||||
neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg])
|
||||
neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores])
|
||||
pos_scores = np.array(
|
||||
[
|
||||
cosine(pos[i].embedding, clean_mean)
|
||||
for i in range(len(pos))
|
||||
if keep[i]
|
||||
]
|
||||
)
|
||||
print(
|
||||
f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives"
|
||||
)
|
||||
print(
|
||||
f" pos cos vs mean : min={pos_scores.min():.3f} "
|
||||
f"mean={pos_scores.mean():.3f} max={pos_scores.max():.3f}"
|
||||
)
|
||||
print(
|
||||
f" neg cos vs mean : min={neg_scores.min():.3f} "
|
||||
f"mean={neg_scores.mean():.3f} max={neg_scores.max():.3f}"
|
||||
)
|
||||
print(
|
||||
f" neg conf : min={neg_confs.min():.3f} "
|
||||
f"mean={neg_confs.mean():.3f} max={neg_confs.max():.3f}"
|
||||
)
|
||||
print(
|
||||
f" margin (pos.min - neg.max): "
|
||||
f"{pos_scores.min() - neg_scores.max():+.3f}"
|
||||
)
|
||||
|
||||
|
||||
def contamination_analysis(
|
||||
pos: list[FaceSample], neg: list[FaceSample]
|
||||
) -> None:
|
||||
"""Check whether the positive collection contains a second identity.
|
||||
|
||||
Two signals:
|
||||
(a) Per-positive: if an image is closer to at least one negative than
|
||||
to the rest of the positive class, it's likely a mislabeled face.
|
||||
(b) 2-means split of the positive embeddings: if one cluster center
|
||||
lands close to the negative mean, that cluster is a contaminating
|
||||
sub-identity that's pulling the class mean toward the negatives.
|
||||
"""
|
||||
print("\n" + "=" * 78)
|
||||
print("CONTAMINATION ANALYSIS")
|
||||
print("=" * 78)
|
||||
|
||||
pos_embs = np.stack([l2(s.embedding) for s in pos])
|
||||
neg_embs = np.stack([l2(s.embedding) for s in neg])
|
||||
pos_names = [os.path.basename(s.path) for s in pos]
|
||||
|
||||
pos_pos = pos_embs @ pos_embs.T
|
||||
np.fill_diagonal(pos_pos, np.nan)
|
||||
pos_neg = pos_embs @ neg_embs.T
|
||||
|
||||
mean_intra = np.nanmean(pos_pos, axis=1)
|
||||
max_to_neg = pos_neg.max(axis=1)
|
||||
mean_to_neg = pos_neg.mean(axis=1)
|
||||
|
||||
print(
|
||||
"\nPositives closer to a negative than to their own class avg"
|
||||
"\n(these are candidates for mislabeled images):"
|
||||
)
|
||||
print(
|
||||
f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} "
|
||||
f"{'delta':>6} name"
|
||||
)
|
||||
rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra))
|
||||
rows.sort(key=lambda r: -(r[1] - r[3]))
|
||||
for nm, mxn, mnn, mi in rows[:15]:
|
||||
delta = mxn - mi
|
||||
marker = " <<" if delta > 0 else ""
|
||||
print(f"{mxn:7.3f} {mnn:8.3f} {mi:10.3f} {delta:6.3f} {nm}{marker}")
|
||||
|
||||
# 2-means in cosine space (no sklearn dependency).
|
||||
print("\n2-means split of positive embeddings (cosine space):")
|
||||
rng = np.random.default_rng(0)
|
||||
best = None
|
||||
for _ in range(5):
|
||||
idx = rng.choice(len(pos_embs), 2, replace=False)
|
||||
centers = pos_embs[idx].copy()
|
||||
for _ in range(50):
|
||||
sims = pos_embs @ centers.T
|
||||
labels = np.argmax(sims, axis=1)
|
||||
new_centers = np.stack(
|
||||
[
|
||||
l2(pos_embs[labels == k].mean(axis=0))
|
||||
if np.any(labels == k)
|
||||
else centers[k]
|
||||
for k in range(2)
|
||||
]
|
||||
)
|
||||
if np.allclose(new_centers, centers):
|
||||
break
|
||||
centers = new_centers
|
||||
tight = float(np.mean([sims[i, labels[i]] for i in range(len(labels))]))
|
||||
if best is None or tight > best[0]:
|
||||
best = (tight, labels.copy(), centers.copy())
|
||||
|
||||
_, labels, centers = best
|
||||
sizes = [int((labels == k).sum()) for k in range(2)]
|
||||
neg_mean = l2(neg_embs.mean(axis=0))
|
||||
print(
|
||||
f" cluster 0: size={sizes[0]:>2} "
|
||||
f"center<->other_center_cos={float(centers[0] @ centers[1]):.3f} "
|
||||
f"center<->neg_mean_cos={float(centers[0] @ neg_mean):.3f}"
|
||||
)
|
||||
print(
|
||||
f" cluster 1: size={sizes[1]:>2} "
|
||||
f"center<->neg_mean_cos={float(centers[1] @ neg_mean):.3f}"
|
||||
)
|
||||
|
||||
neg_aligned = 0 if centers[0] @ neg_mean > centers[1] @ neg_mean else 1
|
||||
print(
|
||||
f"\n cluster {neg_aligned} is more similar to the negatives — "
|
||||
f"its members are the contamination candidates:"
|
||||
)
|
||||
for i, lbl in enumerate(labels):
|
||||
if lbl == neg_aligned:
|
||||
print(
|
||||
f" max_to_neg={max_to_neg[i]:.3f} "
|
||||
f"mean_intra={mean_intra[i]:.3f} {pos_names[i]}"
|
||||
)
|
||||
|
||||
keep_mask = labels != neg_aligned
|
||||
if keep_mask.sum() >= 3:
|
||||
clean_embs = [pos[i].embedding for i in range(len(pos)) if keep_mask[i]]
|
||||
clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0)
|
||||
print(
|
||||
f"\n Rebuilding class mean from the OTHER cluster "
|
||||
f"({keep_mask.sum()} images):"
|
||||
)
|
||||
print(f" {'cos':>6} {'conf':>6} name")
|
||||
for n in neg:
|
||||
cs = cosine(n.embedding, clean_mean)
|
||||
cf = similarity_to_confidence(cs)
|
||||
print(f" {cs:6.3f} {cf:6.3f} {os.path.basename(n.path)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Analyze a face recognition collection outside Frigate.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
ap.add_argument("--positive", required=True, help="Training folder for one identity")
|
||||
ap.add_argument(
|
||||
"--negative",
|
||||
default=None,
|
||||
help="Runtime-crop folder to score against (optional)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--model-cache",
|
||||
default="/config/model_cache",
|
||||
help="Directory containing facedet/arcface.onnx and facedet/landmarkdet.yaml",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--trim",
|
||||
type=float,
|
||||
default=0.15,
|
||||
help="trim_mean proportion (Frigate uses 0.15)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--vector-outlier",
|
||||
action="store_true",
|
||||
help="Sweep the vector-wise outlier filter threshold",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--degenerate",
|
||||
action="store_true",
|
||||
help="Test whether negatives share a degenerate embedding region",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--contamination",
|
||||
action="store_true",
|
||||
help="Check whether the positive folder contains a second identity",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
arcface_path = os.path.join(args.model_cache, "facedet", "arcface.onnx")
|
||||
landmark_path = os.path.join(args.model_cache, "facedet", "landmarkdet.yaml")
|
||||
for p in (arcface_path, landmark_path):
|
||||
if not os.path.exists(p):
|
||||
print(f"ERROR: model file not found: {p}")
|
||||
return 1
|
||||
|
||||
print(f"Loading ArcFace from {arcface_path}")
|
||||
embedder = ArcFaceEmbedder(arcface_path)
|
||||
print(f"Loading landmark model from {landmark_path}")
|
||||
aligner = LandmarkAligner(landmark_path)
|
||||
|
||||
print(f"\nLoading positives from {args.positive} ...")
|
||||
pos = load_folder(args.positive, aligner, embedder)
|
||||
print(f" {len(pos)} positives loaded")
|
||||
|
||||
neg: list[FaceSample] = []
|
||||
if args.negative:
|
||||
print(f"\nLoading negatives from {args.negative} ...")
|
||||
neg = load_folder(args.negative, aligner, embedder)
|
||||
print(f" {len(neg)} negatives loaded")
|
||||
|
||||
if not pos:
|
||||
print("no positive samples — aborting")
|
||||
return 1
|
||||
|
||||
mean_emb = trimmed_mean([s.embedding for s in pos], trim=args.trim)
|
||||
summarize_positive(pos, mean_emb)
|
||||
if neg:
|
||||
summarize_negative(neg, mean_emb, pos)
|
||||
|
||||
if args.vector_outlier:
|
||||
vector_outlier_test(pos, neg, args.trim)
|
||||
if args.degenerate and neg:
|
||||
degenerate_embedding_test(pos, neg)
|
||||
if args.contamination and neg:
|
||||
contamination_analysis(pos, neg)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,4 +1,114 @@
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
|
||||
test.describe("Export Page - Delete race @high", () => {
|
||||
// Empirical guard for radix-ui/primitives#3445: when a modal DropdownMenu
|
||||
// opens an AlertDialog and the AlertDialog's confirm action causes the
|
||||
// parent's optimistic cache update to unmount the card, we want to know
|
||||
// whether the deduped react-dismissable-layer (1.1.11) handles the
|
||||
// pointer-events stack cleanup or whether `modal={false}` is still
|
||||
// required on the DropdownMenu. The classic "canonical" pattern, distinct
|
||||
// from the FaceSelectionDialog auto-unmount race already covered by
|
||||
// face-library.spec.ts.
|
||||
test("deleting an export via dropdown→alert→confirm leaves body interactive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const initialExports = [
|
||||
{
|
||||
id: "export-race-001",
|
||||
camera: "front_door",
|
||||
name: "Race - Test Export",
|
||||
date: 1775490731.3863528,
|
||||
video_path: "/exports/export-race-001.mp4",
|
||||
thumb_path: "/exports/export-race-001-thumb.jpg",
|
||||
in_progress: false,
|
||||
export_case_id: null,
|
||||
},
|
||||
];
|
||||
let deleted = false;
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
exports: initialExports,
|
||||
});
|
||||
|
||||
// Flip /api/export to empty after the delete POST is observed so the
|
||||
// page's SWR mutate sees the export gone.
|
||||
await frigateApp.page.route("**/api/export**", async (route) => {
|
||||
const payload = deleted ? [] : initialExports;
|
||||
await route.fulfill({ json: payload });
|
||||
});
|
||||
await frigateApp.page.route("**/api/exports/delete", async (route) => {
|
||||
deleted = true;
|
||||
const delayMs = Number(
|
||||
(globalThis as { process?: { env?: Record<string, string> } }).process
|
||||
?.env?.DELETE_DELAY_MS ?? "100",
|
||||
);
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
await frigateApp.goto("/export");
|
||||
await expect(frigateApp.page.getByText("Race - Test Export")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Open the kebab menu on the export card. The kebab uses the
|
||||
// (misleading) aria-label "Edit name" from ExportCard's source — it
|
||||
// wraps the FiMoreVertical icon. There is exactly one such button on
|
||||
// the page once we have a single export rendered.
|
||||
const kebab = frigateApp.page
|
||||
.getByRole("button", { name: /edit name/i })
|
||||
.first();
|
||||
await expect(kebab).toBeVisible({ timeout: 5_000 });
|
||||
await kebab.click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Delete Export
|
||||
await menu
|
||||
.getByRole("menuitem", { name: /delete export/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// AlertDialog at page level. The confirm button's accessible name is
|
||||
// "Delete Export" (its aria-label), the visible text is just "Delete".
|
||||
const confirm = frigateApp.page.getByRole("alertdialog");
|
||||
await expect(confirm).toBeVisible({ timeout: 3_000 });
|
||||
await confirm
|
||||
.getByRole("button", { name: /^delete export$/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The card optimistically disappears, the dialog closes, and body
|
||||
// pointer-events must come unstuck.
|
||||
await expect(
|
||||
frigateApp.page.getByText("Race - Test Export"),
|
||||
).not.toBeVisible({ timeout: 5_000 });
|
||||
await waitForBodyInteractive(frigateApp.page, 5_000);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
|
||||
// Sanity: another page-level button still responds.
|
||||
const newCase = frigateApp.page.getByRole("button", { name: /new case/i });
|
||||
await expect(newCase).toBeVisible({ timeout: 3_000 });
|
||||
await newCase.click();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("dialog").filter({ hasText: /create case/i }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Export Page - Overview @high", () => {
|
||||
test("renders uncategorized exports and case cards from mock data", async ({
|
||||
|
||||
@@ -358,6 +358,158 @@ test.describe("FaceSelectionDialog @high", () => {
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
test("classifying the last image in a group leaves body interactive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Regression guard for the stuck body pointer-events bug when the
|
||||
// last image in a grouped-recognition detail Dialog is classified.
|
||||
// Tracked upstream at radix-ui/primitives#3445.
|
||||
//
|
||||
// Root cause: when the user clicks a FaceSelectionDialog menu item,
|
||||
// the modal DropdownMenu enters its exit animation (Radix's Presence
|
||||
// keeps it in the DOM with data-state="closed" until animationend).
|
||||
// While that is in flight the classify axios resolves, SWR removes
|
||||
// the image from /api/faces, the parent's map no longer renders the
|
||||
// grouped card, and React unmounts the subtree — including the still-
|
||||
// animating DropdownMenu's Presence container. DismissableLayer's
|
||||
// shared modal-layer stack can't reconcile the interrupted exit, so
|
||||
// the `body { pointer-events: none }` entry it put on mount is never
|
||||
// popped and the rest of the UI becomes unclickable.
|
||||
//
|
||||
// The fix is `modal={false}` on the FaceSelectionDialog's
|
||||
// DropdownMenu (desktop path only). With modal=false the DropdownMenu
|
||||
// never puts an entry on DismissableLayer's body-pointer-events stack
|
||||
// in the first place, so there's nothing to leak when its Presence is
|
||||
// torn down mid-animation. The Radix-community-documented workaround
|
||||
// for #3445.
|
||||
//
|
||||
// The bug only reproduces when the mock resolves fast enough that
|
||||
// the parent unmounts before the dropdown's exit animation finishes.
|
||||
// Measured window via a 3x sweep on the pre-fix build: 0–200 ms
|
||||
// triggers it; 300 ms+ no longer reproduces. Production LAN networks
|
||||
// sit comfortably inside the bad window, while `npm run dev` seems
|
||||
// to mask it via React StrictMode's double-effect scheduling.
|
||||
const EVENT_ID = "1775487131.3863528-race";
|
||||
const initialFaces = withGroupedTrainingAttempt(basicFacesMock(), {
|
||||
eventId: EVENT_ID,
|
||||
attempts: [
|
||||
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
|
||||
],
|
||||
});
|
||||
|
||||
let classified = false;
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
faces: initialFaces,
|
||||
events: [
|
||||
{
|
||||
id: EVENT_ID,
|
||||
label: "person",
|
||||
sub_label: null,
|
||||
camera: "front_door",
|
||||
start_time: 1775487131.3863528,
|
||||
end_time: 1775487161.3863528,
|
||||
false_positive: false,
|
||||
zones: ["front_yard"],
|
||||
thumbnail: null,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
retain_indefinitely: false,
|
||||
plus_id: null,
|
||||
model_hash: "abc123",
|
||||
detector_type: "cpu",
|
||||
model_type: "ssd",
|
||||
data: {
|
||||
top_score: 0.92,
|
||||
score: 0.92,
|
||||
region: [0.1, 0.1, 0.5, 0.8],
|
||||
box: [0.2, 0.15, 0.45, 0.75],
|
||||
area: 0.18,
|
||||
ratio: 0.6,
|
||||
type: "object",
|
||||
path_data: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Re-route /api/faces to flip to the "train empty" payload once the
|
||||
// classify POST has been received. Registered AFTER installDefaults so
|
||||
// Playwright's LIFO route matching hits this handler first.
|
||||
await frigateApp.page.route("**/api/faces", async (route) => {
|
||||
const payload = classified ? basicFacesMock() : initialFaces;
|
||||
await route.fulfill({ json: payload });
|
||||
});
|
||||
|
||||
// Hold the classify POST briefly. The race opens when the parent
|
||||
// unmounts before the dropdown's exit animation finishes (~200ms
|
||||
// in Radix). 100ms keeps us comfortably inside that window and
|
||||
// reliably triggered the bug in a 3x sweep across 0/50/100/200ms
|
||||
// on the pre-fix build. CLASSIFY_DELAY_MS overrides for local sweeps.
|
||||
const delayMs = Number(
|
||||
(globalThis as { process?: { env?: Record<string, string> } }).process
|
||||
?.env?.CLASSIFY_DELAY_MS ?? "100",
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
"**/api/faces/train/*/classify",
|
||||
async (route) => {
|
||||
classified = true;
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
await route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
// Open the grouped detail Dialog.
|
||||
const groupedImage = frigateApp.page
|
||||
.locator('img[src*="clips/faces/train/"]')
|
||||
.first();
|
||||
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
|
||||
await groupedImage.locator("xpath=..").click();
|
||||
const dialog = frigateApp.page
|
||||
.getByRole("dialog")
|
||||
.filter({
|
||||
has: frigateApp.page.locator('img[src*="clips/faces/train/"]'),
|
||||
})
|
||||
.first();
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Single attempt → single `+` trigger.
|
||||
const triggers = dialog.locator('[aria-haspopup="menu"]');
|
||||
await expect(triggers).toHaveCount(1);
|
||||
await triggers.first().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
await menu.getByRole("menuitem", { name: /^alice$/i }).click();
|
||||
|
||||
// The Dialog must leave the tree cleanly, and body must recover.
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Give Radix's exit animation + cleanup a comfortable margin on top of
|
||||
// the ~300ms simulated network delay.
|
||||
await waitForBodyInteractive(frigateApp.page, 5_000);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
|
||||
// User-visible confirmation: click something outside the dialog
|
||||
// and assert it actually responds.
|
||||
const librarySelector = frigateApp.page
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /\(\d+\)/ })
|
||||
.first();
|
||||
await librarySelector.click();
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library — mobile @high @mobile", () => {
|
||||
|
||||
Generated
+16
-16
@@ -49,12 +49,12 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"konva": "^10.2.3",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -7013,12 +7013,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@@ -8876,12 +8876,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
|
||||
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
|
||||
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.1.0"
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-resources-for-ts": {
|
||||
@@ -9636,15 +9636,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
|
||||
+2
-2
@@ -63,12 +63,12 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"konva": "^10.2.3",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
{}
|
||||
{
|
||||
"auth": {
|
||||
"label": "Автентикация",
|
||||
"session_length": {
|
||||
"label": "Продължителност на сесията"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@
|
||||
"classification": "Classificació",
|
||||
"chat": "Xat",
|
||||
"actions": "Accions",
|
||||
"profiles": "Perfils"
|
||||
"profiles": "Perfils",
|
||||
"features": "Característiques"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": {
|
||||
|
||||
@@ -60,15 +60,76 @@
|
||||
"noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid",
|
||||
"failed": "No s'ha pogut inciar l'exportació: {{error}}"
|
||||
},
|
||||
"view": "Vista"
|
||||
"view": "Vista",
|
||||
"queued": "Exporta a la cua. Mostra el progrés a la pàgina d'exportacions.",
|
||||
"batchSuccess_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.",
|
||||
"batchSuccess_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"batchSuccess_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"batchPartial": "S'han iniciat {{successful}} de {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||
"batchFailed": "No s'han pogut iniciar {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Exporta a la cua 1. Obrint el cas ara.",
|
||||
"batchQueuedSuccess_many": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||
"batchQueuedSuccess_other": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||
"batchQueuedPartial": "{{successful}} de {{total}} exportacions a la cua. Càmeres fallides: {{failedCameras}}",
|
||||
"batchQueueFailed": "No s'han pogut posar a la cua {{total}} exportacions. Càmeres fallides: {{failedCameras}}"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Guardar exportació",
|
||||
"previewExport": "Previsualitzar exportació"
|
||||
"previewExport": "Previsualitzar exportació",
|
||||
"queueingExport": "S'està fent la cua de l'exportació...",
|
||||
"useThisRange": "Utilitza aquest interval"
|
||||
},
|
||||
"case": {
|
||||
"label": "Cas",
|
||||
"placeholder": "Selecciona un cas"
|
||||
"placeholder": "Selecciona un cas",
|
||||
"newCaseOption": "Crea un cas no",
|
||||
"newCaseNamePlaceholder": "Nom de cas nou",
|
||||
"newCaseDescriptionPlaceholder": "Descripció del cas",
|
||||
"nonAdminHelp": "Es crearà un nou cas per a aquestes exportacions."
|
||||
},
|
||||
"queueing": "S'està fent la cua de l'exportació...",
|
||||
"tabs": {
|
||||
"export": "Càmera única",
|
||||
"multiCamera": "Multicàmera"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Interval de temps",
|
||||
"selectFromTimeline": "Selecciona des de la línia de temps",
|
||||
"cameraSelection": "Càmeres",
|
||||
"cameraSelectionHelp": "Les càmeres amb objectes rastrejats en aquest interval de temps estan preseleccionades",
|
||||
"checkingActivity": "Comprovant l'activitat de la càmera...",
|
||||
"noCameras": "No hi ha càmeres disponibles",
|
||||
"detectionCount_one": "1 objecte rastrejat",
|
||||
"detectionCount_many": "{{count}} objectes rastrejats",
|
||||
"detectionCount_other": "{{count}} objectes rastrejats",
|
||||
"nameLabel": "Nom de l'exportació",
|
||||
"namePlaceholder": "Nom base opcional per a aquestes exportacions",
|
||||
"queueingButton": "S'estan posant a la cua les exportacions...",
|
||||
"exportButton_one": "Exporta 1 càmera",
|
||||
"exportButton_many": "Exporta {{count}} càmeres",
|
||||
"exportButton_other": "Exporta {{count}} càmeres"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "Exporta {{count}} ressenyes",
|
||||
"title_many": "Exporta {{count}} ressenyes",
|
||||
"title_other": "Exporta {{count}} ressenyes",
|
||||
"description": "Exporta cada revisió seleccionada. Totes les exportacions s'agruparan en un sol cas.",
|
||||
"descriptionNoCase": "Exporta cada revisió seleccionada.",
|
||||
"caseNamePlaceholder": "Exporta la revisió - {{date}}",
|
||||
"exportButton_one": "Exporta {{count}} ressenyes",
|
||||
"exportButton_many": "Exporta {{count}} ressenyes",
|
||||
"exportButton_other": "Exporta {{count}} ressenyes",
|
||||
"exportingButton": "S'està exportant...",
|
||||
"toast": {
|
||||
"started_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.",
|
||||
"started_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"started_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"startedNoCase_one": "S'ha iniciat l'exportació 1.",
|
||||
"startedNoCase_many": "S'han iniciat {{count}} exportacions.",
|
||||
"startedNoCase_other": "S'han iniciat {{count}} exportacions.",
|
||||
"partial": "S'han iniciat {{successful}} de {{total}} exportacions. Ha fallat: {{failedItems}}",
|
||||
"failed": "No s'han pogut iniciar {{total}} exportacions. Ha fallat: {{failedItems}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
@@ -116,6 +177,14 @@
|
||||
"success": "Els enregistraments de vídeo associats als elements de revisió seleccionats s’han suprimit correctament.",
|
||||
"error": "No s'ha pogut suprimir: {{error}}"
|
||||
}
|
||||
},
|
||||
"shareTimestamp": {
|
||||
"label": "Comparteix la marca horària",
|
||||
"title": "Comparteix la marca horària",
|
||||
"description": "Comparteix un URL amb marca horària de la posició actual del jugador o tria una marca horària personalitzada. Tingueu en compte que aquest no és un URL de compartició pública i només és accessible per als usuaris amb accés a Frigate i aquesta càmera.",
|
||||
"custom": "Marca horària personalitzada",
|
||||
"button": "Comparteix l'URL de la marca horària",
|
||||
"shareTitle": "Marca de temps de revisió de Frigate: {{camera}}"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"noPreviewFoundFor": "No s'ha trobat cap previsualització per a {{cameraName}}",
|
||||
"submitFrigatePlus": {
|
||||
"title": "Enviar aquesta imatge a Frigate+?",
|
||||
"submit": "Enviar"
|
||||
"submit": "Enviar",
|
||||
"previewError": "No s'ha pogut carregar la vista prèvia de la instantània. És possible que l'enregistrament no estigui disponible en aquest moment."
|
||||
},
|
||||
"livePlayerRequiredIOSVersion": "Es requereix iOS 17.1 o superior per a aquest tipus de reproducció en directe.",
|
||||
"streamOffline": {
|
||||
|
||||
@@ -1951,7 +1951,7 @@
|
||||
},
|
||||
"roles": {
|
||||
"label": "Rols",
|
||||
"description": "Funcions genAI (eines, visió, incrustacions); un proveïdor per rol."
|
||||
"description": "Rols de GenAI (xat, descripcions, incrustacions); un proveïdor per rol."
|
||||
},
|
||||
"provider_options": {
|
||||
"label": "Opcions del proveïdor",
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
},
|
||||
"documentTitle": "Revisió - Frigate",
|
||||
"recordings": {
|
||||
"documentTitle": "Enregistraments - Frigate"
|
||||
"documentTitle": "Enregistraments - Frigate",
|
||||
"invalidSharedLink": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'un error d'anàlisi.",
|
||||
"invalidSharedCamera": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'una càmera desconeguda o no autoritzada."
|
||||
},
|
||||
"calendarFilter": {
|
||||
"last24Hours": "Últimes 24 hores"
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"title": "Confirmar la supressió",
|
||||
"desc": "Eliminant aquest objecte seguit borrarà l'snapshot, qualsevol embedding gravat, i qualsevol detall de seguiment. Les imatges gravades d'aquest objecte seguit en l'historial <em>NO</em> seràn eliminades.<br /><br />Estas segur que vols continuar?"
|
||||
"desc": "Suprimir aquest objecte rastrejat elimina la instantània, qualsevol incrustació desada, i qualsevol entrada de detalls de seguiment associada. Les imatges gravades d'aquest objecte seguit en l'historial <em>NO</em> seràn eliminades.<br /><br />Estas segur que vols continuar?"
|
||||
},
|
||||
"toast": {
|
||||
"error": "S'ha produït un error en suprimir aquest objecte rastrejat: {{errorMessage}}"
|
||||
@@ -289,7 +289,10 @@
|
||||
"zones": "Zones",
|
||||
"ratio": "Ràtio",
|
||||
"area": "Àrea",
|
||||
"score": "Puntuació"
|
||||
"score": "Puntuació",
|
||||
"computedScore": "Puntuació calculada",
|
||||
"topScore": "Puntuació superior",
|
||||
"toggleAdvancedScores": "Commuta les puntuacions avançades"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "Error al canviar el nom de l’exportació: {{errorMessage}}",
|
||||
"assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}"
|
||||
"assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}",
|
||||
"caseSaveFailed": "No s'ha pogut desar el cas: {{errorMessage}}",
|
||||
"caseDeleteFailed": "No s'ha pogut suprimir el cas: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
@@ -22,7 +24,8 @@
|
||||
"downloadVideo": "Baixa el vídeo",
|
||||
"editName": "Edita el nom",
|
||||
"deleteExport": "Suprimeix l'exportació",
|
||||
"assignToCase": "Afegeix al cas"
|
||||
"assignToCase": "Afegeix al cas",
|
||||
"removeFromCase": "Elimina del cas"
|
||||
},
|
||||
"headings": {
|
||||
"cases": "Casos",
|
||||
@@ -35,5 +38,91 @@
|
||||
"newCaseOption": "Crea un cas nou",
|
||||
"nameLabel": "Nom del cas",
|
||||
"descriptionLabel": "Descripció"
|
||||
},
|
||||
"toolbar": {
|
||||
"newCase": "Cas nou",
|
||||
"addExport": "Afegeix una exportació",
|
||||
"editCase": "Edita el cas",
|
||||
"deleteCase": "Suprimeix el cas"
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Suprimeix el cas",
|
||||
"desc": "Esteu segur que voleu suprimir {{caseName}}?",
|
||||
"descKeepExports": "Les exportacions continuaran estant disponibles com a exportacions sense categoria.",
|
||||
"descDeleteExports": "Totes les exportacions en aquest cas s'eliminaran permanentment.",
|
||||
"deleteExports": "Elimina també les exportacions"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "Encara no hi ha exportacions"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "Exportació de {{camera}}",
|
||||
"queued": "En cua",
|
||||
"running": "En execució",
|
||||
"preparing": "Preparant",
|
||||
"copying": "Copiant",
|
||||
"encoding": "Codificant",
|
||||
"encodingRetry": "Codificant (reintent)",
|
||||
"finalizing": "Finalitzant"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "Sense descripció",
|
||||
"createdAt": "{{value}} creat",
|
||||
"exportCount_one": "1 exportació",
|
||||
"exportCount_other": "{{count}} exportacions",
|
||||
"cameraCount_one": "1 càmera",
|
||||
"cameraCount_other": "{{count}} càmeres",
|
||||
"showMore": "Mostra'n més",
|
||||
"showLess": "Mostra menys",
|
||||
"emptyTitle": "Aquest cas és buit",
|
||||
"emptyDescription": "Afegeix les exportacions no categoritzades existents per mantenir el cas organitzat.",
|
||||
"emptyDescriptionNoExports": "Encara no hi ha exportacions sense categoria per afegir."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "Crea un cas",
|
||||
"editTitle": "Edita el cas",
|
||||
"namePlaceholder": "Nom del cas",
|
||||
"descriptionPlaceholder": "Afegeix notes o context per a aquest cas"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "Afegeix l'exportació a {{caseName}}",
|
||||
"searchPlaceholder": "Cerca exportacions sense categoria",
|
||||
"empty": "No hi ha exportacions sense categoria que coincideixin amb aquesta cerca.",
|
||||
"addButton_one": "Afegeix 1 exportació",
|
||||
"addButton_other": "Afegeix {{count}} exportacions",
|
||||
"adding": "S'està afegint..."
|
||||
},
|
||||
"selected_one": "{{count}} seleccionats",
|
||||
"selected_other": "{{count}} seleccionats",
|
||||
"bulkActions": {
|
||||
"addToCase": "Afegeix al cas",
|
||||
"moveToCase": "Mou al cas",
|
||||
"removeFromCase": "Elimina del cas",
|
||||
"delete": "Suprimeix",
|
||||
"deleteNow": "Suprimeix ara"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Suprimeix les exportacions",
|
||||
"desc_one": "Esteu segur que voleu suprimir {{count}} l'exportació?",
|
||||
"desc_other": "steu segur que voleu suprimir {{count}} exportacions?"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "Elimina del cas",
|
||||
"desc_one": "Voleu suprimir {{count}} d'aquest cas?",
|
||||
"desc_other": "Voleu eliminar {{count}} exportacions d'aquest cas?",
|
||||
"descKeepExports": "Les exportacions es mouran a sense categoria.",
|
||||
"descDeleteExports": "Les exportacions s'eliminaran permanentment.",
|
||||
"deleteExports": "Suprimeix les exportacions"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Exportacions suprimides amb èxit",
|
||||
"reassign": "Assignació de cas actualitzada amb èxit",
|
||||
"remove": "S'han eliminat les exportacions del cas"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "No s'han pogut suprimir les exportacions: {{errorMessage}}",
|
||||
"reassignFailed": "No s'ha pogut actualitzar l'assignació de cas: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"uploadFace": "Puja una imatge del rostre",
|
||||
"nextSteps": "Següents passos",
|
||||
"description": {
|
||||
"uploadFace": "Puja una imatge de {{name}} que mostri el seu rostre de cares. No cal que la imatge estigui retallada només al rostre."
|
||||
"uploadFace": "Pugeu una imatge de {{name}} que mostra la seva cara des d'un angle frontal. La imatge no necessita ser retallada a la seva cara."
|
||||
}
|
||||
},
|
||||
"selectFace": "Seleccionar rostre",
|
||||
|
||||
@@ -1280,7 +1280,8 @@
|
||||
},
|
||||
"hikvision": {
|
||||
"substreamWarning": "El substream 1 està bloquejat a una resolució baixa. Moltes càmeres Hikvision suporten subfluxos addicionals que han d'estar habilitats a la configuració de la càmera. Es recomana comprovar i utilitzar aquests corrents si estan disponibles."
|
||||
}
|
||||
},
|
||||
"resolutionUnknown": "La resolució d'aquest flux no s'ha pogut investigar. Heu d'establir manualment la resolució de detecció a Configuració o a la configuració."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1297,7 +1298,13 @@
|
||||
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
|
||||
"disableLabel": "Càmeres inhabilitades",
|
||||
"disableDesc": "Habilita una càmera que actualment no és visible a la interfície d'usuari i està desactivada a la configuració. Es requereix un reinici de Frigate després d'activar-la.",
|
||||
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis."
|
||||
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis.",
|
||||
"friendlyName": {
|
||||
"edit": "Edita el nom de la pantalla de la càmera",
|
||||
"title": "Edita el nom de la pantalla",
|
||||
"description": "Estableix el nom amigable que es mostra per a aquesta càmera a tota la interfície d'usuari de la Fragata. Deixeu-ho en blanc per utilitzar l'ID de la càmera.",
|
||||
"rename": "Canvia el nom"
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Afegeix una càmera",
|
||||
@@ -1659,7 +1666,16 @@
|
||||
"empty": "No hi ha etiquetes disponibles",
|
||||
"allNonAlertDetections": "Totes les activitats no alertes s'inclouran com a deteccions."
|
||||
},
|
||||
"addCustomLabel": "Afegeix una etiqueta personalitzada..."
|
||||
"addCustomLabel": "Afegeix una etiqueta personalitzada...",
|
||||
"genaiModel": {
|
||||
"placeholder": "Selecciona el model…",
|
||||
"search": "Cerca models…",
|
||||
"noModels": "No hi ha models disponibles"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "per exemple. Cotxe de la parella",
|
||||
"platePlaceholder": "Matricula o regex"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Configuració global",
|
||||
|
||||
@@ -64,20 +64,73 @@
|
||||
"toast": {
|
||||
"error": {
|
||||
"endTimeMustAfterStartTime": "Die Endzeit darf nicht vor der Startzeit liegen",
|
||||
"failed": "Fehler beim Starten des Exports: {{error}}",
|
||||
"failed": "Fehler beim Export in die Warteschlange: {{error}}",
|
||||
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
||||
},
|
||||
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite.",
|
||||
"view": "Ansicht"
|
||||
"view": "Ansicht",
|
||||
"queued": "Export in Warteschlange gestellt. Fortschritt auf der Exportseite verfolgen.",
|
||||
"batchSuccess_one": "1 Export gestartet. Öffne den Fall jetzt.",
|
||||
"batchSuccess_other": "{{count}} Exports gestartet. Öffne den Fall jetzt.",
|
||||
"batchPartial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||
"batchFailed": "Fehler beim Starten der {{total}} Exporte. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "1 Export in die Warteschlange gestellt. Fall wird jetzt geöffnet.",
|
||||
"batchQueuedSuccess_other": "{{count}} Exporte in der Warteschlange. Fall wird jetzt geöffnet.",
|
||||
"batchQueuedPartial": "{{successful}} von {{total}} Exporten in die Warteschlange gestellt. Fehlerhafte Kameras: {{failedCameras}}",
|
||||
"batchQueueFailed": "Fehler beim Einreihen von {{total}} Exporten in die Warteschlange. Fehlerhafte Kameras: {{failedCameras}}"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Export speichern",
|
||||
"previewExport": "Exportvorschau"
|
||||
"previewExport": "Exportvorschau",
|
||||
"queueingExport": "Export wird in die Warteschlange gestellt...",
|
||||
"useThisRange": "Nutzen Sie diesen Bereich"
|
||||
},
|
||||
"export": "Exportieren",
|
||||
"case": {
|
||||
"label": "Fall",
|
||||
"placeholder": "Einen Fall auswählen"
|
||||
"placeholder": "Einen Fall auswählen",
|
||||
"newCaseOption": "Einen neuen Fall erstellen",
|
||||
"newCaseNamePlaceholder": "Neuer Fallname",
|
||||
"newCaseDescriptionPlaceholder": "Fall Beschreibung",
|
||||
"nonAdminHelp": "Für diese Exporte wird ein neuer Fall angelegt."
|
||||
},
|
||||
"queueing": "Export wird in die Warteschlange gestellt...",
|
||||
"tabs": {
|
||||
"export": "Einzelne Kamera",
|
||||
"multiCamera": "Mehrere-Kameras"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Zeitbereich",
|
||||
"selectFromTimeline": "Wählen Sie aus der Zeitleiste aus",
|
||||
"cameraSelection": "Kameras",
|
||||
"cameraSelectionHelp": "Kameras, die in diesem Zeitbereich Objekte verfolgen, sind vorausgewählt",
|
||||
"checkingActivity": "Kameraaktivität wird überprüft...",
|
||||
"noCameras": "keine kamaeras verfügbar",
|
||||
"detectionCount_one": "1 verfolgtes Objekt",
|
||||
"detectionCount_other": "{{count}} verfolgtesObjekte",
|
||||
"nameLabel": "Export Name",
|
||||
"namePlaceholder": "Optionaler Basisname für diese Exporte",
|
||||
"queueingButton": "Exporte werden in die Warteschlange gestellt...",
|
||||
"exportButton_one": "Export 1 Kamera",
|
||||
"exportButton_other": "xport {{count}} Kameras"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "1 Bewertung exportieren",
|
||||
"title_other": "{{count}} Bewertung exportieren",
|
||||
"description": "Exportieren Sie jede ausgewählte Rezension. Alle Exporte werden in einem einzigen Fall zusammengefasst.",
|
||||
"descriptionNoCase": "Jede ausgewählte Bewertung exportieren.",
|
||||
"caseNamePlaceholder": "Export prüfen - {{date}}",
|
||||
"exportButton_one": "1 Bewertung exportieren",
|
||||
"exportButton_other": "{{count}} Bewertung exportieren",
|
||||
"exportingButton": "Exportieren...",
|
||||
"toast": {
|
||||
"started_one": "1 Export gestartet. Fall wird jetzt geöffnet.",
|
||||
"started_other": "{{count}} Exporte gestartet. Fall wird jetzt geöffnet.",
|
||||
"startedNoCase_one": "1 Export gestartet.",
|
||||
"startedNoCase_other": "{{count}} Exports gestartet.",
|
||||
"partial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagen: {{failedItems}}",
|
||||
"failed": "Fehler beim Starten der {{total}} Exporte. Fehler: {{failedItems}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
|
||||
@@ -1710,7 +1710,7 @@
|
||||
},
|
||||
"roles": {
|
||||
"label": "Rollen",
|
||||
"description": "GenAI-Rollen (Tools, Vision, Einbettungen); ein Anbieter pro Rolle."
|
||||
"description": "GenAI-Rollen (Nachrichten, Beschreibung, Einbettungen); ein Anbieter pro Rolle."
|
||||
},
|
||||
"provider_options": {
|
||||
"label": "Anbieter Optionen",
|
||||
|
||||
@@ -282,7 +282,10 @@
|
||||
"zones": "Zonen",
|
||||
"ratio": "Verhältnis",
|
||||
"area": "Bereich",
|
||||
"score": "Bewertung"
|
||||
"score": "Bewertung",
|
||||
"computedScore": "Berechnetes Ergebnis",
|
||||
"topScore": "Bester Treffer",
|
||||
"toggleAdvancedScores": "Erweiterte Ergebnisse umschalten"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "Umbenennen des Exports fehlgeschlagen: {{errorMessage}}",
|
||||
"assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}"
|
||||
"assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}",
|
||||
"caseSaveFailed": "Fehler beim speichern vom Fall: {{errorMessage}}",
|
||||
"caseDeleteFailed": "Fehler beim löschem vom Fall: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
@@ -22,7 +24,8 @@
|
||||
"downloadVideo": "Video herunterladen",
|
||||
"editName": "Name ändern",
|
||||
"deleteExport": "Export löschen",
|
||||
"assignToCase": "Hinzufügen zum Fall"
|
||||
"assignToCase": "Hinzufügen zum Fall",
|
||||
"removeFromCase": "Vom Gehäuse entfernen"
|
||||
},
|
||||
"headings": {
|
||||
"cases": "Fälle",
|
||||
@@ -35,5 +38,91 @@
|
||||
"newCaseOption": "Neuen Fall erstellen",
|
||||
"nameLabel": "Fallname",
|
||||
"descriptionLabel": "Beschreibung"
|
||||
},
|
||||
"toolbar": {
|
||||
"newCase": "Neuer Fall",
|
||||
"addExport": "Zum expotieren hinzufügen",
|
||||
"editCase": "Fall bearbeiten",
|
||||
"deleteCase": "Fall löschen"
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Fall löschen",
|
||||
"desc": "Sind sie sich sicher löschen von{{caseName}}?",
|
||||
"descKeepExports": "Exporte bleiben als nicht kategorisierte Exporte verfügbar.",
|
||||
"descDeleteExports": "Alle Exporte werden in diesem Fall endgültig gelöscht.",
|
||||
"deleteExports": "Exporte auch löschen"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "Noch keine Exporte"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "{{camera}} export",
|
||||
"queued": "In der Warteschlange",
|
||||
"running": "läuft",
|
||||
"preparing": "Vorbereitung",
|
||||
"copying": "kopieren",
|
||||
"encoding": "Codierung",
|
||||
"encodingRetry": "Kodierung (Wiederholung)",
|
||||
"finalizing": "Abschließen"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "keine Beschreibung",
|
||||
"createdAt": "Erstellt {{value}}",
|
||||
"exportCount_one": "1 Export",
|
||||
"exportCount_other": "{{count}} Exports",
|
||||
"cameraCount_one": "1 Kamera",
|
||||
"cameraCount_other": "{{count}} Kameras",
|
||||
"showMore": "Mehr anzeigen",
|
||||
"showLess": "Weniger Anzeigen",
|
||||
"emptyTitle": "Der Fall ist leer",
|
||||
"emptyDescription": "Fügen Sie vorhandene, nicht kategorisierte Exporte hinzu, um den Fall übersichtlich zu halten.",
|
||||
"emptyDescriptionNoExports": "Es sind noch keine nicht kategorisierten Exporte zum Hinzufügen verfügbar."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "Fall erstellen",
|
||||
"editTitle": "Fall bearbeiten",
|
||||
"namePlaceholder": "Fall Name",
|
||||
"descriptionPlaceholder": "Fügen Sie Anmerkungen oder Kontext zu diesem Fall hinzu"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "Export zum {{caseName}} hinzufügen",
|
||||
"searchPlaceholder": "Suche nach nicht kategorisierten Exporten",
|
||||
"empty": "Es wurden keine nicht kategorisierten Exporte gefunden, die dieser Suche entsprechen.",
|
||||
"addButton_one": "1 Export hinzufügen",
|
||||
"addButton_other": "Fügen Sie {{count}} Exporte hinzu",
|
||||
"adding": "Hinzufügen..."
|
||||
},
|
||||
"selected_one": "{{count}} ausgewählt",
|
||||
"selected_other": "{{count}} ausgewählt",
|
||||
"bulkActions": {
|
||||
"addToCase": "Zum Fall hinzufügen",
|
||||
"moveToCase": "Zum Fall wechseln",
|
||||
"removeFromCase": "Aus dem Fall nehmen",
|
||||
"delete": "löschen",
|
||||
"deleteNow": "jetzt löschen"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Exporte löschen",
|
||||
"desc_one": "Möchten Sie den Export {{count}} wirklich löschen?",
|
||||
"desc_other": "Möchten Sie wirklich {{count}} Exporte löschen?"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "Aus dem Fall nehmen",
|
||||
"desc_one": "{{count}}-Export aus diesem Fall entfernen?",
|
||||
"desc_other": "{{count}} Exporte aus diesem Fall entfernen?",
|
||||
"descKeepExports": "Die Exporte werden in die Kategorie „Nicht kategorisiert“ verschoben.",
|
||||
"descDeleteExports": "Exporte werden endgültig gelöscht.",
|
||||
"deleteExports": "Löschen Sie stattdessen Exporte"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Exporte erfolgreich gelöscht",
|
||||
"reassign": "Fallzuweisung erfolgreich aktualisiert",
|
||||
"remove": "Exporte erfolgreich aus dem Fall entfernt"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "Fehler beim Löschen der Exporte: {{errorMessage}}",
|
||||
"reassignFailed": "Fehler beim Aktualisieren der Fallzuordnung: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,10 @@
|
||||
"zone": "Zone",
|
||||
"motion_mask": "Bewegungsmaske",
|
||||
"object_mask": "Objektmaske"
|
||||
},
|
||||
"revertOverride": {
|
||||
"title": "Basis Konfiguration wiederherstellen",
|
||||
"desc": "Dadurch wird die Profilüberschreibung für {{type}}<em>{{name}}</em> aufgehoben und die Grundkonfiguration wiederhergestellt."
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -507,7 +511,8 @@
|
||||
"title": "Aktiviert",
|
||||
"description": "Ob diese Maske in der Konfigurationsdatei aktiviert ist. Ist sie deaktiviert, kann sie nicht über MQTT aktiviert werden. Deaktivierte Masken werden zur Laufzeit ignoriert."
|
||||
}
|
||||
}
|
||||
},
|
||||
"addDisabledProfile": "Fügen Sie es zuerst der Basiskonfiguration hinzu und überschreiben Sie es dann im Profil"
|
||||
},
|
||||
"debug": {
|
||||
"objectShapeFilterDrawing": {
|
||||
@@ -1647,7 +1652,8 @@
|
||||
"keyDuplicate": "Der Name des Detektors ist bereits vorhanden.",
|
||||
"noSchema": "Es sind keine Detektorschemata verfügbar.",
|
||||
"none": "Es sind keine Detektorinstanzen konfiguriert.",
|
||||
"add": "Detektor hinzufügen"
|
||||
"add": "Detektor hinzufügen",
|
||||
"addCustomKey": "Benutzerdefinierten Schlüssel hinzufügen"
|
||||
},
|
||||
"record": {
|
||||
"title": "Aufnahmeeinstellungen"
|
||||
@@ -1718,7 +1724,16 @@
|
||||
"title": "Einstellungen für Zeitstempel"
|
||||
},
|
||||
"searchPlaceholder": "Suche...",
|
||||
"addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen..."
|
||||
"addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen...",
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "z.B. das Auto der Frau",
|
||||
"platePlaceholder": "Kennzeichen oder regulärer Ausdruck"
|
||||
},
|
||||
"genaiModel": {
|
||||
"placeholder": "Modell auswählen…",
|
||||
"search": "Modell suchen…",
|
||||
"noModels": "Keine Modelle verfügbar"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Globale Konfiguration",
|
||||
@@ -1876,6 +1891,10 @@
|
||||
},
|
||||
"snapshots": {
|
||||
"detectDisabled": "Die Objekterkennung ist deaktiviert. Es werden keine Momentaufnahmen von verfolgten Objekten erstellt."
|
||||
},
|
||||
"detectors": {
|
||||
"mixedTypes": "Alle Detektoren müssen von gleichem Typ sein, Entferne bestehende Detektoren um einen anderen Typ zu benutzen.",
|
||||
"mixedTypesSuggestion": "Alle Detektoren müssen vom gleichem Typ sein. Entferne bestehende oder wähle {{type}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
{}
|
||||
{
|
||||
"version": {
|
||||
"label": "Τρέχουσα έκδοση διαμόρφωσης"
|
||||
},
|
||||
"safe_mode": {
|
||||
"label": "Ασφαλής λειτουργία"
|
||||
},
|
||||
"auth": {
|
||||
"reset_admin_password": {
|
||||
"label": "Επανέφερε κωδικού πρόσβασης για τον διαχειριστή admin",
|
||||
"description": "Άμα είναι αλήθεια, επαναφέρει τον κωδικό πρόσβασης του χρήστη διαχειριστή(admin) κατά την εκκίνηση και εκτύπωση του νέου κωδικού πρόσβασης στα αρχείο καταγραφής(logs)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
"export": "Export",
|
||||
"actions": "Actions",
|
||||
"uiPlayground": "UI Playground",
|
||||
"features": "Features",
|
||||
"faceLibrary": "Face Library",
|
||||
"classification": "Classification",
|
||||
"chat": "Chat",
|
||||
|
||||
@@ -415,7 +415,7 @@
|
||||
"audioCodecGood": "Audio codec is {{codec}}.",
|
||||
"resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.",
|
||||
"resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.",
|
||||
"resolutionUnknown": "The resolution of this stream could not be probed. This will cause issues on startup. You should manually set the detect resolution in Settings or your config.",
|
||||
"resolutionUnknown": "The resolution of this stream could not be probed. You should manually set the detect resolution in Settings or your config.",
|
||||
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
|
||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||
@@ -457,7 +457,13 @@
|
||||
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
|
||||
"disableLabel": "Disabled cameras",
|
||||
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
|
||||
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes."
|
||||
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
|
||||
"friendlyName": {
|
||||
"edit": "Edit camera display name",
|
||||
"title": "Edit Display Name",
|
||||
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
|
||||
"rename": "Rename"
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Add Camera",
|
||||
|
||||
@@ -195,7 +195,8 @@
|
||||
"explore": "Explorar",
|
||||
"uiPlayground": "Zona de pruebas de la interfaz de usuario",
|
||||
"faceLibrary": "Biblioteca de rostros",
|
||||
"classification": "Clasificación"
|
||||
"classification": "Clasificación",
|
||||
"profiles": "Perfiles"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
|
||||
@@ -77,7 +77,11 @@
|
||||
"saveExport": "Guardar exportación",
|
||||
"previewExport": "Vista previa de la exportación"
|
||||
},
|
||||
"selectOrExport": "Seleccionar o exportar"
|
||||
"selectOrExport": "Seleccionar o exportar",
|
||||
"case": {
|
||||
"label": "Caso",
|
||||
"newCaseDescriptionPlaceholder": "Descripción de caso"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"restreaming": {
|
||||
@@ -124,6 +128,9 @@
|
||||
"markAsReviewed": "Marcar como revisado",
|
||||
"deleteNow": "Eliminar ahora",
|
||||
"markAsUnreviewed": "Marcar como no revisado"
|
||||
},
|
||||
"shareTimestamp": {
|
||||
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara."
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"noPreviewFoundFor": "No se encontró vista previa para {{cameraName}}",
|
||||
"submitFrigatePlus": {
|
||||
"submit": "Enviar",
|
||||
"title": "¿Enviar este fotograma a Frigate+?"
|
||||
"title": "¿Enviar este fotograma a Frigate+?",
|
||||
"previewError": "No se pudo cargar la vista previa de la instantánea. Es posible que la grabación no esté disponible en este momento."
|
||||
},
|
||||
"streamOffline": {
|
||||
"desc": "No se han recibido fotogramas en la transmisión <code>detect</code> de {{cameraName}}, revisa los registros de errores",
|
||||
|
||||
@@ -42,10 +42,32 @@
|
||||
"label": "Nombre descriptivo",
|
||||
"description": "Nombre descriptivo de la cámara utilizado en la interfaz de usuario de Frigate"
|
||||
},
|
||||
"label": "Configuración de Cámara",
|
||||
"label": "Configuración de cámara",
|
||||
"onvif": {
|
||||
"profile": {
|
||||
"label": "Perfil ONVIF"
|
||||
"label": "Perfil ONVIF",
|
||||
"description": "Perfil multimedia ONVIF específico que se utilizará para el control PTZ, identificado por token o nombre. Si no se especifica, se selecciona automáticamente el primer perfil con una configuración PTZ válida."
|
||||
},
|
||||
"autotracking": {
|
||||
"zoom_factor": {
|
||||
"description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75."
|
||||
},
|
||||
"calibrate_on_startup": {
|
||||
"description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración."
|
||||
},
|
||||
"description": "Realice un seguimiento automático de objetos en movimiento y manténgalos centrados en el encuadre mediante movimientos de cámara PTZ.",
|
||||
"zooming": {
|
||||
"description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos)."
|
||||
},
|
||||
"return_preset": {
|
||||
"description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento."
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida."
|
||||
}
|
||||
},
|
||||
"tls_insecure": {
|
||||
"description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras)."
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
@@ -83,7 +105,24 @@
|
||||
"max_area": {
|
||||
"description": "Área máxima del cuadro delimitador (píxeles o porcentaje) permitida para este tipo de objeto. Puede expresarse en píxeles (entero) o como porcentaje (decimal entre 0,000001 y 0,99).",
|
||||
"label": "Área máxima del objeto"
|
||||
}
|
||||
},
|
||||
"description": "Filtros para aplicar a los objetos dentro de esta zona. Se utilizan para reducir los falsos positivos o restringir qué objetos se consideran presentes en la zona."
|
||||
},
|
||||
"objects": {
|
||||
"description": "Lista de tipos de objetos (del mapa de etiquetas) que pueden activar esta zona. Puede ser una cadena de texto o una lista de cadenas. Si está vacío, se consideran todos los objetos."
|
||||
},
|
||||
"description": "Las zonas le permiten definir un área específica del fotograma, de modo que pueda determinar si un objeto se encuentra o no dentro de un área determinada.",
|
||||
"speed_threshold": {
|
||||
"description": "Velocidad mínima (en unidades del mundo real, si se han configurado distancias) requerida para que un objeto se considere presente en la zona. Se utiliza para los disparadores de zona basados en la velocidad."
|
||||
},
|
||||
"friendly_name": {
|
||||
"description": "Un nombre fácil de usar para la zona, que se muestra en la interfaz de usuario de Frigate. Si no se especifica, se utilizará una versión formateada del nombre de la zona."
|
||||
},
|
||||
"inertia": {
|
||||
"description": "Número de fotogramas consecutivos en los que se debe detectar un objeto dentro de la zona antes de considerarlo presente. Ayuda a filtrar las detecciones transitorias."
|
||||
},
|
||||
"loitering_time": {
|
||||
"description": "Número de segundos que un objeto debe permanecer en la zona para ser considerado como merodeo. Establezca en 0 para desactivar la detección de merodeo."
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
@@ -100,7 +139,151 @@
|
||||
"use_snapshot": {
|
||||
"label": "Usar instantáneas",
|
||||
"description": "Usar instantáneas de objetos en lugar de miniaturas para la generación de descripciones de GenAI."
|
||||
},
|
||||
"send_triggers": {
|
||||
"after_significant_updates": {
|
||||
"description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado."
|
||||
},
|
||||
"description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.)."
|
||||
},
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mqtt": {
|
||||
"label": "MQTT",
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"email": {
|
||||
"label": "Email de notificacion"
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"description": "Configuración para la transcripción de audio en vivo y de voz, utilizada para eventos y subtítulos en tiempo real.",
|
||||
"enabled": {
|
||||
"label": "Habilitar transcripción"
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"skip_motion_threshold": {
|
||||
"description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función."
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento."
|
||||
},
|
||||
"threshold": {
|
||||
"description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255)."
|
||||
}
|
||||
},
|
||||
"lpr": {
|
||||
"enhancement": {
|
||||
"description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución."
|
||||
},
|
||||
"expire_time": {
|
||||
"description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas)."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"fps": {
|
||||
"description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez)."
|
||||
},
|
||||
"min_initialized": {
|
||||
"description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2."
|
||||
},
|
||||
"height": {
|
||||
"description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
|
||||
},
|
||||
"width": {
|
||||
"description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
|
||||
},
|
||||
"stationary": {
|
||||
"description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo."
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"motion": {
|
||||
"description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
|
||||
},
|
||||
"continuous": {
|
||||
"description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
|
||||
},
|
||||
"detections": {
|
||||
"pre_capture": {
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
|
||||
},
|
||||
"post_capture": {
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"pre_capture": {
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
|
||||
},
|
||||
"post_capture": {
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"dashboard": {
|
||||
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz."
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"height": {
|
||||
"description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección."
|
||||
},
|
||||
"description": "Configuraciones utilizadas por la interfaz web para controlar la selección, la resolución y la calidad de transmisiónes en vivo."
|
||||
},
|
||||
"review": {
|
||||
"description": "Configuraciones que controlan las alertas, las detecciones y los resúmenes de revisión de GenAI utilizados por la interfaz de usuario y el almacenamiento de esta cámara.",
|
||||
"alerts": {
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona."
|
||||
},
|
||||
"labels": {
|
||||
"description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person)."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona."
|
||||
},
|
||||
"description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones."
|
||||
},
|
||||
"genai": {
|
||||
"image_source": {
|
||||
"description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens."
|
||||
},
|
||||
"additional_concerns": {
|
||||
"description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara."
|
||||
},
|
||||
"activity_context_prompt": {
|
||||
"description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI."
|
||||
},
|
||||
"description": "Controla el uso de IA generativa (GenAI) para la elaboración de descripciones y resúmenes de elementos de revisión.",
|
||||
"debug_save_thumbnails": {
|
||||
"description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión."
|
||||
}
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
"description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"retry_interval": {
|
||||
"description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10."
|
||||
},
|
||||
"path": {
|
||||
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\")."
|
||||
},
|
||||
"output_args": {
|
||||
"description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación."
|
||||
},
|
||||
"description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,45 @@
|
||||
"cookie_secure": {
|
||||
"label": "Flag de cookie segura",
|
||||
"description": "Establece el flag de seguridad en la cookie de autenticación; debe ser 'true' cuando se utilice TLS."
|
||||
},
|
||||
"failed_login_rate_limit": {
|
||||
"label": "Limite de intento de acceso fallidos"
|
||||
},
|
||||
"session_length": {
|
||||
"description": "Duración de la sesión en segundos para sesiones de JWT."
|
||||
},
|
||||
"admin_first_time_login": {
|
||||
"description": "Cuando se establece en true, la interfaz de usuario puede mostrar un enlace de ayuda en la página de inicio de sesión, informando a los usuarios sobre cómo iniciar sesión tras el restablecimiento de la contraseña de administrador. "
|
||||
},
|
||||
"refresh_time": {
|
||||
"description": "Cuando a una sesión le queden menos de esta cantidad de segundos para expirar, actualícela para restablecer su duración completa."
|
||||
}
|
||||
},
|
||||
"onvif": {
|
||||
"profile": {
|
||||
"label": "Perfil ONVIF"
|
||||
"label": "Perfil ONVIF",
|
||||
"description": "Perfil multimedia ONVIF específico que se utilizará para el control PTZ, identificado por token o nombre. Si no se especifica, se selecciona automáticamente el primer perfil con una configuración PTZ válida."
|
||||
},
|
||||
"autotracking": {
|
||||
"zoom_factor": {
|
||||
"description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75."
|
||||
},
|
||||
"calibrate_on_startup": {
|
||||
"description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración."
|
||||
},
|
||||
"description": "Realice un seguimiento automático de objetos en movimiento y manténgalos centrados en el encuadre mediante movimientos de cámara PTZ.",
|
||||
"zooming": {
|
||||
"description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos)."
|
||||
},
|
||||
"return_preset": {
|
||||
"description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento."
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida."
|
||||
}
|
||||
},
|
||||
"tls_insecure": {
|
||||
"description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras)."
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
@@ -91,7 +125,19 @@
|
||||
"use_snapshot": {
|
||||
"label": "Usar instantáneas",
|
||||
"description": "Usar instantáneas de objetos en lugar de miniaturas para la generación de descripciones de GenAI."
|
||||
},
|
||||
"send_triggers": {
|
||||
"after_significant_updates": {
|
||||
"description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado."
|
||||
},
|
||||
"description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.)."
|
||||
},
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI."
|
||||
}
|
||||
},
|
||||
"track": {
|
||||
"description": "Lista de etiquetas de objetos a rastrear para todas las cámaras; puede anularse por cámara."
|
||||
}
|
||||
},
|
||||
"detectors": {
|
||||
@@ -107,6 +153,310 @@
|
||||
"api_key": {
|
||||
"label": "Clave de API de DeepStack (si es necesaria)"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"label": "Tipo"
|
||||
},
|
||||
"label": "Detector de hardware",
|
||||
"cpu": {
|
||||
"label": "CPU",
|
||||
"num_threads": {
|
||||
"label": "Número de hilos para detección"
|
||||
},
|
||||
"description": "Detector TFLite de CPU que ejecuta modelos de TensorFlow Lite en la CPU del host sin aceleración por hardware. No recomendado."
|
||||
},
|
||||
"axengine": {
|
||||
"label": "Motor AX NPU"
|
||||
},
|
||||
"teflon_tfl": {
|
||||
"description": "Detector de delegados Teflon para TFLite, que utiliza la biblioteca de delegados Mesa Teflon para acelerar la inferencia en las GPU compatibles."
|
||||
},
|
||||
"synaptics": {
|
||||
"description": "Detector NPU de Synaptics para modelos en formato .synap, utilizando el Synap SDK en hardware de Synaptics."
|
||||
},
|
||||
"zmq": {
|
||||
"description": "Detector ZMQ IPC que descarga la inferencia a un proceso externo a través de un punto de conexión IPC de ZeroMQ."
|
||||
},
|
||||
"hailo8l": {
|
||||
"description": "Detector Hailo-8/Hailo-8L que utiliza modelos HEF y el SDK HailoRT para la inferencia en hardware Hailo."
|
||||
},
|
||||
"onnx": {
|
||||
"description": "Detector ONNX para ejecutar modelos ONNX; utilizará los backends de aceleración disponibles (CUDA/ROCm/OpenVINO) cuando estén disponibles."
|
||||
},
|
||||
"description": "Configuración para detectores de objetos (backends de CPU, GPU y ONNX) y cualquier ajuste del modelo específico del detector.",
|
||||
"openvino": {
|
||||
"description": "Detector OpenVINO para CPU AMD e Intel, GPU Intel y hardware VPU Intel."
|
||||
},
|
||||
"tensorrt": {
|
||||
"description": "Detector TensorRT para dispositivos Nvidia Jetson que utiliza motores TensorRT serializados para una inferencia acelerada."
|
||||
},
|
||||
"degirum": {
|
||||
"description": "Detector DeGirum para ejecutar modelos a través de la nube de DeGirum o servicios de inferencia local."
|
||||
},
|
||||
"rknn": {
|
||||
"description": "Detector RKNN para NPUs de Rockchip; ejecuta modelos compilados para RKNN en hardware de Rockchip."
|
||||
}
|
||||
},
|
||||
"database": {
|
||||
"label": "Base de datos",
|
||||
"description": "Configuración de la base de datos SQLite utilizada por Frigate para almacenar los metadatos de los objetos rastreados y las grabaciones."
|
||||
},
|
||||
"mqtt": {
|
||||
"label": "MQTT",
|
||||
"port": {
|
||||
"label": "Puerto MQTT"
|
||||
},
|
||||
"tls_client_cert": {
|
||||
"label": "Certificado cliente"
|
||||
},
|
||||
"description": "Configuración para conectar y publicar telemetría, instantáneas y detalles de eventos en un broker MQTT.",
|
||||
"topic_prefix": {
|
||||
"description": "Prefijo del tema MQTT para todos los temas de Frigate; debe ser único si se ejecutan múltiples instancias."
|
||||
},
|
||||
"client_id": {
|
||||
"description": "Identificador de cliente utilizado al conectarse al broker MQTT; debe ser único para cada instancia."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"email": {
|
||||
"label": "Email de notificacion"
|
||||
}
|
||||
},
|
||||
"networking": {
|
||||
"ipv6": {
|
||||
"label": "Configuración IPV6"
|
||||
},
|
||||
"listen": {
|
||||
"internal": {
|
||||
"label": "Puerto interno"
|
||||
},
|
||||
"external": {
|
||||
"label": "Puerto externo",
|
||||
"description": "Puerto externo de escucha para Frigate (por defecto 8791)."
|
||||
},
|
||||
"description": "Configuración de los puertos de escucha internos y externos. Esto es para usuarios avanzados. Para la mayoría de los casos de uso, se recomienda modificar la sección de puertos de su configuración de Docker Compose."
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"label": "Proxy",
|
||||
"separator": {
|
||||
"label": "Carácter de separación"
|
||||
},
|
||||
"default_role": {
|
||||
"description": "Rol predeterminado asignado a los usuarios autenticados por proxy cuando no se aplica ningún mapeo de roles (administrador o espectador)."
|
||||
},
|
||||
"description": "Configuración para integrar Frigate detrás de un proxy inverso que transmite encabezados de usuario autenticados.",
|
||||
"header_map": {
|
||||
"description": "Mapear los encabezados de proxy entrantes a los campos de usuario y rol de Frigate para la autenticación basada en proxy.",
|
||||
"role": {
|
||||
"description": "Encabezado que contiene el rol o los grupos del usuario autenticado provenientes del proxy ascendente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
"label": "Telemetria",
|
||||
"stats": {
|
||||
"intel_gpu_stats": {
|
||||
"label": "Estadísticas GPU Intel",
|
||||
"description": "Habilitar la recopilación de estadísticas de la GPU Intel si hay una GPU Intel presente."
|
||||
},
|
||||
"network_bandwidth": {
|
||||
"label": "Ancho de banda"
|
||||
},
|
||||
"amd_gpu_stats": {
|
||||
"label": "Estadísticas GPU Amd",
|
||||
"description": "Habilitar la recopilación de estadísticas de la GPU AMD si hay una GPU AMD presente."
|
||||
},
|
||||
"intel_gpu_device": {
|
||||
"description": "Identificador de dispositivo utilizado al tratar las GPU Intel como SR-IOV para corregir las estadísticas de la GPU."
|
||||
}
|
||||
},
|
||||
"version_check": {
|
||||
"description": "Habilite una verificación saliente para detectar si hay disponible una versión más reciente de Frigate."
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"timezone": {
|
||||
"label": "Uso horario",
|
||||
"description": "Zona horaria opcional que se mostrará en la interfaz de usuario (si no se especifica, se utilizará la hora local del navegador)."
|
||||
},
|
||||
"unit_system": {
|
||||
"label": "Unidad de sistema",
|
||||
"description": "Sistema de unidades para la visualización (métrico o imperial) utilizado en la interfaz de usuario y en MQTT."
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"description": "Configuración para la transcripción de audio en vivo y de voz, utilizada para eventos y subtítulos en tiempo real.",
|
||||
"language": {
|
||||
"description": "Código de idioma utilizado para la transcripción/traducción (por ejemplo, 'es' para Español). Consulte https://whisper-api.com/docs/languages/ para ver los códigos de idioma compatibles."
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Habilitar o deshabilitar la transcripción automática de audio para todas las cámaras; puede anularse por cámara."
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"skip_motion_threshold": {
|
||||
"description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función."
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento."
|
||||
},
|
||||
"threshold": {
|
||||
"description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255)."
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Habilitar o deshabilitar la detección de movimiento para todas las cámaras; puede anularse para cada cámara individualmente."
|
||||
}
|
||||
},
|
||||
"lpr": {
|
||||
"enhancement": {
|
||||
"description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución."
|
||||
},
|
||||
"expire_time": {
|
||||
"description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas)."
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Habilitar o deshabilitar el reconocimiento de matrículas para todas las cámaras; puede anularse por cámara."
|
||||
},
|
||||
"min_plate_length": {
|
||||
"description": "Número mínimo de caracteres que debe contener una matrícula reconocida para ser considerada válida."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"fps": {
|
||||
"description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez)."
|
||||
},
|
||||
"min_initialized": {
|
||||
"description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2."
|
||||
},
|
||||
"height": {
|
||||
"description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
|
||||
},
|
||||
"width": {
|
||||
"description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión."
|
||||
},
|
||||
"stationary": {
|
||||
"description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo."
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Habilitar o deshabilitar la detección de objetos para todas las cámaras; puede anularse para cada cámara individualmente."
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"motion": {
|
||||
"description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
|
||||
},
|
||||
"continuous": {
|
||||
"description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones."
|
||||
},
|
||||
"detections": {
|
||||
"pre_capture": {
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
|
||||
},
|
||||
"post_capture": {
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"pre_capture": {
|
||||
"description": "Número de segundos antes del evento de detección que se incluirán en la grabación."
|
||||
},
|
||||
"post_capture": {
|
||||
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
|
||||
}
|
||||
}
|
||||
},
|
||||
"camera_ui": {
|
||||
"dashboard": {
|
||||
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz."
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"description": "Configuración para controlar la resolución y la calidad de la transmisión en vivo de jsmpeg. Esto no afecta a las cámaras retransmitidas que utilizan go2rtc para la visualización en vivo.",
|
||||
"height": {
|
||||
"description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección."
|
||||
}
|
||||
},
|
||||
"semantic_search": {
|
||||
"model": {
|
||||
"description": "El modelo de embeddings a utilizar para la búsqueda semántica (por ejemplo, 'jinav1'), o el nombre de un proveedor de GenAI con el rol de embeddings."
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"alerts": {
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona."
|
||||
},
|
||||
"labels": {
|
||||
"description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person)."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona."
|
||||
},
|
||||
"description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones."
|
||||
},
|
||||
"genai": {
|
||||
"image_source": {
|
||||
"description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens."
|
||||
},
|
||||
"additional_concerns": {
|
||||
"description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara."
|
||||
},
|
||||
"activity_context_prompt": {
|
||||
"description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI."
|
||||
},
|
||||
"description": "Controla el uso de IA generativa (GenAI) para la elaboración de descripciones y resúmenes de elementos de revisión.",
|
||||
"debug_save_thumbnails": {
|
||||
"description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión."
|
||||
}
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
"description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista.",
|
||||
"restream": {
|
||||
"description": "Retransmita la salida de video de Birdseye como una transmisión en vivo RTSP; al habilitar esta opción, Birdseye se mantendrá en ejecución de forma continua."
|
||||
},
|
||||
"layout": {
|
||||
"max_cameras": {
|
||||
"description": "Número máximo de cámaras a mostrar simultáneamente en Birdseye; muestra las cámaras más recientes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"retry_interval": {
|
||||
"description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10."
|
||||
},
|
||||
"path": {
|
||||
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\")."
|
||||
},
|
||||
"output_args": {
|
||||
"description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación."
|
||||
},
|
||||
"description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol."
|
||||
},
|
||||
"go2rtc": {
|
||||
"description": "Configuración del servicio integrado de retransmisión go2rtc, utilizado para el relevo y la traducción de transmisiones en vivo."
|
||||
},
|
||||
"genai": {
|
||||
"description": "Configuración para los proveedores integrados de IA generativa (GenAI) utilizados para generar descripciones de objetos y resúmenes de reseñas.",
|
||||
"api_key": {
|
||||
"description": "Clave de API requerida por algunos proveedores (también puede configurarse mediante variables de entorno)."
|
||||
},
|
||||
"base_url": {
|
||||
"description": "URL base para proveedores autoalojados o compatibles (por ejemplo, una instancia de Ollama)."
|
||||
},
|
||||
"model": {
|
||||
"description": "El modelo del proveedor que se utilizará para generar descripciones o resúmenes."
|
||||
}
|
||||
},
|
||||
"face_recognition": {
|
||||
"description": "Configuración para la detección y el reconocimiento facial en todas las cámaras; puede anularse por cámara."
|
||||
},
|
||||
"camera_mqtt": {
|
||||
"required_zones": {
|
||||
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@
|
||||
"global": {
|
||||
"retention": "Retención global",
|
||||
"events": "Eventos globales"
|
||||
},
|
||||
"cameras": {
|
||||
"events": "Evento",
|
||||
"retention": "Retención"
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"cameras": {
|
||||
"cameraFfmpeg": "Argumentos de FFmpeg específicos de la cámara"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"rolesUnique": "Cada rol solo puede asignarse a un flujo de entrada.",
|
||||
"detectRequired": "Al menos un flujo de entrada debe tener asignado el rol 'detect'."
|
||||
"detectRequired": "Al menos un flujo de entrada debe tener asignado el rol 'detect'.",
|
||||
"hwaccelDetectOnly": "Solo el flujo de entrada con la función \"detect\" puede definir argumentos de aceleración por hardware."
|
||||
}
|
||||
},
|
||||
"anyOf": "Debe coincidir con al menos uno de los esquemas permitidos",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"carrot": "Zanahoria",
|
||||
"hot_dog": "Perrito caliente",
|
||||
"pizza": "Pizza",
|
||||
"donut": "Donut",
|
||||
"donut": "Rosquilla",
|
||||
"chair": "Silla",
|
||||
"couch": "Sofá",
|
||||
"potted_plant": "Planta en maceta",
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory_one": "Clase Borrada",
|
||||
"deletedCategory_many": "",
|
||||
"deletedCategory_other": "",
|
||||
"deletedImage_one": "Imágenes Borradas",
|
||||
"deletedImage_many": "",
|
||||
"deletedImage_other": "",
|
||||
"deletedCategory_one": "Se eliminó {{count}} clase",
|
||||
"deletedCategory_many": "Se eliminaron {{count}} clases",
|
||||
"deletedCategory_other": "Se eliminaron {{count}} clases",
|
||||
"deletedImage_one": "Se eliminó {{count}} imagen",
|
||||
"deletedImage_many": "Se eliminaron {{count}} imágenes",
|
||||
"deletedImage_other": "Se eliminaron {{count}} imágenes",
|
||||
"deletedModel_one": "Borrado con éxito {{count}} modelo",
|
||||
"deletedModel_many": "Borrados con éxito {{count}} modelos",
|
||||
"deletedModel_other": "Borrados con éxito {{count}} modelos",
|
||||
@@ -68,7 +68,7 @@
|
||||
"details": {
|
||||
"scoreInfo": "La puntuación representa la confianza media de clasificación en todas las detecciones de este objeto.",
|
||||
"unknown": "Desconocido",
|
||||
"none": "Ninguna"
|
||||
"none": "Ninguno"
|
||||
},
|
||||
"categorizeImage": "Clasificar Imagen",
|
||||
"menu": {
|
||||
|
||||
@@ -22,17 +22,26 @@
|
||||
"downloadVideo": "Descargar video",
|
||||
"editName": "Editar nombre",
|
||||
"deleteExport": "Eliminar exportación",
|
||||
"assignToCase": "Añadir al caso"
|
||||
"assignToCase": "Añadir al caso",
|
||||
"removeFromCase": "Remover del contenedor"
|
||||
},
|
||||
"headings": {
|
||||
"cases": "Casos",
|
||||
"uncategorizedExports": "Exportaciones sin categorizar"
|
||||
"uncategorizedExports": "Exportaciones sin Categorizar"
|
||||
},
|
||||
"caseDialog": {
|
||||
"title": "Añadir al caso",
|
||||
"newCaseOption": "Crear nuevo caso",
|
||||
"nameLabel": "Nombre del caso",
|
||||
"description": "Elige un caso existente o crea uno nuevo.",
|
||||
"selectLabel": "Caso"
|
||||
"selectLabel": "Caso",
|
||||
"descriptionLabel": "Descripción"
|
||||
},
|
||||
"toolbar": {
|
||||
"addExport": "Añadir Exportación"
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Eliminar caso",
|
||||
"desc": "¿Estás seguro de que quieres eliminar {{caseName}}?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"label": "Haz clic en el marco para centrar la cámara",
|
||||
"enable": "Habilitar clic para mover",
|
||||
"disable": "Deshabilitar clic para mover",
|
||||
"enableWithZoom": "Activar clic para mover / arrastrar para hacer zoom"
|
||||
"enableWithZoom": "Habilitar clic para mover / arrastrar para aumentar"
|
||||
},
|
||||
"up": {
|
||||
"label": "Mover la cámara PTZ hacia arriba"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"menu": {
|
||||
"cameras": "Configuración de Cámara",
|
||||
"debug": "Depuración",
|
||||
"ui": "Interfaz de usuario",
|
||||
"ui": "Interfaz de Usuario",
|
||||
"classification": "Clasificación",
|
||||
"motionTuner": "Ajuste de movimiento",
|
||||
"masksAndZones": "Máscaras / Zonas",
|
||||
@@ -35,7 +35,22 @@
|
||||
"cameraReview": "Revisar",
|
||||
"general": "General",
|
||||
"globalConfig": "Configuración Global",
|
||||
"system": "Sistema"
|
||||
"system": "Sistema",
|
||||
"integrations": "Integraciones",
|
||||
"uiSettings": "Configuración de Interfaz de Usuario",
|
||||
"profiles": "Perfiles",
|
||||
"globalDetect": "Detección de Objetos",
|
||||
"globalRecording": "Grabación",
|
||||
"globalSnapshots": "Instantáneas",
|
||||
"globalFfmpeg": "FFmpeg",
|
||||
"globalMotion": "Detección de Movimiento",
|
||||
"globalObjects": "Objetos",
|
||||
"globalReview": "Revisión",
|
||||
"globalAudioEvents": "Eventos de Audio",
|
||||
"globalLivePlayback": "Reproducción en Vivo",
|
||||
"globalTimestampStyle": "Estilo de Marca de Tiempo",
|
||||
"systemDatabase": "Base de Datos",
|
||||
"systemAuthentication": "Autenticación"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@@ -353,6 +368,9 @@
|
||||
"allObjects": "Todos los objetos",
|
||||
"toast": {
|
||||
"success": "La zona ({{zoneName}}) ha sido guardada."
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Indica si esta zona está activa y habilitada en la configuración. Si está deshabilitado, no puede ser habilitado por MQTT. Las zonas deshabilitadas se ignoran durante la ejecución."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -697,7 +715,7 @@
|
||||
"cleanCopySnapshots": "<code>clean_copy</code> Instantáneas"
|
||||
},
|
||||
"desc": "Enviar a Frigate+ requiere que tanto las capturas instantáneas como las capturas <code>clean_copy</code> estén habilitadas en tu configuración.",
|
||||
"cleanCopyWarning": "Algunas cámaras tienen las instantáneas habilitadas pero tienen la copia limpia desactivada. Necesitas habilitar <code>clean_copy</code> en tu configuración de instantáneas para poder enviar imágenes de estas cámaras a Frigate+."
|
||||
"cleanCopyWarning": "Algunas cámaras tienen las instantáneas deshabilitadas"
|
||||
},
|
||||
"modelInfo": {
|
||||
"title": "Información del modelo",
|
||||
@@ -722,7 +740,8 @@
|
||||
"error": "No se pudieron guardar los cambios en la configuración: {{errorMessage}}"
|
||||
},
|
||||
"restart_required": "Es necesario reiniciar (se ha cambiado el modelo Frigate+)",
|
||||
"unsavedChanges": "Cambios en la configuración de Frigate+ no guardados"
|
||||
"unsavedChanges": "Cambios en la configuración de Frigate+ no guardados",
|
||||
"description": "Frigate+ es un servicio de suscripción que proporciona acceso a funciones y capacidades adicionales para su instancia de Frigate, incluida la posibilidad de utilizar modelos de detección de objetos personalizados entrenados con sus propios datos. Puede gestionar la configuración de sus modelos de Frigate+ aquí."
|
||||
},
|
||||
"enrichments": {
|
||||
"title": "Configuración de Enriquecimientos",
|
||||
@@ -1172,7 +1191,8 @@
|
||||
"backToSettings": "Volver a configuración de la cámara",
|
||||
"streams": {
|
||||
"title": "Habilitar/deshabilitar cámaras",
|
||||
"desc": "Desactiva temporalmente una cámara hasta que Frigate se reinicie. Desactivar una cámara detiene por completo el procesamiento de las transmisiones de Frigate. La detección, la grabación y la depuración no estarán disponibles.<br /> <em>Nota: Esto no desactiva las retransmisiones de go2rtc.</em>"
|
||||
"desc": "Desactiva temporalmente una cámara hasta que Frigate se reinicie. Desactivar una cámara detiene por completo el procesamiento de las transmisiones de Frigate. La detección, la grabación y la depuración no estarán disponibles.<br /> <em>Nota: Esto no desactiva las retransmisiones de go2rtc.</em>",
|
||||
"enableDesc": "Deshabilita temporalmente una cámara habilitada hasta que Frigate se reinicie. Deshabilitar una cámara detiene por completo el procesamiento de las transmisiones de esa cámara por parte de Frigate. La detección, la grabación y la depuración no estarán disponibles.<br /> <em>Nota: Esto no deshabilita las retransmisiones de go2rtc.</em>"
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Añadir cámara",
|
||||
@@ -1202,6 +1222,9 @@
|
||||
"toast": {
|
||||
"success": "Cámara {{cameraName}} guardada correctamente"
|
||||
}
|
||||
},
|
||||
"deleteCameraDialog": {
|
||||
"description": "Eliminar una cámara borrará permanentemente todas las grabaciones, los objetos rastreados y la configuración de esa cámara. Es posible que sea necesario eliminar manualmente cualquier transmisión go2rtc asociada a esta cámara."
|
||||
}
|
||||
},
|
||||
"cameraReview": {
|
||||
@@ -1253,12 +1276,133 @@
|
||||
"maintenance": {
|
||||
"sync": {
|
||||
"verboseDesc": "Escribe una lista completa de archivos huérfanos en el disco para su revisión.",
|
||||
"verbose": "Detallado"
|
||||
"verbose": "Detallado",
|
||||
"desc": "Frigate limpiará periódicamente los archivos multimedia según un cronograma regular, de acuerdo con su configuración de retención. Es normal ver algunos archivos huérfanos mientras Frigate se ejecuta. Utilice esta función para eliminar del disco los archivos multimedia huérfanos que ya no se referencian en la base de datos.",
|
||||
"forceDesc": "Omitir el umbral de seguridad y completar la sincronización incluso si se eliminara más del 50% de los archivos."
|
||||
},
|
||||
"regionGrid": {
|
||||
"clearConfirmDesc": "No se recomienda borrar la cuadrícula de la región a menos que haya cambiado recientemente el tamaño del modelo de su detector o la posición física de su cámara y esté experimentando problemas de seguimiento de objetos. La cuadrícula se reconstruirá automáticamente con el tiempo a medida que se realice el seguimiento de los objetos. Es necesario reiniciar Frigate para que los cambios surtan efecto.",
|
||||
"desc": "La cuadrícula de regiones es una optimización que aprende dónde suelen aparecer los objetos de diferentes tamaños en el campo de visión de cada cámara. Frigate utiliza estos datos para dimensionar de forma eficiente las regiones de detección. La cuadrícula se construye automáticamente a lo largo del tiempo a partir de los datos de los objetos rastreados."
|
||||
}
|
||||
},
|
||||
"configForm": {
|
||||
"camera": {
|
||||
"noCameras": "No hay cámaras disponibles"
|
||||
"noCameras": "No hay cámaras disponibles",
|
||||
"description": "Estos ajustes se aplican únicamente a esta cámara y anulan los ajustes globales."
|
||||
},
|
||||
"genaiModel": {
|
||||
"noModels": "No hay modelos disponibles"
|
||||
},
|
||||
"global": {
|
||||
"description": "Estos ajustes se aplican a todas las cámaras, a menos que se anulen en los ajustes específicos de cada cámara."
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Configuración global",
|
||||
"description": "Configura los ajustes globales que se aplican a todas las cámaras, a menos que se sobrescriban.",
|
||||
"toast": {
|
||||
"success": "Ajustes globales guardados con éxito",
|
||||
"error": "Error al guardar los ajustes globales",
|
||||
"validationError": "Error de validación"
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
"title": "Configuración de cámara",
|
||||
"description": "Configura los ajustes de cámaras individuales. Estos ajustes sobrescriben los valores globales predeterminados.",
|
||||
"overriddenBadge": "Sobrescrito",
|
||||
"resetToGlobal": "Restablecer al valor global",
|
||||
"toast": {
|
||||
"success": "Ajustes de cámara guardados con éxito",
|
||||
"error": "Error al guardar los ajustes de cámara"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Ajustes guardados con éxito",
|
||||
"applied": "Ajustes aplicados con éxito",
|
||||
"successRestartRequired": "Ajustes guardados con éxito. Reinicia Frigate para aplicar los cambios.",
|
||||
"error": "Error al guardar los ajustes",
|
||||
"validationError": "Error de validación: {{message}}",
|
||||
"resetSuccess": "Restablecido a los valores globales predeterminados",
|
||||
"resetError": "Error al restablecer los ajustes",
|
||||
"saveAllSuccess_one": "Se ha guardado {{count}} sección con éxito.",
|
||||
"saveAllSuccess_many": "Se han guardado las {{count}} secciones con éxito.",
|
||||
"saveAllSuccess_other": "Se han guardado {{count}} secciones con éxito.",
|
||||
"saveAllPartial_one": "Se ha guardado {{successCount}} de {{totalCount}} sección. {{failCount}} ha fallado.",
|
||||
"saveAllPartial_many": "Se han guardado {{successCount}} de {{totalCount}} secciones. {{failCount}} han fallado.",
|
||||
"saveAllPartial_other": "Se han guardado {{successCount}} de {{totalCount}} secciones. {{failCount}} han fallado.",
|
||||
"saveAllFailure": "Error al guardar todas las secciones."
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Perfiles",
|
||||
"activeProfile": "Perfil activo",
|
||||
"noActiveProfile": "Sin perfil activo",
|
||||
"active": "Activo",
|
||||
"activated": "Perfil '{{profile}}' activado",
|
||||
"activateFailed": "Error al establecer el perfil",
|
||||
"deactivated": "Perfil desactivado",
|
||||
"noProfiles": "No hay perfiles definidos.",
|
||||
"noOverrides": "Sin sobrescripciones",
|
||||
"cameraCount_one": "{{count}} cámara",
|
||||
"cameraCount_many": "{{count}} de cámaras",
|
||||
"cameraCount_other": "{{count}} cámaras",
|
||||
"columnCamera": "Cámara",
|
||||
"columnOverrides": "Sobrescripciones del perfil",
|
||||
"baseConfig": "Configuración base",
|
||||
"addProfile": "Añadir perfil",
|
||||
"newProfile": "Nuevo perfil",
|
||||
"profileNamePlaceholder": "ej. Armado, Fuera de casa, Modo noche",
|
||||
"friendlyNameLabel": "Nombre del perfil",
|
||||
"profileIdLabel": "ID del perfil",
|
||||
"profileIdDescription": "Identificador interno utilizado en la configuración y automatizaciones",
|
||||
"nameInvalid": "Solo se permiten letras minúsculas, números y guiones bajos",
|
||||
"nameDuplicate": "Ya existe un perfil con este nombre",
|
||||
"error": {
|
||||
"mustBeAtLeastTwoCharacters": "Debe tener al menos 2 caracteres",
|
||||
"mustNotContainPeriod": "No debe contener puntos",
|
||||
"alreadyExists": "Ya existe un perfil con este ID"
|
||||
},
|
||||
"renameProfile": "Renombrar perfil",
|
||||
"renameSuccess": "Perfil renombrado a '{{profile}}'",
|
||||
"enabledDescription": "Los perfiles están habilitados. Cree un nuevo perfil a continuación, navegue a una sección de configuración de cámara para realizar sus cambios y guarde para que estos surtan efecto.",
|
||||
"disabledDescription": "Los perfiles le permiten definir conjuntos con nombre de anulaciones de configuración de la cámara (por ejemplo: armado, fuera, noche) que pueden activarse bajo demanda."
|
||||
},
|
||||
"go2rtcStreams": {
|
||||
"renameStreamDesc": "Introduce un nuevo nombre para esta transmisión. Cambiar el nombre de una transmisión puede provocar fallos en las cámaras u otras transmisiones que hagan referencia a ella por su nombre.",
|
||||
"addStreamDesc": "Introduce un nombre para la nueva transmisión. Este nombre se utilizará para hacer referencia a la transmisión en la configuración de su cámara.",
|
||||
"description": "Gestione las configuraciones de transmisión de go2rtc para la retransmisión de cámaras. Cada transmisión tiene un nombre y una o más URL de origen.",
|
||||
"deleteStreamConfirm": "¿Está seguro de que desea eliminar la transmisión \"{{streamName}}\"? Las cámaras que hagan referencia a esta transmisión podrían dejar de funcionar."
|
||||
},
|
||||
"configMessages": {
|
||||
"birdseye": {
|
||||
"objectsModeDetectDisabled": "Birdseye está configurado en modo 'objects', pero la detección de objetos está desactivada para esta cámara. La cámara no aparecerá en Birdseye."
|
||||
},
|
||||
"lpr": {
|
||||
"globalDisabled": "El reconocimiento de matrículas no está habilitado a nivel global. Habilítelo en la configuración global para que funcione el reconocimiento de matrículas a nivel de cámara."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "Ninguna transmisión tiene definido el rol de audio. Debe habilitar el rol de audio para que funcione la detección de audio."
|
||||
},
|
||||
"faceRecognition": {
|
||||
"personNotTracked": "El reconocimiento facial requiere que se realice el seguimiento del objeto 'person'. Asegúrese de que 'person' se encuentre en la lista de seguimiento de objetos."
|
||||
},
|
||||
"audioTranscription": {
|
||||
"audioDetectionDisabled": "La detección de audio no está habilitada para esta cámara. La transcripción de audio requiere que la detección de audio esté activa."
|
||||
},
|
||||
"snapshots": {
|
||||
"detectDisabled": "La detección de objetos está desactivada. Las instantáneas se generan a partir de los objetos rastreados y no se crearán."
|
||||
},
|
||||
"detectors": {
|
||||
"mixedTypes": "Todos los detectores deben ser del mismo tipo. Retire los detectores existentes para utilizar un tipo diferente."
|
||||
},
|
||||
"review": {
|
||||
"detectDisabled": "La detección de objetos está desactivada. Los elementos de revisión requieren objetos detectados para categorizar las alertas y detecciones."
|
||||
}
|
||||
},
|
||||
"resetToDefaultDescription": "Esto restablecerá todos los ajustes de esta sección a sus valores predeterminados. Esta acción no se puede deshacer.",
|
||||
"resetToGlobalDescription": "Esto restablecerá la configuración de esta sección a los valores predeterminados globales. Esta acción no se puede deshacer.",
|
||||
"detectionModel": {
|
||||
"plusActive": {
|
||||
"description": "Esta instancia está ejecutando un modelo de Frigate+. Seleccione o cambie su modelo en la configuración de Frigate+."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,17 @@
|
||||
"reviews": "Revisiones",
|
||||
"face_recognition": "Reconocimiento facial",
|
||||
"camera_activity": "Actividad de cámara",
|
||||
"classification": "Clasificación"
|
||||
"classification": "Clasificación",
|
||||
"system": "Sistema",
|
||||
"camera": "Cámara",
|
||||
"all_cameras": "Todas las cámaras",
|
||||
"cameras_count_one": "{{count}} Cámara",
|
||||
"cameras_count_other": "{{count}} Cámaras",
|
||||
"lpr": "Reconocimiento de matriculas"
|
||||
},
|
||||
"count_other": "{{count}} mensajes",
|
||||
"count_one": "{{count}} mensaje"
|
||||
"count_one": "{{count}} mensaje",
|
||||
"empty": "No se han capturado mensaje aún"
|
||||
}
|
||||
},
|
||||
"title": "Sistema",
|
||||
@@ -99,7 +106,10 @@
|
||||
"title": "Aviso de estadísticas Intel GPU",
|
||||
"message": "Estadísticas de GPU no disponibles",
|
||||
"description": "Este es un error conocido en las herramientas de informes de estadísticas de GPU de Intel (intel_gpu_top). El error se produce y muestra repetidamente un uso de GPU del 0 %, incluso cuando la aceleración de hardware y la detección de objetos se ejecutan correctamente en la (i)GPU. No se trata de un error de Frigate. Puede reiniciar el host para solucionar el problema temporalmente y confirmar que la GPU funciona correctamente. Esto no afecta al rendimiento."
|
||||
}
|
||||
},
|
||||
"npuTemperature": "Temperatura NPU",
|
||||
"gpuCompute": "Cálculo GPU / Codificación",
|
||||
"gpuTemperature": "Temperatura GPU"
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "Otros Procesos",
|
||||
@@ -136,7 +146,11 @@
|
||||
},
|
||||
"shm": {
|
||||
"title": "Asignación de SHM (memoria compartida)",
|
||||
"warning": "El tamaño actual de SHM de {{total}}MB es muy pequeño. Aumente al menos a {{min_shm}}MB."
|
||||
"warning": "El tamaño actual de SHM de {{total}}MB es muy pequeño. Aumente al menos a {{min_shm}}MB.",
|
||||
"frameLifetime": {
|
||||
"title": "Tiempo de vida del fotograma",
|
||||
"description": "Cada cámara tiene espacio en la memoria compartida para {{frames}} cuadros. Si la velocidad de cuadros de la cámara es alta, cada cuadro se guarda aproximadamente {{lifetime}} antes de ser sobreescrito."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
@@ -174,7 +188,8 @@
|
||||
"cameraDetect": "{{camName}} detectar",
|
||||
"cameraFramesPerSecond": "{{camName}} cuadros por segundo",
|
||||
"cameraDetectionsPerSecond": "{{camName}} detecciones por segundo",
|
||||
"overallSkippedDetectionsPerSecond": "detecciones omitidas por segundo totales"
|
||||
"overallSkippedDetectionsPerSecond": "detecciones omitidas por segundo totales",
|
||||
"cameraGpu": "{{camName}} GPU"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
@@ -183,6 +198,17 @@
|
||||
"error": {
|
||||
"unableToProbeCamera": "No se pudo sondear la cámara: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"connectionQuality": {
|
||||
"excellent": "Excelente",
|
||||
"poor": "Debil",
|
||||
"title": "Calidad de la conexión",
|
||||
"fps": "Cuadros por segundo",
|
||||
"expectedFps": "Cuadros por segundo esperados",
|
||||
"reconnectsLastHour": "Reconexiones (última hora)",
|
||||
"unusable": "No usable",
|
||||
"fair": "Normal",
|
||||
"stallsLastHour": "Bloqueos (última hora)"
|
||||
}
|
||||
},
|
||||
"lastRefreshed": "Última actualización: ",
|
||||
@@ -221,6 +247,7 @@
|
||||
"detectIsSlow": "{{detect}} es lento ({{speed}} ms)",
|
||||
"cameraIsOffline": "{{camera}} está desconectada",
|
||||
"detectIsVerySlow": "{{detect}} es muy lento ({{speed}} ms)",
|
||||
"shmTooLow": "Asignación de /dev/shm ({{total}} MB) debe aumentarse al menos a {{min}} MB."
|
||||
"shmTooLow": "Asignación de /dev/shm ({{total}} MB) debe aumentarse al menos a {{min}} MB.",
|
||||
"debugReplayActive": "Sesión de depuración activa"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,5 +113,165 @@
|
||||
"quack": "Prääksumine",
|
||||
"goose": "Hani",
|
||||
"honk": "Kaagatamine",
|
||||
"wild_animals": "Metsloomad"
|
||||
"wild_animals": "Metsloomad",
|
||||
"roaring_cats": "Möirgavad kassid",
|
||||
"roar": "Möirgamine",
|
||||
"chirp": "Sirisemine",
|
||||
"squawk": "Prääksatamine",
|
||||
"pigeon": "Tuvi",
|
||||
"coo": "Kudrutamine",
|
||||
"crow": "Vares",
|
||||
"caw": "Kraaksumine",
|
||||
"owl": "Öökull",
|
||||
"hoot": "Huikamine",
|
||||
"flapping_wings": "Tiibade laperdamine",
|
||||
"buzz": "Sumisemine",
|
||||
"frog": "Konn",
|
||||
"croak": "Krooksumine",
|
||||
"snake": "Madu",
|
||||
"rattle": "Kõristamine/lõgistamine",
|
||||
"whale_vocalization": "Vaalaskala häälitsused",
|
||||
"music": "Muusika",
|
||||
"musical_instrument": "Pill",
|
||||
"plucked_string_instrument": "Keelpill",
|
||||
"guitar": "Kitarr",
|
||||
"electric_guitar": "Elektrikitarr",
|
||||
"bass_guitar": "Basskitarr",
|
||||
"acoustic_guitar": "Akustiline kitarr",
|
||||
"sitar": "Sitar",
|
||||
"mandolin": "Mandoliin",
|
||||
"banjo": "Bändžo",
|
||||
"zither": "Kannel/tsitter",
|
||||
"ukulele": "Ukulele",
|
||||
"piano": "Klaver",
|
||||
"electric_piano": "Elektriklaver",
|
||||
"organ": "Orel",
|
||||
"electronic_organ": "Elektriorel",
|
||||
"hammond_organ": "Hammond-orel",
|
||||
"synthesizer": "Süntesaator",
|
||||
"sampler": "Sämpler",
|
||||
"harpsichord": "Klavessiin",
|
||||
"percussion": "Löökriistad",
|
||||
"drum_kit": "Trummikomplekt",
|
||||
"bass_drum": "Basstrumm",
|
||||
"tambourine": "Tamburiin",
|
||||
"glockenspiel": "Ksülofon",
|
||||
"vibraphone": "Vibrafon (metalltorudega ksülofon)",
|
||||
"marimba": "Marimbafon",
|
||||
"tubular_bells": "Torukellad",
|
||||
"gong": "Gong",
|
||||
"orchestra": "Orkester",
|
||||
"cello": "Tšello",
|
||||
"pizzicato": "Pizzicato (poogenpilli sõrmega mängimine)",
|
||||
"violin": "Viiul",
|
||||
"string_section": "Keelpillid",
|
||||
"trombone": "Tromboon",
|
||||
"trumpet": "Trompet",
|
||||
"french_horn": "Metsasarv",
|
||||
"brass_instrument": "Puhkpillid",
|
||||
"double_bass": "Kontrabass",
|
||||
"wind_instrument": "Puhkpill",
|
||||
"flute": "Flööt",
|
||||
"saxophone": "Saksofon",
|
||||
"clarinet": "Klarnet",
|
||||
"harp": "Harf",
|
||||
"bell": "Kellad",
|
||||
"church_bell": "Kirikukell",
|
||||
"jingle_bell": "Aisakell",
|
||||
"bicycle_bell": "Rattakell",
|
||||
"tuning_fork": "Helihark/kammertoon",
|
||||
"bagpipes": "Torupillid",
|
||||
"didgeridoo": "Didžeriduu",
|
||||
"pop_music": "Popmuusika",
|
||||
"hip_hop_music": "Hiphop muusika",
|
||||
"rock_music": "Rokkmuusika",
|
||||
"beatboxing": "Beatbox",
|
||||
"heavy_metal": "Hevimuusika",
|
||||
"punk_rock": "Punkrokk",
|
||||
"grunge": "Grunge",
|
||||
"progressive_rock": "Progressiivne rokk",
|
||||
"rhythm_and_blues": "Rütmibluus",
|
||||
"soul_music": "Soulmuusika",
|
||||
"reggae": "Reggae",
|
||||
"country": "Kantrimuusika",
|
||||
"funk": "Funkmuusika",
|
||||
"folk_music": "Rahvamuusika",
|
||||
"middle_eastern_music": "Lähis-Ida muusika",
|
||||
"jazz": "Džäss",
|
||||
"disco": "Disko",
|
||||
"classical_music": "Klassikaline muusika",
|
||||
"opera": "Ooper",
|
||||
"electronic_music": "Elektrooniline muusika",
|
||||
"house_music": "House-muusika",
|
||||
"techno": "Tekno",
|
||||
"dubstep": "Dubstep",
|
||||
"drum_and_bass": "Drum and Bass",
|
||||
"electronic_dance_music": "Elektrooniline tantsumuusika",
|
||||
"music_of_latin_america": "Ladina-Ameerika muusika",
|
||||
"salsa_music": "Salsa",
|
||||
"flamenco": "Flamenko",
|
||||
"blues": "Bluus",
|
||||
"music_for_children": "Lastemuusika",
|
||||
"new-age_music": "New Age muusika",
|
||||
"vocal_music": "Laulmine",
|
||||
"a_capella": "A Capella",
|
||||
"music_of_africa": "Aafrika muusika",
|
||||
"afrobeat": "Afrobeat",
|
||||
"christian_music": "Kristlik muusika",
|
||||
"gospel_music": "Gospelmuusika",
|
||||
"music_of_asia": "Aasia muusika",
|
||||
"music_of_bollywood": "Bollywoodi muusika",
|
||||
"ska": "Ska",
|
||||
"carnatic_music": "Karnataka muusika",
|
||||
"trance_music": "Trance muusika",
|
||||
"ambient_music": "Ambient muusika",
|
||||
"electronica": "Electronica",
|
||||
"swing_music": "Svingmuusika",
|
||||
"bluegrass": "Bluegrass",
|
||||
"psychedelic_rock": "Psühhedeelne rokk",
|
||||
"rock_and_roll": "Rock'n'roll",
|
||||
"scratching": "Kriipimine/kraapimine",
|
||||
"theremin": "Teremin",
|
||||
"accordion": "Akordion",
|
||||
"harmonica": "Suupill",
|
||||
"wind_chime": "Tuulekell",
|
||||
"chime": "Kelluke",
|
||||
"traditional_music": "Traditsiooniline muusika",
|
||||
"independent_music": "Sõltumatu muusika",
|
||||
"song": "Laul",
|
||||
"background_music": "Taustamuusika",
|
||||
"lullaby": "Hällilaul",
|
||||
"christmas_music": "Jõulumuusika",
|
||||
"video_game_music": "Videomängude muusika",
|
||||
"dance_music": "Tantsumuusika",
|
||||
"wedding_music": "Pulmamuusika",
|
||||
"happy_music": "Rõõmus muusika",
|
||||
"sad_music": "Kurb muusika",
|
||||
"tender_music": "Tundeline muusika",
|
||||
"angry_music": "Vihane muusika",
|
||||
"exciting_music": "Põnev muusika",
|
||||
"scary_music": "Hirmutav muusika",
|
||||
"wind": "Tuul",
|
||||
"thunderstorm": "Äikesetorm",
|
||||
"thunder": "Kõu/äike",
|
||||
"water": "Vesi",
|
||||
"rain": "Vihm",
|
||||
"raindrop": "Vihmapiisk",
|
||||
"spray": "Pritsimine",
|
||||
"pump": "Pumpamine",
|
||||
"stir": "Segamine/nihelemine",
|
||||
"boiling": "Keemine",
|
||||
"sonar": "Kajalood",
|
||||
"arrow": "Nool",
|
||||
"whoosh": "Vuhh/vuhisemine",
|
||||
"thump": "Potsatus/mütsatus",
|
||||
"thunk": "Põmakas",
|
||||
"doorbell": "Uksekell",
|
||||
"traffic_noise": "Liiklusmüra",
|
||||
"rail_transport": "Raudteetransport",
|
||||
"train_whistle": "Rongivile",
|
||||
"sailboat": "Purjekas",
|
||||
"soundtrack_music": "Filmimuusika",
|
||||
"jingle": "Kõlisemine/tilisemine",
|
||||
"theme_music": "Tunnusmuusika"
|
||||
}
|
||||
|
||||
@@ -181,7 +181,8 @@
|
||||
"classification": "Klassifikatsioon",
|
||||
"chat": "Vestlus",
|
||||
"actions": "Tegevused",
|
||||
"profiles": "Profiilid"
|
||||
"profiles": "Profiilid",
|
||||
"features": "Funktsionaalsused"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
"toast": {
|
||||
"error": "Jälgitavate objektide kustutamine ei õnnestunud: {{errorMessage}}",
|
||||
"success": "Jälgitavate objektide kustutamine õnnestus."
|
||||
}
|
||||
},
|
||||
"title": "Kinnita kustutamine",
|
||||
"desc": "Nende {{objectLength}} jälgitava objekti kustutamine eemaldab tõmmise salvestuse, kõik seotud salvestatud sissekanded ja kõik seotud objekti elutsükli kirjed. Ajaloo vaates salvestatud videomaterjali nende jälgitavate objektide kohta <em>EI</em> kustutata.<br /><br/>Kas soovid kindlasti jätkata?<br/><br/>Hoia <em>Shift</em>-klahvi all, et seda teateakent tulevikus vahele jätta."
|
||||
},
|
||||
"cameras": {
|
||||
"all": {
|
||||
"title": "Kõik kaamerad",
|
||||
"short": "Kaamerad"
|
||||
}
|
||||
},
|
||||
"label": "Kaamerate filter"
|
||||
},
|
||||
"labels": {
|
||||
"all": {
|
||||
@@ -38,7 +41,8 @@
|
||||
"defaultView": {
|
||||
"title": "Vaikimisi vaade",
|
||||
"summary": "Kokkuvõte",
|
||||
"unfilteredGrid": "Filtreerimata ruudustik"
|
||||
"unfilteredGrid": "Filtreerimata ruudustik",
|
||||
"desc": "Kui filtreid pole valitud, näita viimaste jälgitud objektide kokkuvõtet sildi kohta või näita filtreerimata ruudustikuvaadet."
|
||||
},
|
||||
"gridColumns": {
|
||||
"title": "Ruudustiku veerud",
|
||||
@@ -48,16 +52,26 @@
|
||||
"options": {
|
||||
"thumbnailImage": "Pisipilt",
|
||||
"description": "Kirjeldus"
|
||||
}
|
||||
},
|
||||
"label": "Otsinguallikas",
|
||||
"desc": "Vali, kas soovid otsida sinu jälgitavate objektide pisipilte või kirjeldusi."
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"selectDateBy": {
|
||||
"label": "Vali kuupäev, mille alusel tahad filtreerida"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logSettings": {
|
||||
"loading": {
|
||||
"title": "Laadin"
|
||||
"title": "Laadin",
|
||||
"desc": "Kui logipaneeli vaade on keritud lõpuni, siis kuvatakse lisanduvad logikirjed automaatselt kohe."
|
||||
},
|
||||
"disableLogStreaming": "Keela logi voogedastus",
|
||||
"allLogs": "Kõik logid"
|
||||
"allLogs": "Kõik logid",
|
||||
"label": "Logimistase filtri jaoks",
|
||||
"filterBySeverity": "Kriitilisus filtri jaoks"
|
||||
},
|
||||
"classes": {
|
||||
"label": "Klassid",
|
||||
@@ -83,6 +97,44 @@
|
||||
"estimatedSpeed": "Hinnanguline kiirus: ({{unit}})",
|
||||
"features": {
|
||||
"label": "Omadused",
|
||||
"hasSnapshot": "Leidub hetkvõte"
|
||||
"hasSnapshot": "Leidub hetkvõte",
|
||||
"hasVideoClip": "Videoklipp on olemas",
|
||||
"submittedToFrigatePlus": {
|
||||
"label": "Saadetud teenusesse Frigate+",
|
||||
"tips": "Sa pead filtreerima jälgitavaid objekte, millel on tõmmis.<br /><br />Kui jälgitaval objektil pole tõmmist, siis teda Frigate+ teenusesse saata ei saa."
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"label": "Klassifitseerimisatribuudid",
|
||||
"all": "Kõik atribuudid"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Järjestus",
|
||||
"dateAsc": "Kuupäev (kasvavalt)",
|
||||
"dateDesc": "Kuupäev (kahanevalt)",
|
||||
"scoreAsc": "Objekti punktiskoor (kasvavalt)",
|
||||
"scoreDesc": "Objekti punktiskoor (kahanevalt)",
|
||||
"speedAsc": "Hinnanguline kiirus (kasvavalt)",
|
||||
"speedDesc": "Hinnanguline kiirus (kahanevalt)",
|
||||
"relevance": "Teemakohasus"
|
||||
},
|
||||
"review": {
|
||||
"showReviewed": "Näita ülevaadatuid"
|
||||
},
|
||||
"motion": {
|
||||
"showMotionOnly": "Näita vaid liikumisega klippe"
|
||||
},
|
||||
"zoneMask": {
|
||||
"filterBy": "Tsoonimask filtri jaoks"
|
||||
},
|
||||
"recognizedLicensePlates": {
|
||||
"title": "Tuvastatud sõiduki numbrimärgid",
|
||||
"loadFailed": "Tuvastatud sõiduki numbrimärkide laadimine ei õnnestunud.",
|
||||
"loading": "Laadin tuvastatud sõiduki numbrimärke…",
|
||||
"placeholder": "Sõidukite numbrimärkide otsimiseks kirjuta midagi…",
|
||||
"noLicensePlatesFound": "Sõidukite numbrimärke ei leidu.",
|
||||
"selectPlatesFromList": "Vali loendist üks või enam sõiduki numbrimärki.",
|
||||
"selectAll": "Vali kõik",
|
||||
"clearAll": "Eemalda kõik"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"noPreviewFoundFor": "{{cameraName}} kaamera eelvaadet ei leidu",
|
||||
"submitFrigatePlus": {
|
||||
"submit": "Saada",
|
||||
"title": "Kas saadad selle kaadri Frigate+ teenusesse?"
|
||||
"title": "Kas saadad selle kaadri Frigate+ teenusesse?",
|
||||
"previewError": "Hetktõmmise eelvaate laadimine ei õnnestu. Salvestus ei pruugi olla hetkel saadaval."
|
||||
},
|
||||
"cameraDisabled": "Kaamera on kasutuselt eemaldatud",
|
||||
"stats": {
|
||||
|
||||
@@ -2,5 +2,21 @@
|
||||
"name": {
|
||||
"label": "Kaamera nimi",
|
||||
"description": "Kaamera nimi on nõutav"
|
||||
},
|
||||
"friendly_name": {
|
||||
"label": "Sõbralik nimi",
|
||||
"description": "Frigate UI-s kasutatud kaamerasõbralik nimi"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Kasutusel",
|
||||
"description": "Kasutusel"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Helisündmused"
|
||||
},
|
||||
"birdseye": {
|
||||
"mode": {
|
||||
"label": "Jälgimisrežiim"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
{}
|
||||
{
|
||||
"audio": {
|
||||
"label": "Helisündmused"
|
||||
},
|
||||
"birdseye": {
|
||||
"mode": {
|
||||
"label": "Jälgimisrežiim"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,73 @@
|
||||
{}
|
||||
{
|
||||
"audio": {
|
||||
"global": {
|
||||
"detection": "Üldine tuvastamine",
|
||||
"sensitivity": "Üldine tundlikkus"
|
||||
},
|
||||
"cameras": {
|
||||
"detection": "Tuvastamine",
|
||||
"sensitivity": "Tundlikkus"
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"global": {
|
||||
"sensitivity": "Üldine tundlikkus",
|
||||
"algorithm": "Üldine algoritm"
|
||||
},
|
||||
"cameras": {
|
||||
"sensitivity": "Tundlikkus",
|
||||
"algorithm": "Algoritm"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"global": {
|
||||
"display": "Üldine vaade"
|
||||
},
|
||||
"cameras": {
|
||||
"display": "Vaade"
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"global": {
|
||||
"appearance": "Üldine välimus"
|
||||
},
|
||||
"cameras": {
|
||||
"appearance": "Välimus"
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"global": {
|
||||
"resolution": "Üldine eraldusvõime",
|
||||
"tracking": "Üldine jälgimine"
|
||||
},
|
||||
"cameras": {
|
||||
"resolution": "Eraldusvõime",
|
||||
"tracking": "Jälgimine"
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"global": {
|
||||
"filtering": "Üldine filtreerimine",
|
||||
"tracking": "Üldine jälgimine"
|
||||
},
|
||||
"cameras": {
|
||||
"filtering": "Filtreerimine",
|
||||
"tracking": "Jälgimine"
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"global": {
|
||||
"retention": "Üldine säilitamine",
|
||||
"events": "Üldised sündmused"
|
||||
},
|
||||
"cameras": {
|
||||
"retention": "Säilitamine",
|
||||
"events": "Sündmused"
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"cameras": {
|
||||
"cameraFfmpeg": "Kaamerakohased FFmpegi argumendid"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,32 @@
|
||||
{}
|
||||
{
|
||||
"minimum": "Peab olema vähemalt {{limit}}",
|
||||
"maximum": "Võib olla kuni {{limit}}",
|
||||
"exclusiveMinimum": "Peab olema suurem, kui {{limit}}",
|
||||
"exclusiveMaximum": "Peab olema väiksem, kui {{limit}}",
|
||||
"minLength": "Peab olema vähemalt {{limit}} tähemärk(i) pikk",
|
||||
"maxLength": "Võib olla kuni {{limit}} tähemärk(i) pikk",
|
||||
"minItems": "Peab sisaldama vähemalt {{limit}} objekti",
|
||||
"maxItems": "Võib sisaldada kuni {{limit}} objekti",
|
||||
"pattern": "Vigane vorming",
|
||||
"required": "See väli on kohustuslik",
|
||||
"type": "Vigane väärtuse tüüp",
|
||||
"enum": "Peab olema üks lubatud väärtustest",
|
||||
"const": "Väärtus ei vasta eeldatud konstandile",
|
||||
"uniqueItems": "Kõik väärtused peavad olema unikaalsed",
|
||||
"format": "Vigane vorming",
|
||||
"additionalProperties": "Tundmatu omadus pole lubatud",
|
||||
"oneOf": "Peab vastama täpselt ühele lubatud skeemile",
|
||||
"anyOf": "Peab vastama vähemalt ühele lubatud skeemile",
|
||||
"proxy": {
|
||||
"header_map": {
|
||||
"roleHeaderRequired": "Kui rollide vastendused on seadistatud, siis rollide päis on nõutav."
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"rolesUnique": "Iga rolli saad määrata ühele sisendvoole.",
|
||||
"detectRequired": "„Tuvasta“ rollile pead määrama vähemalt ühe sisendvoo.",
|
||||
"hwaccelDetectOnly": "Vaid „Tuvasta“ rolliga sisendvoog võib määratleda raudvaralise kiirenduse argumente."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"normalActivity": "Tavaline",
|
||||
"needsReview": "Vajab ülevaatamist",
|
||||
"securityConcern": "Võib olla turvaprobleem",
|
||||
"timeline": "Ajajoon",
|
||||
"timeline": {
|
||||
"label": "Ajajoon"
|
||||
},
|
||||
"timeline.aria": "Vali ajajoon",
|
||||
"zoomIn": "Suumi sisse",
|
||||
"zoomOut": "Suumi välja",
|
||||
@@ -53,7 +55,9 @@
|
||||
},
|
||||
"documentTitle": "Ülevaatamine - Frigate",
|
||||
"recordings": {
|
||||
"documentTitle": "Salvestised - Frigate"
|
||||
"documentTitle": "Salvestised - Frigate",
|
||||
"invalidSharedLink": "Töötlemisvea tõttu ei õnnestu avada ajatempliga salvestuse linki.",
|
||||
"invalidSharedCamera": "Tundmatu või volituseta kaamera tõttu ei õnnestu avada ajatempliga salvestuse linki."
|
||||
},
|
||||
"calendarFilter": {
|
||||
"last24Hours": "Viimased 24 tundi"
|
||||
@@ -61,5 +65,28 @@
|
||||
"objectTrack": {
|
||||
"clickToSeek": "Klõpsa siia ajapunkti kerimiseks",
|
||||
"trackedPoint": "Jälgitav punkt"
|
||||
},
|
||||
"motionSearch": {
|
||||
"menuItem": "Liikumise otsing",
|
||||
"openMenu": "Kaamera valikud"
|
||||
},
|
||||
"motionPreviews": {
|
||||
"menuItem": "Vaata liikumiste eelvaateid",
|
||||
"title": "Liikumiste eelvaated: {{camera}}",
|
||||
"mobileSettingsTitle": "Liikumiste eelvaadete seadistused",
|
||||
"mobileSettingsDesc": "Kohenda taasesituse kiirust ja heledust ning vali kuupäev, et vaadata läbi ainult liikumist kajastavaid klipid.",
|
||||
"dim": "Hämarus",
|
||||
"dimAria": "Muuda hämarust",
|
||||
"dimDesc": "Kohenda hämarust parandamaks liikumisala nähtavust.",
|
||||
"speed": "Kiirus",
|
||||
"speedAria": "Vali eelvaate taasesituse kiirus",
|
||||
"speedDesc": "Määratle kiirus, millega eelvaate klippe näidatakse.",
|
||||
"back": "Tagasi",
|
||||
"empty": "Ühtegi eelvaadet pole saadaval",
|
||||
"noPreview": "Eelvaade pole saadaval",
|
||||
"seekAria": "Keri „{{camera}}“ kaamera vaade ajatempli juurde: {{time}}",
|
||||
"filter": "Filtreeri",
|
||||
"filterDesc": "Näitamaks ainult liikumisega klippe antud aladel, vali soovitud piirkonnad.",
|
||||
"filterClear": "Tühjenda"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"ratio": "Suhtarv",
|
||||
"area": "Ala",
|
||||
"score": "Punktiskoor"
|
||||
}
|
||||
},
|
||||
"external": "{{label}} on tuvastatud",
|
||||
"heard": "{{label}} on kuuldud",
|
||||
"gone": "{{label}} on jäänud"
|
||||
},
|
||||
"title": "Jälgimise üksikasjad",
|
||||
"noImageFound": "Selle ajatempli kohta ei leidu pilti.",
|
||||
@@ -44,7 +47,8 @@
|
||||
"carousel": {
|
||||
"previous": "Eelmine slaid",
|
||||
"next": "Järgmine slaid"
|
||||
}
|
||||
},
|
||||
"count": "{{first}} / {{second}}"
|
||||
},
|
||||
"documentTitle": "Avasta - Frigate",
|
||||
"generativeAI": "Generatiivne tehisaru",
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
},
|
||||
"details": {
|
||||
"timestamp": "Ajatampel",
|
||||
"unknown": "Pole teada"
|
||||
"unknown": "Pole teada",
|
||||
"scoreInfo": "Skoor on kõigi nägude hindete kaalutud keskmine, kus kaalukoefitsiendiks on iga pildi näo suurus."
|
||||
},
|
||||
"uploadFaceImage": {
|
||||
"title": "Laadi näopilt üles",
|
||||
"desc": "Laadi üles pilt, et otsida sellelt nägusid ja lisada see {{pageToggle}}'i jaoks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"autotracking": "Automaatne jälgimine",
|
||||
"recording": "Salvestus"
|
||||
},
|
||||
"documentTitle": "Otseülekanne - Frigate",
|
||||
"documentTitle": {
|
||||
"default": "Frigate reaalajas"
|
||||
},
|
||||
"documentTitle.withCamera": "{{camera}} - Otseülekanne - Frigate",
|
||||
"lowBandwidthMode": "Väikese ribalaiusega režiim",
|
||||
"twoWayTalk": {
|
||||
@@ -30,7 +32,8 @@
|
||||
"clickMove": {
|
||||
"label": "Kaamerapildi joondamiseks keskele klõpsa kaadris",
|
||||
"enable": "Kasuta klõpsamisega teisaldamist",
|
||||
"disable": "Ära kasuta klõpsamisega teisaldamist"
|
||||
"disable": "Ära kasuta klõpsamisega teisaldamist",
|
||||
"enableWithZoom": "Luba liigutamine klõpsuga / suumimine lohistamisega"
|
||||
},
|
||||
"left": {
|
||||
"label": "Pööra liigutatavat kaamerat vasakule"
|
||||
@@ -100,11 +103,18 @@
|
||||
},
|
||||
"audio": {
|
||||
"available": "Selles voogedastuses on heliriba saadaval",
|
||||
"unavailable": "Selles voogedastuses pole heliriba saadaval"
|
||||
"unavailable": "Selles voogedastuses pole heliriba saadaval",
|
||||
"tips": {
|
||||
"title": "Heli peab tulema sinu kaamerast ja selle voogedastuse jaoks peab see go2rtc-s olema seadistatud."
|
||||
}
|
||||
},
|
||||
"title": "Voogedastus",
|
||||
"lowBandwidth": {
|
||||
"resetStream": "Lähtesta voogedastus"
|
||||
"resetStream": "Lähtesta voogedastus",
|
||||
"tips": "Reaalaja pilt on puhverdamise või voogedastuse vigade tõttu madala ribalaiusega režiimis."
|
||||
},
|
||||
"debug": {
|
||||
"picker": "Voogedastuse osa valik pole silumisrežiimis saadaval. Silumisvaade kasutab alati voogedastust, millele on määratud tuvastamisroll."
|
||||
}
|
||||
},
|
||||
"notifications": "Teavitused",
|
||||
@@ -137,7 +147,15 @@
|
||||
"showStats": {
|
||||
"label": "Näita statistikat",
|
||||
"desc": "Selle eelistuse puhul näidatakse voogedastuse statistikat kaamerapildi peal."
|
||||
}
|
||||
},
|
||||
"tips": "Laadi alla hetktõmmis või käivita käsitsi sündmus vastavalt selle kaamera salvestiste säilitamise seadistustele.",
|
||||
"start": "Alusta tellimuspõhist salvestamist",
|
||||
"started": "Alustasin käsitsi tellitavat salvestamist.",
|
||||
"failedToStart": "Käsitsi tellitava salvestamise alustamine ei õnnestunud.",
|
||||
"recordDisabledTips": "Kuna selle kaamera seadistustes on salvestamine keelatud või piiratud, siis salvestatakse ainult pilt.",
|
||||
"end": "Lõpeta tellimuspõhine salvestamine",
|
||||
"ended": "Lõpetasin käsitsi tellitava salvestamise.",
|
||||
"failedToEnd": "Käsitsi tellitava salvestamise lõpetamine ei õnnestunud."
|
||||
},
|
||||
"noCameras": {
|
||||
"buttonText": "Lisa kaamera",
|
||||
@@ -174,5 +192,8 @@
|
||||
},
|
||||
"history": {
|
||||
"label": "Näita varasemat sisu"
|
||||
},
|
||||
"suspend": {
|
||||
"forTime": "Peatamise aeg: "
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"clear": "Tühjenda otsing",
|
||||
"save": "Salvesta otsing",
|
||||
"delete": "Kustuta salvestatud otsing",
|
||||
"filterInformation": "Filtri teave"
|
||||
"filterInformation": "Filtri teave",
|
||||
"filterActive": "Filtreid valituna"
|
||||
},
|
||||
"filter": {
|
||||
"label": {
|
||||
@@ -17,7 +18,23 @@
|
||||
"cameras": "Kaamerad",
|
||||
"labels": "Sildid",
|
||||
"zones": "Tsoonid",
|
||||
"sub_labels": "Alamsildid"
|
||||
"sub_labels": "Alamsildid",
|
||||
"attributes": "Omadused",
|
||||
"search_type": "Otsingutüüp",
|
||||
"time_range": "Ajavahemik",
|
||||
"before": "Enne",
|
||||
"after": "Pärast"
|
||||
},
|
||||
"searchType": {
|
||||
"thumbnail": "Pisipilt",
|
||||
"description": "Kirjeldus"
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
"beforeDateBeLaterAfter": "„Enne“ kuupäev peab olema varasem, kui „Pärast“ kuupäev.",
|
||||
"afterDatebeEarlierBefore": "„Pärast“ kuupäev peab olema hilisem, kui „Enne“ kuupäev."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackedObjectId": "Jälgitava objekti tunnus"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
"second_other": "{{time}}sekunttia",
|
||||
"formattedTimestampHourMinute": {
|
||||
"24hour": "HH:mm"
|
||||
}
|
||||
},
|
||||
"never": "Ei koskaan"
|
||||
},
|
||||
"pagination": {
|
||||
"next": {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"loginFailed": "Kirjautuminen epäonnistui",
|
||||
"unknownError": "Tuntematon virhe. Tarkista logit.",
|
||||
"webUnknownError": "Tuntematon virhe. Tarkista konsolilogi."
|
||||
}
|
||||
},
|
||||
"firstTimeLogin": "Ensimmäistä kertaa kirjautumassa sisään? Tunnukset löytyvät Frigaten lokista."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"title": "Fregatti käynnistyy uudelleen",
|
||||
"content": "Tämä sivu latautuu uudelleen {{countdown}} sekunnin kuluttua.",
|
||||
"button": "Pakota uudelleenlataus nyt"
|
||||
}
|
||||
},
|
||||
"description": "Tämä sammuttaa Frigaten lyhyeksi aikaa uudelleenkäynnistyksen ajaksi."
|
||||
},
|
||||
"explore": {
|
||||
"plus": {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"noRecordingsFoundForThisTime": "Ei tallenteita valitulta ajalta",
|
||||
"submitFrigatePlus": {
|
||||
"title": "Lähetä tämä kuva Frigate+:aan?",
|
||||
"submit": "Lähetä"
|
||||
"submit": "Lähetä",
|
||||
"previewError": "Pysäytyskuvan esikatselua ei voi ladata. Tallenne ei ole ehkä saatavissa tällä hetkellä."
|
||||
},
|
||||
"livePlayerRequiredIOSVersion": "iOS 17.1 tai uudempi vaaditaan tälle suoratoistotyypille.",
|
||||
"streamOffline": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user