mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-05 02:35:22 +03:00
Merge branch 'dev' into ab-camera-order
This commit is contained in:
commit
19b313536b
@ -37,9 +37,12 @@
|
|||||||
"onAutoForward": "silent"
|
"onAutoForward": "silent"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"ms-python.vscode-pylance",
|
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"ms-python.black-formatter",
|
||||||
"visualstudioexptteam.vscodeintellicode",
|
"visualstudioexptteam.vscodeintellicode",
|
||||||
"mhutchie.git-graph",
|
"mhutchie.git-graph",
|
||||||
"ms-azuretools.vscode-docker",
|
"ms-azuretools.vscode-docker",
|
||||||
@ -55,7 +58,7 @@
|
|||||||
"remote.autoForwardPorts": false,
|
"remote.autoForwardPorts": false,
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"python.linting.enabled": true,
|
"python.linting.enabled": true,
|
||||||
"python.formatting.provider": "black",
|
"python.formatting.provider": "none",
|
||||||
"python.languageServer": "Pylance",
|
"python.languageServer": "Pylance",
|
||||||
"editor.formatOnPaste": false,
|
"editor.formatOnPaste": false,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
@ -65,14 +68,23 @@
|
|||||||
"python.testing.unittestArgs": ["-v", "-s", "./frigate/test"],
|
"python.testing.unittestArgs": ["-v", "-s", "./frigate/test"],
|
||||||
"files.trimTrailingWhitespace": true,
|
"files.trimTrailingWhitespace": true,
|
||||||
"eslint.workingDirectories": ["./web"],
|
"eslint.workingDirectories": ["./web"],
|
||||||
|
"[python]": {
|
||||||
|
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
"[json][jsonc]": {
|
"[json][jsonc]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[jsx][js][tsx][ts]": {
|
"[jsx][js][tsx][ts]": {
|
||||||
"editor.codeActionsOnSave": ["source.addMissingImports", "source.fixAll"],
|
"editor.codeActionsOnSave": [
|
||||||
|
"source.addMissingImports",
|
||||||
|
"source.fixAll"
|
||||||
|
],
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"cSpell.ignoreWords": ["rtmp"],
|
"cSpell.ignoreWords": ["rtmp"],
|
||||||
"cSpell.words": ["preact"]
|
"cSpell.words": ["preact"]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -173,8 +173,8 @@ 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 |
|
||||||
|
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
112
frigate/http.py
112
frigate/http.py
@ -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 = {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
219
frigate/ptz.py
Normal 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()),
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
if event_type == "start":
|
timeline_entry = {
|
||||||
Timeline.insert(
|
Timeline.timestamp: event_data["frame_time"],
|
||||||
timestamp=event_data["frame_time"],
|
Timeline.camera: camera,
|
||||||
camera=camera,
|
Timeline.source: "tracked_object",
|
||||||
source="tracked_object",
|
Timeline.source_id: event_data["id"],
|
||||||
source_id=event_data["id"],
|
Timeline.data: {
|
||||||
class_type="visible",
|
"box": to_relative_box(
|
||||||
data={
|
camera_config.detect.width,
|
||||||
"box": [
|
camera_config.detect.height,
|
||||||
event_data["box"][0] / camera_config.detect.width,
|
event_data["box"],
|
||||||
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"],
|
"label": event_data["label"],
|
||||||
"region": [
|
"region": to_relative_box(
|
||||||
event_data["region"][0] / camera_config.detect.width,
|
camera_config.detect.width,
|
||||||
event_data["region"][1] / camera_config.detect.height,
|
camera_config.detect.height,
|
||||||
event_data["region"][2] / camera_config.detect.width,
|
event_data["region"],
|
||||||
event_data["region"][3] / camera_config.detect.height,
|
),
|
||||||
],
|
|
||||||
},
|
},
|
||||||
).execute()
|
}
|
||||||
|
if event_type == "start":
|
||||||
|
timeline_entry[Timeline.class_type] = "visible"
|
||||||
|
Timeline.insert(timeline_entry).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()
|
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
|||||||
52
migrations/014_event_updates_for_fp.py
Normal file
52
migrations/014_event_updates_for_fp.py
Normal 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
|
||||||
@ -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.*
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
scikit-build == 0.17.1
|
scikit-build == 0.17.*
|
||||||
nvidia-pyindex
|
nvidia-pyindex
|
||||||
|
|||||||
741
web/package-lock.json
generated
741
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
248
web/src/components/CameraControlPanel.jsx
Normal file
248
web/src/components/CameraControlPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
19
web/src/icons/ArrowDownDouble.jsx
Normal file
19
web/src/icons/ArrowDownDouble.jsx
Normal 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);
|
||||||
19
web/src/icons/ArrowLeftDouble.jsx
Normal file
19
web/src/icons/ArrowLeftDouble.jsx
Normal 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);
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
19
web/src/icons/ArrowUpDouble.jsx
Normal file
19
web/src/icons/ArrowUpDouble.jsx
Normal 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);
|
||||||
@ -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>
|
||||||
|
|
||||||
|
<div className="xl:flex justify-between">
|
||||||
{player}
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,8 +450,77 @@ export default function Events({ path, ...props }) {
|
|||||||
/>
|
/>
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
{state.showPlusConfig && (
|
{state.showPlusSubmit && (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
|
{config.plus.enabled ? (
|
||||||
|
<>
|
||||||
|
<div className="p-4">
|
||||||
|
<Heading size="lg">Submit to Frigate+</Heading>
|
||||||
|
|
||||||
|
<img
|
||||||
|
className="flex-grow-0"
|
||||||
|
src={`${apiHost}/api/events/${plusSubmitEvent.id}/snapshot.jpg`}
|
||||||
|
alt={`${plusSubmitEvent.label}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{plusSubmitEvent.validBox ? (
|
||||||
|
<p className="mb-2">
|
||||||
|
Objects in locations you want to avoid are not false positives. Submitting them as false positives
|
||||||
|
will confuse the model.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<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">
|
<div className="p-4">
|
||||||
<Heading size="lg">Setup a Frigate+ Account</Heading>
|
<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>
|
<p className="mb-2">In order to submit images to Frigate+, you first need to setup an account.</p>
|
||||||
@ -454,10 +534,12 @@ export default function Events({ path, ...props }) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
<Button className="ml-2" onClick={() => setState({ ...state, showPlusConfig: false })} type="text">
|
<Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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' ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user