Merge branch 'dev' into ab-camera-order

This commit is contained in:
Alin Balutoiu 2023-04-26 14:02:48 +02:00 committed by GitHub
commit 19b313536b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1752 additions and 655 deletions

View File

@ -37,42 +37,54 @@
"onAutoForward": "silent" "onAutoForward": "silent"
} }
}, },
"extensions": [ "customizations": {
"ms-python.vscode-pylance", "vscode": {
"ms-python.python", "extensions": [
"visualstudioexptteam.vscodeintellicode", "ms-python.python",
"mhutchie.git-graph", "ms-python.vscode-pylance",
"ms-azuretools.vscode-docker", "ms-python.black-formatter",
"streetsidesoftware.code-spell-checker", "visualstudioexptteam.vscodeintellicode",
"esbenp.prettier-vscode", "mhutchie.git-graph",
"dbaeumer.vscode-eslint", "ms-azuretools.vscode-docker",
"mikestead.dotenv", "streetsidesoftware.code-spell-checker",
"csstools.postcss", "esbenp.prettier-vscode",
"blanu.vscode-styled-jsx", "dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss" "mikestead.dotenv",
], "csstools.postcss",
"settings": { "blanu.vscode-styled-jsx",
"remote.autoForwardPorts": false, "bradlc.vscode-tailwindcss"
"python.linting.pylintEnabled": true, ],
"python.linting.enabled": true, "settings": {
"python.formatting.provider": "black", "remote.autoForwardPorts": false,
"python.languageServer": "Pylance", "python.linting.pylintEnabled": true,
"editor.formatOnPaste": false, "python.linting.enabled": true,
"editor.formatOnSave": true, "python.formatting.provider": "none",
"editor.formatOnType": true, "python.languageServer": "Pylance",
"python.testing.pytestEnabled": false, "editor.formatOnPaste": false,
"python.testing.unittestEnabled": true, "editor.formatOnSave": true,
"python.testing.unittestArgs": ["-v", "-s", "./frigate/test"], "editor.formatOnType": true,
"files.trimTrailingWhitespace": true, "python.testing.pytestEnabled": false,
"eslint.workingDirectories": ["./web"], "python.testing.unittestEnabled": true,
"[json][jsonc]": { "python.testing.unittestArgs": ["-v", "-s", "./frigate/test"],
"editor.defaultFormatter": "esbenp.prettier-vscode" "files.trimTrailingWhitespace": true,
}, "eslint.workingDirectories": ["./web"],
"[jsx][js][tsx][ts]": { "[python]": {
"editor.codeActionsOnSave": ["source.addMissingImports", "source.fixAll"], "editor.defaultFormatter": "ms-python.black-formatter",
"editor.tabSize": 2 "editor.formatOnSave": true
}, },
"cSpell.ignoreWords": ["rtmp"], "[json][jsonc]": {
"cSpell.words": ["preact"] "editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsx][js][tsx][ts]": {
"editor.codeActionsOnSave": [
"source.addMissingImports",
"source.fixAll"
],
"editor.tabSize": 2
},
"cSpell.ignoreWords": ["rtmp"],
"cSpell.words": ["preact"]
}
}
} }
} }

View File

@ -65,7 +65,7 @@ jobs:
- name: Check out the repository - name: Check out the repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.5.0 uses: actions/setup-python@v4.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install requirements - name: Install requirements

View File

@ -12,7 +12,7 @@ s6-svc -O .
function migrate_db_path() { function migrate_db_path() {
# Find config file in yaml or yml, but prefer yaml # Find config file in yaml or yml, but prefer yaml
local config_file="${CONFIG_FILE:-"/config/config.yml"}" local config_file="${CONFIG_FILE:-"/config/config.yml"}"
local config_file_yaml="${config_file//.yaml/.yml}" local config_file_yaml="${config_file//.yml/.yaml}"
if [[ -f "${config_file_yaml}" ]]; then if [[ -f "${config_file_yaml}" ]]; then
config_file="${config_file_yaml}" config_file="${config_file_yaml}"
elif [[ ! -f "${config_file}" ]]; then elif [[ ! -f "${config_file}" ]]; then

View File

@ -221,6 +221,7 @@ http {
add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
proxy_pass http://frigate_api/; proxy_pass http://frigate_api/;
proxy_pass_request_headers on; proxy_pass_request_headers on;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@ -24,7 +24,6 @@ Examples of available modules are:
- `frigate.app` - `frigate.app`
- `frigate.mqtt` - `frigate.mqtt`
- `frigate.object_detection` - `frigate.object_detection`
- `frigate.zeroconf`
- `detector.<detector_name>` - `detector.<detector_name>`
- `watchdog.<camera_name>` - `watchdog.<camera_name>`
- `ffmpeg.<camera_name>.<sorted_roles>` NOTE: All FFmpeg logs are sent as `error` level. - `ffmpeg.<camera_name>.<sorted_roles>` NOTE: All FFmpeg logs are sent as `error` level.

View File

@ -48,3 +48,21 @@ cameras:
``` ```
For camera model specific settings check the [camera specific](camera_specific.md) infos. For camera model specific settings check the [camera specific](camera_specific.md) infos.
## Setting up camera PTZ controls
Add onvif config to camera
```yaml
cameras:
back:
ffmpeg:
...
onvif:
host: 10.0.10.10
port: 8000
user: admin
password: password
```
then PTZ controls will be available in the cameras WebUI.

View File

@ -55,6 +55,14 @@ mqtt:
- path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast - path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:8554/unicast
``` ```
```yaml
onvif:
host: 10.0.10.10
port: 8000
user: "{FRIGATE_RTSP_USER}"
password: "{FRIGATE_RTSP_PASSWORD}"
```
```yaml ```yaml
mqtt: mqtt:
# Optional: Enable mqtt server (default: shown below) # Optional: Enable mqtt server (default: shown below)
@ -497,6 +505,19 @@ cameras:
# Optional: Whether or not to show the camera in the Frigate UI (default: shown below) # Optional: Whether or not to show the camera in the Frigate UI (default: shown below)
dashboard: True dashboard: True
# Optional: connect to ONVIF camera
# to enable PTZ controls.
onvif:
# Required: host of the camera being connected to.
host: 0.0.0.0
# Optional: ONVIF port for device (default: shown below).
port: 8000
# Optional: username for login.
# NOTE: Some devices require admin to access ONVIF.
user: admin
# Optional: password for login.
password: admin
# Optional: Configuration for how to sort the cameras in the Birdseye view. # Optional: Configuration for how to sort the cameras in the Birdseye view.
birdseye: birdseye:
# Optional: Adjust sort order of cameras in the Birdseye view. Larger numbers come later (default: shown below) # Optional: Adjust sort order of cameras in the Birdseye view. Larger numbers come later (default: shown below)

View File

@ -172,11 +172,11 @@ Events from the database. Accepts the following query string parameters:
Timeline of key moments of an event(s) from the database. Accepts the following query string parameters: Timeline of key moments of an event(s) from the database. Accepts the following query string parameters:
| param | Type | Description | | param | Type | Description |
| -------------------- | ---- | --------------------------------------------- | | ----------- | ---- | ----------------------------------- |
| `camera` | int | Name of camera | | `camera` | str | Name of camera |
| `source_id` | str | ID of tracked object | | `source_id` | str | ID of tracked object |
| `limit` | int | Limit the number of events returned | | `limit` | int | Limit the number of events returned |
### `GET /api/events/summary` ### `GET /api/events/summary`
@ -198,6 +198,14 @@ Sets retain to true for the event id.
Submits the snapshot of the event to Frigate+ for labeling. Submits the snapshot of the event to Frigate+ for labeling.
| param | Type | Description |
| -------------------- | ---- | ---------------------------------- |
| `include_annotation` | int | Submit annotation to Frigate+ too. |
### `PUT /api/events/<id>/false_positive`
Submits the snapshot of the event to Frigate+ for labeling and adds the detection as a false positive.
### `DELETE /api/events/<id>/retain` ### `DELETE /api/events/<id>/retain`
Sets retain to false for the event id (event may be deleted quickly after removing). Sets retain to false for the event id (event may be deleted quickly after removing).
@ -283,3 +291,7 @@ Get ffprobe output for camera feed paths.
| param | Type | Description | | param | Type | Description |
| ------- | ------ | ---------------------------------- | | ------- | ------ | ---------------------------------- |
| `paths` | string | `,` separated list of camera paths | | `paths` | string | `,` separated list of camera paths |
### `GET /api/<camera_name>/ptz/info`
Get PTZ info for the camera.

View File

@ -158,3 +158,14 @@ Topic to adjust motion contour area for a camera. Expected value is an integer.
### `frigate/<camera_name>/motion_contour_area/state` ### `frigate/<camera_name>/motion_contour_area/state`
Topic with current motion contour area for a camera. Published value is an integer. Topic with current motion contour area for a camera. Published value is an integer.
### `frigate/<camera_name>/ptz`
Topic to send PTZ commands to camera.
| Command | Description |
| ---------------------- | --------------------------------------------------------------------------------------- |
| `preset-<preset_name>` | send command to move to preset with name `<preset_name>` |
| `MOVE_<dir>` | send command to continuously move in `<dir>`, possible values are [UP, DOWN, LEFT, RIGHT] |
| `ZOOM_<dir>` | send command to continuously zoom `<dir>`, possible values are [IN, OUT] |
| `STOP` | send command to stop moving |

View File

@ -27,6 +27,7 @@ from frigate.models import Event, Recordings, Timeline
from frigate.object_processing import TrackedObjectProcessor from frigate.object_processing import TrackedObjectProcessor
from frigate.output import output_frames from frigate.output import output_frames
from frigate.plus import PlusApi from frigate.plus import PlusApi
from frigate.ptz import OnvifController
from frigate.record import RecordingCleanup, RecordingMaintainer from frigate.record import RecordingCleanup, RecordingMaintainer
from frigate.stats import StatsEmitter, stats_init from frigate.stats import StatsEmitter, stats_init
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
@ -173,9 +174,13 @@ class FrigateApp:
self.stats_tracking, self.stats_tracking,
self.detected_frames_processor, self.detected_frames_processor,
self.storage_maintainer, self.storage_maintainer,
self.onvif_controller,
self.plus_api, self.plus_api,
) )
def init_onvif(self) -> None:
self.onvif_controller = OnvifController(self.config)
def init_dispatcher(self) -> None: def init_dispatcher(self) -> None:
comms: list[Communicator] = [] comms: list[Communicator] = []
@ -183,7 +188,9 @@ class FrigateApp:
comms.append(MqttClient(self.config)) comms.append(MqttClient(self.config))
comms.append(WebSocketClient(self.config)) comms.append(WebSocketClient(self.config))
self.dispatcher = Dispatcher(self.config, self.camera_metrics, comms) self.dispatcher = Dispatcher(
self.config, self.onvif_controller, self.camera_metrics, comms
)
def start_detectors(self) -> None: def start_detectors(self) -> None:
for name in self.config.cameras.keys(): for name in self.config.cameras.keys():
@ -382,6 +389,7 @@ class FrigateApp:
self.set_log_levels() self.set_log_levels()
self.init_queues() self.init_queues()
self.init_database() self.init_database()
self.init_onvif()
self.init_dispatcher() self.init_dispatcher()
except Exception as e: except Exception as e:
print(e) print(e)

View File

@ -7,6 +7,7 @@ from typing import Any, Callable
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.ptz import OnvifController, OnvifCommandEnum
from frigate.types import CameraMetricsTypes from frigate.types import CameraMetricsTypes
from frigate.util import restart_frigate from frigate.util import restart_frigate
@ -39,10 +40,12 @@ class Dispatcher:
def __init__( def __init__(
self, self,
config: FrigateConfig, config: FrigateConfig,
onvif: OnvifController,
camera_metrics: dict[str, CameraMetricsTypes], camera_metrics: dict[str, CameraMetricsTypes],
communicators: list[Communicator], communicators: list[Communicator],
) -> None: ) -> None:
self.config = config self.config = config
self.onvif = onvif
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.comms = communicators self.comms = communicators
@ -63,12 +66,21 @@ class Dispatcher:
"""Handle receiving of payload from communicators.""" """Handle receiving of payload from communicators."""
if topic.endswith("set"): if topic.endswith("set"):
try: try:
# example /cam_name/detect/set payload=ON|OFF
camera_name = topic.split("/")[-3] camera_name = topic.split("/")[-3]
command = topic.split("/")[-2] command = topic.split("/")[-2]
self._camera_settings_handlers[command](camera_name, payload) self._camera_settings_handlers[command](camera_name, payload)
except Exception as e: except IndexError as e:
logger.error(f"Received invalid set command: {topic}") logger.error(f"Received invalid set command: {topic}")
return return
elif topic.endswith("ptz"):
try:
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
camera_name = topic.split("/")[-2]
self._on_ptz_command(camera_name, payload)
except IndexError as e:
logger.error(f"Received invalid ptz command: {topic}")
return
elif topic == "restart": elif topic == "restart":
restart_frigate() restart_frigate()
@ -204,3 +216,18 @@ class Dispatcher:
snapshots_settings.enabled = False snapshots_settings.enabled = False
self.publish(f"{camera_name}/snapshots/state", payload, retain=True) self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
def _on_ptz_command(self, camera_name: str, payload: str) -> None:
"""Callback for ptz topic."""
try:
if "preset" in payload.lower():
command = OnvifCommandEnum.preset
param = payload.lower().split("-")[1]
else:
command = OnvifCommandEnum[payload.lower()]
param = ""
self.onvif.handle_command(camera_name, command, param)
logger.info(f"Setting ptz command to {command} for {camera_name}")
except KeyError as k:
logger.error(f"Invalid PTZ command {payload}: {k}")

View File

@ -167,6 +167,12 @@ class MqttClient(Communicator): # type: ignore[misc]
self.on_mqtt_command, self.on_mqtt_command,
) )
if self.config.cameras[name].onvif.host:
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/ptz",
self.on_mqtt_command,
)
self.client.message_callback_add( self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command
) )

View File

@ -18,6 +18,7 @@ from frigate.const import (
REGEX_CAMERA_NAME, REGEX_CAMERA_NAME,
YAML_EXT, YAML_EXT,
) )
from frigate.detectors.detector_config import BaseDetectorConfig
from frigate.util import ( from frigate.util import (
create_mask, create_mask,
deep_merge, deep_merge,
@ -124,6 +125,13 @@ class MqttConfig(FrigateBaseModel):
return v return v
class OnvifConfig(FrigateBaseModel):
host: str = Field(default="", title="Onvif Host")
port: int = Field(default=8000, title="Onvif Port")
user: Optional[str] = Field(title="Onvif Username")
password: Optional[str] = Field(title="Onvif Password")
class RetainModeEnum(str, Enum): class RetainModeEnum(str, Enum):
all = "all" all = "all"
motion = "motion" motion = "motion"
@ -607,6 +615,9 @@ class CameraConfig(FrigateBaseModel):
detect: DetectConfig = Field( detect: DetectConfig = Field(
default_factory=DetectConfig, title="Object detection configuration." default_factory=DetectConfig, title="Object detection configuration."
) )
onvif: OnvifConfig = Field(
default_factory=OnvifConfig, title="Camera Onvif Configuration."
)
ui: CameraUiConfig = Field( ui: CameraUiConfig = Field(
default_factory=CameraUiConfig, title="Camera UI Modifications." default_factory=CameraUiConfig, title="Camera UI Modifications."
) )
@ -771,7 +782,7 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
def verify_valid_live_stream_name( def verify_valid_live_stream_name(
frigate_config: FrigateConfig, camera_config: CameraConfig frigate_config: FrigateConfig, camera_config: CameraConfig
) -> None: ) -> ValueError | None:
"""Verify that a restream exists to use for live view.""" """Verify that a restream exists to use for live view."""
if ( if (
camera_config.live.stream_name camera_config.live.stream_name
@ -849,7 +860,7 @@ class FrigateConfig(FrigateBaseModel):
model: ModelConfig = Field( model: ModelConfig = Field(
default_factory=ModelConfig, title="Detection model configuration." default_factory=ModelConfig, title="Detection model configuration."
) )
detectors: Dict[str, DetectorConfig] = Field( detectors: Dict[str, BaseDetectorConfig] = Field(
default=DEFAULT_DETECTORS, default=DEFAULT_DETECTORS,
title="Detector hardware configuration.", title="Detector hardware configuration.",
) )
@ -939,6 +950,15 @@ class FrigateConfig(FrigateBaseModel):
for input in camera_config.ffmpeg.inputs: for input in camera_config.ffmpeg.inputs:
input.path = input.path.format(**FRIGATE_ENV_VARS) input.path = input.path.format(**FRIGATE_ENV_VARS)
# ONVIF substitution
if camera_config.onvif.user or camera_config.onvif.password:
camera_config.onvif.user = camera_config.onvif.user.format(
**FRIGATE_ENV_VARS
)
camera_config.onvif.password = camera_config.onvif.password.format(
**FRIGATE_ENV_VARS
)
# Add default filters # Add default filters
object_keys = camera_config.objects.track object_keys = camera_config.objects.track
if camera_config.objects.filters is None: if camera_config.objects.filters is None:
@ -1032,7 +1052,15 @@ class FrigateConfig(FrigateBaseModel):
detector_config.model.dict(exclude_unset=True), detector_config.model.dict(exclude_unset=True),
config.model.dict(exclude_unset=True), config.model.dict(exclude_unset=True),
) )
if not "path" in merged_model:
if detector_config.type == "cpu":
merged_model["path"] = "/cpu_model.tflite"
elif detector_config.type == "edgetpu":
merged_model["path"] = "/edgetpu_model.tflite"
detector_config.model = ModelConfig.parse_obj(merged_model) detector_config.model = ModelConfig.parse_obj(merged_model)
detector_config.model.compute_model_hash()
config.detectors[key] = detector_config config.detectors[key] = detector_config
return config return config

View File

@ -1,3 +1,4 @@
import hashlib
import logging import logging
from enum import Enum from enum import Enum
from typing import Dict, List, Optional, Tuple, Union, Literal from typing import Dict, List, Optional, Tuple, Union, Literal
@ -49,6 +50,7 @@ class ModelConfig(BaseModel):
) )
_merged_labelmap: Optional[Dict[int, str]] = PrivateAttr() _merged_labelmap: Optional[Dict[int, str]] = PrivateAttr()
_colormap: Dict[int, Tuple[int, int, int]] = PrivateAttr() _colormap: Dict[int, Tuple[int, int, int]] = PrivateAttr()
_model_hash: str = PrivateAttr()
@property @property
def merged_labelmap(self) -> Dict[int, str]: def merged_labelmap(self) -> Dict[int, str]:
@ -58,6 +60,10 @@ class ModelConfig(BaseModel):
def colormap(self) -> Dict[int, Tuple[int, int, int]]: def colormap(self) -> Dict[int, Tuple[int, int, int]]:
return self._colormap return self._colormap
@property
def model_hash(self) -> str:
return self._model_hash
def __init__(self, **config): def __init__(self, **config):
super().__init__(**config) super().__init__(**config)
@ -67,6 +73,13 @@ class ModelConfig(BaseModel):
} }
self._colormap = {} self._colormap = {}
def compute_model_hash(self) -> None:
with open(self.path, "rb") as f:
file_hash = hashlib.md5()
while chunk := f.read(8192):
file_hash.update(chunk)
self._model_hash = file_hash.hexdigest()
def create_colormap(self, enabled_labels: set[str]) -> None: def create_colormap(self, enabled_labels: set[str]) -> None:
"""Get a list of colors for enabled labels.""" """Get a list of colors for enabled labels."""
cmap = plt.cm.get_cmap("tab10", len(enabled_labels)) cmap = plt.cm.get_cmap("tab10", len(enabled_labels))

View File

@ -27,7 +27,7 @@ class CpuTfl(DetectionApi):
def __init__(self, detector_config: CpuDetectorConfig): def __init__(self, detector_config: CpuDetectorConfig):
self.interpreter = Interpreter( self.interpreter = Interpreter(
model_path=detector_config.model.path or "/cpu_model.tflite", model_path=detector_config.model.path,
num_threads=detector_config.num_threads or 3, num_threads=detector_config.num_threads or 3,
) )

View File

@ -37,7 +37,7 @@ class EdgeTpuTfl(DetectionApi):
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config) edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
logger.info("TPU found") logger.info("TPU found")
self.interpreter = Interpreter( self.interpreter = Interpreter(
model_path=detector_config.model.path or "/edgetpu_model.tflite", model_path=detector_config.model.path,
experimental_delegates=[edge_tpu_delegate], experimental_delegates=[edge_tpu_delegate],
) )
except ValueError: except ValueError:

View File

@ -12,6 +12,7 @@ from frigate.const import CLIPS_DIR
from frigate.models import Event from frigate.models import Event
from frigate.timeline import TimelineSourceEnum from frigate.timeline import TimelineSourceEnum
from frigate.types import CameraMetricsTypes from frigate.types import CameraMetricsTypes
from frigate.util import to_relative_box
from multiprocessing.queues import Queue from multiprocessing.queues import Queue
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
@ -20,22 +21,18 @@ from typing import Dict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def should_insert_db(prev_event: Event, current_event: Event) -> bool:
"""If current event has new clip or snapshot."""
return (not prev_event["has_clip"] and not prev_event["has_snapshot"]) and (
current_event["has_clip"] or current_event["has_snapshot"]
)
def should_update_db(prev_event: Event, current_event: Event) -> bool: def should_update_db(prev_event: Event, current_event: Event) -> bool:
"""If current_event has updated fields and (clip or snapshot).""" """If current_event has updated fields and (clip or snapshot)."""
if current_event["has_clip"] or current_event["has_snapshot"]: if current_event["has_clip"] or current_event["has_snapshot"]:
# if this is the first time has_clip or has_snapshot turned true
if not prev_event["has_clip"] and not prev_event["has_snapshot"]:
return True
# or if any of the following values changed
if ( if (
prev_event["top_score"] != current_event["top_score"] prev_event["top_score"] != current_event["top_score"]
or prev_event["entered_zones"] != current_event["entered_zones"] or prev_event["entered_zones"] != current_event["entered_zones"]
or prev_event["thumbnail"] != current_event["thumbnail"] or prev_event["thumbnail"] != current_event["thumbnail"]
or prev_event["has_clip"] != current_event["has_clip"] or prev_event["end_time"] != current_event["end_time"]
or prev_event["has_snapshot"] != current_event["has_snapshot"]
): ):
return True return True
return False return False
@ -85,81 +82,91 @@ class EventProcessor(threading.Thread):
) )
) )
event_config: EventsConfig = self.config.cameras[camera].record.events # if this is the first message, just store it and continue, its not time to insert it in the db
if event_type == "start": if event_type == "start":
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
continue
elif event_type == "update" and should_insert_db( if should_update_db(self.events_in_process[event_data["id"]], event_data):
self.events_in_process[event_data["id"]], event_data camera_config = self.config.cameras[camera]
): event_config: EventsConfig = camera_config.record.events
width = camera_config.detect.width
height = camera_config.detect.height
first_detector = list(self.config.detectors.values())[0]
start_time = event_data["start_time"] - event_config.pre_capture
end_time = (
None
if event_data["end_time"] is None
else event_data["end_time"] + event_config.post_capture
)
# score of the snapshot
score = (
None
if event_data["snapshot"] is None
else event_data["snapshot"]["score"]
)
# detection region in the snapshot
region = (
None
if event_data["snapshot"] is None
else to_relative_box(
width,
height,
event_data["snapshot"]["region"],
)
)
# bounding box for the snapshot
box = (
None
if event_data["snapshot"] is None
else to_relative_box(
width,
height,
event_data["snapshot"]["box"],
)
)
# keep these from being set back to false because the event
# may have started while recordings and snapshots were enabled
# this would be an issue for long running events
if self.events_in_process[event_data["id"]]["has_clip"]:
event_data["has_clip"] = True
if self.events_in_process[event_data["id"]]["has_snapshot"]:
event_data["has_snapshot"] = True
event = {
Event.id: event_data["id"],
Event.label: event_data["label"],
Event.camera: camera,
Event.start_time: start_time,
Event.end_time: end_time,
Event.top_score: event_data["top_score"],
Event.score: score,
Event.zones: list(event_data["entered_zones"]),
Event.thumbnail: event_data["thumbnail"],
Event.region: region,
Event.box: box,
Event.has_clip: event_data["has_clip"],
Event.has_snapshot: event_data["has_snapshot"],
Event.model_hash: first_detector.model.model_hash,
Event.model_type: first_detector.model.model_type,
Event.detector_type: first_detector.type,
}
(
Event.insert(event)
.on_conflict(
conflict_target=[Event.id],
update=event,
)
.execute()
)
# update the stored copy for comparison on future update messages
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly
Event.insert(
id=event_data["id"],
label=event_data["label"],
camera=camera,
start_time=event_data["start_time"] - event_config.pre_capture,
end_time=None,
top_score=event_data["top_score"],
false_positive=event_data["false_positive"],
zones=list(event_data["entered_zones"]),
thumbnail=event_data["thumbnail"],
region=event_data["region"],
box=event_data["box"],
area=event_data["area"],
has_clip=event_data["has_clip"],
has_snapshot=event_data["has_snapshot"],
).execute()
elif event_type == "update" and should_update_db(
self.events_in_process[event_data["id"]], event_data
):
self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly
Event.update(
label=event_data["label"],
camera=camera,
start_time=event_data["start_time"] - event_config.pre_capture,
end_time=None,
top_score=event_data["top_score"],
false_positive=event_data["false_positive"],
zones=list(event_data["entered_zones"]),
thumbnail=event_data["thumbnail"],
region=event_data["region"],
box=event_data["box"],
area=event_data["area"],
ratio=event_data["ratio"],
has_clip=event_data["has_clip"],
has_snapshot=event_data["has_snapshot"],
).where(Event.id == event_data["id"]).execute()
elif event_type == "end":
if event_data["has_clip"] or event_data["has_snapshot"]:
# Full update for valid end of event
Event.update(
label=event_data["label"],
camera=camera,
start_time=event_data["start_time"] - event_config.pre_capture,
end_time=event_data["end_time"] + event_config.post_capture,
top_score=event_data["top_score"],
false_positive=event_data["false_positive"],
zones=list(event_data["entered_zones"]),
thumbnail=event_data["thumbnail"],
region=event_data["region"],
box=event_data["box"],
area=event_data["area"],
ratio=event_data["ratio"],
has_clip=event_data["has_clip"],
has_snapshot=event_data["has_snapshot"],
).where(Event.id == event_data["id"]).execute()
else:
# Event ended after clip & snapshot disabled,
# only end time should be updated.
Event.update(
end_time=event_data["end_time"] + event_config.post_capture
).where(Event.id == event_data["id"]).execute()
if event_type == "end":
del self.events_in_process[event_data["id"]] del self.events_in_process[event_data["id"]]
self.event_processed_queue.put((event_data["id"], camera)) self.event_processed_queue.put((event_data["id"], camera))

View File

@ -35,6 +35,8 @@ from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.models import Event, Recordings, Timeline from frigate.models import Event, Recordings, Timeline
from frigate.object_processing import TrackedObject from frigate.object_processing import TrackedObject
from frigate.plus import PlusApi
from frigate.ptz import OnvifController
from frigate.stats import stats_snapshot from frigate.stats import stats_snapshot
from frigate.util import ( from frigate.util import (
clean_camera_user_pass, clean_camera_user_pass,
@ -42,6 +44,7 @@ from frigate.util import (
restart_frigate, restart_frigate,
vainfo_hwaccel, vainfo_hwaccel,
get_tz_modifiers, get_tz_modifiers,
to_relative_box,
) )
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
from frigate.version import VERSION from frigate.version import VERSION
@ -57,7 +60,8 @@ def create_app(
stats_tracking, stats_tracking,
detected_frames_processor, detected_frames_processor,
storage_maintainer: StorageMaintainer, storage_maintainer: StorageMaintainer,
plus_api, onvif: OnvifController,
plus_api: PlusApi,
): ):
app = Flask(__name__) app = Flask(__name__)
@ -75,6 +79,7 @@ def create_app(
app.stats_tracking = stats_tracking app.stats_tracking = stats_tracking
app.detected_frames_processor = detected_frames_processor app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer app.storage_maintainer = storage_maintainer
app.onvif = onvif
app.plus_api = plus_api app.plus_api = plus_api
app.camera_error_image = None app.camera_error_image = None
app.hwaccel_errors = [] app.hwaccel_errors = []
@ -179,6 +184,10 @@ def send_to_plus(id):
400, 400,
) )
include_annotation = (
request.json.get("include_annotation") if request.is_json else None
)
try: try:
event = Event.get(Event.id == id) event = Event.get(Event.id == id)
except DoesNotExist: except DoesNotExist:
@ -186,6 +195,10 @@ def send_to_plus(id):
logger.error(message) logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 404) return make_response(jsonify({"success": False, "message": message}), 404)
# events from before the conversion to relative dimensions cant include annotations
if any(d > 1 for d in event.box):
include_annotation = None
if event.end_time is None: if event.end_time is None:
logger.error(f"Unable to load clean png for in-progress event: {event.id}") logger.error(f"Unable to load clean png for in-progress event: {event.id}")
return make_response( return make_response(
@ -238,9 +251,96 @@ def send_to_plus(id):
event.plus_id = plus_id event.plus_id = plus_id
event.save() event.save()
if not include_annotation is None:
region = event.region
box = event.box
try:
current_app.plus_api.add_annotation(
event.plus_id,
box,
event.label,
)
except Exception as ex:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": str(ex)}),
400,
)
return make_response(jsonify({"success": True, "plus_id": plus_id}), 200) return make_response(jsonify({"success": True, "plus_id": plus_id}), 200)
@bp.route("/events/<id>/false_positive", methods=("PUT",))
def false_positive(id):
if not current_app.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
return make_response(
jsonify(
{
"success": False,
"message": message,
}
),
400,
)
try:
event = Event.get(Event.id == id)
except DoesNotExist:
message = f"Event {id} not found"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 404)
# events from before the conversion to relative dimensions cant include annotations
if any(d > 1 for d in event.box):
message = f"Events prior to 0.13 cannot be submitted as false positives"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400)
if event.false_positive:
message = f"False positive already submitted to Frigate+"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400)
if not event.plus_id:
plus_response = send_to_plus(id)
if plus_response.status_code != 200:
return plus_response
# need to refetch the event now that it has a plus_id
event = Event.get(Event.id == id)
region = event.region
box = event.box
# provide top score if score is unavailable
score = event.top_score if event.score is None else event.score
try:
current_app.plus_api.add_false_positive(
event.plus_id,
region,
box,
score,
event.label,
event.model_hash,
event.model_type,
event.detector_type,
)
except Exception as ex:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": str(ex)}),
400,
)
event.false_positive = True
event.save()
return make_response(jsonify({"success": True, "plus_id": event.plus_id}), 200)
@bp.route("/events/<id>/retain", methods=("DELETE",)) @bp.route("/events/<id>/retain", methods=("DELETE",))
def delete_retain(id): def delete_retain(id):
try: try:
@ -654,6 +754,8 @@ def events():
Event.retain_indefinitely, Event.retain_indefinitely,
Event.sub_label, Event.sub_label,
Event.top_score, Event.top_score,
Event.false_positive,
Event.box,
] ]
if camera != "all": if camera != "all":
@ -895,6 +997,14 @@ def mjpeg_feed(camera_name):
return "Camera named {} not found".format(camera_name), 404 return "Camera named {} not found".format(camera_name), 404
@bp.route("/<camera_name>/ptz/info")
def camera_ptz_info(camera_name):
if camera_name in current_app.frigate_config.cameras:
return jsonify(current_app.onvif.get_camera_info(camera_name))
else:
return "Camera named {} not found".format(camera_name), 404
@bp.route("/<camera_name>/latest.jpg") @bp.route("/<camera_name>/latest.jpg")
def latest_frame(camera_name): def latest_frame(camera_name):
draw_options = { draw_options = {

View File

@ -19,6 +19,10 @@ from frigate.util import clean_camera_user_pass
def listener_configurer() -> None: def listener_configurer() -> None:
root = logging.getLogger() root = logging.getLogger()
if root.hasHandlers():
root.handlers.clear()
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
formatter = logging.Formatter( formatter = logging.Formatter(
"[%(asctime)s] %(name)-30s %(levelname)-8s: %(message)s", "%Y-%m-%d %H:%M:%S" "[%(asctime)s] %(name)-30s %(levelname)-8s: %(message)s", "%Y-%m-%d %H:%M:%S"
@ -31,6 +35,10 @@ def listener_configurer() -> None:
def root_configurer(queue: Queue) -> None: def root_configurer(queue: Queue) -> None:
h = handlers.QueueHandler(queue) h = handlers.QueueHandler(queue)
root = logging.getLogger() root = logging.getLogger()
if root.hasHandlers():
root.handlers.clear()
root.addHandler(h) root.addHandler(h)
root.setLevel(logging.INFO) root.setLevel(logging.INFO)

View File

@ -19,6 +19,7 @@ class Event(Model): # type: ignore[misc]
start_time = DateTimeField() start_time = DateTimeField()
end_time = DateTimeField() end_time = DateTimeField()
top_score = FloatField() top_score = FloatField()
score = FloatField()
false_positive = BooleanField() false_positive = BooleanField()
zones = JSONField() zones = JSONField()
thumbnail = TextField() thumbnail = TextField()
@ -30,6 +31,9 @@ class Event(Model): # type: ignore[misc]
retain_indefinitely = BooleanField(default=False) retain_indefinitely = BooleanField(default=False)
ratio = FloatField(default=1.0) ratio = FloatField(default=1.0)
plus_id = CharField(max_length=30) plus_id = CharField(max_length=30)
model_hash = CharField(max_length=32)
detector_type = CharField(max_length=32)
model_type = CharField(max_length=32)
class Timeline(Model): # type: ignore[misc] class Timeline(Model): # type: ignore[misc]

View File

@ -185,7 +185,7 @@ class TrackedObject:
"id": self.obj_data["id"], "id": self.obj_data["id"],
"camera": self.camera, "camera": self.camera,
"frame_time": self.obj_data["frame_time"], "frame_time": self.obj_data["frame_time"],
"snapshot_time": snapshot_time, "snapshot": self.thumbnail_data,
"label": self.obj_data["label"], "label": self.obj_data["label"],
"sub_label": self.obj_data.get("sub_label"), "sub_label": self.obj_data.get("sub_label"),
"top_score": self.top_score, "top_score": self.top_score,

View File

@ -3,6 +3,7 @@ import json
import logging import logging
import os import os
import re import re
from typing import List
import requests import requests
from frigate.const import PLUS_ENV_VAR, PLUS_API_HOST from frigate.const import PLUS_ENV_VAR, PLUS_API_HOST
from requests.models import Response from requests.models import Response
@ -79,6 +80,13 @@ class PlusApi:
json=data, json=data,
) )
def _put(self, path: str, data: dict) -> Response:
return requests.put(
f"{self.host}/v1/{path}",
headers=self._get_authorization_header(),
json=data,
)
def is_active(self) -> bool: def is_active(self) -> bool:
return self._is_active return self._is_active
@ -124,3 +132,58 @@ class PlusApi:
# return image id # return image id
return str(presigned_urls.get("imageId")) return str(presigned_urls.get("imageId"))
def add_false_positive(
self,
plus_id: str,
region: List[float],
bbox: List[float],
score: float,
label: str,
model_hash: str,
model_type: str,
detector_type: str,
) -> None:
r = self._put(
f"image/{plus_id}/false_positive",
{
"label": label,
"x": bbox[0],
"y": bbox[1],
"w": bbox[2],
"h": bbox[3],
"regionX": region[0],
"regionY": region[1],
"regionW": region[2],
"regionH": region[3],
"score": score,
"model_hash": model_hash,
"model_type": model_type,
"detector_type": detector_type,
},
)
if not r.ok:
raise Exception(r.text)
def add_annotation(
self,
plus_id: str,
bbox: List[float],
label: str,
difficult: bool = False,
) -> None:
r = self._put(
f"image/{plus_id}/annotation",
{
"label": label,
"x": bbox[0],
"y": bbox[1],
"w": bbox[2],
"h": bbox[3],
"difficult": difficult,
},
)
if not r.ok:
raise Exception(r.text)

219
frigate/ptz.py Normal file
View File

@ -0,0 +1,219 @@
"""Configure and control camera via onvif."""
import logging
import site
from enum import Enum
from onvif import ONVIFCamera, ONVIFError
from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)
class OnvifCommandEnum(str, Enum):
"""Holds all possible move commands"""
init = "init"
move_down = "move_down"
move_left = "move_left"
move_right = "move_right"
move_up = "move_up"
preset = "preset"
stop = "stop"
zoom_in = "zoom_in"
zoom_out = "zoom_out"
class OnvifController:
def __init__(self, config: FrigateConfig) -> None:
self.cams: dict[str, ONVIFCamera] = {}
for cam_name, cam in config.cameras.items():
if not cam.enabled:
continue
if cam.onvif.host:
try:
self.cams[cam_name] = {
"onvif": ONVIFCamera(
cam.onvif.host,
cam.onvif.port,
cam.onvif.user,
cam.onvif.password,
wsdl_dir=site.getsitepackages()[0].replace(
"dist-packages", "site-packages"
)
+ "/wsdl",
),
"init": False,
"active": False,
"presets": {},
}
except ONVIFError as e:
logger.error(f"Onvif connection to {cam.name} failed: {e}")
def _init_onvif(self, camera_name: str) -> bool:
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
# create init services
media = onvif.create_media_service()
try:
profile = media.GetProfiles()[0]
except ONVIFError as e:
logger.error(f"Unable to connect to camera: {camera_name}: {e}")
return False
ptz = onvif.create_ptz_service()
request = ptz.create_type("GetConfigurationOptions")
request.ConfigurationToken = profile.PTZConfiguration.token
# setup moving request
move_request = ptz.create_type("ContinuousMove")
move_request.ProfileToken = profile.token
self.cams[camera_name]["move_request"] = move_request
# setup existing presets
try:
presets: list[dict] = ptz.GetPresets({"ProfileToken": profile.token})
except ONVIFError as e:
logger.error(f"Unable to get presets from camera: {camera_name}: {e}")
return False
for preset in presets:
self.cams[camera_name]["presets"][preset["Name"].lower()] = preset["token"]
# get list of supported features
ptz_config = ptz.GetConfigurationOptions(request)
supported_features = []
if ptz_config.Spaces and ptz_config.Spaces.ContinuousPanTiltVelocitySpace:
supported_features.append("pt")
if ptz_config.Spaces and ptz_config.Spaces.ContinuousZoomVelocitySpace:
supported_features.append("zoom")
self.cams[camera_name]["features"] = supported_features
self.cams[camera_name]["init"] = True
return True
def _stop(self, camera_name: str) -> None:
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
move_request = self.cams[camera_name]["move_request"]
onvif.get_service("ptz").Stop(
{
"ProfileToken": move_request.ProfileToken,
"PanTilt": True,
"Zoom": True,
}
)
self.cams[camera_name]["active"] = False
def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
self._stop(camera_name)
self.cams[camera_name]["active"] = True
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
move_request = self.cams[camera_name]["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,
}
}
onvif.get_service("ptz").ContinuousMove(move_request)
def _move_to_preset(self, camera_name: str, preset: str) -> None:
if not preset in self.cams[camera_name]["presets"]:
logger.error(f"{preset} is not a valid preset for {camera_name}")
return
self.cams[camera_name]["active"] = True
move_request = self.cams[camera_name]["move_request"]
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
preset_token = self.cams[camera_name]["presets"][preset]
onvif.get_service("ptz").GotoPreset(
{
"ProfileToken": move_request.ProfileToken,
"PresetToken": preset_token,
}
)
self.cams[camera_name]["active"] = False
def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, stopping..."
)
self._stop(camera_name)
self.cams[camera_name]["active"] = True
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
move_request = self.cams[camera_name]["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}}
onvif.get_service("ptz").ContinuousMove(move_request)
def handle_command(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None:
if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}")
return
if not self.cams[camera_name]["init"]:
if not self._init_onvif(camera_name):
return
if command == OnvifCommandEnum.init:
# already init
return
elif command == OnvifCommandEnum.stop:
self._stop(camera_name)
elif command == OnvifCommandEnum.preset:
self._move_to_preset(camera_name, param)
elif (
command == OnvifCommandEnum.zoom_in or command == OnvifCommandEnum.zoom_out
):
self._zoom(camera_name, command)
else:
self._move(camera_name, command)
def get_camera_info(self, camera_name: str) -> dict[str, any]:
if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}")
return {}
if not self.cams[camera_name]["init"]:
self._init_onvif(camera_name)
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
}

View File

@ -54,7 +54,8 @@ class TestConfig(unittest.TestCase):
"type": "openvino", "type": "openvino",
}, },
}, },
"model": {"path": "/default.tflite", "width": 512}, # needs to be a file that will exist, doesnt matter what
"model": {"path": "/etc/hosts", "width": 512},
} }
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal))) frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
@ -72,10 +73,10 @@ class TestConfig(unittest.TestCase):
assert runtime_config.detectors["edgetpu"].device is None assert runtime_config.detectors["edgetpu"].device is None
assert runtime_config.detectors["openvino"].device is None assert runtime_config.detectors["openvino"].device is None
assert runtime_config.model.path == "/default.tflite" assert runtime_config.model.path == "/etc/hosts"
assert runtime_config.detectors["cpu"].model.path == "/cpu_model.tflite" assert runtime_config.detectors["cpu"].model.path == "/cpu_model.tflite"
assert runtime_config.detectors["edgetpu"].model.path == "/edgetpu_model.tflite" assert runtime_config.detectors["edgetpu"].model.path == "/edgetpu_model.tflite"
assert runtime_config.detectors["openvino"].model.path == "/default.tflite" assert runtime_config.detectors["openvino"].model.path == "/etc/hosts"
assert runtime_config.model.width == 512 assert runtime_config.model.width == 512
assert runtime_config.detectors["cpu"].model.width == 512 assert runtime_config.detectors["cpu"].model.width == 512

View File

@ -114,7 +114,13 @@ class TestHttp(unittest.TestCase):
def test_get_event_list(self): def test_get_event_list(self):
app = create_app( app = create_app(
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
PlusApi(),
) )
id = "123456.random" id = "123456.random"
id2 = "7890.random" id2 = "7890.random"
@ -143,7 +149,13 @@ class TestHttp(unittest.TestCase):
def test_get_good_event(self): def test_get_good_event(self):
app = create_app( app = create_app(
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
PlusApi(),
) )
id = "123456.random" id = "123456.random"
@ -157,7 +169,13 @@ class TestHttp(unittest.TestCase):
def test_get_bad_event(self): def test_get_bad_event(self):
app = create_app( app = create_app(
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
PlusApi(),
) )
id = "123456.random" id = "123456.random"
bad_id = "654321.other" bad_id = "654321.other"
@ -170,7 +188,13 @@ class TestHttp(unittest.TestCase):
def test_delete_event(self): def test_delete_event(self):
app = create_app( app = create_app(
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
PlusApi(),
) )
id = "123456.random" id = "123456.random"
@ -185,7 +209,13 @@ class TestHttp(unittest.TestCase):
def test_event_retention(self): def test_event_retention(self):
app = create_app( app = create_app(
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
PlusApi(),
) )
id = "123456.random" id = "123456.random"
@ -204,7 +234,13 @@ class TestHttp(unittest.TestCase):
def test_set_delete_sub_label(self): def test_set_delete_sub_label(self):
app = create_app( app = create_app(
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
PlusApi(),
) )
id = "123456.random" id = "123456.random"
sub_label = "sub" sub_label = "sub"
@ -232,7 +268,13 @@ class TestHttp(unittest.TestCase):
def test_sub_label_list(self): def test_sub_label_list(self):
app = create_app( app = create_app(
FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
PlusApi(),
) )
id = "123456.random" id = "123456.random"
sub_label = "sub" sub_label = "sub"
@ -255,6 +297,7 @@ class TestHttp(unittest.TestCase):
None, None,
None, None,
None, None,
None,
PlusApi(), PlusApi(),
) )
@ -270,6 +313,7 @@ class TestHttp(unittest.TestCase):
None, None,
None, None,
None, None,
None,
PlusApi(), PlusApi(),
) )
id = "123456.random" id = "123456.random"
@ -288,6 +332,7 @@ class TestHttp(unittest.TestCase):
None, None,
None, None,
None, None,
None,
PlusApi(), PlusApi(),
) )
mock_stats.return_value = self.test_stats mock_stats.return_value = self.test_stats

View File

@ -12,6 +12,8 @@ from frigate.models import Timeline
from multiprocessing.queues import Queue from multiprocessing.queues import Queue
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from frigate.util import to_relative_box
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,77 +66,36 @@ class TimelineProcessor(threading.Thread):
"""Handle object detection.""" """Handle object detection."""
camera_config = self.config.cameras[camera] camera_config = self.config.cameras[camera]
timeline_entry = {
Timeline.timestamp: event_data["frame_time"],
Timeline.camera: camera,
Timeline.source: "tracked_object",
Timeline.source_id: event_data["id"],
Timeline.data: {
"box": to_relative_box(
camera_config.detect.width,
camera_config.detect.height,
event_data["box"],
),
"label": event_data["label"],
"region": to_relative_box(
camera_config.detect.width,
camera_config.detect.height,
event_data["region"],
),
},
}
if event_type == "start": if event_type == "start":
Timeline.insert( timeline_entry[Timeline.class_type] = "visible"
timestamp=event_data["frame_time"], Timeline.insert(timeline_entry).execute()
camera=camera,
source="tracked_object",
source_id=event_data["id"],
class_type="visible",
data={
"box": [
event_data["box"][0] / camera_config.detect.width,
event_data["box"][1] / camera_config.detect.height,
event_data["box"][2] / camera_config.detect.width,
event_data["box"][3] / camera_config.detect.height,
],
"label": event_data["label"],
"region": [
event_data["region"][0] / camera_config.detect.width,
event_data["region"][1] / camera_config.detect.height,
event_data["region"][2] / camera_config.detect.width,
event_data["region"][3] / camera_config.detect.height,
],
},
).execute()
elif ( elif (
event_type == "update" event_type == "update"
and prev_event_data["current_zones"] != event_data["current_zones"] and prev_event_data["current_zones"] != event_data["current_zones"]
and len(event_data["current_zones"]) > 0 and len(event_data["current_zones"]) > 0
): ):
Timeline.insert( timeline_entry[Timeline.class_type] = "entered_zone"
timestamp=event_data["frame_time"], timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
camera=camera, Timeline.insert(timeline_entry).execute()
source="tracked_object",
source_id=event_data["id"],
class_type="entered_zone",
data={
"box": [
event_data["box"][0] / camera_config.detect.width,
event_data["box"][1] / camera_config.detect.height,
event_data["box"][2] / camera_config.detect.width,
event_data["box"][3] / camera_config.detect.height,
],
"label": event_data["label"],
"region": [
event_data["region"][0] / camera_config.detect.width,
event_data["region"][1] / camera_config.detect.height,
event_data["region"][2] / camera_config.detect.width,
event_data["region"][3] / camera_config.detect.height,
],
"zones": event_data["current_zones"],
},
).execute()
elif event_type == "end": elif event_type == "end":
Timeline.insert( timeline_entry[Timeline.class_type] = "gone"
timestamp=event_data["frame_time"], Timeline.insert(timeline_entry).execute()
camera=camera,
source="tracked_object",
source_id=event_data["id"],
class_type="gone",
data={
"box": [
event_data["box"][0] / camera_config.detect.width,
event_data["box"][1] / camera_config.detect.height,
event_data["box"][2] / camera_config.detect.width,
event_data["box"][3] / camera_config.detect.height,
],
"label": event_data["label"],
"region": [
event_data["region"][0] / camera_config.detect.width,
event_data["region"][1] / camera_config.detect.height,
event_data["region"][2] / camera_config.detect.width,
event_data["region"][3] / camera_config.detect.height,
],
},
).execute()

View File

@ -1065,3 +1065,14 @@ def get_tz_modifiers(tz_name: str) -> Tuple[str, str]:
hour_modifier = f"{hours_offset} hour" hour_modifier = f"{hours_offset} hour"
minute_modifier = f"{minutes_offset} minute" minute_modifier = f"{minutes_offset} minute"
return hour_modifier, minute_modifier return hour_modifier, minute_modifier
def to_relative_box(
width: int, height: int, box: Tuple[int, int, int, int]
) -> Tuple[int, int, int, int]:
return (
box[0] / width, # x
box[1] / height, # y
(box[2] - box[0]) / width, # w
(box[3] - box[1]) / height, # h
)

View File

@ -0,0 +1,52 @@
"""Peewee migrations
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import datetime as dt
import peewee as pw
from playhouse.sqlite_ext import *
from decimal import ROUND_HALF_EVEN
from frigate.models import Event
try:
import playhouse.postgres_ext as pw_pext
except ImportError:
pass
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.add_fields(
Event,
score=pw.FloatField(null=True),
model_hash=pw.CharField(max_length=32, null=True),
detector_type=pw.CharField(max_length=32, null=True),
model_type=pw.CharField(max_length=32, null=True),
)
migrator.drop_not_null(Event, "area", "false_positive")
migrator.add_default(Event, "false_positive", 0)
def rollback(migrator, database, fake=False, **kwargs):
pass

View File

@ -1,18 +1,19 @@
click == 8.1.* click == 8.1.*
Flask == 2.2.* Flask == 2.2.*
imutils == 0.5.* imutils == 0.5.*
matplotlib == 3.6.* matplotlib == 3.7.*
mypy == 0.942 mypy == 0.942
numpy == 1.23.* numpy == 1.23.*
onvif_zeep == 0.2.12
opencv-python-headless == 4.5.5.* opencv-python-headless == 4.5.5.*
paho-mqtt == 1.6.* paho-mqtt == 1.6.*
peewee == 3.15.* peewee == 3.15.*
peewee_migrate == 1.6.* peewee_migrate == 1.7.*
psutil == 5.9.* psutil == 5.9.*
pydantic == 1.10.* pydantic == 1.10.*
PyYAML == 6.0 PyYAML == 6.0
pytz == 2023.3 pytz == 2023.3
tzlocal == 4.2 tzlocal == 4.3
types-PyYAML == 6.0.* types-PyYAML == 6.0.*
requests == 2.28.* requests == 2.28.*
types-requests == 2.28.* types-requests == 2.28.*

View File

@ -1,2 +1,2 @@
scikit-build == 0.17.1 scikit-build == 0.17.*
nvidia-pyindex nvidia-pyindex

741
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5", "@cycjimmy/jsmpeg-player": "^6.0.5",
"axios": "^1.3.5", "axios": "^1.3.6",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"idb-keyval": "^6.2.0", "idb-keyval": "^6.2.0",
@ -36,23 +36,23 @@
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/preact": "^3.2.3", "@testing-library/preact": "^3.2.3",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.58.0", "@typescript-eslint/parser": "^5.59.1",
"@vitest/coverage-c8": "^0.30.1", "@vitest/coverage-c8": "^0.30.1",
"@vitest/ui": "^0.30.1", "@vitest/ui": "^0.30.1",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"eslint": "^8.38.0", "eslint": "^8.39.0",
"eslint-config-preact": "^1.3.0", "eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-vitest-globals": "^1.3.1", "eslint-plugin-vitest-globals": "^1.3.1",
"fake-indexeddb": "^4.0.1", "fake-indexeddb": "^4.0.1",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",
"msw": "^1.2.1", "msw": "^1.2.1",
"postcss": "^8.4.19", "postcss": "^8.4.23",
"prettier": "^2.8.7", "prettier": "^2.8.8",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.2",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"vite": "^4.2.1", "vite": "^4.3.2",
"vitest": "^0.30.1" "vitest": "^0.30.1"
} }
} }

View File

@ -120,6 +120,15 @@ export function useSnapshotsState(camera) {
return { payload, send, connected }; return { payload, send, connected };
} }
export function usePtzCommand(camera) {
const {
value: { payload },
send,
connected,
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
return { payload, send, connected };
}
export function useRestart() { export function useRestart() {
const { const {
value: { payload }, value: { payload },

View File

@ -0,0 +1,248 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import useSWR from 'swr';
import { usePtzCommand } from '../api/ws';
import ActivityIndicator from './ActivityIndicator';
import ArrowRightDouble from '../icons/ArrowRightDouble';
import ArrowUpDouble from '../icons/ArrowUpDouble';
import ArrowDownDouble from '../icons/ArrowDownDouble';
import ArrowLeftDouble from '../icons/ArrowLeftDouble';
import Button from './Button';
import Heading from './Heading';
export default function CameraControlPanel({ camera = '' }) {
const { data: ptz } = useSWR(`${camera}/ptz/info`);
const [currentPreset, setCurrentPreset] = useState('');
const { payload: _, send: sendPtz } = usePtzCommand(camera);
const onSetPreview = async (e) => {
e.stopPropagation();
if (currentPreset == 'none') {
return;
}
sendPtz(`preset-${currentPreset}`);
setCurrentPreset('');
};
const onSetMove = async (e, dir) => {
e.stopPropagation();
sendPtz(`MOVE_${dir}`);
setCurrentPreset('');
};
const onSetZoom = async (e, dir) => {
e.stopPropagation();
sendPtz(`ZOOM_${dir}`);
setCurrentPreset('');
};
const onSetStop = async (e) => {
e.stopPropagation();
sendPtz('STOP');
};
if (!ptz) {
return <ActivityIndicator />;
}
document.addEventListener('keydown', (e) => {
if (!e) {
return;
}
if (e.repeat) {
e.preventDefault();
return;
}
if (ptz.features.includes('pt')) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
onSetMove(e, 'LEFT');
} else if (e.key === 'ArrowRight') {
e.preventDefault();
onSetMove(e, 'RIGHT');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
onSetMove(e, 'UP');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onSetMove(e, 'DOWN');
}
if (ptz.features.includes('zoom')) {
if (e.key == '+') {
e.preventDefault();
onSetZoom(e, 'IN');
} else if (e.key == '-') {
e.preventDefault();
onSetZoom(e, 'OUT');
}
}
}
});
document.addEventListener('keyup', (e) => {
if (!e || e.repeat) {
return;
}
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === '+' ||
e.key === '-'
) {
e.preventDefault();
onSetStop(e);
}
});
return (
<div data-testid="control-panel" className="p-4 text-center sm:flex justify-start">
{ptz.features.includes('pt') && (
<div className="flex justify-center">
<div className="w-44 px-4">
<Heading size="xs" className="my-4">
Pan / Tilt
</Heading>
<div className="w-full flex justify-center">
<button
onMouseDown={(e) => onSetMove(e, 'UP')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'UP');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowUpDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
<div className="w-full flex justify-between">
<button
onMouseDown={(e) => onSetMove(e, 'LEFT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'LEFT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowLeftDouble className="btn h-12 p-2 bg-slate-500" />
</button>
<button
onMouseDown={(e) => onSetMove(e, 'RIGHT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'RIGHT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowRightDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
<div className="flex justify-center">
<button
onMouseDown={(e) => onSetMove(e, 'DOWN')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetMove(e, 'DOWN');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<ArrowDownDouble className="h-12 p-2 bg-slate-500" />
</button>
</div>
</div>
</div>
)}
{ptz.features.includes('zoom') && (
<div className="px-4 sm:w-44">
<Heading size="xs" className="my-4">
Zoom
</Heading>
<div className="w-full flex justify-center">
<button
onMouseDown={(e) => onSetZoom(e, 'IN')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetZoom(e, 'IN');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">+</div>
</button>
</div>
<div className="h-12" />
<div className="flex justify-center">
<button
onMouseDown={(e) => onSetZoom(e, 'OUT')}
onMouseUp={(e) => onSetStop(e)}
onTouchStart={(e) => {
onSetZoom(e, 'OUT');
e.preventDefault();
}}
onTouchEnd={(e) => {
onSetStop(e);
e.preventDefault();
}}
>
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">-</div>
</button>
</div>
</div>
)}
{(ptz.presets || []).length > 0 && (
<div className="px-4">
<Heading size="xs" className="my-4">
Presets
</Heading>
<div className="py-4">
<select
className="cursor-pointer rounded dark:bg-slate-800"
value={currentPreset}
onChange={(e) => {
setCurrentPreset(e.target.value);
}}
>
<option value="">Select Preset</option>
{ptz.presets.map((item) => (
<option key={item} value={item}>
{item.charAt(0).toUpperCase() + item.slice(1)}
</option>
))}
</select>
</div>
<Button onClick={(e) => onSetPreview(e)}>Move Camera To Preset</Button>
</div>
)}
</div>
);
}

View File

@ -20,15 +20,50 @@ export default function TimelineSummary({ event, onFrameSelected }) {
const [timeIndex, setTimeIndex] = useState(-1); const [timeIndex, setTimeIndex] = useState(-1);
const recordingParams = {
before: event.end_time || Date.now(),
after: event.start_time,
};
const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false });
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
const getSeekSeconds = (seekUnix) => {
if (!recordings) {
return 0;
}
let seekSeconds = 0;
recordings.every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > seekUnix) {
return false;
}
if (segment.end_time < seekUnix) {
seekSeconds += segment.end_time - segment.start_time;
return true;
}
seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix);
return true;
});
return seekSeconds;
};
const onSelectMoment = async (index) => { const onSelectMoment = async (index) => {
setTimeIndex(index); setTimeIndex(index);
onFrameSelected(eventTimeline[index]); onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp));
}; };
if (!eventTimeline || !config) { if (!eventTimeline || !config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
if (eventTimeline.length == 0) {
return <div />;
}
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="h-14 flex justify-center"> <div className="h-14 flex justify-center">

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowDownDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M19.5 5.25l-7.5 7.5-7.5-7.5m15 6l-7.5 7.5-7.5-7.5" />
</svg>
);
}
export default memo(ArrowDownDouble);

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowLeftDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
</svg>
);
}
export default memo(ArrowLeftDouble);

View File

@ -3,8 +3,15 @@ import { memo } from 'preact/compat';
export function ArrowRightDouble({ className = '' }) { export function ArrowRightDouble({ className = '' }) {
return ( return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" /> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
</svg> </svg>
); );
} }

View File

@ -0,0 +1,19 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowUpDouble({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M4.5 12.75l7.5-7.5 7.5 7.5m-15 6l7.5-7.5 7.5 7.5" />
</svg>
);
}
export default memo(ArrowUpDouble);

View File

@ -6,6 +6,8 @@ import Heading from '../components/Heading';
import WebRtcPlayer from '../components/WebRtcPlayer'; import WebRtcPlayer from '../components/WebRtcPlayer';
import MsePlayer from '../components/MsePlayer'; import MsePlayer from '../components/MsePlayer';
import useSWR from 'swr'; import useSWR from 'swr';
import { useMemo } from 'preact/hooks';
import CameraControlPanel from '../components/CameraControlPanel';
export default function Birdseye() { export default function Birdseye() {
const { data: config } = useSWR('config'); const { data: config } = useSWR('config');
@ -16,6 +18,16 @@ export default function Birdseye() {
); );
const sourceValues = ['mse', 'webrtc', 'jsmpeg']; const sourceValues = ['mse', 'webrtc', 'jsmpeg'];
const ptzCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.entries(config.cameras)
.filter(([_, conf]) => conf.onvif?.host)
.map(([_, camera]) => camera.name);
}, [config]);
if (!config || !sourceIsLoaded) { if (!config || !sourceIsLoaded) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -25,7 +37,7 @@ export default function Birdseye() {
if ('MediaSource' in window) { if ('MediaSource' in window) {
player = ( player = (
<Fragment> <Fragment>
<div className="max-w-5xl"> <div className="max-w-5xl xl:w-1/2">
<MsePlayer camera="birdseye" /> <MsePlayer camera="birdseye" />
</div> </div>
</Fragment> </Fragment>
@ -42,7 +54,7 @@ export default function Birdseye() {
} else if (viewSource == 'webrtc' && config.birdseye.restream) { } else if (viewSource == 'webrtc' && config.birdseye.restream) {
player = ( player = (
<Fragment> <Fragment>
<div className="max-w-5xl"> <div className="max-w-5xl xl:w-1/2">
<WebRtcPlayer camera="birdseye" /> <WebRtcPlayer camera="birdseye" />
</div> </div>
</Fragment> </Fragment>
@ -50,7 +62,7 @@ export default function Birdseye() {
} else { } else {
player = ( player = (
<Fragment> <Fragment>
<div className="max-w-7xl"> <div className="max-w-7xl xl:w-1/2">
<JSMpegPlayer camera="birdseye" /> <JSMpegPlayer camera="birdseye" />
</div> </div>
</Fragment> </Fragment>
@ -79,7 +91,21 @@ export default function Birdseye() {
)} )}
</div> </div>
{player} <div className="xl:flex justify-between">
{player}
{ptzCameras && (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min xl:h-min xl:w-1/2">
<Heading size="sm">Control Panel</Heading>
{ptzCameras.map((camera) => (
<div className="p-4" key={camera}>
<Heading size="lg">{camera.replaceAll('_', ' ')}</Heading>
<CameraControlPanel camera={camera} />
</div>
))}
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -15,6 +15,7 @@ import { useApiHost } from '../api';
import useSWR from 'swr'; import useSWR from 'swr';
import WebRtcPlayer from '../components/WebRtcPlayer'; import WebRtcPlayer from '../components/WebRtcPlayer';
import MsePlayer from '../components/MsePlayer'; import MsePlayer from '../components/MsePlayer';
import CameraControlPanel from '../components/CameraControlPanel';
const emptyObject = Object.freeze({}); const emptyObject = Object.freeze({});
@ -188,6 +189,13 @@ export default function Camera({ camera }) {
{player} {player}
{cameraConfig?.onvif?.host && (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min">
<Heading size="sm">Control Panel</Heading>
<CameraControlPanel camera={camera} />
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
<Heading size="sm">Tracked objects</Heading> <Heading size="sm">Tracked objects</Heading>
<div className="flex flex-wrap justify-start"> <div className="flex flex-wrap justify-start">

View File

@ -3,6 +3,7 @@ import { route } from 'preact-router';
import ActivityIndicator from '../components/ActivityIndicator'; import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading'; import Heading from '../components/Heading';
import { Tabs, TextTab } from '../components/Tabs'; import { Tabs, TextTab } from '../components/Tabs';
import Link from '../components/Link';
import { useApiHost } from '../api'; import { useApiHost } from '../api';
import useSWR from 'swr'; import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite'; import useSWRInfinite from 'swr/infinite';
@ -57,7 +58,12 @@ export default function Events({ path, ...props }) {
showDownloadMenu: false, showDownloadMenu: false,
showDatePicker: false, showDatePicker: false,
showCalendar: false, showCalendar: false,
showPlusConfig: false, showPlusSubmit: false,
});
const [plusSubmitEvent, setPlusSubmitEvent] = useState({
id: null,
label: null,
validBox: null,
}); });
const [uploading, setUploading] = useState([]); const [uploading, setUploading] = useState([]);
const [viewEvent, setViewEvent] = useState(); const [viewEvent, setViewEvent] = useState();
@ -65,6 +71,8 @@ export default function Events({ path, ...props }) {
const [eventDetailType, setEventDetailType] = useState('clip'); const [eventDetailType, setEventDetailType] = useState('clip');
const [downloadEvent, setDownloadEvent] = useState({ const [downloadEvent, setDownloadEvent] = useState({
id: null, id: null,
label: null,
box: null,
has_clip: false, has_clip: false,
has_snapshot: false, has_snapshot: false,
plus_id: undefined, plus_id: undefined,
@ -182,14 +190,10 @@ export default function Events({ path, ...props }) {
onFilter(name, items); onFilter(name, items);
}; };
const onEventFrameSelected = (event, frame) => { const onEventFrameSelected = (event, frame, seekSeconds) => {
const eventDuration = event.end_time - event.start_time;
if (this.player) { if (this.player) {
this.player.pause(); this.player.pause();
const videoOffset = this.player.duration() - eventDuration; this.player.currentTime(seekSeconds);
const startTime = videoOffset + (frame.timestamp - event.start_time);
this.player.currentTime(startTime);
setEventOverlay(frame); setEventOverlay(frame);
} }
}; };
@ -202,6 +206,8 @@ export default function Events({ path, ...props }) {
e.stopPropagation(); e.stopPropagation();
setDownloadEvent((_prev) => ({ setDownloadEvent((_prev) => ({
id: event.id, id: event.id,
box: event.box,
label: event.label,
has_clip: event.has_clip, has_clip: event.has_clip,
has_snapshot: event.has_snapshot, has_snapshot: event.has_snapshot,
plus_id: event.plus_id, plus_id: event.plus_id,
@ -211,6 +217,16 @@ export default function Events({ path, ...props }) {
setState({ ...state, showDownloadMenu: true }); setState({ ...state, showDownloadMenu: true });
}; };
const showSubmitToPlus = (event_id, label, box, e) => {
if (e) {
e.stopPropagation();
}
// if any of the box coordinates are > 1, then the box data is from an older version
// and not valid to submit to plus with the snapshot image
setPlusSubmitEvent({ id: event_id, label, validBox: !box.some((d) => d > 1) });
setState({ ...state, showDownloadMenu: false, showPlusSubmit: true });
};
const handleSelectDateRange = useCallback( const handleSelectDateRange = useCallback(
(dates) => { (dates) => {
setSearchParams({ ...searchParams, before: dates.before, after: dates.after }); setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
@ -255,23 +271,16 @@ export default function Events({ path, ...props }) {
[size, setSize, isValidating, isDone] [size, setSize, isValidating, isDone]
); );
const onSendToPlus = async (id, e) => { const onSendToPlus = async (id, false_positive, validBox) => {
if (e) {
e.stopPropagation();
}
if (uploading.includes(id)) { if (uploading.includes(id)) {
return; return;
} }
if (!config.plus.enabled) {
setState({ ...state, showDownloadMenu: false, showPlusConfig: true });
return;
}
setUploading((prev) => [...prev, id]); setUploading((prev) => [...prev, id]);
const response = await axios.post(`events/${id}/plus`); const response = false_positive
? await axios.put(`events/${id}/false_positive`)
: await axios.post(`events/${id}/plus`, validBox ? { include_annotation: 1 } : {});
if (response.status === 200) { if (response.status === 200) {
mutate( mutate(
@ -293,6 +302,8 @@ export default function Events({ path, ...props }) {
if (state.showDownloadMenu && downloadEvent.id === id) { if (state.showDownloadMenu && downloadEvent.id === id) {
setState({ ...state, showDownloadMenu: false }); setState({ ...state, showDownloadMenu: false });
} }
setState({ ...state, showPlusSubmit: false });
}; };
const handleEventDetailTabChange = (index) => { const handleEventDetailTabChange = (index) => {
@ -379,12 +390,12 @@ export default function Events({ path, ...props }) {
download download
/> />
)} )}
{(downloadEvent.end_time && downloadEvent.has_snapshot && !downloadEvent.plus_id) && ( {downloadEvent.end_time && downloadEvent.has_snapshot && !downloadEvent.plus_id && (
<MenuItem <MenuItem
icon={UploadPlus} icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'} label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus" value="plus"
onSelect={() => onSendToPlus(downloadEvent.id)} onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/> />
)} )}
{downloadEvent.plus_id && ( {downloadEvent.plus_id && (
@ -439,25 +450,96 @@ export default function Events({ path, ...props }) {
/> />
</Menu> </Menu>
)} )}
{state.showPlusConfig && ( {state.showPlusSubmit && (
<Dialog> <Dialog>
<div className="p-4"> {config.plus.enabled ? (
<Heading size="lg">Setup a Frigate+ Account</Heading> <>
<p className="mb-2">In order to submit images to Frigate+, you first need to setup an account.</p> <div className="p-4">
<a <Heading size="lg">Submit to Frigate+</Heading>
className="text-blue-500 hover:underline"
href="https://plus.frigate.video" <img
target="_blank" className="flex-grow-0"
rel="noopener noreferrer" src={`${apiHost}/api/events/${plusSubmitEvent.id}/snapshot.jpg`}
> alt={`${plusSubmitEvent.label}`}
https://plus.frigate.video />
</a>
</div> {plusSubmitEvent.validBox ? (
<div className="p-2 flex justify-start flex-row-reverse space-x-2"> <p className="mb-2">
<Button className="ml-2" onClick={() => setState({ ...state, showPlusConfig: false })} type="text"> Objects in locations you want to avoid are not false positives. Submitting them as false positives
Close will confuse the model.
</Button> </p>
</div> ) : (
<p className="mb-2">
Events prior to version 0.13 can only be submitted to Frigate+ without annotations.
</p>
)}
</div>
{plusSubmitEvent.validBox ? (
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
{uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
</Button>
<Button
className="ml-2"
color="red"
onClick={() => onSendToPlus(plusSubmitEvent.id, true, plusSubmitEvent.validBox)}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
This is not a {plusSubmitEvent.label}
</Button>
<Button
className="ml-2"
color="green"
onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
This is a {plusSubmitEvent.label}
</Button>
</div>
) : (
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button
className="ml-2"
onClick={() => setState({ ...state, showPlusSubmit: false })}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
{uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
</Button>
<Button
className="ml-2"
onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
Submit to Frigate+
</Button>
</div>
)}
</>
) : (
<>
<div className="p-4">
<Heading size="lg">Setup a Frigate+ Account</Heading>
<p className="mb-2">In order to submit images to Frigate+, you first need to setup an account.</p>
<a
className="text-blue-500 hover:underline"
href="https://plus.frigate.video"
target="_blank"
rel="noopener noreferrer"
>
https://plus.frigate.video
</a>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
Close
</Button>
</div>
</>
)}
</Dialog> </Dialog>
)} )}
{deleteFavoriteState.showDeleteFavorite && ( {deleteFavoriteState.showDeleteFavorite && (
@ -543,12 +625,20 @@ export default function Events({ path, ...props }) {
{event.end_time && event.has_snapshot && ( {event.end_time && event.has_snapshot && (
<Fragment> <Fragment>
{event.plus_id ? ( {event.plus_id ? (
<div className="uppercase text-xs">Sent to Frigate+</div> <div className="uppercase text-xs underline">
<Link
href={`https://plus.frigate.video/dashboard/edit-image/?id=${event.plus_id}`}
target="_blank"
rel="nofollow"
>
Edit in Frigate+
</Link>
</div>
) : ( ) : (
<Button <Button
color="gray" color="gray"
disabled={uploading.includes(event.id)} disabled={uploading.includes(event.id)}
onClick={(e) => onSendToPlus(event.id, e)} onClick={(e) => showSubmitToPlus(event.id, event.label, event.box, e)}
> >
{uploading.includes(event.id) ? 'Uploading...' : 'Send to Frigate+'} {uploading.includes(event.id) ? 'Uploading...' : 'Send to Frigate+'}
</Button> </Button>
@ -590,7 +680,7 @@ export default function Events({ path, ...props }) {
<div> <div>
<TimelineSummary <TimelineSummary
event={event} event={event}
onFrameSelected={(frame) => onEventFrameSelected(event, frame)} onFrameSelected={(frame, seekSeconds) => onEventFrameSelected(event, frame, seekSeconds)}
/> />
<div> <div>
<VideoPlayer <VideoPlayer
@ -621,8 +711,12 @@ export default function Events({ path, ...props }) {
style={{ style={{
left: `${Math.round(eventOverlay.data.box[0] * 100)}%`, left: `${Math.round(eventOverlay.data.box[0] * 100)}%`,
top: `${Math.round(eventOverlay.data.box[1] * 100)}%`, top: `${Math.round(eventOverlay.data.box[1] * 100)}%`,
right: `${Math.round((1 - eventOverlay.data.box[2]) * 100)}%`, right: `${Math.round(
bottom: `${Math.round((1 - eventOverlay.data.box[3]) * 100)}%`, (1 - eventOverlay.data.box[2] - eventOverlay.data.box[0]) * 100
)}%`,
bottom: `${Math.round(
(1 - eventOverlay.data.box[3] - eventOverlay.data.box[1]) * 100
)}%`,
}} }}
> >
{eventOverlay.class_type == 'entered_zone' ? ( {eventOverlay.class_type == 'entered_zone' ? (