octv2_server_v2.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830
  1. #!/usr/bin/env python3
  2. """
  3. OCTv2 (Oreo Cookie Thrower v2) - Raspberry Pi Server v2
  4. - ESP32 serial communication for motor control
  5. - Automatic mouth detection and targeting
  6. - Stepper motor control for rotation and elevation
  7. """
  8. import socket
  9. import json
  10. import threading
  11. import time
  12. import logging
  13. import serial
  14. import cv2
  15. import numpy as np
  16. from datetime import datetime
  17. from typing import Dict, Any, Optional, Tuple, List
  18. import io
  19. import os
  20. import math
  21. # Camera imports
  22. try:
  23. from picamera2 import Picamera2
  24. CAMERA_AVAILABLE = True
  25. except ImportError:
  26. print("Camera not available - running in simulation mode")
  27. CAMERA_AVAILABLE = False
  28. # Configure logging
  29. logging.basicConfig(
  30. level=logging.INFO,
  31. format='%(asctime)s - %(levelname)s - %(message)s'
  32. )
  33. logger = logging.getLogger(__name__)
  34. class ESP32Controller:
  35. """Handle serial communication with ESP32 for motor control"""
  36. def __init__(self, port='/dev/ttyUSB0', baudrate=115200):
  37. self.port = port
  38. self.baudrate = baudrate
  39. self.serial_conn = None
  40. self.connect()
  41. def connect(self):
  42. """Connect to ESP32 via serial"""
  43. try:
  44. self.serial_conn = serial.Serial(self.port, self.baudrate, timeout=1)
  45. time.sleep(2) # Wait for ESP32 to initialize
  46. logger.info(f"Connected to ESP32 on {self.port}")
  47. return True
  48. except Exception as e:
  49. logger.error(f"Failed to connect to ESP32: {e}")
  50. return False
  51. def send_command(self, command: str) -> str:
  52. """Send command to ESP32 and get response"""
  53. if not self.serial_conn:
  54. logger.error("ESP32 not connected")
  55. return "ERROR: Not connected"
  56. try:
  57. self.serial_conn.write(f"{command}\n".encode())
  58. response = self.serial_conn.readline().decode().strip()
  59. logger.debug(f"ESP32 Command: {command} -> Response: {response}")
  60. return response
  61. except Exception as e:
  62. logger.error(f"ESP32 communication error: {e}")
  63. return "ERROR: Communication failed"
  64. def home_motors(self) -> bool:
  65. """Home both rotation and elevation motors"""
  66. response = self.send_command("HOME")
  67. return response == "OK"
  68. def move_to_position(self, rotation_degrees: float, elevation_degrees: float) -> bool:
  69. """Move to absolute position (rotation: -90 to +90, elevation: 0 to 60)"""
  70. cmd = f"MOVE {rotation_degrees:.1f} {elevation_degrees:.1f}"
  71. response = self.send_command(cmd)
  72. return response == "OK"
  73. def move_relative(self, delta_rotation: float, delta_elevation: float) -> bool:
  74. """Move relative to current position"""
  75. cmd = f"REL {delta_rotation:.1f} {delta_elevation:.1f}"
  76. response = self.send_command(cmd)
  77. return response == "OK"
  78. def fire_oreo(self) -> bool:
  79. """Trigger the firing mechanism"""
  80. response = self.send_command("FIRE")
  81. return response == "OK"
  82. def get_position(self) -> Tuple[float, float]:
  83. """Get current position (rotation, elevation)"""
  84. response = self.send_command("POS")
  85. try:
  86. parts = response.split()
  87. if len(parts) == 2:
  88. return float(parts[0]), float(parts[1])
  89. except:
  90. pass
  91. return 0.0, 0.0
  92. class MouthDetector:
  93. """Detect open mouths in camera feed for automatic targeting"""
  94. def __init__(self):
  95. # Load OpenCV face cascade classifier
  96. self.face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
  97. # For actual open mouth detection, we'll use facial landmarks
  98. try:
  99. import dlib
  100. # Download shape predictor: http://dlib.net/files/shape_predictor_68_face_landmarks.dat.bz2
  101. self.predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")
  102. self.detector = dlib.get_frontal_face_detector()
  103. self.use_dlib = True
  104. logger.info("Using dlib for precise mouth detection")
  105. except ImportError:
  106. logger.warning("dlib not available - using basic mouth area detection")
  107. self.use_dlib = False
  108. # Camera parameters for targeting calculations
  109. self.camera_width = 640
  110. self.camera_height = 480
  111. self.center_x = self.camera_width // 2
  112. self.center_y = self.camera_height // 2
  113. # Camera-follows-aim targeting parameters
  114. self.target_deadzone_pixels = 30 # Don't adjust if mouth is within this radius of center
  115. self.max_adjustment_degrees = 10 # Maximum single adjustment per iteration
  116. # Distance estimation parameters (based on average human face size)
  117. self.average_face_width_cm = 16.0 # Average human face width
  118. self.camera_focal_length_mm = 3.04 # Pi Camera focal length
  119. self.sensor_width_mm = 3.68 # Pi Camera sensor width
  120. # Aiming offsets (configurable for mechanical compensation)
  121. self.rotation_offset_degrees = 0.0 # Adjust if camera/launcher not perfectly aligned
  122. self.elevation_offset_degrees = 0.0 # Adjust for gravity compensation
  123. self.distance_elevation_factor = 0.5 # Elevation adjustment based on distance
  124. logger.info("Mouth detector initialized")
  125. def detect_open_mouths(self, frame) -> List[Tuple[int, int, int, int, float]]:
  126. """
  127. Detect open mouths in frame
  128. Returns list of (x, y, w, h, confidence) for each detected mouth
  129. """
  130. gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  131. mouths = []
  132. if self.use_dlib:
  133. # Use dlib for precise facial landmark detection
  134. faces = self.detector(gray)
  135. for face in faces:
  136. landmarks = self.predictor(gray, face)
  137. # Get mouth landmarks (points 48-67 in 68-point model)
  138. mouth_points = []
  139. for i in range(48, 68):
  140. point = landmarks.part(i)
  141. mouth_points.append((point.x, point.y))
  142. # Advanced mouth analysis for WIDE-OPEN detection only
  143. mouth_state, confidence = self._analyze_mouth_state(landmarks)
  144. # Only target WIDE_OPEN mouths (not speaking or smiling)
  145. if mouth_state == "WIDE_OPEN":
  146. # Calculate bounding box around mouth
  147. mouth_xs = [p[0] for p in mouth_points]
  148. mouth_ys = [p[1] for p in mouth_points]
  149. mx = min(mouth_xs) - 10
  150. my = min(mouth_ys) - 10
  151. mw = max(mouth_xs) - min(mouth_xs) + 20
  152. mh = max(mouth_ys) - min(mouth_ys) + 20
  153. mouths.append((mx, my, mw, mh, confidence))
  154. else:
  155. # Fallback to basic face detection + mouth area estimation
  156. faces = self.face_cascade.detectMultiScale(gray, 1.3, 5)
  157. for (fx, fy, fw, fh) in faces:
  158. # Estimate mouth area in lower third of face
  159. mouth_x = fx + int(fw * 0.25)
  160. mouth_y = fy + int(fh * 0.6)
  161. mouth_w = int(fw * 0.5)
  162. mouth_h = int(fh * 0.3)
  163. # Use intensity variance as proxy for open mouth
  164. mouth_roi = gray[mouth_y:mouth_y+mouth_h, mouth_x:mouth_x+mouth_w]
  165. if mouth_roi.size > 0:
  166. variance = np.var(mouth_roi)
  167. # Higher variance might indicate teeth/tongue visibility
  168. confidence = min(1.0, variance / 1000.0) # Adjust threshold
  169. if confidence > 0.3: # Minimum confidence threshold
  170. mouths.append((mouth_x, mouth_y, mouth_w, mouth_h, confidence))
  171. # Sort by confidence (highest first)
  172. mouths.sort(key=lambda x: x[4], reverse=True)
  173. return mouths
  174. def _analyze_mouth_state(self, landmarks) -> Tuple[str, float]:
  175. """
  176. Analyze mouth landmarks to determine state and confidence
  177. Returns: (state, confidence) where state is CLOSED, SPEAKING, SMILING, or WIDE_OPEN
  178. """
  179. # Key mouth landmark points
  180. # Outer lip: 48-54 (top), 54-60 (bottom)
  181. # Inner lip: 60-64 (top), 64-68 (bottom)
  182. # Vertical measurements (mouth opening)
  183. outer_top = landmarks.part(51) # Top of upper lip (center)
  184. outer_bottom = landmarks.part(57) # Bottom of lower lip (center)
  185. inner_top = landmarks.part(62) # Top of inner lip
  186. inner_bottom = landmarks.part(66) # Bottom of inner lip
  187. # Horizontal measurements (mouth width)
  188. left_corner = landmarks.part(48) # Left mouth corner
  189. right_corner = landmarks.part(54) # Right mouth corner
  190. # Calculate dimensions
  191. outer_height = abs(outer_top.y - outer_bottom.y)
  192. inner_height = abs(inner_top.y - inner_bottom.y)
  193. mouth_width = abs(right_corner.x - left_corner.x)
  194. # Calculate ratios
  195. outer_aspect_ratio = outer_height / mouth_width if mouth_width > 0 else 0
  196. inner_aspect_ratio = inner_height / mouth_width if mouth_width > 0 else 0
  197. # Calculate lip separation (distance between inner and outer lip)
  198. lip_thickness_top = abs(outer_top.y - inner_top.y)
  199. lip_thickness_bottom = abs(outer_bottom.y - inner_bottom.y)
  200. avg_lip_thickness = (lip_thickness_top + lip_thickness_bottom) / 2
  201. # Determine mouth state based on multiple criteria
  202. # WIDE_OPEN: Large inner opening + significant lip separation
  203. if (inner_aspect_ratio > 0.6 and
  204. outer_aspect_ratio > 0.4 and
  205. avg_lip_thickness > 8): # Pixels of lip separation
  206. confidence = min(1.0, inner_aspect_ratio * 1.5)
  207. return "WIDE_OPEN", confidence
  208. # SPEAKING: Moderate opening but less lip separation
  209. elif (inner_aspect_ratio > 0.3 and
  210. outer_aspect_ratio > 0.2 and
  211. avg_lip_thickness > 3):
  212. confidence = min(1.0, inner_aspect_ratio * 0.8)
  213. return "SPEAKING", confidence
  214. # SMILING: Wide mouth but minimal vertical opening
  215. elif (mouth_width > 40 and # Wider than normal
  216. outer_aspect_ratio < 0.25 and
  217. inner_aspect_ratio < 0.2):
  218. # Check if corners are raised (smile detection)
  219. mouth_center_y = (outer_top.y + outer_bottom.y) / 2
  220. corner_raise = mouth_center_y - ((left_corner.y + right_corner.y) / 2)
  221. if corner_raise > 3: # Corners raised above center
  222. return "SMILING", 0.3
  223. # Default: CLOSED
  224. return "CLOSED", 0.1
  225. def estimate_distance(self, face_width_pixels: int) -> float:
  226. """
  227. Estimate distance to face based on face width in pixels
  228. Returns distance in centimeters
  229. """
  230. if face_width_pixels <= 0:
  231. return 100.0 # Default fallback distance
  232. # Distance = (real_face_width * focal_length * image_width) / (face_width_pixels * sensor_width)
  233. distance_cm = (
  234. self.average_face_width_cm *
  235. self.camera_focal_length_mm *
  236. self.camera_width
  237. ) / (face_width_pixels * self.sensor_width_mm)
  238. # Clamp to reasonable range (50cm to 500cm)
  239. return max(50.0, min(500.0, distance_cm))
  240. def calculate_centering_adjustment(self, mouth_x: int, mouth_y: int, face_width: int) -> Tuple[float, float, float]:
  241. """
  242. Calculate motor adjustments to center the mouth in camera view
  243. Returns: (rotation_adjustment, elevation_adjustment, estimated_distance)
  244. """
  245. # Calculate offset from center
  246. dx = mouth_x - self.center_x
  247. dy = mouth_y - self.center_y
  248. distance_from_center = math.sqrt(dx*dx + dy*dy)
  249. # Estimate distance for context
  250. estimated_distance = self.estimate_distance(face_width)
  251. # If mouth is already centered (within deadzone), no adjustment needed
  252. if distance_from_center < self.target_deadzone_pixels:
  253. return 0.0, 0.0, estimated_distance
  254. # Calculate adjustment angles based on pixel offset
  255. # Larger faces (closer) need smaller adjustments, smaller faces (farther) need larger adjustments
  256. distance_factor = max(0.5, min(2.0, 100.0 / estimated_distance)) # Scale adjustments by distance
  257. # Convert pixel offset to approximate angle adjustment
  258. # This is empirical - you'll need to tune these values for your setup
  259. pixels_per_degree_rotation = 15 * distance_factor # Adjust based on your camera/motor setup
  260. pixels_per_degree_elevation = 12 * distance_factor # Adjust based on your camera/motor setup
  261. rotation_adjustment = dx / pixels_per_degree_rotation
  262. elevation_adjustment = -dy / pixels_per_degree_elevation # Negative because Y increases downward
  263. # Apply configured offsets
  264. rotation_adjustment += self.rotation_offset_degrees
  265. elevation_adjustment += self.elevation_offset_degrees
  266. # Add distance-based elevation compensation (closer targets need higher elevation)
  267. distance_elevation_compensation = (200.0 - estimated_distance) * self.distance_elevation_factor / 100.0
  268. elevation_adjustment += distance_elevation_compensation
  269. # Clamp to maximum adjustment per iteration
  270. rotation_adjustment = max(-self.max_adjustment_degrees,
  271. min(self.max_adjustment_degrees, rotation_adjustment))
  272. elevation_adjustment = max(-self.max_adjustment_degrees,
  273. min(self.max_adjustment_degrees, elevation_adjustment))
  274. return rotation_adjustment, elevation_adjustment, estimated_distance
  275. class OCTv2Server:
  276. def __init__(self, host='0.0.0.0', port=8080):
  277. self.host = host
  278. self.port = port
  279. self.running = False
  280. self.clients = []
  281. # Hardware components
  282. self.esp32 = ESP32Controller()
  283. self.mouth_detector = MouthDetector()
  284. # Hardware state
  285. self.current_rotation = 0.0 # -90 to +90 degrees
  286. self.current_elevation = 30.0 # 0 to 60 degrees
  287. self.is_auto_mode = False
  288. self.is_homed = False
  289. self.auto_fire_enabled = True
  290. # Camera state
  291. self.camera = None
  292. self.streaming_clients = []
  293. self.stream_thread = None
  294. # Auto mode state
  295. self.auto_mode_thread = None
  296. self.last_target_time = 0
  297. self.target_cooldown = 2.0 # Seconds between automatic shots
  298. self.setup_camera()
  299. def setup_camera(self):
  300. """Initialize camera"""
  301. if not CAMERA_AVAILABLE:
  302. logger.info("Camera not available - simulating camera")
  303. return
  304. try:
  305. self.camera = Picamera2()
  306. config = self.camera.create_preview_configuration(
  307. main={"size": (640, 480)},
  308. lores={"size": (320, 240)},
  309. display="lores"
  310. )
  311. self.camera.configure(config)
  312. self.camera.start()
  313. logger.info("Camera initialized successfully")
  314. except Exception as e:
  315. logger.error(f"Camera setup failed: {e}")
  316. def start_server(self):
  317. """Start the TCP server"""
  318. self.running = True
  319. server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  320. server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  321. try:
  322. server_socket.bind((self.host, self.port))
  323. server_socket.listen(5)
  324. logger.info(f"OCTv2 Server v2 listening on {self.host}:{self.port}")
  325. # Start auto mode thread
  326. self.auto_mode_thread = threading.Thread(target=self.auto_mode_worker)
  327. self.auto_mode_thread.daemon = True
  328. self.auto_mode_thread.start()
  329. while self.running:
  330. try:
  331. client_socket, address = server_socket.accept()
  332. logger.info(f"Client connected from {address}")
  333. client_thread = threading.Thread(
  334. target=self.handle_client,
  335. args=(client_socket, address)
  336. )
  337. client_thread.daemon = True
  338. client_thread.start()
  339. except Exception as e:
  340. if self.running:
  341. logger.error(f"Error accepting client: {e}")
  342. except Exception as e:
  343. logger.error(f"Server error: {e}")
  344. finally:
  345. server_socket.close()
  346. self.cleanup()
  347. def handle_client(self, client_socket, address):
  348. """Handle individual client connections"""
  349. self.clients.append(client_socket)
  350. try:
  351. while self.running:
  352. data = client_socket.recv(1024)
  353. if not data:
  354. break
  355. try:
  356. command = json.loads(data.decode('utf-8'))
  357. logger.info(f"Received command: {command}")
  358. response = self.process_command(command, client_socket)
  359. if response:
  360. client_socket.send(json.dumps(response).encode('utf-8'))
  361. except json.JSONDecodeError:
  362. logger.error("Invalid JSON received")
  363. except Exception as e:
  364. logger.error(f"Error processing command: {e}")
  365. except Exception as e:
  366. logger.error(f"Client {address} error: {e}")
  367. finally:
  368. if client_socket in self.clients:
  369. self.clients.remove(client_socket)
  370. if client_socket in self.streaming_clients:
  371. self.streaming_clients.remove(client_socket)
  372. client_socket.close()
  373. logger.info(f"Client {address} disconnected")
  374. def process_command(self, command: Dict[str, Any], client_socket) -> Optional[Dict[str, Any]]:
  375. """Process commands from iOS app"""
  376. action = command.get('action')
  377. logger.info(f"Processing action: {action}")
  378. if action == 'aim_left':
  379. return self.aim_left()
  380. elif action == 'aim_right':
  381. return self.aim_right()
  382. elif action == 'fire':
  383. angle = command.get('angle', self.current_elevation)
  384. return self.fire_oreo(angle)
  385. elif action == 'home':
  386. return self.home_device()
  387. elif action == 'set_mode':
  388. mode = command.get('mode', 'manual')
  389. return self.set_mode(mode)
  390. elif action == 'capture_photo':
  391. return self.capture_photo(client_socket)
  392. elif action == 'start_video_stream':
  393. return self.start_video_stream(client_socket)
  394. elif action == 'stop_video_stream':
  395. return self.stop_video_stream(client_socket)
  396. elif action == 'status':
  397. return self.get_status()
  398. else:
  399. return {'error': f'Unknown action: {action}'}
  400. def aim_left(self) -> Dict[str, Any]:
  401. """Move aim left by small increment"""
  402. new_rotation = max(-90, self.current_rotation - 5)
  403. if self.esp32.move_to_position(new_rotation, self.current_elevation):
  404. self.current_rotation = new_rotation
  405. return {'status': 'success', 'rotation': self.current_rotation}
  406. return {'status': 'error', 'message': 'Failed to move left'}
  407. def aim_right(self) -> Dict[str, Any]:
  408. """Move aim right by small increment"""
  409. new_rotation = min(90, self.current_rotation + 5)
  410. if self.esp32.move_to_position(new_rotation, self.current_elevation):
  411. self.current_rotation = new_rotation
  412. return {'status': 'success', 'rotation': self.current_rotation}
  413. return {'status': 'error', 'message': 'Failed to move right'}
  414. def fire_oreo(self, elevation: float = None) -> Dict[str, Any]:
  415. """Fire an Oreo at the specified elevation"""
  416. if elevation is not None:
  417. # Move to target elevation first
  418. self.current_elevation = max(0, min(60, elevation))
  419. if not self.esp32.move_to_position(self.current_rotation, self.current_elevation):
  420. return {'status': 'error', 'message': 'Failed to move to position'}
  421. time.sleep(0.5) # Wait for positioning
  422. logger.info(f"FIRING OREO at rotation={self.current_rotation}°, elevation={self.current_elevation}°!")
  423. if self.esp32.fire_oreo():
  424. return {
  425. 'status': 'success',
  426. 'message': f'Oreo fired at R:{self.current_rotation}° E:{self.current_elevation}°',
  427. 'rotation': self.current_rotation,
  428. 'elevation': self.current_elevation
  429. }
  430. else:
  431. return {'status': 'error', 'message': 'Fire mechanism failed'}
  432. def home_device(self) -> Dict[str, Any]:
  433. """Home the device to its reference position"""
  434. logger.info("Homing device...")
  435. if self.esp32.home_motors():
  436. self.current_rotation = 0.0
  437. self.current_elevation = 0.0
  438. self.is_homed = True
  439. return {
  440. 'status': 'success',
  441. 'message': 'Device homed successfully',
  442. 'rotation': 0,
  443. 'elevation': 0
  444. }
  445. else:
  446. return {'status': 'error', 'message': 'Homing failed'}
  447. def set_mode(self, mode: str) -> Dict[str, Any]:
  448. """Set operating mode (auto/manual)"""
  449. self.is_auto_mode = (mode.lower() == 'auto')
  450. logger.info(f"Mode set to: {mode}")
  451. if self.is_auto_mode:
  452. logger.info("🎯 AUTOMATIC MODE ENABLED - Seeking open mouths!")
  453. else:
  454. logger.info("🎮 Manual mode enabled")
  455. return {
  456. 'status': 'success',
  457. 'mode': mode,
  458. 'auto_mode': self.is_auto_mode
  459. }
  460. def auto_mode_worker(self):
  461. """Worker thread for automatic mouth detection and firing"""
  462. while self.running:
  463. try:
  464. if self.is_auto_mode and CAMERA_AVAILABLE and self.camera:
  465. # Capture frame for analysis
  466. frame = self.camera.capture_array()
  467. # Detect open mouths
  468. mouths = self.mouth_detector.detect_open_mouths(frame)
  469. if mouths and self.auto_fire_enabled:
  470. current_time = time.time()
  471. if current_time - self.last_target_time > self.target_cooldown:
  472. # Target the most confident mouth
  473. best_mouth = mouths[0]
  474. mx, my, mw, mh, confidence = best_mouth
  475. # Calculate mouth center and face width (estimate from mouth width)
  476. mouth_center_x = mx + mw // 2
  477. mouth_center_y = my + mh // 2
  478. estimated_face_width = int(mw * 2.5) # Face is roughly 2.5x wider than mouth
  479. # Calculate centering adjustment
  480. rotation_adj, elevation_adj, distance = self.mouth_detector.calculate_centering_adjustment(
  481. mouth_center_x, mouth_center_y, estimated_face_width
  482. )
  483. logger.info(f"🎯 AUTO TARGET: Mouth detected (confidence {confidence:.2f}, distance ~{distance:.0f}cm)")
  484. # Only adjust if mouth is not already centered
  485. if abs(rotation_adj) > 0.1 or abs(elevation_adj) > 0.1:
  486. # Calculate new position with adjustments
  487. new_rotation = max(-90, min(90, self.current_rotation + rotation_adj))
  488. new_elevation = max(0, min(60, self.current_elevation + elevation_adj))
  489. logger.info(f"🎯 CENTERING: Adjusting R:{rotation_adj:+.1f}° E:{elevation_adj:+.1f}° -> R:{new_rotation:.1f}° E:{new_elevation:.1f}°")
  490. # Move to center the target
  491. if self.esp32.move_to_position(new_rotation, new_elevation):
  492. self.current_rotation = new_rotation
  493. self.current_elevation = new_elevation
  494. time.sleep(0.5) # Wait for positioning
  495. else:
  496. logger.info("🎯 TARGET CENTERED: Mouth already in optimal position")
  497. # Check if mouth is now well-centered for firing
  498. dx = mouth_center_x - self.mouth_detector.center_x
  499. dy = mouth_center_y - self.mouth_detector.center_y
  500. center_distance = math.sqrt(dx*dx + dy*dy)
  501. if center_distance < self.mouth_detector.target_deadzone_pixels * 1.5: # Allow some tolerance
  502. # Fire!
  503. logger.info(f"🔥 AUTO FIRE: Launching Oreo at centered target! (offset: {center_distance:.0f}px)")
  504. self.esp32.fire_oreo()
  505. self.last_target_time = current_time
  506. else:
  507. logger.info(f"🎯 TARGET NOT CENTERED: Waiting for better positioning (offset: {center_distance:.0f}px)")
  508. time.sleep(0.1) # Check at ~10fps
  509. except Exception as e:
  510. logger.error(f"Auto mode error: {e}")
  511. time.sleep(1)
  512. def capture_photo(self, client_socket) -> Dict[str, Any]:
  513. """Capture a high-resolution photo"""
  514. if not CAMERA_AVAILABLE:
  515. logger.info("SIMULATED: Photo captured")
  516. return {'status': 'success', 'message': 'Photo captured (simulated)'}
  517. try:
  518. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  519. filename = f"octv2_photo_{timestamp}.jpg"
  520. self.camera.capture_file(filename)
  521. # Send photo back to app
  522. with open(filename, 'rb') as f:
  523. photo_data = f.read()
  524. client_socket.send(photo_data)
  525. return {
  526. 'status': 'success',
  527. 'filename': filename,
  528. 'message': 'Photo captured and saved'
  529. }
  530. except Exception as e:
  531. logger.error(f"Photo capture failed: {e}")
  532. return {'status': 'error', 'message': str(e)}
  533. def start_video_stream(self, client_socket) -> Dict[str, Any]:
  534. """Start video streaming to client"""
  535. if client_socket not in self.streaming_clients:
  536. self.streaming_clients.append(client_socket)
  537. if not self.stream_thread or not self.stream_thread.is_alive():
  538. self.stream_thread = threading.Thread(target=self.video_stream_worker)
  539. self.stream_thread.daemon = True
  540. self.stream_thread.start()
  541. return {'status': 'success', 'message': 'Video stream started'}
  542. def stop_video_stream(self, client_socket) -> Dict[str, Any]:
  543. """Stop video streaming to client"""
  544. if client_socket in self.streaming_clients:
  545. self.streaming_clients.remove(client_socket)
  546. return {'status': 'success', 'message': 'Video stream stopped'}
  547. def video_stream_worker(self):
  548. """Worker thread for video streaming with mouth detection overlay"""
  549. if not CAMERA_AVAILABLE:
  550. logger.info("SIMULATED: Video streaming started")
  551. return
  552. try:
  553. while self.streaming_clients:
  554. # Capture frame
  555. frame = self.camera.capture_array()
  556. # Add mouth detection overlay in auto mode
  557. if self.is_auto_mode:
  558. mouths = self.mouth_detector.detect_open_mouths(frame)
  559. self._add_mouth_detection_overlay(frame, mouths)
  560. # Draw targeting crosshair and deadzone
  561. center_x, center_y = frame.shape[1] // 2, frame.shape[0] // 2
  562. deadzone = self.mouth_detector.target_deadzone_pixels
  563. # Main crosshair
  564. cv2.line(frame, (center_x-20, center_y), (center_x+20, center_y), (255, 0, 0), 2)
  565. cv2.line(frame, (center_x, center_y-20), (center_x, center_y+20), (255, 0, 0), 2)
  566. # Deadzone circle
  567. cv2.circle(frame, (center_x, center_y), deadzone, (255, 0, 0), 2)
  568. cv2.putText(frame, 'TARGET ZONE', (center_x-50, center_y+deadzone+20),
  569. cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 2)
  570. # Convert to JPEG
  571. _, jpeg = cv2.imencode('.jpg', frame)
  572. frame_data = jpeg.tobytes()
  573. # Send to all streaming clients
  574. for client in self.streaming_clients[:]:
  575. try:
  576. client.send(frame_data)
  577. except Exception as e:
  578. logger.error(f"Failed to send frame to client: {e}")
  579. self.streaming_clients.remove(client)
  580. time.sleep(0.1) # ~10 FPS
  581. except Exception as e:
  582. logger.error(f"Video streaming error: {e}")
  583. def get_status(self) -> Dict[str, Any]:
  584. """Return current device status"""
  585. # Get actual position from ESP32
  586. actual_rotation, actual_elevation = self.esp32.get_position()
  587. return {
  588. 'status': 'success',
  589. 'rotation': actual_rotation,
  590. 'elevation': actual_elevation,
  591. 'auto_mode': self.is_auto_mode,
  592. 'homed': self.is_homed,
  593. 'streaming_clients': len(self.streaming_clients),
  594. 'total_clients': len(self.clients),
  595. 'esp32_connected': self.esp32.serial_conn is not None
  596. }
  597. def cleanup(self):
  598. """Clean up resources"""
  599. self.running = False
  600. if self.camera and CAMERA_AVAILABLE:
  601. self.camera.stop()
  602. if self.esp32.serial_conn:
  603. self.esp32.serial_conn.close()
  604. logger.info("Server shutdown complete")
  605. def _add_mouth_detection_overlay(self, frame, mouths):
  606. """Add visual overlay showing mouth detection results"""
  607. if not self.mouth_detector.use_dlib:
  608. # Simple overlay for basic detection
  609. for mx, my, mw, mh, confidence in mouths:
  610. color = (0, 255, 0) if confidence > 0.5 else (0, 255, 255)
  611. cv2.rectangle(frame, (mx, my), (mx + mw, my + mh), color, 2)
  612. cv2.putText(frame, f'{confidence:.2f}', (mx, my-10),
  613. cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
  614. return
  615. # Advanced overlay showing all mouth states
  616. gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  617. faces = self.mouth_detector.detector(gray)
  618. for face in faces:
  619. landmarks = self.mouth_detector.predictor(gray, face)
  620. mouth_state, confidence = self.mouth_detector._analyze_mouth_state(landmarks)
  621. # Get face bounding box
  622. x1, y1, x2, y2 = face.left(), face.top(), face.right(), face.bottom()
  623. # Color coding for different states
  624. state_colors = {
  625. "WIDE_OPEN": (0, 255, 0), # Bright green - TARGET!
  626. "SPEAKING": (0, 165, 255), # Orange
  627. "SMILING": (255, 255, 0), # Cyan
  628. "CLOSED": (128, 128, 128) # Gray
  629. }
  630. color = state_colors.get(mouth_state, (128, 128, 128))
  631. # Draw face rectangle
  632. if mouth_state == "WIDE_OPEN":
  633. # Thick border for targets
  634. cv2.rectangle(frame, (x1, y1), (x2, y2), color, 4)
  635. # Add target indicator
  636. cv2.putText(frame, "🎯 TARGET!", (x1, y1-40),
  637. cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
  638. else:
  639. # Thin border for non-targets
  640. cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
  641. # Show mouth state, confidence, and distance
  642. cv2.putText(frame, f'{mouth_state}', (x1, y1-20),
  643. cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
  644. cv2.putText(frame, f'{confidence:.2f}', (x1, y2+20),
  645. cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
  646. # Add distance estimation for WIDE_OPEN mouths
  647. if mouth_state == "WIDE_OPEN":
  648. face_width = x2 - x1
  649. distance = self.mouth_detector.estimate_distance(face_width)
  650. cv2.putText(frame, f'~{distance:.0f}cm', (x1, y2+40),
  651. cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
  652. # Draw mouth landmarks for WIDE_OPEN targets
  653. if mouth_state == "WIDE_OPEN":
  654. # Draw mouth contour
  655. mouth_points = []
  656. for i in range(48, 68):
  657. point = landmarks.part(i)
  658. mouth_points.append((point.x, point.y))
  659. if i in [48, 54, 62, 66]: # Key points
  660. cv2.circle(frame, (point.x, point.y), 3, (0, 255, 0), -1)
  661. # Draw mouth outline
  662. mouth_points = np.array(mouth_points, dtype=np.int32)
  663. cv2.polylines(frame, [mouth_points], True, (0, 255, 0), 2)
  664. # Add detection stats
  665. total_faces = len(faces)
  666. wide_open_count = len([m for m in mouths if len(m) > 4]) # mouths from detect_open_mouths only contain WIDE_OPEN
  667. cv2.putText(frame, f'Faces: {total_faces}', (10, 30),
  668. cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
  669. cv2.putText(frame, f'Targets: {wide_open_count}', (10, 60),
  670. cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
  671. def main():
  672. """Main entry point"""
  673. print("🍪 OCTv2 (Oreo Cookie Thrower v2) Server v2 Starting...")
  674. print("🤖 Features: ESP32 Control + Automatic Mouth Detection")
  675. server = OCTv2Server()
  676. try:
  677. server.start_server()
  678. except KeyboardInterrupt:
  679. print("\n🛑 Shutting down OCTv2 server...")
  680. server.cleanup()
  681. if __name__ == "__main__":
  682. main()