|
| 1 | +const BlockType = require("../../../../../../scratch-editor/packages/scratch-vm/src/extension-support/block-type"); |
| 2 | +const ArgumentType = require( |
| 3 | + "../../../../../../scratch-editor/packages/scratch-vm/src/extension-support/argument-type", |
| 4 | +); |
| 5 | +const io = require("../socket.io.min.js"); |
| 6 | +const Video = require("../../../../../../scratch-editor/packages/scratch-vm/src/io/video"); |
| 7 | +const Rectangle = require("../../../../../../scratch-editor/packages/scratch-render/src/Rectangle.js"); |
| 8 | +const StageLayering = require("../../../../../../scratch-editor/packages/scratch-vm/src/engine/stage-layering.js"); |
| 9 | +const { Detection, MODEL_LABELS } = require("./object_detection"); |
| 10 | + |
| 11 | +/** |
| 12 | + * Url of icon to be displayed at the left edge of each extension block. |
| 13 | + * @type {string} |
| 14 | + */ |
| 15 | +// eslint-disable-next-line max-len |
| 16 | +const iconURI = ""; |
| 17 | + |
| 18 | +/** |
| 19 | + * Url of icon to be displayed in the toolbox menu for the extension category. |
| 20 | + * @type {string} |
| 21 | + */ |
| 22 | +// eslint-disable-next-line max-len |
| 23 | +const menuIconURI = ""; |
| 24 | + |
| 25 | +const wsServerURL = `${window.location.protocol}//${window.location.hostname}:7000`; |
| 26 | + |
| 27 | +/** |
| 28 | + * RGB color constants for confidence visualization |
| 29 | + */ |
| 30 | +const RGB_COLORS = { |
| 31 | + RED: { r: 1.0, g: 0.0, b: 0.0 }, |
| 32 | + ORANGE: { r: 1.0, g: 0.5, b: 0.0 }, |
| 33 | + GREEN: { r: 0.0, g: 1.0, b: 0.0 }, |
| 34 | +}; |
| 35 | + |
| 36 | +class ArduinoObjectDetection { |
| 37 | + constructor(runtime) { |
| 38 | + this.runtime = runtime; |
| 39 | + |
| 40 | + /** @type {Array<Detection>} */ |
| 41 | + this.detectedObjects = []; |
| 42 | + |
| 43 | + this._penSkinId = null; |
| 44 | + |
| 45 | + this._isfaceDetected = false; |
| 46 | + |
| 47 | + /** @type {number|null} */ |
| 48 | + this._loopIntervalId = null; |
| 49 | + |
| 50 | + /** @type {boolean} */ |
| 51 | + this._isLoopRunning = false; |
| 52 | + |
| 53 | + /** @type {boolean} */ |
| 54 | + this._enableBoundingBoxes = true; |
| 55 | + |
| 56 | + this.runtime.on("PROJECT_LOADED", () => { |
| 57 | + if (!this.runtime.renderer) { |
| 58 | + console.log("Renderer is NOT available in runtime."); |
| 59 | + return; |
| 60 | + } |
| 61 | + if (!this._penSkinId) { |
| 62 | + this._penSkinId = this.runtime.renderer.createPenSkin(); |
| 63 | + this.penDrawableId = this.runtime.renderer.createDrawable(StageLayering.PEN_LAYER); |
| 64 | + this.runtime.renderer.updateDrawableSkinId(this.penDrawableId, this._penSkinId); |
| 65 | + } |
| 66 | + }); |
| 67 | + |
| 68 | + this.io = io(wsServerURL, { |
| 69 | + path: "/socket.io", |
| 70 | + transports: ["polling", "websocket"], |
| 71 | + autoConnect: true, |
| 72 | + }); |
| 73 | + |
| 74 | + this.io.on("detection_result", (data) => { |
| 75 | + this.detectedObjects = []; |
| 76 | + |
| 77 | + this._clearBoundingBoxes(); |
| 78 | + |
| 79 | + data.detection.forEach((detection) => { |
| 80 | + const [x1, y1, x2, y2] = detection.bounding_box_xyxy; |
| 81 | + |
| 82 | + const detectionObject = new Detection( |
| 83 | + detection.class_name, |
| 84 | + this._createRectangleFromBoundingBox(x1, y1, x2, y2), |
| 85 | + parseFloat(detection.confidence), |
| 86 | + ); |
| 87 | + this.detectedObjects.push(detectionObject); |
| 88 | + |
| 89 | + console.log( |
| 90 | + `Detected ${detectionObject.label} with confidence ${ |
| 91 | + detectionObject.confidence.toFixed(2) |
| 92 | + } took ${data.processing_time}`, |
| 93 | + ); |
| 94 | + }); |
| 95 | + |
| 96 | + const personDetected = this.detectedObjects.some(detectionObject => |
| 97 | + detectionObject.label === MODEL_LABELS.PERSON |
| 98 | + ); |
| 99 | + this._isfaceDetected = personDetected; |
| 100 | + |
| 101 | + if (this._enableBoundingBoxes) { |
| 102 | + this._drawBoundingBoxes(); |
| 103 | + } else { |
| 104 | + this._clearBoundingBoxes(); |
| 105 | + } |
| 106 | + }); |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +ArduinoObjectDetection.prototype.getInfo = function() { |
| 111 | + return { |
| 112 | + id: "ArduinoObjectDetection", |
| 113 | + name: "Arduino Object Detection", |
| 114 | + menuIconURI: menuIconURI, |
| 115 | + blockIconURI: iconURI, |
| 116 | + blocks: [ |
| 117 | + { |
| 118 | + opcode: "whenPersonDetected", |
| 119 | + blockType: BlockType.HAT, |
| 120 | + text: "when person detected", |
| 121 | + func: "whenPersonDetected", |
| 122 | + arguments: {}, |
| 123 | + }, |
| 124 | + { |
| 125 | + opcode: "startDetectionLoop", |
| 126 | + blockType: BlockType.COMMAND, |
| 127 | + text: "start detection", |
| 128 | + func: "startDetectionLoop", |
| 129 | + arguments: {}, |
| 130 | + }, |
| 131 | + { |
| 132 | + opcode: "stopDetectionLoop", |
| 133 | + blockType: BlockType.COMMAND, |
| 134 | + text: "stop detection", |
| 135 | + func: "stopDetectionLoop", |
| 136 | + arguments: {}, |
| 137 | + }, |
| 138 | + { |
| 139 | + opcode: "isPersonDetected", |
| 140 | + blockType: BlockType.BOOLEAN, |
| 141 | + text: "is person detected", |
| 142 | + func: "isPersonDetected", |
| 143 | + arguments: {}, |
| 144 | + }, |
| 145 | + { |
| 146 | + opcode: "showBoundingBoxes", |
| 147 | + blockType: BlockType.COMMAND, |
| 148 | + text: "show bounding boxes", |
| 149 | + func: "showBoundingBoxes", |
| 150 | + arguments: {}, |
| 151 | + }, |
| 152 | + { |
| 153 | + opcode: "hideBoundingBoxes", |
| 154 | + blockType: BlockType.COMMAND, |
| 155 | + text: "hide bounding boxes", |
| 156 | + func: "hideBoundingBoxes", |
| 157 | + arguments: {}, |
| 158 | + }, |
| 159 | + ], |
| 160 | + menus: { |
| 161 | + modelsLabels: Object.values(MODEL_LABELS).sort(), |
| 162 | + }, |
| 163 | + }; |
| 164 | +}; |
| 165 | + |
| 166 | +ArduinoObjectDetection.prototype.startDetectionLoop = function(args) { |
| 167 | + if (this._isLoopRunning) { |
| 168 | + console.log("Detection loop is already running"); |
| 169 | + return; |
| 170 | + } |
| 171 | + |
| 172 | + this._isLoopRunning = true; |
| 173 | + this.runtime.ioDevices.video.enableVideo(); |
| 174 | + this._loop(); |
| 175 | + |
| 176 | + this._loopIntervalId = setInterval(() => { |
| 177 | + this._loop(); |
| 178 | + }, 1000); // 1000ms = 1s |
| 179 | +}; |
| 180 | + |
| 181 | +ArduinoObjectDetection.prototype.stopDetectionLoop = function(args) { |
| 182 | + this.runtime.ioDevices.video.disableVideo(); |
| 183 | + this.hideBoundingBoxes(); |
| 184 | + |
| 185 | + if (!this._isLoopRunning) { |
| 186 | + console.log("Detection loop is not running"); |
| 187 | + return; |
| 188 | + } |
| 189 | + |
| 190 | + console.log("Stopping detection loop"); |
| 191 | + this._isLoopRunning = false; |
| 192 | + if (this._loopIntervalId) { |
| 193 | + clearInterval(this._loopIntervalId); |
| 194 | + this._loopIntervalId = null; |
| 195 | + } |
| 196 | +}; |
| 197 | + |
| 198 | +ArduinoObjectDetection.prototype._loop = function() { |
| 199 | + if (!this._isLoopRunning) { |
| 200 | + return; |
| 201 | + } |
| 202 | + this._detectObjects(); |
| 203 | + |
| 204 | + // Note: The face detection state (_isfaceDetected) will be updated |
| 205 | + // automatically when the detection_result event is received |
| 206 | +}; |
| 207 | + |
| 208 | +ArduinoObjectDetection.prototype.whenPersonDetected = function(args) { |
| 209 | + return this.isPersonDetected(); |
| 210 | +}; |
| 211 | + |
| 212 | +ArduinoObjectDetection.prototype.isPersonDetected = function(args) { |
| 213 | + return this._isfaceDetected; |
| 214 | +}; |
| 215 | + |
| 216 | +ArduinoObjectDetection.prototype.hideBoundingBoxes = function(args) { |
| 217 | + this._enableBoundingBoxes = false; |
| 218 | + this._clearBoundingBoxes(); |
| 219 | +}; |
| 220 | + |
| 221 | +ArduinoObjectDetection.prototype.showBoundingBoxes = function(args) { |
| 222 | + this._enableBoundingBoxes = true; |
| 223 | + this._drawBoundingBoxes(); |
| 224 | +}; |
| 225 | + |
| 226 | +ArduinoObjectDetection.prototype._detectObjects = function(args) { |
| 227 | + if (!this.runtime.ioDevices) { |
| 228 | + console.log("No ioDevices available."); |
| 229 | + return; |
| 230 | + } |
| 231 | + const canvas = this.runtime.ioDevices.video.getFrame({ |
| 232 | + format: Video.FORMAT_CANVAS, |
| 233 | + dimensions: [480, 360], // the same as the stage resolution |
| 234 | + }); |
| 235 | + if (!canvas) { |
| 236 | + console.log("No canvas available from video frame."); |
| 237 | + return; |
| 238 | + } |
| 239 | + const dataUrl = canvas.toDataURL("image/png"); |
| 240 | + const base64Frame = dataUrl.split(",")[1]; |
| 241 | + this.io.emit("detect_objects", { image: base64Frame }); |
| 242 | +}; |
| 243 | + |
| 244 | +ArduinoObjectDetection.prototype._clearBoundingBoxes = function(args) { |
| 245 | + if (!this.runtime.renderer || !this._penSkinId) { |
| 246 | + console.log("Renderer or pen skin not available for clearing"); |
| 247 | + return; |
| 248 | + } |
| 249 | + const penSkin = this.runtime.renderer._allSkins[this._penSkinId]; |
| 250 | + if (penSkin && penSkin.clear) { |
| 251 | + penSkin.clear(); |
| 252 | + } else { |
| 253 | + console.log("Could not clear pen skin"); |
| 254 | + } |
| 255 | +}; |
| 256 | + |
| 257 | +ArduinoObjectDetection.prototype._drawBoundingBoxes = function(args) { |
| 258 | + this.detectedObjects.forEach(detectionObject => { |
| 259 | + const { r, g, b } = this._getColorByConfidence(detectionObject.confidence); |
| 260 | + const penAttributes = { |
| 261 | + color4f: [r, g, b, 1.0], |
| 262 | + diameter: 3, |
| 263 | + }; |
| 264 | + this._drawRectangleWithPen(detectionObject.rectangle, penAttributes); |
| 265 | + }); |
| 266 | +}; |
| 267 | + |
| 268 | +/** |
| 269 | + * Get pen color based on confidence level |
| 270 | + * @param {number} confidence - Confidence score (0 to 100) |
| 271 | + * @returns {Object} RGB color object {r, g, b} in 0-1 range |
| 272 | + */ |
| 273 | +ArduinoObjectDetection.prototype._getColorByConfidence = function(confidence) { |
| 274 | + if (confidence >= 90) { |
| 275 | + return RGB_COLORS.GREEN; |
| 276 | + } |
| 277 | + if (confidence >= 75 && confidence < 90) { |
| 278 | + return RGB_COLORS.ORANGE; |
| 279 | + } |
| 280 | + return RGB_COLORS.RED; |
| 281 | +}; |
| 282 | + |
| 283 | +/** |
| 284 | + * Draw a rectangle using the Rectangle class and pen system |
| 285 | + * @param {Rectangle} rectangle - Rectangle object defining the bounds |
| 286 | + * @param {Object} penAttributes - Pen drawing attributes (color, thickness) |
| 287 | + */ |
| 288 | +ArduinoObjectDetection.prototype._drawRectangleWithPen = function(rectangle, penAttributes) { |
| 289 | + if (!this.runtime.renderer || !this._penSkinId) { |
| 290 | + console.log("Renderer or pen skin not available"); |
| 291 | + return; |
| 292 | + } |
| 293 | + |
| 294 | + // TODO: Get the pen skin object in a better way |
| 295 | + const penSkin = this.runtime.renderer._allSkins[this._penSkinId]; |
| 296 | + if (!penSkin) { |
| 297 | + console.log("Pen skin not found"); |
| 298 | + return; |
| 299 | + } |
| 300 | + |
| 301 | + const left = rectangle.left; |
| 302 | + const right = rectangle.right; |
| 303 | + const bottom = rectangle.bottom; |
| 304 | + const top = rectangle.top; |
| 305 | + |
| 306 | + penSkin.drawLine(penAttributes, left, top, right, top); |
| 307 | + penSkin.drawLine(penAttributes, right, top, right, bottom); |
| 308 | + penSkin.drawLine(penAttributes, right, bottom, left, bottom); |
| 309 | + penSkin.drawLine(penAttributes, left, bottom, left, top); |
| 310 | +}; |
| 311 | + |
| 312 | +ArduinoObjectDetection.prototype._createRectangleFromBoundingBox = function(x1, y1, x2, y2) { |
| 313 | + x1 = x1 - 240; // 0-480 -> -240 to +240 |
| 314 | + y1 = -(y1 - 180); // 0-360 -> -180 to +180 |
| 315 | + x2 = x2 - 240; |
| 316 | + y2 = -(y2 - 180); |
| 317 | + |
| 318 | + const left = Math.min(x1, x2); |
| 319 | + const right = Math.max(x1, x2); |
| 320 | + const bottom = Math.min(y1, y2); |
| 321 | + const top = Math.max(y1, y2); |
| 322 | + |
| 323 | + const rectangle = new Rectangle(); |
| 324 | + rectangle.initFromBounds(left, right, bottom, top); |
| 325 | + return rectangle; |
| 326 | +}; |
| 327 | + |
| 328 | +module.exports = ArduinoObjectDetection; |
0 commit comments