mirror of
https://github.com/Friedrich-M/UniMate.git
synced 2026-10-21 05:59:13 +08:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LjgwfhefMhyk7qWfFTek8y
263 lines
13 KiB
Python
263 lines
13 KiB
Python
"""Stage 3 for one asset: cleaned joint names and the facing pair.
|
|
|
|
Three sources, each producing the two stage-3 entries stage 4 reads
|
|
(``clean_joint_names.json``, ``face_joint_names.json``):
|
|
|
|
- ``dataset``: the dataset's own (reviewed and patched) entries for the asset,
|
|
matched by raw joint name (a joint the dataset's skeleton lacks gets the rule
|
|
label); reproduces a training asset exactly.
|
|
- ``rule``: the rule-based cleaner, its rig-level refinements
|
|
(``joint_annotation/structure_rule.py``; geometric labels of
|
|
``skeleton_parse`` for a mostly unnamed rig) and the face resolver, then the
|
|
automatic fixes ``tools/patch_annotations.py`` applies to every rig (root
|
|
label unification, side-from-x on object datasets, face-label resync), then
|
|
the reviewed-label lookup (``joint_annotation/name_map.py``: a known rig
|
|
tree's labels, else confident per-name labels where the rules left a
|
|
placeholder). Offline.
|
|
- ``llm``: the LLM cleaner and face selector (``joint_annotation/llm.py``
|
|
backends), then the same automatic fixes; a failed or timed-out call, or
|
|
an LLM pair without horizontal separation, keeps the ``rule`` result.
|
|
|
|
Outside ``dataset`` mode the joint names and the face pair can come from
|
|
different sources (``names_mode`` / ``face_mode``), e.g. rule names with an
|
|
LLM face pair.
|
|
|
|
A face pair given by hand (``face_r`` / ``face_l`` raw names) replaces the
|
|
selected one in any mode. A selected pair without horizontal separation in the
|
|
rest pose (coincident or vertical joints, which define no facing) is dropped
|
|
for the identity facing; a hand-given one is an error.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from collections import Counter, OrderedDict
|
|
|
|
from loguru import logger
|
|
|
|
import numpy as np
|
|
|
|
from data_process.joint_annotation.face_select_rule import empty_entry, resolve_face_joints
|
|
from data_process.joint_annotation.name_map import apply_name_map, load_name_map
|
|
from data_process.joint_annotation.names_clean_rule import (
|
|
clean_joint_name,
|
|
limb_context,
|
|
post_process,
|
|
)
|
|
from data_process.joint_annotation.structure_rule import fill_unnamed, rig_context
|
|
from data_process.tools import patch_annotations
|
|
from data_process.utils.kinematics import rest_global
|
|
from data_process.utils.skeleton import FACING_EPS
|
|
|
|
MODES = ('rule', 'llm', 'dataset')
|
|
|
|
|
|
def _patch_labels(ds, root, clean, names, log, generic_only):
|
|
stats = Counter()
|
|
saved = patch_annotations.CHAIN_RIGS, patch_annotations.X_SIDE_RIGS
|
|
if generic_only:
|
|
patch_annotations.CHAIN_RIGS, patch_annotations.X_SIDE_RIGS = {}, {}
|
|
try:
|
|
patch_annotations.patch_joint_labels(ds, root, clean, names, {}, stats, log)
|
|
finally:
|
|
patch_annotations.CHAIN_RIGS, patch_annotations.X_SIDE_RIGS = saved
|
|
|
|
|
|
def rule_annotation(ds, root, names, log=None, generic_only=False, name_map=None):
|
|
"""Rule labels and face pairs for the rigs of *names* (``{rig: raw names}``)
|
|
exported under *root*.
|
|
|
|
Labels: the rule cleaner with its refinements (learned vocabulary, sided
|
|
placeholders, end bones), the hierarchy's limb context, then the automatic
|
|
label fixes of ``patch_annotations`` (root unification, side-from-x), then
|
|
the reviewed-label lookup of *name_map* (default: the shipped
|
|
``learned_name_map.json.gz``; ``False``: none): a known rig tree's labels,
|
|
else confident per-name labels in place of weak rule labels.
|
|
|
|
The face pair is resolved on the rule labels without the learned
|
|
vocabulary (richer labels steer the resolver to other parts more often than
|
|
they help), except that a thigh pair of the refined labels wins over a
|
|
plain pair of another part. The lookup does not move the pair. *generic_only* leaves out the
|
|
fixes keyed by dataset rig name (``CHAIN_RIGS``, ``X_SIDE_RIGS``), which an
|
|
unseen asset never gets.
|
|
"""
|
|
log = [] if log is None else log
|
|
clean = {r: [post_process(clean_joint_name(n, r)) for n in raw] for r, raw in names.items()}
|
|
plain = {r: [post_process(clean_joint_name(n, r, refine=False)) for n in raw]
|
|
for r, raw in names.items()}
|
|
rest, parents = {}, {}
|
|
for rig in names:
|
|
npz = patch_annotations.rig_npz(root, ds, rig)
|
|
if npz:
|
|
with np.load(npz, allow_pickle=True) as data:
|
|
parents[rig] = data['parents']
|
|
rest[rig] = rest_global(data)
|
|
clean[rig] = rig_context(limb_context(clean[rig], parents[rig]), list(names[rig]),
|
|
parents[rig])
|
|
clean[rig] = fill_unnamed(clean[rig], parents[rig], rest[rig])
|
|
_patch_labels(ds, root, clean, names, log, generic_only)
|
|
_patch_labels(ds, root, plain, names, [], generic_only)
|
|
face = {}
|
|
for rig in names:
|
|
raw = list(names[rig])
|
|
usable = _separation_check(rest[rig]) if rig in rest else None
|
|
# the plain rule labels first; a rig they give no pair (unnamed joints),
|
|
# or a pair of another part where the refined labels find thighs, takes
|
|
# the refined labels' pair
|
|
face[rig] = resolve_face_joints(plain[rig], raw, usable)
|
|
refined = resolve_face_joints(clean[rig], raw, usable)
|
|
if not face[rig]['r_hip']['raw'] or (
|
|
_part(refined, raw, clean[rig]) == 'Thigh'
|
|
and _part(face[rig], raw, clean[rig]) != 'Thigh'):
|
|
face[rig] = refined
|
|
if name_map is not False:
|
|
lookup = load_name_map() if name_map is None else name_map
|
|
for rig in names:
|
|
clean[rig], _ = apply_name_map(lookup, list(names[rig]), clean[rig], parents.get(rig))
|
|
patch_annotations.patch_face_pairs(ds, face, clean, names, {}, Counter(), log)
|
|
return clean, face
|
|
|
|
|
|
def _part(entry, raw, labels):
|
|
"""The refined label of a face entry's right joint, without its side
|
|
('' for an empty pair)."""
|
|
joint = entry.get('r_hip', {}).get('raw')
|
|
if not joint or entry.get('body_axis'):
|
|
return ''
|
|
return re.sub(r'^(Left|Right) ', '', labels[raw.index(joint)])
|
|
|
|
|
|
def _separation_check(pos):
|
|
"""The resolver's pair predicate: horizontal separation in rest pose *pos*."""
|
|
def usable(ri, li):
|
|
return _separated(pos[ri], pos[li])
|
|
return usable
|
|
|
|
|
|
def _separated(a, b):
|
|
across = np.asarray(a, dtype=float) - np.asarray(b, dtype=float)
|
|
across = across / max(np.linalg.norm(across), 1e-8)
|
|
return bool(np.linalg.norm(np.cross([0.0, 1.0, 0.0], across)) > FACING_EPS)
|
|
|
|
|
|
def manual_face(raw, clean, face_r, face_l, body_axis=False):
|
|
"""A ``face_joint_names.json`` entry for the hand-given pair *face_r* /
|
|
*face_l* (raw names of the exported skeleton, right or head first)."""
|
|
for joint in (face_r, face_l):
|
|
if joint not in raw:
|
|
raise ValueError(f"face joint {joint!r} is not a joint of the exported skeleton")
|
|
entry = OrderedDict([
|
|
('r_hip', {'raw': face_r, 'clean': clean[raw.index(face_r)]}),
|
|
('l_hip', {'raw': face_l, 'clean': clean[raw.index(face_l)]}),
|
|
('source', 'body_axis' if body_axis else 'manual'),
|
|
])
|
|
if body_axis:
|
|
entry['body_axis'] = True
|
|
return entry
|
|
|
|
|
|
def facing_defined(face, raw, rest_pos):
|
|
"""Whether the pair has horizontal separation in the rest pose (export
|
|
space, Y-up), the condition stage 4's facing needs; an empty pair counts."""
|
|
r, l = face.get('r_hip', {}).get('raw'), face.get('l_hip', {}).get('raw')
|
|
if not (r and l):
|
|
return True
|
|
across = np.asarray(rest_pos[raw.index(r)]) - np.asarray(rest_pos[raw.index(l)])
|
|
across = across / max(np.linalg.norm(across), 1e-8)
|
|
return bool(np.linalg.norm(np.cross([0.0, 1.0, 0.0], across)) > FACING_EPS)
|
|
|
|
|
|
def annotate(mode, ds, name, raw, export_dir, dataset_export_dir=None,
|
|
face_r=None, face_l=None, body_axis=False, llm_client=None, llm_options=None,
|
|
rest_pos=None, names_mode=None, face_mode=None):
|
|
"""Return ``(clean_names, face_entry, notes)`` for one asset.
|
|
|
|
*raw* is the exported skeleton's joint names; *ds* the profile name (it
|
|
selects dataset-specific automatic fixes); *dataset_export_dir* the
|
|
dataset export holding the entries for ``mode='dataset'``; *llm_options*
|
|
``max_tokens`` / ``max_retries`` / ``timeout_seconds`` for both LLM calls.
|
|
Outside ``dataset`` mode, *names_mode* and *face_mode* ('rule' / 'llm',
|
|
default *mode*) choose the source of the joint names and of the face pair
|
|
separately; an LLM call that fails falls back to the rule result.
|
|
"""
|
|
log = []
|
|
if mode == 'dataset':
|
|
if not dataset_export_dir:
|
|
raise ValueError("mode 'dataset' needs the dataset export directory")
|
|
entries = {}
|
|
for key, fname in (('raw', 'joint_names.json'), ('clean', 'clean_joint_names.json'),
|
|
('face', 'face_joint_names.json')):
|
|
with open(os.path.join(dataset_export_dir, fname)) as f:
|
|
entries[key] = json.load(f).get(name)
|
|
if any(v is None for v in entries.values()):
|
|
raise ValueError(f"'{name}' has no stage-3 entry in {dataset_export_dir}")
|
|
# By raw joint name: a skeleton exported without animation can keep
|
|
# joints the dataset's export pruned; those get the rule label.
|
|
label = dict(zip(entries['raw'], entries['clean']))
|
|
extra = [n for n in raw if n not in label]
|
|
clean = [label[n] if n in label else post_process(clean_joint_name(n, name)) for n in raw]
|
|
if extra:
|
|
log.append(f'{len(extra)} joint(s) not in the dataset skeleton, rule-labelled: '
|
|
f'{extra[:5]}')
|
|
face = entries['face']
|
|
for key in ('r_hip', 'l_hip'):
|
|
joint = face.get(key, {}).get('raw')
|
|
if joint and joint not in raw:
|
|
raise ValueError(f"'{name}': dataset face joint {joint!r} is not in the "
|
|
f"exported skeleton")
|
|
elif mode in ('rule', 'llm'):
|
|
names_mode, face_mode = names_mode or mode, face_mode or mode
|
|
for m in (names_mode, face_mode):
|
|
if m not in ('rule', 'llm'):
|
|
raise ValueError(f"names / face source must be 'rule' or 'llm', got {m!r}")
|
|
if 'llm' in (names_mode, face_mode) and llm_client is None:
|
|
raise ValueError("an 'llm' names / face source needs an LLM client")
|
|
# The rule result: the 'rule' source, and the fallback of a failed LLM call.
|
|
clean_d, face_d = rule_annotation(ds, export_dir, {name: list(raw)}, log)
|
|
clean, face = clean_d[name], face_d[name]
|
|
opts = dict(llm_options or {})
|
|
names = {name: list(raw)}
|
|
if names_mode == 'llm':
|
|
from data_process.joint_annotation.names_clean_llm import clean_rig
|
|
llm_clean, fell_back = clean_rig(name, list(raw), llm_client, **opts)
|
|
if fell_back:
|
|
log.append('joint names: LLM failed, rule labels kept')
|
|
else:
|
|
llm_d = {name: list(llm_clean)}
|
|
_patch_labels(ds, export_dir, llm_d, names, log, generic_only=False)
|
|
clean = llm_d[name]
|
|
if face_mode == 'llm':
|
|
from data_process.joint_annotation.face_select_llm import resolve_rig
|
|
llm_face, fell_back = resolve_rig(name, list(raw), list(clean), llm_client, **opts)
|
|
if fell_back:
|
|
log.append('face pair: LLM failed, rule pair kept')
|
|
elif rest_pos is not None and not facing_defined(llm_face, list(raw), rest_pos):
|
|
log.append(f"face pair: the LLM's {llm_face['r_hip']['raw']} / "
|
|
f"{llm_face['l_hip']['raw']} has no horizontal separation in "
|
|
f"the rest pose, rule pair kept")
|
|
else:
|
|
face = llm_face
|
|
face_d = {name: face}
|
|
patch_annotations.patch_face_pairs(ds, face_d, {name: clean}, names, {}, Counter(), log)
|
|
face = face_d[name]
|
|
log.append(f'sources: joint names {names_mode}, face pair {face_mode}')
|
|
else:
|
|
raise ValueError(f"mode must be one of {MODES}, got {mode!r}")
|
|
|
|
if face_r or face_l:
|
|
if not (face_r and face_l):
|
|
raise ValueError("give both face_r and face_l")
|
|
face = manual_face(list(raw), clean, face_r, face_l, body_axis)
|
|
log.append(f'face pair given by hand: {face_r} / {face_l}')
|
|
if rest_pos is not None and not facing_defined(face, list(raw), rest_pos):
|
|
raise ValueError(f"face pair {face_r} / {face_l} has no horizontal separation "
|
|
f"in the rest pose; choose another")
|
|
elif rest_pos is not None and not facing_defined(face, list(raw), rest_pos):
|
|
# Stage 4 would skip the asset; the authored facing is the better fallback.
|
|
log.append(f"face pair {face['r_hip']['raw']} / {face['l_hip']['raw']} has no "
|
|
f"horizontal separation in the rest pose; using none (identity facing)")
|
|
face = empty_entry()
|
|
for line in log:
|
|
logger.info(f"[annotate] {line}")
|
|
return list(clean), face, log
|