mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 10:06:41 +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",
|
"device_inventory": "host-sysfs-and-stat",
|
||||||
"application_acceleration": "requires-workload-test",
|
"application_acceleration": "requires-workload-test",
|
||||||
"tone_mapping": "not-implied-by-device-access"
|
"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": {
|
"image_channel_policy": {
|
||||||
|
|||||||
@@ -923,6 +923,31 @@
|
|||||||
"device_inventory": "host-sysfs-and-stat",
|
"device_inventory": "host-sysfs-and-stat",
|
||||||
"application_acceleration": "requires-workload-test",
|
"application_acceleration": "requires-workload-test",
|
||||||
"tone_mapping": "not-implied-by-device-access"
|
"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": {
|
"image_channel_policy": {
|
||||||
|
|||||||
@@ -2154,7 +2154,10 @@ if [[ -z ${PROXMENUX_OCI_TRANSACTION:-} ]] && ! oci_quiet python3 "${SCRIPT_DIR}
|
|||||||
--archive "$ARCHIVE_PATH" --digest "$DIGEST"; then
|
--archive "$ARCHIVE_PATH" --digest "$DIGEST"; then
|
||||||
msg_warn "$(translate "Container installed, but without a verifiable record for future updates.")"
|
msg_warn "$(translate "Container installed, but without a verifiable record for future updates.")"
|
||||||
fi
|
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 \
|
RESULT=$(jq -cn \
|
||||||
--arg app_id "$APP_ID" \
|
--arg app_id "$APP_ID" \
|
||||||
--arg image "$IMAGE_REF" \
|
--arg image "$IMAGE_REF" \
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ def gpu_path(path):
|
|||||||
|
|
||||||
|
|
||||||
def peripheral_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(
|
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):
|
def system_path(path):
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ def _device(key, value):
|
|||||||
raise ValueError(f'{key}: {translate("Unsupported device options")}')
|
raise ValueError(f'{key}: {translate("Unsupported device options")}')
|
||||||
path = fields.get('path')
|
path = fields.get('path')
|
||||||
if not (gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(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)
|
snapshot = gpu_devices.snapshot(path)
|
||||||
mode = fields.get('mode', '0660')
|
mode = fields.get('mode', '0660')
|
||||||
if not re.fullmatch(r'0?[0-7]{3}', mode):
|
if not re.fullmatch(r'0?[0-7]{3}', mode):
|
||||||
@@ -120,7 +120,7 @@ def propose(record, config):
|
|||||||
else:
|
else:
|
||||||
device = _device(key, current[key])
|
device = _device(key, current[key])
|
||||||
deployment.setdefault('devices', []).append(device)
|
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})")
|
details.append(f"{key}: {device['host_path']} ({kind})")
|
||||||
filtered = b'\n'.join(line for line in config.splitlines()
|
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'
|
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(
|
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
|
||||||
installer_profile, environment, unprivileged, ui, mode)
|
installer_profile, environment, unprivileged, ui, mode)
|
||||||
|
devices, completion_notes = configure_detector(installer_profile, devices, ui)
|
||||||
|
|
||||||
if advanced:
|
if advanced:
|
||||||
from .extra_devices import ask_extra_devices
|
from .extra_devices import ask_extra_devices
|
||||||
@@ -506,6 +507,7 @@ def build_deployment(
|
|||||||
"mounts": mounts,
|
"mounts": mounts,
|
||||||
"tmpfs_mounts": tmpfs_mounts,
|
"tmpfs_mounts": tmpfs_mounts,
|
||||||
"devices": devices,
|
"devices": devices,
|
||||||
|
**({"completion_notes": completion_notes} if completion_notes else {}),
|
||||||
"hardware_profile": selected_hardware_profile,
|
"hardware_profile": selected_hardware_profile,
|
||||||
"device_permissions": (device_permissions(template['container_contract']['image']['reference'],
|
"device_permissions": (device_permissions(template['container_contract']['image']['reference'],
|
||||||
devices, installer_profile.get('device_permissions'))
|
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}")
|
raise InstallError(f"{translate('The installation ended with exit code')} {return_code}")
|
||||||
return result
|
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):
|
def configure_acceleration(installer_profile, environment, unprivileged, ui, mode=ADVANCED_MODE):
|
||||||
advanced = mode != DEFAULT_MODE
|
advanced = mode != DEFAULT_MODE
|
||||||
devices: list[dict[str, Any]] = []
|
devices: list[dict[str, Any]] = []
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ def edit_recreation(record, ui):
|
|||||||
if ui.confirm(translate('Change the access network?'), False):
|
if ui.confirm(translate('Change the access network?'), False):
|
||||||
edit_network(deployment, ui)
|
edit_network(deployment, ui)
|
||||||
edit_acceleration(candidate, 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
|
from .extra_devices import ask_extra_devices
|
||||||
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
|
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
|
||||||
repository = reference.split('@')[0].rsplit(':', 1)[0]
|
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