Skip to content

Commit 808e880

Browse files
committed
fix(config_io): don't clobber template fields with empty UI values
When exporting after loading a config but before loading mocap data, the UI sends empty kpNames (and similar empty fields) that overwrote the template's populated KP_NAMES list. Now empty UI values are skipped if the template has a populated value at that key. Repro: load data/stac_rodent_acm.yaml, export without loading .mat — before: KP_NAMES: [] after: KP_NAMES preserved from template (21 entries)
1 parent f90f5df commit 808e880

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

backend/config_io.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,21 +93,36 @@ def load_stac_yaml(path: str) -> dict:
9393
}
9494

9595

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+
96105
def _overlay_onto_template(template: dict, ui_fields: dict) -> dict:
97106
"""Overlay UI-managed fields onto a template, preserving its shape.
98107
99108
- Flat template → overlay at top level, preserving key order (UI fields
100109
replace existing keys in place; new keys appended).
101110
- Wrapped template → overlay under raw["model"].
102111
- 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.
103115
"""
104116
out = copy.deepcopy(template)
105117
out.pop("skeleton_editor", None)
106118

107119
target = out if _is_flat(out) else out.setdefault("model", {})
108120

109121
for field in _UI_MANAGED_FIELDS:
110-
target[field] = ui_fields[field]
122+
value = ui_fields[field]
123+
if _is_empty(value) and not _is_empty(target.get(field)):
124+
continue
125+
target[field] = value
111126
return out
112127

113128

tests/test_config_io.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,63 @@ def test_dump_without_template_emits_wrapped():
210210
assert out["model"]["KEYPOINT_MODEL_PAIRS"]["Snout"] == "skull"
211211

212212

213+
def test_dump_empty_ui_field_does_not_clobber_template(tmp_path):
214+
"""Exporting without loaded mocap must not wipe the template's KP_NAMES.
215+
216+
Reproduces a bug where loading a config without any keypoint data loaded
217+
yielded KP_NAMES=[] on export, overwriting the template's populated list.
218+
"""
219+
src = textwrap.dedent(
220+
"""
221+
MJCF_PATH: "models/rodent.xml"
222+
N_ITERS: 6
223+
KEYPOINT_MODEL_PAIRS:
224+
Snout: skull
225+
SpineF: vertebra_cervical_5
226+
KEYPOINT_INITIAL_OFFSETS:
227+
Snout: 0. 0. 0.
228+
SpineF: 0. 0. 0.
229+
KP_NAMES:
230+
- Snout
231+
- SpineF
232+
- SpineM
233+
SCALE_FACTOR: 0.9
234+
MOCAP_SCALE_FACTOR: 0.001
235+
"""
236+
)
237+
path = tmp_path / "with_kp_names.yaml"
238+
path.write_text(src)
239+
loaded = load_stac_yaml(str(path))
240+
241+
# Simulate the UI state right after loading config but before loading any
242+
# mocap data: kpNames/keypointModelPairs still present from the template,
243+
# but if the user cleared them (or they were never populated in state),
244+
# we must not clobber what the template already has.
245+
loaded["kpNames"] = [] # UI hasn't loaded mocap → empty
246+
out = yaml.safe_load(dump_stac_yaml(loaded))
247+
assert out["KP_NAMES"] == ["Snout", "SpineF", "SpineM"]
248+
249+
250+
def test_dump_empty_field_when_template_also_empty():
251+
"""If the template has no value either, emit whatever the UI has (incl. empty)."""
252+
config = {
253+
"keypointModelPairs": {"Snout": "skull"},
254+
"keypointInitialOffsets": {},
255+
"kpNames": [],
256+
"scaleFactor": 0.9,
257+
"mocapScaleFactor": 0.01,
258+
"xmlPath": "models/rodent.xml",
259+
"_rawTemplate": {
260+
"MJCF_PATH": "models/rodent.xml",
261+
"N_ITERS": 6,
262+
},
263+
}
264+
out = yaml.safe_load(dump_stac_yaml(config))
265+
# UI field overrides are applied when template has nothing to preserve.
266+
assert out["KEYPOINT_MODEL_PAIRS"] == {"Snout": "skull"}
267+
assert out["N_ITERS"] == 6
268+
269+
213270
def test_dump_ui_sidecar_none_when_default():
214271
"""Sidecar returns None when there's no UI-only state worth saving."""
215272
assert dump_stac_ui_sidecar({"segmentScales": {}}) is None

0 commit comments

Comments
 (0)