feat(oci): offer an Intel NPU or a Coral as Frigate's object detector

This commit is contained in:
MacRimi
2026-09-28 17:48:47 +02:00
parent adc42caae3
commit bf07f0a8a9
8 changed files with 200 additions and 4 deletions
+25
View File
@@ -923,6 +923,31 @@
"device_inventory": "host-sysfs-and-stat",
"application_acceleration": "requires-workload-test",
"tone_mapping": "not-implied-by-device-access"
},
"object_detector": {
"prompt": "Object detector for Frigate",
"devices": [
{
"id": "intel-npu",
"label": "Intel NPU",
"host_glob": "/dev/accel/accel*",
"path_pattern": "/dev/accel/accel[0-9]+",
"sysfs_class": "accel",
"sysfs_vendor": "0x8086",
"completion_notes": [
"Frigate uses the Intel NPU once /config/config.yaml defines a detector with type: openvino and device: NPU."
]
},
{
"id": "coral-pcie",
"label": "Coral PCIe/M.2",
"host_glob": "/dev/apex_*",
"path_pattern": "/dev/apex_[0-9]+",
"completion_notes": [
"Frigate uses the Coral once /config/config.yaml defines a detector with type: edgetpu and device: pci."
]
}
]
}
},
"image_channel_policy": {
+25
View File
@@ -923,6 +923,31 @@
"device_inventory": "host-sysfs-and-stat",
"application_acceleration": "requires-workload-test",
"tone_mapping": "not-implied-by-device-access"
},
"object_detector": {
"prompt": "Object detector for Frigate",
"devices": [
{
"id": "intel-npu",
"label": "Intel NPU",
"host_glob": "/dev/accel/accel*",
"path_pattern": "/dev/accel/accel[0-9]+",
"sysfs_class": "accel",
"sysfs_vendor": "0x8086",
"completion_notes": [
"Frigate uses the Intel NPU once /config/config.yaml defines a detector with type: openvino and device: NPU."
]
},
{
"id": "coral-pcie",
"label": "Coral PCIe/M.2",
"host_glob": "/dev/apex_*",
"path_pattern": "/dev/apex_[0-9]+",
"completion_notes": [
"Frigate uses the Coral once /config/config.yaml defines a detector with type: edgetpu and device: pci."
]
}
]
}
},
"image_channel_policy": {
+4 -1
View File
@@ -2154,7 +2154,10 @@ if [[ -z ${PROXMENUX_OCI_TRANSACTION:-} ]] && ! oci_quiet python3 "${SCRIPT_DIR}
--archive "$ARCHIVE_PATH" --digest "$DIGEST"; then
msg_warn "$(translate "Container installed, but without a verifiable record for future updates.")"
fi
COMPLETION_NOTES=$(jq -c '.proxmox.installer_profile.completion_notes // []' "$TEMPLATE_FILE")
# The template's notes, then those of this installation's choices, such as the
# detector configuration for a device the user attached.
COMPLETION_NOTES=$(jq -cs '(.[0].proxmox.installer_profile.completion_notes // [])
+ (.[1].completion_notes // [])' "$TEMPLATE_FILE" "$DEPLOYMENT_FILE")
RESULT=$(jq -cn \
--arg app_id "$APP_ID" \
--arg image "$IMAGE_REF" \
+2 -1
View File
@@ -15,8 +15,9 @@ def gpu_path(path):
def peripheral_path(path):
"""Coral, NPU (/dev/accel), USB and serial nodes, identified by their sysfs path."""
return isinstance(path, str) and re.fullmatch(
r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path) is not None
r'/dev/(apex_[0-9]+|accel/accel[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path) is not None
def system_path(path):
+2 -2
View File
@@ -64,7 +64,7 @@ def _device(key, value):
raise ValueError(f'{key}: {translate("Unsupported device options")}')
path = fields.get('path')
if not (gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(path)):
raise ValueError(f'{key}: {translate("Only Intel/AMD DRM, Coral and USB nodes can be adopted automatically")}')
raise ValueError(f'{key}: {translate("Only Intel/AMD DRM, Coral, NPU and USB nodes can be adopted automatically")}')
snapshot = gpu_devices.snapshot(path)
mode = fields.get('mode', '0660')
if not re.fullmatch(r'0?[0-7]{3}', mode):
@@ -120,7 +120,7 @@ def propose(record, config):
else:
device = _device(key, current[key])
deployment.setdefault('devices', []).append(device)
kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral'
kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral/NPU'
details.append(f"{key}: {device['host_path']} ({kind})")
filtered = b'\n'.join(line for line in config.splitlines()
if not any(line.startswith(key.encode() + b': ') for key in new_keys)) + b'\n'
+50
View File
@@ -420,6 +420,7 @@ def build_deployment(
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
installer_profile, environment, unprivileged, ui, mode)
devices, completion_notes = configure_detector(installer_profile, devices, ui)
if advanced:
from .extra_devices import ask_extra_devices
@@ -506,6 +507,7 @@ def build_deployment(
"mounts": mounts,
"tmpfs_mounts": tmpfs_mounts,
"devices": devices,
**({"completion_notes": completion_notes} if completion_notes else {}),
"hardware_profile": selected_hardware_profile,
"device_permissions": (device_permissions(template['container_contract']['image']['reference'],
devices, installer_profile.get('device_permissions'))
@@ -1417,6 +1419,54 @@ def _stream_process(command: list[str], standard_input: bytes | None = None) ->
raise InstallError(f"{translate('The installation ended with exit code')} {return_code}")
return result
def detected_detector_devices(installer_profile, root=Path("/")):
"""Object detection devices the template accepts and this host has.
A device is offered only when its node exists and, when the template names
a vendor, the device behind it is from that vendor: an AMD NPU is also
/dev/accel/accel0, and Frigate runs on Intel's only.
"""
found = []
for item in (installer_profile.get("object_detector") or {}).get("devices", []):
pattern = re.compile(item["path_pattern"])
for node in sorted((root / "dev").glob(item["host_glob"].removeprefix("/dev/"))):
path = "/" + str(node.relative_to(root))
if not pattern.fullmatch(path) or not node.is_char_device():
continue
vendor = item.get("sysfs_vendor")
if vendor:
sysfs = root / "sys/class" / item["sysfs_class"] / node.name / "device/vendor"
try:
if sysfs.read_text().strip() != vendor:
continue
except OSError:
continue
found.append({**item, "host_path": path})
return found
def configure_detector(installer_profile, devices, ui, root=Path("/")):
"""Offer the object detection devices found on the host; ask nothing when
there are none. Returns the devices and the note that tells how to use it."""
attached = {item.get("host_path") for item in devices}
candidates = [item for item in detected_detector_devices(installer_profile, root)
if item["host_path"] not in attached]
if not candidates or any(item.get("id", "").startswith("detector-") for item in devices):
return devices, []
options = [("none", translate("No detector device (CPU)"))] + [
(item["host_path"], f"{translate(item['label'])} ({item['host_path']})") for item in candidates]
selected = ui.choose(translate(installer_profile["object_detector"].get("prompt", "Object detector")), options, "none")
if selected is None:
raise UserCancelled(translate("Device configuration cancelled"))
chosen = next((item for item in candidates if item["host_path"] == selected), None)
if chosen is None:
return devices, []
device = {"id": f"detector-{chosen['id']}", "kind": "character-device",
"host_path": chosen["host_path"], "container_path": chosen["host_path"],
"mode": "0660", "deny_write": False, "gid_strategy": "host-device-gid"}
return [*devices, device], list(chosen.get("completion_notes", []))
def configure_acceleration(installer_profile, environment, unprivileged, ui, mode=ADVANCED_MODE):
advanced = mode != DEFAULT_MODE
devices: list[dict[str, Any]] = []
+5
View File
@@ -117,6 +117,11 @@ def edit_recreation(record, ui):
if ui.confirm(translate('Change the access network?'), False):
edit_network(deployment, ui)
edit_acceleration(candidate, ui)
from .installer import configure_detector
installer_profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {})
deployment['devices'], notes = configure_detector(installer_profile, deployment.get('devices', []), ui)
if notes:
deployment['completion_notes'] = notes
from .extra_devices import ask_extra_devices
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
repository = reference.split('@')[0].rsplit(':', 1)[0]
+87
View File
@@ -0,0 +1,87 @@
"""Frigate offers the object detection devices found on the host, and asks nothing without them."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci.installer import configure_detector, detected_detector_devices
import oci_gpu_devices
PROFILE = json.loads((ROOT / "catalog/curated/frigate.json").read_text())["proxmox"]["installer_profile"]
class ObjectDetectorTests(unittest.TestCase):
def host(self, nodes, vendors=None):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
root = Path(tmp.name)
for node in nodes:
(root / node.lstrip("/")).parent.mkdir(parents=True, exist_ok=True)
(root / node.lstrip("/")).touch()
for sysfs, vendor in (vendors or {}).items():
(root / sysfs).parent.mkdir(parents=True, exist_ok=True)
(root / sysfs).write_text(vendor + "\n")
# Regular files stand in for the character devices of a real host.
patcher = patch.object(Path, "is_char_device", lambda self: self.is_file())
patcher.start()
self.addCleanup(patcher.stop)
return root
def test_intel_npu_and_coral_are_found_amd_npu_is_not(self):
root = self.host(["/dev/accel/accel0", "/dev/accel/accel1", "/dev/apex_0"],
{"sys/class/accel/accel0/device/vendor": "0x8086",
"sys/class/accel/accel1/device/vendor": "0x1022"})
found = detected_detector_devices(PROFILE, root)
self.assertEqual([(item["id"], item["host_path"]) for item in found],
[("intel-npu", "/dev/accel/accel0"), ("coral-pcie", "/dev/apex_0")])
def test_nothing_found_asks_nothing(self):
root = self.host([])
ui = Mock()
self.assertEqual(configure_detector(PROFILE, [], ui, root), ([], []))
ui.choose.assert_not_called()
def test_selected_npu_is_attached_with_the_host_gid_and_a_note(self):
root = self.host(["/dev/accel/accel0"], {"sys/class/accel/accel0/device/vendor": "0x8086"})
ui = Mock()
ui.choose.return_value = "/dev/accel/accel0"
devices, notes = configure_detector(PROFILE, [{"id": "gpu-render", "host_path": "/dev/dri/renderD128"}], ui, root)
self.assertEqual(devices[-1], {"id": "detector-intel-npu", "kind": "character-device",
"host_path": "/dev/accel/accel0", "container_path": "/dev/accel/accel0",
"mode": "0660", "deny_write": False, "gid_strategy": "host-device-gid"})
self.assertIn("device: NPU", notes[0])
self.assertEqual([tag for tag, _label in ui.choose.call_args.args[1]], ["none", "/dev/accel/accel0"])
def test_no_detector_keeps_the_devices(self):
root = self.host(["/dev/apex_0"])
ui = Mock()
ui.choose.return_value = "none"
self.assertEqual(configure_detector(PROFILE, [], ui, root), ([], []))
def test_an_attached_detector_is_not_offered_again(self):
root = self.host(["/dev/apex_0"])
ui = Mock()
devices = [{"id": "detector-coral-pcie", "host_path": "/dev/apex_0"}]
self.assertEqual(configure_detector(PROFILE, devices, ui, root), (devices, []))
ui.choose.assert_not_called()
def test_template_without_detectors_asks_nothing(self):
ui = Mock()
self.assertEqual(configure_detector({}, [], ui, self.host(["/dev/apex_0"])), ([], []))
ui.choose.assert_not_called()
def test_npu_node_is_a_supported_device_for_updates_and_adoption(self):
self.assertTrue(oci_gpu_devices.peripheral_path("/dev/accel/accel0"))
self.assertFalse(oci_gpu_devices.peripheral_path("/dev/accel/accel"))
self.assertFalse(oci_gpu_devices.peripheral_path("/dev/accel/../kvm"))
if __name__ == "__main__":
unittest.main()