Camera UI

Serve fresh camera snapshots with explicit image responses and expose live crop controls without placing file or camera startup work on the UI thread.

Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
This commit is contained in:
firestar5683
2026-09-25 14:53:21 -05:00
parent 1d13b9d88b
commit 264bb5a8a8
6 changed files with 442 additions and 0 deletions
@@ -0,0 +1,87 @@
"""Short, namespaced camera leases. Manager alone owns camera processes."""
import fcntl
import hashlib
import os
from pathlib import Path
import stat
import tempfile
import time
TTL_NS = 15_000_000_000
FRAME_TTL_NS = 5_000_000_000
def boot_ns():
return time.clock_gettime_ns(getattr(time, 'CLOCK_BOOTTIME', time.CLOCK_MONOTONIC))
def _path(name):
prefix = os.environ.get('OPENPILOT_PREFIX', 'd')
namespace = hashlib.sha256(prefix.encode()).hexdigest()[:16]
return Path('/dev/shm') / f'starpilot-camera-{os.getuid()}-{namespace}' / name
def _read(path):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_size > 32:
raise ValueError('Invalid camera lease')
return int(os.read(fd, 32))
finally:
os.close(fd)
def _write(path, value):
path = Path(path)
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
info = path.parent.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise OSError('Camera lease directory is not private')
lock = os.open(path.with_name(path.name + '.lock'), os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
temporary = None
try:
info = os.fstat(lock)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
raise OSError('Invalid camera lease lock')
fcntl.flock(lock, fcntl.LOCK_EX)
try:
previous = _read(path)
if abs(previous - value) <= TTL_NS:
value = max(value, previous)
except (OSError, ValueError):
pass
fd, temporary = tempfile.mkstemp(prefix=path.name + '.', dir=path.parent)
try:
os.write(fd, str(value).encode())
finally:
os.close(fd)
os.replace(temporary, path)
temporary = None
finally:
if temporary is not None:
os.unlink(temporary)
os.close(lock)
def request(clock=boot_ns, path=None):
_write(path or _path('lease'), clock() + TTL_NS)
def requested(clock=boot_ns, path=None):
try:
remaining = _read(path or _path('lease')) - clock()
return 0 < remaining <= TTL_NS
except (OSError, ValueError):
return False
def mark_frame(clock=boot_ns, path=None):
_write(path or _path('cabin-frame'), clock())
def frame_available(clock=boot_ns, path=None):
try:
return 0 <= clock() - _read(path or _path('cabin-frame')) <= FRAME_TTL_NS
except (OSError, ValueError):
return False
@@ -0,0 +1,71 @@
"""One bounded parked snapshot from an already-running camera owner."""
import subprocess
import sys
import threading
import time
CAMERAS = frozenset(("cabin", "wide", "narrow"))
MAX_JPEG_BYTES = 1_000_000
_CAPTURE_LOCK = threading.Lock()
class SnapshotUnavailable(Exception):
pass
class SnapshotDenied(SnapshotUnavailable):
pass
class CameraSnapshot:
def __init__(self, *, spawn=subprocess.Popen, clock=time.monotonic):
self.spawn, self.clock = spawn, clock
self.last_request = {}
def capture(self, camera: str, *, permitted) -> bytes:
if camera not in CAMERAS:
raise ValueError("Unknown camera")
if not permitted():
raise SnapshotDenied
if not _CAPTURE_LOCK.acquire(blocking=False):
raise SnapshotUnavailable("Another snapshot is in progress")
process = None
try:
if self.clock() - self.last_request.get(camera, float('-inf')) < 1:
raise SnapshotUnavailable("Wait before another snapshot")
self.last_request[camera] = self.clock()
from openpilot.starpilot.galaxy.camera_request import request, boot_ns
request()
frame_after = boot_ns()
deadline = self.clock() + 12
process = self.spawn([sys.executable, '-m', 'openpilot.starpilot.galaxy.camera_snapshot_worker', camera],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, close_fds=True)
while self.clock() < deadline:
if not permitted():
raise SnapshotDenied
try:
body, _ = process.communicate(timeout=min(.1, max(.001, deadline - self.clock())))
except subprocess.TimeoutExpired:
continue
if not permitted():
raise SnapshotDenied
if (process.returncode != 0 or not 4 <= len(body) <= MAX_JPEG_BYTES or
not body.startswith(b'\xff\xd8') or not body.endswith(b'\xff\xd9')):
raise SnapshotUnavailable("No fresh camera frame")
if camera == "cabin":
from openpilot.starpilot.galaxy.camera_request import mark_frame
mark_frame(clock=lambda: frame_after)
return body
raise SnapshotUnavailable("Camera timed out")
except OSError as error:
raise SnapshotUnavailable("Camera unavailable") from error
finally:
if process is not None:
if process.poll() is None:
process.kill()
process.wait()
if process.stdout is not None:
process.stdout.close()
_CAPTURE_LOCK.release()
@@ -0,0 +1,61 @@
"""CPU-only frame reader. Does not start or stop camerad or change Params."""
from io import BytesIO
import sys
import time
from PIL import Image
from msgq.visionipc import VisionIpcClient
from openpilot.cereal.visionipc import VisionStreamType
from openpilot.starpilot.galaxy.camera_snapshot import MAX_JPEG_BYTES
from openpilot.system.camerad.snapshot import extract_image
STREAMS = {"cabin": VisionStreamType.VISION_STREAM_CABIN, "wide": VisionStreamType.VISION_STREAM_WIDE_ROAD,
"narrow": VisionStreamType.VISION_STREAM_NARROW_ROAD}
def boot_ns():
return time.clock_gettime_ns(getattr(time, "CLOCK_BOOTTIME", time.CLOCK_MONOTONIC))
def capture(camera, *, client_factory=VisionIpcClient, clock=boot_ns):
started = clock()
client = client_factory("camerad", STREAMS[camera], True)
while not client.connect(False):
if clock() - started > 9_000_000_000:
return None
time.sleep(.1)
while clock() - started < 10_000_000_000:
frame = client.recv(timeout_ms=100)
now = clock()
# Native camerad does not populate VIPC extra.valid; use capture freshness.
if frame is None or not started < client.timestamp_sof <= client.timestamp_eof <= now:
continue
if now - client.timestamp_sof > 500_000_000:
continue
width, height, stride, uv_offset = frame.width, frame.height, frame.stride, frame.uv_offset
uv_height = ((height // 2 + 15) // 16) * 16
if (not 0 < width <= 4096 or not 0 < height <= 4096 or width % 2 or height % 2 or
not width <= stride <= 8192 or stride % 2 or not stride * height <= uv_offset <= stride * 4096 or
uv_offset % stride or len(frame.data) < uv_offset + stride * uv_height):
return None
image = Image.fromarray(extract_image(frame))
image.thumbnail((1280, 720))
output = BytesIO()
image.save(output, format="JPEG", quality=85)
body = output.getvalue()
return body if len(body) <= MAX_JPEG_BYTES else None
return None
if __name__ == '__main__':
try:
result = capture(sys.argv[1]) if len(sys.argv) == 2 and sys.argv[1] in STREAMS else None
if result is not None:
sys.stdout.buffer.write(result)
else:
sys.exit(1)
except Exception:
sys.exit(1)
@@ -0,0 +1,32 @@
from concurrent.futures import ThreadPoolExecutor
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpilot.starpilot.galaxy import camera_request
class TestCameraRequest(unittest.TestCase):
def test_concurrent_latest_expiry_and_finite_lease(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'lease'
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda n: camera_request.request(clock=lambda: 100 + n, path=path), range(128)))
self.assertEqual(int(path.read_text()), 227 + camera_request.TTL_NS)
self.assertTrue(camera_request.requested(clock=lambda: 228, path=path))
self.assertFalse(camera_request.requested(clock=lambda: 227 + camera_request.TTL_NS, path=path))
self.assertFalse(camera_request.requested(clock=lambda: 1, path=path))
def test_prefix_isolation_and_frame_receipt_aging(self):
with patch.dict(os.environ, OPENPILOT_PREFIX='camera-a'):
first = camera_request._path('lease')
with patch.dict(os.environ, OPENPILOT_PREFIX='camera-b'):
self.assertNotEqual(first, camera_request._path('lease'))
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'frame'
camera_request.mark_frame(clock=lambda: 100, path=path)
self.assertTrue(camera_request.frame_available(clock=lambda: 101, path=path))
self.assertFalse(camera_request.frame_available(clock=lambda: 101 + camera_request.FRAME_TTL_NS, path=path))
self.assertFalse(camera_request.frame_available(clock=lambda: 99, path=path))
@@ -0,0 +1,125 @@
from io import BytesIO
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from PIL import Image
import numpy as np
from openpilot.starpilot.galaxy.camera_snapshot import CameraSnapshot, SnapshotDenied, SnapshotUnavailable
from openpilot.starpilot.galaxy.camera_snapshot_worker import capture
class CameraSnapshotTest(unittest.TestCase):
def setUp(self):
for name in ("request", "mark_frame"):
replacement = patch("openpilot.starpilot.galaxy.camera_request." + name)
setattr(self, name + "_mock", replacement.start())
self.addCleanup(replacement.stop)
def test_denied_does_not_launch_reader(self):
owner = CameraSnapshot(spawn=lambda *args, **kwargs: self.fail("launched"))
with self.assertRaises(SnapshotDenied):
owner.capture('cabin', permitted=lambda: False)
with self.assertRaises(ValueError):
owner.capture('../camera', permitted=lambda: True)
def test_revocation_kills_and_reaps_blocked_reader(self):
children = []
def spawn(args, **kwargs):
self.assertEqual(args[-1], 'cabin')
with self.assertRaises(SnapshotUnavailable):
CameraSnapshot(spawn=lambda *a, **k: self.fail("concurrent reader")).capture(
'wide', permitted=lambda: True)
children.append(subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], **kwargs))
return children[-1]
calls = 0
def permitted():
nonlocal calls
calls += 1
return calls < 3
with self.assertRaises(SnapshotDenied):
CameraSnapshot(spawn=spawn).capture('cabin', permitted=permitted)
self.assertIsNotNone(children[0].poll())
self.assertTrue(children[0].stdout.closed)
def test_deadline_kills_blocked_native_reader(self):
children = []
ticks = iter(range(100))
def spawn(args, **kwargs):
children.append(subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], **kwargs))
return children[-1]
with self.assertRaises(SnapshotUnavailable):
CameraSnapshot(spawn=spawn, clock=lambda: next(ticks)).capture('cabin', permitted=lambda: True)
self.assertIsNotNone(children[0].poll())
self.assertTrue(children[0].stdout.closed)
def test_reader_result_and_final_authority_check(self):
def spawn(args, **kwargs):
return subprocess.Popen([sys.executable, '-c', "import sys; sys.stdout.buffer.write(bytes([255,216,255,217]))"], **kwargs)
owner = CameraSnapshot(spawn=spawn)
self.assertEqual(owner.capture('wide', permitted=lambda: True), b'\xff\xd8\xff\xd9')
self.assertEqual(owner.capture('cabin', permitted=lambda: True), b'\xff\xd8\xff\xd9')
self.assertEqual(self.request_mock.call_count, 2)
self.assertEqual(self.mark_frame_mock.call_count, 1) # Cabin availability receipt only.
with self.assertRaises(SnapshotUnavailable):
owner.capture('wide', permitted=lambda: True)
calls = 0
def permitted():
nonlocal calls
calls += 1
return calls < 3
with self.assertRaises(SnapshotDenied):
CameraSnapshot(spawn=spawn).capture('wide', permitted=permitted)
def test_real_nv12_conversion_accepts_only_post_request_fresh_native_frame(self):
now = 1_000_000_000
data = np.full(80, 128, dtype=np.uint8)
frame = SimpleNamespace(width=4, height=4, stride=4, uv_offset=16, data=data)
class Client:
valid = False # Native camerad leaves the extra metadata bit unset.
timestamp_sof = now - 1
timestamp_eof = now - 1
frames = 0
def connect(self, blocking):
self.blocking = blocking
return True
def recv(self, timeout_ms):
nonlocal now
self.frames += 1
now += 100_000_000
if self.frames > 1:
self.timestamp_sof = now - 20_000_000
self.timestamp_eof = now - 10_000_000
return frame
client = Client()
result = capture('cabin', client_factory=lambda *args: client, clock=lambda: now)
self.assertFalse(client.blocking)
self.assertEqual(client.frames, 2)
image = Image.open(BytesIO(result))
self.assertEqual(image.size, (4, 4))
self.assertEqual(image.mode, 'RGB')
frame.width = 99999
self.assertIsNone(capture('cabin', client_factory=lambda *args: client, clock=lambda: now))
def test_native_frames_with_stale_future_or_reversed_timestamps_never_convert(self):
for fault in ('stale', 'future', 'reversed'):
with self.subTest(fault=fault):
now = [1_000_000_000]
frame = SimpleNamespace(width=4, height=4, stride=4, uv_offset=16,
data=np.full(80, 128, dtype=np.uint8))
class Client:
valid = False
timestamp_sof = timestamp_eof = 0
def connect(self, blocking): return True
def recv(self, timeout_ms, *, fault=fault, frame=frame, now=now):
now[0] += 100_000_000
self.timestamp_sof = now[0] - (600_000_000 if fault == 'stale' else 20_000_000)
self.timestamp_eof = now[0] + 1 if fault == 'future' else self.timestamp_sof - 1 if fault == 'reversed' else now[0] - 10_000_000
return frame
with patch('openpilot.starpilot.galaxy.camera_snapshot_worker.extract_image') as convert:
self.assertIsNone(capture('cabin', client_factory=lambda *args: Client(), clock=lambda now=now: now[0]))
convert.assert_not_called()
@@ -0,0 +1,66 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { CamerasPage, CameraSnapshotFeed } from "../web/js/cameras.js"
import { SettingsFeed, SettingsPage } from "../web/js/settings.js"
const requests = []
const states = []
const feed = new SettingsFeed({ publish: (state) => states.push(state),
fetcher: (url, options) => new Promise((resolve) => requests.push({ url, options, resolve })),
later: () => 1, cancelTimer: () => {} })
feed.start("pip")
assert.equal(requests[0].url, "./api/settings/pages/pip")
assert.equal(feed.page, "pip")
feed.stop()
requests[0].resolve({ ok: true, status: 200, json: async () => ({ page: "pip", rows: [] }) })
await Promise.resolve()
await Promise.resolve()
assert.equal(states.at(-1).status, "idle") // Hidden or signed-out page cannot restore saved values.
const mounted = { mode: "local", initialPage: "pip", state: { developerOpen: false }, feed: { start(page) { assert.equal(page, "pip") } } }
SettingsPage.mounted.call(mounted)
let returned = 0
const page = { initialPage: "pip", returnTo: () => returned++, state: { query: "anything", data: { page: "pip" } },
feed: { load() { throw new Error("Root PiP Back must return to Cameras") } } }
SettingsPage.methods.back.call(page)
assert.equal(returned, 1)
assert.equal(page.state.query, "")
assert.match(CamerasPage.template, /Adjust the camera crop with a live cabin preview/)
assert.doesNotMatch(CamerasPage.template, /live camera preview is unavailable/)
assert.match(CamerasPage.template, /Sentry[\s\S]*View motion events/)
assert.match(CamerasPage.template, /Saved motion settings/)
assert.match(CamerasPage.template, /V-ASM[\s\S]*Open saved settings/)
assert.match(CamerasPage.template, /live camera image and current warning status are unavailable/)
assert.match(CamerasPage.template, /snapshots.capture\(camera\)/)
assert.match(SettingsPage.template, /\{\{ title \}\}/)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
assert.match(app, /<PipPage v-else-if="route\.path === '\/cameras\/pip'"/)
assert.match(app, /route\.path === '\/cameras\/sentry-settings'[\s\S]*initial-page="sentry"/)
assert.match(app, /CamerasPage v-else-if="route\.path === '\/cameras'"/)
const captures = [], snapshots = [], revoked = []
let timer
const camera = new CameraSnapshotFeed({ publish: (value) => snapshots.push(value), unauthorized: () => snapshots.push("unauthorized"),
fetcher: (url, options) => new Promise((resolve) => captures.push({ url, options, resolve })),
createURL: () => "blob:camera", revokeURL: (url) => revoked.push(url),
later: (fn) => { timer = fn; return 1 }, cancelTimer: () => {} })
const pending = camera.capture("cabin")
assert.equal(captures[0].url, "./api/cameras/snapshot")
assert.deepEqual(JSON.parse(captures[0].options.body), { camera: "cabin" })
captures[0].resolve({ ok: true, status: 200, headers: new Map([["Content-Type", "image/jpeg; charset=binary"]]), blob: async () => ({ size: 100 }) })
await pending
assert.equal(snapshots.at(-1).image, "blob:camera")
camera.stop()
assert.deepEqual(revoked, ["blob:camera"])
const late = camera.capture("wide")
camera.stop()
assert.equal(captures[1].options.signal.aborted, true)
captures[1].resolve({ ok: true, status: 200, headers: new Map([["Content-Type", "image/jpeg"]]), blob: async () => ({ size: 100 }) })
await late
assert.equal(snapshots.at(-1).image, "")
const timed = camera.capture("cabin")
timer()
captures[2].resolve({ status: 401 })
await timed
assert.match(snapshots.at(-1).error, /timed out/)