Add Rust Move sphere candidate tracker

This commit is contained in:
Meta Vens 2026-06-10 20:35:35 +01:00
commit c2e5d03764
9 changed files with 474 additions and 0 deletions

View file

@ -50,6 +50,12 @@ a named invariant that the native runtime cannot protect yet.
owner. It launches `Mimir.PsMoveProbe` in steady hold mode so the locally
attached `VID_054C/PID_03D5` Move sphere stays lit for calibration/tracking
receipts.
- `native/move_tracker` is the first native/Rust Move optical candidate
extractor. Rust owns dispatch planning, FFI, validation, and a deterministic
CPU mirror; `shaders/MoveSphereCandidate.comp.hlsl` owns one 16px tile per
thread group and emits weighted bright-sphere candidates. This is marker
extraction only, not final pose, stereo triangulation, calibration,
association, IMU fusion, or prediction.
- `MimirProcessStreamSource` for bridge/network command edges.
- `MimirFrameEventProcessStreamSource` for temporary JSON-line frame metadata
from native probes into the same rolling buffers Fensalir inspects. One probe

7
native/move_tracker/Cargo.lock generated Normal file
View file

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "mimir-move-tracker"
version = "0.1.0"

View file

@ -0,0 +1,10 @@
[package]
name = "mimir-move-tracker"
version = "0.1.0"
edition = "2024"
description = "Rust orchestration and CPU mirror for PS Move sphere tracking compute"
[lib]
crate-type = ["rlib", "cdylib", "staticlib"]
[dependencies]

View file

@ -0,0 +1,23 @@
# Mimir Move Tracker
Rust owns orchestration for PS Move sphere tracking. The compute shader owns the
parallel image reduction stage. This crate deliberately stops at per-frame
marker candidates: final pose, stereo triangulation, IMU fusion, prediction,
and calibration remain separate owners.
## Current Contract
- Input: one luma frame (`Y8`) with width, height, stride, source hash, and frame
sequence.
- GPU stage: one 16px tile per thread group, emitting at most one candidate per
tile.
- Output: `MoveMarkerCandidate` records containing weighted centroid, radius,
area, peak/mean luma, and score.
- Rust mirror: deterministic CPU implementation and FFI wrappers for tests and
non-GPU smoke paths.
- Shader IDs are carried as `uint2` low/high pairs to avoid making 64-bit HLSL
integer support a dependency of the first extraction pass.
This is the first stage required for PlayStation-class wand tracking. The
remaining stack needs calibrated camera rays, controlled sphere colors/exposure,
multi-camera association, triangulation, IMU fusion, and latency prediction.

View file

@ -0,0 +1,104 @@
// Mimir PS Move sphere candidate extraction.
// One thread group owns one image tile. The shader emits at most one bright
// marker candidate per 16px tile; Rust owns dispatch sizing and downstream ranking.
struct MoveSphereCandidate
{
uint2 sourceIdHash;
uint2 frameSequence;
uint tileX;
uint tileY;
float centerXPx;
float centerYPx;
float radiusPx;
uint areaPx;
float meanLuma;
uint peakLuma;
float score;
};
cbuffer MoveTrackerConfig : register(b0)
{
uint width;
uint height;
uint strideBytes;
uint tileSize;
uint thresholdMin;
uint minAreaPx;
uint maxCandidates;
uint _pad0;
uint2 sourceIdHash;
uint2 frameSequence;
};
ByteAddressBuffer frameY8 : register(t0);
RWStructuredBuffer<MoveSphereCandidate> candidates : register(u0);
RWByteAddressBuffer candidateCounter : register(u1);
groupshared uint gArea;
groupshared uint gSumX;
groupshared uint gSumY;
groupshared uint gSumW;
groupshared uint gPeak;
[numthreads(16, 16, 1)]
void main(uint3 groupId : SV_GroupID, uint3 groupThreadId : SV_GroupThreadID)
{
if (groupThreadId.x == 0 && groupThreadId.y == 0)
{
gArea = 0;
gSumX = 0;
gSumY = 0;
gSumW = 0;
gPeak = 0;
}
GroupMemoryBarrierWithGroupSync();
uint x = groupId.x * tileSize + groupThreadId.x;
uint y = groupId.y * tileSize + groupThreadId.y;
if (groupThreadId.x < tileSize && groupThreadId.y < tileSize && x < width && y < height)
{
uint byteOffset = y * strideBytes + x;
uint packed = frameY8.Load(byteOffset & ~3u);
uint shift = (byteOffset & 3u) * 8u;
uint luma = (packed >> shift) & 0xffu;
if (luma >= thresholdMin)
{
uint weight = luma - thresholdMin + 1u;
InterlockedAdd(gArea, 1u);
InterlockedAdd(gSumX, x * weight);
InterlockedAdd(gSumY, y * weight);
InterlockedAdd(gSumW, weight);
InterlockedMax(gPeak, luma);
}
}
GroupMemoryBarrierWithGroupSync();
if (groupThreadId.x == 0 && groupThreadId.y == 0 && gArea >= minAreaPx && gSumW > 0)
{
uint index;
candidateCounter.InterlockedAdd(0, 1, index);
if (index < maxCandidates)
{
float area = (float)gArea;
float tilePixels = (float)(tileSize * tileSize);
float areaScore = min(1.0, area / tilePixels);
float brightnessScore = (float)gPeak / 255.0;
MoveSphereCandidate candidate;
candidate.sourceIdHash = sourceIdHash;
candidate.frameSequence = frameSequence;
candidate.tileX = groupId.x;
candidate.tileY = groupId.y;
candidate.centerXPx = (float)gSumX / (float)gSumW;
candidate.centerYPx = (float)gSumY / (float)gSumW;
candidate.radiusPx = sqrt(area / 3.14159265358979323846);
candidate.areaPx = gArea;
candidate.meanLuma = (float)thresholdMin + (((float)gSumW / area) - 1.0);
candidate.peakLuma = gPeak;
candidate.score = brightnessScore * (0.65 + areaScore * 0.35);
candidates[index] = candidate;
}
}
}

View file

@ -0,0 +1,308 @@
#![deny(unsafe_op_in_unsafe_fn)]
use core::ffi::c_int;
pub const MIMIR_MOVE_TRACKER_OK: c_int = 0;
pub const MIMIR_MOVE_TRACKER_NULL: c_int = -1;
pub const MIMIR_MOVE_TRACKER_INVALID: c_int = -2;
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MoveTrackerConfig {
pub width: u32,
pub height: u32,
pub stride_bytes: u32,
pub tile_size: u32,
pub threshold_min: u8,
pub min_area_px: u32,
pub max_candidates: u32,
pub source_id_hash: u64,
pub frame_sequence: u64,
}
impl Default for MoveTrackerConfig {
fn default() -> Self {
Self {
width: 320,
height: 240,
stride_bytes: 320,
tile_size: 16,
threshold_min: 180,
min_area_px: 4,
max_candidates: 64,
source_id_hash: 0,
frame_sequence: 0,
}
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MoveTrackerDispatchPlan {
pub group_size_x: u32,
pub group_size_y: u32,
pub groups_x: u32,
pub groups_y: u32,
pub max_candidates: u32,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MoveMarkerCandidate {
pub source_id_hash: u64,
pub frame_sequence: u64,
pub tile_x: u32,
pub tile_y: u32,
pub center_x_px: f32,
pub center_y_px: f32,
pub radius_px: f32,
pub area_px: u32,
pub mean_luma: f32,
pub peak_luma: u32,
pub score: f32,
}
pub fn dispatch_plan(config: MoveTrackerConfig) -> Option<MoveTrackerDispatchPlan> {
if !config_is_valid(config) {
return None;
}
let tile = config.tile_size;
Some(MoveTrackerDispatchPlan {
group_size_x: tile,
group_size_y: tile,
groups_x: config.width.div_ceil(tile),
groups_y: config.height.div_ceil(tile),
max_candidates: config.max_candidates,
})
}
pub fn extract_luma_candidates(
frame: &[u8],
config: MoveTrackerConfig,
) -> Option<Vec<MoveMarkerCandidate>> {
if !config_is_valid(config) {
return None;
}
let required = required_frame_bytes(config)?;
if frame.len() < required {
return None;
}
let mut candidates = Vec::new();
let tile = config.tile_size;
let groups_x = config.width.div_ceil(tile);
let groups_y = config.height.div_ceil(tile);
for tile_y in 0..groups_y {
for tile_x in 0..groups_x {
if candidates.len() >= config.max_candidates as usize {
return Some(candidates);
}
if let Some(candidate) = extract_tile(frame, config, tile_x, tile_y) {
candidates.push(candidate);
}
}
}
candidates.sort_by(|left, right| {
right
.score
.partial_cmp(&left.score)
.unwrap_or(core::cmp::Ordering::Equal)
});
Some(candidates)
}
fn extract_tile(
frame: &[u8],
config: MoveTrackerConfig,
tile_x: u32,
tile_y: u32,
) -> Option<MoveMarkerCandidate> {
let x0 = tile_x * config.tile_size;
let y0 = tile_y * config.tile_size;
let x1 = (x0 + config.tile_size).min(config.width);
let y1 = (y0 + config.tile_size).min(config.height);
let threshold = u32::from(config.threshold_min);
let mut area = 0u32;
let mut sum_x = 0u64;
let mut sum_y = 0u64;
let mut sum_w = 0u64;
let mut peak = 0u32;
for y in y0..y1 {
let row = y as usize * config.stride_bytes as usize;
for x in x0..x1 {
let luma = u32::from(frame[row + x as usize]);
if luma < threshold {
continue;
}
area += 1;
peak = peak.max(luma);
let weight = u64::from(luma - threshold + 1);
sum_x += u64::from(x) * weight;
sum_y += u64::from(y) * weight;
sum_w += weight;
}
}
if area < config.min_area_px || sum_w == 0 {
return None;
}
let center_x = sum_x as f32 / sum_w as f32;
let center_y = sum_y as f32 / sum_w as f32;
let radius = (area as f32 / core::f32::consts::PI).sqrt();
let mean_luma = threshold as f32 + ((sum_w as f32 / area as f32) - 1.0);
let area_score = (area as f32 / (config.tile_size * config.tile_size) as f32).min(1.0);
let brightness_score = peak as f32 / 255.0;
Some(MoveMarkerCandidate {
source_id_hash: config.source_id_hash,
frame_sequence: config.frame_sequence,
tile_x,
tile_y,
center_x_px: center_x,
center_y_px: center_y,
radius_px: radius,
area_px: area,
mean_luma,
peak_luma: peak,
score: brightness_score * (0.65 + area_score * 0.35),
})
}
fn config_is_valid(config: MoveTrackerConfig) -> bool {
config.width > 0
&& config.height > 0
&& config.stride_bytes >= config.width
&& config.tile_size == 16
&& config.min_area_px > 0
&& config.max_candidates > 0
}
fn required_frame_bytes(config: MoveTrackerConfig) -> Option<usize> {
let rows = usize::try_from(config.height).ok()?;
let stride = usize::try_from(config.stride_bytes).ok()?;
rows.checked_mul(stride)
}
#[unsafe(no_mangle)]
pub extern "C" fn mimir_move_tracker_dispatch_plan(
config: MoveTrackerConfig,
out_plan: *mut MoveTrackerDispatchPlan,
) -> c_int {
if out_plan.is_null() {
return MIMIR_MOVE_TRACKER_NULL;
}
let Some(plan) = dispatch_plan(config) else {
return MIMIR_MOVE_TRACKER_INVALID;
};
unsafe {
*out_plan = plan;
}
MIMIR_MOVE_TRACKER_OK
}
#[unsafe(no_mangle)]
pub extern "C" fn mimir_move_tracker_extract_luma_cpu(
config: MoveTrackerConfig,
frame: *const u8,
frame_len: usize,
out_candidates: *mut MoveMarkerCandidate,
out_capacity: usize,
out_count: *mut usize,
) -> c_int {
if frame.is_null() || out_candidates.is_null() || out_count.is_null() {
return MIMIR_MOVE_TRACKER_NULL;
}
let frame = unsafe { core::slice::from_raw_parts(frame, frame_len) };
let Some(candidates) = extract_luma_candidates(frame, config) else {
return MIMIR_MOVE_TRACKER_INVALID;
};
let written = candidates.len().min(out_capacity);
unsafe {
core::ptr::copy_nonoverlapping(candidates.as_ptr(), out_candidates, written);
*out_count = written;
}
MIMIR_MOVE_TRACKER_OK
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_plan_covers_partial_tiles() {
let config = MoveTrackerConfig {
width: 33,
height: 17,
stride_bytes: 40,
tile_size: 16,
..MoveTrackerConfig::default()
};
let plan = dispatch_plan(config).expect("valid plan");
assert_eq!(3, plan.groups_x);
assert_eq!(2, plan.groups_y);
assert_eq!(16, plan.group_size_x);
assert_eq!(16, plan.group_size_y);
}
#[test]
fn extracts_weighted_marker_candidate() {
let config = MoveTrackerConfig {
width: 32,
height: 24,
stride_bytes: 32,
tile_size: 16,
threshold_min: 180,
min_area_px: 3,
max_candidates: 8,
source_id_hash: 42,
frame_sequence: 7,
};
let mut frame = vec![0u8; (config.stride_bytes * config.height) as usize];
for (x, y, luma) in [
(10u32, 8u32, 230u8),
(11, 8, 250),
(10, 9, 240),
(11, 9, 255),
] {
frame[(y * config.stride_bytes + x) as usize] = luma;
}
let candidates = extract_luma_candidates(&frame, config).expect("candidates");
assert_eq!(1, candidates.len());
let candidate = candidates[0];
assert_eq!(42, candidate.source_id_hash);
assert_eq!(7, candidate.frame_sequence);
assert_eq!(0, candidate.tile_x);
assert_eq!(0, candidate.tile_y);
assert_eq!(4, candidate.area_px);
assert_eq!(255, candidate.peak_luma);
assert!(candidate.center_x_px > 10.4 && candidate.center_x_px < 10.7);
assert!(candidate.center_y_px > 8.4 && candidate.center_y_px < 8.7);
assert!(candidate.score > 0.65);
}
#[test]
fn rejects_short_frames_and_empty_config() {
assert!(extract_luma_candidates(&[], MoveTrackerConfig::default()).is_none());
assert!(
dispatch_plan(MoveTrackerConfig {
width: 0,
..MoveTrackerConfig::default()
})
.is_none()
);
}
}

View file

@ -136,6 +136,14 @@ That launcher runs `Mimir.PsMoveProbe` against the local Windows HID col01
output collection and refreshes the PS Move LED report so the USB-attached Move
stays visible as a tracking/calibration marker.
`native/move_tracker` owns the first native/GPU-shaped Move optical extraction
stage. Rust owns dispatch planning, FFI, configuration validation, and a
deterministic CPU mirror; `MoveSphereCandidate.comp.hlsl` owns the parallel
16px-tile luma reduction that emits ranked sphere candidates. This stage owns
per-frame marker candidates only. Stereo triangulation, camera calibration,
controller association, IMU fusion, prediction, and final 6DoF pose remain
separate later owners.
## Program Composition
[[docs/mimir-program-composition|Mimir Program Composition]] is the live

View file

@ -126,3 +126,4 @@
{"ts": "2026-06-10T16:41:42+01:00", "type": "implementation", "status": "ok", "note": "Added typed PS Move tracking observations as Mimir runtime streams: MimirStreamKind.Tracking, mimir.move_tracking_observation.v1, local Starfire and remote Nightwing Move capture profiles, Muninn producer identity, Odin discovery provenance, runtime buffer readout, and BufferSmoke proofs for Starfire/Nightwing USB-attached Moves entering Mimir buffers."}
{"ts": "2026-06-10T17:38:22+01:00", "type": "verification", "status": "ok", "note": "Restored and launched the narrow Nightwing Move tracking witness path. scripts/start-nightwing-move-tracking.ps1 started Mimir.EveSensorReceiver on Starfire port 8796, staged Nightwing Eye/Move Python tools, launched Nightwing worker PID 1131578 with --move-light move-usb=/dev/hidraw1:#35ff6c, and VerseRecorder C:\\Users\\Meta\\Videos\\Mimir\\VerseCaptures\\20260610-173549 recorded mimir.psmove_light_state.v1 HID success plus mimir.move_controller_observation_state.v1 blobs from both ps3-eye-0 and ps3-eye-1."}
{"ts": "2026-06-10T18:11:18+01:00", "type": "verification", "status": "ok", "note": "Started the Starfire-local USB Move light owner. Windows sees Sony VID_054C/PID_03D5 with col01 output reports; scripts/start-starfire-move-light.ps1 launched Mimir.PsMoveProbe PID 27344 with --rgb #35ff6c --hold-seconds 0 --refresh-ms 250, and artifacts/runtime/starfire-move-light-20260610-180943/move-light-events.jsonl shows repeated mimir.psmove_usb_pulse_event.v1 hold writes succeeding with ok rgb=#35FF6C bytes=49."}
{"ts": "2026-06-10T20:33:54+01:00", "type": "implementation", "status": "ok", "note": "Added native/move_tracker as Rust-orchestrated PS Move optical candidate extraction: Rust owns dispatch/FFI/CPU mirror; HLSL owns 16px-tile luma reduction. Scope is marker candidates only, before calibration/triangulation/IMU fusion/prediction."}

View file

@ -91,6 +91,13 @@ accepted_design:
blob extraction, and the stream currently emits `mimir.psmove_light_state.v1`
plus `mimir.move_controller_observation_state.v1` optical witness records
before later fusion into full tracking poses.
- `native/move_tracker` owns the first Rust/native Move optical candidate
stage: Rust validates config, plans dispatch, exposes FFI, and mirrors the
compute path on CPU; `MoveSphereCandidate.comp.hlsl` reduces one 16px luma
tile per thread group into weighted sphere candidates. This stage emits
marker candidates only. Calibration, multi-camera association,
triangulation, IMU fusion, prediction, and final 6DoF pose are still
separate owners.
- `MimirFrameEventProcessStreamSource` is a temporary diagnostic adapter for
JSON-line video frame and audio block metadata from native probes into
runtime rolling buffers; it does not carry pixels/audio payloads or own the