mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-04 22:06:51 +03:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920e8a1c9f | ||
|
|
ed8367e51e | ||
|
|
e8a3c870f1 | ||
|
|
30012f89fc | ||
|
|
cc1ff63b53 | ||
|
|
9b2839f4fb | ||
|
|
c06bf97b5e | ||
|
|
f3723698cd | ||
|
|
38b87feece |
@@ -571,6 +571,8 @@ notifications:
|
||||
enabled: False
|
||||
# Optional: Email for push service to reach out to
|
||||
# NOTE: This is required to use notifications
|
||||
# NOTE: Email can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
|
||||
# e.g. email: '{FRIGATE_NOTIFICATION_EMAIL}'
|
||||
email: "admin@example.com"
|
||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||
cooldown: 0
|
||||
|
||||
+14
-3
@@ -311,9 +311,12 @@ def config(request: Request):
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
is_admin = request.headers.get("remote-role") == "admin"
|
||||
|
||||
# hide environment_vars and the notification email from non-admin users
|
||||
if not is_admin:
|
||||
config.pop("environment_vars", None)
|
||||
redact_credential(config["notifications"], "email")
|
||||
|
||||
# redact mqtt credentials
|
||||
redact_credential(config["mqtt"], "password")
|
||||
@@ -370,7 +373,15 @@ def config(request: Request):
|
||||
camera_name
|
||||
)
|
||||
if base_sections:
|
||||
camera_dict["base_config"] = base_sections
|
||||
# copy so redaction below can't alter the profile manager's cache
|
||||
camera_dict["base_config"] = copy.deepcopy(base_sections)
|
||||
|
||||
# cameras inherit the global notification email
|
||||
if not is_admin:
|
||||
redact_credential(camera_dict["notifications"], "email")
|
||||
redact_credential(
|
||||
camera_dict.get("base_config", {}).get("notifications", {}), "email"
|
||||
)
|
||||
|
||||
# remove go2rtc stream passwords
|
||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||
|
||||
+3
-7
@@ -77,7 +77,7 @@ from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.output.output import OutputProcess
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
from frigate.record.cleanup import RecordingCleanup
|
||||
from frigate.record.export import migrate_exports
|
||||
@@ -166,11 +166,7 @@ class FrigateApp:
|
||||
# create camera_metrics
|
||||
for camera_name in self.config.cameras.keys():
|
||||
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[camera_name] = PTZMetrics(
|
||||
autotracker_enabled=self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.enabled
|
||||
)
|
||||
self.ptz_metrics[camera_name] = PTZMetrics()
|
||||
|
||||
def init_queues(self) -> None:
|
||||
# Queue for cameras to push tracked objects to
|
||||
@@ -443,7 +439,7 @@ class FrigateApp:
|
||||
)
|
||||
|
||||
def start_ptz_autotracker(self) -> None:
|
||||
self.ptz_autotracker_thread = PtzAutoTrackerThread(
|
||||
self.ptz_autotracker_thread = PtzAutoTracker(
|
||||
self.config,
|
||||
self.onvif_controller,
|
||||
self.ptz_metrics,
|
||||
|
||||
@@ -43,8 +43,6 @@ class CameraMetrics:
|
||||
|
||||
|
||||
class PTZMetrics:
|
||||
autotracker_enabled: Synchronized
|
||||
|
||||
start_time: Synchronized
|
||||
stop_time: Synchronized
|
||||
frame_time: Synchronized
|
||||
@@ -52,13 +50,10 @@ class PTZMetrics:
|
||||
max_zoom: Synchronized
|
||||
min_zoom: Synchronized
|
||||
|
||||
tracking_active: Event
|
||||
motor_stopped: Event
|
||||
reset: Event
|
||||
|
||||
def __init__(self, *, autotracker_enabled: bool):
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
@@ -66,7 +61,6 @@ class PTZMetrics:
|
||||
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||
|
||||
self.tracking_active = mp.Event()
|
||||
self.motor_stopped = mp.Event()
|
||||
self.reset = mp.Event()
|
||||
|
||||
|
||||
@@ -120,9 +120,7 @@ class CameraMaintainer(threading.Thread):
|
||||
|
||||
if runtime:
|
||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[name] = PTZMetrics(
|
||||
autotracker_enabled=config.onvif.autotracking.enabled
|
||||
)
|
||||
self.ptz_metrics[name] = PTZMetrics()
|
||||
self.region_grids[name] = get_camera_regions_grid(
|
||||
name,
|
||||
config.detect,
|
||||
|
||||
+9
-13
@@ -16,7 +16,7 @@ from frigate.config import (
|
||||
ZoomingModeEnum,
|
||||
)
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
from frigate.track.tracked_object import TrackedObject
|
||||
from frigate.util.image import (
|
||||
SharedMemoryFrameManager,
|
||||
@@ -35,7 +35,7 @@ class CameraState:
|
||||
name: str,
|
||||
config: FrigateConfig,
|
||||
frame_manager: SharedMemoryFrameManager,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
ptz_autotracker_thread: PtzAutoTracker,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
@@ -115,17 +115,13 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
|
||||
self.name
|
||||
)
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
and self.ptz_autotracker_thread.autotracker_init.get(self.name)
|
||||
and self.ptz_autotracker_thread.tracked_object[self.name]
|
||||
is not None
|
||||
and obj["id"]
|
||||
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
== self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
|
||||
self.name
|
||||
].obj_data["id"] # type: ignore[attr-defined]
|
||||
].obj_data["id"]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
@@ -138,9 +134,9 @@ class CameraState:
|
||||
and self.camera_config.detect.width is not None
|
||||
and self.camera_config.detect.height is not None
|
||||
):
|
||||
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
|
||||
self.name
|
||||
]["max_target_box"] # type: ignore[index]
|
||||
max_target_box = calculate_max_target_box(
|
||||
self.camera_config.onvif.autotracking.zoom_factor
|
||||
)
|
||||
side_length = max_target_box * (
|
||||
max(
|
||||
self.camera_config.detect.width,
|
||||
|
||||
@@ -759,15 +759,13 @@ class Dispatcher:
|
||||
"Autotracking must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
if not ptz_autotracker_settings.enabled:
|
||||
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name].autotracker_enabled.value = True
|
||||
self.ptz_metrics[camera_name].start_time.value = 0
|
||||
ptz_autotracker_settings.enabled = True
|
||||
elif payload == "OFF":
|
||||
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
if ptz_autotracker_settings.enabled:
|
||||
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name].autotracker_enabled.value = False
|
||||
self.ptz_metrics[camera_name].start_time.value = 0
|
||||
ptz_autotracker_settings.enabled = False
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["NotificationConfig"]
|
||||
|
||||
@@ -11,7 +12,7 @@ class NotificationConfig(FrigateBaseModel):
|
||||
title="Enable notifications",
|
||||
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
email: str | None = Field(
|
||||
email: EnvString | None = Field(
|
||||
default=None,
|
||||
title="Notification email",
|
||||
description="Email address used for push notifications or required by certain notification providers.",
|
||||
|
||||
@@ -186,15 +186,28 @@ class ModelConfig(BaseModel):
|
||||
|
||||
# download the model if it doesn't exist
|
||||
if not os.path.isfile(self.path):
|
||||
download_url = plus_api.get_model_download_url(model_id)
|
||||
r = requests.get(download_url)
|
||||
try:
|
||||
download_url = plus_api.get_model_download_url(model_id)
|
||||
r = requests.get(download_url)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ValueError(
|
||||
f"Unable to connect to Frigate+ to download model {model_id}"
|
||||
) from e
|
||||
|
||||
with open(self.path, "wb") as f:
|
||||
f.write(r.content)
|
||||
|
||||
# download the model info if it doesn't exist
|
||||
if not os.path.isfile(model_info_path):
|
||||
try:
|
||||
model_info = plus_api.get_model_info(model_id)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ValueError(
|
||||
f"Unable to connect to Frigate+ to download model info for {model_id}"
|
||||
) from e
|
||||
|
||||
with open(model_info_path, "w") as f:
|
||||
json.dump(plus_api.get_model_info(model_id), f)
|
||||
json.dump(model_info, f)
|
||||
|
||||
model_info = load_plus_model_info(model_id)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
config: RuntimeMotionConfig,
|
||||
fps: int,
|
||||
ptz_metrics: PTZMetrics | None = None,
|
||||
autotracking_enabled: bool = False,
|
||||
name: str = "improved",
|
||||
blur_radius: int = 1,
|
||||
interpolation: int = cv2.INTER_NEAREST,
|
||||
@@ -45,6 +46,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
self.contrast_values[:, 1:2] = 255
|
||||
self.contrast_values_index = 0
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.autotracking_enabled = autotracking_enabled
|
||||
self.last_stop_time: float | None = None
|
||||
|
||||
def is_calibrating(self) -> bool:
|
||||
@@ -59,8 +61,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
# if ptz motor is moving from autotracking, quickly return
|
||||
# a single box that is 80% of the frame
|
||||
if self.ptz_metrics is not None and (
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
and not self.ptz_metrics.motor_stopped.is_set()
|
||||
self.autotracking_enabled and not self.ptz_metrics.motor_stopped.is_set()
|
||||
):
|
||||
return [
|
||||
(
|
||||
@@ -162,7 +163,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
# if so, reassign the average to the current frame so we begin with a new baseline
|
||||
if self.ptz_metrics is not None and (
|
||||
# ensure we only do this for cameras with autotracking enabled
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
self.autotracking_enabled
|
||||
and self.ptz_metrics.motor_stopped.is_set()
|
||||
and (
|
||||
self.last_stop_time is None
|
||||
|
||||
+15
-4
@@ -9,7 +9,9 @@ from typing import Any
|
||||
import cv2
|
||||
import requests
|
||||
from numpy import ndarray
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests.models import Response
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
|
||||
|
||||
@@ -101,6 +103,13 @@ class PlusApi:
|
||||
self._is_active: bool = self.key is not None
|
||||
self._token_data: dict = {}
|
||||
|
||||
# Retry connection failures so a network that comes up late at startup
|
||||
# doesn't fail the Frigate+ model download
|
||||
self._session = requests.Session()
|
||||
self._session.mount(
|
||||
self.host, HTTPAdapter(max_retries=Retry(connect=5, backoff_factor=1))
|
||||
)
|
||||
|
||||
def _refresh_token_if_needed(self) -> None:
|
||||
if (
|
||||
self._token_data.get("expires") is None
|
||||
@@ -111,7 +120,9 @@ class PlusApi:
|
||||
"Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key"
|
||||
)
|
||||
parts = self.key.split(":")
|
||||
r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1]))
|
||||
r = self._session.get(
|
||||
f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])
|
||||
)
|
||||
if not r.ok:
|
||||
raise Exception(f"Unable to refresh API token: {r.text}")
|
||||
self._token_data = r.json()
|
||||
@@ -121,19 +132,19 @@ class PlusApi:
|
||||
return {"authorization": f"Bearer {self._token_data.get('accessToken')}"}
|
||||
|
||||
def _get(self, path: str) -> Response:
|
||||
return requests.get(
|
||||
return self._session.get(
|
||||
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
|
||||
)
|
||||
|
||||
def _post(self, path: str, data: dict) -> Response:
|
||||
return requests.post(
|
||||
return self._session.post(
|
||||
f"{self.host}/v1/{path}",
|
||||
headers=self._get_authorization_header(),
|
||||
json=data,
|
||||
)
|
||||
|
||||
def _put(self, path: str, data: dict) -> Response:
|
||||
return requests.put(
|
||||
return self._session.put(
|
||||
f"{self.host}/v1/{path}",
|
||||
headers=self._get_authorization_header(),
|
||||
json=data,
|
||||
|
||||
+176
-321
@@ -23,6 +23,7 @@ from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import (
|
||||
AUTOTRACKING_MAX_AREA_RATIO,
|
||||
@@ -42,6 +43,11 @@ from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_max_target_box(zoom_factor: float) -> float:
|
||||
"""Return the largest target box ratio allowed for a zoom factor."""
|
||||
return AUTOTRACKING_MAX_AREA_RATIO ** (1 / zoom_factor)
|
||||
|
||||
|
||||
def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
|
||||
# Determine if the PTZ was in motion at the set frame time
|
||||
# for non ptz/autotracking cameras, this will always return False
|
||||
@@ -180,7 +186,7 @@ class PtzMotionEstimator:
|
||||
return self.coord_transformations
|
||||
|
||||
|
||||
class PtzAutoTrackerThread(threading.Thread):
|
||||
class PtzAutoTracker(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
@@ -190,56 +196,14 @@ class PtzAutoTrackerThread(threading.Thread):
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
super().__init__(name="ptz_autotracker")
|
||||
self.ptz_autotracker = PtzAutoTracker(
|
||||
config, onvif, ptz_metrics, dispatcher, stop_event
|
||||
)
|
||||
self.stop_event = stop_event
|
||||
self.config = config
|
||||
|
||||
def run(self):
|
||||
while not self.stop_event.wait(1):
|
||||
self.ptz_autotracker.check_for_updates()
|
||||
|
||||
for camera, camera_config in list(self.config.cameras.items()):
|
||||
if not camera_config.enabled:
|
||||
continue
|
||||
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.ptz_autotracker.camera_maintenance(camera),
|
||||
self.ptz_autotracker.onvif.loop,
|
||||
)
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
else:
|
||||
# disabled dynamically by mqtt
|
||||
if self.ptz_autotracker.tracked_object.get(camera):
|
||||
self.ptz_autotracker.tracked_object[camera] = None
|
||||
self.ptz_autotracker.tracked_object_history[camera].clear()
|
||||
|
||||
self.ptz_autotracker.config_subscriber.stop()
|
||||
logger.info("Exiting autotracker...")
|
||||
|
||||
|
||||
class PtzAutoTracker:
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
onvif: OnvifController,
|
||||
ptz_metrics: PTZMetrics,
|
||||
dispatcher: Dispatcher,
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.onvif = onvif
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.dispatcher = dispatcher
|
||||
self.stop_event = stop_event
|
||||
self.tracked_object: dict[str, object] = {}
|
||||
self.tracked_object: dict[str, TrackedObject | None] = {}
|
||||
self.tracked_object_history: dict[str, object] = {}
|
||||
self.tracked_object_metrics: dict[str, object] = {}
|
||||
self.object_types: dict[str, object] = {}
|
||||
self.required_zones: dict[str, object] = {}
|
||||
self.tracked_object_metrics: dict[str, dict[str, Any]] = {}
|
||||
self.move_queues: dict[str, object] = {}
|
||||
self.move_queue_locks: dict[str, object] = {}
|
||||
self.move_threads: dict[str, object] = {}
|
||||
@@ -249,7 +213,6 @@ class PtzAutoTracker:
|
||||
self.intercept: dict[str, object] = {}
|
||||
self.move_coefficients: dict[str, object] = {}
|
||||
self.zoom_time: dict[str, float] = {}
|
||||
self.zoom_factor: dict[str, object] = {}
|
||||
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
@@ -277,44 +240,37 @@ class PtzAutoTracker:
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""Apply camera config updates and mirror autotracking state to ptz metrics.
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.wait(1):
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
The camera processes read autotracker_enabled rather than the config, so it
|
||||
has to follow every path that can change autotracking, not just the mqtt
|
||||
toggle that writes it directly.
|
||||
"""
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
for cameras in updates.values():
|
||||
for camera in cameras:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
metrics = self.ptz_metrics.get(camera)
|
||||
|
||||
# a camera added at runtime gets its metrics from the maintainer on
|
||||
# another thread, which seeds them from this same config value
|
||||
if camera_config is None or metrics is None:
|
||||
for camera, camera_config in list(self.config.cameras.items()):
|
||||
if not camera_config.enabled:
|
||||
continue
|
||||
|
||||
metrics.autotracker_enabled.value = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.camera_maintenance(camera), self.onvif.loop
|
||||
)
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
else:
|
||||
# disabled dynamically by mqtt
|
||||
if self.tracked_object.get(camera):
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_history[camera].clear()
|
||||
|
||||
self.config_subscriber.stop()
|
||||
logger.info("Exiting autotracker...")
|
||||
|
||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
self.object_types[camera] = camera_config.onvif.autotracking.track
|
||||
self.required_zones[camera] = camera_config.onvif.autotracking.required_zones
|
||||
self.zoom_factor[camera] = camera_config.onvif.autotracking.zoom_factor
|
||||
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_history[camera] = deque(
|
||||
maxlen=round(camera_config.detect.fps * 1.5)
|
||||
)
|
||||
self.tracked_object_metrics[camera] = {
|
||||
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||
** (1 / self.zoom_factor[camera])
|
||||
}
|
||||
self._reset_tracked_object_metrics(camera)
|
||||
|
||||
self.calibrating[camera] = False
|
||||
self.move_metrics[camera] = []
|
||||
@@ -326,43 +282,22 @@ class PtzAutoTracker:
|
||||
|
||||
# handle onvif constructor failing due to no connection
|
||||
if camera not in self.onvif.cams:
|
||||
logger.warning(
|
||||
f"Disabling autotracking for {camera}: onvif connection failed"
|
||||
)
|
||||
camera_config.onvif.autotracking.enabled = False
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
self._disable(camera, "onvif connection failed")
|
||||
return
|
||||
|
||||
if not self.onvif.cams[camera]["init"]:
|
||||
if not await self.onvif._init_onvif(camera):
|
||||
logger.warning(
|
||||
f"Disabling autotracking for {camera}: Unable to initialize onvif"
|
||||
)
|
||||
camera_config.onvif.autotracking.enabled = False
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
self._disable(camera, "Unable to initialize onvif")
|
||||
return
|
||||
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
logger.warning(
|
||||
f"Disabling autotracking for {camera}: FOV relative movement not supported"
|
||||
)
|
||||
camera_config.onvif.autotracking.enabled = False
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
|
||||
if not (
|
||||
isinstance(move_status_supported, bool) and move_status_supported
|
||||
) and not (
|
||||
isinstance(move_status_supported, str)
|
||||
and move_status_supported.lower() == "true"
|
||||
):
|
||||
logger.warning(
|
||||
f"Disabling autotracking for {camera}: ONVIF MoveStatus not supported"
|
||||
)
|
||||
camera_config.onvif.autotracking.enabled = False
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
|
||||
if self.onvif.cams[camera]["init"]:
|
||||
@@ -374,59 +309,41 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
if len(camera_config.onvif.autotracking.movement_weights) == 6:
|
||||
camera_config.onvif.autotracking.movement_weights = [
|
||||
float(val)
|
||||
for val in camera_config.onvif.autotracking.movement_weights
|
||||
]
|
||||
self.ptz_metrics[
|
||||
camera
|
||||
].min_zoom.value = (
|
||||
camera_config.onvif.autotracking.movement_weights[0]
|
||||
)
|
||||
self.ptz_metrics[
|
||||
camera
|
||||
].max_zoom.value = (
|
||||
camera_config.onvif.autotracking.movement_weights[1]
|
||||
)
|
||||
self.intercept[camera] = (
|
||||
camera_config.onvif.autotracking.movement_weights[2]
|
||||
)
|
||||
self.move_coefficients[camera] = (
|
||||
camera_config.onvif.autotracking.movement_weights[3:5]
|
||||
)
|
||||
self.zoom_time[camera] = (
|
||||
camera_config.onvif.autotracking.movement_weights[5]
|
||||
)
|
||||
else:
|
||||
camera_config.onvif.autotracking.enabled = False
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
logger.warning(
|
||||
f"Autotracker recalibration is required for {camera}. Disabling autotracking."
|
||||
)
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
|
||||
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
|
||||
self.ptz_metrics[camera].tracking_active.clear()
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||
self.autotracker_init[camera] = True
|
||||
|
||||
def _write_config(self, camera):
|
||||
config_file = find_config_file()
|
||||
def _disable(self, camera: str, reason: str) -> None:
|
||||
logger.warning(f"Disabling autotracking for {camera}: {reason}")
|
||||
autotracking_config = self.config.cameras[camera].onvif.autotracking
|
||||
autotracking_config.enabled = False
|
||||
|
||||
logger.debug(
|
||||
f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}"
|
||||
# the camera process holds its own copy of the config
|
||||
self.dispatcher.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
||||
autotracking_config,
|
||||
)
|
||||
|
||||
update_yaml_file_bulk(
|
||||
config_file,
|
||||
{
|
||||
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
|
||||
camera
|
||||
].onvif.autotracking.movement_weights
|
||||
},
|
||||
)
|
||||
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
||||
self.tracked_object_metrics[camera] = {}
|
||||
|
||||
async def _wait_until_stopped(
|
||||
self, camera: str, metrics: PTZMetrics | None = None
|
||||
) -> None:
|
||||
metrics = metrics or self.ptz_metrics[camera]
|
||||
|
||||
while not metrics.motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
async def _calibrate_camera(self, camera):
|
||||
# move the camera from the preset in steps and measure the time it takes to move that amount
|
||||
@@ -459,8 +376,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -470,8 +386,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -488,8 +403,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -503,8 +417,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
zoom_stop_time = time.time()
|
||||
|
||||
@@ -518,8 +431,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
full_relative_stop_time = time.time()
|
||||
|
||||
@@ -531,8 +443,7 @@ class PtzAutoTracker:
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
self.zoom_time[camera] = (
|
||||
full_relative_stop_time - full_relative_start_time
|
||||
@@ -558,9 +469,7 @@ class PtzAutoTracker:
|
||||
self.ptz_metrics[camera].reset.set()
|
||||
self.ptz_metrics[camera].motor_stopped.clear()
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
for step in range(num_steps):
|
||||
pan = step_sizes[step]
|
||||
@@ -569,9 +478,7 @@ class PtzAutoTracker:
|
||||
start_time = time.time()
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
stop_time = time.time()
|
||||
|
||||
self.move_metrics[camera].append(
|
||||
@@ -590,9 +497,7 @@ class PtzAutoTracker:
|
||||
self.ptz_metrics[camera].reset.set()
|
||||
self.ptz_metrics[camera].motor_stopped.clear()
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
logger.info(
|
||||
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
|
||||
@@ -668,7 +573,14 @@ class PtzAutoTracker:
|
||||
f"{camera}: New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
|
||||
)
|
||||
|
||||
self._write_config(camera)
|
||||
update_yaml_file_bulk(
|
||||
find_config_file(),
|
||||
{
|
||||
f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[
|
||||
camera
|
||||
].onvif.autotracking.movement_weights
|
||||
},
|
||||
)
|
||||
|
||||
def _predict_movement_time(self, camera, pan, tilt):
|
||||
combined_movement = abs(pan) + abs(tilt)
|
||||
@@ -682,6 +594,18 @@ class PtzAutoTracker:
|
||||
[self.tracked_object_history[camera][-1]["frame_time"] + time],
|
||||
)
|
||||
|
||||
def _predict_target_box(self, camera, predicted_time):
|
||||
target_box = self.tracked_object_metrics[camera]["target_box"]
|
||||
|
||||
if not predicted_time:
|
||||
return target_box
|
||||
|
||||
frame_shape = self.config.cameras[camera].frame_shape
|
||||
|
||||
return target_box + self._predict_area_after_time(camera, predicted_time) / (
|
||||
frame_shape[0] * frame_shape[1]
|
||||
)
|
||||
|
||||
def _calculate_tracked_object_metrics(self, camera, obj):
|
||||
def remove_outliers(data):
|
||||
areas = [item["area"] for item in data]
|
||||
@@ -703,19 +627,20 @@ class PtzAutoTracker:
|
||||
return filtered_data
|
||||
|
||||
camera_config = self.config.cameras[camera]
|
||||
tom = self.tracked_object_metrics[camera]
|
||||
zoom_factor = camera_config.onvif.autotracking.zoom_factor
|
||||
camera_width = camera_config.frame_shape[1]
|
||||
camera_height = camera_config.frame_shape[0]
|
||||
|
||||
# Extract areas and calculate weighted average
|
||||
# grab the largest dimension of the bounding box and create a square from that
|
||||
# Filter out the initial frame and use a recent time window
|
||||
# Use a recent time window
|
||||
current_time = obj.obj_data["frame_time"]
|
||||
time_window = 1.5 # seconds
|
||||
history = [
|
||||
entry
|
||||
for entry in self.tracked_object_history[camera]
|
||||
if not entry.get("is_initial_frame", False)
|
||||
and current_time - entry["frame_time"] <= time_window
|
||||
if current_time - entry["frame_time"] <= time_window
|
||||
]
|
||||
if not history: # Fallback to latest if no recent entries
|
||||
history = [self.tracked_object_history[camera][-1]]
|
||||
@@ -748,33 +673,29 @@ class PtzAutoTracker:
|
||||
)
|
||||
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
||||
|
||||
self.tracked_object_metrics[camera]["area_coefficients"] = np.linalg.lstsq(
|
||||
X.reshape(-1, 1), y, rcond=None
|
||||
)[0]
|
||||
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
|
||||
0
|
||||
]
|
||||
else:
|
||||
self.tracked_object_metrics[camera]["area_coefficients"] = np.array([0])
|
||||
tom["area_coefficients"] = np.array([0])
|
||||
|
||||
weights = np.arange(1, len(filtered_areas) + 1)
|
||||
weighted_area = np.average(
|
||||
[item["area"] for item in filtered_areas], weights=weights
|
||||
)
|
||||
|
||||
self.tracked_object_metrics[camera]["target_box"] = (
|
||||
tom["target_box"] = (
|
||||
weighted_area / (camera_width * camera_height)
|
||||
) ** self.zoom_factor[camera]
|
||||
) ** zoom_factor
|
||||
|
||||
if "original_target_box" not in self.tracked_object_metrics[camera]:
|
||||
self.tracked_object_metrics[camera]["original_target_box"] = (
|
||||
self.tracked_object_metrics[camera]["target_box"]
|
||||
)
|
||||
if "original_target_box" not in tom:
|
||||
tom["original_target_box"] = tom["target_box"]
|
||||
|
||||
(
|
||||
self.tracked_object_metrics[camera]["valid_velocity"],
|
||||
self.tracked_object_metrics[camera]["velocity"],
|
||||
tom["valid_velocity"],
|
||||
tom["velocity"],
|
||||
) = self._get_valid_velocity(camera, obj)
|
||||
self.tracked_object_metrics[camera]["distance"] = self._get_distance_threshold(
|
||||
camera, obj
|
||||
)
|
||||
tom["distance"] = self._get_distance_threshold(camera, obj)
|
||||
|
||||
centroid_distance = np.linalg.norm(
|
||||
[
|
||||
@@ -785,9 +706,7 @@ class PtzAutoTracker:
|
||||
|
||||
logger.debug(f"{camera}: Centroid distance: {centroid_distance}")
|
||||
|
||||
self.tracked_object_metrics[camera]["below_distance_threshold"] = (
|
||||
centroid_distance < self.tracked_object_metrics[camera]["distance"]
|
||||
)
|
||||
tom["below_distance_threshold"] = centroid_distance < tom["distance"]
|
||||
|
||||
async def _process_move_queue(self, camera):
|
||||
move_queue = self.move_queues[camera]
|
||||
@@ -833,16 +752,12 @@ class PtzAutoTracker:
|
||||
if pan != 0 or tilt != 0:
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not metrics.motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
if zoom > 0 and metrics.zoom_level.value != zoom:
|
||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not metrics.motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
logger.debug(
|
||||
@@ -878,41 +793,20 @@ class PtzAutoTracker:
|
||||
await move_queue.get()
|
||||
|
||||
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
|
||||
def split_value(value, suppress_diff=True):
|
||||
clipped = np.clip(value, -1, 1)
|
||||
|
||||
# don't make small movements
|
||||
if -0.05 < clipped < 0.05 and suppress_diff:
|
||||
diff = 0.0
|
||||
else:
|
||||
diff = value - clipped
|
||||
|
||||
return clipped, diff
|
||||
pan, tilt, zoom = (np.clip(value, -1, 1) for value in (pan, tilt, zoom))
|
||||
|
||||
if (
|
||||
frame_time > self.ptz_metrics[camera].start_time.value
|
||||
(pan != 0 or tilt != 0 or zoom != 0)
|
||||
and frame_time > self.ptz_metrics[camera].start_time.value
|
||||
and frame_time > self.ptz_metrics[camera].stop_time.value
|
||||
and not self.move_queue_locks[camera].locked()
|
||||
):
|
||||
# we can split up any large moves caused by velocity estimated movements if necessary
|
||||
# get an excess amount and assign it instead of 0 below
|
||||
while pan != 0 or tilt != 0 or zoom != 0:
|
||||
pan, _ = split_value(pan)
|
||||
tilt, _ = split_value(tilt)
|
||||
zoom, _ = split_value(zoom, False)
|
||||
|
||||
logger.debug(
|
||||
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
|
||||
)
|
||||
move_data = (frame_time, pan, tilt, zoom)
|
||||
self.onvif.loop.call_soon_threadsafe(
|
||||
self.move_queues[camera].put_nowait, move_data
|
||||
)
|
||||
|
||||
# reset values to not split up large movements
|
||||
pan = 0
|
||||
tilt = 0
|
||||
zoom = 0
|
||||
logger.debug(
|
||||
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
|
||||
)
|
||||
self.onvif.loop.call_soon_threadsafe(
|
||||
self.move_queues[camera].put_nowait, (frame_time, pan, tilt, zoom)
|
||||
)
|
||||
|
||||
def _touching_frame_edges(self, camera, box):
|
||||
camera_config = self.config.cameras[camera]
|
||||
@@ -1046,16 +940,17 @@ class PtzAutoTracker:
|
||||
|
||||
return distance_threshold
|
||||
|
||||
def _should_zoom_in(
|
||||
self, camera: str, obj: TrackedObject, box, predicted_time, debug_zooming=False
|
||||
):
|
||||
def _should_zoom_in(self, camera: str, box, predicted_time):
|
||||
# returns True if we should zoom in, False if we should zoom out, None to do nothing
|
||||
camera_config = self.config.cameras[camera]
|
||||
tom = self.tracked_object_metrics[camera]
|
||||
zoom_factor = camera_config.onvif.autotracking.zoom_factor
|
||||
max_target_box = calculate_max_target_box(zoom_factor)
|
||||
camera_width = camera_config.frame_shape[1]
|
||||
camera_height = camera_config.frame_shape[0]
|
||||
camera_fps = camera_config.detect.fps
|
||||
|
||||
average_velocity = self.tracked_object_metrics[camera]["velocity"]
|
||||
average_velocity = tom["velocity"]
|
||||
|
||||
bb_left, bb_top, bb_right, bb_bottom = box
|
||||
|
||||
@@ -1073,13 +968,11 @@ class PtzAutoTracker:
|
||||
touching_frame_edges = self._touching_frame_edges(camera, box)
|
||||
|
||||
# make sure object is centered in the frame
|
||||
below_distance_threshold = self.tracked_object_metrics[camera][
|
||||
"below_distance_threshold"
|
||||
]
|
||||
below_distance_threshold = tom["below_distance_threshold"]
|
||||
|
||||
below_dimension_threshold = (bb_right - bb_left) <= camera_width * (
|
||||
self.zoom_factor[camera] + 0.1
|
||||
) and (bb_bottom - bb_top) <= camera_height * (self.zoom_factor[camera] + 0.1)
|
||||
zoom_factor + 0.1
|
||||
) and (bb_bottom - bb_top) <= camera_height * (zoom_factor + 0.1)
|
||||
|
||||
# ensure object is not moving quickly
|
||||
below_velocity_threshold = np.all(
|
||||
@@ -1087,30 +980,16 @@ class PtzAutoTracker:
|
||||
< np.tile([velocity_threshold_x, velocity_threshold_y], 2)
|
||||
) or np.all(average_velocity == 0)
|
||||
|
||||
if not predicted_time:
|
||||
calculated_target_box = self.tracked_object_metrics[camera]["target_box"]
|
||||
else:
|
||||
calculated_target_box = self.tracked_object_metrics[camera][
|
||||
"target_box"
|
||||
] + self._predict_area_after_time(camera, predicted_time) / (
|
||||
camera_width * camera_height
|
||||
)
|
||||
calculated_target_box = self._predict_target_box(camera, predicted_time)
|
||||
|
||||
below_area_threshold = (
|
||||
calculated_target_box
|
||||
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||
)
|
||||
below_area_threshold = calculated_target_box < max_target_box
|
||||
|
||||
# introduce some hysteresis to prevent a yo-yo zooming effect
|
||||
zoom_out_hysteresis = (
|
||||
calculated_target_box
|
||||
> self.tracked_object_metrics[camera]["max_target_box"]
|
||||
* AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
||||
calculated_target_box > max_target_box * AUTOTRACKING_ZOOM_OUT_HYSTERESIS
|
||||
)
|
||||
zoom_in_hysteresis = (
|
||||
calculated_target_box
|
||||
< self.tracked_object_metrics[camera]["max_target_box"]
|
||||
* AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
||||
calculated_target_box < max_target_box * AUTOTRACKING_ZOOM_IN_HYSTERESIS
|
||||
)
|
||||
|
||||
at_max_zoom = (
|
||||
@@ -1122,31 +1001,29 @@ class PtzAutoTracker:
|
||||
== self.ptz_metrics[camera].min_zoom.value
|
||||
)
|
||||
|
||||
# debug zooming
|
||||
if debug_zooming:
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {self.tracked_object_metrics[camera]['target_box']}, calculated: {calculated_target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (self.zoom_factor[camera] + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (self.zoom_factor[camera] + 0.1)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
|
||||
)
|
||||
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
|
||||
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: touching edges: count: {touching_frame_edges} left: {bb_left < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_width}, right: {bb_right > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_width}, top: {bb_top < AUTOTRACKING_ZOOM_EDGE_THRESHOLD * camera_height}, bottom: {bb_bottom > (1 - AUTOTRACKING_ZOOM_EDGE_THRESHOLD) * camera_height}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below distance threshold: {(below_distance_threshold)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below area threshold: {(below_area_threshold)} target: {tom['target_box']}, calculated: {calculated_target_box}, max: {max_target_box}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (zoom_factor + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (zoom_factor + 0.1)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}"
|
||||
)
|
||||
logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}")
|
||||
logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}")
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {tom['original_target_box']} max: {max_target_box} target: {calculated_target_box if calculated_target_box else tom['target_box']}"
|
||||
)
|
||||
|
||||
# Zoom in conditions (and)
|
||||
if (
|
||||
@@ -1237,7 +1114,7 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
zoom = self._get_zoom_amount(
|
||||
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True
|
||||
camera, obj, predicted_box, predicted_movement_time
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -1298,9 +1175,11 @@ class PtzAutoTracker:
|
||||
obj: TrackedObject,
|
||||
predicted_box,
|
||||
predicted_movement_time,
|
||||
debug_zoom=True,
|
||||
):
|
||||
camera_config = self.config.cameras[camera]
|
||||
tom = self.tracked_object_metrics[camera]
|
||||
zoom_factor = camera_config.onvif.autotracking.zoom_factor
|
||||
max_target_box = calculate_max_target_box(zoom_factor)
|
||||
|
||||
# frame width and height
|
||||
camera_width = camera_config.frame_shape[1]
|
||||
@@ -1317,16 +1196,12 @@ class PtzAutoTracker:
|
||||
# absolute zooming separately from pan/tilt
|
||||
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
|
||||
# don't zoom on initial move
|
||||
if "target_box" not in self.tracked_object_metrics[camera]:
|
||||
if "target_box" not in tom:
|
||||
zoom = current_zoom_level
|
||||
else:
|
||||
if (
|
||||
result := self._should_zoom_in(
|
||||
camera,
|
||||
obj,
|
||||
obj.obj_data["box"],
|
||||
predicted_movement_time,
|
||||
debug_zoom,
|
||||
camera, obj.obj_data["box"], predicted_movement_time
|
||||
)
|
||||
) is not None:
|
||||
# divide zoom in 10 increments and always zoom out more than in
|
||||
@@ -1342,46 +1217,35 @@ class PtzAutoTracker:
|
||||
# relative zooming concurrently with pan/tilt
|
||||
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
|
||||
# this is our initial zoom in on a new object
|
||||
if "target_box" not in self.tracked_object_metrics[camera]:
|
||||
zoom = target_box ** self.zoom_factor[camera]
|
||||
if zoom > self.tracked_object_metrics[camera]["max_target_box"]:
|
||||
if "target_box" not in tom:
|
||||
zoom = target_box**zoom_factor
|
||||
if zoom > max_target_box:
|
||||
zoom = -(1 - zoom)
|
||||
logger.debug(
|
||||
f"{camera}: target box: {target_box}, max: {self.tracked_object_metrics[camera]['max_target_box']}, calc zoom: {zoom}"
|
||||
f"{camera}: target box: {target_box}, max: {max_target_box}, calc zoom: {zoom}"
|
||||
)
|
||||
else:
|
||||
if (
|
||||
result := self._should_zoom_in(
|
||||
camera,
|
||||
obj,
|
||||
predicted_box
|
||||
if camera_config.onvif.autotracking.movement_weights
|
||||
else obj.obj_data["box"],
|
||||
predicted_movement_time,
|
||||
debug_zoom,
|
||||
)
|
||||
) is not None:
|
||||
if predicted_movement_time:
|
||||
calculated_target_box = self.tracked_object_metrics[camera][
|
||||
"target_box"
|
||||
] + self._predict_area_after_time(
|
||||
camera, predicted_movement_time
|
||||
) / (camera_width * camera_height)
|
||||
logger.debug(
|
||||
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {self.tracked_object_metrics[camera]['target_box']}, calculated box: {calculated_target_box}"
|
||||
)
|
||||
else:
|
||||
calculated_target_box = self.tracked_object_metrics[camera][
|
||||
"target_box"
|
||||
]
|
||||
# zoom value
|
||||
ratio = (
|
||||
self.tracked_object_metrics[camera]["max_target_box"]
|
||||
/ calculated_target_box
|
||||
calculated_target_box = self._predict_target_box(
|
||||
camera, predicted_movement_time
|
||||
)
|
||||
if predicted_movement_time:
|
||||
logger.debug(
|
||||
f"{camera}: Zooming prediction: predicted movement time: {predicted_movement_time}, original box: {tom['target_box']}, calculated box: {calculated_target_box}"
|
||||
)
|
||||
# zoom value
|
||||
ratio = max_target_box / calculated_target_box
|
||||
zoom = (ratio - 1) / (ratio + 1)
|
||||
logger.debug(
|
||||
f"{camera}: limit: {self.tracked_object_metrics[camera]['max_target_box']}, ratio: {ratio} zoom calculation: {zoom}"
|
||||
f"{camera}: limit: {max_target_box}, ratio: {ratio} zoom calculation: {zoom}"
|
||||
)
|
||||
if not result:
|
||||
# zoom out with special condition if zooming out because of velocity, edges, etc.
|
||||
@@ -1394,9 +1258,6 @@ class PtzAutoTracker:
|
||||
|
||||
return zoom
|
||||
|
||||
def is_autotracking(self, camera: str):
|
||||
return self.tracked_object[camera] is not None
|
||||
|
||||
def autotrack_object(self, camera: str, obj: TrackedObject):
|
||||
if camera not in self.config.cameras:
|
||||
return
|
||||
@@ -1420,8 +1281,9 @@ class PtzAutoTracker:
|
||||
# new object
|
||||
self.tracked_object[camera] is None
|
||||
and obj.camera_config.name == camera
|
||||
and obj.obj_data["label"] in self.object_types[camera]
|
||||
and set(obj.entered_zones) & set(self.required_zones[camera])
|
||||
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
|
||||
and set(obj.entered_zones)
|
||||
& set(camera_config.onvif.autotracking.required_zones)
|
||||
and not obj.previous["false_positive"]
|
||||
and not obj.false_positive
|
||||
and not self.tracked_object_history[camera]
|
||||
@@ -1430,7 +1292,6 @@ class PtzAutoTracker:
|
||||
logger.debug(
|
||||
f"{camera}: New object: {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"
|
||||
)
|
||||
self.ptz_metrics[camera].tracking_active.set()
|
||||
self.dispatcher.publish(
|
||||
f"{camera}/ptz_autotracker/active", "ON", retain=False
|
||||
)
|
||||
@@ -1479,7 +1340,7 @@ class PtzAutoTracker:
|
||||
# Should we check region (maybe too broad) or expand the previous object's box a bit and check that?
|
||||
self.tracked_object[camera] is None
|
||||
and obj.camera_config.name == camera
|
||||
and obj.obj_data["label"] in self.object_types[camera]
|
||||
and obj.obj_data["label"] in camera_config.onvif.autotracking.track
|
||||
and not obj.previous["false_positive"]
|
||||
and not obj.false_positive
|
||||
and self.tracked_object_history[camera]
|
||||
@@ -1515,10 +1376,7 @@ class PtzAutoTracker:
|
||||
f"{camera}: End object: {obj.obj_data['id']} {obj.obj_data['box']}"
|
||||
)
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_metrics[camera] = {
|
||||
"max_target_box": AUTOTRACKING_MAX_AREA_RATIO
|
||||
** (1 / self.zoom_factor[camera])
|
||||
}
|
||||
self._reset_tracked_object_metrics(camera)
|
||||
|
||||
async def camera_maintenance(self, camera):
|
||||
# bail and don't check anything if we're not set up yet, calibrating, or
|
||||
@@ -1555,8 +1413,7 @@ class PtzAutoTracker:
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_history[camera].clear()
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
logger.debug(
|
||||
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
|
||||
)
|
||||
@@ -1566,10 +1423,8 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
# update stored zoom level from preset
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
await self._wait_until_stopped(camera)
|
||||
|
||||
self.ptz_metrics[camera].tracking_active.clear()
|
||||
self.dispatcher.publish(
|
||||
f"{camera}/ptz_autotracker/active", "OFF", retain=False
|
||||
)
|
||||
|
||||
+205
-304
@@ -10,8 +10,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy
|
||||
from onvif import ONVIFCamera, ONVIFError, ONVIFService
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from onvif import ONVIFCamera, ONVIFService
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
@@ -41,6 +40,14 @@ class OnvifCommandEnum(str, Enum):
|
||||
focus_out = "focus_out"
|
||||
|
||||
|
||||
PAN_TILT_VELOCITY = {
|
||||
OnvifCommandEnum.move_left: (-0.5, 0),
|
||||
OnvifCommandEnum.move_right: (0.5, 0),
|
||||
OnvifCommandEnum.move_up: (0, 0.5),
|
||||
OnvifCommandEnum.move_down: (0, -0.5),
|
||||
}
|
||||
|
||||
|
||||
class OnvifController:
|
||||
ptz_metrics: dict[str, PTZMetrics]
|
||||
|
||||
@@ -61,14 +68,6 @@ class OnvifController:
|
||||
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
||||
self.loop_thread.start()
|
||||
|
||||
self.camera_configs = {}
|
||||
for cam_name, cam in config.cameras.items():
|
||||
if not cam.enabled:
|
||||
continue
|
||||
if cam.onvif.host:
|
||||
self.camera_configs[cam_name] = cam
|
||||
self.status_locks[cam_name] = asyncio.Lock()
|
||||
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
@@ -92,8 +91,9 @@ class OnvifController:
|
||||
|
||||
async def _init_cameras(self) -> None:
|
||||
"""Initialize all configured cameras."""
|
||||
for cam_name in self.camera_configs:
|
||||
await self._init_single_camera(cam_name)
|
||||
for cam_name, cam in list(self.config.cameras.items()):
|
||||
if cam.enabled and cam.onvif.host:
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _poll_config_updates(self) -> None:
|
||||
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
||||
@@ -129,13 +129,9 @@ class OnvifController:
|
||||
|
||||
async def _remove_camera(self, cam_name: str) -> None:
|
||||
"""Tear down the ONVIF session for a camera removed at runtime."""
|
||||
if cam_name not in self.cams and cam_name not in self.camera_configs:
|
||||
return
|
||||
|
||||
logger.debug(f"Tearing down ONVIF for {cam_name} after camera removal")
|
||||
await self._close_camera(cam_name)
|
||||
self.cams.pop(cam_name, None)
|
||||
self.camera_configs.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
self.status_locks.pop(cam_name, None)
|
||||
|
||||
@@ -143,25 +139,14 @@ class OnvifController:
|
||||
"""Re-initialize a camera after config change."""
|
||||
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
||||
|
||||
# close existing session before re-init
|
||||
# close existing session and reset state before re-init
|
||||
await self._close_camera(cam_name)
|
||||
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if not cam or not cam.onvif.host:
|
||||
# ONVIF removed from config, clean up
|
||||
self.cams.pop(cam_name, None)
|
||||
self.camera_configs.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
return
|
||||
|
||||
# update stored config and reset state
|
||||
self.camera_configs[cam_name] = cam
|
||||
if cam_name not in self.status_locks:
|
||||
self.status_locks[cam_name] = asyncio.Lock()
|
||||
self.cams.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
|
||||
await self._init_single_camera(cam_name)
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if cam and cam.onvif.host:
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _init_single_camera(self, cam_name: str) -> bool:
|
||||
"""Initialize a single camera by name.
|
||||
@@ -172,11 +157,12 @@ class OnvifController:
|
||||
Returns:
|
||||
bool: True if initialization succeeded, False otherwise
|
||||
"""
|
||||
if cam_name not in self.camera_configs:
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if cam is None:
|
||||
logger.error(f"No configuration found for camera {cam_name}")
|
||||
return False
|
||||
|
||||
cam = self.camera_configs[cam_name]
|
||||
self.status_locks.setdefault(cam_name, asyncio.Lock())
|
||||
try:
|
||||
self.cams[cam_name] = {
|
||||
"onvif": ONVIFCamera(
|
||||
@@ -195,12 +181,11 @@ class OnvifController:
|
||||
"profiles": [],
|
||||
}
|
||||
return True
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
|
||||
# track initial failures
|
||||
self.failed_cams[cam_name] = {
|
||||
"retry_attempts": 0,
|
||||
"last_error": str(e),
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
return False
|
||||
@@ -211,7 +196,8 @@ class OnvifController:
|
||||
if camera_config is None:
|
||||
return False
|
||||
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
cam = self.cams[camera_name]
|
||||
onvif: ONVIFCamera = cam["onvif"]
|
||||
try:
|
||||
await onvif.update_xaddrs()
|
||||
except Exception as e:
|
||||
@@ -226,7 +212,7 @@ class OnvifController:
|
||||
# this will fire an exception if camera is not a ptz
|
||||
capabilities = onvif.get_definition("ptz")
|
||||
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -235,7 +221,7 @@ class OnvifController:
|
||||
try:
|
||||
profiles = await media.GetProfiles()
|
||||
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -254,7 +240,7 @@ class OnvifController:
|
||||
]
|
||||
|
||||
# store available profiles for API response and log for debugging
|
||||
self.cams[camera_name]["profiles"] = [
|
||||
cam["profiles"] = [
|
||||
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
||||
for p in valid_profiles
|
||||
]
|
||||
@@ -267,19 +253,18 @@ class OnvifController:
|
||||
)
|
||||
|
||||
configured_profile = camera_config.onvif.profile
|
||||
profile = None
|
||||
|
||||
if configured_profile is not None:
|
||||
# match by exact token first, then by name
|
||||
for p in valid_profiles:
|
||||
if p.token == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
for p in valid_profiles:
|
||||
if getattr(p, "Name", None) == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
profile = next(
|
||||
(
|
||||
p
|
||||
for key in ("token", "Name")
|
||||
for p in valid_profiles
|
||||
if getattr(p, key, None) == configured_profile
|
||||
),
|
||||
None,
|
||||
)
|
||||
if profile is None:
|
||||
available = [
|
||||
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
||||
@@ -304,39 +289,30 @@ class OnvifController:
|
||||
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
|
||||
# get the PTZ config for the profile
|
||||
try:
|
||||
configs = profile.PTZConfiguration
|
||||
logger.debug(
|
||||
f"Onvif ptz config for media profile in {camera_name}: {configs}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Invalid Onvif PTZ configuration for camera: {camera_name}: {e}"
|
||||
)
|
||||
return False
|
||||
configs = profile.PTZConfiguration
|
||||
logger.debug(f"Onvif ptz config for media profile in {camera_name}: {configs}")
|
||||
|
||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||
self.cams[camera_name]["ptz"] = ptz
|
||||
cam["ptz"] = ptz
|
||||
|
||||
try:
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
||||
imaging = None
|
||||
self.cams[camera_name]["imaging"] = imaging
|
||||
cam["imaging"] = imaging
|
||||
try:
|
||||
video_sources = await media.GetVideoSources()
|
||||
if video_sources and len(video_sources) > 0:
|
||||
self.cams[camera_name]["video_source_token"] = video_sources[0].token
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
cam["video_source_token"] = video_sources[0].token
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to get video sources for {camera_name}: {e}")
|
||||
self.cams[camera_name]["video_source_token"] = None
|
||||
cam["video_source_token"] = None
|
||||
|
||||
# setup continuous moving request
|
||||
move_request = ptz.create_type("ContinuousMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["move_request"] = move_request
|
||||
cam["move_request"] = move_request
|
||||
|
||||
# get PTZ configuration options for feature detection and relative movement
|
||||
ptz_config = None
|
||||
@@ -349,7 +325,7 @@ class OnvifController:
|
||||
logger.debug(
|
||||
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
||||
)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
||||
)
|
||||
@@ -375,18 +351,6 @@ class OnvifController:
|
||||
autotracking_config.enabled_in_config and autotracking_config.enabled
|
||||
)
|
||||
|
||||
# these are local and cost nothing to build, and autotracking can be enabled
|
||||
# after a camera is initialized, so always create them rather than baking the
|
||||
# current config value into init state
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
|
||||
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||
self.cams[camera_name]["service_capabilities_request"] = (
|
||||
service_capabilities_request
|
||||
)
|
||||
|
||||
# setup relative move request when FOV relative movement is supported
|
||||
if (
|
||||
fov_space_id is not None
|
||||
@@ -395,9 +359,7 @@ class OnvifController:
|
||||
# one-off GetStatus to seed Translation field
|
||||
status = None
|
||||
try:
|
||||
one_off_status_request = ptz.create_type("GetStatus")
|
||||
one_off_status_request.ProfileToken = profile.token
|
||||
status = await ptz.GetStatus(one_off_status_request)
|
||||
status = await ptz.GetStatus({"ProfileToken": profile.token})
|
||||
logger.debug(f"Onvif status for {camera_name}: {status}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
||||
@@ -424,7 +386,7 @@ class OnvifController:
|
||||
# configure zoom on relative move request
|
||||
if (
|
||||
autotracking_enabled
|
||||
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
||||
and autotracking_config.zooming == ZoomingModeEnum.relative
|
||||
):
|
||||
zoom_space_id = next(
|
||||
(
|
||||
@@ -463,21 +425,16 @@ class OnvifController:
|
||||
)
|
||||
|
||||
if rel_move_request.Speed is None:
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after setup: {rel_move_request}"
|
||||
)
|
||||
self.cams[camera_name]["relative_move_request"] = rel_move_request
|
||||
|
||||
# setup absolute move request
|
||||
abs_move_request = ptz.create_type("AbsoluteMove")
|
||||
abs_move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["absolute_move_request"] = abs_move_request
|
||||
cam["relative_move_request"] = rel_move_request
|
||||
|
||||
# setup existing presets
|
||||
try:
|
||||
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
|
||||
presets = []
|
||||
|
||||
@@ -490,7 +447,7 @@ class OnvifController:
|
||||
preset_name = preset_name.encode("latin-1").decode("utf-8")
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
pass
|
||||
self.cams[camera_name]["presets"][preset_name.lower()] = preset["token"]
|
||||
cam["presets"][preset_name.lower()] = preset["token"]
|
||||
|
||||
# get list of supported features
|
||||
supported_features = []
|
||||
@@ -504,64 +461,40 @@ class OnvifController:
|
||||
if configs.DefaultRelativePanTiltTranslationSpace:
|
||||
supported_features.append("pt-r")
|
||||
|
||||
spaces = getattr(ptz_config, "Spaces", None)
|
||||
|
||||
if configs.DefaultRelativeZoomTranslationSpace:
|
||||
supported_features.append("zoom-r")
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
self.cams[camera_name]["relative_zoom_range"] = (
|
||||
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||
)
|
||||
except Exception as e:
|
||||
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
)
|
||||
if getattr(spaces, "RelativeZoomTranslationSpace", None):
|
||||
cam["relative_zoom_range"] = spaces.RelativeZoomTranslationSpace[0]
|
||||
|
||||
if configs.DefaultAbsoluteZoomPositionSpace:
|
||||
supported_features.append("zoom-a")
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
self.cams[camera_name]["absolute_zoom_range"] = (
|
||||
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
||||
)
|
||||
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
||||
except Exception as e:
|
||||
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||
)
|
||||
if getattr(spaces, "AbsoluteZoomPositionSpace", None):
|
||||
cam["absolute_zoom_range"] = spaces.AbsoluteZoomPositionSpace[0]
|
||||
|
||||
# disable autotracking zoom if required ranges are unavailable
|
||||
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||
if "relative_zoom_range" not in self.cams[camera_name]:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable"
|
||||
)
|
||||
if autotracking_config.zooming == ZoomingModeEnum.absolute:
|
||||
if "absolute_zoom_range" not in self.cams[camera_name]:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable"
|
||||
)
|
||||
|
||||
if (
|
||||
self.cams[camera_name]["video_source_token"] is not None
|
||||
and imaging is not None
|
||||
# autotracking zoom needs the range for its mode, and get_camera_status
|
||||
# reads the absolute range in both modes
|
||||
zooming = autotracking_config.zooming
|
||||
if zooming != ZoomingModeEnum.disabled and (
|
||||
"absolute_zoom_range" not in cam or f"{zooming.value}_zoom_range" not in cam
|
||||
):
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: {zooming.value} zoom range unavailable"
|
||||
)
|
||||
|
||||
if cam["video_source_token"] is not None and imaging is not None:
|
||||
try:
|
||||
imaging_capabilities = await imaging.GetImagingSettings(
|
||||
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||
{"VideoSourceToken": cam["video_source_token"]}
|
||||
)
|
||||
if (
|
||||
hasattr(imaging_capabilities, "Focus")
|
||||
and imaging_capabilities.Focus
|
||||
):
|
||||
supported_features.append("focus")
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
||||
|
||||
# detect FOV relative movement support
|
||||
@@ -570,17 +503,18 @@ class OnvifController:
|
||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||
):
|
||||
supported_features.append("pt-r-fov")
|
||||
self.cams[camera_name]["relative_fov_range"] = (
|
||||
cam["relative_fov_range"] = (
|
||||
ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
||||
)
|
||||
|
||||
self.cams[camera_name]["features"] = supported_features
|
||||
self.cams[camera_name]["init"] = True
|
||||
cam["features"] = supported_features
|
||||
cam["init"] = True
|
||||
return True
|
||||
|
||||
async def _stop(self, camera_name: str) -> None:
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
await self.cams[camera_name]["ptz"].Stop(
|
||||
cam = self.cams[camera_name]
|
||||
move_request = cam["move_request"]
|
||||
await cam["ptz"].Stop(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PanTilt": True,
|
||||
@@ -588,88 +522,75 @@ class OnvifController:
|
||||
}
|
||||
)
|
||||
if (
|
||||
"focus" in self.cams[camera_name]["features"]
|
||||
and self.cams[camera_name]["video_source_token"]
|
||||
and self.cams[camera_name]["imaging"] is not None
|
||||
"focus" in cam["features"]
|
||||
and cam["video_source_token"]
|
||||
and cam["imaging"] is not None
|
||||
):
|
||||
try:
|
||||
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
||||
stop_request.VideoSourceToken = self.cams[camera_name][
|
||||
"video_source_token"
|
||||
]
|
||||
await self.cams[camera_name]["imaging"].Stop(stop_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
stop_request = cam["imaging"].create_type("Stop")
|
||||
stop_request.VideoSourceToken = cam["video_source_token"]
|
||||
await cam["imaging"].Stop(stop_request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to stop focus for {camera_name}: {e}")
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "pt" not in self.cams[camera_name]["features"]:
|
||||
if "pt" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
cam["active"] = True
|
||||
move_request = cam["move_request"]
|
||||
|
||||
if command == OnvifCommandEnum.move_left:
|
||||
move_request.Velocity = {"PanTilt": {"x": -0.5, "y": 0}}
|
||||
elif command == OnvifCommandEnum.move_right:
|
||||
move_request.Velocity = {"PanTilt": {"x": 0.5, "y": 0}}
|
||||
elif command == OnvifCommandEnum.move_up:
|
||||
move_request.Velocity = {
|
||||
"PanTilt": {
|
||||
"x": 0,
|
||||
"y": 0.5,
|
||||
}
|
||||
}
|
||||
elif command == OnvifCommandEnum.move_down:
|
||||
move_request.Velocity = {
|
||||
"PanTilt": {
|
||||
"x": 0,
|
||||
"y": -0.5,
|
||||
}
|
||||
}
|
||||
x, y = PAN_TILT_VELOCITY[command]
|
||||
move_request.Velocity = {"PanTilt": {"x": x, "y": y}}
|
||||
|
||||
try:
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
await cam["ptz"].ContinuousMove(move_request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
|
||||
|
||||
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
||||
if "pt-r-fov" not in self.cams[camera_name]["features"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if "pt-r-fov" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
||||
return
|
||||
|
||||
metrics = self.ptz_metrics.get(camera_name)
|
||||
camera_config = self.config.cameras.get(camera_name)
|
||||
|
||||
if metrics is None:
|
||||
if metrics is None or camera_config is None:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
||||
)
|
||||
|
||||
if self.cams[camera_name]["active"]:
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
|
||||
# only track start_time for autotracking
|
||||
if metrics.autotracker_enabled.value:
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
metrics.motor_stopped.clear()
|
||||
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
||||
metrics.start_time.value = metrics.frame_time.value
|
||||
metrics.stop_time.value = 0
|
||||
|
||||
move_request = self.cams[camera_name]["relative_move_request"]
|
||||
move_request = cam["relative_move_request"]
|
||||
|
||||
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
|
||||
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
|
||||
@@ -677,55 +598,49 @@ class OnvifController:
|
||||
pan,
|
||||
[-1, 1],
|
||||
[
|
||||
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
|
||||
cam["relative_fov_range"]["XRange"]["Min"],
|
||||
cam["relative_fov_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
tilt = numpy.interp(
|
||||
tilt,
|
||||
[-1, 1],
|
||||
[
|
||||
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
|
||||
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
|
||||
cam["relative_fov_range"]["YRange"]["Min"],
|
||||
cam["relative_fov_range"]["YRange"]["Max"],
|
||||
],
|
||||
)
|
||||
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
"y": speed,
|
||||
},
|
||||
}
|
||||
move_speed = {"PanTilt": {"x": speed, "y": speed}}
|
||||
|
||||
move_request.Translation.PanTilt.x = pan
|
||||
move_request.Translation.PanTilt.y = tilt
|
||||
|
||||
# include zoom if requested and camera supports relative zoom
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
"y": speed,
|
||||
},
|
||||
"Zoom": {"x": speed},
|
||||
}
|
||||
include_zoom = zoom != 0 and "zoom-r" in cam["features"]
|
||||
|
||||
if include_zoom:
|
||||
move_speed["Zoom"] = {"x": speed}
|
||||
move_request["Translation"]["Zoom"] = {"x": zoom}
|
||||
|
||||
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
||||
move_request.Speed = move_speed
|
||||
|
||||
await cam["ptz"].RelativeMove(move_request)
|
||||
|
||||
# reset after the move request
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
move_request.Translation.PanTilt.y = 0
|
||||
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
if include_zoom:
|
||||
del move_request["Translation"]["Zoom"]
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
||||
cam = self.cams[camera_name]
|
||||
preset = preset.lower()
|
||||
|
||||
if preset not in self.cams[camera_name]["presets"]:
|
||||
if preset not in cam["presets"]:
|
||||
logger.error(f"{preset} is not a valid preset for {camera_name}")
|
||||
return
|
||||
|
||||
@@ -734,44 +649,48 @@ class OnvifController:
|
||||
if metrics is None:
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
metrics.start_time.value = 0
|
||||
metrics.stop_time.value = 0
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
preset_token = self.cams[camera_name]["presets"][preset]
|
||||
move_request = cam["move_request"]
|
||||
preset_token = cam["presets"][preset]
|
||||
|
||||
await self.cams[camera_name]["ptz"].GotoPreset(
|
||||
await cam["ptz"].GotoPreset(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PresetToken": preset_token,
|
||||
}
|
||||
)
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "zoom" not in self.cams[camera_name]["features"]:
|
||||
if "zoom" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF zooming.")
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
cam["active"] = True
|
||||
move_request = cam["move_request"]
|
||||
|
||||
if command == OnvifCommandEnum.zoom_in:
|
||||
move_request.Velocity = {"Zoom": {"x": 0.5}}
|
||||
elif command == OnvifCommandEnum.zoom_out:
|
||||
move_request.Velocity = {"Zoom": {"x": -0.5}}
|
||||
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
await cam["ptz"].ContinuousMove(move_request)
|
||||
|
||||
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
||||
if "zoom-a" not in self.cams[camera_name]["features"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if "zoom-a" not in cam["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
||||
return
|
||||
|
||||
@@ -782,56 +701,59 @@ class OnvifController:
|
||||
|
||||
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
|
||||
|
||||
if self.cams[camera_name]["active"]:
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
metrics.motor_stopped.clear()
|
||||
logger.debug(f"{camera_name}: PTZ start time: {metrics.frame_time.value}")
|
||||
metrics.start_time.value = metrics.frame_time.value
|
||||
metrics.stop_time.value = 0
|
||||
move_request = self.cams[camera_name]["absolute_move_request"]
|
||||
|
||||
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
|
||||
zoom = numpy.interp(
|
||||
zoom,
|
||||
[0, 1],
|
||||
[
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
|
||||
move_request.Speed = {"Zoom": speed}
|
||||
move_request.Position = {"Zoom": zoom}
|
||||
|
||||
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
||||
|
||||
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
|
||||
await cam["ptz"].AbsoluteMove(
|
||||
{
|
||||
"ProfileToken": cam["move_request"].ProfileToken,
|
||||
"Position": {"Zoom": zoom},
|
||||
"Speed": {"Zoom": speed},
|
||||
}
|
||||
)
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if cam["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if (
|
||||
"focus" not in self.cams[camera_name]["features"]
|
||||
or not self.cams[camera_name]["video_source_token"]
|
||||
or self.cams[camera_name]["imaging"] is None
|
||||
"focus" not in cam["features"]
|
||||
or not cam["video_source_token"]
|
||||
or cam["imaging"] is None
|
||||
):
|
||||
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
move_request = self.cams[camera_name]["imaging"].create_type("Move")
|
||||
move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"]
|
||||
cam["active"] = True
|
||||
move_request = cam["imaging"].create_type("Move")
|
||||
move_request.VideoSourceToken = cam["video_source_token"]
|
||||
move_request.Focus = {
|
||||
"Continuous": {
|
||||
"Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5
|
||||
@@ -839,10 +761,10 @@ class OnvifController:
|
||||
}
|
||||
|
||||
try:
|
||||
await self.cams[camera_name]["imaging"].Move(move_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
await cam["imaging"].Move(move_request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}")
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
|
||||
async def handle_command_async(
|
||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||
@@ -883,7 +805,7 @@ class OnvifController:
|
||||
await self._focus(camera_name, command)
|
||||
else:
|
||||
await self._move(camera_name, command)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to handle onvif command: {e}")
|
||||
|
||||
def handle_command(
|
||||
@@ -906,6 +828,15 @@ class OnvifController:
|
||||
f"Error executing command {command} for camera {camera_name}: {e}"
|
||||
)
|
||||
|
||||
def _camera_info(self, camera_name: str) -> dict[str, Any]:
|
||||
cam = self.cams[camera_name]
|
||||
return {
|
||||
"name": camera_name,
|
||||
"features": cam["features"],
|
||||
"presets": list(cam["presets"]),
|
||||
"profiles": cam["profiles"],
|
||||
}
|
||||
|
||||
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
|
||||
@@ -929,60 +860,21 @@ class OnvifController:
|
||||
return {}
|
||||
|
||||
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
|
||||
return {
|
||||
"name": camera_name,
|
||||
"features": self.cams[camera_name]["features"],
|
||||
"presets": list(self.cams[camera_name]["presets"].keys()),
|
||||
"profiles": self.cams[camera_name].get("profiles", []),
|
||||
}
|
||||
return self._camera_info(camera_name)
|
||||
|
||||
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
|
||||
success = await self._init_single_camera(camera_name)
|
||||
if not success:
|
||||
return {}
|
||||
|
||||
# Reset retry count after timeout
|
||||
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
|
||||
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
|
||||
failed = self.failed_cams.get(camera_name, {})
|
||||
attempts = failed.get("retry_attempts", 0)
|
||||
last_attempt = failed.get("last_attempt", 0)
|
||||
|
||||
# Reset retry count after timeout
|
||||
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
|
||||
logger.debug(f"Resetting retry count for {camera_name} after timeout")
|
||||
attempts = 0
|
||||
self.failed_cams[camera_name]["retry_attempts"] = 0
|
||||
|
||||
# Attempt initialization/reconnection
|
||||
if attempts < self.max_retries:
|
||||
logger.info(
|
||||
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
|
||||
)
|
||||
try:
|
||||
if await self._init_onvif(camera_name):
|
||||
if camera_name in self.failed_cams:
|
||||
del self.failed_cams[camera_name]
|
||||
return {
|
||||
"name": camera_name,
|
||||
"features": self.cams[camera_name]["features"],
|
||||
"presets": list(self.cams[camera_name]["presets"].keys()),
|
||||
}
|
||||
else:
|
||||
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
||||
self.failed_cams[camera_name] = {
|
||||
"retry_attempts": attempts + 1,
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during ONVIF initialization for {camera_name}: {e}"
|
||||
)
|
||||
if camera_name not in self.failed_cams:
|
||||
self.failed_cams[camera_name] = {"retry_attempts": 0}
|
||||
self.failed_cams[camera_name].update(
|
||||
{
|
||||
"retry_attempts": attempts + 1,
|
||||
"last_error": str(e),
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
if attempts >= self.max_retries:
|
||||
remaining_time = max(
|
||||
@@ -991,8 +883,24 @@ class OnvifController:
|
||||
logger.error(
|
||||
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
|
||||
)
|
||||
return {}
|
||||
|
||||
logger.debug(f"Could not initialize ONVIF for {camera_name}")
|
||||
logger.info(
|
||||
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
|
||||
)
|
||||
try:
|
||||
if await self._init_onvif(camera_name):
|
||||
self.failed_cams.pop(camera_name, None)
|
||||
return self._camera_info(camera_name)
|
||||
|
||||
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during ONVIF initialization for {camera_name}: {e}")
|
||||
|
||||
self.failed_cams[camera_name] = {
|
||||
"retry_attempts": attempts + 1,
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
return {}
|
||||
|
||||
async def get_service_capabilities(self, camera_name: str) -> None:
|
||||
@@ -1000,16 +908,13 @@ class OnvifController:
|
||||
logger.error(f"ONVIF is not configured for {camera_name}")
|
||||
return {}
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if not cam["init"]:
|
||||
await self._init_onvif(camera_name)
|
||||
|
||||
service_capabilities_request = self.cams[camera_name][
|
||||
"service_capabilities_request"
|
||||
]
|
||||
try:
|
||||
service_capabilities = await self.cams[camera_name][
|
||||
"ptz"
|
||||
].GetServiceCapabilities(service_capabilities_request)
|
||||
service_capabilities = await cam["ptz"].GetServiceCapabilities()
|
||||
|
||||
logger.debug(
|
||||
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
||||
@@ -1035,13 +940,16 @@ class OnvifController:
|
||||
if metrics is None or camera_config is None:
|
||||
return
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
cam = self.cams[camera_name]
|
||||
|
||||
if not cam["init"]:
|
||||
if not await self._init_onvif(camera_name):
|
||||
return
|
||||
|
||||
status_request = self.cams[camera_name]["status_request"]
|
||||
try:
|
||||
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
|
||||
status = await cam["ptz"].GetStatus(
|
||||
{"ProfileToken": cam["move_request"].ProfileToken}
|
||||
)
|
||||
except Exception:
|
||||
pass # We're unsupported, that'll be reported in the next check.
|
||||
|
||||
@@ -1072,7 +980,7 @@ class OnvifController:
|
||||
if pan_tilt_status == "IDLE" and (
|
||||
zoom_status is None or zoom_status == "IDLE"
|
||||
):
|
||||
self.cams[camera_name]["active"] = False
|
||||
cam["active"] = False
|
||||
if not metrics.motor_stopped.is_set():
|
||||
metrics.motor_stopped.set()
|
||||
|
||||
@@ -1082,7 +990,7 @@ class OnvifController:
|
||||
|
||||
metrics.stop_time.value = metrics.frame_time.value
|
||||
else:
|
||||
self.cams[camera_name]["active"] = True
|
||||
cam["active"] = True
|
||||
if metrics.motor_stopped.is_set():
|
||||
metrics.motor_stopped.clear()
|
||||
|
||||
@@ -1098,8 +1006,8 @@ class OnvifController:
|
||||
metrics.zoom_level.value = numpy.interp(
|
||||
round(status.Position.Zoom.x, 2),
|
||||
[
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Min"],
|
||||
cam["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
[0, 1],
|
||||
)
|
||||
@@ -1138,7 +1046,7 @@ class OnvifController:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Gracefully shut down the ONVIF controller."""
|
||||
if not hasattr(self, "loop") or self.loop.is_closed():
|
||||
if self.loop.is_closed():
|
||||
logger.debug("ONVIF controller already closed")
|
||||
return
|
||||
|
||||
@@ -1155,13 +1063,6 @@ class OnvifController:
|
||||
|
||||
self.config_subscriber.stop()
|
||||
|
||||
def stop_and_cleanup():
|
||||
try:
|
||||
self.loop.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during loop cleanup: {e}")
|
||||
|
||||
# Schedule stop and cleanup in the loop thread
|
||||
self.loop.call_soon_threadsafe(stop_and_cleanup)
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
|
||||
self.loop_thread.join()
|
||||
|
||||
+17
-1
@@ -236,6 +236,15 @@ def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> flo
|
||||
return round(skipped_fps / camera_fps * 100, 1)
|
||||
|
||||
|
||||
def get_go2rtc_pid(cpu_usages: dict[str, dict[str, Any]]) -> int | None:
|
||||
"""Find the pid of the running go2rtc process in the cpu usages."""
|
||||
for pid, usage in cpu_usages.items():
|
||||
if usage.get("cmdline", "").split(" ")[0].endswith("/go2rtc"):
|
||||
return int(pid)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
@@ -356,6 +365,14 @@ def stats_snapshot(
|
||||
|
||||
stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config)
|
||||
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
# go2rtc is supervised by s6, so its pid changes when s6 restarts it
|
||||
go2rtc_pid = get_go2rtc_pid(cpu_usages)
|
||||
|
||||
if go2rtc_pid is not None:
|
||||
stats_tracking["processes"]["go2rtc"] = go2rtc_pid
|
||||
|
||||
stats["processes"] = {}
|
||||
for name, pid in stats_tracking["processes"].items():
|
||||
stats["processes"][name] = {
|
||||
@@ -364,7 +381,6 @@ def stats_snapshot(
|
||||
|
||||
# Embed cpu/mem stats into detectors, cameras, and processes
|
||||
# so history consumers don't need the full cpu_usages dict
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
for det_stats in stats["detectors"].values():
|
||||
pid_str = str(det_stats.get("pid", ""))
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.config.env import FRIGATE_ENV_VARS
|
||||
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
@@ -111,6 +112,30 @@ class TestHttpApp(BaseTestHttp):
|
||||
mqtt = response.json()["mqtt"]
|
||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
|
||||
def test_config_response_hides_notification_email_from_viewers(self):
|
||||
self.minimal_config["notifications"] = {"email": "{FRIGATE_TEST_EMAIL}"}
|
||||
|
||||
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_TEST_EMAIL": "me@example.com"}):
|
||||
app = super().create_app()
|
||||
|
||||
assert app.frigate_config.notifications.email == "me@example.com"
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get(
|
||||
"/config",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
config = response.json()
|
||||
assert config["notifications"]["email"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
assert (
|
||||
config["cameras"]["front_door"]["notifications"]["email"]
|
||||
== REDACTED_CREDENTIAL_SENTINEL
|
||||
)
|
||||
|
||||
response = client.get("/config")
|
||||
assert response.json()["notifications"]["email"] == "me@example.com"
|
||||
|
||||
def test_config_response_keeps_plus_model_reference(self):
|
||||
model_id = "test_plus_reference"
|
||||
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
|
||||
@@ -59,7 +59,6 @@ def build_watchdog(
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
watchdog.requestor = MagicMock()
|
||||
return watchdog
|
||||
|
||||
|
||||
@@ -108,8 +107,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def test_status_goes_to_the_matching_role_topic(self):
|
||||
watchdog = self._build_watchdog()
|
||||
|
||||
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
|
||||
|
||||
watchdog.requestor.send_data.assert_any_call(
|
||||
"front_door/status/record", "online"
|
||||
@@ -121,9 +120,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def test_status_is_cached_per_stream(self):
|
||||
watchdog = self._build_watchdog()
|
||||
|
||||
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
|
||||
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
|
||||
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
|
||||
|
||||
assert watchdog.requestor.send_data.call_count == 2
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from pydantic import ValidationError
|
||||
from ruamel.yaml.constructor import DuplicateKeyError
|
||||
|
||||
@@ -1595,6 +1596,31 @@ class TestConfig(unittest.TestCase):
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert frigate_config.primary_model.merged_labelmap[0] == "amazon"
|
||||
|
||||
@patch(
|
||||
"frigate.plus.PlusApi.get_model_download_url",
|
||||
side_effect=requests.exceptions.ConnectionError,
|
||||
)
|
||||
def test_plus_unreachable_is_validation_error(self, _):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"models": [{"path": "plus://unreachable", "devices": ["cpu"]}],
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, "Unable to connect to Frigate+"):
|
||||
FrigateConfig(**config)
|
||||
|
||||
def test_fails_on_invalid_role(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for resolving the go2rtc pid from cpu usages."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from frigate.stats.util import get_go2rtc_pid, stats_snapshot
|
||||
|
||||
|
||||
class TestGo2rtcPid(unittest.TestCase):
|
||||
def test_finds_go2rtc_by_binary_path(self):
|
||||
cpu_usages = {
|
||||
"frigate.full_system": {"cpu": "1.0", "mem": "2.0"},
|
||||
"100": {"cmdline": "ffmpeg -i rtsp://127.0.0.1:8554/go2rtc_cam"},
|
||||
"200": {
|
||||
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=/dev/shm/go2rtc.yaml"
|
||||
},
|
||||
"300": {"cmdline": "frigate.recording"},
|
||||
}
|
||||
|
||||
self.assertEqual(get_go2rtc_pid(cpu_usages), 200)
|
||||
|
||||
def test_finds_custom_go2rtc_binary(self):
|
||||
self.assertEqual(get_go2rtc_pid({"42": {"cmdline": "/config/go2rtc"}}), 42)
|
||||
|
||||
def test_returns_none_when_go2rtc_is_not_running(self):
|
||||
self.assertIsNone(get_go2rtc_pid({"100": {"cmdline": "ffmpeg -i x"}}))
|
||||
self.assertIsNone(get_go2rtc_pid({}))
|
||||
|
||||
|
||||
class TestGo2rtcPidInSnapshot(unittest.TestCase):
|
||||
def snapshot(self, tracking: dict, go2rtc_pid: int) -> dict:
|
||||
def update_stats(stats: dict) -> None:
|
||||
stats["cpu_usages"] = {
|
||||
str(go2rtc_pid): {
|
||||
"cmdline": "/usr/local/go2rtc/bin/go2rtc -config=x",
|
||||
"cpu": str(go2rtc_pid / 100),
|
||||
"mem": str(go2rtc_pid / 10),
|
||||
}
|
||||
}
|
||||
|
||||
config = SimpleNamespace(
|
||||
cameras={},
|
||||
telemetry=SimpleNamespace(stats=SimpleNamespace(network_bandwidth=False)),
|
||||
)
|
||||
hardware_stats = Mock()
|
||||
hardware_stats.update_stats.side_effect = update_stats
|
||||
|
||||
with (
|
||||
patch("frigate.stats.util.get_detector_stats", return_value={}),
|
||||
patch("frigate.stats.util.embeddings_stats", return_value={}),
|
||||
patch("frigate.stats.util.calculate_shm_requirements", return_value={}),
|
||||
):
|
||||
return stats_snapshot(config, tracking, hardware_stats)
|
||||
|
||||
def test_snapshot_follows_go2rtc_restart(self):
|
||||
tracking = {
|
||||
"camera_metrics": {},
|
||||
"detectors": {},
|
||||
"started": 0,
|
||||
"latest_frigate_version": "",
|
||||
"processes": {"go2rtc": 200, "recording": 50},
|
||||
"storage_maintainer": None,
|
||||
}
|
||||
|
||||
first = self.snapshot(tracking, 200)["processes"]["go2rtc"]
|
||||
self.assertEqual(first, {"pid": 200, "cpu": "2.0", "mem": "20.0"})
|
||||
|
||||
restarted = self.snapshot(tracking, 300)["processes"]["go2rtc"]
|
||||
self.assertEqual(restarted, {"pid": 300, "cpu": "3.0", "mem": "30.0"})
|
||||
@@ -29,7 +29,6 @@ class TestImprovedMotionDetector(unittest.TestCase):
|
||||
|
||||
class DummyPTZ:
|
||||
def __init__(self):
|
||||
self.autotracker_enabled = _Stub(False)
|
||||
self.motor_stopped = _Stub(False)
|
||||
self.stop_time = _Stub(0)
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ KeyError on the autotracker thread or silently keep the wrong state:
|
||||
|
||||
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
|
||||
constructed, so runtime-enabled cameras raised KeyError on lookup.
|
||||
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
|
||||
updated it when autotracking was enabled through a config save, so it stayed
|
||||
False and the tracker never built a motion estimator.
|
||||
- _disable only changed the main process config, so the camera process kept
|
||||
running its motion estimator for a camera that could not autotrack.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
@@ -17,7 +16,8 @@ from unittest.mock import MagicMock
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
|
||||
@@ -53,8 +53,9 @@ def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
|
||||
onvif over the network. Only the config/metrics state is relevant here."""
|
||||
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
|
||||
tracker.config = _config(autotracking_enabled)
|
||||
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
|
||||
tracker.ptz_metrics = {CAMERA: PTZMetrics()}
|
||||
tracker.onvif = MagicMock()
|
||||
tracker.dispatcher = MagicMock()
|
||||
tracker.config_subscriber = MagicMock()
|
||||
tracker.autotracker_init = {}
|
||||
tracker.calibrating = {}
|
||||
@@ -83,47 +84,49 @@ class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
|
||||
tracker.onvif.get_camera_status.assert_not_called()
|
||||
|
||||
|
||||
class TestAutotrackerMetricSync(unittest.TestCase):
|
||||
def test_metric_follows_config_when_enabled_by_update(self) -> None:
|
||||
# autotracking enabled via a config save: the metric was seeded False when
|
||||
# the camera was added and nothing else updates it
|
||||
class TestAutotrackerEnqueueMove(unittest.TestCase):
|
||||
def _enqueue(self, pan: float, tilt: float, zoom: float) -> MagicMock:
|
||||
tracker = _make_tracker()
|
||||
tracker.move_queues = {CAMERA: MagicMock()}
|
||||
tracker.move_queue_locks = {CAMERA: MagicMock()}
|
||||
tracker.move_queue_locks[CAMERA].locked.return_value = False
|
||||
|
||||
tracker._enqueue_move(CAMERA, 1000.0, pan, tilt, zoom)
|
||||
|
||||
return tracker.onvif.loop.call_soon_threadsafe
|
||||
|
||||
def test_move_is_clipped_to_the_onvif_range(self) -> None:
|
||||
# velocity estimates can push the predicted centroid outside the frame
|
||||
call_soon = self._enqueue(1.7, -2.5, 0.4)
|
||||
|
||||
call_soon.assert_called_once()
|
||||
self.assertEqual(call_soon.call_args.args[1], (1000.0, 1.0, -1.0, 0.4))
|
||||
|
||||
def test_empty_move_is_not_enqueued(self) -> None:
|
||||
self._enqueue(0, 0, 0).assert_not_called()
|
||||
|
||||
|
||||
class TestAutotrackerDisable(unittest.TestCase):
|
||||
def test_disable_publishes_to_camera_process(self) -> None:
|
||||
tracker = _make_tracker(autotracking_enabled=True)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
|
||||
tracker.check_for_updates()
|
||||
tracker._disable(CAMERA, "onvif connection failed")
|
||||
|
||||
self.assertTrue(metrics.autotracker_enabled.value)
|
||||
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
|
||||
self.assertFalse(autotracking.enabled)
|
||||
|
||||
def test_metric_follows_config_when_disabled_by_update(self) -> None:
|
||||
tracker = _make_tracker(autotracking_enabled=False)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
metrics.autotracker_enabled.value = True
|
||||
publish = tracker.dispatcher.config_updater.publish_update
|
||||
publish.assert_called_once()
|
||||
topic, payload = publish.call_args.args
|
||||
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.autotracking)
|
||||
self.assertEqual(topic.camera, CAMERA)
|
||||
self.assertIs(payload, autotracking)
|
||||
|
||||
tracker.config_subscriber.check_for_updates.return_value = {
|
||||
"autotracking": [CAMERA]
|
||||
}
|
||||
tracker.check_for_updates()
|
||||
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
def test_metric_sync_skips_camera_without_metrics(self) -> None:
|
||||
# `add` reaches the maintainer and the autotracker on separate threads with
|
||||
# no ordering guarantee, so the metrics may not exist yet
|
||||
tracker = _make_tracker()
|
||||
tracker.ptz_metrics = {}
|
||||
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
|
||||
|
||||
tracker.check_for_updates()
|
||||
|
||||
def test_metric_sync_skips_unknown_camera(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
tracker.config_subscriber.check_for_updates.return_value = {
|
||||
"add": ["not_in_config"]
|
||||
}
|
||||
|
||||
tracker.check_for_updates()
|
||||
class TestMaxTargetBox(unittest.TestCase):
|
||||
def test_follows_zoom_factor(self) -> None:
|
||||
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
|
||||
self.assertAlmostEqual(calculate_max_target_box(0.25), 0.6**4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
|
||||
Regression coverage for a camera that is initialized while autotracking is off and
|
||||
has it enabled later, which is the normal wizard flow: set the camera up first,
|
||||
configure autotracking afterwards. The autotracking-only request objects used to
|
||||
be created only when autotracking was enabled at init time, so the camera was left
|
||||
with init=True but no status_request. get_camera_status skips its re-init branch
|
||||
when init is True, so it went straight to the missing key and raised KeyError on
|
||||
the tracking thread.
|
||||
|
||||
The request objects are built from the locally parsed WSDL and cost no network, so
|
||||
they are always created and init=True now implies they exist.
|
||||
configure autotracking afterwards. get_camera_status skips its re-init branch when
|
||||
init is True, so everything it reads must exist whether or not autotracking was
|
||||
enabled at init time.
|
||||
|
||||
Also covers the inverse direction: the ptz movement timestamps must not be written
|
||||
for a camera that has autotracking off, because nothing clears them back out.
|
||||
@@ -99,7 +94,6 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
controller.config = config
|
||||
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
|
||||
controller.failed_cams = {}
|
||||
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||
controller.ptz_metrics = {CAMERA: MagicMock()}
|
||||
return controller
|
||||
|
||||
@@ -110,7 +104,6 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
config = _config(autotracking_enabled)
|
||||
controller = OnvifController.__new__(OnvifController)
|
||||
controller.config = config
|
||||
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||
controller.failed_cams = {}
|
||||
|
||||
ptz = MagicMock()
|
||||
@@ -128,45 +121,30 @@ def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
},
|
||||
}
|
||||
}
|
||||
controller.ptz_metrics = {
|
||||
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
|
||||
}
|
||||
controller.ptz_metrics = {CAMERA: PTZMetrics()}
|
||||
return controller
|
||||
|
||||
|
||||
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_status_request_created_when_autotracking_disabled(self) -> None:
|
||||
async def test_camera_status_independent_of_autotracking_at_init(self) -> None:
|
||||
# the wizard flow: onvif configured first, autotracking enabled later
|
||||
controller = _make_controller(autotracking_enabled=False)
|
||||
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
self.assertTrue(cam["init"])
|
||||
self.assertIn("status_request", cam)
|
||||
self.assertIn("service_capabilities_request", cam)
|
||||
|
||||
async def test_status_request_created_when_autotracking_enabled(self) -> None:
|
||||
controller = _make_controller(autotracking_enabled=True)
|
||||
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
self.assertIn("status_request", cam)
|
||||
self.assertIn("service_capabilities_request", cam)
|
||||
|
||||
async def test_init_implies_status_request_exists(self) -> None:
|
||||
# the invariant get_camera_status relies on: it skips re-init when init is
|
||||
# True and then reads status_request without guarding
|
||||
for autotracking_enabled in (True, False):
|
||||
with self.subTest(autotracking_enabled=autotracking_enabled):
|
||||
controller = _make_controller(autotracking_enabled)
|
||||
controller.status_locks = {CAMERA: asyncio.Lock()}
|
||||
|
||||
await controller._init_onvif(CAMERA)
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
if cam["init"]:
|
||||
self.assertEqual(cam["status_request"].request_type, "GetStatus")
|
||||
status = MagicMock()
|
||||
status.MoveStatus.PanTilt = "IDLE"
|
||||
status.MoveStatus.Zoom = "IDLE"
|
||||
ptz = controller.cams[CAMERA]["ptz"]
|
||||
ptz.GetStatus = AsyncMock(return_value=status)
|
||||
|
||||
await controller.get_camera_status(CAMERA)
|
||||
|
||||
ptz.GetStatus.assert_awaited_once_with({"ProfileToken": "profile_1"})
|
||||
self.assertFalse(controller.cams[CAMERA]["active"])
|
||||
|
||||
async def test_requests_built_without_contacting_camera(self) -> None:
|
||||
# create_type is a local WSDL lookup; cameras that do not implement
|
||||
|
||||
@@ -223,7 +223,7 @@ class NorfairTracker(ObjectTracker):
|
||||
),
|
||||
}
|
||||
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
self.camera_config, self.ptz_metrics
|
||||
)
|
||||
@@ -515,7 +515,7 @@ class NorfairTracker(ObjectTracker):
|
||||
yuv_frame: np.ndarray | None = None
|
||||
|
||||
if (
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
or self.detect_config.stationary.classifier
|
||||
):
|
||||
yuv_frame = self.frame_manager.get(
|
||||
@@ -534,7 +534,7 @@ class NorfairTracker(ObjectTracker):
|
||||
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
|
||||
|
||||
embedding = None
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
embedding = get_histogram(
|
||||
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
|
||||
)
|
||||
@@ -559,7 +559,7 @@ class NorfairTracker(ObjectTracker):
|
||||
|
||||
coord_transformations = None
|
||||
|
||||
if self.ptz_metrics.autotracker_enabled.value:
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
# we must have been enabled by mqtt, so set up the estimator
|
||||
if not self.ptz_motion_estimator:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
|
||||
@@ -41,7 +41,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Event, ReviewSegment, Timeline
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.track.tracked_object import TrackedObject
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
|
||||
@@ -60,7 +60,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
config: FrigateConfig,
|
||||
dispatcher: Dispatcher,
|
||||
tracked_objects_queue: MpQueue,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
ptz_autotracker_thread: PtzAutoTracker,
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
super().__init__(name="detected_frames_processor")
|
||||
@@ -153,7 +153,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
self.ptz_autotracker_thread.ptz_autotracker.autotrack_object(camera, obj)
|
||||
self.ptz_autotracker_thread.autotrack_object(camera, obj)
|
||||
|
||||
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
# populate has_snapshot
|
||||
@@ -177,7 +177,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
"type": "end",
|
||||
}
|
||||
self.dispatcher.publish("events", json.dumps(message), retain=False)
|
||||
self.ptz_autotracker_thread.ptz_autotracker.end_object(camera, obj)
|
||||
self.ptz_autotracker_thread.end_object(camera, obj)
|
||||
|
||||
self.event_sender.publish(
|
||||
(
|
||||
|
||||
+10
-2
@@ -94,6 +94,7 @@ class CameraTracker(FrigateProcess):
|
||||
self.config.detect.fps,
|
||||
name=self.config.name,
|
||||
ptz_metrics=self.ptz_metrics,
|
||||
autotracking_enabled=self.config.onvif.autotracking.enabled,
|
||||
)
|
||||
object_detector = RemoteObjectDetector(
|
||||
self.config.name,
|
||||
@@ -195,10 +196,12 @@ def process_frames(
|
||||
None,
|
||||
{camera_config.name: camera_config},
|
||||
[
|
||||
CameraConfigUpdateEnum.autotracking,
|
||||
CameraConfigUpdateEnum.detect,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.onvif,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -235,6 +238,11 @@ def process_frames(
|
||||
motion_detector.config = camera_config.motion
|
||||
motion_detector.update_mask()
|
||||
|
||||
if "autotracking" in updated_configs or "onvif" in updated_configs:
|
||||
motion_detector.autotracking_enabled = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
|
||||
if (
|
||||
not camera_enabled
|
||||
and prev_enabled != camera_enabled
|
||||
@@ -349,8 +357,8 @@ def process_frames(
|
||||
|
||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||
# the ptz timestamps are only maintained while autotracking is on, so gate
|
||||
# on the metric rather than trusting them to be reset otherwise
|
||||
ptz_moving = ptz_metrics.autotracker_enabled.value and (
|
||||
# on the config rather than trusting them to be reset otherwise
|
||||
ptz_moving = camera_config.onvif.autotracking.enabled and (
|
||||
ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
|
||||
+48
-43
@@ -6,6 +6,7 @@ import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from multiprocessing import Queue, Value
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -108,6 +109,27 @@ def capture_frames(
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoleStatus:
|
||||
"""Publishes a role's status when it changes or the resend interval elapses."""
|
||||
|
||||
requestor: InterProcessRequestor
|
||||
topic: str
|
||||
resend_interval: float
|
||||
last_status: str | None = None
|
||||
last_update_time: float = 0.0
|
||||
|
||||
def send(self, status: str, now: float) -> None:
|
||||
"""Publish a changed status or resend it after the configured interval."""
|
||||
if (
|
||||
status != self.last_status
|
||||
or (now - self.last_update_time) >= self.resend_interval
|
||||
):
|
||||
self.requestor.send_data(self.topic, status)
|
||||
self.last_status = status
|
||||
self.last_update_time = now
|
||||
|
||||
|
||||
class CameraWatchdog(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -186,33 +208,16 @@ class CameraWatchdog(threading.Thread):
|
||||
self._stall_active: bool = False
|
||||
|
||||
# Status caching to reduce message volume
|
||||
self._last_detect_status: str | None = None
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_detect_status_update_time: float = 0.0
|
||||
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
|
||||
self.detect_status = self._role_status("detect")
|
||||
self.record_status = {
|
||||
stream_type: self._role_status(role)
|
||||
for stream_type, role in STREAM_TYPE_TO_ROLE.items()
|
||||
}
|
||||
|
||||
def _send_detect_status(self, status: str, now: float) -> None:
|
||||
"""Send detect status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_detect_status
|
||||
or (now - self._last_detect_status_update_time) >= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(f"{self.config.name}/status/detect", status)
|
||||
self._last_detect_status = status
|
||||
self._last_detect_status_update_time = now
|
||||
|
||||
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
|
||||
"""Send a record stream's status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_record_status.get(stream_type)
|
||||
or (now - self._last_record_status_update_time[stream_type])
|
||||
>= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
|
||||
)
|
||||
self._last_record_status[stream_type] = status
|
||||
self._last_record_status_update_time[stream_type] = now
|
||||
def _role_status(self, role: str) -> RoleStatus:
|
||||
return RoleStatus(
|
||||
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
|
||||
)
|
||||
|
||||
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
|
||||
"""Send offline status for each role of a restarted ffmpeg process."""
|
||||
@@ -222,7 +227,7 @@ class CameraWatchdog(threading.Thread):
|
||||
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
|
||||
|
||||
if stream_type is not None:
|
||||
self._send_record_status(stream_type, "offline", now)
|
||||
self.record_status[stream_type].send("offline", now)
|
||||
else:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
@@ -388,11 +393,11 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# update camera status
|
||||
now = datetime.now().timestamp()
|
||||
self._send_detect_status("disabled", now)
|
||||
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
|
||||
self.detect_status.send("disabled", now)
|
||||
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
|
||||
# cameras without a sub stream never get a record_sub topic
|
||||
if self.config.record.sub.enabled:
|
||||
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
|
||||
self.record_status[STREAM_TYPE_SUB].send("disabled", now)
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
@@ -465,7 +470,7 @@ class CameraWatchdog(threading.Thread):
|
||||
can_restart = time_since_last_restart >= self.sleeptime
|
||||
|
||||
if not self.capture_thread.is_alive():
|
||||
self._send_detect_status("offline", now)
|
||||
self.detect_status.send("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.error(
|
||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||
@@ -477,7 +482,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.fps_overflow_count += 1
|
||||
|
||||
if self.fps_overflow_count == 3:
|
||||
self._send_detect_status("offline", now)
|
||||
self.detect_status.send("offline", now)
|
||||
self.fps_overflow_count = 0
|
||||
self.camera_fps.value = 0
|
||||
self.logger.info(
|
||||
@@ -487,7 +492,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.reset_capture_thread(drain_output=False)
|
||||
last_restart_time = now
|
||||
elif now - self.capture_thread.current_frame.value > 20:
|
||||
self._send_detect_status("offline", now)
|
||||
self.detect_status.send("offline", now)
|
||||
self.camera_fps.value = 0
|
||||
self.logger.info(
|
||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
||||
@@ -497,7 +502,7 @@ class CameraWatchdog(threading.Thread):
|
||||
last_restart_time = now
|
||||
else:
|
||||
# process is running normally
|
||||
self._send_detect_status("online", now)
|
||||
self.detect_status.send("online", now)
|
||||
self.fps_overflow_count = 0
|
||||
|
||||
for p in self.ffmpeg_other_processes:
|
||||
@@ -540,7 +545,7 @@ class CameraWatchdog(threading.Thread):
|
||||
elif stale_stream is None:
|
||||
if poll is None:
|
||||
for stream_type in recorded_streams:
|
||||
self._send_record_status(stream_type, "online", now)
|
||||
self.record_status[stream_type].send("online", now)
|
||||
|
||||
p["latest_segment_time"] = max(
|
||||
self.latest_cache_segment_time[stream_type]
|
||||
@@ -556,22 +561,22 @@ class CameraWatchdog(threading.Thread):
|
||||
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
||||
)
|
||||
|
||||
if (
|
||||
self.detect_process_records_sub
|
||||
and self.config.record.stream_enabled(STREAM_TYPE_SUB)
|
||||
and self.capture_thread is not None
|
||||
and self.capture_thread.is_alive()
|
||||
if self.detect_process_records_sub and self.config.record.stream_enabled(
|
||||
STREAM_TYPE_SUB
|
||||
):
|
||||
now_utc = datetime.now().astimezone(UTC)
|
||||
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
|
||||
|
||||
if stale_reason is None:
|
||||
self._send_record_status(STREAM_TYPE_SUB, "online", now)
|
||||
if self.detect_status.last_status == "offline":
|
||||
# the sub stream is down whenever the detect process is
|
||||
self.record_status[STREAM_TYPE_SUB].send("offline", now)
|
||||
elif stale_reason is None:
|
||||
self.record_status[STREAM_TYPE_SUB].send("online", now)
|
||||
elif can_restart:
|
||||
self.logger.error(
|
||||
f"{stale_reason} for {self.config.name} (sub, shared with detect) in the last {self.record_stale_threshold[STREAM_TYPE_SUB]}s. Restarting ffmpeg..."
|
||||
)
|
||||
self._send_record_status(STREAM_TYPE_SUB, "offline", now)
|
||||
self.record_status[STREAM_TYPE_SUB].send("offline", now)
|
||||
self.reset_capture_thread()
|
||||
last_restart_time = now
|
||||
|
||||
|
||||
Reference in New Issue
Block a user