Files
NODEDC_MISSION_CORE/apps/node-agent/sensors/device.py
T

504 lines
21 KiB
Python

"""D455 adapter. Hardware ownership and raw acquisition remain on the board."""
import hashlib
import json
import math
import os
import queue
import shutil
import threading
import time
import uuid
from contextlib import suppress
from datetime import UTC, datetime
import numpy as np
import pyrealsense2 as rs
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
MODEL = {
"plugin_id": "missioncore.realsense",
"plugin_version": "0.6.0",
"model_id": "realsense.d455",
}
def utc():
return datetime.now(UTC).isoformat()
def device_id(serial):
return "rsd455_" + hashlib.sha256(serial.encode()).hexdigest()[:32]
def atomic(path, value):
tmp = path.with_suffix(".tmp")
with tmp.open("w") as f:
json.dump(value, f, ensure_ascii=False, allow_nan=False)
f.flush()
os.fsync(f.fileno())
tmp.replace(path)
def kind(profile):
return str(profile.stream_type()).split(".")[-1]
class Device:
def __init__(self, serial, root, execution):
self.serial = serial
self.id = device_id(serial)
self.root = root / self.id
self.root.mkdir(exist_ok=True, mode=0o700)
self.lock = threading.RLock()
self.execution = execution
self.session = "sensor_" + uuid.uuid4().hex
self.opened = utc()
self.revision = 0
self.online = True
self.acquisition = "idle"
self.message = ""
self.pipeline = None
self.thread = None
self.queue = queue.Queue(maxsize=2)
self.stop_event = threading.Event()
self.images = {}
self.motion = {}
self.depth = None
self.intrinsics = None
self.frames = {}
self.last_frame = None
self.record = None
self.profiles = []
self.options = []
self.sdk_device = None
self.firmware = ""
self.transport = ""
self.config = {"name": "RealSense D455", "verified": False}
if (self.root / "config.json").exists():
self.config.update(json.loads((self.root / "config.json").read_text()))
# A previous verification is evidence, not a live readiness assertion.
self.verified_this_process = False
for path in self.root.glob("recordings/*/manifest.json"):
value = json.loads(path.read_text())
if value.get("state") == "recording":
value.update(state="interrupted", recovered_at=utc())
atomic(path, value)
def save(self):
atomic(self.root / "config.json", self.config)
def refresh(self, dev):
with self.lock:
self.sdk_device = dev
self.online = True
self.firmware = dev.get_info(rs.camera_info.firmware_version)
self.transport = dev.get_info(rs.camera_info.usb_type_descriptor)
if self.pipeline is not None:
return
profiles, options = [], []
for index, sensor in enumerate(dev.query_sensors()):
for profile in sensor.get_stream_profiles():
stream = kind(profile)
if stream not in ("color", "depth", "infrared", "accel", "gyro"):
continue
data = {
"sensor": index,
"stream": stream,
"index": profile.stream_index(),
"fps": profile.fps(),
"format": str(profile.format()).split(".")[-1],
}
if profile.is_video_stream_profile():
video = profile.as_video_stream_profile()
data.update(width=video.width(), height=video.height())
data["id"] = hashlib.sha256(
json.dumps(data, sort_keys=True).encode()
).hexdigest()[:16]
profiles.append(data)
for option in sensor.get_supported_options():
try:
limits = sensor.get_option_range(option)
value = sensor.get_option(option)
if not all(
math.isfinite(x) for x in (limits.min, limits.max, limits.step, value)
):
continue
options.append(
{
"id": f"{index}:{int(option)}",
"sensor": sensor.get_info(rs.camera_info.name),
"label": str(option).split(".")[-1],
"value": value,
"min": limits.min,
"max": limits.max,
"step": limits.step,
"read_only": sensor.is_option_read_only(option),
}
)
except RuntimeError:
continue
self.profiles, self.options = profiles, options
def defaults(self):
result = []
for stream, index, fmt, fps in [
("depth", 0, "z16", 15),
("color", 0, "rgb8", 15),
("infrared", 1, "y8", 15),
("infrared", 2, "y8", 15),
("accel", 0, "motion_xyz32f", 63),
("gyro", 0, "motion_xyz32f", 200),
]:
candidates = [
p
for p in self.profiles
if p["stream"] == stream and p["index"] == index and p["format"] == fmt
]
if candidates:
p = min(
candidates,
key=lambda p: (
abs(p.get("width", 640) - 640)
+ abs(p.get("height", 480) - 480)
+ abs(p["fps"] - fps) * 20
),
)
result.append(p["id"])
return result
def snapshot(self, detailed=True):
with self.lock:
now = utc()
snap = DeviceSessionSnapshot.model_validate(
{
"context": {
"session_id": self.session,
"device": {
"device_id": self.id,
"model": MODEL,
"stability": "stable",
"basis": "hardware-identifier",
},
"execution": self.execution,
"opened_at": self.opened,
},
"revision": self.revision,
"enrollment": "enrolled" if self.config["verified"] else "empty",
"connectivity": "connected" if self.online else "offline",
"acquisition": self.acquisition,
"observed_at": now,
"message": self.message or None,
}
)
value = {
"id": self.id,
"name": self.config["name"],
"model": "RealSense D455",
"prepared": True,
"verified": self.verified_this_process,
"online": self.online,
"snapshot": snap.model_dump(mode="json"),
"firmware": self.firmware,
"usb": self.transport,
"frames": dict(self.frames),
"last_frame": self.last_frame,
"recording": self.record,
"layers": list(self.images)
+ (["points"] if self.depth is not None else [])
+ (["motion"] if self.motion else []),
}
if detailed:
value.update(
profiles=self.profiles,
defaults=self.defaults(),
options=self.options,
recordings=self.recordings(),
motion=dict(self.motion),
)
return value
def callback(self, frame):
if frame.is_motion_frame():
v = frame.as_motion_frame().get_motion_data()
key = kind(frame.profile)
self.motion[key] = {
"x": v.x,
"y": v.y,
"z": v.z,
"timestamp_ms": frame.get_timestamp(),
"clock": str(frame.get_frame_timestamp_domain()),
"observed_at": utc(),
}
self.frames[key] = self.frames.get(key, 0) + 1
elif frame.is_frameset():
with suppress(queue.Full):
self.queue.put_nowait(frame.as_frameset())
def start(self, selected=None, record=False):
with self.lock:
if self.pipeline is not None:
raise ValueError("Захват уже запущен. Сначала остановите его.")
if not self.online or self.sdk_device is None:
raise ValueError("Камера не подключена.")
ids = selected if selected is not None else self.defaults()
if not isinstance(ids, list) or not 1 <= len(ids) <= 6 or len(set(ids)) != len(ids):
raise ValueError("Выберите профили потоков.")
profiles = [next((p for p in self.profiles if p["id"] == ident), None) for ident in ids]
if any(p is None for p in profiles) or len(
{(p["stream"], p["index"]) for p in profiles}
) != len(profiles):
raise ValueError("Выбраны несовместимые профили потоков.")
if not any(p["stream"] in ("depth", "color", "infrared") for p in profiles):
raise ValueError("Выберите хотя бы один видеопоток.")
config = rs.config()
config.enable_device(self.serial)
for p in profiles:
stream, fmt = getattr(rs.stream, p["stream"]), getattr(rs.format, p["format"])
if "width" in p:
config.enable_stream(stream, p["index"], p["width"], p["height"], fmt, p["fps"])
else:
config.enable_stream(stream, p["index"], fmt, p["fps"])
pipeline = rs.pipeline()
if not config.can_resolve(rs.pipeline_wrapper(pipeline)):
raise ValueError("Камера не поддерживает эту комбинацию профилей. Выберите другую.")
self.acquisition = "starting"
self.revision += 1
self.images, self.motion, self.frames = {}, {}, {}
self.depth, self.last_frame = None, None
self.queue = queue.Queue(maxsize=2)
self.stop_event.clear()
record_path = None
if record:
if shutil.disk_usage(self.root).free < 2 * 1024**3:
self.acquisition = "idle"
raise ValueError("Для исходной записи нужно не меньше 2 ГиБ свободного места.")
ident = "capture_" + uuid.uuid4().hex
record_path = self.root / "recordings" / ident
record_path.mkdir(parents=True, mode=0o700)
config.enable_record_to_file(str(record_path / "source.bag"))
self.record = {
"id": ident,
"state": "recording",
"started_at": utc(),
"monotonic_ns": time.monotonic_ns(),
"device_id": self.id,
"session_id": self.session,
"profiles": profiles,
"firmware": self.firmware,
"sdk": "2.58.4.10922",
"options": self.options,
}
atomic(record_path / "manifest.json", self.record)
try:
active = pipeline.start(config, self.callback)
self.pipeline = pipeline
self.acquisition = "streaming"
self.message = ""
self.depth_scale = active.get_device().first_depth_sensor().get_depth_scale()
calibration = []
for p in active.get_streams():
if p.is_video_stream_profile():
v = p.as_video_stream_profile().get_intrinsics()
calibration.append(
{
"stream": kind(p),
"index": p.stream_index(),
"width": v.width,
"height": v.height,
"fx": v.fx,
"fy": v.fy,
"ppx": v.ppx,
"ppy": v.ppy,
"coeffs": v.coeffs,
"model": str(v.model),
}
)
if self.record:
self.record["calibration"] = calibration
self.record["depth_scale"] = self.depth_scale
atomic(record_path / "manifest.json", self.record)
self.thread = threading.Thread(target=self.consume, daemon=True)
self.thread.start()
except Exception:
with suppress(RuntimeError):
pipeline.stop()
self.pipeline = None
self.acquisition = "failed"
self.message = "Не удалось открыть потоки камеры. Проверьте подключение и профили."
if self.record:
self.record.update(state="failed", ended_at=utc())
atomic(record_path / "manifest.json", self.record)
self.record = None
raise ValueError(self.message) from None
def consume(self):
colorizer = rs.colorizer()
last_data, last_disk = time.monotonic(), time.monotonic()
while not self.stop_event.is_set():
try:
frames = self.queue.get(timeout=1)
except queue.Empty:
if time.monotonic() - last_data > 8:
self.message = "Кадры перестали поступать. Проверьте USB и остановите захват."
self.acquisition = "failed"
self.online = False
break
continue
last_data = time.monotonic()
for frame in frames:
key = kind(frame.profile)
if key == "infrared":
key += str(frame.profile.stream_index())
if not frame.is_video_frame():
continue
data = np.asanyarray(frame.get_data()).copy()
if key == "depth":
self.depth = data
self.intrinsics = frame.profile.as_video_stream_profile().get_intrinsics()
data = np.asanyarray(colorizer.colorize(frame).get_data()).copy()
elif data.ndim == 2:
data = np.repeat(data[:, :, None], 3, axis=2)
elif frame.profile.format() == rs.format.bgr8:
data = data[:, :, ::-1].copy()
elif frame.profile.format() != rs.format.rgb8:
continue
self.images[key] = data
self.frames[key] = self.frames.get(key, 0) + 1
self.last_frame = {
"observed_at": utc(),
"monotonic_ns": time.monotonic_ns(),
"device_timestamp_ms": frame.get_timestamp(),
"clock": str(frame.get_frame_timestamp_domain()),
}
if self.record and time.monotonic() - last_disk > 2:
last_disk = time.monotonic()
if shutil.disk_usage(self.root).free < 512 * 1024**2:
self.message = "Запись остановлена: мало свободного места."
break
if not self.stop_event.is_set():
# Explicit device/disk failure closes raw recording; never auto-restart.
self.stop(failed=True, from_capture=True)
def stop(self, failed=False, from_capture=False):
with self.lock:
self.stop_event.set()
pipeline, self.pipeline = self.pipeline, None
if pipeline is not None:
self.acquisition = "stopping"
pipeline.stop()
if self.thread and not from_capture:
self.thread.join(timeout=3)
self.acquisition = "failed" if failed else "idle"
self.revision += 1
if self.record:
value = dict(self.record)
value.update(
state="failed" if failed else "complete",
ended_at=utc(),
ended_monotonic_ns=time.monotonic_ns(),
frames=dict(self.frames),
)
path = self.root / "recordings" / value["id"]
source = path / "source.bag"
if source.exists():
digest = hashlib.sha256()
with source.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
value.update(sha256=digest.hexdigest(), bytes=source.stat().st_size)
atomic(path / "manifest.json", value)
self.record = None
return {"ok": True}
def verify(self):
if self.pipeline is not None:
raise ValueError("Остановите захват перед повторной проверкой.")
try:
self.start()
deadline = time.monotonic() + 8
expected = {
p["stream"] + (str(p["index"]) if p["stream"] == "infrared" else "")
for p in self.profiles
if p["id"] in self.defaults()
}
while time.monotonic() < deadline:
if all(self.frames.get(key, 0) >= 2 for key in expected):
self.config["verified"] = True
self.verified_this_process = True
self.config["verified_at"] = utc()
self.save()
return {
"ok": True,
"frames": dict(self.frames),
"verified_at": self.config["verified_at"],
}
time.sleep(0.1)
raise ValueError(
"Не получены кадры всех выбранных потоков. Проверьте USB 3 и повторите проверку."
)
finally:
self.stop()
def rename(self, name):
if (
not isinstance(name, str)
or not name.strip()
or len(name) > 80
or any(ord(c) < 32 for c in name)
):
raise ValueError("Введите название до 80 символов.")
with self.lock:
self.config["name"] = name.strip()
self.save()
self.revision += 1
return {"ok": True}
def set_option(self, identifier, value):
with self.lock:
item = next((v for v in self.options if v["id"] == identifier), None)
if (
not item
or item["read_only"]
or type(value) not in (int, float)
or not math.isfinite(value)
or not item["min"] <= value <= item["max"]
):
raise ValueError("Параметр недоступен или значение вне диапазона.")
sensor_index, option_id = map(int, identifier.split(":"))
sensor = self.sdk_device.query_sensors()[sensor_index]
option = rs.option(option_id)
sensor.set_option(option, float(value))
item["value"] = sensor.get_option(option)
self.revision += 1
return {"ok": True, "value": item["value"]}
def points(self):
depth, intrinsics = self.depth, self.intrinsics
if depth is None or intrinsics is None:
return []
# SDK deprojection respects the camera's actual distortion model.
h, w = depth.shape
stride = max(8, math.ceil(math.sqrt(h * w / 2000)))
result = []
for y in range(0, h, stride):
for x in range(0, w, stride):
z = float(depth[y, x]) * self.depth_scale
if 0 < z < 15:
result.extend(
round(v, 4) for v in rs.rs2_deproject_pixel_to_point(intrinsics, [x, y], z)
)
return result
def recordings(self):
result = []
for path in sorted(self.root.glob("recordings/*/manifest.json"), reverse=True)[:100]:
value = json.loads(path.read_text())
result.append(
{
k: value.get(k)
for k in ("id", "state", "started_at", "ended_at", "bytes", "sha256")
}
)
return result