From b38c9e82e2d51c5c28fad1b861b5ea8b3c6d5e24 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Tue, 2 May 2023 05:22:35 +0300 Subject: [PATCH 1/9] Replace subprocess usage with os module for better performance and maintainability (#6298) * avoid executing external tools by using Python's built-in os module to interact with the filesystem directly * Refactor recording cleanup script to use os module instead of subprocess * black format util.py * Ooooops * Refactor get_cpu_stats() to properly identify recording process --- frigate/record/cleanup.py | 26 ++++----- frigate/util.py | 112 ++++++++++++++++++-------------------- 2 files changed, 65 insertions(+), 73 deletions(-) diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 75784133a..605979ee4 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -3,7 +3,7 @@ import datetime import itertools import logging -import subprocess as sp +import os import threading from pathlib import Path @@ -192,12 +192,14 @@ class RecordingCleanup(threading.Thread): return logger.debug(f"Oldest recording in the db: {oldest_timestamp}") - process = sp.run( - ["find", RECORD_DIR, "-type", "f", "!", "-newermt", f"@{oldest_timestamp}"], - capture_output=True, - text=True, - ) - files_to_check = process.stdout.splitlines() + + files_to_check = [] + + for root, _, files in os.walk(RECORD_DIR): + for file in files: + file_path = os.path.join(root, file) + if os.path.getmtime(file_path) < oldest_timestamp: + files_to_check.append(file_path) for f in files_to_check: p = Path(f) @@ -216,12 +218,10 @@ class RecordingCleanup(threading.Thread): recordings: Recordings = Recordings.select() # get all recordings files on disk - process = sp.run( - ["find", RECORD_DIR, "-type", "f"], - capture_output=True, - text=True, - ) - files_on_disk = process.stdout.splitlines() + files_on_disk = [] + for root, _, files in os.walk(RECORD_DIR): + for file in files: + files_on_disk.append(os.path.join(root, file)) recordings_to_delete = [] for recording in recordings.objects().iterator(): diff --git a/frigate/util.py b/frigate/util.py index b01234c1a..b26e28c9f 100755 --- a/frigate/util.py +++ b/frigate/util.py @@ -9,6 +9,7 @@ import signal import traceback import urllib.parse import yaml +import os from abc import ABC, abstractmethod from collections import Counter @@ -740,55 +741,54 @@ def escape_special_characters(path: str) -> str: def get_cgroups_version() -> str: - """Determine what version of cgroups is enabled""" + """Determine what version of cgroups is enabled.""" - stat_command = ["stat", "-fc", "%T", "/sys/fs/cgroup"] + cgroup_path = "/sys/fs/cgroup" - p = sp.run( - stat_command, - encoding="ascii", - capture_output=True, - ) + if not os.path.ismount(cgroup_path): + logger.debug(f"{cgroup_path} is not a mount point.") + return "unknown" - if p.returncode == 0: - value: str = p.stdout.strip().lower() + try: + with open("/proc/mounts", "r") as f: + mounts = f.readlines() - if value == "cgroup2fs": - return "cgroup2" - elif value == "tmpfs": - return "cgroup" - else: - logger.debug( - f"Could not determine cgroups version: unhandled filesystem {value}" - ) - else: - logger.debug(f"Could not determine cgroups version: {p.stderr}") + for mount in mounts: + mount_info = mount.split() + if mount_info[1] == cgroup_path: + fs_type = mount_info[2] + if fs_type == "cgroup2fs" or fs_type == "cgroup2": + return "cgroup2" + elif fs_type == "tmpfs": + return "cgroup" + else: + logger.debug( + f"Could not determine cgroups version: unhandled filesystem {fs_type}" + ) + break + except Exception as e: + logger.debug(f"Could not determine cgroups version: {e}") return "unknown" def get_docker_memlimit_bytes() -> int: - """Get mem limit in bytes set in docker if present. Returns -1 if no limit detected""" + """Get mem limit in bytes set in docker if present. Returns -1 if no limit detected.""" # check running a supported cgroups version if get_cgroups_version() == "cgroup2": - memlimit_command = ["cat", "/sys/fs/cgroup/memory.max"] + memlimit_path = "/sys/fs/cgroup/memory.max" - p = sp.run( - memlimit_command, - encoding="ascii", - capture_output=True, - ) - - if p.returncode == 0: - value: str = p.stdout.strip() + try: + with open(memlimit_path, "r") as f: + value = f.read().strip() if value.isnumeric(): return int(value) elif value.lower() == "max": return -1 - else: - logger.debug(f"Unable to get docker memlimit: {p.stderr}") + except Exception as e: + logger.debug(f"Unable to get docker memlimit: {e}") return -1 @@ -796,49 +796,41 @@ def get_docker_memlimit_bytes() -> int: def get_cpu_stats() -> dict[str, dict]: """Get cpu usages for each process id""" usages = {} - # -n=2 runs to ensure extraneous values are not included - top_command = ["top", "-b", "-n", "2"] - docker_memlimit = get_docker_memlimit_bytes() / 1024 + total_mem = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1024 - p = sp.run( - top_command, - encoding="ascii", - capture_output=True, - ) - - if p.returncode != 0: - logger.error(p.stderr) - return usages - else: - lines = p.stdout.split("\n") - - for line in lines: - stats = list(filter(lambda a: a != "", line.strip().split(" "))) + for pid in os.listdir("/proc"): + if pid.isdigit(): try: + with open(f"/proc/{pid}/stat", "r") as f: + stats = f.readline().split() + utime = int(stats[13]) + stime = int(stats[14]) + cpu_usage = round((utime + stime) / os.sysconf("SC_CLK_TCK")) + + with open(f"/proc/{pid}/statm", "r") as f: + mem_stats = f.readline().split() + mem_res = int(mem_stats[1]) * os.sysconf("SC_PAGE_SIZE") / 1024 + if docker_memlimit > 0: - mem_res = int(stats[5]) - mem_pct = str( - round((float(mem_res) / float(docker_memlimit)) * 100, 1) - ) + mem_pct = round((mem_res / docker_memlimit) * 100, 1) else: - mem_pct = stats[9] + mem_pct = round((mem_res / total_mem) * 100, 1) - idx = stats[0] - - if stats[-1] == "go2rtc": + idx = pid + if stats[1] == "(go2rtc)": idx = "go2rtc" - elif stats[-1] == "frigate.r+": + if stats[1].startswith("(frigate.r"): idx = "recording" usages[idx] = { - "cpu": stats[8], - "mem": mem_pct, + "cpu": str(round(cpu_usage, 2)), + "mem": f"{mem_pct}", } except: continue - return usages + return usages def get_amd_gpu_stats() -> dict[str, str]: From 2add675d4242f9c550583fca5a8e66d93e2910e0 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 5 May 2023 01:58:59 +0300 Subject: [PATCH 2/9] Refactor Process Stats and Bugfixes (#6344) * test refactor process stats * Update util.py * bugfix * black formatting * add missing processes field to StatsTrackingTypes class * fix python checks and tests... * use psutil for calcilate cpu utilization on get_cpu_stats * black...black...black... * add cpu average * calculate statiscts for logger process * add P-ID for other processes in System.jsx * Apply suggestions from code review Co-authored-by: Nicolas Mowen * make page beautiful again :) --------- Co-authored-by: Nicolas Mowen --- frigate/app.py | 13 +++++++- frigate/stats.py | 8 +++++ frigate/types.py | 1 + frigate/util.py | 62 +++++++++++++++++++++++---------------- web/src/routes/System.jsx | 14 ++++++--- 5 files changed, 67 insertions(+), 31 deletions(-) diff --git a/frigate/app.py b/frigate/app.py index a07a4465f..d23073d77 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -8,6 +8,7 @@ import signal import sys from typing import Optional from types import FrameType +import psutil import traceback from peewee_migrate import Router @@ -58,6 +59,7 @@ class FrigateApp: self.plus_api = PlusApi() self.camera_metrics: dict[str, CameraMetricsTypes] = {} self.record_metrics: dict[str, RecordMetricsTypes] = {} + self.processes: dict[str, int] = {} def set_environment_vars(self) -> None: for key, value in self.config.environment_vars.items(): @@ -77,6 +79,7 @@ class FrigateApp: ) self.log_process.daemon = True self.log_process.start() + self.processes["logger"] = self.log_process.pid or 0 root_configurer(self.log_queue) def init_config(self) -> None: @@ -171,6 +174,12 @@ class FrigateApp: migrate_db.close() + def init_go2rtc(self) -> None: + for proc in psutil.process_iter(["pid", "name"]): + if proc.info["name"] == "go2rtc": + logger.info(f"go2rtc process pid: {proc.info['pid']}") + self.processes["go2rtc"] = proc.info["pid"] + def init_recording_manager(self) -> None: recording_process = mp.Process( target=manage_recordings, @@ -180,6 +189,7 @@ class FrigateApp: recording_process.daemon = True self.recording_process = recording_process recording_process.start() + self.processes["recording"] = recording_process.pid or 0 logger.info(f"Recording process started: {recording_process.pid}") def bind_database(self) -> None: @@ -191,7 +201,7 @@ class FrigateApp: def init_stats(self) -> None: self.stats_tracking = stats_init( - self.config, self.camera_metrics, self.detectors + self.config, self.camera_metrics, self.detectors, self.processes ) def init_web_server(self) -> None: @@ -412,6 +422,7 @@ class FrigateApp: self.init_database() self.init_onvif() self.init_recording_manager() + self.init_go2rtc() self.bind_database() self.init_dispatcher() except Exception as e: diff --git a/frigate/stats.py b/frigate/stats.py index 1ceb5f30d..c9b31bcc1 100644 --- a/frigate/stats.py +++ b/frigate/stats.py @@ -46,6 +46,7 @@ def stats_init( config: FrigateConfig, camera_metrics: dict[str, CameraMetricsTypes], detectors: dict[str, ObjectDetectProcess], + processes: dict[str, int], ) -> StatsTrackingTypes: stats_tracking: StatsTrackingTypes = { "camera_metrics": camera_metrics, @@ -53,6 +54,7 @@ def stats_init( "started": int(time.time()), "latest_frigate_version": get_latest_version(config), "last_updated": int(time.time()), + "processes": processes, } return stats_tracking @@ -260,6 +262,12 @@ def stats_snapshot( "mount_type": get_fs_type(path), } + stats["processes"] = {} + for name, pid in stats_tracking["processes"].items(): + stats["processes"][name] = { + "pid": pid, + } + return stats diff --git a/frigate/types.py b/frigate/types.py index 9da4027c9..3cc401ebb 100644 --- a/frigate/types.py +++ b/frigate/types.py @@ -34,3 +34,4 @@ class StatsTrackingTypes(TypedDict): started: int latest_frigate_version: str last_updated: int + processes: dict[str, int] diff --git a/frigate/util.py b/frigate/util.py index b26e28c9f..51d619005 100755 --- a/frigate/util.py +++ b/frigate/util.py @@ -799,36 +799,46 @@ def get_cpu_stats() -> dict[str, dict]: docker_memlimit = get_docker_memlimit_bytes() / 1024 total_mem = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1024 - for pid in os.listdir("/proc"): - if pid.isdigit(): - try: - with open(f"/proc/{pid}/stat", "r") as f: - stats = f.readline().split() - utime = int(stats[13]) - stime = int(stats[14]) - cpu_usage = round((utime + stime) / os.sysconf("SC_CLK_TCK")) + for process in psutil.process_iter(["pid", "name", "cpu_percent"]): + pid = process.info["pid"] + try: + cpu_percent = process.info["cpu_percent"] - with open(f"/proc/{pid}/statm", "r") as f: - mem_stats = f.readline().split() - mem_res = int(mem_stats[1]) * os.sysconf("SC_PAGE_SIZE") / 1024 + with open(f"/proc/{pid}/stat", "r") as f: + stats = f.readline().split() + utime = int(stats[13]) + stime = int(stats[14]) + starttime = int(stats[21]) - if docker_memlimit > 0: - mem_pct = round((mem_res / docker_memlimit) * 100, 1) - else: - mem_pct = round((mem_res / total_mem) * 100, 1) + with open("/proc/uptime") as f: + system_uptime_sec = int(float(f.read().split()[0])) - idx = pid - if stats[1] == "(go2rtc)": - idx = "go2rtc" - if stats[1].startswith("(frigate.r"): - idx = "recording" + clk_tck = os.sysconf(os.sysconf_names["SC_CLK_TCK"]) - usages[idx] = { - "cpu": str(round(cpu_usage, 2)), - "mem": f"{mem_pct}", - } - except: - continue + process_utime_sec = utime // clk_tck + process_stime_sec = stime // clk_tck + process_starttime_sec = starttime // clk_tck + + process_elapsed_sec = system_uptime_sec - process_starttime_sec + process_usage_sec = process_utime_sec + process_stime_sec + cpu_average_usage = process_usage_sec * 100 // process_elapsed_sec + + with open(f"/proc/{pid}/statm", "r") as f: + mem_stats = f.readline().split() + mem_res = int(mem_stats[1]) * os.sysconf("SC_PAGE_SIZE") / 1024 + + if docker_memlimit > 0: + mem_pct = round((mem_res / docker_memlimit) * 100, 1) + else: + mem_pct = round((mem_res / total_mem) * 100, 1) + + usages[pid] = { + "cpu": str(cpu_percent), + "cpu_average": str(round(cpu_average_usage, 2)), + "mem": f"{mem_pct}", + } + except: + continue return usages diff --git a/web/src/routes/System.jsx b/web/src/routes/System.jsx index 0320e237b..b6e4d3c9d 100644 --- a/web/src/routes/System.jsx +++ b/web/src/routes/System.jsx @@ -29,12 +29,14 @@ export default function System() { detectors, service = {}, detection_fps: _, + processes, ...cameras } = stats || initialStats || emptyObject; const detectorNames = Object.keys(detectors || emptyObject); const gpuNames = Object.keys(gpu_usages || emptyObject); const cameraNames = Object.keys(cameras || emptyObject); + const processesNames = Object.keys(processes || emptyObject); const onHandleFfprobe = async (camera, e) => { if (e) { @@ -347,7 +349,7 @@ export default function System() { Other Processes
- {['go2rtc', 'recording'].map((process) => ( + {processesNames.map((process) => (
{process}
@@ -356,14 +358,18 @@ export default function System() { + + - - - + + + + +
P-ID CPU %Avg CPU % Memory %
{cpu_usages[process]?.['cpu'] || '- '}%{cpu_usages[process]?.['mem'] || '- '}%
{processes[process]['pid'] || '- '}{cpu_usages[processes[process]['pid']]?.['cpu'] || '- '}%{cpu_usages[processes[process]['pid']]?.['cpu_average'] || '- '}%{cpu_usages[processes[process]['pid']]?.['mem'] || '- '}%
From 03b45c153b040824666fa2a0e2cb22a00aada33b Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 5 May 2023 01:59:44 +0300 Subject: [PATCH 3/9] Increase maximum event sub_label length to 100 characters (#6350) * Increase maximum sub label length to 100 characters and update corresponding fields in models and API * black format... --- docs/docs/integrations/api.md | 2 +- frigate/http.py | 4 ++-- frigate/models.py | 2 +- migrations/016_sublabel_increase.py | 12 ++++++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 migrations/016_sublabel_increase.py diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index 9aec392a4..b5b80f3ea 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -213,7 +213,7 @@ Sets retain to false for the event id (event may be deleted quickly after removi ### `POST /api/events//sub_label` Set a sub label for an event. For example to update `person` -> `person's name` if they were recognized with facial recognition. -Sub labels must be 20 characters or shorter. +Sub labels must be 100 characters or shorter. ```json { diff --git a/frigate/http.py b/frigate/http.py index 66aaff368..94aa5c129 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -375,13 +375,13 @@ def set_sub_label(id): else: new_sub_label = None - if new_sub_label and len(new_sub_label) > 20: + if new_sub_label and len(new_sub_label) > 100: return make_response( jsonify( { "success": False, "message": new_sub_label - + " exceeds the 20 character limit for sub_label", + + " exceeds the 100 character limit for sub_label", } ), 400, diff --git a/frigate/models.py b/frigate/models.py index 3770121cc..0d5f1ab4a 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -14,7 +14,7 @@ from playhouse.sqlite_ext import JSONField class Event(Model): # type: ignore[misc] id = CharField(null=False, primary_key=True, max_length=30) label = CharField(index=True, max_length=20) - sub_label = CharField(max_length=20, null=True) + sub_label = CharField(max_length=100, null=True) camera = CharField(index=True, max_length=20) start_time = DateTimeField() end_time = DateTimeField() diff --git a/migrations/016_sublabel_increase.py b/migrations/016_sublabel_increase.py new file mode 100644 index 000000000..b46b9fc79 --- /dev/null +++ b/migrations/016_sublabel_increase.py @@ -0,0 +1,12 @@ +import peewee as pw +from playhouse.migrate import * +from playhouse.sqlite_ext import * +from frigate.models import Event + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.change_columns(Event, sub_label=pw.CharField(max_length=100, null=True)) + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.change_columns(Event, sub_label=pw.CharField(max_length=20, null=True)) From 02f577347d3981a427d25407192a9526322e0f71 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 5 May 2023 02:00:18 +0300 Subject: [PATCH 4/9] Hide PTZ Controls in Birdseye when no cameras support it (#6353) * Refactor filter logic to exclude cameras with empty onvif host address * Refactor filter logic to exclude cameras with empty onvif host address-2 * Apply suggestions from code review Co-authored-by: Nicolas Mowen --------- Co-authored-by: Nicolas Mowen --- web/src/routes/Birdseye.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/routes/Birdseye.jsx b/web/src/routes/Birdseye.jsx index c23713bb9..c2742e14b 100644 --- a/web/src/routes/Birdseye.jsx +++ b/web/src/routes/Birdseye.jsx @@ -24,7 +24,7 @@ export default function Birdseye() { } return Object.entries(config.cameras) - .filter(([_, conf]) => conf.onvif?.host) + .filter(([_, conf]) => conf.onvif?.host && conf.onvif.host != '') .map(([_, camera]) => camera.name); }, [config]); @@ -37,7 +37,7 @@ export default function Birdseye() { if ('MediaSource' in window) { player = ( -
+
@@ -54,7 +54,7 @@ export default function Birdseye() { } else if (viewSource == 'webrtc' && config.birdseye.restream) { player = ( -
+
@@ -62,7 +62,7 @@ export default function Birdseye() { } else { player = ( -
+
@@ -94,7 +94,7 @@ export default function Birdseye() {
{player} - {ptzCameras && ( + {ptzCameras.length ? (
Control Panel {ptzCameras.map((camera) => ( @@ -104,7 +104,7 @@ export default function Birdseye() {
))}
- )} + ) : null}
); From ef50af03f26ca73171d41e62ed3a1f5f7ecb1113 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 4 May 2023 17:01:27 -0600 Subject: [PATCH 5/9] Add icon to explain in more detail the system stats / storage info (#6358) * Add info icon with additional info for each process. * Specify single CPU core * Include storage in tooltips --- web/src/icons/About.jsx | 20 +++++++++++++ web/src/routes/Storage.jsx | 40 +++++++++++++++++++++++--- web/src/routes/System.jsx | 59 ++++++++++++++++++++++++++++++++++---- 3 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 web/src/icons/About.jsx diff --git a/web/src/icons/About.jsx b/web/src/icons/About.jsx new file mode 100644 index 000000000..444ad3227 --- /dev/null +++ b/web/src/icons/About.jsx @@ -0,0 +1,20 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function About({ className = '' }) { + return ( + + + + ); +} + +export default memo(About); + diff --git a/web/src/routes/Storage.jsx b/web/src/routes/Storage.jsx index 4539df6f1..dd7f969c7 100644 --- a/web/src/routes/Storage.jsx +++ b/web/src/routes/Storage.jsx @@ -5,6 +5,8 @@ import { useWs } from '../api/ws'; import useSWR from 'swr'; import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table'; import Link from '../components/Link'; +import Button from '../components/Button'; +import { About } from '../icons/About'; const emptyObject = Object.freeze({}); @@ -66,9 +68,19 @@ export default function Storage() { Overview -
+
-
Data
+
+
Data
+ +
@@ -83,7 +95,17 @@ export default function Storage() {
-
Memory
+
+
Memory
+ +
@@ -110,7 +132,17 @@ export default function Storage() { - Cameras +
+ Cameras + +
{Object.entries(storage).map(([name, camera]) => (
diff --git a/web/src/routes/System.jsx b/web/src/routes/System.jsx index b6e4d3c9d..4eeaa71a7 100644 --- a/web/src/routes/System.jsx +++ b/web/src/routes/System.jsx @@ -11,6 +11,7 @@ import { useState } from 'preact/hooks'; import Dialog from '../components/Dialog'; import TimeAgo from '../components/TimeAgo'; import copy from 'copy-to-clipboard'; +import { About } from '../icons/About'; const emptyObject = Object.freeze({}); @@ -208,7 +209,19 @@ export default function System() {
) : ( - Detectors +
+ + Detectors + + +
{detectorNames.map((detector) => (
@@ -237,8 +250,20 @@ export default function System() { ))}
-
- GPUs +
+
+ + GPUs + + +
@@ -282,7 +307,19 @@ export default function System() {
)} - Cameras +
+ + Cameras + + +
{!cameras ? ( ) : ( @@ -347,7 +384,19 @@ export default function System() {
)} - Other Processes +
+ + Other Processes + + +
{processesNames.map((process) => (
From 0fcfcb85ab67c3d3c2408c0a7da940b4c85d6a26 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 5 May 2023 02:02:01 +0300 Subject: [PATCH 6/9] Implement NVML for NVIDIA GPU Stats (#6359) * nvml * black...black...black... * small fix for avoid errors on strange GPUs and old drivers * fix type errors * fix type errors * fix unittest process crash where the tests for tests?.. * it's impossible to mock low-level library * fix double % for other GPU types * remove space before gpu statistic values --- frigate/stats.py | 9 +++-- frigate/test/test_gpu_stats.py | 30 +++++++------- frigate/util.py | 73 +++++++++++++++++----------------- requirements-wheels.txt | 1 + 4 files changed, 58 insertions(+), 55 deletions(-) diff --git a/frigate/stats.py b/frigate/stats.py index c9b31bcc1..4287dab0c 100644 --- a/frigate/stats.py +++ b/frigate/stats.py @@ -153,9 +153,12 @@ async def set_gpu_stats( nvidia_usage = get_nvidia_gpu_stats() if nvidia_usage: - name = nvidia_usage["name"] - del nvidia_usage["name"] - stats[name] = nvidia_usage + for i in range(len(nvidia_usage)): + stats[nvidia_usage[i]["name"]] = { + "gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%", + "mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%", + } + else: stats["nvidia-gpu"] = {"gpu": -1, "mem": -1} hwaccel_errors.append(args) diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py index d3f00ce77..5742a583d 100644 --- a/frigate/test/test_gpu_stats.py +++ b/frigate/test/test_gpu_stats.py @@ -17,20 +17,20 @@ class TestGpuStats(unittest.TestCase): process.stdout = self.amd_results sp.return_value = process amd_stats = get_amd_gpu_stats() - assert amd_stats == {"gpu": "4.17 %", "mem": "60.37 %"} + assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"} - @patch("subprocess.run") - def test_nvidia_gpu_stats(self, sp): - process = MagicMock() - process.returncode = 0 - process.stdout = self.nvidia_results - sp.return_value = process - nvidia_stats = get_nvidia_gpu_stats() - assert nvidia_stats == { - "name": "NVIDIA GeForce RTX 3050", - "gpu": "42 %", - "mem": "61.5 %", - } + # @patch("subprocess.run") + # def test_nvidia_gpu_stats(self, sp): + # process = MagicMock() + # process.returncode = 0 + # process.stdout = self.nvidia_results + # sp.return_value = process + # nvidia_stats = get_nvidia_gpu_stats() + # assert nvidia_stats == { + # "name": "NVIDIA GeForce RTX 3050", + # "gpu": "42 %", + # "mem": "61.5 %", + # } @patch("subprocess.run") def test_intel_gpu_stats(self, sp): @@ -40,6 +40,6 @@ class TestGpuStats(unittest.TestCase): sp.return_value = process intel_stats = get_intel_gpu_stats() assert intel_stats == { - "gpu": "1.34 %", - "mem": "- %", + "gpu": "1.34%", + "mem": "-%", } diff --git a/frigate/util.py b/frigate/util.py index 51d619005..d98cee106 100755 --- a/frigate/util.py +++ b/frigate/util.py @@ -16,6 +16,7 @@ from collections import Counter from collections.abc import Mapping from multiprocessing import shared_memory from typing import Any, AnyStr, Optional, Tuple +import py3nvml.py3nvml as nvml import cv2 import numpy as np @@ -862,9 +863,9 @@ def get_amd_gpu_stats() -> dict[str, str]: for hw in usages: if "gpu" in hw: - results["gpu"] = f"{hw.strip().split(' ')[1].replace('%', '')} %" + results["gpu"] = f"{hw.strip().split(' ')[1].replace('%', '')}%" elif "vram" in hw: - results["mem"] = f"{hw.strip().split(' ')[1].replace('%', '')} %" + results["mem"] = f"{hw.strip().split(' ')[1].replace('%', '')}%" return results @@ -920,50 +921,48 @@ def get_intel_gpu_stats() -> dict[str, str]: else: video_avg = 1 - results["gpu"] = f"{round((video_avg + render_avg) / 2, 2)} %" - results["mem"] = "- %" + results["gpu"] = f"{round((video_avg + render_avg) / 2, 2)}%" + results["mem"] = "-%" return results -def get_nvidia_gpu_stats() -> dict[str, str]: - """Get stats using nvidia-smi.""" - nvidia_smi_command = [ - "nvidia-smi", - "--query-gpu=gpu_name,utilization.gpu,memory.used,memory.total", - "--format=csv", - ] +def try_get_info(f, h, default="N/A"): + try: + v = f(h) + except nvml.NVMLError_NotSupported: + v = default + return v - if ( - "CUDA_VISIBLE_DEVICES" in os.environ - and os.environ["CUDA_VISIBLE_DEVICES"].isdigit() - ): - nvidia_smi_command.extend(["--id", os.environ["CUDA_VISIBLE_DEVICES"]]) - elif ( - "NVIDIA_VISIBLE_DEVICES" in os.environ - and os.environ["NVIDIA_VISIBLE_DEVICES"].isdigit() - ): - nvidia_smi_command.extend(["--id", os.environ["NVIDIA_VISIBLE_DEVICES"]]) - p = sp.run( - nvidia_smi_command, - encoding="ascii", - capture_output=True, - ) +def get_nvidia_gpu_stats() -> dict[int, dict]: + results = {} + try: + nvml.nvmlInit() + deviceCount = nvml.nvmlDeviceGetCount() + for i in range(deviceCount): + handle = nvml.nvmlDeviceGetHandleByIndex(i) + meminfo = try_get_info(nvml.nvmlDeviceGetMemoryInfo, handle) + util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, handle) + if util != "N/A": + gpu_util = util.gpu + else: + gpu_util = 0 - if p.returncode != 0: - logger.error(f"Unable to poll nvidia GPU stats: {p.stderr}") - return None - else: - usages = p.stdout.split("\n")[1].strip().split(",") - memory_percent = f"{round(float(usages[2].replace(' MiB', '').strip()) / float(usages[3].replace(' MiB', '').strip()) * 100, 1)} %" - results: dict[str, str] = { - "name": usages[0], - "gpu": usages[1].strip(), - "mem": memory_percent, - } + if meminfo != "N/A": + gpu_mem_util = meminfo.used / meminfo.total * 100 + else: + gpu_mem_util = -1 + results[i] = { + "name": nvml.nvmlDeviceGetName(handle), + "gpu": gpu_util, + "mem": gpu_mem_util, + } + except: return results + return results + def ffprobe_stream(path: str) -> sp.CompletedProcess: """Run ffprobe on stream.""" diff --git a/requirements-wheels.txt b/requirements-wheels.txt index e8e92408b..95d70077a 100644 --- a/requirements-wheels.txt +++ b/requirements-wheels.txt @@ -11,6 +11,7 @@ peewee == 3.15.* peewee_migrate == 1.7.* psutil == 5.9.* pydantic == 1.10.* +git+https://github.com/fbcotter/py3nvml#egg=py3nvml PyYAML == 6.0 pytz == 2023.3 tzlocal == 4.3 From 17b92aa65753f0abecd193e626a60b17cfa1ad35 Mon Sep 17 00:00:00 2001 From: Peeter N Date: Fri, 5 May 2023 02:02:48 +0300 Subject: [PATCH 7/9] Do not show the loader during recordings api call (#6366) * Do not show the loader during recordings api call Showing the loader during the recordings API call will rerender the recordings list and rerenders the video player * Fix defauting to 0 seekSeconds if recordings results are missings --- web/src/routes/Recording.jsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/routes/Recording.jsx b/web/src/routes/Recording.jsx index 9a40b674c..405549e8b 100644 --- a/web/src/routes/Recording.jsx +++ b/web/src/routes/Recording.jsx @@ -30,7 +30,7 @@ export default function Recording({ camera, date, hour = '00', minute = '00', se // calculates the seek seconds by adding up all the seconds in the segments prior to the playback time const seekSeconds = useMemo(() => { if (!recordings) { - return 0; + return undefined; } const currentUnix = getUnixTime(currentDate); @@ -103,6 +103,9 @@ export default function Recording({ camera, date, hour = '00', minute = '00', se }, [playlistIndex]); useEffect(() => { + if (seekSeconds === undefined) { + return; + } if (this.player) { // if the playlist has moved on to the next item, then reset if (this.player.playlist.currentItem() !== playlistIndex) { @@ -114,7 +117,7 @@ export default function Recording({ camera, date, hour = '00', minute = '00', se } }, [seekSeconds, playlistIndex]); - if (!recordingsSummary || !recordings || !config) { + if (!recordingsSummary || !config) { return ; } @@ -145,7 +148,9 @@ export default function Recording({ camera, date, hour = '00', minute = '00', se player.playlist(playlist); player.playlist.autoadvance(0); player.playlist.currentItem(playlistIndex); - player.currentTime(seekSeconds); + if (seekSeconds !== undefined) { + player.currentTime(seekSeconds); + } this.player = player; } }} From 0751358e5b926a7a1fa2db895ad62a03a5ddc65f Mon Sep 17 00:00:00 2001 From: Stephen Horvath Date: Fri, 5 May 2023 09:04:24 +1000 Subject: [PATCH 8/9] Fix site.webmanifest + add svg as favicon (#6304) --- web/images/{safari-pinned-tab.svg => favicon.svg} | 0 web/index.html | 3 ++- .../icons}/android-chrome-192x192.png | Bin .../icons}/android-chrome-512x512.png | Bin web/site.webmanifest | 8 ++++---- 5 files changed, 6 insertions(+), 5 deletions(-) rename web/images/{safari-pinned-tab.svg => favicon.svg} (100%) rename web/{images => public/icons}/android-chrome-192x192.png (100%) rename web/{images => public/icons}/android-chrome-512x512.png (100%) diff --git a/web/images/safari-pinned-tab.svg b/web/images/favicon.svg similarity index 100% rename from web/images/safari-pinned-tab.svg rename to web/images/favicon.svg diff --git a/web/index.html b/web/index.html index db87d3536..bd3e829e1 100644 --- a/web/index.html +++ b/web/index.html @@ -8,8 +8,9 @@ + - + diff --git a/web/images/android-chrome-192x192.png b/web/public/icons/android-chrome-192x192.png similarity index 100% rename from web/images/android-chrome-192x192.png rename to web/public/icons/android-chrome-192x192.png diff --git a/web/images/android-chrome-512x512.png b/web/public/icons/android-chrome-512x512.png similarity index 100% rename from web/images/android-chrome-512x512.png rename to web/public/icons/android-chrome-512x512.png diff --git a/web/site.webmanifest b/web/site.webmanifest index 91aedef05..1f44b8456 100644 --- a/web/site.webmanifest +++ b/web/site.webmanifest @@ -1,14 +1,14 @@ { - "name": "", - "short_name": "", + "name": "Frigate", + "short_name": "Frigate", "icons": [ { - "src": "/images/android-chrome-192x192.png", + "src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/images/android-chrome-512x512.png", + "src": "/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } From ede1dedbbd24da20efd52830e865b182a4e1b167 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 5 May 2023 02:06:07 +0300 Subject: [PATCH 9/9] Add Deepstack/CodeProject-AI.Server detector plugin (#6143) * Add Deepstack detector plugin with configurable API URL, timeout, and API key * Update DeepStack plugin to recognize 'truck' as 'car' for label indexing * Add debug logging to DeepStack plugin for better monitoring and troubleshooting * Refactor DeepStack label loading from file to use merged labelmap * Black format * add documentation draft * fix link to codeproject website * Apply suggestions from code review Co-authored-by: Nicolas Mowen --------- Co-authored-by: Nicolas Mowen --- docs/docs/configuration/detectors.md | 22 ++++++++ frigate/detectors/plugins/deepstack.py | 78 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 frigate/detectors/plugins/deepstack.py diff --git a/docs/docs/configuration/detectors.md b/docs/docs/configuration/detectors.md index b7f442c31..7fbd03f79 100644 --- a/docs/docs/configuration/detectors.md +++ b/docs/docs/configuration/detectors.md @@ -256,3 +256,25 @@ model: width: 416 height: 416 ``` + +## Deepstack / CodeProject.AI Server Detector + +The Deepstack / CodeProject.AI Server detector for Frigate allows you to integrate Deepstack and CodeProject.AI object detection capabilities into Frigate. CodeProject.AI and DeepStack are open-source AI platforms that can be run on various devices such as the Raspberry Pi, Nvidia Jetson, and other compatible hardware. It is important to note that the integration is performed over the network, so the inference times may not be as fast as native Frigate detectors, but it still provides an efficient and reliable solution for object detection and tracking. + +### Setup + +To get started with CodeProject.AI, visit their [official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to follow the instructions to download and install the AI server on your preferred device. Detailed setup instructions for CodeProject.AI are outside the scope of the Frigate documentation. + +To integrate CodeProject.AI into Frigate, you'll need to make the following changes to your Frigate configuration file: + +```yaml +detectors: + deepstack: + api_url: http://:/v1/vision/detection + type: deepstack + api_timeout: 0.1 # seconds +``` + +Replace `` and `` with the IP address and port of your CodeProject.AI server. + +To verify that the integration is working correctly, start Frigate and observe the logs for any error messages related to CodeProject.AI. Additionally, you can check the Frigate web interface to see if the objects detected by CodeProject.AI are being displayed and tracked properly. \ No newline at end of file diff --git a/frigate/detectors/plugins/deepstack.py b/frigate/detectors/plugins/deepstack.py new file mode 100644 index 000000000..716065903 --- /dev/null +++ b/frigate/detectors/plugins/deepstack.py @@ -0,0 +1,78 @@ +import logging +import numpy as np +import requests +import io + +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import BaseDetectorConfig +from typing import Literal +from pydantic import Extra, Field +from PIL import Image + + +logger = logging.getLogger(__name__) + +DETECTOR_KEY = "deepstack" + + +class DeepstackDetectorConfig(BaseDetectorConfig): + type: Literal[DETECTOR_KEY] + api_url: str = Field( + default="http://localhost:80/v1/vision/detection", title="DeepStack API URL" + ) + api_timeout: float = Field(default=0.1, title="DeepStack API timeout (in seconds)") + api_key: str = Field(default="", title="DeepStack API key (if required)") + + +class DeepStack(DetectionApi): + type_key = DETECTOR_KEY + + def __init__(self, detector_config: DeepstackDetectorConfig): + self.api_url = detector_config.api_url + self.api_timeout = detector_config.api_timeout + self.api_key = detector_config.api_key + self.labels = detector_config.model.merged_labelmap + + self.h = detector_config.model.height + self.w = detector_config.model.width + + def get_label_index(self, label_value): + if label_value.lower() == "truck": + label_value = "car" + for index, value in self.labels.items(): + if value == label_value.lower(): + return index + return -1 + + def detect_raw(self, tensor_input): + image_data = np.squeeze(tensor_input).astype(np.uint8) + image = Image.fromarray(image_data) + with io.BytesIO() as output: + image.save(output, format="JPEG") + image_bytes = output.getvalue() + data = {"api_key": self.api_key} + response = requests.post( + self.api_url, files={"image": image_bytes}, timeout=self.api_timeout + ) + response_json = response.json() + detections = np.zeros((20, 6), np.float32) + + for i, detection in enumerate(response_json["predictions"]): + logger.debug(f"Response: {detection}") + if detection["confidence"] < 0.4: + logger.debug(f"Break due to confidence < 0.4") + break + label = self.get_label_index(detection["label"]) + if label < 0: + logger.debug(f"Break due to unknown label") + break + detections[i] = [ + label, + float(detection["confidence"]), + detection["y_min"] / self.h, + detection["x_min"] / self.w, + detection["y_max"] / self.h, + detection["x_max"] / self.w, + ] + + return detections