itsMarco-G commited on
Commit
37663c4
·
1 Parent(s): b71c207

Add settings tab controls and refine UI layout

Browse files
reachy_phone_home/main.py CHANGED
@@ -158,7 +158,7 @@ class TrackerConfig:
158
  phone_use_clear_sec: float = 0.5
159
  phone_not_seen_clear_sec: float = 1.0
160
  pad: float = 0.1
161
- missing_neutral_sec: float = 0.5
162
  neutral_duration: float = 1.2
163
  person_y_ratio: float = 0.3
164
  look_down_after_sec: float = 5.0
@@ -201,6 +201,7 @@ def run_tracker(
201
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
202
  logger = logging.getLogger("yolo26l_phone_use_tracker")
203
  logger.setLevel(logging.INFO)
 
204
 
205
  weights = _resolve_weights(config.weights, logger)
206
  model = YOLO(weights)
@@ -267,7 +268,7 @@ def run_tracker(
267
  frame,
268
  verbose=False,
269
  classes=[PERSON_CLASS_ID, PHONE_CLASS_ID],
270
- conf=config.conf,
271
  imgsz=config.imgsz,
272
  )
273
  boxes = results[0].boxes if results else None
@@ -449,6 +450,9 @@ def run_tracker(
449
  ):
450
  oscillate_start = time.time()
451
  last_heartbeat = time.time()
 
 
 
452
  scheduler.on_heartbeat(
453
  phone_tracked=(last_track_state == "tracking_phone"),
454
  phone_use=last_phone_use_state,
@@ -626,99 +630,27 @@ def main() -> None:
626
  parser = argparse.ArgumentParser(description="Reachy phone use tracker (YOLO26l)")
627
  parser.add_argument("--weights", type=str, default="yolo26l")
628
  parser.add_argument("--conf", type=float, default=0.15)
629
- parser.add_argument("--process-every", type=int, default=1)
630
- parser.add_argument("--imgsz", type=int, default=640)
631
- parser.add_argument("--head-duration", type=float, default=1.2)
632
- parser.add_argument("--move-threshold-px", type=int, default=60)
633
- parser.add_argument("--no-head", action="store_true")
634
- parser.add_argument("--phone-use-confirm-sec", type=float, default=0.5)
635
- parser.add_argument("--phone-use-clear-sec", type=float, default=0.5)
636
- parser.add_argument("--phone-not-seen-clear-sec", type=float, default=1.0)
637
- parser.add_argument("--pad", type=float, default=0.1)
638
- parser.add_argument("--missing-neutral-sec", type=float, default=0.5)
639
- parser.add_argument("--neutral-duration", type=float, default=1.2)
640
- parser.add_argument("--person-y-ratio", type=float, default=0.3)
641
- parser.add_argument("--look-down-after-sec", type=float, default=5.0)
642
- parser.add_argument("--look-down-duration", type=float, default=1.2)
643
- parser.add_argument("--look-down-z-mm", type=float, default=8.0)
644
- parser.add_argument("--look-down-pitch-deg", type=float, default=30.0)
645
- parser.add_argument("--look-down-window-sec", type=float, default=5.0)
646
- parser.add_argument("--person-search-window-sec", type=float, default=5.0)
647
- parser.add_argument("--no-antenna", action="store_true")
648
- parser.add_argument("--antenna-angry-left", type=float, default=-2.6)
649
- parser.add_argument("--antenna-angry-right", type=float, default=2.6)
650
- parser.add_argument("--antenna-neutral-left", type=float, default=0.0)
651
- parser.add_argument("--antenna-neutral-right", type=float, default=0.0)
652
- parser.add_argument("--antenna-transition-sec", type=float, default=0.5)
653
- parser.add_argument("--antenna-relax-sec", type=float, default=1.0)
654
- parser.add_argument("--antenna-happy-amp", type=float, default=0.2)
655
- parser.add_argument("--antenna-happy-duration", type=float, default=0.5)
656
  parser.add_argument("--good-job-heartbeats", type=int, default=3)
657
- parser.add_argument("--phone-use-bad-sec", type=float, default=10.0)
658
- parser.add_argument("--movement-restore-sec", type=float, default=0.6)
659
- parser.add_argument("--phone-home-x-frac", type=float, default=0.0)
660
- parser.add_argument("--phone-home-y-frac", type=float, default=0.5)
661
- parser.add_argument("--phone-home-w-frac", type=float, default=1.0)
662
- parser.add_argument("--phone-home-h-frac", type=float, default=0.5)
663
- parser.add_argument("--phone-home-confirm-sec", type=float, default=0.5)
664
- parser.add_argument("--phone-home-clear-sec", type=float, default=1.0)
665
- parser.add_argument("--phone-home-pitch-deg", type=float, default=25.0)
666
  parser.add_argument("--display", action="store_true", help="Show the OpenCV window")
667
- parser.add_argument("--no-display", action="store_true", help="Disable the OpenCV window")
668
- parser.add_argument("--with-web-ui", action="store_true", help="Run the web UI server")
669
  parser.add_argument("--no-web-ui", action="store_true", help="Disable the web UI server")
670
  parser.set_defaults(no_display=True)
671
  args = parser.parse_args()
672
- if args.display:
673
- args.no_display = False
674
  display_env = os.getenv("REACHY_PHONE_HOME_DISPLAY", "")
675
- if display_env.strip() in ("1", "true", "TRUE", "yes", "YES"):
676
- args.no_display = False
 
 
 
 
677
 
678
  config = TrackerConfig(
679
  weights=args.weights,
680
  conf=args.conf,
681
- process_every=args.process_every,
682
- imgsz=args.imgsz,
683
- head_duration=args.head_duration,
684
- move_threshold_px=args.move_threshold_px,
685
- no_head=args.no_head,
686
- phone_use_confirm_sec=args.phone_use_confirm_sec,
687
- phone_use_clear_sec=args.phone_use_clear_sec,
688
- phone_not_seen_clear_sec=args.phone_not_seen_clear_sec,
689
- pad=args.pad,
690
- missing_neutral_sec=args.missing_neutral_sec,
691
- neutral_duration=args.neutral_duration,
692
- person_y_ratio=args.person_y_ratio,
693
- look_down_after_sec=args.look_down_after_sec,
694
- look_down_duration=args.look_down_duration,
695
- look_down_z_mm=args.look_down_z_mm,
696
- look_down_pitch_deg=args.look_down_pitch_deg,
697
- look_down_window_sec=args.look_down_window_sec,
698
- person_search_window_sec=args.person_search_window_sec,
699
- no_antenna=args.no_antenna,
700
- antenna_angry_left=args.antenna_angry_left,
701
- antenna_angry_right=args.antenna_angry_right,
702
- antenna_neutral_left=args.antenna_neutral_left,
703
- antenna_neutral_right=args.antenna_neutral_right,
704
- antenna_transition_sec=args.antenna_transition_sec,
705
- antenna_relax_sec=args.antenna_relax_sec,
706
- antenna_happy_amp=args.antenna_happy_amp,
707
- antenna_happy_duration=args.antenna_happy_duration,
708
  good_job_heartbeats=args.good_job_heartbeats,
709
- phone_use_bad_sec=args.phone_use_bad_sec,
710
- movement_restore_sec=args.movement_restore_sec,
711
- phone_home_x_frac=args.phone_home_x_frac,
712
- phone_home_y_frac=args.phone_home_y_frac,
713
- phone_home_w_frac=args.phone_home_w_frac,
714
- phone_home_h_frac=args.phone_home_h_frac,
715
- phone_home_confirm_sec=args.phone_home_confirm_sec,
716
- phone_home_clear_sec=args.phone_home_clear_sec,
717
- phone_home_pitch_deg=args.phone_home_pitch_deg,
718
- no_display=args.no_display,
719
  )
720
 
721
- if not args.no_web_ui:
722
  ReachyPhoneHome(config=config).wrapped_run()
723
  return
724
 
 
158
  phone_use_clear_sec: float = 0.5
159
  phone_not_seen_clear_sec: float = 1.0
160
  pad: float = 0.1
161
+ missing_neutral_sec: float = 0.6
162
  neutral_duration: float = 1.2
163
  person_y_ratio: float = 0.3
164
  look_down_after_sec: float = 5.0
 
201
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
202
  logger = logging.getLogger("yolo26l_phone_use_tracker")
203
  logger.setLevel(logging.INFO)
204
+ WEB_UI.set_download_callback(lambda w: _resolve_weights(w, logger))
205
 
206
  weights = _resolve_weights(config.weights, logger)
207
  model = YOLO(weights)
 
268
  frame,
269
  verbose=False,
270
  classes=[PERSON_CLASS_ID, PHONE_CLASS_ID],
271
+ conf=WEB_UI.get_conf(config.conf),
272
  imgsz=config.imgsz,
273
  )
274
  boxes = results[0].boxes if results else None
 
450
  ):
451
  oscillate_start = time.time()
452
  last_heartbeat = time.time()
453
+ scheduler.good_job_heartbeats = WEB_UI.get_good_job_heartbeats(
454
+ config.good_job_heartbeats
455
+ )
456
  scheduler.on_heartbeat(
457
  phone_tracked=(last_track_state == "tracking_phone"),
458
  phone_use=last_phone_use_state,
 
630
  parser = argparse.ArgumentParser(description="Reachy phone use tracker (YOLO26l)")
631
  parser.add_argument("--weights", type=str, default="yolo26l")
632
  parser.add_argument("--conf", type=float, default=0.15)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  parser.add_argument("--good-job-heartbeats", type=int, default=3)
 
 
 
 
 
 
 
 
 
634
  parser.add_argument("--display", action="store_true", help="Show the OpenCV window")
 
 
635
  parser.add_argument("--no-web-ui", action="store_true", help="Disable the web UI server")
636
  parser.set_defaults(no_display=True)
637
  args = parser.parse_args()
 
 
638
  display_env = os.getenv("REACHY_PHONE_HOME_DISPLAY", "")
639
+ display_enabled = args.display or display_env.strip() in ("1", "true", "TRUE", "yes", "YES")
640
+ no_display = not display_enabled
641
+
642
+ web_env = os.getenv("REACHY_PHONE_HOME_WEB_UI", "")
643
+ env_disable = web_env.strip() in ("0", "false", "FALSE", "no", "NO")
644
+ web_ui_enabled = not args.no_web_ui and not env_disable
645
 
646
  config = TrackerConfig(
647
  weights=args.weights,
648
  conf=args.conf,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  good_job_heartbeats=args.good_job_heartbeats,
650
+ no_display=no_display,
 
 
 
 
 
 
 
 
 
651
  )
652
 
653
+ if web_ui_enabled:
654
  ReachyPhoneHome(config=config).wrapped_run()
655
  return
656
 
reachy_phone_home/static/index.html CHANGED
@@ -8,7 +8,7 @@
8
  :root {
9
  color-scheme: dark;
10
  --video-max-width: 720px;
11
- --video-aspect: 16 / 9;
12
  }
13
  body {
14
  margin: 0;
@@ -137,6 +137,86 @@
137
  max-width: 960px;
138
  width: min(960px, 100%);
139
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  .card {
141
  border: 1px solid #232836;
142
  background: #0b0e13;
@@ -233,6 +313,11 @@
233
  <div class="status" id="status">Connecting...</div>
234
  </header>
235
  <div class="container">
 
 
 
 
 
236
  <div class="cards">
237
  <div class="card" id="cardPhoneHome">
238
  <div class="card-header">
@@ -263,7 +348,7 @@
263
  <div class="sub" id="subReachy">Waiting for signals</div>
264
  </div>
265
  </div>
266
- <div id="video">
267
  <div class="video-frame">
268
  <div id="videoPlaceholder" class="video-placeholder">Debug video disabled</div>
269
  <img id="videoStream" data-src="/stream.mjpg" alt="Reachy camera stream" />
@@ -274,12 +359,64 @@
274
  </div>
275
  </div>
276
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  <script>
278
  let lastId = 0;
279
  const statusEl = document.getElementById("status");
280
  const debugBtn = document.getElementById("debugBtn");
281
  const videoStream = document.getElementById("videoStream");
282
  const videoPlaceholder = document.getElementById("videoPlaceholder");
 
 
 
 
 
 
 
 
 
 
283
  let videoEnabled = false;
284
  let currentState = null;
285
  let phoneDetected = null;
@@ -296,6 +433,31 @@
296
  const statusReachy = document.getElementById("statusReachy");
297
  const subReachy = document.getElementById("subReachy");
298
  const statusPhoneHome = document.getElementById("statusPhoneHome");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  function setVideoEnabled(enabled) {
300
  videoEnabled = enabled;
301
  if (videoEnabled) {
@@ -306,6 +468,16 @@
306
  videoStream.src = `${base}?t=${Date.now()}`;
307
  videoStream.style.display = "block";
308
  videoPlaceholder.style.display = "none";
 
 
 
 
 
 
 
 
 
 
309
  })
310
  .catch(() => {});
311
  } else {
@@ -314,6 +486,14 @@
314
  videoPlaceholder.style.display = "flex";
315
  debugBtn.textContent = "Enable debug video";
316
  fetch("/debug?enabled=0").catch(() => {});
 
 
 
 
 
 
 
 
317
  }
318
  }
319
 
@@ -323,12 +503,69 @@
323
 
324
  setVideoEnabled(false);
325
 
326
- videoStream.addEventListener("load", () => {
327
- if (videoStream.naturalWidth && videoStream.naturalHeight) {
328
- document.documentElement.style.setProperty(
329
- "--video-aspect",
330
- `${videoStream.naturalWidth} / ${videoStream.naturalHeight}`
331
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  }
333
  });
334
 
@@ -439,6 +676,7 @@
439
  }
440
 
441
  poll();
 
442
  </script>
443
  </body>
444
  </html>
 
8
  :root {
9
  color-scheme: dark;
10
  --video-max-width: 720px;
11
+ --video-aspect: 4 / 3;
12
  }
13
  body {
14
  margin: 0;
 
137
  max-width: 960px;
138
  width: min(960px, 100%);
139
  }
140
+ .tabs {
141
+ display: flex;
142
+ gap: 8px;
143
+ margin: 16px 20px 0;
144
+ }
145
+ .tab-btn {
146
+ background: #1b2230;
147
+ color: #e9e6e0;
148
+ border: 1px solid #2b3447;
149
+ padding: 6px 12px;
150
+ border-radius: 999px;
151
+ cursor: pointer;
152
+ font-size: 12px;
153
+ }
154
+ .tab-btn.active {
155
+ background: #2a3448;
156
+ }
157
+ .panel {
158
+ width: 100%;
159
+ max-width: 960px;
160
+ display: flex;
161
+ flex-direction: column;
162
+ align-items: center;
163
+ }
164
+ .settings-card {
165
+ border: 1px solid #232836;
166
+ background: #0b0e13;
167
+ border-radius: 12px;
168
+ padding: 16px;
169
+ flex: 0 0 420px;
170
+ max-width: 420px;
171
+ }
172
+ .settings-row {
173
+ display: flex;
174
+ align-items: flex-start;
175
+ justify-content: space-between;
176
+ gap: 12px;
177
+ margin-bottom: 12px;
178
+ flex-wrap: wrap;
179
+ }
180
+ .settings-row label {
181
+ font-size: 12px;
182
+ color: #c9d1d9;
183
+ flex: 1 1 140px;
184
+ }
185
+ .settings-row input,
186
+ .settings-row select {
187
+ background: #10141c;
188
+ border: 1px solid #2b3447;
189
+ color: #e9e6e0;
190
+ padding: 6px 8px;
191
+ border-radius: 8px;
192
+ max-width: 100%;
193
+ box-sizing: border-box;
194
+ flex: 1 1 160px;
195
+ }
196
+ .settings-actions {
197
+ display: flex;
198
+ gap: 10px;
199
+ flex-wrap: wrap;
200
+ align-items: center;
201
+ }
202
+ .settings-actions select {
203
+ flex: 1 1 160px;
204
+ min-width: 0;
205
+ }
206
+ .settings-grid {
207
+ display: flex;
208
+ flex-wrap: wrap;
209
+ gap: 12px;
210
+ margin: 16px auto 40px;
211
+ width: min(960px, 100%);
212
+ box-sizing: border-box;
213
+ justify-content: center;
214
+ }
215
+ .settings-card h4 {
216
+ margin: 0 0 10px 0;
217
+ font-size: 13px;
218
+ color: #e9e6e0;
219
+ }
220
  .card {
221
  border: 1px solid #232836;
222
  background: #0b0e13;
 
313
  <div class="status" id="status">Connecting...</div>
314
  </header>
315
  <div class="container">
316
+ <div class="tabs">
317
+ <button class="tab-btn active" id="tabStatus">Status</button>
318
+ <button class="tab-btn" id="tabSettings">Settings</button>
319
+ </div>
320
+ <div class="panel" id="panelStatus">
321
  <div class="cards">
322
  <div class="card" id="cardPhoneHome">
323
  <div class="card-header">
 
348
  <div class="sub" id="subReachy">Waiting for signals</div>
349
  </div>
350
  </div>
351
+ <div id="video">
352
  <div class="video-frame">
353
  <div id="videoPlaceholder" class="video-placeholder">Debug video disabled</div>
354
  <img id="videoStream" data-src="/stream.mjpg" alt="Reachy camera stream" />
 
359
  </div>
360
  </div>
361
  </div>
362
+ <div class="panel" id="panelSettings" style="display: none;">
363
+ <div class="settings-grid">
364
+ <div class="settings-card">
365
+ <h4>Weights</h4>
366
+ <div class="settings-row">
367
+ <label for="weightsSelect">Download weights</label>
368
+ </div>
369
+ <div class="settings-actions">
370
+ <select id="weightsSelect">
371
+ <option value="yolo26l">yolo26l</option>
372
+ <option value="yolo26m">yolo26m</option>
373
+ <option value="yolo26s">yolo26s</option>
374
+ <option value="yolo26n">yolo26n</option>
375
+ </select>
376
+ <button id="downloadWeightsBtn" class="tab-btn" type="button">Download</button>
377
+ </div>
378
+ </div>
379
+ <div class="settings-card">
380
+ <h4>Detection</h4>
381
+ <div class="settings-row">
382
+ <label for="confInput">Confidence</label>
383
+ <input id="confInput" type="number" min="0.05" max="0.9" step="0.01" value="0.15" />
384
+ </div>
385
+ <div class="settings-row">
386
+ <label for="goodJobSelect">Celebration delay</label>
387
+ <select id="goodJobSelect">
388
+ <option value="10">10s</option>
389
+ <option value="30" selected>30s</option>
390
+ <option value="60">1 min</option>
391
+ <option value="120">2 min</option>
392
+ <option value="300">5 min</option>
393
+ <option value="600">10 min</option>
394
+ </select>
395
+ </div>
396
+ <div class="settings-actions">
397
+ <button id="saveSettingsBtn" class="tab-btn" type="button">Save</button>
398
+ <span class="muted" id="settingsStatus"></span>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ </div>
404
  <script>
405
  let lastId = 0;
406
  const statusEl = document.getElementById("status");
407
  const debugBtn = document.getElementById("debugBtn");
408
  const videoStream = document.getElementById("videoStream");
409
  const videoPlaceholder = document.getElementById("videoPlaceholder");
410
+ const tabStatus = document.getElementById("tabStatus");
411
+ const tabSettings = document.getElementById("tabSettings");
412
+ const panelStatus = document.getElementById("panelStatus");
413
+ const panelSettings = document.getElementById("panelSettings");
414
+ const weightsSelect = document.getElementById("weightsSelect");
415
+ const downloadWeightsBtn = document.getElementById("downloadWeightsBtn");
416
+ const confInput = document.getElementById("confInput");
417
+ const goodJobSelect = document.getElementById("goodJobSelect");
418
+ const saveSettingsBtn = document.getElementById("saveSettingsBtn");
419
+ const settingsStatus = document.getElementById("settingsStatus");
420
  let videoEnabled = false;
421
  let currentState = null;
422
  let phoneDetected = null;
 
433
  const statusReachy = document.getElementById("statusReachy");
434
  const subReachy = document.getElementById("subReachy");
435
  const statusPhoneHome = document.getElementById("statusPhoneHome");
436
+ let aspectTimer = null;
437
+ let snapshotTimer = null;
438
+
439
+ function updateAspectFromStream() {
440
+ if (videoStream.naturalWidth && videoStream.naturalHeight) {
441
+ document.documentElement.style.setProperty(
442
+ "--video-aspect",
443
+ `${videoStream.naturalWidth} / ${videoStream.naturalHeight}`
444
+ );
445
+ }
446
+ }
447
+
448
+ function updateAspectFromSnapshot() {
449
+ const img = new Image();
450
+ img.onload = () => {
451
+ if (img.naturalWidth && img.naturalHeight) {
452
+ document.documentElement.style.setProperty(
453
+ "--video-aspect",
454
+ `${img.naturalWidth} / ${img.naturalHeight}`
455
+ );
456
+ }
457
+ };
458
+ img.src = `/snapshot.jpg?t=${Date.now()}`;
459
+ }
460
+
461
  function setVideoEnabled(enabled) {
462
  videoEnabled = enabled;
463
  if (videoEnabled) {
 
468
  videoStream.src = `${base}?t=${Date.now()}`;
469
  videoStream.style.display = "block";
470
  videoPlaceholder.style.display = "none";
471
+ updateAspectFromStream();
472
+ if (aspectTimer) {
473
+ clearInterval(aspectTimer);
474
+ }
475
+ aspectTimer = setInterval(updateAspectFromStream, 500);
476
+ updateAspectFromSnapshot();
477
+ if (snapshotTimer) {
478
+ clearInterval(snapshotTimer);
479
+ }
480
+ snapshotTimer = setInterval(updateAspectFromSnapshot, 2000);
481
  })
482
  .catch(() => {});
483
  } else {
 
486
  videoPlaceholder.style.display = "flex";
487
  debugBtn.textContent = "Enable debug video";
488
  fetch("/debug?enabled=0").catch(() => {});
489
+ if (aspectTimer) {
490
+ clearInterval(aspectTimer);
491
+ aspectTimer = null;
492
+ }
493
+ if (snapshotTimer) {
494
+ clearInterval(snapshotTimer);
495
+ snapshotTimer = null;
496
+ }
497
  }
498
  }
499
 
 
503
 
504
  setVideoEnabled(false);
505
 
506
+ videoStream.addEventListener("load", updateAspectFromStream);
507
+
508
+ function setTab(name) {
509
+ if (name === "settings") {
510
+ panelStatus.style.display = "none";
511
+ panelSettings.style.display = "flex";
512
+ tabStatus.classList.remove("active");
513
+ tabSettings.classList.add("active");
514
+ } else {
515
+ panelStatus.style.display = "block";
516
+ panelSettings.style.display = "none";
517
+ tabStatus.classList.add("active");
518
+ tabSettings.classList.remove("active");
519
+ }
520
+ }
521
+
522
+ tabStatus.addEventListener("click", () => setTab("status"));
523
+ tabSettings.addEventListener("click", () => setTab("settings"));
524
+
525
+ async function loadSettings() {
526
+ try {
527
+ const res = await fetch("/settings");
528
+ const data = await res.json();
529
+ if (data.conf !== null && data.conf !== undefined) {
530
+ confInput.value = data.conf;
531
+ }
532
+ if (data.good_job_heartbeats !== null && data.good_job_heartbeats !== undefined) {
533
+ const seconds = data.good_job_heartbeats * 10;
534
+ goodJobSelect.value = String(seconds);
535
+ }
536
+ } catch (err) {
537
+ settingsStatus.textContent = "Failed to load settings";
538
+ }
539
+ }
540
+
541
+ saveSettingsBtn.addEventListener("click", async () => {
542
+ settingsStatus.textContent = "Saving...";
543
+ const confVal = parseFloat(confInput.value);
544
+ const seconds = parseInt(goodJobSelect.value, 10);
545
+ const beats = Math.max(1, Math.round(seconds / 10));
546
+ try {
547
+ await fetch("/settings", {
548
+ method: "POST",
549
+ headers: { "Content-Type": "application/json" },
550
+ body: JSON.stringify({ conf: confVal, good_job_heartbeats: beats }),
551
+ });
552
+ settingsStatus.textContent = "Saved";
553
+ } catch (err) {
554
+ settingsStatus.textContent = "Save failed";
555
+ }
556
+ });
557
+
558
+ downloadWeightsBtn.addEventListener("click", async () => {
559
+ settingsStatus.textContent = "Downloading...";
560
+ try {
561
+ await fetch("/download_weights", {
562
+ method: "POST",
563
+ headers: { "Content-Type": "application/json" },
564
+ body: JSON.stringify({ weights: weightsSelect.value }),
565
+ });
566
+ settingsStatus.textContent = "Download queued";
567
+ } catch (err) {
568
+ settingsStatus.textContent = "Download failed";
569
  }
570
  });
571
 
 
676
  }
677
 
678
  poll();
679
+ loadSettings();
680
  </script>
681
  </body>
682
  </html>
reachy_phone_home/web_ui.py CHANGED
@@ -2,14 +2,15 @@ from __future__ import annotations
2
 
3
  import threading
4
  import time
5
- from typing import Any, Iterable
6
 
7
  import cv2
8
 
9
  try:
10
- from fastapi import Response
11
  from starlette.responses import JSONResponse, StreamingResponse
12
  except Exception: # pragma: no cover - optional for script usage
 
13
  Response = None
14
  JSONResponse = None
15
  StreamingResponse = None
@@ -25,6 +26,9 @@ class WebUIState:
25
  self._latest_frame = None
26
  self._video_enabled = False
27
  self._max_log_lines = 300
 
 
 
28
 
29
  def append_log(self, line: str) -> None:
30
  with self._log_lock:
@@ -54,6 +58,34 @@ class WebUIState:
54
  with self._frame_lock:
55
  self._latest_frame = frame.copy()
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def _placeholder(self, text: str) -> bytes:
58
  import numpy as np
59
 
@@ -126,5 +158,47 @@ class WebUIState:
126
  async def snapshot() -> Response:
127
  return Response(content=self.snapshot_jpg(), media_type="image/jpeg")
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  WEB_UI = WebUIState()
 
2
 
3
  import threading
4
  import time
5
+ from typing import Any, Callable, Iterable, Optional
6
 
7
  import cv2
8
 
9
  try:
10
+ from fastapi import Request, Response
11
  from starlette.responses import JSONResponse, StreamingResponse
12
  except Exception: # pragma: no cover - optional for script usage
13
+ Request = None
14
  Response = None
15
  JSONResponse = None
16
  StreamingResponse = None
 
26
  self._latest_frame = None
27
  self._video_enabled = False
28
  self._max_log_lines = 300
29
+ self._conf_override: Optional[float] = None
30
+ self._good_job_heartbeats: Optional[int] = None
31
+ self._download_callback: Optional[Callable[[str], None]] = None
32
 
33
  def append_log(self, line: str) -> None:
34
  with self._log_lock:
 
58
  with self._frame_lock:
59
  self._latest_frame = frame.copy()
60
 
61
+ def set_conf_override(self, conf: Optional[float]) -> None:
62
+ self._conf_override = conf
63
+
64
+ def get_conf(self, default: float) -> float:
65
+ if self._conf_override is None:
66
+ return default
67
+ return float(self._conf_override)
68
+
69
+ def set_good_job_heartbeats(self, beats: Optional[int]) -> None:
70
+ if beats is None:
71
+ self._good_job_heartbeats = None
72
+ else:
73
+ self._good_job_heartbeats = max(1, int(beats))
74
+
75
+ def get_good_job_heartbeats(self, default: int) -> int:
76
+ if self._good_job_heartbeats is None:
77
+ return int(default)
78
+ return int(self._good_job_heartbeats)
79
+
80
+ def set_download_callback(self, callback: Callable[[str], None]) -> None:
81
+ self._download_callback = callback
82
+
83
+ def download_weights(self, weights: str) -> bool:
84
+ if self._download_callback is None:
85
+ return False
86
+ self._download_callback(weights)
87
+ return True
88
+
89
  def _placeholder(self, text: str) -> bytes:
90
  import numpy as np
91
 
 
158
  async def snapshot() -> Response:
159
  return Response(content=self.snapshot_jpg(), media_type="image/jpeg")
160
 
161
+ @app.get("/settings")
162
+ async def settings() -> JSONResponse:
163
+ return JSONResponse(
164
+ {
165
+ "conf": self._conf_override,
166
+ "good_job_heartbeats": self._good_job_heartbeats,
167
+ }
168
+ )
169
+
170
+ @app.post("/settings")
171
+ async def update_settings(request: Request) -> JSONResponse:
172
+ data = {}
173
+ if request is not None:
174
+ try:
175
+ data = await request.json()
176
+ except Exception:
177
+ data = {}
178
+ conf = data.get("conf")
179
+ beats = data.get("good_job_heartbeats")
180
+ self.set_conf_override(conf if conf is None else float(conf))
181
+ self.set_good_job_heartbeats(beats if beats is None else int(beats))
182
+ return JSONResponse(
183
+ {
184
+ "conf": self._conf_override,
185
+ "good_job_heartbeats": self._good_job_heartbeats,
186
+ }
187
+ )
188
+
189
+ @app.post("/download_weights")
190
+ async def download_weights(request: Request) -> JSONResponse:
191
+ data = {}
192
+ if request is not None:
193
+ try:
194
+ data = await request.json()
195
+ except Exception:
196
+ data = {}
197
+ weights = str(data.get("weights", "")).strip()
198
+ ok = False
199
+ if weights:
200
+ ok = self.download_weights(weights)
201
+ return JSONResponse({"ok": ok, "weights": weights})
202
+
203
 
204
  WEB_UI = WebUIState()
web_log_ui.html CHANGED
@@ -137,6 +137,86 @@
137
  max-width: 960px;
138
  width: min(960px, 100%);
139
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  .card {
141
  border: 1px solid #232836;
142
  background: #0b0e13;
@@ -233,6 +313,11 @@
233
  <div class="status" id="status">Connecting...</div>
234
  </header>
235
  <div class="container">
 
 
 
 
 
236
  <div class="cards">
237
  <div class="card" id="cardPhoneHome">
238
  <div class="card-header">
@@ -263,7 +348,7 @@
263
  <div class="sub" id="subReachy">Waiting for signals</div>
264
  </div>
265
  </div>
266
- <div id="video">
267
  <div class="video-frame">
268
  <div id="videoPlaceholder" class="video-placeholder">Debug video disabled</div>
269
  <img id="videoStream" data-src="/stream.mjpg" alt="Reachy camera stream" />
@@ -274,12 +359,64 @@
274
  </div>
275
  </div>
276
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  <script>
278
  let lastId = 0;
279
  const statusEl = document.getElementById("status");
280
  const debugBtn = document.getElementById("debugBtn");
281
  const videoStream = document.getElementById("videoStream");
282
  const videoPlaceholder = document.getElementById("videoPlaceholder");
 
 
 
 
 
 
 
 
 
 
283
  let videoEnabled = false;
284
  let currentState = null;
285
  let phoneDetected = null;
@@ -368,6 +505,70 @@
368
 
369
  videoStream.addEventListener("load", updateAspectFromStream);
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  function setCard(card, statusEl, active, text, alert = false) {
372
  card.classList.toggle("active", active);
373
  card.classList.toggle("alert", alert);
@@ -475,6 +676,7 @@
475
  }
476
 
477
  poll();
 
478
  </script>
479
  </body>
480
  </html>
 
137
  max-width: 960px;
138
  width: min(960px, 100%);
139
  }
140
+ .tabs {
141
+ display: flex;
142
+ gap: 8px;
143
+ margin: 16px 20px 0;
144
+ }
145
+ .tab-btn {
146
+ background: #1b2230;
147
+ color: #e9e6e0;
148
+ border: 1px solid #2b3447;
149
+ padding: 6px 12px;
150
+ border-radius: 999px;
151
+ cursor: pointer;
152
+ font-size: 12px;
153
+ }
154
+ .tab-btn.active {
155
+ background: #2a3448;
156
+ }
157
+ .panel {
158
+ width: 100%;
159
+ max-width: 960px;
160
+ display: flex;
161
+ flex-direction: column;
162
+ align-items: center;
163
+ }
164
+ .settings-card {
165
+ border: 1px solid #232836;
166
+ background: #0b0e13;
167
+ border-radius: 12px;
168
+ padding: 16px;
169
+ flex: 0 0 420px;
170
+ max-width: 420px;
171
+ }
172
+ .settings-row {
173
+ display: flex;
174
+ align-items: flex-start;
175
+ justify-content: space-between;
176
+ gap: 12px;
177
+ margin-bottom: 12px;
178
+ flex-wrap: wrap;
179
+ }
180
+ .settings-row label {
181
+ font-size: 12px;
182
+ color: #c9d1d9;
183
+ flex: 1 1 140px;
184
+ }
185
+ .settings-row input,
186
+ .settings-row select {
187
+ background: #10141c;
188
+ border: 1px solid #2b3447;
189
+ color: #e9e6e0;
190
+ padding: 6px 8px;
191
+ border-radius: 8px;
192
+ max-width: 100%;
193
+ box-sizing: border-box;
194
+ flex: 1 1 160px;
195
+ }
196
+ .settings-actions {
197
+ display: flex;
198
+ gap: 10px;
199
+ flex-wrap: wrap;
200
+ align-items: center;
201
+ }
202
+ .settings-actions select {
203
+ flex: 1 1 160px;
204
+ min-width: 0;
205
+ }
206
+ .settings-grid {
207
+ display: flex;
208
+ flex-wrap: wrap;
209
+ gap: 12px;
210
+ margin: 16px auto 40px;
211
+ width: min(960px, 100%);
212
+ box-sizing: border-box;
213
+ justify-content: center;
214
+ }
215
+ .settings-card h4 {
216
+ margin: 0 0 10px 0;
217
+ font-size: 13px;
218
+ color: #e9e6e0;
219
+ }
220
  .card {
221
  border: 1px solid #232836;
222
  background: #0b0e13;
 
313
  <div class="status" id="status">Connecting...</div>
314
  </header>
315
  <div class="container">
316
+ <div class="tabs">
317
+ <button class="tab-btn active" id="tabStatus">Status</button>
318
+ <button class="tab-btn" id="tabSettings">Settings</button>
319
+ </div>
320
+ <div class="panel" id="panelStatus">
321
  <div class="cards">
322
  <div class="card" id="cardPhoneHome">
323
  <div class="card-header">
 
348
  <div class="sub" id="subReachy">Waiting for signals</div>
349
  </div>
350
  </div>
351
+ <div id="video">
352
  <div class="video-frame">
353
  <div id="videoPlaceholder" class="video-placeholder">Debug video disabled</div>
354
  <img id="videoStream" data-src="/stream.mjpg" alt="Reachy camera stream" />
 
359
  </div>
360
  </div>
361
  </div>
362
+ <div class="panel" id="panelSettings" style="display: none;">
363
+ <div class="settings-grid">
364
+ <div class="settings-card">
365
+ <h4>Weights</h4>
366
+ <div class="settings-row">
367
+ <label for="weightsSelect">Download weights</label>
368
+ </div>
369
+ <div class="settings-actions">
370
+ <select id="weightsSelect">
371
+ <option value="yolo26l">yolo26l</option>
372
+ <option value="yolo26m">yolo26m</option>
373
+ <option value="yolo26s">yolo26s</option>
374
+ <option value="yolo26n">yolo26n</option>
375
+ </select>
376
+ <button id="downloadWeightsBtn" class="tab-btn" type="button">Download</button>
377
+ </div>
378
+ </div>
379
+ <div class="settings-card">
380
+ <h4>Detection</h4>
381
+ <div class="settings-row">
382
+ <label for="confInput">Confidence</label>
383
+ <input id="confInput" type="number" min="0.05" max="0.9" step="0.01" value="0.15" />
384
+ </div>
385
+ <div class="settings-row">
386
+ <label for="goodJobSelect">Celebration delay</label>
387
+ <select id="goodJobSelect">
388
+ <option value="10">10s</option>
389
+ <option value="30" selected>30s</option>
390
+ <option value="60">1 min</option>
391
+ <option value="120">2 min</option>
392
+ <option value="300">5 min</option>
393
+ <option value="600">10 min</option>
394
+ </select>
395
+ </div>
396
+ <div class="settings-actions">
397
+ <button id="saveSettingsBtn" class="tab-btn" type="button">Save</button>
398
+ <span class="muted" id="settingsStatus"></span>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ </div>
404
  <script>
405
  let lastId = 0;
406
  const statusEl = document.getElementById("status");
407
  const debugBtn = document.getElementById("debugBtn");
408
  const videoStream = document.getElementById("videoStream");
409
  const videoPlaceholder = document.getElementById("videoPlaceholder");
410
+ const tabStatus = document.getElementById("tabStatus");
411
+ const tabSettings = document.getElementById("tabSettings");
412
+ const panelStatus = document.getElementById("panelStatus");
413
+ const panelSettings = document.getElementById("panelSettings");
414
+ const weightsSelect = document.getElementById("weightsSelect");
415
+ const downloadWeightsBtn = document.getElementById("downloadWeightsBtn");
416
+ const confInput = document.getElementById("confInput");
417
+ const goodJobSelect = document.getElementById("goodJobSelect");
418
+ const saveSettingsBtn = document.getElementById("saveSettingsBtn");
419
+ const settingsStatus = document.getElementById("settingsStatus");
420
  let videoEnabled = false;
421
  let currentState = null;
422
  let phoneDetected = null;
 
505
 
506
  videoStream.addEventListener("load", updateAspectFromStream);
507
 
508
+ function setTab(name) {
509
+ if (name === "settings") {
510
+ panelStatus.style.display = "none";
511
+ panelSettings.style.display = "flex";
512
+ tabStatus.classList.remove("active");
513
+ tabSettings.classList.add("active");
514
+ } else {
515
+ panelStatus.style.display = "block";
516
+ panelSettings.style.display = "none";
517
+ tabStatus.classList.add("active");
518
+ tabSettings.classList.remove("active");
519
+ }
520
+ }
521
+
522
+ tabStatus.addEventListener("click", () => setTab("status"));
523
+ tabSettings.addEventListener("click", () => setTab("settings"));
524
+
525
+ async function loadSettings() {
526
+ try {
527
+ const res = await fetch("/settings");
528
+ const data = await res.json();
529
+ if (data.conf !== null && data.conf !== undefined) {
530
+ confInput.value = data.conf;
531
+ }
532
+ if (data.good_job_heartbeats !== null && data.good_job_heartbeats !== undefined) {
533
+ const seconds = data.good_job_heartbeats * 10;
534
+ goodJobSelect.value = String(seconds);
535
+ }
536
+ } catch (err) {
537
+ settingsStatus.textContent = "Failed to load settings";
538
+ }
539
+ }
540
+
541
+ saveSettingsBtn.addEventListener("click", async () => {
542
+ settingsStatus.textContent = "Saving...";
543
+ const confVal = parseFloat(confInput.value);
544
+ const seconds = parseInt(goodJobSelect.value, 10);
545
+ const beats = Math.max(1, Math.round(seconds / 10));
546
+ try {
547
+ await fetch("/settings", {
548
+ method: "POST",
549
+ headers: { "Content-Type": "application/json" },
550
+ body: JSON.stringify({ conf: confVal, good_job_heartbeats: beats }),
551
+ });
552
+ settingsStatus.textContent = "Saved";
553
+ } catch (err) {
554
+ settingsStatus.textContent = "Save failed";
555
+ }
556
+ });
557
+
558
+ downloadWeightsBtn.addEventListener("click", async () => {
559
+ settingsStatus.textContent = "Downloading...";
560
+ try {
561
+ await fetch("/download_weights", {
562
+ method: "POST",
563
+ headers: { "Content-Type": "application/json" },
564
+ body: JSON.stringify({ weights: weightsSelect.value }),
565
+ });
566
+ settingsStatus.textContent = "Download queued";
567
+ } catch (err) {
568
+ settingsStatus.textContent = "Download failed";
569
+ }
570
+ });
571
+
572
  function setCard(card, statusEl, active, text, alert = false) {
573
  card.classList.toggle("active", active);
574
  card.classList.toggle("alert", alert);
 
676
  }
677
 
678
  poll();
679
+ loadSettings();
680
  </script>
681
  </body>
682
  </html>