Add Mimir-owned Move calibration sessions
This commit is contained in:
parent
11e3086ee4
commit
c01ef5096c
4 changed files with 391 additions and 38 deletions
|
|
@ -165,6 +165,16 @@ if (args.Any(arg => string.Equals(arg, "--move-stereo-calibration-assessment", S
|
|||
ParseDoubleOption(args, "--orb-radius-meters", 0.0));
|
||||
}
|
||||
|
||||
if (args.Any(arg => string.Equals(arg, "--sensor-calibration-session-live", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return RunSensorCalibrationSessionLive(
|
||||
ParseStringOption(args, "--odin-cultmesh-uri", "cultmesh://127.0.0.1:17871/rendezvous/provider-catalog"),
|
||||
ParseIntOption(args, "--duration-seconds", 120),
|
||||
ParseStringOption(args, "--output-prefix", "artifacts/move-calibration/live-session"),
|
||||
ParseRepeatedStringOption(args, "--wand-id", [
|
||||
"move-0006f523e2d1", "move-000704a39772", "move-000704a6be5f", "move-000704a800d0"]));
|
||||
}
|
||||
|
||||
if (args.Any(arg => string.Equals(arg, "--move-fusion-smoke", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return RunMoveFusionSmoke();
|
||||
|
|
@ -2130,6 +2140,126 @@ static int RunMuninnMoveVisibilityWindowSmoke(string odinCultMeshUri, int durati
|
|||
}
|
||||
}
|
||||
|
||||
static int RunSensorCalibrationSessionLive(
|
||||
string odinCultMeshUri,
|
||||
int durationSeconds,
|
||||
string outputPrefix,
|
||||
IReadOnlyCollection<string> wandIds)
|
||||
{
|
||||
const string providerId = "muninn.telemetry.nightwing";
|
||||
const string streamId = "muninn:nightwing:move-evidence";
|
||||
var cameraIds = new[] { "nightwing-eye-0", "nightwing-eye-1" };
|
||||
var configuration = new MimirMoveProofRuntimeConfiguration
|
||||
{
|
||||
OdinCultMeshUri = odinCultMeshUri,
|
||||
MuninnProviderId = providerId,
|
||||
EvidenceStreamId = streamId
|
||||
};
|
||||
if (!MimirOdinMoveProofEvidenceRingProvider.Instance.TryOpenEvidenceRing(configuration, out var lease, out var diagnostic) || lease is null)
|
||||
throw new InvalidOperationException($"{providerId}: {diagnostic}");
|
||||
|
||||
using (lease)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var session = MimirSensorCalibrationSessions.CreateFourWandOpticalSession(
|
||||
wandIds, cameraIds, startedAt, Math.Max(1, durationSeconds));
|
||||
var observations = new List<MoveVisibilityObservation>();
|
||||
var seenFrames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var deadline = startedAt + TimeSpan.FromSeconds(Math.Max(1, durationSeconds));
|
||||
var lastCheckpoint = DateTimeOffset.MinValue;
|
||||
var evidencePath = $"{outputPrefix}.evidence.mpack";
|
||||
var sessionPath = $"{outputPrefix}.session.mpack";
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(sessionPath))!);
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline && session.Phase == MimirSensorCalibrationSessionPhase.Collecting)
|
||||
{
|
||||
if (lease.Ring.TryAcquireLatestRead(out var frameLease))
|
||||
{
|
||||
using (frameLease)
|
||||
{
|
||||
var frame = MimirMuninnMoveEvidenceAdapter.DeserializeStreamFrame(
|
||||
frameLease.Memory[..frameLease.Handle.ByteLength]);
|
||||
if (seenFrames.Add(frame.FrameId))
|
||||
{
|
||||
observations.AddRange(frame.MarkerCandidates
|
||||
.Where(marker => !string.IsNullOrWhiteSpace(marker.MoveId))
|
||||
.Select(marker => new MoveVisibilityObservation(
|
||||
providerId, marker.CameraId, marker.MoveId, frame.FrameId,
|
||||
frame.PublishedAtNs, marker.CenterXPx, marker.CenterYPx,
|
||||
marker.RadiusPx, marker.Score)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if ((now - lastCheckpoint).TotalSeconds >= 1.0)
|
||||
{
|
||||
var evidence = BuildVisibilityReceipt(startedAt, now, providerId, cameraIds, observations);
|
||||
session = MimirSensorCalibrationSessions.UpdateCollection(session, evidence, 640, 480, now);
|
||||
File.WriteAllBytes(evidencePath, MessagePackSerializer.Serialize(evidence));
|
||||
File.WriteAllBytes(sessionPath, MessagePackSerializer.Serialize(session));
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
session.SessionId,
|
||||
session.Phase,
|
||||
elapsedSeconds = Math.Round((now - startedAt).TotalSeconds, 1),
|
||||
frames = seenFrames.Count,
|
||||
observations = observations.Count,
|
||||
correspondences = evidence.Correspondences.Length,
|
||||
sensors = session.Sensors.Select(sensor => new
|
||||
{
|
||||
sensor.SensorId,
|
||||
sensor.ObservationCount,
|
||||
sensor.OccupiedGridCells,
|
||||
sensor.DistinctWandCount,
|
||||
radiusRatio = sensor.ObservationCount == 0 ? 0.0 : Math.Round(sensor.MaximumRadiusPx / sensor.MinimumRadiusPx, 2),
|
||||
sensor.MissingGridCells,
|
||||
sensor.CollectionComplete
|
||||
}),
|
||||
session.MissingRequirements
|
||||
}));
|
||||
lastCheckpoint = now;
|
||||
}
|
||||
Thread.Sleep(5);
|
||||
}
|
||||
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
var finalEvidence = BuildVisibilityReceipt(startedAt, completedAt, providerId, cameraIds, observations);
|
||||
session = MimirSensorCalibrationSessions.UpdateCollection(session, finalEvidence, 640, 480, completedAt);
|
||||
File.WriteAllBytes(evidencePath, MessagePackSerializer.Serialize(finalEvidence));
|
||||
File.WriteAllBytes(sessionPath, MessagePackSerializer.Serialize(session));
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { final = true, session.SessionId, session.Phase, evidencePath, sessionPath, session.MissingRequirements }));
|
||||
return session.Phase == MimirSensorCalibrationSessionPhase.Fitting ? 0 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
static MoveVisibilityWindowReceipt BuildVisibilityReceipt(
|
||||
DateTimeOffset startedAt,
|
||||
DateTimeOffset endedAt,
|
||||
string providerId,
|
||||
IReadOnlyList<string> cameraIds,
|
||||
IReadOnlyCollection<MoveVisibilityObservation> observations)
|
||||
{
|
||||
var observationArray = observations.ToArray();
|
||||
var correspondences = observationArray
|
||||
.GroupBy(observation => (observation.FrameId, observation.MoveId))
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.FirstOrDefault(value => value.CameraId == cameraIds[0]);
|
||||
var second = group.FirstOrDefault(value => value.CameraId == cameraIds[1]);
|
||||
return first is null || second is null ? null : new MoveCrossCameraCorrespondence(
|
||||
group.Key.MoveId, first, second, Math.Abs(first.PublishedAtNs - second.PublishedAtNs));
|
||||
})
|
||||
.Where(value => value is not null)
|
||||
.Cast<MoveCrossCameraCorrespondence>()
|
||||
.ToArray();
|
||||
return new MoveVisibilityWindowReceipt(
|
||||
"mimir.move_visibility_window.v1",
|
||||
startedAt.ToUnixTimeMilliseconds() * 1_000_000,
|
||||
endedAt.ToUnixTimeMilliseconds() * 1_000_000,
|
||||
[providerId], observationArray, correspondences);
|
||||
}
|
||||
|
||||
static int RunMoveStereoCalibrationAssessment(
|
||||
string inputPath,
|
||||
string outputPath,
|
||||
|
|
@ -3361,7 +3491,7 @@ static int RunMoveProofPipelineSmoke(string nativeReservoirPath)
|
|||
|
||||
static async Task<int> RunMoveCalibrationProtocolSmokeAsync(string outputPath)
|
||||
{
|
||||
var protocol = MimirMoveCalibrationProtocol.CreateStarfireNightwingProtocol(
|
||||
var protocol = MimirMoveCalibrationProtocol.CreateNightwingFourWandProtocol(
|
||||
new DateTimeOffset(2026, 6, 12, 14, 45, 0, TimeSpan.Zero));
|
||||
var errors = MimirMoveCalibrationProtocol.Validate(protocol);
|
||||
if (errors.Length > 0)
|
||||
|
|
@ -3388,7 +3518,7 @@ static async Task<int> RunMoveCalibrationProtocolSmokeAsync(string outputPath)
|
|||
$"move-calibration-protocol-smoke path={outputPath} protocol={protocol.ProtocolId} requiredStreams={requiredStreams} optionalStreams={optionalStreams} phases={protocol.Phases.Length} durationSeconds={totalDuration:0.0} outputs={protocol.Outputs.Length} questOptional={protocol.StreamRequirements.Any(stream => stream.StreamId.StartsWith("quest:", StringComparison.Ordinal))}");
|
||||
|
||||
return requiredStreams >= 3 &&
|
||||
protocol.Phases.Length == 7 &&
|
||||
protocol.Phases.Length == 8 &&
|
||||
protocol.Outputs.Length == 4 &&
|
||||
protocol.Acceptance.RequiredDerivedOutputs.Length == 4
|
||||
? 0
|
||||
|
|
|
|||
|
|
@ -56,23 +56,15 @@ public sealed record MimirMoveCalibrationAcceptance(
|
|||
|
||||
public static class MimirMoveCalibrationProtocol
|
||||
{
|
||||
public static MimirMoveCalibrationProtocolDocument CreateStarfireNightwingProtocol(
|
||||
public static MimirMoveCalibrationProtocolDocument CreateNightwingFourWandProtocol(
|
||||
DateTimeOffset? createdAt = null) =>
|
||||
new(
|
||||
ProtocolId: "mimir-move-calibration-starfire-nightwing-v1",
|
||||
ProtocolId: "mimir-move-calibration-nightwing-four-wand-v1",
|
||||
CreatedAtUtc: (createdAt ?? DateTimeOffset.UtcNow).ToString("O"),
|
||||
TrackingSpaceId: "mimir-stage-space",
|
||||
FusionAuthorityId: "mimir.runtime.move-fusion",
|
||||
StreamRequirements:
|
||||
[
|
||||
new(
|
||||
"muninn:starfire:move-evidence",
|
||||
"starfire",
|
||||
"Muninn on Starfire",
|
||||
"Move optical marker candidates plus USB controller IMU/buttons",
|
||||
"CultMesh shared-memory bytes frame, same-host when Mimir runs on Starfire",
|
||||
Required: true,
|
||||
"Starfire owns the USB-attached Move and local camera witnesses."),
|
||||
new(
|
||||
"muninn:nightwing:move-evidence",
|
||||
"nightwing",
|
||||
|
|
@ -81,6 +73,14 @@ public static class MimirMoveCalibrationProtocol
|
|||
"CultMesh bytes stream over the Verse; shared-memory on Nightwing, network bridge to Starfire",
|
||||
Required: true,
|
||||
"Nightwing owns its USB-attached Move and local camera witnesses."),
|
||||
new(
|
||||
"mimir:starfire:sensor-calibration-session",
|
||||
"starfire",
|
||||
"Mimir.Runtime",
|
||||
"Bounded calibration lifecycle, coverage, fit, validation, and promotion state",
|
||||
"Typed CultMesh state with CultCache persistence",
|
||||
Required: true,
|
||||
"Mimir owns the calibration task and its completion verdict."),
|
||||
new(
|
||||
"mimir:starfire:move-controller-poses",
|
||||
"starfire",
|
||||
|
|
@ -128,36 +128,36 @@ public static class MimirMoveCalibrationProtocol
|
|||
"preflight-streams",
|
||||
"Stream liveness and clock edge check",
|
||||
10.0,
|
||||
"Hold both Moves still and visible. Confirm Starfire, Nightwing, and optional Quest pose streams are fresh.",
|
||||
["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"],
|
||||
"Hold the four Moves visible. Confirm Nightwing evidence and stable identities are fresh.",
|
||||
["muninn:nightwing:move-evidence"],
|
||||
["stream-freshness", "clock-skew-estimate", "controller-id-map"]),
|
||||
new(
|
||||
"dark-stillness",
|
||||
"IMU bias with orbs off",
|
||||
8.0,
|
||||
"Set Move lights off. Place both Moves still on the calibration surface.",
|
||||
["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"],
|
||||
["muninn:nightwing:move-evidence"],
|
||||
["gyro-bias", "accelerometer-gravity-vector", "magnetometer-baseline"]),
|
||||
new(
|
||||
"lit-stillness",
|
||||
"Optical centroid and static gravity alignment",
|
||||
8.0,
|
||||
"Light one Move at a time, then both. Keep them still and visible to both camera sets.",
|
||||
["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"],
|
||||
["muninn:nightwing:move-evidence"],
|
||||
["camera-marker-centroid-stability", "light-command-to-controller-association"]),
|
||||
new(
|
||||
"axis-sweeps",
|
||||
"Slow single-axis rotations",
|
||||
24.0,
|
||||
"Sweep each Move slowly through pitch, yaw, and roll while keeping the orb visible.",
|
||||
["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"],
|
||||
["muninn:nightwing:move-evidence"],
|
||||
["gyro-scale-fit", "accelerometer-frame-fit", "optical-angular-consistency"]),
|
||||
new(
|
||||
"figure-eight",
|
||||
"Magnetometer and cross-axis motion",
|
||||
20.0,
|
||||
"Move each controller through a broad figure-eight. Leave the Quest headset/controllers stationary as the external VR reference cluster.",
|
||||
["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"],
|
||||
["muninn:nightwing:move-evidence"],
|
||||
["magnetometer-hard-soft-iron-fit", "cross-axis-coupling-check"]),
|
||||
new(
|
||||
"quest-reference",
|
||||
|
|
@ -166,12 +166,19 @@ public static class MimirMoveCalibrationProtocol
|
|||
"Keep the Move between the two Quest controllers in front of the headset. Capture the static Quest controller/headset frame while Mimir observes the Move.",
|
||||
["quest:usb:headset-pose", "quest:usb:left-controller-pose", "quest:usb:right-controller-pose"],
|
||||
["external-vr-pose-frame", "quest-to-mimir-frame-fit", "quest-clock-offset-estimate"]),
|
||||
new(
|
||||
"wand-volume-sweep",
|
||||
"Four-wand optical volume coverage",
|
||||
120.0,
|
||||
"Wave all four uniquely identified wands throughout the shared camera volume until Mimir reports sufficient per-sensor coverage.",
|
||||
["muninn:nightwing:move-evidence", "mimir:starfire:sensor-calibration-session"],
|
||||
["sensor-grid-coverage", "same-frame-correspondences", "sphere-radius-range", "fit-and-held-out-partitions"]),
|
||||
new(
|
||||
"validation-pass",
|
||||
"Held-out tracking validation",
|
||||
15.0,
|
||||
"Run free hand motion with both orbs visible. Mimir may publish validation poses, but not promote calibration if confidence fails.",
|
||||
["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence", "mimir:starfire:move-controller-poses"],
|
||||
["muninn:nightwing:move-evidence", "mimir:starfire:move-controller-poses"],
|
||||
["triangulated-position-residuals", "imu-prediction-residuals", "promotion-decision"])
|
||||
],
|
||||
Outputs:
|
||||
|
|
@ -227,16 +234,21 @@ public static class MimirMoveCalibrationProtocol
|
|||
errors.Add("required-streams-too-small");
|
||||
}
|
||||
|
||||
if (!protocol.StreamRequirements.Any(stream => stream.StreamId == "muninn:starfire:move-evidence" && stream.Required))
|
||||
{
|
||||
errors.Add("missing-starfire-muninn-move-evidence");
|
||||
}
|
||||
|
||||
if (!protocol.StreamRequirements.Any(stream => stream.StreamId == "muninn:nightwing:move-evidence" && stream.Required))
|
||||
{
|
||||
errors.Add("missing-nightwing-muninn-move-evidence");
|
||||
}
|
||||
|
||||
if (!protocol.StreamRequirements.Any(stream => stream.StreamId == "mimir:starfire:sensor-calibration-session" && stream.Required))
|
||||
{
|
||||
errors.Add("missing-mimir-calibration-session");
|
||||
}
|
||||
|
||||
if (!protocol.Phases.Any(phase => phase.PhaseId == "wand-volume-sweep"))
|
||||
{
|
||||
errors.Add("missing-wand-volume-sweep");
|
||||
}
|
||||
|
||||
if (!protocol.Phases.Any(phase => phase.PhaseId == "dark-stillness"))
|
||||
{
|
||||
errors.Add("missing-imu-bias-phase");
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
using System.Numerics;
|
||||
using MessagePack;
|
||||
|
||||
namespace Mimir.Runtime.Synchronization;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed record MimirMoveFusionCameraCalibration(
|
||||
string CameraId,
|
||||
ulong WitnessIdHash,
|
||||
MimirVector3Snapshot PositionMeters,
|
||||
MimirQuaternionSnapshot Orientation,
|
||||
double FocalLengthXPx,
|
||||
double FocalLengthYPx,
|
||||
double PrincipalPointXPx,
|
||||
double PrincipalPointYPx);
|
||||
[property: Key(0)] string CameraId,
|
||||
[property: Key(1)] ulong WitnessIdHash,
|
||||
[property: Key(2)] MimirVector3Snapshot PositionMeters,
|
||||
[property: Key(3)] MimirQuaternionSnapshot Orientation,
|
||||
[property: Key(4)] double FocalLengthXPx,
|
||||
[property: Key(5)] double FocalLengthYPx,
|
||||
[property: Key(6)] double PrincipalPointXPx,
|
||||
[property: Key(7)] double PrincipalPointYPx);
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed record MimirMoveFusionRigCalibration(
|
||||
string CalibrationId,
|
||||
string TrackingSpaceId,
|
||||
IReadOnlyList<MimirMoveFusionCameraCalibration> Cameras,
|
||||
double GyroUnitsPerRadianPerSecond = 1.0,
|
||||
double MaximumAssociationSkewMilliseconds = 20.0,
|
||||
double SingleRayFallbackDepthMeters = 1.5);
|
||||
[property: Key(0)] string CalibrationId,
|
||||
[property: Key(1)] string TrackingSpaceId,
|
||||
[property: Key(2)] IReadOnlyList<MimirMoveFusionCameraCalibration> Cameras,
|
||||
[property: Key(3)] double GyroUnitsPerRadianPerSecond = 1.0,
|
||||
[property: Key(4)] double MaximumAssociationSkewMilliseconds = 20.0,
|
||||
[property: Key(5)] double SingleRayFallbackDepthMeters = 1.5);
|
||||
|
||||
public sealed record MimirMoveFusionResult(
|
||||
IReadOnlyList<MimirMoveControllerPoseDocument> Poses,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace Mimir.Runtime.Synchronization;
|
||||
|
||||
public enum MimirSensorCalibrationSessionPhase
|
||||
{
|
||||
Collecting,
|
||||
Fitting,
|
||||
Validating,
|
||||
Promoted,
|
||||
Rejected,
|
||||
Canceled
|
||||
}
|
||||
|
||||
[CultDocument("mimir.sensor_calibration_session", "mimir.sensor_calibration_session.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed record MimirSensorCalibrationSessionDocument(
|
||||
[property: Key(0)] [property: CultName] string SessionId,
|
||||
[property: Key(1)] string TrackingSpaceId,
|
||||
[property: Key(2)] string Owner,
|
||||
[property: Key(3)] string StartedAtUtc,
|
||||
[property: Key(4)] double MaximumDurationSeconds,
|
||||
[property: Key(5)] string[] RequiredWandIds,
|
||||
[property: Key(6)] string[] SensorIds,
|
||||
[property: Key(7)] MimirSensorCalibrationAcceptance Acceptance,
|
||||
[property: Key(8)] MimirSensorCalibrationSessionPhase Phase,
|
||||
[property: Key(9)] MimirSensorCalibrationSensorProgress[] Sensors,
|
||||
[property: Key(10)] string[] MissingRequirements,
|
||||
[property: Key(11)] string Detail);
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed record MimirSensorCalibrationAcceptance(
|
||||
[property: Key(0)] int GridColumns,
|
||||
[property: Key(1)] int GridRows,
|
||||
[property: Key(2)] int MinimumOccupiedGridCellsPerSensor,
|
||||
[property: Key(3)] int MinimumObservationsPerSensor,
|
||||
[property: Key(4)] int MinimumSameFrameCorrespondences,
|
||||
[property: Key(5)] int MinimumDistinctWands,
|
||||
[property: Key(6)] double MinimumRadiusRatio,
|
||||
[property: Key(7)] double HeldOutFraction,
|
||||
[property: Key(8)] double MaximumMedianReprojectionErrorPx,
|
||||
[property: Key(9)] double MaximumP95ReprojectionErrorPx,
|
||||
[property: Key(10)] double MaximumAssociationSkewMilliseconds);
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed record MimirSensorCalibrationSensorProgress(
|
||||
[property: Key(0)] string SensorId,
|
||||
[property: Key(1)] int ObservationCount,
|
||||
[property: Key(2)] int OccupiedGridCells,
|
||||
[property: Key(3)] int GridCellCount,
|
||||
[property: Key(4)] int DistinctWandCount,
|
||||
[property: Key(5)] double MinimumRadiusPx,
|
||||
[property: Key(6)] double MaximumRadiusPx,
|
||||
[property: Key(7)] int[] MissingGridCells,
|
||||
[property: Key(8)] bool CollectionComplete);
|
||||
|
||||
[CultDocument("mimir.sensor_calibration_receipt", "mimir.sensor_calibration_receipt.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed record MimirSensorCalibrationReceiptDocument(
|
||||
[property: Key(0)] [property: CultName] string ReceiptId,
|
||||
[property: Key(1)] string SessionId,
|
||||
[property: Key(2)] string TrackingSpaceId,
|
||||
[property: Key(3)] string CompletedAtUtc,
|
||||
[property: Key(4)] MimirSensorCalibrationSessionPhase Verdict,
|
||||
[property: Key(5)] int TrainingCorrespondenceCount,
|
||||
[property: Key(6)] int HeldOutCorrespondenceCount,
|
||||
[property: Key(7)] MimirSensorCalibrationFitResiduals Residuals,
|
||||
[property: Key(8)] MimirMoveFusionRigCalibration? PromotedCalibration,
|
||||
[property: Key(9)] string[] RejectionReasons,
|
||||
[property: Key(10)] string Solver);
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed record MimirSensorCalibrationFitResiduals(
|
||||
[property: Key(0)] double TrainingMedianReprojectionErrorPx,
|
||||
[property: Key(1)] double TrainingP95ReprojectionErrorPx,
|
||||
[property: Key(2)] double HeldOutMedianReprojectionErrorPx,
|
||||
[property: Key(3)] double HeldOutP95ReprojectionErrorPx,
|
||||
[property: Key(4)] double MedianAssociationSkewMilliseconds,
|
||||
[property: Key(5)] double MaximumAssociationSkewMilliseconds,
|
||||
[property: Key(6)] double ConditionEstimate,
|
||||
[property: Key(7)] int Iterations);
|
||||
|
||||
public static class MimirSensorCalibrationSessions
|
||||
{
|
||||
public static MimirSensorCalibrationSessionDocument CreateFourWandOpticalSession(
|
||||
IEnumerable<string> wandIds,
|
||||
IEnumerable<string> sensorIds,
|
||||
DateTimeOffset? startedAt = null,
|
||||
double maximumDurationSeconds = 120.0)
|
||||
{
|
||||
var wands = wandIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).Order().ToArray();
|
||||
var sensors = sensorIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).Order().ToArray();
|
||||
if (wands.Length != 4) throw new ArgumentException("A four-wand calibration session requires exactly four stable wand IDs.", nameof(wandIds));
|
||||
if (sensors.Length < 2) throw new ArgumentException("Optical calibration requires at least two sensors.", nameof(sensorIds));
|
||||
if (!double.IsFinite(maximumDurationSeconds) || maximumDurationSeconds <= 0.0) throw new ArgumentOutOfRangeException(nameof(maximumDurationSeconds));
|
||||
var start = startedAt ?? DateTimeOffset.UtcNow;
|
||||
return new MimirSensorCalibrationSessionDocument(
|
||||
$"mimir-sensor-calibration:{start.ToUnixTimeMilliseconds()}",
|
||||
"mimir-stage-space",
|
||||
"Mimir.Runtime",
|
||||
start.ToString("O"),
|
||||
maximumDurationSeconds,
|
||||
wands,
|
||||
sensors,
|
||||
new MimirSensorCalibrationAcceptance(
|
||||
GridColumns: 4,
|
||||
GridRows: 3,
|
||||
MinimumOccupiedGridCellsPerSensor: 9,
|
||||
MinimumObservationsPerSensor: 1_000,
|
||||
MinimumSameFrameCorrespondences: 500,
|
||||
MinimumDistinctWands: 4,
|
||||
MinimumRadiusRatio: 1.5,
|
||||
HeldOutFraction: 0.2,
|
||||
MaximumMedianReprojectionErrorPx: 1.5,
|
||||
MaximumP95ReprojectionErrorPx: 4.0,
|
||||
MaximumAssociationSkewMilliseconds: 20.0),
|
||||
MimirSensorCalibrationSessionPhase.Collecting,
|
||||
sensors.Select(sensor => new MimirSensorCalibrationSensorProgress(
|
||||
sensor, 0, 0, 12, 0, double.NaN, double.NaN, Enumerable.Range(0, 12).ToArray(), false)).ToArray(),
|
||||
["wand-motion-not-yet-captured"],
|
||||
"Wave one or two uniquely identified wands at a time through the shared sensor volume; Mimir accumulates successive passes until every wand and sensor has sufficient coverage.");
|
||||
}
|
||||
|
||||
public static MimirSensorCalibrationSessionDocument UpdateCollection(
|
||||
MimirSensorCalibrationSessionDocument session,
|
||||
MoveVisibilityWindowReceipt evidence,
|
||||
int imageWidth,
|
||||
int imageHeight,
|
||||
DateTimeOffset? observedAt = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
if (session.Phase != MimirSensorCalibrationSessionPhase.Collecting) return session;
|
||||
if (imageWidth <= 0) throw new ArgumentOutOfRangeException(nameof(imageWidth));
|
||||
if (imageHeight <= 0) throw new ArgumentOutOfRangeException(nameof(imageHeight));
|
||||
|
||||
var acceptance = session.Acceptance;
|
||||
var requiredWands = session.RequiredWandIds.ToHashSet(StringComparer.Ordinal);
|
||||
var progress = session.SensorIds.Select(sensorId =>
|
||||
{
|
||||
var observations = evidence.Observations.Where(value =>
|
||||
string.Equals(value.CameraId, sensorId, StringComparison.Ordinal) &&
|
||||
requiredWands.Contains(value.MoveId) &&
|
||||
float.IsFinite(value.CenterXPx) && float.IsFinite(value.CenterYPx) &&
|
||||
float.IsFinite(value.RadiusPx) && value.RadiusPx > 0.0f).ToArray();
|
||||
var occupied = observations.Select(value => GridCell(
|
||||
value.CenterXPx, value.CenterYPx, imageWidth, imageHeight,
|
||||
acceptance.GridColumns, acceptance.GridRows)).Distinct().Order().ToArray();
|
||||
var missing = Enumerable.Range(0, acceptance.GridColumns * acceptance.GridRows)
|
||||
.Except(occupied).ToArray();
|
||||
var minimumRadius = observations.Length == 0 ? double.NaN : observations.Min(value => value.RadiusPx);
|
||||
var maximumRadius = observations.Length == 0 ? double.NaN : observations.Max(value => value.RadiusPx);
|
||||
var radiusRatio = observations.Length == 0 ? 0.0 : maximumRadius / minimumRadius;
|
||||
var distinctWands = observations.Select(value => value.MoveId).Distinct(StringComparer.Ordinal).Count();
|
||||
var complete = observations.Length >= acceptance.MinimumObservationsPerSensor &&
|
||||
occupied.Length >= acceptance.MinimumOccupiedGridCellsPerSensor &&
|
||||
distinctWands >= acceptance.MinimumDistinctWands &&
|
||||
radiusRatio >= acceptance.MinimumRadiusRatio;
|
||||
return new MimirSensorCalibrationSensorProgress(
|
||||
sensorId, observations.Length, occupied.Length,
|
||||
acceptance.GridColumns * acceptance.GridRows, distinctWands,
|
||||
minimumRadius, maximumRadius, missing, complete);
|
||||
}).ToArray();
|
||||
|
||||
var correspondenceCount = evidence.Correspondences.Count(value =>
|
||||
requiredWands.Contains(value.MoveId) &&
|
||||
value.AbsoluteSkewNs <= acceptance.MaximumAssociationSkewMilliseconds * 1_000_000.0 &&
|
||||
string.Equals(value.First.FrameId, value.Second.FrameId, StringComparison.Ordinal));
|
||||
var missingRequirements = new List<string>();
|
||||
foreach (var sensor in progress)
|
||||
{
|
||||
if (sensor.ObservationCount < acceptance.MinimumObservationsPerSensor)
|
||||
missingRequirements.Add($"{sensor.SensorId}:observations-{sensor.ObservationCount}-of-{acceptance.MinimumObservationsPerSensor}");
|
||||
if (sensor.OccupiedGridCells < acceptance.MinimumOccupiedGridCellsPerSensor)
|
||||
missingRequirements.Add($"{sensor.SensorId}:grid-cells-{sensor.OccupiedGridCells}-of-{acceptance.MinimumOccupiedGridCellsPerSensor}");
|
||||
if (sensor.DistinctWandCount < acceptance.MinimumDistinctWands)
|
||||
missingRequirements.Add($"{sensor.SensorId}:wands-{sensor.DistinctWandCount}-of-{acceptance.MinimumDistinctWands}");
|
||||
var radiusRatio = sensor.ObservationCount == 0 ? 0.0 : sensor.MaximumRadiusPx / sensor.MinimumRadiusPx;
|
||||
if (radiusRatio < acceptance.MinimumRadiusRatio)
|
||||
missingRequirements.Add($"{sensor.SensorId}:radius-ratio-{radiusRatio:F2}-of-{acceptance.MinimumRadiusRatio:F2}");
|
||||
}
|
||||
if (correspondenceCount < acceptance.MinimumSameFrameCorrespondences)
|
||||
missingRequirements.Add($"same-frame-correspondences-{correspondenceCount}-of-{acceptance.MinimumSameFrameCorrespondences}");
|
||||
|
||||
var elapsed = (observedAt ?? DateTimeOffset.UtcNow) - DateTimeOffset.Parse(session.StartedAtUtc);
|
||||
var collectionComplete = progress.All(value => value.CollectionComplete) &&
|
||||
correspondenceCount >= acceptance.MinimumSameFrameCorrespondences;
|
||||
var expired = elapsed.TotalSeconds >= session.MaximumDurationSeconds;
|
||||
var phase = collectionComplete
|
||||
? MimirSensorCalibrationSessionPhase.Fitting
|
||||
: expired ? MimirSensorCalibrationSessionPhase.Rejected : MimirSensorCalibrationSessionPhase.Collecting;
|
||||
var detail = phase switch
|
||||
{
|
||||
MimirSensorCalibrationSessionPhase.Fitting => "Collection complete; Mimir owns the retained evidence and may begin fitting.",
|
||||
MimirSensorCalibrationSessionPhase.Rejected => "Collection window expired before coverage requirements were met; no calibration was promoted.",
|
||||
_ => "Collection active. Continue sweeping one or two wands at a time through missing image regions and depth ranges."
|
||||
};
|
||||
return session with { Phase = phase, Sensors = progress, MissingRequirements = missingRequirements.ToArray(), Detail = detail };
|
||||
}
|
||||
|
||||
private static int GridCell(float x, float y, int width, int height, int columns, int rows)
|
||||
{
|
||||
var column = Math.Clamp((int)(x * columns / width), 0, columns - 1);
|
||||
var row = Math.Clamp((int)(y * rows / height), 0, rows - 1);
|
||||
return row * columns + column;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue