Skip to content

Commit 4bcacb7

Browse files
committed
Add configurable skeleton styling and UI integration
Introduce a first-class SkeletonStyle and SkeletonColorMode (and BGR alias) in config, and wire them through the GUI and skeleton utilities. Updates include: - dlclivegui/config.py: add BGR type, SkeletonColorMode enum, Pydantic SkeletonStyle model, expose skeleton fields on VisualizationSettings and with_overlays on RecordingSettings, and helper color accessors. - dlclivegui/gui/main_window.py: add _apply_viz_settings_to_ui/_apply_viz_settings_to_skeleton, create a unified _draw_skeleton_on_frame renderer, read/write skeleton style from UI, refactor and simplify skeleton enable/disable and model heuristics, and wire recording.with_overlays. - dlclivegui/gui/misc/color_dropdowns.py: reuse BGR from config. - dlclivegui/utils/skeleton.py: remove duplicate style/type definitions, import style and enums from config, and add module docstring. These changes centralize skeleton styling, enable UI control and persistence of style, and clean up duplicate definitions across modules.
1 parent 2f3ebb1 commit 4bcacb7

File tree

4 files changed

+151
-68
lines changed

4 files changed

+151
-68
lines changed

dlclivegui/config.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@
1212
TileLayout = Literal["auto", "2x2", "1x4", "4x1"]
1313
Precision = Literal["FP32", "FP16"]
1414
ModelType = Literal["pytorch", "tensorflow"]
15+
BGR = tuple[int, int, int] # (B, G, R) color format
16+
17+
18+
class SkeletonColorMode(str, Enum):
19+
SOLID = "solid"
20+
GRADIENT_KEYPOINTS = "gradient_keypoints" # use endpoint keypoint colors
21+
22+
23+
class SkeletonStyle(BaseModel):
24+
mode: SkeletonColorMode = SkeletonColorMode.SOLID
25+
color: BGR = (0, 255, 255) # default if SOLID
26+
thickness: int = 2 # base thickness in pixels
27+
gradient_steps: int = 16 # segments per edge when gradient
28+
scale_with_zoom: bool = True # scale thickness with (sx, sy)
29+
30+
def effective_thickness(self, sx: float, sy: float) -> int:
31+
if not self.scale_with_zoom:
32+
return max(1, int(self.thickness))
33+
return max(1, int(round(self.thickness * min(sx, sy))))
1534

1635

1736
class CameraSettings(BaseModel):
@@ -301,12 +320,22 @@ class VisualizationSettings(BaseModel):
301320
colormap: str = "hot"
302321
bbox_color: tuple[int, int, int] = (0, 0, 255)
303322

323+
show_pose: bool = True
324+
show_skeleton: bool = False
325+
skeleton_style: SkeletonStyle = Field(default_factory=SkeletonStyle)
326+
304327
def get_bbox_color_bgr(self) -> tuple[int, int, int]:
305328
"""Get bounding box color in BGR format"""
306329
if isinstance(self.bbox_color, (list, tuple)) and len(self.bbox_color) == 3:
307330
return tuple(int(c) for c in self.bbox_color)
308331
return (0, 0, 255) # default red
309332

333+
def get_skeleton_color_bgr(self) -> tuple[int, int, int]:
334+
c = self.skeleton_style.color
335+
if isinstance(c, (list, tuple)) and len(c) == 3:
336+
return tuple(int(v) for v in c)
337+
return (0, 255, 255) # default yellow
338+
310339

311340
class RecordingSettings(BaseModel):
312341
enabled: bool = False
@@ -315,6 +344,7 @@ class RecordingSettings(BaseModel):
315344
container: Literal["mp4", "avi", "mov"] = "mp4"
316345
codec: str = "libx264"
317346
crf: int = Field(default=23, ge=0, le=51)
347+
with_overlays: bool = False
318348

319349
def output_path(self) -> Path:
320350
"""Return the absolute output path for recordings."""

dlclivegui/gui/main_window.py

Lines changed: 115 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
VisualizationSettings,
6464
)
6565

66+
from ..config import SkeletonColorMode, SkeletonStyle
6667
from ..processors.processor_utils import (
6768
default_processors_dir,
6869
instantiate_from_scan,
@@ -891,6 +892,45 @@ def _connect_signals(self) -> None:
891892

892893
# ------------------------------------------------------------------
893894
# Config
895+
# ------------------------------------------------------------------
896+
def _apply_viz_settings_to_ui(self, viz: VisualizationSettings) -> None:
897+
"""Set UI state from VisualizationSettings (does not require skeleton to exist)."""
898+
# Pose toggle
899+
self.show_predictions_checkbox.blockSignals(True)
900+
self.show_predictions_checkbox.setChecked(bool(viz.show_pose))
901+
self.show_predictions_checkbox.blockSignals(False)
902+
903+
# Skeleton toggle (may remain disabled until skeleton exists)
904+
self.show_skeleton_checkbox.blockSignals(True)
905+
self.show_skeleton_checkbox.setChecked(bool(viz.show_skeleton))
906+
self.show_skeleton_checkbox.blockSignals(False)
907+
908+
# Skeleton style controls (combo/spin) - set values even if disabled
909+
if hasattr(self, "skeleton_color_combo"):
910+
mode = viz.skeleton_style.mode.value # "solid" or "gradient_keypoints"
911+
color = tuple(viz.skeleton_style.color)
912+
color_ui.set_skeleton_combo_from_style(self.skeleton_color_combo, mode=mode, color=color)
913+
914+
if hasattr(self, "skeleton_thickness_spin"):
915+
self.skeleton_thickness_spin.blockSignals(True)
916+
self.skeleton_thickness_spin.setValue(int(viz.skeleton_style.thickness))
917+
self.skeleton_thickness_spin.blockSignals(False)
918+
919+
def _apply_viz_settings_to_skeleton(self, viz: VisualizationSettings) -> None:
920+
"""Apply VisualizationSettings onto the active runtime Skeleton, if present."""
921+
if self._skeleton is None:
922+
return
923+
924+
# Copy style fields
925+
self._skeleton.style.mode = skel.SkeletonColorMode(viz.skeleton_style.mode.value)
926+
self._skeleton.style.color = tuple(viz.skeleton_style.color)
927+
self._skeleton.style.thickness = int(viz.skeleton_style.thickness)
928+
self._skeleton.style.gradient_steps = int(viz.skeleton_style.gradient_steps)
929+
self._skeleton.style.scale_with_zoom = bool(viz.skeleton_style.scale_with_zoom)
930+
931+
# Enable/disable UI controls now that skeleton exists
932+
self._sync_skeleton_controls_from_model()
933+
894934
def _apply_config(self, config: ApplicationSettings) -> None:
895935
# Update active cameras label
896936
self._update_active_cameras_label()
@@ -907,6 +947,7 @@ def _apply_config(self, config: ApplicationSettings) -> None:
907947
self.output_directory_edit.setText(recording.directory)
908948
self.filename_edit.setText(recording.filename)
909949
self.container_combo.setCurrentText(recording.container)
950+
self.record_with_overlays_checkbox.setChecked(recording.with_overlays)
910951
codec_index = self.codec_combo.findText(recording.codec)
911952
if codec_index >= 0:
912953
self.codec_combo.setCurrentIndex(codec_index)
@@ -937,6 +978,7 @@ def _apply_config(self, config: ApplicationSettings) -> None:
937978
viz = config.visualization
938979
self._p_cutoff = viz.p_cutoff
939980
self._colormap = viz.colormap
981+
self._apply_viz_settings_to_ui(viz)
940982
if hasattr(self, "cmap_combo"):
941983
color_ui.set_cmap_combo_from_name(self.cmap_combo, self._colormap, fallback="viridis")
942984
self._bbox_color = viz.get_bbox_color_bgr()
@@ -945,6 +987,7 @@ def _apply_config(self, config: ApplicationSettings) -> None:
945987
## Skeleton
946988
if resolved_model_path.strip():
947989
self._configure_skeleton_for_model(resolved_model_path)
990+
self._apply_viz_settings_to_skeleton(viz)
948991

949992
# Update DLC camera list
950993
self._refresh_dlc_camera_list()
@@ -1007,6 +1050,7 @@ def _recording_settings_from_ui(self) -> RecordingSettings:
10071050
container=self.container_combo.currentText().strip() or "mp4",
10081051
codec=self.codec_combo.currentText().strip() or "libx264",
10091052
crf=int(self.crf_spin.value()),
1053+
with_overlays=self.record_with_overlays_checkbox.isChecked(),
10101054
)
10111055

10121056
def _bbox_settings_from_ui(self) -> BoundingBoxSettings:
@@ -1019,10 +1063,29 @@ def _bbox_settings_from_ui(self) -> BoundingBoxSettings:
10191063
)
10201064

10211065
def _visualization_settings_from_ui(self) -> VisualizationSettings:
1066+
# Read skeleton mode+color from combo
1067+
mode_str, color = color_ui.get_skeleton_style_from_combo(
1068+
self.skeleton_color_combo,
1069+
fallback_mode="solid",
1070+
fallback_color=(0, 255, 255),
1071+
)
1072+
1073+
# Build SkeletonStyle (pydantic)
1074+
style = SkeletonStyle(
1075+
mode=SkeletonColorMode(mode_str), # or SkeletonColorMode.GRADIENT_KEYPOINTS if mode_str matches
1076+
color=tuple(color) if color else (0, 255, 255),
1077+
thickness=int(self.skeleton_thickness_spin.value()),
1078+
gradient_steps=getattr(self._skeleton.style, "gradient_steps", 16) if self._skeleton else 16,
1079+
scale_with_zoom=getattr(self._skeleton.style, "scale_with_zoom", True) if self._skeleton else True,
1080+
)
1081+
10221082
return VisualizationSettings(
10231083
p_cutoff=self._p_cutoff,
10241084
colormap=self._colormap,
10251085
bbox_color=self._bbox_color,
1086+
show_pose=self.show_predictions_checkbox.isChecked(),
1087+
show_skeleton=self.show_skeleton_checkbox.isChecked(),
1088+
skeleton_style=style,
10261089
)
10271090

10281091
# ------------------------------------------------------------------
@@ -1286,28 +1349,16 @@ def _on_show_skeleton_changed(self, _state: int) -> None:
12861349
self._display_frame(self._current_frame, force=True)
12871350

12881351
def _on_skeleton_style_changed(self, _value: int = 0) -> None:
1289-
"""Apply UI skeleton styling to the current Skeleton instance."""
12901352
if self._skeleton is None:
12911353
return
12921354

1293-
mode, color = color_ui.get_skeleton_style_from_combo(
1294-
self.skeleton_color_combo,
1295-
fallback_mode="solid",
1296-
fallback_color=self._skeleton.style.color,
1297-
)
1298-
1299-
# Update style mode
1300-
if mode == "gradient_keypoints":
1301-
self._skeleton.style.mode = skel.SkeletonColorMode.GRADIENT_KEYPOINTS
1302-
else:
1303-
self._skeleton.style.mode = skel.SkeletonColorMode.SOLID
1304-
if color is not None:
1305-
self._skeleton.style.color = tuple(color)
1355+
mode_str, color = color_ui.get_skeleton_style_from_combo(self.skeleton_color_combo)
1356+
self._skeleton.style.mode = skel.SkeletonColorMode(mode_str)
1357+
if self._skeleton.style.mode == skel.SkeletonColorMode.SOLID and color is not None:
1358+
self._skeleton.style.color = tuple(color)
13061359

1307-
# Thickness
13081360
self._skeleton.style.thickness = int(self.skeleton_thickness_spin.value())
13091361

1310-
# Redraw
13111362
if self._current_frame is not None:
13121363
self._display_frame(self._current_frame, force=True)
13131364

@@ -1469,17 +1520,12 @@ def _render_overlays_for_recording(self, cam_id, frame):
14691520
offset=offset,
14701521
scale=scale,
14711522
)
1472-
1473-
if self._skeleton and hasattr(self, "show_skeleton_checkbox") and self.show_skeleton_checkbox.isChecked():
1474-
pose_arr = np.asarray(self._last_pose.pose)
1475-
if pose_arr.ndim == 3:
1476-
st = self._skeleton.draw_many(output, pose_arr, self._p_cutoff, offset, scale)
1477-
else:
1478-
self._skeleton.draw(output, self._last_pose.pose, self._p_cutoff, offset, scale)
1479-
if st.should_disable:
1480-
self.show_skeleton_checkbox.blockSignals(True)
1481-
self.show_skeleton_checkbox.setChecked(False)
1482-
self.show_skeleton_checkbox.blockSignals(False)
1523+
self._draw_skeleton_on_frame(
1524+
output,
1525+
self._last_pose.pose,
1526+
offset=offset,
1527+
scale=scale,
1528+
)
14831529

14841530
if self._bbox_enabled:
14851531
output = draw_bbox(
@@ -1795,7 +1841,7 @@ def _configure_skeleton_for_model(self, model_path: str) -> None:
17951841

17961842
root = p if p.is_dir() else p.parent
17971843
cfg = root / "config.yaml"
1798-
if cfg.exists():
1844+
if cfg.exists() and self._skeleton is None:
17991845
try:
18001846
sk = skel.load_dlc_skeleton(cfg)
18011847
except Exception as e:
@@ -1808,7 +1854,15 @@ def _configure_skeleton_for_model(self, model_path: str) -> None:
18081854
if hasattr(self, "show_skeleton_checkbox"):
18091855
self.show_skeleton_checkbox.setEnabled(True)
18101856
self.statusBar().showMessage("Skeleton available: DLC config.yaml", 3000)
1811-
return
1857+
1858+
if self._skeleton is not None:
1859+
try:
1860+
viz = self._config.visualization
1861+
self._apply_viz_settings_to_skeleton(viz)
1862+
except Exception as e:
1863+
logger.warning(f"Failed to apply visualization settings to skeleton: {e}")
1864+
pass
1865+
return
18121866

18131867
# None found
18141868
self.statusBar().showMessage("No skeleton definition available for this model.", 3000)
@@ -2126,51 +2180,59 @@ def _on_dlc_error(self, message: str) -> None:
21262180
self._stop_inference(show_message=False)
21272181
self._show_error(message)
21282182

2129-
def _try_draw_skeleton(self, overlay: np.ndarray, pose: np.ndarray) -> None:
2183+
def _draw_skeleton_on_frame(
2184+
self,
2185+
overlay: np.ndarray,
2186+
pose: np.ndarray,
2187+
*,
2188+
offset: tuple[int, int],
2189+
scale: tuple[float, float],
2190+
allow_auto_disable: bool = True,
2191+
) -> skel.SkeletonRenderStatus | None:
2192+
"""Draw skeleton on overlay with correct style. Optionally auto-disables UI on mismatch."""
21302193
if self._skeleton is None:
2131-
return
2194+
return None
21322195
if not self.show_skeleton_checkbox.isChecked():
2133-
return
2196+
return None
21342197
if self._skeleton_auto_disabled:
2135-
return
2198+
return None
21362199

21372200
pose_arr = np.asarray(pose)
21382201

2139-
# Compute keypoint colors only if gradient mode is active
2202+
# Provide keypoint_colors iff gradient mode is active
21402203
kp_colors = None
2141-
try:
2142-
if self._skeleton.style.mode == skel.SkeletonColorMode.GRADIENT_KEYPOINTS:
2143-
n_kpts = pose_arr.shape[1] if pose_arr.ndim == 3 else pose_arr.shape[0]
2144-
kp_colors = keypoint_colors_bgr(self._colormap, int(n_kpts))
2204+
if self._skeleton.style.mode == skel.SkeletonColorMode.GRADIENT_KEYPOINTS:
2205+
n_kpts = pose_arr.shape[1] if pose_arr.ndim == 3 else pose_arr.shape[0]
2206+
kp_colors = keypoint_colors_bgr(self._colormap, int(n_kpts))
21452207

2208+
try:
21462209
if pose_arr.ndim == 3:
21472210
status = self._skeleton.draw_many(
21482211
overlay,
21492212
pose_arr,
21502213
p_cutoff=self._p_cutoff,
2151-
offset=self._dlc_tile_offset,
2152-
scale=self._dlc_tile_scale,
2214+
offset=offset,
2215+
scale=scale,
21532216
keypoint_colors=kp_colors,
21542217
)
21552218
else:
21562219
status = self._skeleton.draw(
21572220
overlay,
21582221
pose_arr,
21592222
p_cutoff=self._p_cutoff,
2160-
offset=self._dlc_tile_offset,
2161-
scale=self._dlc_tile_scale,
2223+
offset=offset,
2224+
scale=scale,
21622225
keypoint_colors=kp_colors,
21632226
)
2164-
21652227
except Exception as e:
21662228
status = skel.SkeletonRenderStatus(
21672229
code=skel.SkeletonRenderCode.POSE_SHAPE_INVALID,
21682230
message=f"Skeleton rendering error: {e}",
21692231
)
21702232

2171-
if status.should_disable:
2233+
if allow_auto_disable and status.should_disable:
21722234
self._skeleton_auto_disabled = True
2173-
msg = status.message or "Skeleton disabled due to keypoint mismatch."
2235+
msg = status.message or "Skeleton disabled due to mismatch."
21742236
if msg != self._last_skeleton_disable_msg:
21752237
self._last_skeleton_disable_msg = msg
21762238
self.statusBar().showMessage(f"Skeleton disabled: {msg}", 6000)
@@ -2179,6 +2241,8 @@ def _try_draw_skeleton(self, overlay: np.ndarray, pose: np.ndarray) -> None:
21792241
self.show_skeleton_checkbox.setChecked(False)
21802242
self.show_skeleton_checkbox.blockSignals(False)
21812243

2244+
return status
2245+
21822246
def _update_video_display(self, frame: np.ndarray) -> None:
21832247
display_frame = frame
21842248

@@ -2192,7 +2256,12 @@ def _update_video_display(self, frame: np.ndarray) -> None:
21922256
scale=self._dlc_tile_scale,
21932257
)
21942258

2195-
self._try_draw_skeleton(display_frame, self._last_pose.pose)
2259+
self._draw_skeleton_on_frame(
2260+
display_frame,
2261+
self._last_pose.pose,
2262+
offset=self._dlc_tile_offset,
2263+
scale=self._dlc_tile_scale,
2264+
)
21962265

21972266
if self._bbox_enabled:
21982267
display_frame = draw_bbox(

dlclivegui/gui/misc/color_dropdowns.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
QStyleOptionComboBox,
2525
)
2626

27-
BGR = tuple[int, int, int]
27+
from dlclivegui.config import BGR
28+
2829
TEnum = TypeVar("TEnum")
2930

3031

0 commit comments

Comments
 (0)