infinicode 2.8.12 → 2.8.15
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/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +49 -9
- package/dist/robopark/preview-agent-launcher.d.ts +3 -0
- package/dist/robopark/preview-agent-launcher.js +8 -0
- package/dist/robopark-cli.js +3 -0
- package/package.json +3 -1
- package/packages/robopark/pi-client/README.md +17 -0
- package/packages/robopark/pi-client/_install_steps.sh +29 -0
- package/packages/robopark/pi-client/client.py +305 -0
- package/packages/robopark/pi-client/install.sh +41 -0
- package/packages/robopark/pi-client/join_convo.sh +55 -0
- package/packages/robopark/pi-client/livekit_bridge.py +289 -0
- package/packages/robopark/pi-client/motor_bridge.py +82 -0
- package/packages/robopark/pi-client/pi_ui.py +375 -0
- package/packages/robopark/pi-client/requirements.txt +10 -0
- package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
- package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
- package/packages/robopark/pi-client/smoke_bridge.py +15 -0
- package/packages/robopark/pi-client/stub_motor_server.py +46 -0
- package/packages/robopark/scheduler/main.py +45 -9
- package/packages/robopark/scheduler/preview_agent.py +132 -3
- package/packages/robopark/vision/.env.example +7 -0
- package/packages/robopark/vision/README.md +63 -0
- package/packages/robopark/vision/app_pi_clean.py +307 -0
- package/packages/robopark/vision/audio_server_pi.py +751 -0
- package/packages/robopark/vision/install.sh +34 -0
- package/packages/robopark/vision/motor_server.py +304 -0
- package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
- package/packages/robopark/vision/run.sh +244 -0
- package/packages/robopark/vision/services/services.sh +12 -0
- package/packages/robopark/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/packages/robopark/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
|
@@ -7,6 +7,15 @@ opens the selected camera + microphone and publishes them to the returned
|
|
|
7
7
|
LiveKit room. When the preview expires or is stopped, the agent leaves the
|
|
8
8
|
room and releases the hardware.
|
|
9
9
|
|
|
10
|
+
Also runs a small local HTTP listener for RoboVisionAI_PI's motion webhook
|
|
11
|
+
(POST /api/motion/webhook target — see RoboVisionAI_PI's app_pi_clean.py).
|
|
12
|
+
On a motion event it calls the scheduler's presence-triggered
|
|
13
|
+
POST /api/devices/{id}/request-session and publishes into the returned room
|
|
14
|
+
using the same LiveKitPublisher as the operator preview — this is the
|
|
15
|
+
"scene detection" trigger point the scheduler's request-session docstring
|
|
16
|
+
describes; RoboVisionAI_PI supplies the detection, this agent supplies the
|
|
17
|
+
bridge to an actual session.
|
|
18
|
+
|
|
10
19
|
Configuration (env / ~/.robopark/preview_agent.json):
|
|
11
20
|
SCHEDULER_URL base URL of the RoboPark scheduler
|
|
12
21
|
ROBOT_ID robot identity in the scheduler (defaults to hostname)
|
|
@@ -18,6 +27,9 @@ Configuration (env / ~/.robopark/preview_agent.json):
|
|
|
18
27
|
VIDEO_FPS capture fps (default 15)
|
|
19
28
|
POLL_INTERVAL seconds between scheduler polls (default 3)
|
|
20
29
|
HEARTBEAT_INTERVAL seconds between device heartbeats (default 30)
|
|
30
|
+
VISION_WEBHOOK_PORT local port for the RoboVision motion webhook (default 5057, 0 disables)
|
|
31
|
+
VISION_TRIGGER_COOLDOWN min seconds between motion-triggered sessions (default 20)
|
|
32
|
+
VISION_SESSION_SECONDS how long a motion-triggered session holds the publisher (default 90)
|
|
21
33
|
"""
|
|
22
34
|
from __future__ import annotations
|
|
23
35
|
|
|
@@ -28,8 +40,10 @@ import logging
|
|
|
28
40
|
import os
|
|
29
41
|
import signal
|
|
30
42
|
import sys
|
|
43
|
+
import threading
|
|
31
44
|
import time
|
|
32
45
|
from dataclasses import dataclass
|
|
46
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
33
47
|
from pathlib import Path
|
|
34
48
|
from typing import Optional
|
|
35
49
|
|
|
@@ -46,6 +60,9 @@ DEFAULT_VIDEO_HEIGHT = 480
|
|
|
46
60
|
DEFAULT_FPS = 15
|
|
47
61
|
DEFAULT_POLL_INTERVAL = 3.0
|
|
48
62
|
DEFAULT_HEARTBEAT_INTERVAL = 30.0
|
|
63
|
+
DEFAULT_VISION_WEBHOOK_PORT = 5057
|
|
64
|
+
DEFAULT_VISION_TRIGGER_COOLDOWN = 20.0
|
|
65
|
+
DEFAULT_VISION_SESSION_SECONDS = 90.0
|
|
49
66
|
|
|
50
67
|
|
|
51
68
|
def _load_config() -> dict:
|
|
@@ -120,7 +137,7 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Non
|
|
|
120
137
|
async with httpx.AsyncClient() as client:
|
|
121
138
|
await client.post(
|
|
122
139
|
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
123
|
-
json={"status": "online"},
|
|
140
|
+
json={"status": "online", "ip": _get_lan_ip()},
|
|
124
141
|
headers={"Authorization": f"Bearer {token}"},
|
|
125
142
|
timeout=10.0,
|
|
126
143
|
)
|
|
@@ -150,12 +167,19 @@ class PreviewAgent:
|
|
|
150
167
|
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
151
168
|
self.poll_interval = float(cfg.get("poll_interval", os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
|
|
152
169
|
self.heartbeat_interval = float(cfg.get("heartbeat_interval", os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
|
|
170
|
+
self.vision_webhook_port = int(cfg.get("vision_webhook_port", os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)))
|
|
171
|
+
self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
|
|
172
|
+
self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
|
|
153
173
|
|
|
154
174
|
self._shutdown = asyncio.Event()
|
|
155
175
|
self._task: Optional[asyncio.Task] = None
|
|
156
176
|
self._session: Optional[httpx.AsyncClient] = None
|
|
157
177
|
self._current_state: PreviewState = PreviewState()
|
|
158
178
|
self._publisher: Optional["LiveKitPublisher"] = None
|
|
179
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
180
|
+
self._vision_server: Optional[ThreadingHTTPServer] = None
|
|
181
|
+
self._last_vision_trigger: float = 0.0
|
|
182
|
+
self._vision_active_until: float = 0.0
|
|
159
183
|
|
|
160
184
|
async def run(self) -> None:
|
|
161
185
|
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
@@ -170,6 +194,8 @@ class PreviewAgent:
|
|
|
170
194
|
await self._resolve_device_id()
|
|
171
195
|
|
|
172
196
|
self._session = httpx.AsyncClient()
|
|
197
|
+
self._loop = asyncio.get_running_loop()
|
|
198
|
+
self._start_vision_webhook_server()
|
|
173
199
|
|
|
174
200
|
tasks = [
|
|
175
201
|
asyncio.create_task(self._poll_loop()),
|
|
@@ -182,6 +208,8 @@ class PreviewAgent:
|
|
|
182
208
|
await t
|
|
183
209
|
except asyncio.CancelledError:
|
|
184
210
|
pass
|
|
211
|
+
if self._vision_server:
|
|
212
|
+
self._vision_server.shutdown()
|
|
185
213
|
await self._stop_publisher()
|
|
186
214
|
await self._session.aclose()
|
|
187
215
|
|
|
@@ -209,8 +237,17 @@ class PreviewAgent:
|
|
|
209
237
|
async def _poll_loop(self) -> None:
|
|
210
238
|
while not self._shutdown.is_set():
|
|
211
239
|
try:
|
|
212
|
-
|
|
213
|
-
|
|
240
|
+
if time.time() >= self._vision_active_until:
|
|
241
|
+
# A motion-triggered session (if any) has ended its lease —
|
|
242
|
+
# let the operator-preview poll reconcile the publisher again.
|
|
243
|
+
if self._vision_active_until:
|
|
244
|
+
self._vision_active_until = 0.0
|
|
245
|
+
await self._stop_publisher()
|
|
246
|
+
self._current_state = PreviewState()
|
|
247
|
+
state = await self._fetch_preview_state()
|
|
248
|
+
await self._apply_state(state)
|
|
249
|
+
# else: a vision-triggered session currently owns the publisher —
|
|
250
|
+
# don't let the (unrelated) operator-preview state tear it down.
|
|
214
251
|
except Exception as e:
|
|
215
252
|
logger.warning(f"poll error: {e}")
|
|
216
253
|
try:
|
|
@@ -281,6 +318,92 @@ class PreviewAgent:
|
|
|
281
318
|
logger.warning(f"publisher stop error: {e}")
|
|
282
319
|
self._publisher = None
|
|
283
320
|
|
|
321
|
+
# ---- vision (RoboVisionAI_PI motion webhook) -> presence-triggered session ----
|
|
322
|
+
|
|
323
|
+
def _start_vision_webhook_server(self) -> None:
|
|
324
|
+
if not self.vision_webhook_port:
|
|
325
|
+
return
|
|
326
|
+
agent = self
|
|
327
|
+
|
|
328
|
+
class Handler(BaseHTTPRequestHandler):
|
|
329
|
+
def log_message(self, fmt, *a): # noqa: A002 — quiet by default
|
|
330
|
+
logger.debug("vision webhook: " + fmt, *a)
|
|
331
|
+
|
|
332
|
+
def do_POST(self): # noqa: N802 — http.server's required method name
|
|
333
|
+
try:
|
|
334
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
335
|
+
body = self.rfile.read(length) if length else b"{}"
|
|
336
|
+
payload = json.loads(body or b"{}")
|
|
337
|
+
except Exception as e:
|
|
338
|
+
self.send_response(400)
|
|
339
|
+
self.end_headers()
|
|
340
|
+
self.wfile.write(str(e).encode())
|
|
341
|
+
return
|
|
342
|
+
self.send_response(200)
|
|
343
|
+
self.end_headers()
|
|
344
|
+
self.wfile.write(b'{"ok":true}')
|
|
345
|
+
if agent._loop:
|
|
346
|
+
asyncio.run_coroutine_threadsafe(agent._on_vision_motion(payload), agent._loop)
|
|
347
|
+
|
|
348
|
+
def do_GET(self): # noqa: N802
|
|
349
|
+
self.send_response(200)
|
|
350
|
+
self.end_headers()
|
|
351
|
+
self.wfile.write(b'{"status":"ok","listening_for":"robovision motion webhook"}')
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
self._vision_server = ThreadingHTTPServer(("0.0.0.0", self.vision_webhook_port), Handler)
|
|
355
|
+
thread = threading.Thread(target=self._vision_server.serve_forever, daemon=True)
|
|
356
|
+
thread.start()
|
|
357
|
+
logger.info(
|
|
358
|
+
f"vision webhook listening on :{self.vision_webhook_port} — "
|
|
359
|
+
f"point RoboVisionAI_PI's POST /api/motion/webhook at "
|
|
360
|
+
f"http://<this-host>:{self.vision_webhook_port}/"
|
|
361
|
+
)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
logger.error(f"could not start vision webhook server: {e}")
|
|
364
|
+
self._vision_server = None
|
|
365
|
+
|
|
366
|
+
async def _on_vision_motion(self, payload: dict) -> None:
|
|
367
|
+
now = time.time()
|
|
368
|
+
if now - self._last_vision_trigger < self.vision_trigger_cooldown:
|
|
369
|
+
logger.debug("vision trigger suppressed (cooldown)")
|
|
370
|
+
return
|
|
371
|
+
self._last_vision_trigger = now
|
|
372
|
+
if not self.device_id or not self.device_token or not self._session:
|
|
373
|
+
logger.warning("vision motion event received but not enrolled yet — ignoring")
|
|
374
|
+
return
|
|
375
|
+
logger.info("motion detected by RoboVisionAI_PI — requesting a session")
|
|
376
|
+
try:
|
|
377
|
+
r = await self._session.post(
|
|
378
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
|
|
379
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
380
|
+
timeout=10.0,
|
|
381
|
+
)
|
|
382
|
+
r.raise_for_status()
|
|
383
|
+
data = r.json()
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.error(f"request-session failed: {e}")
|
|
386
|
+
return
|
|
387
|
+
state = PreviewState(
|
|
388
|
+
active=True,
|
|
389
|
+
url=data.get("server_url"),
|
|
390
|
+
token=data.get("token"),
|
|
391
|
+
room=data.get("room_name"),
|
|
392
|
+
)
|
|
393
|
+
self._vision_active_until = time.time() + self.vision_session_seconds
|
|
394
|
+
self._current_state = state
|
|
395
|
+
await self._start_publisher(state)
|
|
396
|
+
session_id = data.get("session_id")
|
|
397
|
+
if session_id and self._session:
|
|
398
|
+
try:
|
|
399
|
+
await self._session.post(
|
|
400
|
+
f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
|
|
401
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
402
|
+
timeout=10.0,
|
|
403
|
+
)
|
|
404
|
+
except Exception as e:
|
|
405
|
+
logger.debug(f"could not mark session joined: {e}")
|
|
406
|
+
|
|
284
407
|
def shutdown(self) -> None:
|
|
285
408
|
self._shutdown.set()
|
|
286
409
|
|
|
@@ -592,6 +715,9 @@ def main() -> None:
|
|
|
592
715
|
parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
593
716
|
parser.add_argument("--poll-interval", type=float, default=float(os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
|
|
594
717
|
parser.add_argument("--heartbeat-interval", type=float, default=float(os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
|
|
718
|
+
parser.add_argument("--vision-webhook-port", type=int, default=int(os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)), help="local port for RoboVisionAI_PI's motion webhook (0 disables)")
|
|
719
|
+
parser.add_argument("--vision-trigger-cooldown", type=float, default=float(os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
|
|
720
|
+
parser.add_argument("--vision-session-seconds", type=float, default=float(os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
|
|
595
721
|
parser.add_argument("--save-config", action="store_true", help="write CLI args to ~/.robopark/preview_agent.json")
|
|
596
722
|
parser.add_argument("-v", "--verbose", action="store_true")
|
|
597
723
|
args = parser.parse_args()
|
|
@@ -612,6 +738,9 @@ def main() -> None:
|
|
|
612
738
|
"video_fps": args.fps,
|
|
613
739
|
"poll_interval": args.poll_interval,
|
|
614
740
|
"heartbeat_interval": args.heartbeat_interval,
|
|
741
|
+
"vision_webhook_port": args.vision_webhook_port,
|
|
742
|
+
"vision_trigger_cooldown": args.vision_trigger_cooldown,
|
|
743
|
+
"vision_session_seconds": args.vision_session_seconds,
|
|
615
744
|
})
|
|
616
745
|
if args.device_token:
|
|
617
746
|
cfg["device_token"] = args.device_token
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# RoboVision — camera/motion detection (robot-side)
|
|
2
|
+
|
|
3
|
+
Runs on the robot. A Flask server (`app_pi_clean.py`, port 5000 by default)
|
|
4
|
+
that:
|
|
5
|
+
|
|
6
|
+
- Streams the camera at `/video_feed` (MJPEG, with OpenCV-drawn bounding
|
|
7
|
+
boxes/labels baked into the frame) — this is what the Park dashboard's
|
|
8
|
+
"Show overlay" button in the camera drawer points at directly.
|
|
9
|
+
- Runs OpenCV DNN object detection (MobileNet SSD) on every frame —
|
|
10
|
+
`/api/detections` for the latest results.
|
|
11
|
+
- Runs frame-diff motion detection, toggled via `POST /api/motion/toggle
|
|
12
|
+
{"active": true}` — when motion is detected it POSTs a JPEG snapshot to a
|
|
13
|
+
configurable webhook (`POST /api/motion/webhook {"url": "..."}`).
|
|
14
|
+
|
|
15
|
+
## Wiring it to an actual session (the production trigger)
|
|
16
|
+
|
|
17
|
+
RoboVision only *detects*; it doesn't itself start a robot session. The
|
|
18
|
+
scheduler's `preview_agent.py` (in `../scheduler/`) now runs a small built-in
|
|
19
|
+
listener for exactly this webhook and turns a motion event into a real,
|
|
20
|
+
presence-triggered session (`POST /api/devices/{id}/request-session`),
|
|
21
|
+
publishing camera + mic into the resulting LiveKit room. Point RoboVision at
|
|
22
|
+
it:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
curl -X POST http://localhost:8080/api/motion/webhook \
|
|
26
|
+
-H "Content-Type: application/json" \
|
|
27
|
+
-d '{"url": "http://localhost:5057/"}'
|
|
28
|
+
curl -X POST http://localhost:8080/api/motion/toggle \
|
|
29
|
+
-H "Content-Type: application/json" -d '{"active": true}'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
(`5057` is `preview_agent.py`'s default `--vision-webhook-port`; override with
|
|
33
|
+
`--vision-webhook-port` if RoboVision runs on a different host than the
|
|
34
|
+
preview agent.)
|
|
35
|
+
|
|
36
|
+
## Required model files (NOT bundled)
|
|
37
|
+
|
|
38
|
+
`app_pi_clean.py` needs two Caffe model files that are **not shipped** in this
|
|
39
|
+
npm package (the weights are ~23MB and would bloat every `infinicode`/
|
|
40
|
+
`robopark` install, including installs that never touch RoboPark):
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
deploy.prototxt
|
|
44
|
+
mobilenet_iter_73000.caffemodel
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Download the standard MobileNet-SSD (VOC) weights (e.g. from
|
|
48
|
+
chuanqi305/MobileNet-SSD) into this directory before running. Without them,
|
|
49
|
+
`app_pi_clean.py` still runs — object detection is silently disabled, motion
|
|
50
|
+
detection still works.
|
|
51
|
+
|
|
52
|
+
## Running
|
|
53
|
+
|
|
54
|
+
Pure Python + OpenCV (Flask, cv2, numpy) — cross-platform, no bash required:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
pip install -r requirements_pi_unified.txt
|
|
58
|
+
python app_pi_clean.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`install.sh`/`run.sh` are Linux/Pi convenience wrappers (venv + systemd-style
|
|
62
|
+
process management) — not required on Windows/macOS, just run the script
|
|
63
|
+
directly.
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
from flask import Flask, Response, jsonify, request
|
|
2
|
+
from flask_cors import CORS
|
|
3
|
+
import cv2
|
|
4
|
+
import time
|
|
5
|
+
import numpy as np
|
|
6
|
+
import threading
|
|
7
|
+
import requests
|
|
8
|
+
import base64
|
|
9
|
+
|
|
10
|
+
app = Flask(__name__)
|
|
11
|
+
CORS(app)
|
|
12
|
+
|
|
13
|
+
# Camera management
|
|
14
|
+
current_camera_index = 0
|
|
15
|
+
camera = None
|
|
16
|
+
|
|
17
|
+
def get_camera():
|
|
18
|
+
global camera, current_camera_index
|
|
19
|
+
if camera is None or not camera.isOpened():
|
|
20
|
+
camera = cv2.VideoCapture(current_camera_index)
|
|
21
|
+
camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
|
22
|
+
camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
|
23
|
+
print(f"Camera {current_camera_index} initialized")
|
|
24
|
+
return camera
|
|
25
|
+
|
|
26
|
+
# Global variables
|
|
27
|
+
latest_detections = []
|
|
28
|
+
lock = threading.Lock()
|
|
29
|
+
caption_mode_enabled = False
|
|
30
|
+
motion_detection_active = False
|
|
31
|
+
motion_detected_state = False
|
|
32
|
+
last_motion_time = 0
|
|
33
|
+
motion_frame_buffer = None
|
|
34
|
+
webhook_url = None
|
|
35
|
+
last_webhook_send_time = 0
|
|
36
|
+
webhook_send_interval = 0.5
|
|
37
|
+
|
|
38
|
+
# Simple object detection using OpenCV DNN (MobileNet SSD)
|
|
39
|
+
try:
|
|
40
|
+
# Load pre-trained MobileNet SSD model
|
|
41
|
+
net = cv2.dnn.readNetFromCaffe(
|
|
42
|
+
'deploy.prototxt',
|
|
43
|
+
'mobilenet_iter_73000.caffemodel'
|
|
44
|
+
)
|
|
45
|
+
DETECTION_AVAILABLE = True
|
|
46
|
+
print("Object detection model loaded")
|
|
47
|
+
except:
|
|
48
|
+
DETECTION_AVAILABLE = False
|
|
49
|
+
print("Object detection model not found - running without detection")
|
|
50
|
+
|
|
51
|
+
# COCO class labels
|
|
52
|
+
CLASSES = ["background", "aeroplane", "bicycle", "bird", "boat",
|
|
53
|
+
"bottle", "bus", "car", "cat", "chair", "cow", "diningtable",
|
|
54
|
+
"dog", "horse", "motorbike", "person", "pottedplant", "sheep",
|
|
55
|
+
"sofa", "train", "tvmonitor"]
|
|
56
|
+
|
|
57
|
+
def detect_objects(frame, conf_threshold=0.5):
|
|
58
|
+
"""Detect objects using OpenCV DNN"""
|
|
59
|
+
if not DETECTION_AVAILABLE:
|
|
60
|
+
return frame, []
|
|
61
|
+
|
|
62
|
+
(h, w) = frame.shape[:2]
|
|
63
|
+
blob = cv2.dnn.blobFromImage(cv2.resize(frame, (300, 300)), 0.007843, (300, 300), 127.5)
|
|
64
|
+
net.setInput(blob)
|
|
65
|
+
detections_dnn = net.forward()
|
|
66
|
+
|
|
67
|
+
detected_objects = []
|
|
68
|
+
|
|
69
|
+
for i in range(detections_dnn.shape[2]):
|
|
70
|
+
confidence = detections_dnn[0, 0, i, 2]
|
|
71
|
+
|
|
72
|
+
if confidence > conf_threshold:
|
|
73
|
+
idx = int(detections_dnn[0, 0, i, 1])
|
|
74
|
+
if idx >= len(CLASSES):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
box = detections_dnn[0, 0, i, 3:7] * np.array([w, h, w, h])
|
|
78
|
+
(startX, startY, endX, endY) = box.astype("int")
|
|
79
|
+
|
|
80
|
+
label = CLASSES[idx]
|
|
81
|
+
|
|
82
|
+
# Draw bounding box
|
|
83
|
+
cv2.rectangle(frame, (startX, startY), (endX, endY), (0, 255, 0), 2)
|
|
84
|
+
|
|
85
|
+
# Draw label with confidence
|
|
86
|
+
text = f"{label}: {confidence*100:.1f}%"
|
|
87
|
+
y = startY - 15 if startY - 15 > 15 else startY + 15
|
|
88
|
+
cv2.putText(frame, text, (startX, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
|
|
89
|
+
|
|
90
|
+
detected_objects.append({
|
|
91
|
+
"label": label,
|
|
92
|
+
"confidence": float(confidence),
|
|
93
|
+
"bbox": [int(startX), int(startY), int(endX), int(endY)],
|
|
94
|
+
"is_focus": False
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return frame, detected_objects
|
|
98
|
+
|
|
99
|
+
def send_webhook(frame_data):
|
|
100
|
+
"""Send frame to webhook URL"""
|
|
101
|
+
global webhook_url, last_webhook_send_time
|
|
102
|
+
|
|
103
|
+
if not webhook_url:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
current_time = time.time()
|
|
107
|
+
if current_time - last_webhook_send_time < webhook_send_interval:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
_, buffer = cv2.imencode('.jpg', frame_data)
|
|
112
|
+
img_base64 = base64.b64encode(buffer).decode('utf-8')
|
|
113
|
+
|
|
114
|
+
payload = {
|
|
115
|
+
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
116
|
+
'image': img_base64,
|
|
117
|
+
'format': 'jpeg',
|
|
118
|
+
'encoding': 'base64'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def send_async():
|
|
122
|
+
try:
|
|
123
|
+
response = requests.post(webhook_url, json=payload, headers={'Content-Type': 'application/json'}, timeout=5)
|
|
124
|
+
if response.status_code == 200:
|
|
125
|
+
print(f"Webhook sent successfully")
|
|
126
|
+
except Exception as e:
|
|
127
|
+
print(f"Webhook error: {e}")
|
|
128
|
+
|
|
129
|
+
thread = threading.Thread(target=send_async, daemon=True)
|
|
130
|
+
thread.start()
|
|
131
|
+
last_webhook_send_time = current_time
|
|
132
|
+
except Exception as e:
|
|
133
|
+
print(f"Error preparing webhook: {e}")
|
|
134
|
+
|
|
135
|
+
def generate_frames():
|
|
136
|
+
global latest_detections, motion_detection_active, motion_detected_state
|
|
137
|
+
global last_motion_time, motion_frame_buffer
|
|
138
|
+
|
|
139
|
+
prev_gray = None
|
|
140
|
+
|
|
141
|
+
while True:
|
|
142
|
+
cam = get_camera()
|
|
143
|
+
if cam is None or not cam.isOpened():
|
|
144
|
+
time.sleep(1)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
success, frame = cam.read()
|
|
148
|
+
if not success:
|
|
149
|
+
time.sleep(0.1)
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# Run object detection
|
|
153
|
+
processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
|
|
154
|
+
|
|
155
|
+
with lock:
|
|
156
|
+
latest_detections = detections
|
|
157
|
+
|
|
158
|
+
# Motion detection logic
|
|
159
|
+
if motion_detection_active:
|
|
160
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
161
|
+
gray = cv2.GaussianBlur(gray, (21, 21), 0)
|
|
162
|
+
|
|
163
|
+
if prev_gray is not None:
|
|
164
|
+
frame_delta = cv2.absdiff(prev_gray, gray)
|
|
165
|
+
thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1]
|
|
166
|
+
thresh = cv2.dilate(thresh, None, iterations=2)
|
|
167
|
+
contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
168
|
+
|
|
169
|
+
motion_detected = False
|
|
170
|
+
for contour in contours:
|
|
171
|
+
if cv2.contourArea(contour) >= 500:
|
|
172
|
+
motion_detected = True
|
|
173
|
+
(x, y, w, h) = cv2.boundingRect(contour)
|
|
174
|
+
cv2.rectangle(processed_frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
if motion_detected:
|
|
178
|
+
motion_detected_state = True
|
|
179
|
+
last_motion_time = time.time()
|
|
180
|
+
motion_frame_buffer = processed_frame.copy()
|
|
181
|
+
print(f"Motion detected!")
|
|
182
|
+
send_webhook(processed_frame)
|
|
183
|
+
else:
|
|
184
|
+
motion_detected_state = False
|
|
185
|
+
|
|
186
|
+
prev_gray = gray
|
|
187
|
+
|
|
188
|
+
ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
189
|
+
frame_bytes = buffer.tobytes()
|
|
190
|
+
|
|
191
|
+
yield (b'--frame\r\n'
|
|
192
|
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
|
193
|
+
|
|
194
|
+
@app.route('/')
|
|
195
|
+
def index():
|
|
196
|
+
return jsonify({"status": "ok", "message": "RoboVision Pi Server", "version": "1.0"})
|
|
197
|
+
|
|
198
|
+
@app.route('/video_feed')
|
|
199
|
+
def video_feed():
|
|
200
|
+
return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
|
201
|
+
|
|
202
|
+
@app.route('/api/detections')
|
|
203
|
+
def get_detections():
|
|
204
|
+
with lock:
|
|
205
|
+
return jsonify(latest_detections)
|
|
206
|
+
|
|
207
|
+
@app.route('/api/caption')
|
|
208
|
+
def get_caption():
|
|
209
|
+
return jsonify({"caption": "Awaiting caption..."})
|
|
210
|
+
|
|
211
|
+
@app.route('/api/caption_mode', methods=['GET', 'POST'])
|
|
212
|
+
def caption_mode():
|
|
213
|
+
global caption_mode_enabled
|
|
214
|
+
if request.method == 'GET':
|
|
215
|
+
return jsonify({"enabled": caption_mode_enabled})
|
|
216
|
+
data = request.json or {}
|
|
217
|
+
caption_mode_enabled = bool(data.get('enabled', False))
|
|
218
|
+
return jsonify({"enabled": caption_mode_enabled})
|
|
219
|
+
|
|
220
|
+
@app.route('/api/cameras', methods=['GET'])
|
|
221
|
+
def list_cameras():
|
|
222
|
+
available = []
|
|
223
|
+
for i in range(4):
|
|
224
|
+
cap = cv2.VideoCapture(i)
|
|
225
|
+
if cap.isOpened():
|
|
226
|
+
available.append({"index": i, "name": f"Camera {i}"})
|
|
227
|
+
cap.release()
|
|
228
|
+
return jsonify({"cameras": available, "current": current_camera_index})
|
|
229
|
+
|
|
230
|
+
@app.route('/api/camera/switch', methods=['POST'])
|
|
231
|
+
def switch_camera():
|
|
232
|
+
global camera, current_camera_index
|
|
233
|
+
data = request.json or {}
|
|
234
|
+
new_index = int(data.get('index', 0))
|
|
235
|
+
|
|
236
|
+
if camera:
|
|
237
|
+
camera.release()
|
|
238
|
+
|
|
239
|
+
current_camera_index = new_index
|
|
240
|
+
camera = None
|
|
241
|
+
|
|
242
|
+
return jsonify({"status": "ok", "camera_index": current_camera_index})
|
|
243
|
+
|
|
244
|
+
@app.route('/api/motion/status', methods=['GET'])
|
|
245
|
+
def motion_status():
|
|
246
|
+
global motion_detection_active, motion_detected_state, last_motion_time
|
|
247
|
+
return jsonify({
|
|
248
|
+
"active": motion_detection_active,
|
|
249
|
+
"motion_detected": motion_detected_state,
|
|
250
|
+
"last_motion": last_motion_time,
|
|
251
|
+
"time_since_motion": time.time() - last_motion_time if last_motion_time > 0 else None
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
@app.route('/api/motion/toggle', methods=['POST'])
|
|
255
|
+
def motion_toggle():
|
|
256
|
+
global motion_detection_active
|
|
257
|
+
data = request.json or {}
|
|
258
|
+
motion_detection_active = bool(data.get('active', False))
|
|
259
|
+
return jsonify({"status": "success", "active": motion_detection_active})
|
|
260
|
+
|
|
261
|
+
@app.route('/api/motion/snapshot', methods=['GET'])
|
|
262
|
+
def motion_snapshot():
|
|
263
|
+
global motion_frame_buffer
|
|
264
|
+
if motion_frame_buffer is not None:
|
|
265
|
+
_, buffer = cv2.imencode('.jpg', motion_frame_buffer, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
266
|
+
img_base64 = base64.b64encode(buffer).decode('utf-8')
|
|
267
|
+
return jsonify({
|
|
268
|
+
"image": img_base64,
|
|
269
|
+
"timestamp": time.time()
|
|
270
|
+
})
|
|
271
|
+
return jsonify({"error": "No frame available"}), 404
|
|
272
|
+
|
|
273
|
+
@app.route('/api/motion/webhook', methods=['GET', 'POST'])
|
|
274
|
+
def motion_webhook():
|
|
275
|
+
global webhook_url
|
|
276
|
+
|
|
277
|
+
if request.method == 'GET':
|
|
278
|
+
return jsonify({
|
|
279
|
+
"webhook_url": webhook_url or "",
|
|
280
|
+
"configured": webhook_url is not None and len(webhook_url) > 0
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
data = request.json or {}
|
|
284
|
+
new_url = data.get('url', '').strip()
|
|
285
|
+
|
|
286
|
+
if new_url:
|
|
287
|
+
webhook_url = new_url
|
|
288
|
+
return jsonify({
|
|
289
|
+
"status": "success",
|
|
290
|
+
"message": "Webhook URL configured",
|
|
291
|
+
"webhook_url": webhook_url
|
|
292
|
+
})
|
|
293
|
+
else:
|
|
294
|
+
webhook_url = None
|
|
295
|
+
return jsonify({
|
|
296
|
+
"status": "success",
|
|
297
|
+
"message": "Webhook URL cleared",
|
|
298
|
+
"webhook_url": None
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
if __name__ == '__main__':
|
|
302
|
+
print("=" * 60)
|
|
303
|
+
print("RoboVision - Raspberry Pi Vision Server (Minimal)")
|
|
304
|
+
print("=" * 60)
|
|
305
|
+
print("Starting Flask server on http://0.0.0.0:5000")
|
|
306
|
+
print("=" * 60)
|
|
307
|
+
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|