Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/glider/core/live_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class LiveSignalBus:
def __init__(self) -> None:
self._lock = threading.RLock()
self._behavior_subs: list[Callable[[BehaviorEvent], None]] = []
self._behaviors: list[str] = []

# -- behavior ---------------------------------------------------------
def subscribe_behavior(self, callback: Callable[[BehaviorEvent], None]) -> None:
Expand All @@ -79,6 +80,23 @@ def unsubscribe_behavior(self, callback: Callable[[BehaviorEvent], None]) -> Non
"live bus: behavior subscriber removed (%d left)", len(self._behavior_subs)
)

@property
def behaviors(self) -> list[str]:
"""The loaded model's ordered class vocabulary, or empty if none.

The bus is already the boundary between vision and the flow, and the
label vocabulary is the one piece of vision metadata the flow *editor*
needs: a Behavior Input node has to offer the behaviors the model can
actually emit. Routing it here keeps the properties panel from reaching
into the camera panel to find a worker thread's attribute.
"""
return list(self._behaviors)

def set_behaviors(self, names: list[str] | None) -> None:
"""Publish the vocabulary of whatever model is now loaded."""
self._behaviors = [str(n) for n in (names or [])]
logger.debug("LiveSignalBus: behavior vocabulary set to %s", self._behaviors)

def publish_behavior(self, event: BehaviorEvent) -> int:
"""Deliver *event* to every subscriber. Returns how many were called.

Expand Down
5 changes: 5 additions & 0 deletions src/glider/gui/panels/camera_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,11 @@ def _on_behavior_ready(self) -> None:
if worker is None:
return
self._preview.set_behavior_vocab(worker.classes)
# Also hand the vocabulary to the flow side, so a Behavior Input node's
# properties can offer the behaviors this model actually emits instead
# of a free-text box where a typo means "never fires".
if self._live_signals is not None:
self._live_signals.set_behaviors(worker.classes)
self._behavior_running = True
self._live_behavior_btn.setText("Stop")
self._live_behavior_btn.setEnabled(True)
Expand Down
65 changes: 64 additions & 1 deletion src/glider/gui/panels/node_editor_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def node_category_for_type(node_type: str) -> str:
"StartFunction",
"EndFunction",
},
"interface": {"Loop", "WaitForInput", "ZoneInput"},
"interface": {"Loop", "WaitForInput", "ZoneInput", "BehaviorInput"},
"hardware": {"Output", "Input", "MotorGovernor", "Maimu"},
}

Expand Down Expand Up @@ -161,6 +161,8 @@ def setup_node_ports(self, node_item, node_type: str) -> None:
"EndFunction": ([">exec"], []),
"FunctionCall": ([">exec"], [">next"]),
"ZoneInput": ([], ["Occupied", "Object Count", ">On Enter", ">On Exit"]),
"BehaviorInput": ([], ["Active", "Behavior", ">On Enter", ">On Exit"]),
"Maimu": ([">exec"], [">exec"]),
}

inputs, outputs = port_configs.get(nt, ([">in"], [">out"]))
Expand Down Expand Up @@ -585,6 +587,53 @@ def _sync_pulse_fields(_=None, combo=mode_combo):
note.setWordWrap(True)
props_layout.addRow(note)

elif node_type == "BehaviorInput":
self._add_section_header(props_layout, "BEHAVIOR")
saved_state = (node_config.state if node_config else None) or {}

# Editable so a graph can be authored before any model is loaded,
# but populated from the loaded model's vocabulary when there is
# one -- a mistyped label silently never fires, which is the worst
# failure mode available to a closed-loop stimulus.
behavior_combo = QComboBox()
behavior_combo.setEditable(True)
known = self._known_behaviors()
if known:
behavior_combo.addItems(known)
behavior_combo.lineEdit().setPlaceholderText(
"behavior label" if known else "start live behavior to list the model's labels"
)
saved_behavior = saved_state.get("target_behavior", "")
behavior_combo.setCurrentText(saved_behavior)
behavior_combo.currentTextChanged.connect(
lambda txt, nid=node_id: self._on_node_property_changed(
nid, "target_behavior", txt.strip()
)
)
props_layout.addRow("Behavior:", behavior_combo)

frames_spin = QSpinBox()
frames_spin.setRange(1, 300)
frames_spin.setSuffix(" frames")
frames_spin.setValue(int(saved_state.get("min_frames", 5)))
frames_spin.setToolTip(
"Consecutive frames carrying the behavior before it counts as "
"entered, and the same number without it before it counts as left."
)
frames_spin.valueChanged.connect(
lambda val, nid=node_id: self._on_node_property_changed(nid, "min_frames", val)
)
props_layout.addRow("Confirm over:", frames_spin)

note = QLabel(
"Per-frame classification is noisy, so a single stray frame must "
"not fire hardware. The confirmation delays the trigger: at 30 fps, "
"5 frames is about 167 ms on top of inference."
)
note.setProperty("textRole", "muted")
note.setWordWrap(True)
props_layout.addRow(note)

elif node_type == "Delay":
self._add_section_header(props_layout, "CONFIGURATION")

Expand Down Expand Up @@ -1313,6 +1362,20 @@ def _on_node_device_changed(self, node_id: str, device_id: str) -> None:

self._update_properties_panel(node_id)

def _known_behaviors(self) -> list[str]:
"""Labels the currently loaded behavior model can emit, if any.

Empty before a model is loaded, which is a normal authoring state and
not an error: the combo stays editable so the graph can still be built.
"""
core = getattr(self, "_core", None)
bus = getattr(core, "live_signals", None) if core is not None else None
try:
return list(bus.behaviors) if bus is not None else []
except Exception: # noqa: BLE001 - a missing vocabulary must not break the panel
logger.debug("Could not read the behavior vocabulary", exc_info=True)
return []

def _on_node_property_changed(self, node_id: str, prop_name: str, value) -> None:
"""Handle property change for a node."""
session = self._session
Expand Down
9 changes: 9 additions & 0 deletions src/glider/gui/panels/node_library_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ def _setup_ui(self):
("Input", "Input", "Read from a device (digital or analog)"),
("Maimu", "Maimu", "Drive a Maimu BLE stimulator: on, off, or a timed pulse"),
],
"Behavior": [
(
"BehaviorInput",
"Behavior Input",
"Trigger on live behavior classification - fires On Enter / On "
"Exit as the animal starts and stops a behavior",
),
],
"Audio": [
("AudioPlayback", "Audio Playback", "Play an audio file (WAV/MP3)"),
],
Expand All @@ -186,6 +194,7 @@ def _setup_ui(self):
"Functions": colors.LIB_FUNCTIONS,
"Control": colors.LIB_CONTROL,
"I/O": colors.LIB_IO,
"Behavior": colors.LIB_BEHAVIOR,
"Audio": colors.LIB_AUDIO,
"Video": colors.LIB_VIDEO,
"default": colors.BORDER,
Expand Down
1 change: 1 addition & 0 deletions src/glider/gui/styles/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def qcolor_with_alpha(color: QColor, alpha: float) -> QColor:
LIB_AUDIO = "#3d1a5f"
LIB_VIDEO = "#1e3a5f"
LIB_ZONES = "#4a3a1a"
LIB_BEHAVIOR = "#5f1a3a"

# === Behavior State Colors (CV-specific, not theme colors) ===
BEHAVIOR_FREEZE = "#0000FF"
Expand Down
Loading
Loading