Add Muninn Move marker camera source
This commit is contained in:
parent
35a4bfd1fb
commit
f22d275d60
1 changed files with 787 additions and 4 deletions
|
|
@ -169,6 +169,14 @@ struct Options {
|
|||
command_id: Option<String>,
|
||||
move_host_address: Option<String>,
|
||||
move_state_sources: Vec<MoveStateSource>,
|
||||
move_marker_camera_sources: Vec<MoveMarkerCameraSource>,
|
||||
move_marker_width: u32,
|
||||
move_marker_height: u32,
|
||||
move_marker_stride_bytes: Option<u32>,
|
||||
move_marker_fps: u32,
|
||||
move_marker_threshold_min: u8,
|
||||
move_marker_min_area_px: u32,
|
||||
move_marker_max_candidates: u32,
|
||||
move_evidence_stream_id: Option<String>,
|
||||
move_evidence_verse_id: String,
|
||||
move_evidence_ring_slots: usize,
|
||||
|
|
@ -192,6 +200,12 @@ struct CatalogSource {
|
|||
label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct MoveMarkerCameraSource {
|
||||
camera_id: String,
|
||||
device_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AudioSourceKind {
|
||||
Loopback,
|
||||
|
|
@ -274,6 +288,12 @@ struct ActiveMoveStateSource {
|
|||
light_hidraw_path: Option<String>,
|
||||
}
|
||||
|
||||
struct ActiveMoveMarkerCameraSource {
|
||||
source: MoveMarkerCameraSource,
|
||||
frame_source: MoveMarkerFrameSource,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct DefaultMoveLightTarget {
|
||||
path: String,
|
||||
|
|
@ -285,6 +305,7 @@ struct MoveMarkerFrameSource {
|
|||
stream_id: String,
|
||||
host_id: String,
|
||||
camera_id: String,
|
||||
fps: u32,
|
||||
tracker_config: muninn_move_tracker::MoveTrackerConfig,
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +363,8 @@ fn serve(options: Options) -> Result<()> {
|
|||
let move_runtime_enabled = serve_should_manage_move_runtime(&options);
|
||||
let mut active_move_states =
|
||||
active_move_state_sources(serve_move_state_sources(&options, move_runtime_enabled));
|
||||
let mut active_move_marker_cameras = active_move_marker_camera_sources(&options);
|
||||
let mut move_marker_camera_reader = PlatformMoveMarkerCameraFrameReader::default();
|
||||
let mut active_capture_streams = Vec::new();
|
||||
start_hid_controller_rudp_ingress(&options)?;
|
||||
|
||||
|
|
@ -375,6 +398,11 @@ fn serve(options: Options) -> Result<()> {
|
|||
move_evidence_stream.as_mut(),
|
||||
hid_controller_stream.as_mut(),
|
||||
)?;
|
||||
publish_move_marker_camera_frames(
|
||||
&mut active_move_marker_cameras,
|
||||
&mut move_marker_camera_reader,
|
||||
move_evidence_stream.as_mut(),
|
||||
)?;
|
||||
publish_quest_access_if_requested(&mut node, &options)?;
|
||||
let state = if active_stream_ids.is_empty() {
|
||||
"idle"
|
||||
|
|
@ -407,6 +435,7 @@ fn serve(options: Options) -> Result<()> {
|
|||
if options.interval_seconds.is_none()
|
||||
&& active_move_lights.is_empty()
|
||||
&& active_move_states.is_empty()
|
||||
&& active_move_marker_cameras.is_empty()
|
||||
&& active_capture_streams.is_empty()
|
||||
&& !has_platform_default_move_lights
|
||||
{
|
||||
|
|
@ -414,6 +443,7 @@ fn serve(options: Options) -> Result<()> {
|
|||
}
|
||||
let sleep = if !active_move_lights.is_empty()
|
||||
|| !active_move_states.is_empty()
|
||||
|| !active_move_marker_cameras.is_empty()
|
||||
|| !active_capture_streams.is_empty()
|
||||
|| has_platform_default_move_lights
|
||||
{
|
||||
|
|
@ -3167,7 +3197,10 @@ fn publish_obs_catalog(
|
|||
}
|
||||
|
||||
fn create_move_evidence_stream(options: &Options) -> Result<Option<ActiveMoveEvidenceStream>> {
|
||||
if options.move_state_sources.is_empty() && options.move_evidence_stream_id.is_none() {
|
||||
if options.move_state_sources.is_empty()
|
||||
&& options.move_marker_camera_sources.is_empty()
|
||||
&& options.move_evidence_stream_id.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
|
@ -3343,6 +3376,460 @@ trait MoveControllerStateReader {
|
|||
fn read_joystick_events(&mut self, joystick_path: &str) -> Result<Vec<JoystickEvent>>;
|
||||
}
|
||||
|
||||
trait MoveMarkerCameraFrameReader {
|
||||
fn read_luma_frame(
|
||||
&mut self,
|
||||
source: &MoveMarkerCameraSource,
|
||||
frame_source: &MoveMarkerFrameSource,
|
||||
) -> Result<Option<Vec<u8>>>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PlatformMoveMarkerCameraFrameReader;
|
||||
|
||||
impl MoveMarkerCameraFrameReader for PlatformMoveMarkerCameraFrameReader {
|
||||
#[cfg(unix)]
|
||||
fn read_luma_frame(
|
||||
&mut self,
|
||||
source: &MoveMarkerCameraSource,
|
||||
frame_source: &MoveMarkerFrameSource,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
read_v4l2_yuyv_luma_frame(source, frame_source)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn read_luma_frame(
|
||||
&mut self,
|
||||
source: &MoveMarkerCameraSource,
|
||||
_frame_source: &MoveMarkerFrameSource,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
Err(anyhow!(
|
||||
"Move marker camera source {} at {} requires a Unix V4L2 runtime",
|
||||
source.camera_id,
|
||||
source.device_path.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const V4L2_BUF_TYPE_VIDEO_CAPTURE: u32 = 1;
|
||||
#[cfg(unix)]
|
||||
const V4L2_FIELD_NONE: u32 = 1;
|
||||
#[cfg(unix)]
|
||||
const V4L2_MEMORY_MMAP: u32 = 1;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_G_FMT: libc::c_ulong = 0xC0D05604;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_S_FMT: libc::c_ulong = 0xC0D05605;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_REQBUFS: libc::c_ulong = 0xC0145608;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_QUERYBUF: libc::c_ulong = 0xC0585609;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_QBUF: libc::c_ulong = 0xC058560F;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_DQBUF: libc::c_ulong = 0xC0585611;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_STREAMON: libc::c_ulong = 0x40045612;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_STREAMOFF: libc::c_ulong = 0x40045613;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_G_PARM: libc::c_ulong = 0xC0CC5615;
|
||||
#[cfg(unix)]
|
||||
const VIDIOC_S_PARM: libc::c_ulong = 0xC0CC5616;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct V4l2PixFormat {
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixelformat: u32,
|
||||
field: u32,
|
||||
bytesperline: u32,
|
||||
sizeimage: u32,
|
||||
colorspace: u32,
|
||||
priv_: u32,
|
||||
flags: u32,
|
||||
ycbcr_enc: u32,
|
||||
quantization: u32,
|
||||
xfer_func: u32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
union V4l2FormatUnion {
|
||||
pix: V4l2PixFormat,
|
||||
raw: [u8; 200],
|
||||
align: u64,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
struct V4l2Format {
|
||||
type_: u32,
|
||||
fmt: V4l2FormatUnion,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
struct V4l2RequestBuffers {
|
||||
count: u32,
|
||||
type_: u32,
|
||||
memory: u32,
|
||||
capabilities: u32,
|
||||
flags: u8,
|
||||
reserved: [u8; 3],
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct V4l2Fract {
|
||||
numerator: u32,
|
||||
denominator: u32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct V4l2CaptureParm {
|
||||
capability: u32,
|
||||
capturemode: u32,
|
||||
timeperframe: V4l2Fract,
|
||||
extendedmode: u32,
|
||||
readbuffers: u32,
|
||||
reserved: [u32; 4],
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
union V4l2StreamParmUnion {
|
||||
capture: V4l2CaptureParm,
|
||||
raw: [u8; 200],
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
struct V4l2StreamParm {
|
||||
type_: u32,
|
||||
parm: V4l2StreamParmUnion,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct V4l2Timecode {
|
||||
type_: u32,
|
||||
flags: u32,
|
||||
frames: u8,
|
||||
seconds: u8,
|
||||
minutes: u8,
|
||||
hours: u8,
|
||||
userbits: [u8; 4],
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
union V4l2BufferUnion {
|
||||
offset: u32,
|
||||
userptr: libc::c_ulong,
|
||||
planes: *mut libc::c_void,
|
||||
fd: i32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[repr(C)]
|
||||
struct V4l2Buffer {
|
||||
index: u32,
|
||||
type_: u32,
|
||||
bytesused: u32,
|
||||
flags: u32,
|
||||
field: u32,
|
||||
timestamp: libc::timeval,
|
||||
timecode: V4l2Timecode,
|
||||
sequence: u32,
|
||||
memory: u32,
|
||||
m: V4l2BufferUnion,
|
||||
length: u32,
|
||||
reserved2: u32,
|
||||
request_fd: i32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct V4l2MappedBuffer {
|
||||
ptr: *mut libc::c_void,
|
||||
length: usize,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_v4l2_yuyv_luma_frame(
|
||||
source: &MoveMarkerCameraSource,
|
||||
frame_source: &MoveMarkerFrameSource,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let config = frame_source.tracker_config;
|
||||
if config.stride_bytes != config.width {
|
||||
return Err(anyhow!(
|
||||
"Move marker V4L2 reader emits compact Y8 frames, so stride {} must equal width {}",
|
||||
config.stride_bytes,
|
||||
config.width
|
||||
));
|
||||
}
|
||||
|
||||
let device = CString::new(source.device_path.as_os_str().as_bytes())
|
||||
.with_context(|| format!("invalid V4L2 device path {}", source.device_path.display()))?;
|
||||
let fd = unsafe { libc::open(device.as_ptr(), libc::O_RDWR | libc::O_NONBLOCK) };
|
||||
if fd < 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.with_context(|| format!("opening V4L2 camera {}", source.device_path.display()));
|
||||
}
|
||||
|
||||
let result = read_v4l2_yuyv_luma_frame_from_fd(fd, source, frame_source);
|
||||
let close_result = unsafe { libc::close(fd) };
|
||||
if close_result != 0 && result.is_ok() {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.with_context(|| format!("closing V4L2 camera {}", source.device_path.display()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_v4l2_yuyv_luma_frame_from_fd(
|
||||
fd: libc::c_int,
|
||||
source: &MoveMarkerCameraSource,
|
||||
frame_source: &MoveMarkerFrameSource,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let config = frame_source.tracker_config;
|
||||
let yuyv = fourcc("YUYV");
|
||||
let mut stream_on = false;
|
||||
let mut maps = Vec::new();
|
||||
let read_result = (|| -> Result<Option<Vec<u8>>> {
|
||||
set_v4l2_frame_interval(fd, frame_source.fps)?;
|
||||
|
||||
let mut fmt = zeroed_v4l2_format();
|
||||
fmt.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
ioctl_mut(fd, VIDIOC_G_FMT, &mut fmt).with_context(|| {
|
||||
format!("querying V4L2 format for {}", source.device_path.display())
|
||||
})?;
|
||||
fmt.fmt.pix.width = config.width;
|
||||
fmt.fmt.pix.height = config.height;
|
||||
fmt.fmt.pix.pixelformat = yuyv;
|
||||
fmt.fmt.pix.field = V4L2_FIELD_NONE;
|
||||
ioctl_mut(fd, VIDIOC_S_FMT, &mut fmt).with_context(|| {
|
||||
format!(
|
||||
"setting V4L2 YUYV format for {}",
|
||||
source.device_path.display()
|
||||
)
|
||||
})?;
|
||||
let actual = unsafe { fmt.fmt.pix };
|
||||
if actual.width != config.width || actual.height != config.height {
|
||||
return Err(anyhow!(
|
||||
"V4L2 camera {} returned {}x{}, expected {}x{}",
|
||||
source.device_path.display(),
|
||||
actual.width,
|
||||
actual.height,
|
||||
config.width,
|
||||
config.height
|
||||
));
|
||||
}
|
||||
if actual.pixelformat != yuyv {
|
||||
return Err(anyhow!(
|
||||
"V4L2 camera {} did not accept YUYV format",
|
||||
source.device_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut req = V4l2RequestBuffers {
|
||||
count: 2,
|
||||
type_: V4L2_BUF_TYPE_VIDEO_CAPTURE,
|
||||
memory: V4L2_MEMORY_MMAP,
|
||||
capabilities: 0,
|
||||
flags: 0,
|
||||
reserved: [0; 3],
|
||||
};
|
||||
ioctl_mut(fd, VIDIOC_REQBUFS, &mut req).with_context(|| {
|
||||
format!(
|
||||
"requesting V4L2 buffers for {}",
|
||||
source.device_path.display()
|
||||
)
|
||||
})?;
|
||||
if req.count == 0 {
|
||||
return Err(anyhow!(
|
||||
"V4L2 camera {} returned no mmap buffers",
|
||||
source.device_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
for index in 0..req.count {
|
||||
let mut buf = zeroed_v4l2_buffer();
|
||||
buf.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = index;
|
||||
ioctl_mut(fd, VIDIOC_QUERYBUF, &mut buf).with_context(|| {
|
||||
format!(
|
||||
"querying V4L2 buffer {index} for {}",
|
||||
source.device_path.display()
|
||||
)
|
||||
})?;
|
||||
let length = usize::try_from(buf.length).context("V4L2 buffer length overflow")?;
|
||||
let offset = unsafe { buf.m.offset };
|
||||
let ptr = unsafe {
|
||||
libc::mmap(
|
||||
std::ptr::null_mut(),
|
||||
length,
|
||||
libc::PROT_READ | libc::PROT_WRITE,
|
||||
libc::MAP_SHARED,
|
||||
fd,
|
||||
offset as libc::off_t,
|
||||
)
|
||||
};
|
||||
if ptr == libc::MAP_FAILED {
|
||||
return Err(std::io::Error::last_os_error()).with_context(|| {
|
||||
format!(
|
||||
"mapping V4L2 buffer {index} for {}",
|
||||
source.device_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
maps.push(V4l2MappedBuffer { ptr, length });
|
||||
ioctl_mut(fd, VIDIOC_QBUF, &mut buf).with_context(|| {
|
||||
format!(
|
||||
"queueing V4L2 buffer {index} for {}",
|
||||
source.device_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut capture_type = V4L2_BUF_TYPE_VIDEO_CAPTURE as libc::c_int;
|
||||
ioctl_mut(fd, VIDIOC_STREAMON, &mut capture_type).with_context(|| {
|
||||
format!("starting V4L2 stream for {}", source.device_path.display())
|
||||
})?;
|
||||
stream_on = true;
|
||||
|
||||
let mut poll_fd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let ready = unsafe { libc::poll(&mut poll_fd, 1, 250) };
|
||||
if ready < 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.with_context(|| format!("polling V4L2 camera {}", source.device_path.display()));
|
||||
}
|
||||
if ready == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut buf = zeroed_v4l2_buffer();
|
||||
buf.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
ioctl_mut(fd, VIDIOC_DQBUF, &mut buf)
|
||||
.with_context(|| format!("reading V4L2 frame from {}", source.device_path.display()))?;
|
||||
let index = usize::try_from(buf.index).context("V4L2 buffer index overflow")?;
|
||||
let mapping = maps.get(index).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"V4L2 camera {} returned unknown buffer index {}",
|
||||
source.device_path.display(),
|
||||
index
|
||||
)
|
||||
})?;
|
||||
let bytes_used = usize::try_from(buf.bytesused).context("V4L2 bytesused overflow")?;
|
||||
let raw_len = bytes_used.min(mapping.length);
|
||||
let raw = unsafe { std::slice::from_raw_parts(mapping.ptr as *const u8, raw_len) };
|
||||
Ok(Some(yuyv_to_compact_luma(
|
||||
raw,
|
||||
config.width,
|
||||
config.height,
|
||||
actual.bytesperline,
|
||||
)?))
|
||||
})();
|
||||
|
||||
if stream_on {
|
||||
let mut capture_type = V4L2_BUF_TYPE_VIDEO_CAPTURE as libc::c_int;
|
||||
let _ = ioctl_mut(fd, VIDIOC_STREAMOFF, &mut capture_type);
|
||||
}
|
||||
for mapping in maps {
|
||||
unsafe {
|
||||
libc::munmap(mapping.ptr, mapping.length);
|
||||
}
|
||||
}
|
||||
read_result
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_v4l2_frame_interval(fd: libc::c_int, fps: u32) -> Result<()> {
|
||||
let mut parm = zeroed_v4l2_stream_parm();
|
||||
parm.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
let _ = ioctl_mut(fd, VIDIOC_G_PARM, &mut parm);
|
||||
parm.parm.capture.timeperframe.numerator = 1;
|
||||
parm.parm.capture.timeperframe.denominator = fps.max(1);
|
||||
ioctl_mut(fd, VIDIOC_S_PARM, &mut parm)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn yuyv_to_compact_luma(raw: &[u8], width: u32, height: u32, stride_bytes: u32) -> Result<Vec<u8>> {
|
||||
let width = usize::try_from(width).context("YUYV width overflow")?;
|
||||
let height = usize::try_from(height).context("YUYV height overflow")?;
|
||||
let stride = usize::try_from(stride_bytes).context("YUYV stride overflow")?;
|
||||
let required = height
|
||||
.checked_sub(1)
|
||||
.and_then(|last_row| last_row.checked_mul(stride))
|
||||
.and_then(|last_row| last_row.checked_add(width.saturating_mul(2)))
|
||||
.unwrap_or(0);
|
||||
if raw.len() < required {
|
||||
return Err(anyhow!(
|
||||
"YUYV frame is too short: {} bytes, expected at least {}",
|
||||
raw.len(),
|
||||
required
|
||||
));
|
||||
}
|
||||
let mut luma = Vec::with_capacity(width.saturating_mul(height));
|
||||
for row in 0..height {
|
||||
let start = row * stride;
|
||||
let row_bytes = &raw[start..start + width * 2];
|
||||
luma.extend(row_bytes.iter().step_by(2).copied());
|
||||
}
|
||||
Ok(luma)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fourcc(value: &str) -> u32 {
|
||||
let bytes = value.as_bytes();
|
||||
u32::from(bytes[0])
|
||||
| (u32::from(bytes[1]) << 8)
|
||||
| (u32::from(bytes[2]) << 16)
|
||||
| (u32::from(bytes[3]) << 24)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ioctl_mut<T>(fd: libc::c_int, request: libc::c_ulong, value: &mut T) -> Result<()> {
|
||||
let result = unsafe { libc::ioctl(fd, request, value as *mut T) };
|
||||
if result == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::last_os_error()).context("V4L2 ioctl failed")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn zeroed_v4l2_format() -> V4l2Format {
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn zeroed_v4l2_stream_parm() -> V4l2StreamParm {
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn zeroed_v4l2_buffer() -> V4l2Buffer {
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
|
||||
struct HidMoveControllerStateReader;
|
||||
|
||||
impl MoveControllerStateReader for HidMoveControllerStateReader {
|
||||
|
|
@ -3587,6 +4074,72 @@ fn publish_move_controller_states(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn active_move_marker_camera_sources(options: &Options) -> Vec<ActiveMoveMarkerCameraSource> {
|
||||
options
|
||||
.move_marker_camera_sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let stream_id = format!(
|
||||
"muninn:{}:{}:move-marker-candidates",
|
||||
options.host_id, source.camera_id
|
||||
);
|
||||
ActiveMoveMarkerCameraSource {
|
||||
source: source.clone(),
|
||||
frame_source: MoveMarkerFrameSource {
|
||||
stream_id,
|
||||
host_id: options.host_id.clone(),
|
||||
camera_id: source.camera_id.clone(),
|
||||
fps: options.move_marker_fps,
|
||||
tracker_config: muninn_move_tracker::MoveTrackerConfig {
|
||||
width: options.move_marker_width,
|
||||
height: options.move_marker_height,
|
||||
stride_bytes: options
|
||||
.move_marker_stride_bytes
|
||||
.unwrap_or(options.move_marker_width),
|
||||
tile_size: 16,
|
||||
threshold_min: options.move_marker_threshold_min,
|
||||
min_area_px: options.move_marker_min_area_px,
|
||||
max_candidates: options.move_marker_max_candidates,
|
||||
source_id_hash: 0,
|
||||
frame_sequence: 0,
|
||||
},
|
||||
},
|
||||
sequence: 0,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn publish_move_marker_camera_frames(
|
||||
active: &mut [ActiveMoveMarkerCameraSource],
|
||||
reader: &mut impl MoveMarkerCameraFrameReader,
|
||||
move_evidence_stream: Option<&mut ActiveMoveEvidenceStream>,
|
||||
) -> Result<()> {
|
||||
let Some(stream) = move_evidence_stream else {
|
||||
return Ok(());
|
||||
};
|
||||
for camera in active {
|
||||
camera.sequence = camera.sequence.saturating_add(1);
|
||||
let mut frame_source = camera.frame_source.clone();
|
||||
frame_source.tracker_config.frame_sequence = camera.sequence;
|
||||
frame_source.tracker_config.source_id_hash =
|
||||
stable_marker_camera_source_hash(&frame_source);
|
||||
let Some(frame) = reader.read_luma_frame(&camera.source, &frame_source)? else {
|
||||
continue;
|
||||
};
|
||||
let observed_at = timestamp()?;
|
||||
publish_move_marker_candidates_from_luma_frame(stream, &frame_source, &frame, observed_at)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stable_marker_camera_source_hash(source: &MoveMarkerFrameSource) -> u64 {
|
||||
stable_u64_hash(&format!(
|
||||
"{}:{}:{}",
|
||||
source.host_id, source.camera_id, source.stream_id
|
||||
))
|
||||
}
|
||||
|
||||
fn put_hid_controller_state_receipt(
|
||||
node: &mut cultmesh_rs::CultMeshNode,
|
||||
options: &Options,
|
||||
|
|
@ -4966,12 +5519,17 @@ fn push_unique_light_target(
|
|||
}
|
||||
|
||||
fn default_move_color_for_identity(identity: &str) -> (u8, u8, u8) {
|
||||
let hash = stable_u64_hash(identity);
|
||||
DEFAULT_MOVE_LIGHT_COLORS[hash as usize % DEFAULT_MOVE_LIGHT_COLORS.len()]
|
||||
}
|
||||
|
||||
fn stable_u64_hash(value: &str) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in identity.as_bytes() {
|
||||
for byte in value.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
DEFAULT_MOVE_LIGHT_COLORS[hash as usize % DEFAULT_MOVE_LIGHT_COLORS.len()]
|
||||
if hash == 0 { 1 } else { hash }
|
||||
}
|
||||
|
||||
fn default_move_light_report(color: (u8, u8, u8), seconds: f64) -> [u8; PS_MOVE_LED_REPORT_LEN] {
|
||||
|
|
@ -7868,6 +8426,14 @@ impl Options {
|
|||
command_id: None,
|
||||
move_host_address: None,
|
||||
move_state_sources: Vec::new(),
|
||||
move_marker_camera_sources: Vec::new(),
|
||||
move_marker_width: 320,
|
||||
move_marker_height: 240,
|
||||
move_marker_stride_bytes: None,
|
||||
move_marker_fps: 187,
|
||||
move_marker_threshold_min: 180,
|
||||
move_marker_min_area_px: 4,
|
||||
move_marker_max_candidates: 64,
|
||||
move_evidence_stream_id: None,
|
||||
move_evidence_verse_id: "mimir-live".to_string(),
|
||||
move_evidence_ring_slots: 4,
|
||||
|
|
@ -8013,6 +8579,39 @@ impl Options {
|
|||
.move_state_sources
|
||||
.push(parse_move_state_source(&value)?);
|
||||
}
|
||||
"--move-marker-camera" => {
|
||||
let value = take_value(&mut args, "--move-marker-camera")?;
|
||||
options
|
||||
.move_marker_camera_sources
|
||||
.push(parse_move_marker_camera_source(&value)?);
|
||||
}
|
||||
"--move-marker-width" => {
|
||||
options.move_marker_width =
|
||||
take_value(&mut args, "--move-marker-width")?.parse()?
|
||||
}
|
||||
"--move-marker-height" => {
|
||||
options.move_marker_height =
|
||||
take_value(&mut args, "--move-marker-height")?.parse()?
|
||||
}
|
||||
"--move-marker-stride-bytes" => {
|
||||
options.move_marker_stride_bytes =
|
||||
Some(take_value(&mut args, "--move-marker-stride-bytes")?.parse()?);
|
||||
}
|
||||
"--move-marker-fps" => {
|
||||
options.move_marker_fps = take_value(&mut args, "--move-marker-fps")?.parse()?
|
||||
}
|
||||
"--move-marker-threshold-min" => {
|
||||
options.move_marker_threshold_min =
|
||||
take_value(&mut args, "--move-marker-threshold-min")?.parse()?
|
||||
}
|
||||
"--move-marker-min-area-px" => {
|
||||
options.move_marker_min_area_px =
|
||||
take_value(&mut args, "--move-marker-min-area-px")?.parse()?
|
||||
}
|
||||
"--move-marker-max-candidates" => {
|
||||
options.move_marker_max_candidates =
|
||||
take_value(&mut args, "--move-marker-max-candidates")?.parse()?
|
||||
}
|
||||
"--move-evidence-stream" => {
|
||||
options.move_evidence_stream_id =
|
||||
Some(take_value(&mut args, "--move-evidence-stream")?)
|
||||
|
|
@ -8166,6 +8765,33 @@ impl Options {
|
|||
"--move-evidence-slot-bytes must be greater than zero"
|
||||
));
|
||||
}
|
||||
if options.move_marker_width == 0 {
|
||||
return Err(anyhow!("--move-marker-width must be greater than zero"));
|
||||
}
|
||||
if options.move_marker_height == 0 {
|
||||
return Err(anyhow!("--move-marker-height must be greater than zero"));
|
||||
}
|
||||
if options
|
||||
.move_marker_stride_bytes
|
||||
.is_some_and(|stride| stride < options.move_marker_width)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"--move-marker-stride-bytes must be at least --move-marker-width"
|
||||
));
|
||||
}
|
||||
if options.move_marker_fps == 0 {
|
||||
return Err(anyhow!("--move-marker-fps must be greater than zero"));
|
||||
}
|
||||
if options.move_marker_min_area_px == 0 {
|
||||
return Err(anyhow!(
|
||||
"--move-marker-min-area-px must be greater than zero"
|
||||
));
|
||||
}
|
||||
if options.move_marker_max_candidates == 0 {
|
||||
return Err(anyhow!(
|
||||
"--move-marker-max-candidates must be greater than zero"
|
||||
));
|
||||
}
|
||||
for (name, value) in [
|
||||
("--quest-serial", options.quest_serial.as_ref()),
|
||||
(
|
||||
|
|
@ -8279,8 +8905,25 @@ fn parse_catalog_source(value: &str) -> Result<CatalogSource> {
|
|||
})
|
||||
}
|
||||
|
||||
fn parse_move_marker_camera_source(value: &str) -> Result<MoveMarkerCameraSource> {
|
||||
let Some((camera_id, device_path)) = value.split_once('=') else {
|
||||
return Err(anyhow!(
|
||||
"--move-marker-camera must be formatted as <camera-id>=<device-path>"
|
||||
));
|
||||
};
|
||||
if camera_id.trim().is_empty() || device_path.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"--move-marker-camera requires non-empty camera id and device path"
|
||||
));
|
||||
}
|
||||
Ok(MoveMarkerCameraSource {
|
||||
camera_id: camera_id.to_string(),
|
||||
device_path: PathBuf::from(device_path),
|
||||
})
|
||||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"Usage: muninn [serve|activate|request-stream|capture-stream-status|obs-catalog-status|request-move-light|move-light-status|move-identity-status|move-source-status|move-state-status|claim-move-host|quest-access-status] [--store <path>] [--activate-store <path>] [--stream-action <start|stop>] [--target-host <cultmesh-uri>] [--media-transport <rudp>] [--media-packet-bytes <bytes>] [--rudp-video-bitrate-kbps <kbps>] [--rudp-latency-budget-ms <ms>] [--video-source <source-id=label>] [--audio-source <source-id=label>] [--audio-source-id <source-id>] [--no-video] [--no-audio] [--loopback-script <path>] [--ffmpeg <path>] [--odin-cultmesh-uri <cultmesh-uri>] [--move-state <move-id>=<hidraw-path>] [--move-host <bt-addr>] [--move-evidence-stream <stream-id>] [--move-evidence-verse <verse-id>] [--quest-adb] [--quest-serial <serial>] [--quest-input-stream <stream-id>] [--quest-pose-stream <stream-id>] [--quest-video-input-stream <stream-id>] [--idunn-rudp-health <addr>] [--idunn-daemon <id>] [--idunn-health-contract <contract>] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances, optional Quest USB access surfaces, and the explicitly configured Move runtime; when serve receives --move-state, --move-host, or --move-evidence-stream it may publish source-local Move controller state, typed Move identity records, a CultMesh Move evidence stream, and keep USB-attached PS Moves claimed to that explicit Bluetooth host; serve consumes typed capture stream commands from Odin/CultMesh plus its local activation store and owns the local ffmpeg/loopback activation child lifecycle, resolves media targets from Odin/CultMesh provider advertisements, and pays startup respect to Odin through --odin-cultmesh-uri by publishing its provider advertisement once; activate starts an explicitly requested local CultNet RUDP stream as a daemon child after resolving the cultmesh:// media target URI through Odin; request-stream publishes a typed capture stream command through Odin/CultMesh using --odin-cultmesh-uri; obs-catalog-status pulls Odin-owned muninn.obs_stream_catalog discovery into the local compatibility store for OBS; capture-stream-status reads typed capture stream command receipts; use --no-video or --no-audio to request one leg over the CultNet RUDP media lane; request-move-light publishes a typed Move light command for Muninn serve to execute; move-light-status reads typed command receipts; move-identity-status reads typed Move identity records; move-source-status prints live Move source discovery; move-state-status reads typed controller-state records; claim-move-host assigns USB-attached PS Moves to a Bluetooth host; quest-access-status reads typed Quest access state. In --health mode, the Idunn RUDP flags publish the same typed daemon health document to Idunn for explicit diagnostics."
|
||||
"Usage: muninn [serve|activate|request-stream|capture-stream-status|obs-catalog-status|request-move-light|move-light-status|move-identity-status|move-source-status|move-state-status|claim-move-host|quest-access-status] [--store <path>] [--activate-store <path>] [--stream-action <start|stop>] [--target-host <cultmesh-uri>] [--media-transport <rudp>] [--media-packet-bytes <bytes>] [--rudp-video-bitrate-kbps <kbps>] [--rudp-latency-budget-ms <ms>] [--video-source <source-id=label>] [--audio-source <source-id=label>] [--audio-source-id <source-id>] [--no-video] [--no-audio] [--loopback-script <path>] [--ffmpeg <path>] [--odin-cultmesh-uri <cultmesh-uri>] [--move-state <move-id>=<hidraw-path>] [--move-marker-camera <camera-id>=<device-path>] [--move-marker-width <px>] [--move-marker-height <px>] [--move-marker-fps <fps>] [--move-host <bt-addr>] [--move-evidence-stream <stream-id>] [--move-evidence-verse <verse-id>] [--quest-adb] [--quest-serial <serial>] [--quest-input-stream <stream-id>] [--quest-pose-stream <stream-id>] [--quest-video-input-stream <stream-id>] [--idunn-rudp-health <addr>] [--idunn-daemon <id>] [--idunn-health-contract <contract>] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances, optional Quest USB access surfaces, and the explicitly configured Move runtime; when serve receives --move-state, --move-marker-camera, --move-host, or --move-evidence-stream it may publish source-local Move controller state, source-local optical marker candidates, typed Move identity records, a CultMesh Move evidence stream, and keep USB-attached PS Moves claimed to that explicit Bluetooth host; serve consumes typed capture stream commands from Odin/CultMesh plus its local activation store and owns the local ffmpeg/loopback activation child lifecycle, resolves media targets from Odin/CultMesh provider advertisements, and pays startup respect to Odin through --odin-cultmesh-uri by publishing its provider advertisement once; activate starts an explicitly requested local CultNet RUDP stream as a daemon child after resolving the cultmesh:// media target URI through Odin; request-stream publishes a typed capture stream command through Odin/CultMesh using --odin-cultmesh-uri; obs-catalog-status pulls Odin-owned muninn.obs_stream_catalog discovery into the local compatibility store for OBS; capture-stream-status reads typed capture stream command receipts; use --no-video or --no-audio to request one leg over the CultNet RUDP media lane; request-move-light publishes a typed Move light command for Muninn serve to execute; move-light-status reads typed command receipts; move-identity-status reads typed Move identity records; move-source-status prints live Move source discovery; move-state-status reads typed controller-state records; claim-move-host assigns USB-attached PS Moves to a Bluetooth host; quest-access-status reads typed Quest access state. In --health mode, the Idunn RUDP flags publish the same typed daemon health document to Idunn for explicit diagnostics."
|
||||
}
|
||||
|
||||
fn parse_move_state_source(value: &str) -> Result<MoveStateSource> {
|
||||
|
|
@ -10350,6 +10993,26 @@ Device 00:07:04:A8:00:D0 (public)
|
|||
failing_joystick_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingMoveMarkerCameraReader {
|
||||
frames: Vec<Vec<u8>>,
|
||||
configs: Vec<muninn_move_tracker::MoveTrackerConfig>,
|
||||
}
|
||||
|
||||
impl MoveMarkerCameraFrameReader for RecordingMoveMarkerCameraReader {
|
||||
fn read_luma_frame(
|
||||
&mut self,
|
||||
_source: &MoveMarkerCameraSource,
|
||||
frame_source: &MoveMarkerFrameSource,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
self.configs.push(frame_source.tracker_config);
|
||||
if self.frames.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(self.frames.remove(0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl MoveControllerStateReader for RecordingMoveStateReader {
|
||||
fn read_report(&mut self, _hidraw_path: &str) -> Result<Option<Vec<u8>>> {
|
||||
if self.reports.is_empty() {
|
||||
|
|
@ -11150,6 +11813,7 @@ Device 00:07:04:A8:00:D0 (public)
|
|||
stream_id: "muninn:nightwing:ps3eye0:move-markers".to_string(),
|
||||
host_id: "nightwing".to_string(),
|
||||
camera_id: "ps3eye0".to_string(),
|
||||
fps: 187,
|
||||
tracker_config: muninn_move_tracker::MoveTrackerConfig {
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
|
@ -11206,6 +11870,125 @@ Device 00:07:04:A8:00:D0 (public)
|
|||
assert_eq!(marker.observed_at, "unix-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_marker_camera_option_builds_owned_evidence_source() {
|
||||
let options = Options::parse(
|
||||
[
|
||||
"serve",
|
||||
"--host",
|
||||
"nightwing",
|
||||
"--move-marker-camera",
|
||||
"ps3eye0=/dev/video0",
|
||||
"--move-marker-width",
|
||||
"32",
|
||||
"--move-marker-height",
|
||||
"32",
|
||||
"--move-marker-fps",
|
||||
"120",
|
||||
"--move-marker-threshold-min",
|
||||
"170",
|
||||
"--move-marker-min-area-px",
|
||||
"3",
|
||||
"--move-marker-max-candidates",
|
||||
"9",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(options.move_marker_camera_sources.len(), 1);
|
||||
assert_eq!(options.move_marker_camera_sources[0].camera_id, "ps3eye0");
|
||||
assert_eq!(
|
||||
options.move_marker_camera_sources[0].device_path,
|
||||
PathBuf::from("/dev/video0")
|
||||
);
|
||||
let active = active_move_marker_camera_sources(&options);
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(
|
||||
active[0].frame_source.stream_id,
|
||||
"muninn:nightwing:ps3eye0:move-marker-candidates"
|
||||
);
|
||||
assert_eq!(active[0].frame_source.fps, 120);
|
||||
assert_eq!(active[0].frame_source.tracker_config.width, 32);
|
||||
assert_eq!(active[0].frame_source.tracker_config.height, 32);
|
||||
assert_eq!(active[0].frame_source.tracker_config.threshold_min, 170);
|
||||
assert_eq!(active[0].frame_source.tracker_config.min_area_px, 3);
|
||||
assert_eq!(active[0].frame_source.tracker_config.max_candidates, 9);
|
||||
assert!(
|
||||
create_move_evidence_stream(&options).unwrap().is_some(),
|
||||
"marker camera alone should create the evidence stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_marker_camera_tick_publishes_extracted_evidence_frame() {
|
||||
let options = Options::parse(
|
||||
[
|
||||
"serve",
|
||||
"--host",
|
||||
"nightwing",
|
||||
"--move-marker-camera",
|
||||
"ps3eye0=/dev/video0",
|
||||
"--move-evidence-stream",
|
||||
"muninn:nightwing:move-evidence",
|
||||
"--move-marker-width",
|
||||
"32",
|
||||
"--move-marker-height",
|
||||
"32",
|
||||
"--move-marker-threshold-min",
|
||||
"180",
|
||||
"--move-marker-min-area-px",
|
||||
"4",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from),
|
||||
)
|
||||
.unwrap();
|
||||
let mut y8 = vec![0u8; 32 * 32];
|
||||
for y in 8..12 {
|
||||
for x in 10..14 {
|
||||
y8[y * 32 + x] = 255;
|
||||
}
|
||||
}
|
||||
let mut active = active_move_marker_camera_sources(&options);
|
||||
let mut reader = RecordingMoveMarkerCameraReader {
|
||||
frames: vec![y8],
|
||||
configs: Vec::new(),
|
||||
};
|
||||
let mut stream = create_move_evidence_stream(&options)
|
||||
.unwrap()
|
||||
.expect("marker camera should create stream");
|
||||
|
||||
publish_move_marker_camera_frames(&mut active, &mut reader, Some(&mut stream)).unwrap();
|
||||
|
||||
assert_eq!(active[0].sequence, 1);
|
||||
assert_eq!(reader.configs.len(), 1);
|
||||
assert_eq!(reader.configs[0].frame_sequence, 1);
|
||||
assert_ne!(reader.configs[0].source_id_hash, 0);
|
||||
let lease = stream
|
||||
.catalog
|
||||
.ring("muninn:nightwing:move-evidence")
|
||||
.and_then(CultMeshSharedMemoryFrameRing::try_acquire_latest_read)
|
||||
.expect("latest marker evidence frame should be readable");
|
||||
let decoded: DecodedMoveEvidenceStreamFrame = rmp_serde::from_slice(lease.bytes()).unwrap();
|
||||
|
||||
assert_eq!(decoded.0, "muninn:nightwing:move-evidence:0");
|
||||
assert_eq!(decoded.1, "muninn:nightwing");
|
||||
assert_eq!(decoded.3.len(), 1);
|
||||
assert!(decoded.4.is_empty());
|
||||
assert_eq!(
|
||||
decoded.3[0].stream_id,
|
||||
"muninn:nightwing:ps3eye0:move-marker-candidates"
|
||||
);
|
||||
assert_eq!(decoded.3[0].camera_id, "ps3eye0");
|
||||
assert_eq!(decoded.3[0].frame_sequence, 1);
|
||||
assert_eq!(
|
||||
decoded.3[0].source_id_hash,
|
||||
reader.configs[0].source_id_hash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_controller_state_projects_raw_hid_report() {
|
||||
let options = Options::parse(
|
||||
|
|
|
|||
Loading…
Reference in a new issue