11"""STAC YAML config and H5 import/export."""
22from __future__ import annotations
3+ import copy
34from pathlib import Path
45import yaml
56import numpy as np
1011# (e.g. configs/model/rodent.yaml). Used to detect flat vs. wrapped shapes.
1112_MODEL_FIELD_MARKERS = ("KEYPOINT_MODEL_PAIRS" , "KP_NAMES" , "MJCF_PATH" )
1213
14+ # Fields that the UI owns and will overwrite on export.
15+ _UI_MANAGED_FIELDS = (
16+ "MJCF_PATH" ,
17+ "SCALE_FACTOR" ,
18+ "MOCAP_SCALE_FACTOR" ,
19+ "KP_NAMES" ,
20+ "KEYPOINT_MODEL_PAIRS" ,
21+ "KEYPOINT_INITIAL_OFFSETS" ,
22+ )
23+
24+
25+ def _is_flat (raw : dict ) -> bool :
26+ """True if `raw` looks like a flat stac-mjx model config."""
27+ return any (k in raw for k in _MODEL_FIELD_MARKERS )
28+
1329
1430def _extract_model_section (raw : dict ) -> dict :
1531 """Return the dict containing model-level fields from a loaded YAML.
@@ -20,13 +36,40 @@ def _extract_model_section(raw: dict) -> dict:
2036 the file into the `model` namespace during composition.
2137 - Wrapped: the UI's own export, where everything is nested under `model:`.
2238 """
23- if any ( k in raw for k in _MODEL_FIELD_MARKERS ):
39+ if _is_flat ( raw ):
2440 return raw
2541 return raw .get ("model" , {})
2642
2743
44+ def _offsets_to_yaml (offsets : dict ) -> dict :
45+ """Convert [x, y, z] offsets to space-separated strings (stac-mjx format)."""
46+ return {kp : f"{ v [0 ]} { v [1 ]} { v [2 ]} " for kp , v in offsets .items ()}
47+
48+
49+ def _ui_managed_fields (config : dict ) -> dict :
50+ """Build the model-level dict of fields the UI owns, in canonical order."""
51+ return {
52+ "MJCF_PATH" : config .get ("xmlPath" , "" ),
53+ "SCALE_FACTOR" : config .get ("scaleFactor" , 0.9 ),
54+ "MOCAP_SCALE_FACTOR" : config .get ("mocapScaleFactor" , 0.01 ),
55+ "KP_NAMES" : config .get (
56+ "kpNames" , list (config .get ("keypointModelPairs" , {}).keys ())
57+ ),
58+ "KEYPOINT_MODEL_PAIRS" : config .get ("keypointModelPairs" , {}),
59+ "KEYPOINT_INITIAL_OFFSETS" : _offsets_to_yaml (
60+ config .get ("keypointInitialOffsets" , {})
61+ ),
62+ }
63+
64+
2865def load_stac_yaml (path : str ) -> dict :
29- """Load STAC config YAML and return normalized dict for the UI."""
66+ """Load STAC config YAML and return normalized dict for the UI.
67+
68+ Returns:
69+ Dict with UI-normalized fields (keypointModelPairs, keypointInitialOffsets,
70+ scaleFactor, mocapScaleFactor, kpNames, xmlPath) plus `_rawTemplate`:
71+ the full parsed YAML, for template-overlay export.
72+ """
3073 with open (path ) as f :
3174 raw = yaml .safe_load (f ) or {}
3275 model = _extract_model_section (raw )
@@ -46,33 +89,82 @@ def load_stac_yaml(path: str) -> dict:
4689 "mocapScaleFactor" : float (model .get ("MOCAP_SCALE_FACTOR" , 0.01 )),
4790 "kpNames" : list (model .get ("KP_NAMES" , [])),
4891 "xmlPath" : model .get ("MJCF_PATH" , "" ),
92+ "_rawTemplate" : raw ,
4993 }
5094
5195
96+ def _is_empty (v ) -> bool :
97+ """Treat None and empty containers/strings as 'no UI data to contribute'."""
98+ if v is None :
99+ return True
100+ if isinstance (v , (list , dict , str )):
101+ return len (v ) == 0
102+ return False
103+
104+
105+ def _overlay_onto_template (template : dict , ui_fields : dict ) -> dict :
106+ """Overlay UI-managed fields onto a template, preserving its shape.
107+
108+ - Flat template → overlay at top level, preserving key order (UI fields
109+ replace existing keys in place; new keys appended).
110+ - Wrapped template → overlay under raw["model"].
111+ - UI-only sections like `skeleton_editor` are stripped.
112+ - Empty UI values (e.g. KP_NAMES=[] when no keypoints were loaded) do not
113+ clobber a populated template field — otherwise exporting without
114+ loading mocap would wipe the template's keypoint list.
115+ """
116+ out = copy .deepcopy (template )
117+ out .pop ("skeleton_editor" , None )
118+
119+ target = out if _is_flat (out ) else out .setdefault ("model" , {})
120+
121+ for field in _UI_MANAGED_FIELDS :
122+ value = ui_fields [field ]
123+ if _is_empty (value ) and not _is_empty (target .get (field )):
124+ continue
125+ target [field ] = value
126+ return out
127+
128+
52129def dump_stac_yaml (config : dict ) -> str :
53- """Serialize UI state to STAC-compatible YAML and return it as a string."""
54- offsets_str = {}
55- for kp , vals in config .get ("keypointInitialOffsets" , {}).items ():
56- offsets_str [kp ] = f"{ vals [0 ]} { vals [1 ]} { vals [2 ]} "
57- yaml_dict = {
58- "model" : {
59- "MJCF_PATH" : config .get ("xmlPath" , "" ),
60- "SCALE_FACTOR" : config .get ("scaleFactor" , 0.9 ),
61- "MOCAP_SCALE_FACTOR" : config .get ("mocapScaleFactor" , 0.01 ),
62- "KP_NAMES" : config .get ("kpNames" , list (config .get ("keypointModelPairs" , {}).keys ())),
63- "KEYPOINT_MODEL_PAIRS" : config .get ("keypointModelPairs" , {}),
64- "KEYPOINT_INITIAL_OFFSETS" : offsets_str ,
65- },
66- }
67- # Include segment scales if any are non-default
68- segment_scales = config .get ("segmentScales" , {})
69- if segment_scales :
70- non_default = {k : v for k , v in segment_scales .items () if abs (v - 1.0 ) > 0.001 }
71- if non_default :
72- yaml_dict ["skeleton_editor" ] = {"segment_scales" : non_default }
130+ """Serialize UI state to STAC-compatible YAML and return it as a string.
131+
132+ If `config` carries `_rawTemplate` (from a prior `load_stac_yaml`), overlay
133+ the UI's edits onto it so fields the UI doesn't manage (N_ITERS,
134+ ROOT_OPTIMIZATION_KEYPOINT, SITES_TO_REGULARIZE, ...) are preserved.
135+
136+ Without a template, emit a UI-wrapped shape (nested under `model:`). That
137+ shape is the UI's internal round-trip format and is NOT a drop-in
138+ stac-mjx config — use template-overlay for that.
139+ """
140+ ui_fields = _ui_managed_fields (config )
141+ template = config .get ("_rawTemplate" )
142+ if template :
143+ yaml_dict = _overlay_onto_template (template , ui_fields )
144+ else :
145+ yaml_dict = {"model" : dict (ui_fields )}
73146 return yaml .dump (yaml_dict , default_flow_style = False , sort_keys = False )
74147
75148
149+ def dump_stac_ui_sidecar (config : dict ) -> str | None :
150+ """Serialize UI-only state (skeleton editor) to its own YAML.
151+
152+ Returns None when there's nothing to save — the caller should skip the
153+ sidecar download in that case rather than emitting an empty file.
154+ """
155+ segment_scales = config .get ("segmentScales" , {})
156+ non_default = {
157+ k : v for k , v in segment_scales .items () if abs (v - 1.0 ) > 0.001
158+ }
159+ if not non_default :
160+ return None
161+ return yaml .dump (
162+ {"skeleton_editor" : {"segment_scales" : non_default }},
163+ default_flow_style = False ,
164+ sort_keys = False ,
165+ )
166+
167+
76168def export_stac_yaml (config : dict , output_path : str ) -> None :
77169 """Export UI state to a STAC-compatible YAML file on disk."""
78170 with open (output_path , "w" ) as f :
0 commit comments