a scrappy gimbal that insults you in shakespearean english
at main 7.5 kB view raw
1import "./global.css"; 2import * as faceapi from "face-api.js"; 3 4let port: SerialPort; 5let videoElement: HTMLVideoElement; 6let lastError = 0; 7let integral = 0; 8let isDragging = false; 9let joystickX = 0; 10let joystickY = 0; 11 12// PID constants 13const Kp = 0.5; 14const Ki = 0.1; 15const Kd = 0.2; 16 17// Check if Serial API is supported 18if (!("serial" in navigator)) { 19 alert( 20 "Web Serial API is not supported in this browser. Please use Chrome or Edge.", 21 ); 22} 23 24const createTemplate = () => ` 25 <div style="display: flex; flex-direction: row; height: 100vh;"> 26 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 20px;"> 27 <button id="connect">Connect Serial Port</button> 28 <button id="startTracking">Start Face Tracking</button> 29 <video id="webcam" width="640" height="480" autoplay muted></video> 30 <canvas id="overlay" style="position: absolute;"></canvas> 31 32 <div id="joystick" style="width: 200px; height: 200px; border: 2px solid black; border-radius: 50%; position: relative; margin: 20px;"> 33 <div id="joystickHandle" style="width: 20px; height: 20px; background: red; border-radius: 50%; position: absolute; top: 90px; left: 90px; cursor: pointer;"></div> 34 </div> 35 <div> 36 <span>Motor Values - X: </span><span id="motor1Value">0</span> 37 <span>, Y: </span><span id="motor2Value">0</span> 38 <button id="sendJoystick">Send</button> 39 </div> 40 </div> 41 <div style="width: 300px; padding: 20px; border-left: 1px solid #ccc; overflow-y: auto;"> 42 <h3>Serial Log</h3> 43 <pre id="serialLog" style="white-space: pre-wrap; margin: 0;"></pre> 44 </div> 45 </div> 46`; 47 48async function loadFaceDetectionModels() { 49 await faceapi.nets.tinyFaceDetector.loadFromUri("/models"); 50 await faceapi.nets.faceLandmark68Net.loadFromUri("/models"); 51} 52 53async function startWebcam() { 54 try { 55 const stream = await navigator.mediaDevices.getUserMedia({ video: true }); 56 videoElement.srcObject = stream; 57 } catch (err) { 58 console.error("Error accessing webcam:", err); 59 alert("Failed to access webcam"); 60 } 61} 62 63function calculatePID(error: number) { 64 integral += error; 65 const derivative = error - lastError; 66 lastError = error; 67 68 return Kp * error + Ki * integral + Kd * derivative; 69} 70 71async function trackFaces() { 72 const canvas = document.getElementById("overlay") as HTMLCanvasElement; 73 canvas.width = videoElement.width; 74 canvas.height = videoElement.height; 75 const displaySize = { 76 width: videoElement.width, 77 height: videoElement.height, 78 }; 79 80 setInterval(async () => { 81 const detections = await faceapi.detectAllFaces( 82 videoElement, 83 new faceapi.TinyFaceDetectorOptions(), 84 ); 85 86 if (detections.length > 0) { 87 const face = detections[0]; 88 const centerX = face.box.x + face.box.width / 2; 89 const targetX = videoElement.width / 2; 90 const error = (centerX - targetX) / videoElement.width; 91 92 const adjustment = calculatePID(error); 93 await sendMotorCommand(1, adjustment); 94 await sendMotorCommand(2, -adjustment); 95 96 // Draw face detection 97 const context = canvas.getContext("2d"); 98 if (context) { 99 context.clearRect(0, 0, canvas.width, canvas.height); 100 faceapi.draw.drawDetections(canvas, detections); 101 } 102 } 103 }, 100); 104} 105 106async function connectSerial() { 107 try { 108 port = await navigator.serial.requestPort(); 109 await port.open({ baudRate: 115200 }); 110 111 if (port.writable == null) { 112 throw new Error("Failed to open serial port - port is not writable"); 113 } 114 115 console.log("Connected to serial port"); 116 appendToLog("Connected to serial port"); 117 118 while (port.readable) { 119 const reader = port.readable.getReader(); 120 try { 121 while (true) { 122 const { value, done } = await reader.read(); 123 if (done) break; 124 const decoded = new TextDecoder().decode(value); 125 appendToLog(decoded); 126 } 127 } catch (error) { 128 console.error(error); 129 } finally { 130 reader.releaseLock(); 131 } 132 } 133 } catch (err) { 134 console.error("Serial port error:", err); 135 alert( 136 "Failed to open serial port. Please check your connection and permissions.", 137 ); 138 } 139} 140 141function appendToLog(message: string) { 142 const log = document.getElementById("serialLog"); 143 if (log) { 144 log.textContent += message + "\n"; 145 log.scrollTop = log.scrollHeight; 146 } 147} 148 149async function sendMotorCommand(motorNum: number, rotation: number) { 150 if (!port) { 151 alert("Please connect serial port first"); 152 return; 153 } 154 155 if (!port.writable) { 156 alert("Serial port is not writable"); 157 return; 158 } 159 160 const writer = port.writable.getWriter(); 161 const encoder = new TextEncoder(); 162 const data = `${motorNum} ${rotation}\r`; 163 164 try { 165 await writer.write(encoder.encode(data)); 166 appendToLog(`Sent to motor ${motorNum}: ${rotation} rotations`); 167 } catch (err) { 168 console.error("Write error:", err); 169 appendToLog(`Error sending to motor ${motorNum}: ${err}`); 170 } finally { 171 writer.releaseLock(); 172 } 173} 174 175function updateJoystickPosition(x: number, y: number) { 176 const joystick = document.getElementById("joystick"); 177 const handle = document.getElementById("joystickHandle"); 178 if (!joystick || !handle) return; 179 180 const bounds = joystick.getBoundingClientRect(); 181 const radius = bounds.width / 2; 182 183 // Calculate relative position from center 184 const relX = x - bounds.left - radius; 185 const relY = y - bounds.top - radius; 186 187 // Calculate distance from center 188 const distance = Math.sqrt(relX * relX + relY * relY); 189 190 // Normalize to radius if outside circle 191 const normalizedX = distance > radius ? (relX / distance) * radius : relX; 192 const normalizedY = distance > radius ? (relY / distance) * radius : relY; 193 194 // Update handle position 195 handle.style.left = normalizedX + radius - 10 + "px"; 196 handle.style.top = normalizedY + radius - 10 + "px"; 197 198 // Update values (-0.5 to 0.5 range) 199 joystickX = normalizedX / (radius * 2); 200 joystickY = normalizedY / (radius * 2); 201 202 document.getElementById("motor1Value")!.textContent = joystickX.toFixed(2); 203 document.getElementById("motor2Value")!.textContent = joystickY.toFixed(2); 204} 205 206function defaultPageRender() { 207 const app = document.querySelector<HTMLDivElement>("#app"); 208 if (!app) throw new Error("App element not found"); 209 app.innerHTML = createTemplate(); 210 211 videoElement = document.getElementById("webcam") as HTMLVideoElement; 212 213 const joystick = document.getElementById("joystick"); 214 const handle = document.getElementById("joystickHandle"); 215 216 if (joystick && handle) { 217 handle.addEventListener("mousedown", () => { 218 isDragging = true; 219 }); 220 221 document.addEventListener("mousemove", (e) => { 222 if (isDragging) { 223 updateJoystickPosition(e.clientX, e.clientY); 224 } 225 }); 226 227 document.addEventListener("mouseup", () => { 228 if (isDragging) { 229 isDragging = false; 230 } 231 }); 232 } 233 234 document.getElementById("connect")?.addEventListener("click", connectSerial); 235 document 236 .getElementById("startTracking") 237 ?.addEventListener("click", async () => { 238 await loadFaceDetectionModels(); 239 await startWebcam(); 240 trackFaces(); 241 }); 242 document.getElementById("sendJoystick")?.addEventListener("click", () => { 243 sendMotorCommand(1, joystickX); 244 sendMotorCommand(2, joystickY); 245 }); 246 247 document.addEventListener("keydown", (e) => { 248 if (e.key === "Enter") { 249 sendMotorCommand(1, joystickX); 250 sendMotorCommand(2, joystickY); 251 } 252 }); 253} 254 255function handleRoute() { 256 defaultPageRender(); 257} 258 259handleRoute();