File size: 4,046 Bytes
7faaef2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | """studio.rigging.sidecar — operator-annotation JSON loader + saver.
Sidecar format (versioned, forwards-compatible):
{
"schema_version": 1,
"image": "goblin.png",
"image_shape": [256, 256],
"joints": {
"head": {"y": 50, "x": 128, "confidence": 1.0},
"neck": {"y": 110, "x": 128},
...
}
}
Sidecar path convention: `<sprite>.joints.json` next to `<sprite>.png`.
Loader is lenient: missing joints default to image-center with confidence 0.0,
so the overlay renders them clearly out-of-place — the operator drags them to
the correct positions by editing the JSON.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
from pixel_cursor.rigging import JOINT_INDEX, JOINT_NAMES, NUM_JOINTS, Skeleton
SCHEMA_VERSION: int = 1
def sidecar_path_for(image_path: Path | str) -> Path:
p = Path(image_path)
return p.with_suffix(".joints.json")
def load_sidecar(
image_path: Path | str,
*,
image_shape: Optional[Tuple[int, int]] = None,
) -> Optional[Skeleton]:
"""Return a Skeleton from the sidecar next to image_path, or None if absent.
Missing or malformed joint entries default to image center with confidence 0.
"""
sidecar = sidecar_path_for(image_path)
if not sidecar.exists():
return None
data = json.loads(sidecar.read_text())
version = int(data.get("schema_version", 1))
if version > SCHEMA_VERSION:
raise ValueError(
f"sidecar schema_version {version} > supported {SCHEMA_VERSION} "
f"({sidecar})"
)
shape_field = data.get("image_shape")
if shape_field is not None:
H, W = int(shape_field[0]), int(shape_field[1])
elif image_shape is not None:
H, W = image_shape
else:
raise ValueError(
f"sidecar {sidecar} has no image_shape; pass image_shape= to load_sidecar"
)
joints_field = data.get("joints", {})
positions = np.zeros((NUM_JOINTS, 2), dtype=np.float32)
confidence = np.zeros(NUM_JOINTS, dtype=np.float32)
for name in JOINT_NAMES:
entry = joints_field.get(name)
idx = JOINT_INDEX[name]
if entry is None:
positions[idx] = (H / 2.0, W / 2.0)
confidence[idx] = 0.0
continue
positions[idx, 0] = float(entry["y"])
positions[idx, 1] = float(entry["x"])
confidence[idx] = float(entry.get("confidence", 1.0))
return Skeleton(positions=positions, confidence=confidence, image_shape=(H, W))
def save_sidecar(
image_path: Path | str,
skeleton: Skeleton,
*,
image_name: Optional[str] = None,
) -> Path:
sidecar = sidecar_path_for(image_path)
name = image_name if image_name is not None else Path(image_path).name
payload = {
"schema_version": SCHEMA_VERSION,
"image": name,
"image_shape": list(skeleton.image_shape),
"joints": {
joint_name: {
"y": round(float(skeleton.positions[i, 0]), 2),
"x": round(float(skeleton.positions[i, 1]), 2),
"confidence": round(float(skeleton.confidence[i]), 3),
}
for i, joint_name in enumerate(JOINT_NAMES)
},
}
sidecar.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n")
return sidecar
def sidecar_template(
image_shape: Tuple[int, int],
image_name: str = "",
) -> dict:
"""Return a template dict the operator can write to disk and fill in."""
H, W = image_shape
cy, cx = H / 2.0, W / 2.0
return {
"schema_version": SCHEMA_VERSION,
"image": image_name,
"image_shape": [H, W],
"joints": {
name: {"y": cy, "x": cx, "confidence": 0.0}
for name in JOINT_NAMES
},
}
__all__ = [
"SCHEMA_VERSION",
"sidecar_path_for",
"load_sidecar",
"save_sidecar",
"sidecar_template",
]
|