Skip to content

Commit 567bb0a

Browse files
authored
Merge PR #101: Add appearance-descriptor sidecar (sim half of multi-operator re-id)
Seeded, pixel-free, GT-derived appearance-descriptor sidecar: EntityAppearance/AppearanceTable with deterministic sample(), Scene.appearance optional field, write_appearance_json. Additive and byte-golden safe (manifest builder ignores it).
2 parents e5a32e8 + 638efde commit 567bb0a

4 files changed

Lines changed: 330 additions & 0 deletions

File tree

src/multicam_sim/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
write_coco,
2828
write_yolo,
2929
)
30+
from .appearance import AppearanceTable, EntityAppearance, write_appearance_json
3031
from .cameras import Camera, Intrinsics
3132
from .dropout import SensorDropout
3233
from .entities import Entity, EntityFrame
@@ -92,6 +93,7 @@
9293
"ActivitySegment",
9394
"ActivityState",
9495
"ActivityTimeline",
96+
"AppearanceTable",
9597
"AssumedCalibration",
9698
"Background",
9799
"BackgroundSpec",
@@ -109,6 +111,7 @@
109111
"DipSchedule",
110112
"DistractorSpec",
111113
"Entity",
114+
"EntityAppearance",
112115
"EntityFrame",
113116
"EntityManifest",
114117
"FrameObs",
@@ -160,6 +163,7 @@
160163
"validate_manifest",
161164
"write_actions_json",
162165
"write_activity_json",
166+
"write_appearance_json",
163167
"write_coco",
164168
"write_group_json",
165169
"write_manifest",

src/multicam_sim/appearance.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Appearance-descriptor sidecar: a seeded, pixel-free, GT-derived re-id cue.
2+
3+
This channel models the *appearance* half of cross-camera re-identification. When
4+
geometry alone cannot disambiguate two subjects — the collinear / ambiguous case
5+
where their epipolar or triangulated positions coincide — a fuser needs a second,
6+
appearance-based signal to tell them apart. This module supplies that signal
7+
*without any pixels*: each entity is assigned a deterministic unit-norm descriptor
8+
vector, and how easily two entities can be told apart is set by a single knob.
9+
10+
**Honest note — what this does and does NOT encode.** The descriptor does NOT
11+
encode, and cannot be inverted to recover, the ground-truth ``entity_id``. It is
12+
NOT a perfect label handed to the fuser. It models the *confusability* of a real
13+
learned appearance embedding (OSNet-style): distinct identities land at distinct
14+
but not perfectly separated points on the unit sphere, and a downstream matcher
15+
must still cluster them under noise. The :attr:`AppearanceTable.separation` knob
16+
is the dose-response axis for re-id evaluation: it monotonically controls the
17+
inter-entity cosine similarity, from ``1.0`` (near-orthogonal descriptors,
18+
perfectly separable — the easy case) down to ``0.0`` (all descriptors collapse to
19+
a shared mean, indistinguishable — the impossible case). Sweeping it traces how
20+
re-id accuracy degrades as identities become harder to separate.
21+
22+
Like :mod:`multicam_sim.activity` and :mod:`multicam_sim.possession`, this is a
23+
pure typed-model sidecar. The table rides in a JSON sidecar and attaches to
24+
:class:`~multicam_sim.scene.Scene` via an optional field that the manifest builder
25+
IGNORES, so the byte-golden analytic manifest is unchanged when appearance GT is
26+
absent (and even when it is present).
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import json
32+
from pathlib import Path
33+
from typing import Any
34+
35+
import numpy as np
36+
from pydantic import BaseModel, ConfigDict, field_validator
37+
38+
_DEFAULT_DIM = 512
39+
40+
41+
class EntityAppearance(BaseModel):
42+
"""One entity's appearance descriptor: a unit-norm vector keyed by id.
43+
44+
``descriptor`` is L2-normalized (unit length). It is a synthetic stand-in for
45+
a learned re-id embedding; it does not encode the ``entity_id``.
46+
"""
47+
48+
model_config = ConfigDict(frozen=True)
49+
50+
entity_id: str
51+
descriptor: list[float]
52+
53+
@field_validator("descriptor")
54+
@classmethod
55+
def _non_empty(cls, value: list[float]) -> list[float]:
56+
if not value:
57+
raise ValueError("descriptor must be non-empty")
58+
return value
59+
60+
61+
class AppearanceTable(BaseModel):
62+
"""A collection of per-entity appearance descriptors of a fixed ``dim``.
63+
64+
Built deterministically from ``(entity_ids, dim, separation, seed)`` via
65+
:meth:`sample`. ``separation`` is the dose-response knob (see the module
66+
docstring): ``1.0`` gives near-orthogonal, easily separable descriptors,
67+
``0.0`` collapses every descriptor onto a shared mean (indistinguishable).
68+
The mean pairwise inter-entity cosine similarity increases monotonically as
69+
``separation`` falls from ``1.0`` to ``0.0``.
70+
"""
71+
72+
model_config = ConfigDict(frozen=True)
73+
74+
dim: int = _DEFAULT_DIM
75+
separation: float
76+
seed: int
77+
entries: list[EntityAppearance] = []
78+
79+
@field_validator("dim")
80+
@classmethod
81+
def _positive_dim(cls, value: int) -> int:
82+
if value < 1:
83+
raise ValueError("dim must be >= 1")
84+
return value
85+
86+
@field_validator("separation")
87+
@classmethod
88+
def _separation_range(cls, value: float) -> float:
89+
if not 0.0 <= value <= 1.0:
90+
raise ValueError("separation must be in [0.0, 1.0]")
91+
return value
92+
93+
@classmethod
94+
def sample(
95+
cls,
96+
entity_ids: list[str],
97+
*,
98+
dim: int = _DEFAULT_DIM,
99+
separation: float,
100+
seed: int,
101+
) -> AppearanceTable:
102+
"""Deterministically draw one unit-norm descriptor per entity.
103+
104+
Draw order is fixed: a single ``rng.standard_normal((N, dim))`` call in
105+
the given ``entity_ids`` order. Each raw draw is L2-normalized to a unit
106+
anchor, then blended toward the shared mean of all unit anchors by
107+
``(1 - separation)`` and renormalized. As ``separation`` falls from
108+
``1.0`` to ``0.0`` every descriptor moves toward the common mean, so the
109+
inter-entity cosine similarity rises monotonically. ``separation == 0.0``
110+
collapses all descriptors onto the shared mean (cosine ``1.0``).
111+
112+
Edge cases: an empty ``entity_ids`` yields an empty table; a single
113+
entity yields its (unchanged, unit-norm) anchor with no mean-blend.
114+
"""
115+
rng = np.random.default_rng(seed)
116+
n = len(entity_ids)
117+
if n == 0:
118+
return cls(dim=dim, separation=separation, seed=seed, entries=[])
119+
120+
raw = rng.standard_normal((n, dim))
121+
# Unit anchors first, so the shared mean is not dominated by the largest
122+
# raw draw (keeps the monotonicity closed form clean).
123+
anchors = raw / np.linalg.norm(raw, axis=1, keepdims=True)
124+
125+
if n == 1:
126+
vectors = anchors
127+
else:
128+
mean = anchors.mean(axis=0, keepdims=True)
129+
blended = separation * anchors + (1.0 - separation) * mean
130+
norms = np.linalg.norm(blended, axis=1, keepdims=True)
131+
# separation == 0 collapses every row exactly to `mean`; guard the
132+
# (astronomically unlikely) zero-mean degenerate by falling back to
133+
# the anchor so the descriptor stays unit-norm and finite.
134+
safe = norms.squeeze(axis=1) > 0.0
135+
vectors = np.where(norms > 0.0, blended / np.where(norms > 0.0, norms, 1.0), anchors)
136+
if not safe.all():
137+
vectors[~safe] = anchors[~safe]
138+
139+
entries = [
140+
EntityAppearance(entity_id=entity_id, descriptor=[float(x) for x in vectors[i]])
141+
for i, entity_id in enumerate(entity_ids)
142+
]
143+
return cls(dim=dim, separation=separation, seed=seed, entries=entries)
144+
145+
def to_json(self, *, indent: int | None = 2) -> str:
146+
"""Serialise to a JSON string (the ``appearance.json`` sidecar payload)."""
147+
return self.model_dump_json(indent=indent)
148+
149+
150+
def write_appearance_json(table: AppearanceTable, path: str | Path) -> dict[str, Any]:
151+
"""Write an appearance table to ``path`` as JSON.
152+
153+
Returns the dumped dict so a caller can assert on it without re-reading.
154+
"""
155+
data: dict[str, Any] = table.model_dump(mode="json")
156+
Path(path).write_text(json.dumps(data, indent=2))
157+
return data

src/multicam_sim/scene.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pydantic import BaseModel
88

99
from .activity import ActivityTimeline
10+
from .appearance import AppearanceTable
1011
from .cameras import Camera
1112
from .entities import Entity
1213
from .occluders import OccluderUnion
@@ -31,6 +32,11 @@ class Scene(BaseModel):
3132
``activity`` is an optional activity-state GT sidecar (see
3233
:mod:`multicam_sim.activity`) — same additive contract as ``possession``.
3334
35+
``appearance`` is an optional appearance-descriptor GT sidecar (see
36+
:mod:`multicam_sim.appearance`), a seeded pixel-free re-id cue — same
37+
additive contract: ``None`` by default and never read by the manifest
38+
builder, so the byte-golden analytic manifest is unchanged.
39+
3440
``background`` / ``light`` are optional render-time environment values and
3541
``randomization`` an optional provenance sidecar (spec + seed, see
3642
:mod:`multicam_sim.randomization`). All three are additive — ``None`` by
@@ -46,6 +52,7 @@ class Scene(BaseModel):
4652
topology: CameraTopology | None = None
4753
possession: PossessionTimeline | None = None
4854
activity: ActivityTimeline | None = None
55+
appearance: AppearanceTable | None = None
4956
background: Background | None = None
5057
light: Light | None = None
5158
randomization: RandomizationRecord | None = None

tests/test_appearance.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Appearance-descriptor sidecar: a seeded, pixel-free, GT-derived re-id cue.
2+
3+
Covers the contract of :mod:`multicam_sim.appearance`: deterministic sampling
4+
across seeds, unit-norm/shape invariants, edge cases, the monotonic
5+
``separation`` dose-response knob, and — the safety property — that attaching the
6+
sidecar to a :class:`Scene` leaves the byte-golden analytic manifest untouched
7+
(the manifest builder ignores it).
8+
9+
The descriptor deliberately does NOT encode the ground-truth ``entity_id``; it
10+
models OSNet-style confusability, and ``separation`` is the axis a re-id
11+
evaluation sweeps to trace accuracy vs. identity separability.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import math
17+
18+
import numpy as np
19+
import pytest
20+
21+
from multicam_sim import build_manifest, build_smoke_scene
22+
from multicam_sim.appearance import AppearanceTable, EntityAppearance
23+
from multicam_sim.scene import Scene
24+
25+
_ENTITY_IDS = ["entity_a", "entity_b", "entity_c", "entity_d"]
26+
27+
28+
def _is_unit(vec: list[float], *, tol: float = 1e-9) -> bool:
29+
return math.isclose(math.sqrt(sum(x * x for x in vec)), 1.0, abs_tol=tol)
30+
31+
32+
def _mean_pairwise_cosine(table: AppearanceTable) -> float:
33+
"""Mean cosine similarity over all distinct entity pairs (unit descriptors, so
34+
cosine == dot product)."""
35+
vecs = [np.asarray(e.descriptor) for e in table.entries]
36+
sims = [float(vecs[i] @ vecs[j]) for i in range(len(vecs)) for j in range(i + 1, len(vecs))]
37+
return sum(sims) / len(sims)
38+
39+
40+
def test_determinism_same_inputs_identical() -> None:
41+
"""Same (entity_ids, dim, separation, seed) -> byte-identical descriptors."""
42+
a = AppearanceTable.sample(_ENTITY_IDS, dim=512, separation=0.8, seed=7)
43+
b = AppearanceTable.sample(_ENTITY_IDS, dim=512, separation=0.8, seed=7)
44+
assert a == b
45+
assert [e.descriptor for e in a.entries] == [e.descriptor for e in b.entries]
46+
47+
48+
@pytest.mark.parametrize("seed", [0, 7, 42])
49+
def test_seeds_give_distinct_valid_unit_vectors(seed: int) -> None:
50+
"""Each seed yields valid unit-norm descriptors; different seeds differ."""
51+
table = AppearanceTable.sample(_ENTITY_IDS, dim=256, separation=0.9, seed=seed)
52+
assert len(table.entries) == len(_ENTITY_IDS)
53+
for entry in table.entries:
54+
assert len(entry.descriptor) == 256
55+
assert _is_unit(entry.descriptor)
56+
assert all(math.isfinite(x) for x in entry.descriptor)
57+
58+
other = AppearanceTable.sample(_ENTITY_IDS, dim=256, separation=0.9, seed=seed + 1)
59+
assert table.entries[0].descriptor != other.entries[0].descriptor
60+
61+
62+
def test_descriptor_does_not_encode_entity_id() -> None:
63+
"""Renaming entities (same seed/dim/separation) keeps the descriptor vectors
64+
identical: the vector is a function of draw order, not of the id string, so it
65+
cannot be inverted to the GT id."""
66+
a = AppearanceTable.sample(["x0", "x1", "x2"], dim=128, separation=0.7, seed=3)
67+
b = AppearanceTable.sample(["y0", "y1", "y2"], dim=128, separation=0.7, seed=3)
68+
assert [e.descriptor for e in a.entries] == [e.descriptor for e in b.entries]
69+
70+
71+
@pytest.mark.parametrize("seed", [0, 7, 42])
72+
def test_separation_knob_monotonic(seed: int) -> None:
73+
"""Mean pairwise inter-entity cosine similarity increases monotonically as
74+
separation falls 1.0 -> 0.0 (the re-id dose-response axis). Coarse steps clear
75+
the ~1/sqrt(dim) noise floor comfortably."""
76+
separations = [1.0, 0.75, 0.5, 0.25, 0.0]
77+
sims = [
78+
_mean_pairwise_cosine(AppearanceTable.sample(_ENTITY_IDS, dim=512, separation=s, seed=seed))
79+
for s in separations
80+
]
81+
# separations descend, so similarities must strictly ascend.
82+
for lower, higher in zip(sims, sims[1:], strict=False):
83+
assert higher > lower, f"non-monotone at seed {seed}: {sims}"
84+
85+
# Endpoints: near-orthogonal (perfectly separable) at 1.0, collapsed at 0.0.
86+
assert sims[0] < 0.2
87+
assert math.isclose(sims[-1], 1.0, abs_tol=1e-6)
88+
89+
90+
def test_unit_norm_and_shape_invariants() -> None:
91+
"""Every descriptor is unit-norm and exactly `dim` long, across separations."""
92+
for separation in (1.0, 0.5, 0.0):
93+
table = AppearanceTable.sample(_ENTITY_IDS, dim=64, separation=separation, seed=11)
94+
assert table.dim == 64
95+
for entry in table.entries:
96+
assert len(entry.descriptor) == 64
97+
assert _is_unit(entry.descriptor)
98+
99+
100+
def test_empty_entity_ids() -> None:
101+
"""No entities -> empty table, no crash (mean never computed)."""
102+
table = AppearanceTable.sample([], dim=512, separation=0.5, seed=1)
103+
assert table.entries == []
104+
assert table.dim == 512
105+
106+
107+
def test_single_entity() -> None:
108+
"""One entity -> its unchanged unit anchor, no mean-blend, no pairwise math."""
109+
table = AppearanceTable.sample(["solo"], dim=128, separation=0.3, seed=2)
110+
assert len(table.entries) == 1
111+
assert _is_unit(table.entries[0].descriptor)
112+
# Separation is irrelevant with one entity: any separation gives the same
113+
# anchor for a fixed seed.
114+
other = AppearanceTable.sample(["solo"], dim=128, separation=0.9, seed=2)
115+
assert table.entries[0].descriptor == other.entries[0].descriptor
116+
117+
118+
def test_default_dim_is_512() -> None:
119+
table = AppearanceTable.sample(_ENTITY_IDS, separation=0.8, seed=5)
120+
assert table.dim == 512
121+
assert all(len(e.descriptor) == 512 for e in table.entries)
122+
123+
124+
def test_validation_rejects_out_of_range_separation() -> None:
125+
with pytest.raises(ValueError):
126+
AppearanceTable(dim=8, separation=1.5, seed=0, entries=[])
127+
with pytest.raises(ValueError):
128+
AppearanceTable(dim=0, separation=0.5, seed=0, entries=[])
129+
with pytest.raises(ValueError):
130+
EntityAppearance(entity_id="e", descriptor=[])
131+
132+
133+
def test_roundtrip_json() -> None:
134+
"""Table survives a JSON round-trip unchanged (the sidecar payload)."""
135+
table = AppearanceTable.sample(_ENTITY_IDS, dim=32, separation=0.6, seed=9)
136+
restored = AppearanceTable.model_validate_json(table.to_json())
137+
assert restored == table
138+
139+
140+
def _scene_with_appearance(*, attach: bool) -> Scene:
141+
scene = build_smoke_scene()
142+
if not attach:
143+
return scene
144+
ids = [e.id for e in scene.entities]
145+
table = AppearanceTable.sample(ids, dim=512, separation=0.7, seed=13)
146+
return scene.model_copy(update={"appearance": table})
147+
148+
149+
def test_manifest_byte_identical_with_and_without_sidecar() -> None:
150+
"""Attaching the appearance sidecar changes NOTHING in the analytic manifest:
151+
the serialized bytes with and without it are exactly equal (manifest builder
152+
ignores the sidecar; byte-golden default preserved)."""
153+
with_side = build_manifest(_scene_with_appearance(attach=True)).to_json().encode()
154+
without = build_manifest(_scene_with_appearance(attach=False)).to_json().encode()
155+
assert with_side == without
156+
157+
158+
def test_manifest_excludes_appearance_sidecar() -> None:
159+
"""The manifest never contains appearance GT, even when the scene carries it."""
160+
manifest_json = build_manifest(_scene_with_appearance(attach=True)).to_json()
161+
assert "appearance" not in manifest_json
162+
assert "descriptor" not in manifest_json

0 commit comments

Comments
 (0)