fix(vision): VisionTuning preview blank until camera switched, silent Apply failures

Two real bugs found interactively:

- The preview stayed blank after opening the widget or switching devices, and
  only appeared after switching camera away and back. Root cause: bec_widgets'
  Image.image(device=, signal=) subscribes via bec_dispatcher.connect_slot with
  no newest_only=True (confirmed by reading image.py directly), so it only
  catches publishes that happen *after* the Redis subscription is actually
  established -- the immediate refresh() right after subscribing could race it
  and be silently missed. A deferred refresh (QTimer.singleShot(200, ...)) after
  .image() reliably lands after the subscription is live; harmless since
  refresh() just republishes the same last frame.

- Apply Edge Params appeared to do nothing on an invalid value (e.g. an even
  blur_ksize, easy to land on since the spin box steps through every integer) --
  set_edge_params()'s VisionInterfaceError was only logger.exception()'d, never
  shown in the GUI. Added a status label so Apply/Refresh failures are visible
  instead of indistinguishable from a stalled widget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fDXnPuNvm3qUsUb4V4Gfo
This commit is contained in:
Mirko HollerandClaude Sonnet 5 authored and Mirko Holler committed 2026-10-26 14:16:01 +01:00
1 parent 5d4686f77f
commit b13e80b916
1 file changed
+25 -4
@@ -140,6 +140,13 @@ class VisionTuning(BECWidget, QWidget):
control_row.addStretch()
root.addLayout(control_row)
# Surfaces the last error from an Apply/Refresh action -- e.g. an even blur_ksize rejected
# by set_edge_params() -- instead of only logging it (a click that silently does nothing
# is indistinguishable from a bug from the operator's side).
self.status_label = QLabel("", parent=self)
self.status_label.setStyleSheet("color: #c0392b;")
root.addWidget(self.status_label)
self.image = Image(parent=self)
root.addWidget(self.image, stretch=1)
@@ -187,6 +194,14 @@ class VisionTuning(BECWidget, QWidget):
self.camera_combo.blockSignals(False)
if device_name:
self.image.image(device=device_name, signal="diagnostic_image")
# The Image widget's subscription (bec_dispatcher.connect_slot, no newest_only) only
# catches publishes that happen *after* the Redis subscription is actually established
# -- a refresh() published immediately after .image() above can race it and be missed,
# leaving the preview blank until some later publish. A short deferred refresh, on top
# of the immediate one _on_camera_changed() triggers below, reliably lands after the
# subscription is live -- cheap and harmless since refresh() just republishes the same
# last frame.
QTimer.singleShot(200, self._on_refresh_clicked)
if interface is not None:
edge_params = interface.get_edge_params()
for spin_box, key in (
@@ -267,8 +282,8 @@ class VisionTuning(BECWidget, QWidget):
interface.set_edge_params(
self.edge_low.value(), self.edge_high.value(), self.edge_blur.value()
)
except Exception:
logger.exception("VisionTuning: set_edge_params failed")
except Exception as exc:
self._set_status(f"set_edge_params failed: {exc}")
return
self._on_refresh_clicked()
@@ -284,8 +299,14 @@ class VisionTuning(BECWidget, QWidget):
return
try:
interface.refresh(self._current_camera_name)
except Exception:
logger.exception("VisionTuning: refresh failed")
except Exception as exc:
self._set_status(f"refresh failed: {exc}")
else:
self._set_status("")
def _set_status(self, text: str):
logger.info(f"VisionTuning: {text}") if text else None
self.status_label.setText(text)
@SafeSlot(bool)
def _on_live_toggled(self, checked: bool):