@kubohiroya/turbowarp-teachable-machine 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tm.js CHANGED
@@ -553,7 +553,7 @@
553
553
  }
554
554
  var package_default = {
555
555
  name: "@kubohiroya/turbowarp-teachable-machine",
556
- version: "3.4.0",
556
+ version: "3.5.0",
557
557
  description: "A TurboWarp extension for recognition using Teachable Machine pose, image, and audio models.",
558
558
  author: "Hiroya Kubo <hiroya@cuc.ac.jp>",
559
559
  license: "MPL-2.0",
@@ -670,6 +670,33 @@
670
670
  var DOCS_URI = "https://kubohiroya.github.io/turbowarp-teachable-machine/";
671
671
  var ACCUMULATED_POSE_CHANGED_EVENT = "TM_ACCUMULATED_POSE_CHANGED";
672
672
  var BLOCK_ICON_URI = `data:image/svg+xml,${encodeURIComponent("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><g fill=\"none\" stroke=\"#fff\" stroke-width=\"5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 21V8h13M43 8h13v13M8 43v13h13M43 56h13V43M32 25v15M20 31l12 5 12-5M32 40 23 52M32 40l9 12\"/><circle cx=\"32\" cy=\"18\" r=\"5\"/></g></svg>")}`;
673
+ /**
674
+ * Put keypoints where the preview shows them: in the camera frame's own space.
675
+ *
676
+ * The model sees a square centre crop with the pixels mirrored, so a keypoint comes back measured
677
+ * from the mirrored square's left edge. Undoing the mirror and adding the crop's offset puts it
678
+ * back on the frame, which is what the preview shows and what the overlay's viewBox spans. The
679
+ * preview's own mirroring is a CSS transform over both the video and the overlay, so these
680
+ * coordinates stay in frame space either way.
681
+ */
682
+ function mapKeypointsToFrame(keypoints, crop) {
683
+ if (!Array.isArray(keypoints) || !(crop.size > 0)) return keypoints;
684
+ return keypoints.map((keypoint) => {
685
+ if (typeof keypoint !== "object" || keypoint === null) return keypoint;
686
+ const position = keypoint.position;
687
+ const x = Number(position?.x);
688
+ const y = Number(position?.y);
689
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return keypoint;
690
+ return {
691
+ ...keypoint,
692
+ position: {
693
+ ...position,
694
+ x: crop.left + (crop.size - x),
695
+ y: crop.top + y
696
+ }
697
+ };
698
+ });
699
+ }
673
700
  var BROWSER_RUNTIME_URL = `https://cdn.jsdelivr.net/npm/${package_default.name}@${package_default.version}/dist/runtime.js`;
674
701
  var POSITION_ITEMS = [
675
702
  {
@@ -1214,6 +1241,7 @@
1214
1241
  const constraints = lease ? void 0 : cameraConstraints(this.resolvedCameraSelection());
1215
1242
  if (constraints) await this.webcam.setup(constraints);
1216
1243
  else await this.webcam.setup();
1244
+ this.ensureCameraCanvas();
1217
1245
  initializeCameraReadbackContext(this.webcam.canvas);
1218
1246
  if (!lease) await this.webcam.play();
1219
1247
  this.attachPreviewToStage();
@@ -1231,6 +1259,23 @@
1231
1259
  }
1232
1260
  }
1233
1261
  /**
1262
+ * Give the camera the readback canvas `setup()` did not build.
1263
+ *
1264
+ * Upstream creates that canvas inside the same branch that opens the camera, so an injected
1265
+ * element skips the canvas with the `getUserMedia` call. 3.4.0 shipped assuming the two were
1266
+ * separate, and every leased start then failed at the first context request with "Webcam canvas
1267
+ * does not provide a 2D context" -- which is to say pose recognition did not start at all
1268
+ * wherever Camera Source was present. The canvas built here is the one upstream would have
1269
+ * built, so the mirrored centre crop every downstream reader expects is unchanged.
1270
+ */
1271
+ ensureCameraCanvas() {
1272
+ if (!this.webcam || this.webcam.canvas) return;
1273
+ const canvas = document.createElement("canvas");
1274
+ canvas.width = this.webcam.width ?? 320;
1275
+ canvas.height = this.webcam.height ?? 240;
1276
+ this.webcam.canvas = canvas;
1277
+ }
1278
+ /**
1234
1279
  * A lease on the shared camera, or null when Camera Source is not loaded.
1235
1280
  *
1236
1281
  * Camera Source owns the device and decides when a stream stops, which is what lets one camera
@@ -1629,7 +1674,7 @@
1629
1674
  if (!this.featureFlags.poseOverlay) return null;
1630
1675
  if (this.poseOverlaySvg) return this.poseOverlaySvg;
1631
1676
  const svg = this.createSvgElement("svg");
1632
- svg.setAttribute("viewBox", "0 0 320 240");
1677
+ svg.setAttribute("viewBox", `0 0 320 240`);
1633
1678
  svg.setAttribute("width", "320");
1634
1679
  svg.setAttribute("height", "240");
1635
1680
  svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
@@ -1735,11 +1780,23 @@
1735
1780
  line.style.display = "block";
1736
1781
  }
1737
1782
  }
1783
+ /**
1784
+ * The element the stage shows.
1785
+ *
1786
+ * The camera's own video, when there is one: it carries the camera's resolution and its aspect,
1787
+ * so the preview stops being a 320x240 readback canvas stretched to fit. The canvas remains the
1788
+ * preview only where there is no video to show, which is the same place inference falls back to
1789
+ * it.
1790
+ */
1791
+ previewSourceElement() {
1792
+ const video = this.webcam?.webcam;
1793
+ return video && video.videoWidth > 0 ? video : this.webcam?.canvas;
1794
+ }
1738
1795
  attachPreviewToStage() {
1739
1796
  if (!this.webcam) throw new Error("TM: Start the camera before attaching the preview.");
1740
1797
  if (!this.webcam.canvas) throw new Error("TM: webcam.canvas is unavailable.");
1741
1798
  const stage = this.findStageElement();
1742
- const canvas = this.webcam.canvas;
1799
+ const canvas = this.previewSourceElement();
1743
1800
  let stageCanvas = null;
1744
1801
  try {
1745
1802
  const candidate = this.findLikelyStageCanvas();
@@ -1783,6 +1840,22 @@
1783
1840
  this.updatePoseOverlayVisibility();
1784
1841
  this.validatePreviewAttachment(stage, canvas);
1785
1842
  }
1843
+ /**
1844
+ * Span the overlay over what the preview is showing.
1845
+ *
1846
+ * Keypoints are in the camera frame's space, so the viewBox has to be the frame -- the video's
1847
+ * own width and height, not the readback canvas's fixed 320x240. On the canvas fallback the two
1848
+ * are the same thing.
1849
+ */
1850
+ updatePoseOverlayViewBox() {
1851
+ const svg = this.poseOverlaySvg;
1852
+ const preview = this.previewCanvas;
1853
+ if (!svg || !preview) return;
1854
+ const width = preview.videoWidth ?? preview.width;
1855
+ const height = preview.videoHeight ?? preview.height;
1856
+ if (!(width > 0) || !(height > 0)) return;
1857
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
1858
+ }
1786
1859
  updatePreviewStyle() {
1787
1860
  const canvas = this.previewCanvas;
1788
1861
  if (!canvas) throw new Error("TM: Start the camera before positioning the preview.");
@@ -1840,9 +1913,10 @@
1840
1913
  target.style.bottom = "8px";
1841
1914
  }
1842
1915
  }
1843
- const mirroringTransform = this.previewMirrored ? "" : "scaleX(-1)";
1916
+ const mirroringTransform = this.previewCanvas === this.webcam?.webcam ? this.previewMirrored ? "scaleX(-1)" : "" : this.previewMirrored ? "" : "scaleX(-1)";
1844
1917
  for (const target of targets) target.style.transform = [positionTransform, mirroringTransform].filter(Boolean).join(" ");
1845
1918
  this.poseOverlaySvg?.setAttribute("preserveAspectRatio", this.previewPosition === "full-stage" ? "xMidYMid slice" : "xMidYMid meet");
1919
+ this.updatePoseOverlayViewBox();
1846
1920
  }
1847
1921
  startLoopIfNeeded() {
1848
1922
  if (this.loopStarted || !this.cameraRunning || !this.webcam) return;
@@ -1875,7 +1949,7 @@
1875
1949
  return;
1876
1950
  }
1877
1951
  try {
1878
- this.webcam.update();
1952
+ if (this.previewSourceElement() !== this.webcam.webcam) this.webcam.update();
1879
1953
  if (this.recognizing && this.model) {
1880
1954
  const model = this.model;
1881
1955
  const recognitionMode = this.recognitionMode;
@@ -1894,15 +1968,73 @@
1894
1968
  if (generation === this.loopGeneration && this.cameraRunning && this.webcam) requestAnimationFrame(() => void this.loop(generation));
1895
1969
  else if (generation === this.loopGeneration) this.loopStarted = false;
1896
1970
  }
1971
+ /**
1972
+ * The square the model looks at, as a tensor, taken straight off the camera.
1973
+ *
1974
+ * The readback canvas is 320x240 and holds a 320x320 centre crop drawn into it, so its bottom
1975
+ * quarter -- 25% of the subject's height -- never reaches the model. Cropping the frame itself
1976
+ * keeps the whole square. The crop and the mirroring both run as tensor ops, which is where the
1977
+ * backend can keep them on the GPU; `fromPixels` uploads a video element directly to a texture
1978
+ * on the WebGL and WebGPU backends, so no Canvas2D stage sits in the path.
1979
+ *
1980
+ * The pixels are mirrored here rather than through `estimatePose(sample, flipHorizontal)`:
1981
+ * that flag flips the returned keypoints, not the image, and the classifier head was trained on
1982
+ * mirrored frames. Flipping the pixels is what keeps the model's input the same as before.
1983
+ *
1984
+ * Returns null when there is no frame to take, and the canvas path answers instead.
1985
+ */
1986
+ captureSquareFrame() {
1987
+ const tensorflow = globalThis.tf;
1988
+ const video = this.webcam?.webcam;
1989
+ if (typeof tensorflow?.browser?.fromPixels !== "function" || typeof tensorflow.tidy !== "function" || !video || !(video.videoWidth > 0) || !(video.videoHeight > 0)) return null;
1990
+ const size = Math.min(video.videoWidth, video.videoHeight);
1991
+ const frame = tensorflow.browser.fromPixels(video);
1992
+ try {
1993
+ const top = frame.shape[0] - size >> 1;
1994
+ const left = frame.shape[1] - size >> 1;
1995
+ return {
1996
+ tensor: tensorflow.tidy(() => tensorflow.reverse(tensorflow.slice(frame, [
1997
+ top,
1998
+ left,
1999
+ 0
2000
+ ], [
2001
+ size,
2002
+ size,
2003
+ 3
2004
+ ]), [1])),
2005
+ crop: {
2006
+ left,
2007
+ top,
2008
+ size
2009
+ }
2010
+ };
2011
+ } finally {
2012
+ frame.dispose();
2013
+ }
2014
+ }
1897
2015
  async recognizeFrame(model, recognitionMode) {
1898
2016
  if (recognitionMode === "pose") {
1899
2017
  if (typeof model.estimatePose !== "function") throw new Error("Teachable Machine: The active model does not support pose recognition.");
1900
- const estimate = await model.estimatePose(this.webcam.canvas);
1901
- const prediction = await model.predict(estimate.posenetOutput);
1902
- return {
1903
- keypoints: estimate.pose?.keypoints,
1904
- prediction
1905
- };
2018
+ const square = this.captureSquareFrame();
2019
+ if (!square) {
2020
+ this.webcam.update?.();
2021
+ const estimate = await model.estimatePose(this.webcam.canvas);
2022
+ const prediction = await model.predict(estimate.posenetOutput);
2023
+ return {
2024
+ keypoints: estimate.pose?.keypoints,
2025
+ prediction
2026
+ };
2027
+ }
2028
+ try {
2029
+ const estimate = await model.estimatePose(square.tensor);
2030
+ const prediction = await model.predict(estimate.posenetOutput);
2031
+ return {
2032
+ keypoints: mapKeypointsToFrame(estimate.pose?.keypoints, square.crop),
2033
+ prediction
2034
+ };
2035
+ } finally {
2036
+ square.tensor.dispose();
2037
+ }
1906
2038
  }
1907
2039
  const source = this.webcam.webcam ?? this.webcam.canvas;
1908
2040
  return {
@@ -5,8 +5,27 @@ export declare const EXTENSION_ID = "kubohiroyatm";
5
5
  export declare const VERSION: string;
6
6
  export declare const DOCS_URI = "https://kubohiroya.github.io/turbowarp-teachable-machine/";
7
7
  export declare const ACCUMULATED_POSE_CHANGED_EVENT = "TM_ACCUMULATED_POSE_CHANGED";
8
+ /** The readback canvas size every downstream frame reader is written against. */
9
+ export declare const CAMERA_FRAME_WIDTH = 320;
10
+ export declare const CAMERA_FRAME_HEIGHT = 240;
8
11
  export declare const BLOCK_ICON_URI: string;
9
12
  export type RecognitionMode = 'pose' | 'image' | 'audio';
13
+ /** Where the square the model saw sits inside the camera's frame. */
14
+ export interface PoseCropWindow {
15
+ readonly left: number;
16
+ readonly top: number;
17
+ readonly size: number;
18
+ }
19
+ /**
20
+ * Put keypoints where the preview shows them: in the camera frame's own space.
21
+ *
22
+ * The model sees a square centre crop with the pixels mirrored, so a keypoint comes back measured
23
+ * from the mirrored square's left edge. Undoing the mirror and adding the crop's offset puts it
24
+ * back on the frame, which is what the preview shows and what the overlay's viewBox spans. The
25
+ * preview's own mirroring is a CSS transform over both the video and the overlay, so these
26
+ * coordinates stay in frame space either way.
27
+ */
28
+ export declare function mapKeypointsToFrame(keypoints: unknown, crop: PoseCropWindow): unknown;
10
29
  export interface TeachableMachineRuntime {
11
30
  Webcam: new (width: number, height: number, flipHorizontal: boolean) => any;
12
31
  load?(modelURL: string, metadataURL: string): Promise<any>;
@@ -152,6 +171,17 @@ export declare class TMExtension {
152
171
  ensureLibrariesLoaded(): Promise<void>;
153
172
  cleanupCameraResources(): void;
154
173
  startCamera(): Promise<void>;
174
+ /**
175
+ * Give the camera the readback canvas `setup()` did not build.
176
+ *
177
+ * Upstream creates that canvas inside the same branch that opens the camera, so an injected
178
+ * element skips the canvas with the `getUserMedia` call. 3.4.0 shipped assuming the two were
179
+ * separate, and every leased start then failed at the first context request with "Webcam canvas
180
+ * does not provide a 2D context" -- which is to say pose recognition did not start at all
181
+ * wherever Camera Source was present. The canvas built here is the one upstream would have
182
+ * built, so the mirrored centre crop every downstream reader expects is unchanged.
183
+ */
184
+ ensureCameraCanvas(): void;
155
185
  /**
156
186
  * A lease on the shared camera, or null when Camera Source is not loaded.
157
187
  *
@@ -217,12 +247,52 @@ export declare class TMExtension {
217
247
  clearPoseOverlay(): void;
218
248
  redrawPoseOverlay(): void;
219
249
  renderPoseOverlay(keypoints: unknown): void;
250
+ /**
251
+ * The element the stage shows.
252
+ *
253
+ * The camera's own video, when there is one: it carries the camera's resolution and its aspect,
254
+ * so the preview stops being a 320x240 readback canvas stretched to fit. The canvas remains the
255
+ * preview only where there is no video to show, which is the same place inference falls back to
256
+ * it.
257
+ */
258
+ previewSourceElement(): any;
220
259
  attachPreviewToStage(): void;
260
+ /**
261
+ * Span the overlay over what the preview is showing.
262
+ *
263
+ * Keypoints are in the camera frame's space, so the viewBox has to be the frame -- the video's
264
+ * own width and height, not the readback canvas's fixed 320x240. On the canvas fallback the two
265
+ * are the same thing.
266
+ */
267
+ updatePoseOverlayViewBox(): void;
221
268
  updatePreviewStyle(): void;
222
269
  startLoopIfNeeded(): void;
223
270
  private trackPreparedModelOperation;
224
271
  waitForPreparedModelIdle(model: object): Promise<void>;
225
272
  loop(generation?: any): Promise<void>;
273
+ /**
274
+ * The square the model looks at, as a tensor, taken straight off the camera.
275
+ *
276
+ * The readback canvas is 320x240 and holds a 320x320 centre crop drawn into it, so its bottom
277
+ * quarter -- 25% of the subject's height -- never reaches the model. Cropping the frame itself
278
+ * keeps the whole square. The crop and the mirroring both run as tensor ops, which is where the
279
+ * backend can keep them on the GPU; `fromPixels` uploads a video element directly to a texture
280
+ * on the WebGL and WebGPU backends, so no Canvas2D stage sits in the path.
281
+ *
282
+ * The pixels are mirrored here rather than through `estimatePose(sample, flipHorizontal)`:
283
+ * that flag flips the returned keypoints, not the image, and the classifier head was trained on
284
+ * mirrored frames. Flipping the pixels is what keeps the model's input the same as before.
285
+ *
286
+ * Returns null when there is no frame to take, and the canvas path answers instead.
287
+ */
288
+ captureSquareFrame(): {
289
+ tensor: any;
290
+ crop: {
291
+ left: number;
292
+ top: number;
293
+ size: number;
294
+ };
295
+ } | null;
226
296
  recognizeFrame(model: any, recognitionMode: RecognitionMode): Promise<{
227
297
  keypoints: any;
228
298
  prediction: any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-teachable-machine",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "A TurboWarp extension for recognition using Teachable Machine pose, image, and audio models.",
5
5
  "author": "Hiroya Kubo <hiroya@cuc.ac.jp>",
6
6
  "license": "MPL-2.0",