@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/README.md +30 -3
- package/dist/backend-wasm.js +1 -1
- package/dist/backend-webgpu.js +1 -1
- package/dist/composition.js +143 -11
- package/dist/posenet.js +1 -1
- package/dist/runtime.js +2 -2
- package/dist/tm.js +143 -11
- package/dist/types/extension.d.ts +70 -0
- package/package.json +1 -1
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.
|
|
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",
|
|
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.
|
|
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
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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.
|
|
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",
|