Skip to content

Commit 185732b

Browse files
authored
Merge pull request #398 from salmanmkc/feat/world-segmentation
world: add a segmentation subsystem (person/background masks)
2 parents e728b94 + 17b4efa commit 185732b

16 files changed

Lines changed: 874 additions & 113 deletions

demos/magic_window/MagicWindow.js

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import * as THREE from 'three';
22
import * as xb from 'xrblocks';
33

4-
import {SegmenterController} from './SegmenterController.js';
5-
64
// Backdrop modes for the cut-out background.
75
// 0 = passthrough: background pixels are discarded so you see straight
86
// through the window to the real world behind it.
@@ -77,9 +75,10 @@ const FRAGMENT_SHADER = /* glsl */ `
7775
// The camera texture uses GL flipY; the mask DataTexture does not, so
7876
// flip the mask's v to line the two up.
7977
float id = texture2D(uMask, vec2(vUv.x, 1.0 - vUv.y)).r * 255.0;
80-
// Until the first mask arrives, show the raw feed. Category 0 is the
81-
// background; everything else is a person.
82-
bool isPerson = (uHasMask < 0.5) || (id >= 0.5);
78+
// Until the first mask arrives, treat the whole frame as background so we
79+
// show the backdrop rather than flashing the raw camera (the room) during
80+
// model warm-up. Category 0 is background; everything else is a person.
81+
bool isPerson = (uHasMask > 0.5) && (id > 0.5);
8382
if (isPerson) {
8483
gl_FragColor = vec4(cam, 1.0);
8584
return;
@@ -95,7 +94,6 @@ const FRAGMENT_SHADER = /* glsl */ `
9594
export class MagicWindow extends xb.Script {
9695
constructor() {
9796
super();
98-
this.segmenter = new SegmenterController();
9997
this.frameCanvas = document.createElement('canvas');
10098
this.frameCtx = this.frameCanvas.getContext('2d', {
10199
willReadFrequently: true,
@@ -145,8 +143,6 @@ export class MagicWindow extends xb.Script {
145143
this.plane.position.set(0, 1.5, -1.2);
146144
this.add(this.plane);
147145

148-
this.segmenter.load();
149-
150146
// Quick keyboard control until the spatial panel lands: B cycles backdrop.
151147
this.onKeyDown_ = (event) => {
152148
if (event.key === 'b' || event.key === 'B') {
@@ -250,10 +246,14 @@ export class MagicWindow extends xb.Script {
250246
}
251247

252248
updateMask_() {
253-
if (!this.segmenter.isReady) {
249+
// The Segmenter's own update() loop keeps latestMask fresh at its
250+
// configured cadence. Reading it here is a synchronous poll — no
251+
// MediaPipe inference is triggered by this call.
252+
const segmentation = xb.core.world?.segmentation;
253+
if (!segmentation) {
254254
return;
255255
}
256-
const mask = this.segmenter.segment(this.frameCanvas);
256+
const mask = segmentation.latestMask;
257257
if (!mask) {
258258
return;
259259
}
@@ -283,7 +283,6 @@ export class MagicWindow extends xb.Script {
283283

284284
dispose() {
285285
window.removeEventListener('keydown', this.onKeyDown_);
286-
this.segmenter.dispose();
287286
this.cameraTexture?.dispose();
288287
this.maskTexture?.dispose();
289288
this.plane.geometry.dispose();

demos/magic_window/SegmenterController.js

Lines changed: 0 additions & 98 deletions
This file was deleted.

demos/magic_window/main.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import {ControlPanel} from './ControlPanel.js';
44
import {MagicWindow} from './MagicWindow.js';
55

66
const options = new xb.Options();
7-
// The device camera feeds the segmenter, so we want a person in frame. The
8-
// user-facing mode pulls the real webcam through the camera module (on desktop
9-
// `facingMode: 'user'` skips the simulator camera and goes straight to
10-
// getUserMedia), giving an actual person to cut out.
7+
// Enable the SDK's segmentation primitive (MediaPipe person/background masks).
8+
// It turns the camera on for us; we then override to the user-facing camera so
9+
// there's a person to cut out (on desktop `facingMode: 'user'` skips the
10+
// simulator camera and goes straight to getUserMedia).
11+
options.enableSegmentation();
1112
options.enableCamera('user');
1213
options.setAppTitle('Magic Window');
1314
options.setAppDescription(

src/core/Options.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,19 @@ export class Options {
345345
return this;
346346
}
347347

348+
/**
349+
* Enables semantic segmentation. Produces per-pixel person / background
350+
* category masks from the device camera (MediaPipe, on-device). Unlike face
351+
* and human detection it does not require depth.
352+
* @returns The instance for chaining.
353+
*/
354+
enableSegmentation() {
355+
this.permissions.camera = true;
356+
this.enableCamera();
357+
this.world.enableSegmentation();
358+
return this;
359+
}
360+
348361
/**
349362
* Enables device camera (passthrough) with a specific facing mode.
350363
* @param facingMode - The desired camera facing mode, either 'environment' or

src/world/World.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {placeOnHorizontalSurface} from './HorizontalPlacement';
1515
// import { LightEstimation } from '/lighting/LightEstimation.js';
1616
import {HumanRecognizer} from './humans/HumanRecognizer';
1717
import {FaceRecognizer} from './faces/FaceRecognizer';
18+
import {Segmenter} from './segmentation/Segmenter';
1819

1920
/**
2021
* Manages all interactions with the real-world environment perceived by the XR
@@ -78,6 +79,11 @@ export class World extends Script {
7879
*/
7980
faces?: FaceRecognizer;
8081

82+
/**
83+
* The semantic segmentation module instance. Null if not enabled.
84+
*/
85+
segmentation?: Segmenter;
86+
8187
/**
8288
* A Three.js Raycaster for performing intersection tests.
8389
*/
@@ -153,6 +159,11 @@ export class World extends Script {
153159
this.add(this.faces);
154160
}
155161

162+
if (this.options.segmentation.enabled) {
163+
this.segmentation = new Segmenter();
164+
this.add(this.segmentation);
165+
}
166+
156167
// TODO: Initialize other modules as they are available & implemented.
157168
/*
158169

src/world/WorldOptions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {PlanesOptions} from './planes/PlanesOptions';
77
import {SoundsOptions} from './sounds/SoundsOptions';
88
import {HumansOptions} from './humans/HumansOptions';
99
import {FacesOptions} from './faces/FacesOptions';
10+
import {SegmentationOptions} from './segmentation/SegmentationOptions';
1011

1112
export class WorldOptions {
1213
debugging = false;
@@ -18,6 +19,7 @@ export class WorldOptions {
1819
sounds = new SoundsOptions();
1920
humans = new HumansOptions();
2021
faces = new FacesOptions();
22+
segmentation = new SegmentationOptions();
2123

2224
constructor(options?: DeepPartial<WorldOptions>) {
2325
if (options) {
@@ -78,4 +80,13 @@ export class WorldOptions {
7880
this.faces.enable();
7981
return this;
8082
}
83+
84+
/**
85+
* Enables semantic segmentation (person / background category masks).
86+
*/
87+
enableSegmentation() {
88+
this.enabled = true;
89+
this.segmentation.enable();
90+
return this;
91+
}
8192
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {describe, it, expect} from 'vitest';
2+
3+
import {SegmentCategory} from './SegmentationMask';
4+
5+
describe('SegmentCategory', () => {
6+
it('matches the selfie multiclass model index ordering', () => {
7+
// These indices are dictated by the model output, do not renumber.
8+
expect(SegmentCategory.Background).toBe(0);
9+
expect(SegmentCategory.Hair).toBe(1);
10+
expect(SegmentCategory.BodySkin).toBe(2);
11+
expect(SegmentCategory.FaceSkin).toBe(3);
12+
expect(SegmentCategory.Clothes).toBe(4);
13+
expect(SegmentCategory.Others).toBe(5);
14+
});
15+
16+
it('treats only background as non-person (index 0)', () => {
17+
// Convention used by consumers: anything >= 1 is part of a person.
18+
expect(SegmentCategory.Background).toBe(0);
19+
expect(SegmentCategory.Hair).toBeGreaterThanOrEqual(1);
20+
expect(SegmentCategory.Others).toBeGreaterThanOrEqual(1);
21+
});
22+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Per-pixel semantic categories emitted by the selfie multiclass segmentation
3+
* model. Index `0` is the background; every other index is part of a person,
4+
* so anything `>= 1` can be treated as foreground.
5+
*/
6+
export enum SegmentCategory {
7+
Background = 0,
8+
Hair = 1,
9+
BodySkin = 2,
10+
FaceSkin = 3,
11+
Clothes = 4,
12+
Others = 5,
13+
}
14+
15+
/**
16+
* A single-frame segmentation result: a tightly packed, row-major map of
17+
* per-pixel {@link SegmentCategory} indices at the given resolution.
18+
*/
19+
export interface SegmentationMask {
20+
/** Row-major per-pixel category indices, length `width * height`. */
21+
data: Uint8Array;
22+
/** Mask width in pixels. */
23+
width: number;
24+
/** Mask height in pixels. */
25+
height: number;
26+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {describe, it, expect} from 'vitest';
2+
3+
import {SegmentationOptions} from './SegmentationOptions';
4+
5+
describe('SegmentationOptions', () => {
6+
it('disables segmentation by default', () => {
7+
const opts = new SegmentationOptions();
8+
expect(opts.enabled).toBe(false);
9+
});
10+
11+
it('defaults to the mediapipe backend with the selfie multiclass model', () => {
12+
const opts = new SegmentationOptions();
13+
expect(opts.backendConfig.activeBackend).toBe('mediapipe');
14+
expect(opts.backendConfig.mediapipe.modelAssetPath).toContain(
15+
'selfie_multiclass'
16+
);
17+
expect(opts.backendConfig.mediapipe.wasmFilesUrl).toContain(
18+
'@mediapipe/tasks-vision'
19+
);
20+
});
21+
22+
it('outputs the category mask by default', () => {
23+
// The category mask is what produces a SegmentationMask, so it must be on
24+
// out of the box.
25+
const opts = new SegmentationOptions();
26+
expect(opts.backendConfig.mediapipe.outputCategoryMask).toBe(true);
27+
});
28+
29+
it('enable() turns segmentation on and returns the instance', () => {
30+
const opts = new SegmentationOptions();
31+
const returned = opts.enable();
32+
expect(opts.enabled).toBe(true);
33+
expect(returned).toBe(opts);
34+
});
35+
36+
it('defaults pollingIntervalMs to 66 (~15 fps)', () => {
37+
const opts = new SegmentationOptions();
38+
expect(opts.pollingIntervalMs).toBe(66);
39+
});
40+
41+
it('deep-merges constructor overrides while keeping unspecified defaults', () => {
42+
const opts = new SegmentationOptions({
43+
backendConfig: {
44+
mediapipe: {
45+
modelAssetPath: 'model://custom',
46+
},
47+
},
48+
});
49+
// Overridden field takes the new value.
50+
expect(opts.backendConfig.mediapipe.modelAssetPath).toBe('model://custom');
51+
// Untouched fields keep their defaults.
52+
expect(opts.backendConfig.mediapipe.outputCategoryMask).toBe(true);
53+
expect(opts.backendConfig.activeBackend).toBe('mediapipe');
54+
});
55+
56+
it('allows overriding pollingIntervalMs via constructor', () => {
57+
const opts = new SegmentationOptions({pollingIntervalMs: 100});
58+
expect(opts.pollingIntervalMs).toBe(100);
59+
// Other defaults are untouched.
60+
expect(opts.backendConfig.activeBackend).toBe('mediapipe');
61+
expect(opts.enabled).toBe(false);
62+
});
63+
});

0 commit comments

Comments
 (0)