/** * Reusable 3-D object-detection Script addon. * * `Object3DDetector` wraps the full pipeline in a reusable {@link Script}: * snap camera + depth mesh, run 2-D detection, obtain * per-object segmentation masks, raycast depth samples into world space, fit an * oriented bounding box (OBB), fuse across views, and optionally show debug * wireframe boxes. */ import * as THREE from 'three'; import { Script, core, enableAcceleratedRaycast, getCameraParametersSnapshot, getDeviceCameraWorldFromView, } from 'xrblocks'; import {Detected3DObject} from './Detected3DObject'; import { anchorFromBboxCenter, sampleDepthInMaskAcrossFrames, } from './geometry/DepthSampling'; import {buildFrozenCamera} from './geometry/FrozenCamera'; import {PoseRing} from './geometry/PoseRing'; import { estimateRoomYawFromMesh, RoomFrameAccumulator, yawRelativeToRoom, } from './geometry/RoomFrame'; import { fuseIntoBoxes, snapBoxToFloor, unionDetections, } from './geometry/Fusion'; import { fitYawOBB, radiusFromBbox, rejectByAnchor, rejectByAnchorDepth, rejectByY, } from './geometry/ObbFitting'; import type {OrientationMode, OrientationOptions} from './geometry/ObbFitting'; import {categorize, isSurfaceLabel, isTinyFlatLabel} from './labels/Categories'; import {samEncodeSnapshot, samMaskFromBbox, getSam} from './masks/SamMask'; import {segmenterMaskFromSnapshot} from './masks/SegmenterMask'; import { buildBoxGroup, rebuildBoxGroupGeometry, CAT_COLOR, } from './visuals/BoxGroup'; import type {ObjectCategory} from './labels/Categories'; const DEPTH_SAMPLE_GRID_SIZE = 10; const DEPTH_SAMPLE_FRAME_COUNT = 3; const MAX_CENTER_HORIZONTAL_DISTANCE_M = 6; const MIN_CENTER_HEIGHT_M = -1; const MAX_CENTER_HEIGHT_M = 5; const MIN_POINTS_BY_CATEGORY: Record = { small: 4, light: 8, flat: 8, furniture: 20, }; const MAX_BOX_WIDTH_M = 4; const MAX_BOX_HEIGHT_M = 3; const MAX_BOX_DEPTH_M = 4; const MIN_DEGENERATE_HORIZONTAL_SIZE_M = 0.5; const MIN_DEGENERATE_HEIGHT_RATIO = 0.1; /** Options for {@link Object3DDetector}. */ export interface Object3DDetectorOptions { /** * Which 2-D detector backend to use. * - `'gemini'` — Gemini open-vocabulary (best variety, requires API key). * - `'mediapipe'` — On-device COCO (no key needed, fixed class set). * - `'both'` — Union of both with IoU dedup. * @defaultValue `'gemini'` */ detectBackend?: 'gemini' | 'mediapipe' | 'both'; /** * Which segmentation mask backend to use for depth sampling. * - `'efficientsam_ti'` — EfficientSAM-Ti via `@litertjs/core` (tighter masks). * - `'mediapipe'` — MediaPipe `InteractiveSegmenter` (faster, no download). * @defaultValue `'efficientsam_ti'` */ maskBackend?: 'efficientsam_ti' | 'mediapipe'; /** * When `true`, accumulate OBBs across multiple `detect()` calls from * different angles. Each new call refines matching existing boxes via * running-average fusion instead of adding a duplicate. * @defaultValue `true` */ fuseAcrossViews?: boolean; /** * When `true`, add wireframe box + label sprite groups to the scene as * children of this Script object. * @defaultValue `false` */ showDebugBoxes?: boolean; /** * Maximum ray-hit distance in metres when sampling the depth mesh. * @defaultValue `12` */ maxRayDistance?: number; /** * World-space sanity bounds; fitted boxes whose centre falls outside are * rejected. Tuned for a room-scale scene around the session origin. * @defaultValue `{maxXZ: 6, minY: -1, maxY: 5}` */ sceneBounds?: {maxXZ?: number; minY?: number; maxY?: number}; /** * Assumed distance in metres from the session origin to the cardinal * walls, used by the tiny-flat fitter (switches, outlets) to snap onto a * wall plane. The default matches the simulator's wood-cabin scene; tune * it (or avoid tiny-flat labels) for real rooms. * @defaultValue `3` */ roomHalf?: number; /** * Extra rotation applied to the device-camera pose at capture time, in * radians (YXZ order, i.e. yaw about +Y first). Use this to null out a * constant per-unit calibration error between the SDK's estimated * passthrough-camera extrinsics and the actual hardware: if detections * land rotated clockwise (viewed from above) by θ, pass `{yaw: θ}`. * @defaultValue `{yaw: 0, pitch: 0, roll: 0}` */ cameraRotationOffset?: {yaw?: number; pitch?: number; roll?: number}; /** * Full 6-DOF correction to the SDK's estimated device-camera extrinsics, * post-multiplied in the camera's own view frame (+x right, +y up, −z * forward), applied after {@link cameraRotationOffset}: `worldFromView` * becomes `worldFromView · T(translation) · R(rotation)`. Same shape and * convention as the ArUco tracker's `calibration` option, so a * calibration recovered by `ArucoTracker.getCalibration()` can be passed * straight through (its `rangeScale` does not apply here — metric range * comes from the depth mesh, not a monocular marker estimate). * @defaultValue `undefined` (no correction) */ cameraExtrinsicCorrection?: {rotation?: number[]; translation?: number[]}; /** * How fitted yaws are reconciled with the room. Defaults to * `{mode: 'roomFrame'}`, which estimates the room's own wall direction from * the depth mesh and falls back to it only when an object's own orientation * is ill-determined. Pass `{mode: 'cardinal'}` for the legacy behaviour of * snapping every box to the session origin's axes. */ orientation?: OrientationOptions; } /** * Machine-readable record of what one {@link Object3DDetector.detect} call * observed about its inputs. Chiefly useful for diagnosing on-device * misalignment, where the interesting quantities (how stale the captured * video frame was, whether the depth mesh is rotated relative to the render * view) are invisible from the fitted boxes alone. */ export interface Object3DDetectorDiagnostics { /** `performance.now()` when the detect call started. */ startedAtMs: number; /** Age of the captured video frame at snapshot time, or `null` when the * browser exposes no `captureTime` for the stream. Large values mean the * pixels predate the pose, which rotates every box by the head motion in * between. */ frameLatencyMs: number | null; /** Gap between the frame's capture time and the timestamp of the recorded * pose used for it. `null` when no historical pose was applied. */ poseMatchErrorMs: number | null; /** Poses currently held in the history ring. */ poseRingSize: number; /** Whether the frozen camera came from the SDK's device-camera model * (`true`) or fell back to a clone of the XR render camera (`false`). */ usedDeviceCameraModel: boolean; /** Vertical FOV and aspect of the frozen camera actually raycast through. */ cameraFovDeg: number; cameraAspect: number; /** Extra rotation applied on top of the SDK extrinsics, in degrees. */ cameraRotationOffsetDeg: {yaw: number; pitch: number; roll: number}; /** Applied 6-DOF extrinsic correction (e.g. from an ArUco calibration * session), or `null` when none is set. */ cameraExtrinsicCorrection: { rotationDeg: number; translationCm: number; } | null; snapshotWidth: number; snapshotHeight: number; /** Whether the platform's view→depth-buffer UV remap is the identity. */ depthRemapIsIdentity: boolean | null; /** Angle between the depth camera's reported orientation and the left eye's, * in degrees. A large value with `matchDepthView: false` means the depth * mesh every ray lands on is itself rotated. */ depthVsEyeRotationDeg: number | null; /** Vertices in the frozen depth mesh snapshot. */ depthMeshVertices: number | null; /** 2-D detections returned by the detector backend. */ detections2d: number; /** Detections that survived fitting and the sanity gates. */ fitted3d: number; /** Count of each rejection reason across all detections. */ rejections: Record; /** Wall-clock milliseconds per stage. */ timings: { freshFrameWait: number; snapshot: number; depthMeshSnapshot: number; detect2d: number; masksAndFit: number; total: number; }; /** Populated when the call bailed out early. */ error: string | null; /** Orientation policy in force for this call. */ orientationMode: OrientationMode; /** Estimated room yaw in degrees, or `null` when no frame was available. */ roomYawDeg: number | null; /** Confidence of the room frame, in `[0, 1]`. */ roomYawConfidence: number | null; /** Vertical surface area that voted for the room frame, in m². */ roomFrameSupportM2: number | null; /** * Per-object yaw outcome. `roomRelativeYawDeg` is the useful one on device: * if wall-aligned furniture reads ≈0 here but the boxes still look wrong, * the fault is upstream in the camera model rather than in fitting. */ yawStats: Array<{ label: string; category: string; yawDeg: number; roomRelativeYawDeg: number; confidence: number; method: string; }>; } // Kick off the three-mesh-bvh dynamic import at module load so it is ready // well before the first detect() call. const _bvhReady: Promise = enableAcceleratedRaycast().catch( () => false ); /** * Post-multiplies the manual Euler offset, then a 6-DOF extrinsic * correction matrix, onto `worldFromView` — both in the camera's own view * space, on top of the SDK's estimated extrinsics: * `worldFromView · R(offset) · extrinsic`. Exported as a pure function so * the composition can be unit-tested without an XR session. Returns the * input instance unchanged when both corrections are identity/absent. */ export function applyCameraPoseCorrections( worldFromView: THREE.Matrix4, offset: {yaw: number; pitch: number; roll: number}, extrinsic: THREE.Matrix4 | null ): THREE.Matrix4 { const hasEuler = offset.yaw !== 0 || offset.pitch !== 0 || offset.roll !== 0; if (!hasEuler && !extrinsic) return worldFromView; const out = worldFromView.clone(); if (hasEuler) { out.multiply( new THREE.Matrix4().makeRotationFromEuler( new THREE.Euler(offset.pitch, offset.yaw, offset.roll, 'YXZ') ) ); } if (extrinsic) out.multiply(extrinsic); return out; } /** * The 3-D object-detection pipeline as a reusable {@link Script}. See the * `objects_3d` demo for a worked integration. Attach it to the scene before * `xb.init()`, then * call `await detector.detect()` to populate `detector.results`. * * ```ts * import {Object3DDetector} from 'xrblocks/addons/objects3d'; * const detector = new Object3DDetector({showDebugBoxes: true}); * xb.add(detector); * xb.init(options); * // …later… * const objects = await detector.detect(); * ``` */ export class Object3DDetector extends Script { private readonly _opts: Required< Omit< Object3DDetectorOptions, | 'sceneBounds' | 'cameraRotationOffset' | 'cameraExtrinsicCorrection' | 'orientation' > > & { sceneBounds: {maxXZ: number; minY: number; maxY: number}; cameraRotationOffset: {yaw: number; pitch: number; roll: number}; orientation: Required> & { roomYaw: number | null; }; }; private _results: Detected3DObject[] = []; private _detectInFlight = false; // ~1.3 s of pose history at 90 fps, enough to cover passthrough video // pipeline latency when pairing a capture with its capture-time pose. private readonly _poseRing = new PoseRing(120); private readonly _roomFrame = new RoomFrameAccumulator(); private _diagnostics: Object3DDetectorDiagnostics | null = null; private _extrinsic: { matrix: THREE.Matrix4; rotationDeg: number; translationCm: number; } | null = null; /** * @param options - Configuration options. */ constructor(options: Object3DDetectorOptions = {}) { super(); this._opts = { detectBackend: options.detectBackend ?? 'gemini', maskBackend: options.maskBackend ?? 'efficientsam_ti', fuseAcrossViews: options.fuseAcrossViews ?? true, showDebugBoxes: options.showDebugBoxes ?? false, maxRayDistance: options.maxRayDistance ?? 12, sceneBounds: { maxXZ: options.sceneBounds?.maxXZ ?? MAX_CENTER_HORIZONTAL_DISTANCE_M, minY: options.sceneBounds?.minY ?? MIN_CENTER_HEIGHT_M, maxY: options.sceneBounds?.maxY ?? MAX_CENTER_HEIGHT_M, }, roomHalf: options.roomHalf ?? 3, cameraRotationOffset: { yaw: options.cameraRotationOffset?.yaw ?? 0, pitch: options.cameraRotationOffset?.pitch ?? 0, roll: options.cameraRotationOffset?.roll ?? 0, }, orientation: { mode: options.orientation?.mode ?? 'roomFrame', roomYaw: options.orientation?.roomYaw ?? null, roomYawConfidence: options.orientation?.roomYawConfidence ?? 0, snapToleranceRad: options.orientation?.snapToleranceRad ?? (12 * Math.PI) / 180, minYawConfidence: options.orientation?.minYawConfidence ?? 0.35, }, }; this.setCameraExtrinsicCorrection(options.cameraExtrinsicCorrection); } /** * Record the device-camera pose every frame so {@link detect} can pair a * captured video frame with the pose at the frame's `captureTime` — the * passthrough video lags head tracking, so the pose at snapshot time is * newer than the snapshot's pixels. */ override update(): void { const deviceCamera = core.deviceCamera; if (!deviceCamera || deviceCamera.simulatorCamera) return; // `core.renderer` may be a WebGPURenderer, whose XR manager types // `getCamera()` as a plain ArrayCamera; the device-camera helpers want the // WebXR-specific shape (same cast as PlanarVST). const xrCameras = core.renderer?.xr?.getCamera?.() as | THREE.WebXRArrayCamera | undefined; if (!xrCameras?.cameras?.length) return; try { this._poseRing.push( performance.now(), getDeviceCameraWorldFromView( core.camera, xrCameras, deviceCamera, this._targetDevice() ) ); } catch (_e) { // Pose momentarily unavailable; skip this frame. } } /** Currently fitted {@link Detected3DObject} instances from the last * (or accumulated) detect run. */ get results(): Detected3DObject[] { return this._results; } /** * Diagnostics from the most recent {@link detect} call, or `null` before * the first one. See {@link Object3DDetectorDiagnostics}. */ get diagnostics(): Object3DDetectorDiagnostics | null { return this._diagnostics; } /** Poses currently held in the capture-time pose history ring. */ get poseRingSize(): number { return this._poseRing.size; } /** Extra rotation applied to the device-camera pose, in radians. */ get cameraRotationOffset(): {yaw: number; pitch: number; roll: number} { return {...this._opts.cameraRotationOffset}; } /** * Adjust the camera rotation offset between detections, so a calibration * error can be nulled out interactively instead of by reloading. Omitted * components are left unchanged. */ setCameraRotationOffset(offset: { yaw?: number; pitch?: number; roll?: number; }): void { const current = this._opts.cameraRotationOffset; current.yaw = offset.yaw ?? current.yaw; current.pitch = offset.pitch ?? current.pitch; current.roll = offset.roll ?? current.roll; } /** The currently applied 6-DOF extrinsic correction, or `null`. */ get cameraExtrinsicCorrection(): { rotation: number[]; translation: number[]; } | null { if (!this._extrinsic) return null; const position = new THREE.Vector3(); const quaternion = new THREE.Quaternion(); this._extrinsic.matrix.decompose(position, quaternion, new THREE.Vector3()); return {rotation: quaternion.toArray(), translation: position.toArray()}; } /** * Set or clear the 6-DOF extrinsic correction (see * {@link Object3DDetectorOptions.cameraExtrinsicCorrection}). Pass `null` * or an object with neither field to clear it. Safe to call between * `detect()` calls, so a calibration recovered on the fly (e.g. from an * ArUco calibration session) can be applied mid-session. */ setCameraExtrinsicCorrection( correction: {rotation?: number[]; translation?: number[]} | null | undefined ): void { if (!correction || (!correction.rotation && !correction.translation)) { this._extrinsic = null; return; } const rotation = correction.rotation?.length === 4 ? new THREE.Quaternion().fromArray(correction.rotation).normalize() : new THREE.Quaternion(); const translation = correction.translation?.length === 3 ? new THREE.Vector3().fromArray(correction.translation) : new THREE.Vector3(); this._extrinsic = { matrix: new THREE.Matrix4().compose( translation, rotation, new THREE.Vector3(1, 1, 1) ), rotationDeg: THREE.MathUtils.radToDeg( 2 * Math.acos(THREE.MathUtils.clamp(Math.abs(rotation.w), -1, 1)) ), translationCm: translation.length() * 100, }; } /** The orientation policy currently in force. */ get orientationMode(): OrientationMode { return this._opts.orientation.mode; } /** * Switch orientation policy between detections, so the modes can be * A/B compared on device without reloading. */ setOrientationMode(mode: OrientationMode): void { this._opts.orientation.mode = mode; } /** The room frame accumulated so far, or `null` before any usable estimate. */ get roomFrame(): ReturnType { return this._roomFrame.current; } /** * Discard the accumulated room frame. Call this after the user recenters or * moves to a different space; {@link clearDetections} does it too. */ resetRoomFrame(): void { this._roomFrame.reset(); } /** * Remove all existing results and their debug visuals from the scene. * Call this to reset the detector before a new area scan. */ clearDetections(): void { for (const obj of this._results) { if (obj.boxGroup) { obj.boxGroup.traverse((o) => { const mesh = o as THREE.Mesh; if (mesh.geometry) mesh.geometry.dispose(); const mat = mesh.material; if (mat) { if (Array.isArray(mat)) mat.forEach((m) => m.dispose?.()); else (mat as THREE.Material).dispose?.(); } }); this.remove(obj.boxGroup); } this.remove(obj); } this._results = []; this._roomFrame.reset(); } /** * Run the full detection + OBB-fitting pipeline and return the list of * fitted {@link Detected3DObject} instances. * * The call: * 1. Captures a camera snapshot and freezes both the camera matrix and the * depth mesh at that instant. * 2. Runs 2-D object detection (Gemini / MediaPipe / both). * 3. For each detection, obtains a per-object segmentation mask (SAM / * MediaPipe segmenter). * 4. Raycasts depth samples through the frozen mask into world space. * 5. Fits an oriented bounding box using a per-category strategy. * 6. Fuses the result into any matching existing box from a prior call. * * @returns Resolved list of fitted {@link Detected3DObject} instances, * or the unchanged current results on re-entry / missing subsystems. */ async detect(): Promise { if (!core.world?.objects) { console.warn( '[Object3DDetector] object detection subsystem not available' ); return this._results; } const worldObjects = core.world.objects; const deviceCamera = core.deviceCamera; if (this._detectInFlight) { console.info( '[Object3DDetector] detect already in flight, ignoring re-entry' ); return this._results; } this._detectInFlight = true; const t0 = performance.now(); const diag: Object3DDetectorDiagnostics = { startedAtMs: t0, frameLatencyMs: null, poseMatchErrorMs: null, poseRingSize: this._poseRing.size, usedDeviceCameraModel: false, cameraFovDeg: 0, cameraAspect: 0, cameraRotationOffsetDeg: { yaw: THREE.MathUtils.radToDeg(this._opts.cameraRotationOffset.yaw), pitch: THREE.MathUtils.radToDeg(this._opts.cameraRotationOffset.pitch), roll: THREE.MathUtils.radToDeg(this._opts.cameraRotationOffset.roll), }, cameraExtrinsicCorrection: this._extrinsic ? { rotationDeg: this._extrinsic.rotationDeg, translationCm: this._extrinsic.translationCm, } : null, snapshotWidth: 0, snapshotHeight: 0, depthRemapIsIdentity: null, depthVsEyeRotationDeg: null, depthMeshVertices: null, detections2d: 0, fitted3d: 0, rejections: {}, timings: { freshFrameWait: 0, snapshot: 0, depthMeshSnapshot: 0, detect2d: 0, masksAndFit: 0, total: 0, }, error: null, orientationMode: this._opts.orientation.mode, roomYawDeg: null, roomYawConfidence: null, roomFrameSupportM2: null, yawStats: [], }; const bail = (message: string): Detected3DObject[] => { console.warn(`[Object3DDetector] ${message}`); diag.error = message; diag.timings.total = performance.now() - t0; this._diagnostics = diag; this._detectInFlight = false; return this._results; }; if (!deviceCamera) { return bail('device camera not available'); } // Wait for a fresh video frame before snapshotting: inside an immersive // session the hidden