mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
feat(oci): offer an Intel NPU or a Coral as Frigate's object detector
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user