robopark 3.3.49 → 3.3.50
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/package.json +1 -1
- package/ui/standalone/.next/BUILD_ID +1 -1
- package/ui/standalone/.next/app-build-manifest.json +79 -79
- package/ui/standalone/.next/app-path-routes-manifest.json +20 -20
- package/ui/standalone/.next/build-manifest.json +2 -2
- package/ui/standalone/.next/prerender-manifest.json +3 -3
- package/ui/standalone/.next/server/app-paths-manifest.json +20 -20
- package/ui/standalone/.next/server/functions-config-manifest.json +1 -1
- package/ui/standalone/.next/server/pages/500.html +1 -1
- package/ui/standalone/.next/server/server-reference-manifest.json +1 -1
- package/vision/app_pi_clean.py +966 -914
- /package/ui/standalone/.next/static/{Avx14kguItlOr_kyCghfc → iThCB-S9kgCYKS76sXobJ}/_buildManifest.js +0 -0
- /package/ui/standalone/.next/static/{Avx14kguItlOr_kyCghfc → iThCB-S9kgCYKS76sXobJ}/_ssgManifest.js +0 -0
package/vision/app_pi_clean.py
CHANGED
|
@@ -1,914 +1,966 @@
|
|
|
1
|
-
from flask import Flask, Response, jsonify, request
|
|
2
|
-
from flask_cors import CORS
|
|
3
|
-
import argparse
|
|
4
|
-
import glob
|
|
5
|
-
import cv2
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
import time
|
|
9
|
-
import numpy as np
|
|
10
|
-
import threading
|
|
11
|
-
import requests
|
|
12
|
-
import base64
|
|
13
|
-
import struct
|
|
14
|
-
import subprocess
|
|
15
|
-
|
|
16
|
-
app = Flask(__name__)
|
|
17
|
-
CORS(app)
|
|
18
|
-
|
|
19
|
-
# Camera management. Production installs create /dev/robopark-camera from
|
|
20
|
-
# the primary USB capture interface, avoiding /dev/videoN renumbering.
|
|
21
|
-
def _normalize_camera_device(value):
|
|
22
|
-
configured = str(value or "").strip()
|
|
23
|
-
if configured.lower() in ("", "auto", "default", "first"):
|
|
24
|
-
if sys.platform.startswith("linux"):
|
|
25
|
-
return "/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else "/dev/video0"
|
|
26
|
-
# Off-Pi we scan instead of assuming index 0: a Windows kiosk with a
|
|
27
|
-
# virtual/IR device in the way exposes the real webcam at 1 or 2.
|
|
28
|
-
return "auto"
|
|
29
|
-
if configured.isdigit():
|
|
30
|
-
return int(configured)
|
|
31
|
-
return configured
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
_configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
|
|
35
|
-
current_camera_index = _normalize_camera_device(_configured_camera)
|
|
36
|
-
camera = None
|
|
37
|
-
camera_lock = threading.Lock()
|
|
38
|
-
frame_condition = threading.Condition()
|
|
39
|
-
latest_frame_bytes = None
|
|
40
|
-
latest_frame_sequence = 0
|
|
41
|
-
camera_worker_started = False
|
|
42
|
-
camera_watchdog_started = False
|
|
43
|
-
camera_read_started_at = 0.0
|
|
44
|
-
camera_last_frame_at = 0.0
|
|
45
|
-
audio_input_device = "default"
|
|
46
|
-
audio_output_device = "default"
|
|
47
|
-
ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
|
|
48
|
-
_camera_inventory_cache = []
|
|
49
|
-
_camera_inventory_cache_at = 0.0
|
|
50
|
-
CAMERA_INVENTORY_CACHE_SECONDS = 30.0
|
|
51
|
-
_camera_name_cache = []
|
|
52
|
-
_camera_name_cache_at = 0.0
|
|
53
|
-
CAMERA_NAME_CACHE_SECONDS = 30.0
|
|
54
|
-
|
|
55
|
-
CAMERA_SCAN_MAX_INDEX = int(os.getenv("ROBOPARK_CAMERA_SCAN_MAX", "3"))
|
|
56
|
-
CAMERA_OPEN_MAX_ATTEMPTS = int(os.getenv("ROBOPARK_CAMERA_OPEN_ATTEMPTS", "3"))
|
|
57
|
-
CAMERA_FIRST_FRAME_TRIES = 5
|
|
58
|
-
CAMERA_FIRST_FRAME_DELAY = 0.15
|
|
59
|
-
|
|
60
|
-
active_camera_device = None
|
|
61
|
-
active_camera_backend = None
|
|
62
|
-
camera_open_attempts = []
|
|
63
|
-
camera_open_error = None
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
cap.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
print(f"
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
print("=" * 60)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
"
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
return
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
@app.route('/
|
|
618
|
-
def
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
try
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
"
|
|
782
|
-
"
|
|
783
|
-
"
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1
|
+
from flask import Flask, Response, jsonify, request
|
|
2
|
+
from flask_cors import CORS
|
|
3
|
+
import argparse
|
|
4
|
+
import glob
|
|
5
|
+
import cv2
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import numpy as np
|
|
10
|
+
import threading
|
|
11
|
+
import requests
|
|
12
|
+
import base64
|
|
13
|
+
import struct
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
app = Flask(__name__)
|
|
17
|
+
CORS(app)
|
|
18
|
+
|
|
19
|
+
# Camera management. Production installs create /dev/robopark-camera from
|
|
20
|
+
# the primary USB capture interface, avoiding /dev/videoN renumbering.
|
|
21
|
+
def _normalize_camera_device(value):
|
|
22
|
+
configured = str(value or "").strip()
|
|
23
|
+
if configured.lower() in ("", "auto", "default", "first"):
|
|
24
|
+
if sys.platform.startswith("linux"):
|
|
25
|
+
return "/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else "/dev/video0"
|
|
26
|
+
# Off-Pi we scan instead of assuming index 0: a Windows kiosk with a
|
|
27
|
+
# virtual/IR device in the way exposes the real webcam at 1 or 2.
|
|
28
|
+
return "auto"
|
|
29
|
+
if configured.isdigit():
|
|
30
|
+
return int(configured)
|
|
31
|
+
return configured
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
|
|
35
|
+
current_camera_index = _normalize_camera_device(_configured_camera)
|
|
36
|
+
camera = None
|
|
37
|
+
camera_lock = threading.Lock()
|
|
38
|
+
frame_condition = threading.Condition()
|
|
39
|
+
latest_frame_bytes = None
|
|
40
|
+
latest_frame_sequence = 0
|
|
41
|
+
camera_worker_started = False
|
|
42
|
+
camera_watchdog_started = False
|
|
43
|
+
camera_read_started_at = 0.0
|
|
44
|
+
camera_last_frame_at = 0.0
|
|
45
|
+
audio_input_device = "default"
|
|
46
|
+
audio_output_device = "default"
|
|
47
|
+
ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
|
|
48
|
+
_camera_inventory_cache = []
|
|
49
|
+
_camera_inventory_cache_at = 0.0
|
|
50
|
+
CAMERA_INVENTORY_CACHE_SECONDS = 30.0
|
|
51
|
+
_camera_name_cache = []
|
|
52
|
+
_camera_name_cache_at = 0.0
|
|
53
|
+
CAMERA_NAME_CACHE_SECONDS = 30.0
|
|
54
|
+
|
|
55
|
+
CAMERA_SCAN_MAX_INDEX = int(os.getenv("ROBOPARK_CAMERA_SCAN_MAX", "3"))
|
|
56
|
+
CAMERA_OPEN_MAX_ATTEMPTS = int(os.getenv("ROBOPARK_CAMERA_OPEN_ATTEMPTS", "3"))
|
|
57
|
+
CAMERA_FIRST_FRAME_TRIES = 5
|
|
58
|
+
CAMERA_FIRST_FRAME_DELAY = 0.15
|
|
59
|
+
|
|
60
|
+
active_camera_device = None
|
|
61
|
+
active_camera_backend = None
|
|
62
|
+
camera_open_attempts = []
|
|
63
|
+
camera_open_error = None
|
|
64
|
+
# Whether the capture is open, tracked as a plain flag instead of asking the
|
|
65
|
+
# VideoCapture each time. cv2 calls serialise against the worker's in-flight
|
|
66
|
+
# read/open, so `camera.isOpened()` inside a request handler blocks for as long
|
|
67
|
+
# as MSMF is stuck opening a device -- which is exactly when an operator is
|
|
68
|
+
# trying to find out what is wrong. Diagnostics must never be able to hang.
|
|
69
|
+
camera_open_flag = False
|
|
70
|
+
# Set while a background inventory probe is running, so /api/media/inventory
|
|
71
|
+
# can answer instantly with what it already knows instead of waiting on OpenCV.
|
|
72
|
+
_camera_inventory_probing = False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _camera_backends():
|
|
76
|
+
"""Backends to try, in the order most likely to bind on this platform.
|
|
77
|
+
|
|
78
|
+
Windows leads with Media Foundation because that is the stack Chrome's
|
|
79
|
+
getUserMedia uses, and the kiosk that fails here streams fine in the
|
|
80
|
+
browser. DirectShow alone logs "backend is generally available but can't
|
|
81
|
+
be used to capture by index" and never binds on that hardware; it stays as
|
|
82
|
+
the second try because some older UVC bridges only enumerate there.
|
|
83
|
+
Linux/Pi keeps V4L2 first — unchanged from the original behaviour.
|
|
84
|
+
"""
|
|
85
|
+
if sys.platform == 'win32':
|
|
86
|
+
return [
|
|
87
|
+
(getattr(cv2, 'CAP_MSMF', cv2.CAP_ANY), 'msmf'),
|
|
88
|
+
(getattr(cv2, 'CAP_DSHOW', cv2.CAP_ANY), 'dshow'),
|
|
89
|
+
(cv2.CAP_ANY, 'default'),
|
|
90
|
+
]
|
|
91
|
+
if sys.platform.startswith('linux'):
|
|
92
|
+
return [(getattr(cv2, 'CAP_V4L2', cv2.CAP_ANY), 'v4l2'), (cv2.CAP_ANY, 'default')]
|
|
93
|
+
return [(cv2.CAP_ANY, 'default')]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tune_capture(cap):
|
|
97
|
+
if sys.platform.startswith('linux'):
|
|
98
|
+
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
|
99
|
+
cap.set(cv2.CAP_PROP_FPS, 15)
|
|
100
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
|
101
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _capture_yields_frame(cap):
|
|
105
|
+
"""isOpened() is not proof of a working camera — MSMF/DSHOW both hand back
|
|
106
|
+
an "open" handle that never decodes a frame. Only a real read counts."""
|
|
107
|
+
for _ in range(CAMERA_FIRST_FRAME_TRIES):
|
|
108
|
+
try:
|
|
109
|
+
success, frame = cap.read()
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
return False, f"read() raised {exc}"
|
|
112
|
+
if success and frame is not None and getattr(frame, 'size', 0):
|
|
113
|
+
return True, None
|
|
114
|
+
time.sleep(CAMERA_FIRST_FRAME_DELAY)
|
|
115
|
+
return False, f"opened but produced no frame in {CAMERA_FIRST_FRAME_TRIES} reads"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _open_camera(index, backend=None):
|
|
119
|
+
"""Open one device with one backend. Returns the capture (possibly closed)."""
|
|
120
|
+
if backend is None:
|
|
121
|
+
backend = _camera_backends()[0][0]
|
|
122
|
+
cap = cv2.VideoCapture(index, backend)
|
|
123
|
+
if cap.isOpened():
|
|
124
|
+
_tune_capture(cap)
|
|
125
|
+
return cap
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _windows_camera_names():
|
|
129
|
+
"""Friendly camera names from Windows PnP, via PowerShell (no new deps).
|
|
130
|
+
|
|
131
|
+
OpenCV exposes no device-name API, so the correlation to indices is
|
|
132
|
+
positional and therefore HEURISTIC: the Nth camera PnP entity is assumed
|
|
133
|
+
to be OpenCV index N. That holds on the usual one-or-two-camera kiosk but
|
|
134
|
+
can be wrong when virtual cameras, IR sensors or non-UVC 'Image' devices
|
|
135
|
+
are installed. Selecting by index is always exact; selecting by name
|
|
136
|
+
depends on this guess.
|
|
137
|
+
"""
|
|
138
|
+
script = (
|
|
139
|
+
"Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | "
|
|
140
|
+
"Where-Object { $_.PNPClass -eq 'Camera' -or $_.PNPClass -eq 'Image' } | "
|
|
141
|
+
"ForEach-Object { $_.Name }"
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
completed = subprocess.run(
|
|
145
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
146
|
+
capture_output=True,
|
|
147
|
+
text=True,
|
|
148
|
+
timeout=10,
|
|
149
|
+
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
|
|
150
|
+
)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
print(f"Camera name enumeration failed: {exc}")
|
|
153
|
+
return []
|
|
154
|
+
return [line.strip() for line in (completed.stdout or "").splitlines() if line.strip()]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _camera_names():
|
|
158
|
+
global _camera_name_cache, _camera_name_cache_at
|
|
159
|
+
now = time.monotonic()
|
|
160
|
+
if now - _camera_name_cache_at < CAMERA_NAME_CACHE_SECONDS:
|
|
161
|
+
return _camera_name_cache
|
|
162
|
+
names = _windows_camera_names() if sys.platform == 'win32' else []
|
|
163
|
+
_camera_name_cache = names
|
|
164
|
+
_camera_name_cache_at = now
|
|
165
|
+
return names
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _match_device_name(wanted):
|
|
169
|
+
"""Resolve a human-typed camera name to device values.
|
|
170
|
+
|
|
171
|
+
Same matching rules as the microphone picker in audio-select.ts: exact
|
|
172
|
+
case-insensitive first, then a unique substring; ambiguity resolves to
|
|
173
|
+
nothing rather than a coin flip.
|
|
174
|
+
"""
|
|
175
|
+
lower = wanted.strip().lower()
|
|
176
|
+
if not lower:
|
|
177
|
+
return []
|
|
178
|
+
if sys.platform.startswith('linux'):
|
|
179
|
+
pairs = [(entry.get("name", ""), entry.get("id")) for entry in _enumerate_cameras()]
|
|
180
|
+
else:
|
|
181
|
+
pairs = [(name, index) for index, name in enumerate(_camera_names())]
|
|
182
|
+
exact = [value for name, value in pairs if name.lower() == lower]
|
|
183
|
+
if len(exact) == 1:
|
|
184
|
+
return exact
|
|
185
|
+
partial = [value for name, value in pairs if lower in name.lower()]
|
|
186
|
+
if len(partial) == 1:
|
|
187
|
+
return partial
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _camera_candidates(device):
|
|
192
|
+
"""Device values to try, in order, for the currently configured selection."""
|
|
193
|
+
text = str(device).strip()
|
|
194
|
+
if text.lower() == 'auto':
|
|
195
|
+
return list(range(CAMERA_SCAN_MAX_INDEX + 1))
|
|
196
|
+
if text.isdigit():
|
|
197
|
+
return [int(text)]
|
|
198
|
+
if text.startswith('/dev/') or os.path.sep in text:
|
|
199
|
+
return [text]
|
|
200
|
+
return _match_device_name(text)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _acquire_camera():
|
|
204
|
+
"""Try every candidate device against every backend until one yields a frame.
|
|
205
|
+
|
|
206
|
+
Returns (capture, device, backend_label, attempt_log). `capture` is None if
|
|
207
|
+
nothing worked; the log names every backend/index pair that was tried and
|
|
208
|
+
why it failed, so the operator is not left guessing.
|
|
209
|
+
"""
|
|
210
|
+
attempts = []
|
|
211
|
+
candidates = _camera_candidates(current_camera_index)
|
|
212
|
+
if not candidates:
|
|
213
|
+
known = ", ".join(_camera_names()) or "(none reported by the OS)"
|
|
214
|
+
attempts.append(f"no camera matches name {current_camera_index!r}; OS reports: {known}")
|
|
215
|
+
return None, None, None, attempts
|
|
216
|
+
|
|
217
|
+
for device in candidates:
|
|
218
|
+
for backend, label in _camera_backends():
|
|
219
|
+
cap = None
|
|
220
|
+
try:
|
|
221
|
+
cap = cv2.VideoCapture(device, backend)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
attempts.append(f"{label}:{device} VideoCapture() raised {exc}")
|
|
224
|
+
continue
|
|
225
|
+
if not cap.isOpened():
|
|
226
|
+
attempts.append(f"{label}:{device} isOpened()=False")
|
|
227
|
+
cap.release()
|
|
228
|
+
continue
|
|
229
|
+
_tune_capture(cap)
|
|
230
|
+
ok, reason = _capture_yields_frame(cap)
|
|
231
|
+
if ok:
|
|
232
|
+
return cap, device, label, attempts
|
|
233
|
+
attempts.append(f"{label}:{device} {reason}")
|
|
234
|
+
cap.release()
|
|
235
|
+
return None, None, None, attempts
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_camera():
|
|
239
|
+
global camera, active_camera_device, active_camera_backend
|
|
240
|
+
global camera_open_attempts, camera_open_error, camera_open_flag
|
|
241
|
+
if camera is not None and camera.isOpened():
|
|
242
|
+
camera_open_flag = True
|
|
243
|
+
return camera
|
|
244
|
+
|
|
245
|
+
cap, device, backend, attempts = _acquire_camera()
|
|
246
|
+
camera_open_attempts = attempts
|
|
247
|
+
if cap is None:
|
|
248
|
+
camera = None
|
|
249
|
+
camera_open_flag = False
|
|
250
|
+
active_camera_device = None
|
|
251
|
+
active_camera_backend = None
|
|
252
|
+
camera_open_error = "; ".join(attempts) or "no candidate devices"
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
camera = cap
|
|
256
|
+
camera_open_flag = True
|
|
257
|
+
active_camera_device = device
|
|
258
|
+
active_camera_backend = backend
|
|
259
|
+
camera_open_error = None
|
|
260
|
+
for failure in attempts:
|
|
261
|
+
print(f"Camera probe skipped {failure}")
|
|
262
|
+
print(f"Camera opened: device={device} backend={backend}")
|
|
263
|
+
return camera
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _report_camera_unavailable():
|
|
267
|
+
backends = "/".join(label for _, label in _camera_backends())
|
|
268
|
+
candidates = _camera_candidates(current_camera_index)
|
|
269
|
+
listed = ", ".join(str(c) for c in candidates) or "(none)"
|
|
270
|
+
print("=" * 60)
|
|
271
|
+
print("CAMERA UNAVAILABLE - giving up after "
|
|
272
|
+
f"{CAMERA_OPEN_MAX_ATTEMPTS} attempts")
|
|
273
|
+
print(f" configured device : {current_camera_index}")
|
|
274
|
+
print(f" backends tried : {backends}")
|
|
275
|
+
print(f" devices tried : {listed}")
|
|
276
|
+
for failure in camera_open_attempts or ["(no candidate devices to try)"]:
|
|
277
|
+
print(f" - {failure}")
|
|
278
|
+
print(" OS-reported cameras: " + (", ".join(_camera_names()) or "(none)"))
|
|
279
|
+
print(" Fix: plug in / free the camera, then POST /api/camera/switch "
|
|
280
|
+
"(or restart). Set ROBOPARK_CAMERA_DEVICE to a name or index; "
|
|
281
|
+
"GET /api/media/inventory lists what this machine can see.")
|
|
282
|
+
print("=" * 60)
|
|
283
|
+
|
|
284
|
+
# Global variables
|
|
285
|
+
latest_detections = []
|
|
286
|
+
lock = threading.Lock()
|
|
287
|
+
caption_mode_enabled = False
|
|
288
|
+
motion_detection_active = False
|
|
289
|
+
motion_detected_state = False
|
|
290
|
+
last_motion_time = 0
|
|
291
|
+
motion_frame_buffer = None
|
|
292
|
+
webhook_url = None
|
|
293
|
+
last_webhook_send_time = 0
|
|
294
|
+
webhook_send_interval = 0.5
|
|
295
|
+
|
|
296
|
+
# Vision-confirm (Ollama Cloud) config — stage 2 of the motion pipeline.
|
|
297
|
+
# Motion detection (frame-diff) is a cheap pre-filter; before we fire the
|
|
298
|
+
# session webhook we ask a vision model to confirm a person is actually in
|
|
299
|
+
# frame, to cut down on false triggers from pets/shadows/wind.
|
|
300
|
+
OLLAMA_CLOUD_API_KEY = os.getenv("OLLAMA_CLOUD_API_KEY", "")
|
|
301
|
+
OLLAMA_CLOUD_VISION_MODEL = os.getenv("OLLAMA_CLOUD_VISION_MODEL", "gemma3:27b")
|
|
302
|
+
OLLAMA_CLOUD_VISION_URL = "https://ollama.com/v1/chat/completions"
|
|
303
|
+
VISION_CONFIRM_TIMEOUT = 8
|
|
304
|
+
|
|
305
|
+
# Simple object detection using OpenCV DNN (MobileNet SSD)
|
|
306
|
+
try:
|
|
307
|
+
# Load pre-trained MobileNet SSD model
|
|
308
|
+
net = cv2.dnn.readNetFromCaffe(
|
|
309
|
+
'deploy.prototxt',
|
|
310
|
+
'mobilenet_iter_73000.caffemodel'
|
|
311
|
+
)
|
|
312
|
+
DETECTION_AVAILABLE = True
|
|
313
|
+
print("Object detection model loaded")
|
|
314
|
+
except:
|
|
315
|
+
DETECTION_AVAILABLE = False
|
|
316
|
+
print("Object detection model not found - running without detection")
|
|
317
|
+
|
|
318
|
+
# COCO class labels
|
|
319
|
+
CLASSES = ["background", "aeroplane", "bicycle", "bird", "boat",
|
|
320
|
+
"bottle", "bus", "car", "cat", "chair", "cow", "diningtable",
|
|
321
|
+
"dog", "horse", "motorbike", "person", "pottedplant", "sheep",
|
|
322
|
+
"sofa", "train", "tvmonitor"]
|
|
323
|
+
|
|
324
|
+
def detect_objects(frame, conf_threshold=0.5):
|
|
325
|
+
"""Detect objects using OpenCV DNN"""
|
|
326
|
+
if not DETECTION_AVAILABLE:
|
|
327
|
+
return frame, []
|
|
328
|
+
|
|
329
|
+
(h, w) = frame.shape[:2]
|
|
330
|
+
blob = cv2.dnn.blobFromImage(cv2.resize(frame, (300, 300)), 0.007843, (300, 300), 127.5)
|
|
331
|
+
net.setInput(blob)
|
|
332
|
+
detections_dnn = net.forward()
|
|
333
|
+
|
|
334
|
+
detected_objects = []
|
|
335
|
+
|
|
336
|
+
for i in range(detections_dnn.shape[2]):
|
|
337
|
+
confidence = detections_dnn[0, 0, i, 2]
|
|
338
|
+
|
|
339
|
+
if confidence > conf_threshold:
|
|
340
|
+
idx = int(detections_dnn[0, 0, i, 1])
|
|
341
|
+
if idx >= len(CLASSES):
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
box = detections_dnn[0, 0, i, 3:7] * np.array([w, h, w, h])
|
|
345
|
+
(startX, startY, endX, endY) = box.astype("int")
|
|
346
|
+
|
|
347
|
+
label = CLASSES[idx]
|
|
348
|
+
|
|
349
|
+
# Draw bounding box
|
|
350
|
+
cv2.rectangle(frame, (startX, startY), (endX, endY), (0, 255, 0), 2)
|
|
351
|
+
|
|
352
|
+
# Draw label with confidence
|
|
353
|
+
text = f"{label}: {confidence*100:.1f}%"
|
|
354
|
+
y = startY - 15 if startY - 15 > 15 else startY + 15
|
|
355
|
+
cv2.putText(frame, text, (startX, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
|
|
356
|
+
|
|
357
|
+
detected_objects.append({
|
|
358
|
+
"label": label,
|
|
359
|
+
"confidence": float(confidence),
|
|
360
|
+
"bbox": [int(startX), int(startY), int(endX), int(endY)],
|
|
361
|
+
"is_focus": False
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
return frame, detected_objects
|
|
365
|
+
|
|
366
|
+
def confirm_person_present(frame):
|
|
367
|
+
"""Ask an Ollama Cloud vision model whether a person is visible in `frame`.
|
|
368
|
+
|
|
369
|
+
This is stage 2 of the motion pipeline: motion detection (frame-diff) is a
|
|
370
|
+
cheap pre-filter, and this confirms a person is actually present before we
|
|
371
|
+
fire the session webhook — cuts down on false triggers from pets, shadows,
|
|
372
|
+
wind, etc.
|
|
373
|
+
|
|
374
|
+
Fails OPEN (returns True) on any error — missing key, network failure,
|
|
375
|
+
timeout, bad response — since the pre-existing motion-only trigger is the
|
|
376
|
+
fallback behavior and a vision-API outage shouldn't silently disable the
|
|
377
|
+
whole trigger system.
|
|
378
|
+
"""
|
|
379
|
+
if not OLLAMA_CLOUD_API_KEY:
|
|
380
|
+
# No key configured: skip the check entirely, preserve motion-only
|
|
381
|
+
# behavior as the zero-config default. Caller logs this case.
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
386
|
+
img_base64 = base64.b64encode(buffer).decode('utf-8')
|
|
387
|
+
|
|
388
|
+
payload = {
|
|
389
|
+
"model": OLLAMA_CLOUD_VISION_MODEL,
|
|
390
|
+
"messages": [
|
|
391
|
+
{
|
|
392
|
+
"role": "user",
|
|
393
|
+
"content": [
|
|
394
|
+
{
|
|
395
|
+
"type": "text",
|
|
396
|
+
"text": "Is there a person clearly visible in this image? Answer with only YES or NO."
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"type": "image_url",
|
|
400
|
+
"image_url": {"url": f"data:image/jpeg;base64,{img_base64}"}
|
|
401
|
+
}
|
|
402
|
+
]
|
|
403
|
+
}
|
|
404
|
+
],
|
|
405
|
+
"stream": False
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
response = requests.post(
|
|
409
|
+
OLLAMA_CLOUD_VISION_URL,
|
|
410
|
+
json=payload,
|
|
411
|
+
headers={
|
|
412
|
+
"Authorization": f"Bearer {OLLAMA_CLOUD_API_KEY}",
|
|
413
|
+
"Content-Type": "application/json"
|
|
414
|
+
},
|
|
415
|
+
timeout=VISION_CONFIRM_TIMEOUT
|
|
416
|
+
)
|
|
417
|
+
response.raise_for_status()
|
|
418
|
+
|
|
419
|
+
answer = response.json()["choices"][0]["message"]["content"].strip()
|
|
420
|
+
return answer.upper().startswith("YES")
|
|
421
|
+
except Exception as e:
|
|
422
|
+
print(f"WARNING: vision-confirm error, failing open (treating as person present): {e}")
|
|
423
|
+
return True
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def send_webhook(frame_data):
|
|
427
|
+
"""Send frame to webhook URL"""
|
|
428
|
+
global webhook_url, last_webhook_send_time
|
|
429
|
+
|
|
430
|
+
if not webhook_url:
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
current_time = time.time()
|
|
434
|
+
if current_time - last_webhook_send_time < webhook_send_interval:
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
_, buffer = cv2.imencode('.jpg', frame_data)
|
|
439
|
+
img_base64 = base64.b64encode(buffer).decode('utf-8')
|
|
440
|
+
|
|
441
|
+
payload = {
|
|
442
|
+
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
443
|
+
'image': img_base64,
|
|
444
|
+
'format': 'jpeg',
|
|
445
|
+
'encoding': 'base64'
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
def send_async():
|
|
449
|
+
try:
|
|
450
|
+
response = requests.post(webhook_url, json=payload, headers={'Content-Type': 'application/json'}, timeout=5)
|
|
451
|
+
if response.status_code == 200:
|
|
452
|
+
print(f"Webhook sent successfully")
|
|
453
|
+
except Exception as e:
|
|
454
|
+
print(f"Webhook error: {e}")
|
|
455
|
+
|
|
456
|
+
thread = threading.Thread(target=send_async, daemon=True)
|
|
457
|
+
thread.start()
|
|
458
|
+
last_webhook_send_time = current_time
|
|
459
|
+
except Exception as e:
|
|
460
|
+
print(f"Error preparing webhook: {e}")
|
|
461
|
+
|
|
462
|
+
def camera_worker():
|
|
463
|
+
global latest_detections, motion_detection_active, motion_detected_state
|
|
464
|
+
global last_motion_time, motion_frame_buffer
|
|
465
|
+
global latest_frame_bytes, latest_frame_sequence
|
|
466
|
+
global camera_read_started_at, camera_last_frame_at, camera
|
|
467
|
+
|
|
468
|
+
global camera_worker_started
|
|
469
|
+
|
|
470
|
+
prev_gray = None
|
|
471
|
+
failed_opens = 0
|
|
472
|
+
|
|
473
|
+
while True:
|
|
474
|
+
with camera_lock:
|
|
475
|
+
cam = get_camera()
|
|
476
|
+
if cam is None or not cam.isOpened():
|
|
477
|
+
failed_opens += 1
|
|
478
|
+
if failed_opens >= CAMERA_OPEN_MAX_ATTEMPTS:
|
|
479
|
+
# Bounded, not infinite: a doomed 1/sec retry loop buries the
|
|
480
|
+
# real error. Clearing the started flag lets an explicit
|
|
481
|
+
# /api/camera/switch or a new /video_feed request try again.
|
|
482
|
+
_report_camera_unavailable()
|
|
483
|
+
with frame_condition:
|
|
484
|
+
camera_worker_started = False
|
|
485
|
+
return
|
|
486
|
+
time.sleep(1)
|
|
487
|
+
continue
|
|
488
|
+
failed_opens = 0
|
|
489
|
+
|
|
490
|
+
camera_read_started_at = time.monotonic()
|
|
491
|
+
try:
|
|
492
|
+
# This is the only camera reader. Do not hold camera_lock here:
|
|
493
|
+
# the watchdog must be able to release a wedged V4L2 handle.
|
|
494
|
+
success, frame = cam.read()
|
|
495
|
+
except Exception as exc:
|
|
496
|
+
print(f"Camera read error: {exc}")
|
|
497
|
+
success, frame = False, None
|
|
498
|
+
finally:
|
|
499
|
+
camera_read_started_at = 0.0
|
|
500
|
+
if not success:
|
|
501
|
+
with camera_lock:
|
|
502
|
+
if camera is cam:
|
|
503
|
+
camera.release()
|
|
504
|
+
camera = None
|
|
505
|
+
camera_open_flag = False
|
|
506
|
+
time.sleep(0.1)
|
|
507
|
+
continue
|
|
508
|
+
camera_last_frame_at = time.monotonic()
|
|
509
|
+
|
|
510
|
+
# Run object detection
|
|
511
|
+
processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
|
|
512
|
+
|
|
513
|
+
with lock:
|
|
514
|
+
latest_detections = detections
|
|
515
|
+
|
|
516
|
+
# Motion detection logic
|
|
517
|
+
if motion_detection_active:
|
|
518
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
519
|
+
gray = cv2.GaussianBlur(gray, (21, 21), 0)
|
|
520
|
+
|
|
521
|
+
if prev_gray is not None:
|
|
522
|
+
frame_delta = cv2.absdiff(prev_gray, gray)
|
|
523
|
+
thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1]
|
|
524
|
+
thresh = cv2.dilate(thresh, None, iterations=2)
|
|
525
|
+
contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
526
|
+
|
|
527
|
+
motion_detected = False
|
|
528
|
+
for contour in contours:
|
|
529
|
+
if cv2.contourArea(contour) >= 500:
|
|
530
|
+
motion_detected = True
|
|
531
|
+
(x, y, w, h) = cv2.boundingRect(contour)
|
|
532
|
+
cv2.rectangle(processed_frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
|
|
533
|
+
break
|
|
534
|
+
|
|
535
|
+
if motion_detected:
|
|
536
|
+
motion_detected_state = True
|
|
537
|
+
last_motion_time = time.time()
|
|
538
|
+
motion_frame_buffer = processed_frame.copy()
|
|
539
|
+
print(f"Motion detected!")
|
|
540
|
+
|
|
541
|
+
# Only run the (network-bound) vision-confirm + webhook
|
|
542
|
+
# once per motion "event" — reuse the same debounce timer
|
|
543
|
+
# send_webhook() itself uses, rather than calling the
|
|
544
|
+
# vision API on every single frame while motion continues.
|
|
545
|
+
if webhook_url and (time.time() - last_webhook_send_time >= webhook_send_interval):
|
|
546
|
+
if not OLLAMA_CLOUD_API_KEY:
|
|
547
|
+
send_webhook(processed_frame)
|
|
548
|
+
elif confirm_person_present(processed_frame):
|
|
549
|
+
send_webhook(processed_frame)
|
|
550
|
+
else:
|
|
551
|
+
print("Motion event suppressed: vision-confirm found no person present")
|
|
552
|
+
else:
|
|
553
|
+
motion_detected_state = False
|
|
554
|
+
|
|
555
|
+
prev_gray = gray
|
|
556
|
+
|
|
557
|
+
ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
558
|
+
if not ret:
|
|
559
|
+
continue
|
|
560
|
+
with frame_condition:
|
|
561
|
+
latest_frame_bytes = buffer.tobytes()
|
|
562
|
+
latest_frame_sequence += 1
|
|
563
|
+
frame_condition.notify_all()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _ensure_camera_worker():
|
|
567
|
+
global camera_worker_started, camera_watchdog_started
|
|
568
|
+
with frame_condition:
|
|
569
|
+
if camera_worker_started:
|
|
570
|
+
return
|
|
571
|
+
camera_worker_started = True
|
|
572
|
+
start_watchdog = not camera_watchdog_started
|
|
573
|
+
camera_watchdog_started = True
|
|
574
|
+
threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
|
|
575
|
+
if start_watchdog:
|
|
576
|
+
# The worker can restart after a bounded open failure; the watchdog is
|
|
577
|
+
# stateless and must not be duplicated each time it does.
|
|
578
|
+
threading.Thread(target=_camera_watchdog, name='robovision-camera-watchdog', daemon=True).start()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _camera_watchdog():
|
|
582
|
+
global camera, camera_read_started_at
|
|
583
|
+
while True:
|
|
584
|
+
time.sleep(2.0)
|
|
585
|
+
started = camera_read_started_at
|
|
586
|
+
if not started or time.monotonic() - started < 8.0:
|
|
587
|
+
continue
|
|
588
|
+
print("Camera read stalled for 8s; releasing V4L2 handle")
|
|
589
|
+
with camera_lock:
|
|
590
|
+
if camera is not None:
|
|
591
|
+
try:
|
|
592
|
+
camera.release()
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
camera = None
|
|
596
|
+
camera_open_flag = False
|
|
597
|
+
camera_read_started_at = 0.0
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def generate_frames():
|
|
601
|
+
_ensure_camera_worker()
|
|
602
|
+
sequence = -1
|
|
603
|
+
while True:
|
|
604
|
+
with frame_condition:
|
|
605
|
+
frame_condition.wait_for(
|
|
606
|
+
lambda: latest_frame_bytes is not None and latest_frame_sequence != sequence,
|
|
607
|
+
timeout=5.0,
|
|
608
|
+
)
|
|
609
|
+
if latest_frame_bytes is None or latest_frame_sequence == sequence:
|
|
610
|
+
continue
|
|
611
|
+
frame_bytes = latest_frame_bytes
|
|
612
|
+
sequence = latest_frame_sequence
|
|
613
|
+
|
|
614
|
+
yield (b'--frame\r\n'
|
|
615
|
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
|
616
|
+
|
|
617
|
+
@app.route('/')
|
|
618
|
+
def index():
|
|
619
|
+
return jsonify({"status": "ok", "message": "RoboVision Pi Server", "version": "1.0"})
|
|
620
|
+
|
|
621
|
+
@app.route('/video_feed')
|
|
622
|
+
def video_feed():
|
|
623
|
+
return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
|
624
|
+
|
|
625
|
+
@app.route('/api/detections')
|
|
626
|
+
def get_detections():
|
|
627
|
+
with lock:
|
|
628
|
+
return jsonify(latest_detections)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@app.route('/api/camera/status')
|
|
632
|
+
def camera_status():
|
|
633
|
+
age = None if not camera_last_frame_at else round(time.monotonic() - camera_last_frame_at, 3)
|
|
634
|
+
return jsonify({
|
|
635
|
+
"device": str(current_camera_index),
|
|
636
|
+
"active_device": active_camera_device,
|
|
637
|
+
"active_backend": active_camera_backend,
|
|
638
|
+
"error": camera_open_error,
|
|
639
|
+
"attempts": camera_open_attempts,
|
|
640
|
+
# Deliberately the cached flag, not camera.isOpened(): see camera_open_flag.
|
|
641
|
+
"open": bool(camera_open_flag),
|
|
642
|
+
"worker_started": camera_worker_started,
|
|
643
|
+
"frame_sequence": latest_frame_sequence,
|
|
644
|
+
"last_frame_age_seconds": age,
|
|
645
|
+
"read_stalled": bool(camera_read_started_at and time.monotonic() - camera_read_started_at >= 8.0),
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
@app.route('/api/caption')
|
|
649
|
+
def get_caption():
|
|
650
|
+
return jsonify({"caption": "Awaiting caption..."})
|
|
651
|
+
|
|
652
|
+
@app.route('/api/caption_mode', methods=['GET', 'POST'])
|
|
653
|
+
def caption_mode():
|
|
654
|
+
global caption_mode_enabled
|
|
655
|
+
if request.method == 'GET':
|
|
656
|
+
return jsonify({"enabled": caption_mode_enabled})
|
|
657
|
+
data = request.json or {}
|
|
658
|
+
caption_mode_enabled = bool(data.get('enabled', False))
|
|
659
|
+
return jsonify({"enabled": caption_mode_enabled})
|
|
660
|
+
|
|
661
|
+
def _enumerate_cameras():
|
|
662
|
+
"""List cameras with friendly names. Cached: probing reopens devices."""
|
|
663
|
+
global _camera_inventory_cache, _camera_inventory_cache_at
|
|
664
|
+
now = time.monotonic()
|
|
665
|
+
if now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS:
|
|
666
|
+
return _camera_inventory_cache
|
|
667
|
+
|
|
668
|
+
available = []
|
|
669
|
+
if sys.platform.startswith('linux'):
|
|
670
|
+
# Query capabilities without starting a stream. Opening every V4L2
|
|
671
|
+
# node through OpenCV also opens metadata/output nodes and can contend
|
|
672
|
+
# with the camera stream already owned by RoboVision.
|
|
673
|
+
import fcntl
|
|
674
|
+
vidioc_querycap = 0x80685600
|
|
675
|
+
video_capture = 0x00000001
|
|
676
|
+
video_capture_mplane = 0x00001000
|
|
677
|
+
device_caps_flag = 0x80000000
|
|
678
|
+
for candidate in sorted(glob.glob('/dev/video*')):
|
|
679
|
+
fd = None
|
|
680
|
+
try:
|
|
681
|
+
fd = os.open(candidate, os.O_RDONLY | os.O_NONBLOCK)
|
|
682
|
+
capability = bytearray(104)
|
|
683
|
+
fcntl.ioctl(fd, vidioc_querycap, capability, True)
|
|
684
|
+
capabilities = struct.unpack_from('=I', capability, 84)[0]
|
|
685
|
+
device_caps = struct.unpack_from('=I', capability, 88)[0]
|
|
686
|
+
effective = device_caps if capabilities & device_caps_flag else capabilities
|
|
687
|
+
if not effective & (video_capture | video_capture_mplane):
|
|
688
|
+
continue
|
|
689
|
+
card = bytes(capability[16:48]).split(b'\0', 1)[0].decode('utf-8', 'replace')
|
|
690
|
+
available.append({
|
|
691
|
+
"index": candidate,
|
|
692
|
+
"id": candidate,
|
|
693
|
+
"name": card or f"Camera {candidate}",
|
|
694
|
+
"backend": "v4l2",
|
|
695
|
+
})
|
|
696
|
+
except (OSError, ValueError):
|
|
697
|
+
continue
|
|
698
|
+
finally:
|
|
699
|
+
if fd is not None:
|
|
700
|
+
os.close(fd)
|
|
701
|
+
else:
|
|
702
|
+
names = _camera_names()
|
|
703
|
+
for candidate in range(CAMERA_SCAN_MAX_INDEX + 1):
|
|
704
|
+
# Positional name correlation — see _windows_camera_names().
|
|
705
|
+
name = names[candidate] if candidate < len(names) else f"Camera {candidate}"
|
|
706
|
+
if camera is not None and camera.isOpened() and candidate == active_camera_device:
|
|
707
|
+
# Never reopen the device the streaming worker owns.
|
|
708
|
+
available.append({"index": candidate, "id": str(candidate), "name": name,
|
|
709
|
+
"backend": active_camera_backend, "active": True})
|
|
710
|
+
continue
|
|
711
|
+
for backend, label in _camera_backends():
|
|
712
|
+
cap = cv2.VideoCapture(candidate, backend)
|
|
713
|
+
opened = cap.isOpened()
|
|
714
|
+
cap.release()
|
|
715
|
+
if opened:
|
|
716
|
+
available.append({"index": candidate, "id": str(candidate), "name": name,
|
|
717
|
+
"backend": label, "active": False})
|
|
718
|
+
break
|
|
719
|
+
|
|
720
|
+
_camera_inventory_cache = available
|
|
721
|
+
_camera_inventory_cache_at = now
|
|
722
|
+
return available
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@app.route('/api/cameras', methods=['GET'])
|
|
726
|
+
def list_cameras():
|
|
727
|
+
return jsonify({
|
|
728
|
+
"cameras": _enumerate_cameras(),
|
|
729
|
+
"current": current_camera_index,
|
|
730
|
+
"active_device": active_camera_device,
|
|
731
|
+
"active_backend": active_camera_backend,
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
def _reselect_camera(value):
|
|
735
|
+
"""Point the worker at a new device (index, name or path) and revive it."""
|
|
736
|
+
global camera, current_camera_index, camera_open_error, camera_open_attempts
|
|
737
|
+
global camera_open_flag
|
|
738
|
+
with camera_lock:
|
|
739
|
+
if camera:
|
|
740
|
+
camera.release()
|
|
741
|
+
current_camera_index = _normalize_camera_device(value)
|
|
742
|
+
camera = None
|
|
743
|
+
camera_open_flag = False
|
|
744
|
+
camera_open_error = None
|
|
745
|
+
camera_open_attempts = []
|
|
746
|
+
# The worker exits after a bounded open failure; an explicit selection is
|
|
747
|
+
# the operator saying "try again".
|
|
748
|
+
_ensure_camera_worker()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
@app.route('/api/camera/switch', methods=['POST'])
|
|
752
|
+
def switch_camera():
|
|
753
|
+
data = request.json or {}
|
|
754
|
+
new_index = data.get('device', data.get('index', 0))
|
|
755
|
+
if isinstance(new_index, str) and new_index.isdigit():
|
|
756
|
+
new_index = int(new_index)
|
|
757
|
+
|
|
758
|
+
_reselect_camera(new_index)
|
|
759
|
+
return jsonify({"status": "ok", "camera_index": current_camera_index})
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _audio_inventory():
|
|
763
|
+
"""Adapt RoboVision's existing audio_server_pi /devices response."""
|
|
764
|
+
try:
|
|
765
|
+
response = requests.get(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/devices", timeout=0.8)
|
|
766
|
+
response.raise_for_status()
|
|
767
|
+
payload = response.json()
|
|
768
|
+
inputs, outputs = [], []
|
|
769
|
+
for device in payload.get("devices", []):
|
|
770
|
+
item = {
|
|
771
|
+
"id": str(device["index"]),
|
|
772
|
+
"name": str(device.get("name", f"Audio device {device['index']}")),
|
|
773
|
+
"backend": "robovision_audio",
|
|
774
|
+
"sample_rate": device.get("default_samplerate"),
|
|
775
|
+
}
|
|
776
|
+
if device.get("max_input_channels", 0) > 0:
|
|
777
|
+
inputs.append(item.copy())
|
|
778
|
+
if device.get("max_output_channels", 0) > 0:
|
|
779
|
+
outputs.append(item.copy())
|
|
780
|
+
return inputs, outputs, {
|
|
781
|
+
"input": payload.get("bluetooth_input"),
|
|
782
|
+
"output": payload.get("bluetooth_output"),
|
|
783
|
+
"online": True,
|
|
784
|
+
}
|
|
785
|
+
except Exception as exc:
|
|
786
|
+
return [], [], {"online": False, "error": str(exc)}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _enumerate_cameras_async():
|
|
790
|
+
"""Whatever the last probe found, refreshed in the background.
|
|
791
|
+
|
|
792
|
+
`_enumerate_cameras()` opens devices through OpenCV, so calling it from a
|
|
793
|
+
request handler hands the caller a request that blocks for as long as the
|
|
794
|
+
driver does -- unbounded on a wedged Windows capture. This endpoint is the
|
|
795
|
+
one an operator (or `robopark start`) reaches for precisely when the camera
|
|
796
|
+
is misbehaving, so it must answer immediately even if the answer is stale
|
|
797
|
+
or empty. The probe runs on its own thread and lands in the cache for the
|
|
798
|
+
next call.
|
|
799
|
+
"""
|
|
800
|
+
global _camera_inventory_probing
|
|
801
|
+
now = time.monotonic()
|
|
802
|
+
fresh = now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS
|
|
803
|
+
if fresh:
|
|
804
|
+
return _camera_inventory_cache, False
|
|
805
|
+
if not _camera_inventory_probing:
|
|
806
|
+
_camera_inventory_probing = True
|
|
807
|
+
|
|
808
|
+
def probe():
|
|
809
|
+
global _camera_inventory_probing
|
|
810
|
+
try:
|
|
811
|
+
_enumerate_cameras()
|
|
812
|
+
except Exception as exc:
|
|
813
|
+
print(f"Camera inventory probe failed: {exc}")
|
|
814
|
+
finally:
|
|
815
|
+
_camera_inventory_probing = False
|
|
816
|
+
|
|
817
|
+
threading.Thread(target=probe, daemon=True).start()
|
|
818
|
+
return _camera_inventory_cache, True
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
@app.route('/api/media/inventory', methods=['GET'])
|
|
822
|
+
def media_inventory():
|
|
823
|
+
cameras, probing = _enumerate_cameras_async()
|
|
824
|
+
inputs, outputs, audio_state = _audio_inventory()
|
|
825
|
+
return jsonify({
|
|
826
|
+
"video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
|
|
827
|
+
"video_state": {
|
|
828
|
+
"active_device": active_camera_device,
|
|
829
|
+
"active_backend": active_camera_backend,
|
|
830
|
+
"open": bool(camera_open_flag),
|
|
831
|
+
# True when the OpenCV probe is still running, so a caller can tell
|
|
832
|
+
# "no cameras found" apart from "not finished looking yet".
|
|
833
|
+
"probing": probing,
|
|
834
|
+
"error": camera_open_error,
|
|
835
|
+
"attempts": camera_open_attempts,
|
|
836
|
+
# Names come from the OS, indices from OpenCV; the pairing is
|
|
837
|
+
# positional and best-effort (see _windows_camera_names()). The raw
|
|
838
|
+
# OS list is exposed too so an operator can see when it is longer
|
|
839
|
+
# than the list of indices OpenCV can actually open.
|
|
840
|
+
"name_correlation": "heuristic" if sys.platform == 'win32' else "exact",
|
|
841
|
+
"os_reported_names": _camera_names(),
|
|
842
|
+
},
|
|
843
|
+
"audio_input": inputs,
|
|
844
|
+
"audio_output": outputs,
|
|
845
|
+
"selected": {
|
|
846
|
+
"video_device": str(current_camera_index),
|
|
847
|
+
"audio_device": str(audio_state.get("input") if audio_state.get("input") is not None else audio_input_device),
|
|
848
|
+
"audio_output_device": str(audio_state.get("output") if audio_state.get("output") is not None else audio_output_device),
|
|
849
|
+
},
|
|
850
|
+
"audio_server": audio_state,
|
|
851
|
+
"source": "robovision_pi",
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@app.route('/api/media/config', methods=['GET', 'POST'])
|
|
856
|
+
def media_config():
|
|
857
|
+
global audio_input_device, audio_output_device, camera, current_camera_index
|
|
858
|
+
if request.method == 'POST':
|
|
859
|
+
data = request.json or {}
|
|
860
|
+
if "video_device" in data:
|
|
861
|
+
value = str(data["video_device"])
|
|
862
|
+
if value not in ("", "none"):
|
|
863
|
+
# "auto" is a legitimate selection now — it means scan.
|
|
864
|
+
_reselect_camera(value)
|
|
865
|
+
if "audio_device" in data:
|
|
866
|
+
audio_input_device = str(data["audio_device"])
|
|
867
|
+
if "audio_output_device" in data:
|
|
868
|
+
audio_output_device = str(data["audio_output_device"])
|
|
869
|
+
if "audio_device" in data or "audio_output_device" in data:
|
|
870
|
+
# audio_server_pi.py is RoboVision's authoritative selector.
|
|
871
|
+
# It accepts its original sounddevice indices via input/output.
|
|
872
|
+
payload = {}
|
|
873
|
+
if "audio_device" in data:
|
|
874
|
+
payload["input"] = int(audio_input_device) if audio_input_device.isdigit() else audio_input_device
|
|
875
|
+
if "audio_output_device" in data:
|
|
876
|
+
payload["output"] = int(audio_output_device) if audio_output_device.isdigit() else audio_output_device
|
|
877
|
+
try:
|
|
878
|
+
requests.post(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/set-device", json=payload, timeout=0.8).raise_for_status()
|
|
879
|
+
except Exception:
|
|
880
|
+
pass
|
|
881
|
+
return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})
|
|
882
|
+
|
|
883
|
+
@app.route('/api/motion/status', methods=['GET'])
|
|
884
|
+
def motion_status():
|
|
885
|
+
global motion_detection_active, motion_detected_state, last_motion_time
|
|
886
|
+
return jsonify({
|
|
887
|
+
"active": motion_detection_active,
|
|
888
|
+
"motion_detected": motion_detected_state,
|
|
889
|
+
"last_motion": last_motion_time,
|
|
890
|
+
"time_since_motion": time.time() - last_motion_time if last_motion_time > 0 else None
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
@app.route('/api/motion/toggle', methods=['POST'])
|
|
894
|
+
def motion_toggle():
|
|
895
|
+
global motion_detection_active
|
|
896
|
+
data = request.json or {}
|
|
897
|
+
motion_detection_active = bool(data.get('active', False))
|
|
898
|
+
return jsonify({"status": "success", "active": motion_detection_active})
|
|
899
|
+
|
|
900
|
+
@app.route('/api/motion/snapshot', methods=['GET'])
|
|
901
|
+
def motion_snapshot():
|
|
902
|
+
global motion_frame_buffer
|
|
903
|
+
if motion_frame_buffer is not None:
|
|
904
|
+
_, buffer = cv2.imencode('.jpg', motion_frame_buffer, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
905
|
+
img_base64 = base64.b64encode(buffer).decode('utf-8')
|
|
906
|
+
return jsonify({
|
|
907
|
+
"image": img_base64,
|
|
908
|
+
"timestamp": time.time()
|
|
909
|
+
})
|
|
910
|
+
return jsonify({"error": "No frame available"}), 404
|
|
911
|
+
|
|
912
|
+
@app.route('/api/motion/webhook', methods=['GET', 'POST'])
|
|
913
|
+
def motion_webhook():
|
|
914
|
+
global webhook_url
|
|
915
|
+
|
|
916
|
+
if request.method == 'GET':
|
|
917
|
+
return jsonify({
|
|
918
|
+
"webhook_url": webhook_url or "",
|
|
919
|
+
"configured": webhook_url is not None and len(webhook_url) > 0
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
data = request.json or {}
|
|
923
|
+
new_url = data.get('url', '').strip()
|
|
924
|
+
|
|
925
|
+
if new_url:
|
|
926
|
+
webhook_url = new_url
|
|
927
|
+
return jsonify({
|
|
928
|
+
"status": "success",
|
|
929
|
+
"message": "Webhook URL configured",
|
|
930
|
+
"webhook_url": webhook_url
|
|
931
|
+
})
|
|
932
|
+
else:
|
|
933
|
+
webhook_url = None
|
|
934
|
+
return jsonify({
|
|
935
|
+
"status": "success",
|
|
936
|
+
"message": "Webhook URL cleared",
|
|
937
|
+
"webhook_url": None
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
if __name__ == '__main__':
|
|
941
|
+
parser = argparse.ArgumentParser(description="RoboVision — camera/motion detection server")
|
|
942
|
+
parser.add_argument("--port", type=int, default=int(os.getenv("VISION_PORT", "5000")))
|
|
943
|
+
parser.add_argument("--motion-webhook-url", default=os.getenv("MOTION_WEBHOOK_URL", ""),
|
|
944
|
+
help="where to POST a snapshot when motion is detected, e.g. http://localhost:5057/")
|
|
945
|
+
parser.add_argument("--motion-active", action="store_true",
|
|
946
|
+
default=os.getenv("MOTION_ACTIVE", "").lower() in ("1", "true", "yes"),
|
|
947
|
+
help="arm motion detection immediately on startup (no manual /api/motion/toggle call needed)")
|
|
948
|
+
args = parser.parse_args()
|
|
949
|
+
|
|
950
|
+
if args.motion_webhook_url:
|
|
951
|
+
webhook_url = args.motion_webhook_url
|
|
952
|
+
if args.motion_active:
|
|
953
|
+
motion_detection_active = True
|
|
954
|
+
|
|
955
|
+
print("=" * 60)
|
|
956
|
+
print("RoboVision - Raspberry Pi Vision Server (Minimal)")
|
|
957
|
+
print("=" * 60)
|
|
958
|
+
print(f"Starting Flask server on http://0.0.0.0:{args.port}")
|
|
959
|
+
if webhook_url:
|
|
960
|
+
print(f"Motion webhook: {webhook_url}")
|
|
961
|
+
print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
|
|
962
|
+
print("=" * 60)
|
|
963
|
+
# Start the single camera owner at boot. Production motion and MJPEG
|
|
964
|
+
# readiness must not depend on an operator opening the dashboard first.
|
|
965
|
+
_ensure_camera_worker()
|
|
966
|
+
app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)
|