Compare commits

...

2 Commits

Author SHA1 Message Date
Andrew Roberts
7fb8d9b050
Camera-specific hwaccel settings for timelapse exports (correct base) (#21386)
* added hwaccel_args to camera.record.export config struct

* populate camera.record.export.hwaccel_args with a cascade up to camera then global if 'auto'

* use new hwaccel args in export

* added documentation for camera-specific hwaccel export

* fix c/p error

* missed an import

* fleshed out the docs and comments a bit

* ruff lint

* separated out the tips in the doc

* fix documentation

* fix and simplify reference config doc
2025-12-22 09:10:40 -07:00
Nicolas Mowen
b8bc98a423
Refactor temperature reporting for detectors and implement Hailo temp reading (#21395)
* Add Hailo temperature retrieval

* Refactor `get_hailo_temps()` to use ctxmanager

* Show Hailo temps in system UI

* Move hailo_platform import to get_hailo_temps

* Refactor temperatures calculations to use within detector block

* Adjust webUI to handle new location

---------

Co-authored-by: tigattack <10629864+tigattack@users.noreply.github.com>
2025-12-22 08:25:38 -07:00
9 changed files with 155 additions and 37 deletions

View File

@ -139,7 +139,11 @@ record:
:::tip :::tip
When using `hwaccel_args` globally hardware encoding is used for time lapse generation. The encoder determines its own behavior so the resulting file size may be undesirably large. When using `hwaccel_args`, hardware encoding is used for timelapse generation. This setting can be overridden for a specific camera (e.g., when camera resolution exceeds hardware encoder limits); set `cameras.<camera>.record.export.hwaccel_args` with the appropriate settings. Using an unrecognized value or empty string will fall back to software encoding (libx264).
:::tip
The encoder determines its own behavior so the resulting file size may be undesirably large.
To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario. To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario.
::: :::

View File

@ -534,6 +534,8 @@ record:
# The -r (framerate) dictates how smooth the output video is. # The -r (framerate) dictates how smooth the output video is.
# So the args would be -vf setpts=0.02*PTS -r 30 in that case. # So the args would be -vf setpts=0.02*PTS -r 30 in that case.
timelapse_args: "-vf setpts=0.04*PTS -r 30" timelapse_args: "-vf setpts=0.04*PTS -r 30"
# Optional: Global hardware acceleration settings for timelapse exports. (default: inherit)
hwaccel_args: auto
# Optional: Recording Preview Settings # Optional: Recording Preview Settings
preview: preview:
# Optional: Quality of recording preview (default: shown below). # Optional: Quality of recording preview (default: shown below).
@ -835,6 +837,11 @@ cameras:
# Optional: camera specific output args (default: inherit) # Optional: camera specific output args (default: inherit)
# output_args: # output_args:
# Optional: camera specific hwaccel args for timelapse export (default: inherit)
# record:
# export:
# hwaccel_args:
# Optional: timeout for highest scoring image before allowing it # Optional: timeout for highest scoring image before allowing it
# to be replaced by a newer image. (default: shown below) # to be replaced by a newer image. (default: shown below)
best_image_timeout: 60 best_image_timeout: 60

View File

@ -1,5 +1,5 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional, Union
from pydantic import Field from pydantic import Field
@ -70,6 +70,9 @@ class RecordExportConfig(FrigateBaseModel):
timelapse_args: str = Field( timelapse_args: str = Field(
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args" default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
) )
hwaccel_args: Union[str, list[str]] = Field(
default="auto", title="Export-specific FFmpeg hardware acceleration arguments."
)
class RecordConfig(FrigateBaseModel): class RecordConfig(FrigateBaseModel):

View File

@ -523,6 +523,14 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.ffmpeg.hwaccel_args == "auto": if camera_config.ffmpeg.hwaccel_args == "auto":
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
# Resolve export hwaccel_args: camera export -> camera ffmpeg -> global ffmpeg
# This allows per-camera override for exports (e.g., when camera resolution
# exceeds hardware encoder limits)
if camera_config.record.export.hwaccel_args == "auto":
camera_config.record.export.hwaccel_args = (
camera_config.ffmpeg.hwaccel_args
)
for input in camera_config.ffmpeg.inputs: for input in camera_config.ffmpeg.inputs:
need_detect_dimensions = "detect" in input.roles and ( need_detect_dimensions = "detect" in input.roles and (
camera_config.detect.height is None camera_config.detect.height is None

View File

@ -228,7 +228,7 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd = ( ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode( parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path, self.config.ffmpeg.ffmpeg_path,
self.config.ffmpeg.hwaccel_args, self.config.cameras[self.camera].record.export.hwaccel_args,
f"-an {ffmpeg_input}", f"-an {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart",
EncodeTypeEnum.timelapse, EncodeTypeEnum.timelapse,
@ -319,7 +319,7 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd = ( ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode( parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path, self.config.ffmpeg.ffmpeg_path,
self.config.ffmpeg.hwaccel_args, self.config.cameras[self.camera].record.export.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
EncodeTypeEnum.timelapse, EncodeTypeEnum.timelapse,

View File

@ -22,6 +22,7 @@ from frigate.util.services import (
get_bandwidth_stats, get_bandwidth_stats,
get_cpu_stats, get_cpu_stats,
get_fs_type, get_fs_type,
get_hailo_temps,
get_intel_gpu_stats, get_intel_gpu_stats,
get_jetson_stats, get_jetson_stats,
get_nvidia_gpu_stats, get_nvidia_gpu_stats,
@ -91,9 +92,76 @@ def get_temperatures() -> dict[str, float]:
if temp is not None: if temp is not None:
temps[apex] = temp temps[apex] = temp
# Get temperatures for Hailo devices
temps.update(get_hailo_temps())
return temps return temps
def get_detector_temperature(
detector_type: str,
detector_index_by_type: dict[str, int],
) -> Optional[float]:
"""Get temperature for a specific detector based on its type."""
if detector_type == "edgetpu":
# Get temperatures for all attached Corals
base = "/sys/class/apex/"
if os.path.isdir(base):
apex_devices = sorted(os.listdir(base))
index = detector_index_by_type.get("edgetpu", 0)
if index < len(apex_devices):
apex_name = apex_devices[index]
temp = read_temperature(os.path.join(base, apex_name, "temp"))
if temp is not None:
return temp
elif detector_type == "hailo8l":
# Get temperatures for Hailo devices
hailo_temps = get_hailo_temps()
if hailo_temps:
hailo_device_names = sorted(hailo_temps.keys())
index = detector_index_by_type.get("hailo8l", 0)
if index < len(hailo_device_names):
device_name = hailo_device_names[index]
return hailo_temps[device_name]
return None
def get_detector_stats(
stats_tracking: StatsTrackingTypes,
) -> dict[str, dict[str, Any]]:
"""Get stats for all detectors, including temperatures based on detector type."""
detector_stats: dict[str, dict[str, Any]] = {}
detector_type_indices: dict[str, int] = {}
for name, detector in stats_tracking["detectors"].items():
pid = detector.detect_process.pid if detector.detect_process else None
detector_type = detector.detector_config.type
# Keep track of the index for each detector type to match temperatures correctly
current_index = detector_type_indices.get(detector_type, 0)
detector_type_indices[detector_type] = current_index + 1
detector_stat = {
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"detection_start": detector.detection_start.value, # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"pid": pid,
}
temp = get_detector_temperature(detector_type, {detector_type: current_index})
if temp is not None:
detector_stat["temperature"] = round(temp, 1)
detector_stats[name] = detector_stat
return detector_stats
def get_processing_stats( def get_processing_stats(
config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str] config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str]
) -> None: ) -> None:
@ -319,18 +387,7 @@ def stats_snapshot(
**connection_quality, **connection_quality,
} }
stats["detectors"] = {} stats["detectors"] = get_detector_stats(stats_tracking)
for name, detector in stats_tracking["detectors"].items():
pid = detector.detect_process.pid if detector.detect_process else None
stats["detectors"][name] = {
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"detection_start": detector.detection_start.value, # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"pid": pid,
}
stats["camera_fps"] = round(total_camera_fps, 2) stats["camera_fps"] = round(total_camera_fps, 2)
stats["process_fps"] = round(total_process_fps, 2) stats["process_fps"] = round(total_process_fps, 2)
stats["skipped_fps"] = round(total_skipped_fps, 2) stats["skipped_fps"] = round(total_skipped_fps, 2)
@ -416,7 +473,6 @@ def stats_snapshot(
"version": VERSION, "version": VERSION,
"latest_version": stats_tracking["latest_frigate_version"], "latest_version": stats_tracking["latest_frigate_version"],
"storage": {}, "storage": {},
"temperatures": get_temperatures(),
"last_updated": int(time.time()), "last_updated": int(time.time()),
} }

View File

@ -549,6 +549,53 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
return results return results
def get_hailo_temps() -> dict[str, float]:
"""Get temperatures for Hailo devices."""
try:
from hailo_platform import Device
except ModuleNotFoundError:
return {}
temps = {}
try:
device_ids = Device.scan()
for i, device_id in enumerate(device_ids):
try:
with Device(device_id) as device:
temp_info = device.control.get_chip_temperature()
# Get board name and normalise it
identity = device.control.identify()
board_name = None
for line in str(identity).split("\n"):
if line.startswith("Board Name:"):
board_name = (
line.split(":", 1)[1].strip().lower().replace("-", "")
)
break
if not board_name:
board_name = f"hailo{i}"
# Use indexed name if multiple devices, otherwise just the board name
device_name = (
f"{board_name}-{i}" if len(device_ids) > 1 else board_name
)
# ts1_temperature is also available, but appeared to be the same as ts0 in testing.
temps[device_name] = round(temp_info.ts0_temperature, 1)
except Exception as e:
logger.debug(
f"Failed to get temperature for Hailo device {device_id}: {e}"
)
continue
except Exception as e:
logger.debug(f"Failed to scan for Hailo devices: {e}")
return temps
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream.""" """Run ffprobe on stream."""
clean_path = escape_special_characters(path) clean_path = escape_special_characters(path)

View File

@ -41,6 +41,7 @@ export type DetectorStats = {
detection_start: number; detection_start: number;
inference_speed: number; inference_speed: number;
pid: number; pid: number;
temperature?: number;
}; };
export type EmbeddingsStats = { export type EmbeddingsStats = {
@ -72,7 +73,6 @@ export type GpuInfo = "vainfo" | "nvinfo";
export type ServiceStats = { export type ServiceStats = {
last_updated: number; last_updated: number;
storage: { [path: string]: StorageStats }; storage: { [path: string]: StorageStats };
temperatures: { [apex: string]: number };
uptime: number; uptime: number;
latest_version: string; latest_version: string;
version: string; version: string;

View File

@ -127,13 +127,6 @@ export default function GeneralMetrics({
return undefined; return undefined;
} }
if (
statsHistory.length > 0 &&
Object.keys(statsHistory[0].service.temperatures).length == 0
) {
return undefined;
}
const series: { const series: {
[key: string]: { name: string; data: { x: number; y: number }[] }; [key: string]: { name: string; data: { x: number; y: number }[] };
} = {}; } = {};
@ -143,22 +136,22 @@ export default function GeneralMetrics({
return; return;
} }
Object.entries(stats.detectors).forEach(([key], cIdx) => { Object.entries(stats.detectors).forEach(([key, detectorStats]) => {
if (!key.includes("coral")) { if (detectorStats.temperature === undefined) {
return; return;
} }
if (cIdx <= Object.keys(stats.service.temperatures).length) { if (!(key in series)) {
if (!(key in series)) { series[key] = {
series[key] = { name: key,
name: key, data: [],
data: [], };
};
}
const temp = Object.values(stats.service.temperatures)[cIdx];
series[key].data.push({ x: statsIdx + 1, y: Math.round(temp) });
} }
series[key].data.push({
x: statsIdx + 1,
y: Math.round(detectorStats.temperature),
});
}); });
}); });