feat(conflicts): rely on original camera names to sort out conflicts between two cameras

This commit is contained in:
CarolinePascal
2026-07-30 18:49:11 +02:00
parent a4179dd6a1
commit fae2848dd7
3 changed files with 95 additions and 7 deletions
+4
View File
@@ -284,6 +284,10 @@ Notes:
for image datasets. for image datasets.
- Views judged unusable are only flagged by default (still renamed). Pass - Views judged unusable are only flagged by default (still renamed). Pass
`--drop_unusable=true` (local path) to remove them. `--drop_unusable=true` (local path) to remove them.
- When two cameras get the same label (e.g. two `wrist` views), the tool first
tries to disambiguate from each camera's **original key** — a `..._left`
source becomes `left_wrist`, `..._right` becomes `right_wrist`. Only labels
still colliding after that fall back to `--on_collision` (`error` or `suffix`).
Key options: `--mode`, `--branch`, `--n_frames`, `--view_vocabulary`, Key options: `--mode`, `--branch`, `--n_frames`, `--view_vocabulary`,
`--allow_combos`, `--on_collision`, `--drop_unusable`, and the shared `--allow_combos`, `--on_collision`, `--drop_unusable`, and the shared
@@ -25,6 +25,8 @@ those frames from the dataset's first episode.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from collections import Counter
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -161,16 +163,21 @@ def build_name_mapping(
"""Compute ``{old_key: observation.images.<label>}`` for labeled cameras. """Compute ``{old_key: observation.images.<label>}`` for labeled cameras.
Cameras without a valid label (or already at their canonical name) are Cameras without a valid label (or already at their canonical name) are
skipped. Collisions are resolved with ``cfg.on_collision`` and the resolved skipped. When several cameras share a label (e.g. two ``wrist`` views), we
first try to disambiguate each from a distinguishing vocabulary word found in
its *original* key (``..._left`` + ``wrist`` → ``left_wrist``); only labels
still colliding after that fall through to ``cfg.on_collision``. The resolved
target is written back onto each verdict's ``proposed_new_key``. target is written back onto each verdict's ``proposed_new_key``.
""" """
label_by_cam = {v.camera_key: v.view_label for v in verdicts if v.view_label is not None}
if cfg.allow_combos:
label_by_cam = _disambiguate_from_source_names(label_by_cam, cfg.view_vocabulary)
desired: dict[str, str] = {} desired: dict[str, str] = {}
for v in verdicts: for cam, label in label_by_cam.items():
if v.view_label is None: target = f"{OBS_IMAGE_PREFIX}{label}"
continue if target != cam:
target = f"{OBS_IMAGE_PREFIX}{v.view_label}" desired[cam] = target
if target != v.camera_key:
desired[v.camera_key] = target
if not desired: if not desired:
return {} return {}
@@ -182,6 +189,57 @@ def build_name_mapping(
return resolved return resolved
def _extract_vocab_tokens(camera_key: str, vocabulary: tuple[str, ...]) -> list[str]:
"""Vocabulary words present in a camera key, in vocabulary order.
Splits on non-alphanumeric boundaries so ``observation.images.cam_left`` →
``["left"]`` and ``left_wrist_0_rgb`` → ``["wrist", "left"]``.
"""
parts = set(re.split(r"[^a-z0-9]+", camera_key.lower()))
return [tok for tok in vocabulary if tok in parts]
def _order_combo(tokens: list[str], vocabulary: tuple[str, ...]) -> str:
"""Join vocab tokens into a combo label, ``wrist`` last, else vocab order.
Keeps the mount word (``wrist``) as the suffix so directional words read
first — ``{wrist, left}`` → ``left_wrist``.
"""
uniq = list(dict.fromkeys(tokens))
def sort_key(tok: str) -> tuple[bool, int]:
return (tok == "wrist", vocabulary.index(tok) if tok in vocabulary else len(vocabulary))
return "_".join(sorted(uniq, key=sort_key))
def _disambiguate_from_source_names(
label_by_cam: dict[str, str], vocabulary: tuple[str, ...]
) -> dict[str, str]:
"""When several cameras share a label, enrich each from its source-key words.
Only labels shared by 2+ cameras are touched; a distinguishing vocab word is
pulled from the camera's original key and combined with the label (kept only
if the result is a valid ≤2-word combo). Anything still colliding afterwards
is left for the caller's collision policy.
"""
counts = Counter(label_by_cam.values())
out = dict(label_by_cam)
for cam, label in label_by_cam.items():
if counts[label] < 2:
continue # unique label — leave the VLM's clean single word alone
label_tokens = label.split("_")
extra = next(
(tok for tok in _extract_vocab_tokens(cam, vocabulary) if tok not in label_tokens), None
)
if extra is None:
continue
combined = _order_combo([*label_tokens, extra], vocabulary)
if is_valid_view_label(combined, vocabulary, allow_combos=True):
out[cam] = combined
return out
def build_report( def build_report(
verdicts: list[CameraVerdict], verdicts: list[CameraVerdict],
mapping: dict[str, str], mapping: dict[str, str],
+26
View File
@@ -150,6 +150,32 @@ def test_build_name_mapping_and_collision():
build_name_mapping(clash, {"observation.images.cam_0": {}, "observation.images.cam_1": {}}, cfg) build_name_mapping(clash, {"observation.images.cam_0": {}, "observation.images.cam_1": {}}, cfg)
def test_build_name_mapping_disambiguates_two_wrists_from_source_names():
cfg = CameraCurationConfig(view_vocabulary=VOCAB, allow_combos=True)
existing = {"observation.images.cam_left": {}, "observation.images.cam_right": {}}
verdicts = [
CameraVerdict("observation.images.cam_left", usable=True, view_label="wrist"),
CameraVerdict("observation.images.cam_right", usable=True, view_label="wrist"),
]
mapping = build_name_mapping(verdicts, existing, cfg)
assert mapping == {
"observation.images.cam_left": "observation.images.left_wrist",
"observation.images.cam_right": "observation.images.right_wrist",
}
def test_build_name_mapping_conflict_without_hint_still_errors():
# Source keys carry no directional vocab word -> cannot disambiguate -> error.
cfg = CameraCurationConfig(view_vocabulary=VOCAB, allow_combos=True)
existing = {"observation.images.cam_0": {}, "observation.images.cam_1": {}}
verdicts = [
CameraVerdict("observation.images.cam_0", usable=True, view_label="wrist"),
CameraVerdict("observation.images.cam_1", usable=True, view_label="wrist"),
]
with pytest.raises(ValueError, match="collision"):
build_name_mapping(verdicts, existing, cfg)
def test_write_report(tmp_path): def test_write_report(tmp_path):
_make_min_meta(tmp_path, "observation.images.cam_0", dtype="video") _make_min_meta(tmp_path, "observation.images.cam_0", dtype="video")
cfg = CameraCurationConfig(repo_id="user/ds", view_vocabulary=VOCAB) cfg = CameraCurationConfig(repo_id="user/ds", view_vocabulary=VOCAB)