openworldformat 0.4.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openworldformat/__init__.py +1765 -0
- openworldformat/agent.py +142 -0
- openworldformat/cinematography.py +404 -0
- openworldformat/dataset.py +83 -0
- openworldformat/entity_refs.py +25 -0
- openworldformat/eval.py +226 -0
- openworldformat/physics.py +424 -0
- openworldformat/soundtrack.py +94 -0
- openworldformat-0.4.4.dist-info/METADATA +278 -0
- openworldformat-0.4.4.dist-info/RECORD +12 -0
- openworldformat-0.4.4.dist-info/WHEEL +5 -0
- openworldformat-0.4.4.dist-info/top_level.txt +1 -0
openworldformat/agent.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Agents: the benchmark's other half — something that emits ops.
|
|
2
|
+
|
|
3
|
+
Scoring a recorded trajectory is one loop; scoring a live agent is the
|
|
4
|
+
same loop with the recording still wet. :func:`run_agent` keeps the
|
|
5
|
+
entries, folds between steps so the agent observes its own edits, and
|
|
6
|
+
scores with :func:`openworldformat.eval.run_task` — no engine, no
|
|
7
|
+
renderer, an agent and a fold.
|
|
8
|
+
|
|
9
|
+
:func:`template_agent` is the baseline: it compiles a task's goal
|
|
10
|
+
predicates into ops — spawn what must exist, move what must be near,
|
|
11
|
+
delete what must be gone, set what fields must hold — and stops when
|
|
12
|
+
the goal reads satisfied. It is a compiler, not a brain: it exists so
|
|
13
|
+
benchmark authors have a floor to beat and a skeleton to copy, and so
|
|
14
|
+
the agent interface has a reference user. Real agents read the world,
|
|
15
|
+
not the answer key; the interesting ones will too.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from . import fold_log, fold_state
|
|
21
|
+
from .eval import run_task
|
|
22
|
+
|
|
23
|
+
__all__ = ["run_agent", "template_agent"]
|
|
24
|
+
|
|
25
|
+
#: What run_agent names the baseline in the log's author field.
|
|
26
|
+
TEMPLATE_NAME = "template"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _find(document: dict, name):
|
|
30
|
+
return next((e for e in document["entities"] if e.get("name") == name), None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_num(v) -> bool:
|
|
34
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def template_agent(document: dict, values: dict, task: dict, entries: list) -> dict | None:
|
|
38
|
+
"""Compile the goal into one entry of ops — the baseline solver.
|
|
39
|
+
|
|
40
|
+
Observes the folded world (``document``) and the state values, and
|
|
41
|
+
emits what's missing: one entry per call, or None when the goal
|
|
42
|
+
already reads satisfied (or holds nothing this template knows how
|
|
43
|
+
to do — unknown predicates are beyond it, by design).
|
|
44
|
+
"""
|
|
45
|
+
# Per-entity requirements, merged so `exists` + `near` on one entity
|
|
46
|
+
# spawn it once, at the target. `gone` wins over both.
|
|
47
|
+
targets: dict = {}
|
|
48
|
+
deletions: set = set()
|
|
49
|
+
field_ops: dict = {}
|
|
50
|
+
for predicate in task.get("goal") or []:
|
|
51
|
+
if "exists" in predicate:
|
|
52
|
+
targets.setdefault(predicate["exists"].get("entity"), None)
|
|
53
|
+
elif "near" in predicate:
|
|
54
|
+
near = predicate["near"]
|
|
55
|
+
targets[near.get("entity")] = list(near.get("position") or [0.0, 0.0, 0.0])
|
|
56
|
+
elif "gone" in predicate:
|
|
57
|
+
name = predicate["gone"].get("entity")
|
|
58
|
+
deletions.add(name)
|
|
59
|
+
targets.pop(name, None)
|
|
60
|
+
elif "field" in predicate:
|
|
61
|
+
field = predicate["field"]
|
|
62
|
+
name, v = field.get("name"), values.get(field.get("name"))
|
|
63
|
+
want = None
|
|
64
|
+
if "equals" in field and v != field["equals"]:
|
|
65
|
+
want = field["equals"]
|
|
66
|
+
if "at_least" in field and _is_num(v) and v < field["at_least"]:
|
|
67
|
+
want = field["at_least"]
|
|
68
|
+
if "at_most" in field and _is_num(v) and v > field["at_most"]:
|
|
69
|
+
want = field["at_most"]
|
|
70
|
+
if want is not None:
|
|
71
|
+
field_ops[name] = want
|
|
72
|
+
|
|
73
|
+
ops: list = []
|
|
74
|
+
next_id = max((e.get("id", 0) for e in document["entities"]), default=0) + 1
|
|
75
|
+
for name, position in targets.items():
|
|
76
|
+
entity = _find(document, name)
|
|
77
|
+
if entity is None:
|
|
78
|
+
entity_doc = {"id": next_id, "name": name}
|
|
79
|
+
if position is not None:
|
|
80
|
+
entity_doc["transform"] = {"position": position}
|
|
81
|
+
ops.append({"SpawnEntity": {"entity": entity_doc}})
|
|
82
|
+
next_id += 1
|
|
83
|
+
elif position is not None:
|
|
84
|
+
transform = entity.get("transform")
|
|
85
|
+
current = transform.get("position") if isinstance(transform, dict) else None
|
|
86
|
+
if current != position:
|
|
87
|
+
# Keep rotation/scale if the entity had them; move the position.
|
|
88
|
+
moved = dict(transform) if isinstance(transform, dict) else {}
|
|
89
|
+
moved["position"] = position
|
|
90
|
+
ops.append({"ModifyEntity": {"id": entity["id"], "patch": {"transform": moved}}})
|
|
91
|
+
for name in sorted(deletions):
|
|
92
|
+
entity = _find(document, name)
|
|
93
|
+
if entity is not None:
|
|
94
|
+
ops.append({"DeleteEntity": {"id": entity["id"]}})
|
|
95
|
+
for name, want in field_ops.items():
|
|
96
|
+
ops.append({"state": {name: want}})
|
|
97
|
+
|
|
98
|
+
if not ops:
|
|
99
|
+
return None
|
|
100
|
+
revision = max((e.get("revision", 0) for e in entries), default=0) + 1
|
|
101
|
+
timestamp = max((e.get("timestamp_ms", 0) for e in entries), default=0) + 1
|
|
102
|
+
return {
|
|
103
|
+
"revision": revision,
|
|
104
|
+
"timestamp_ms": timestamp,
|
|
105
|
+
"author": {"name": TEMPLATE_NAME},
|
|
106
|
+
"ops": ops,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_agent(manifest: dict, task: dict, state_doc: dict | None = None,
|
|
111
|
+
agent=None, max_steps: int = 8) -> dict:
|
|
112
|
+
"""Run an agent against a task: observe, emit, fold, score, repeat.
|
|
113
|
+
|
|
114
|
+
:param manifest: the base world document (parsed)
|
|
115
|
+
:param task: as :func:`openworldformat.eval.run_task` takes it
|
|
116
|
+
:param state_doc: the world's parsed ``state.json``, if it has one
|
|
117
|
+
:param agent: ``f(document, values, task, entries) -> entry | None``
|
|
118
|
+
— the folded world and state values as observation, one entry of
|
|
119
|
+
ops as action, None to stop. Defaults to :func:`template_agent`
|
|
120
|
+
:param max_steps: the step budget — an agent that never stops, stops
|
|
121
|
+
:return: the :func:`run_task` result, plus ``entries`` (the agent's
|
|
122
|
+
ops — serialize to ``ops.jsonl`` and they're a recording)
|
|
123
|
+
"""
|
|
124
|
+
if agent is None:
|
|
125
|
+
agent = template_agent
|
|
126
|
+
entries: list = []
|
|
127
|
+
result = None
|
|
128
|
+
for _ in range(max_steps):
|
|
129
|
+
document = fold_log(manifest, entries)
|
|
130
|
+
values = fold_state(state_doc or {}, entries)["values"]
|
|
131
|
+
entry = agent(document, values, task, entries)
|
|
132
|
+
if entry is None:
|
|
133
|
+
break
|
|
134
|
+
entries.append(entry)
|
|
135
|
+
result = run_task(manifest, entries, task, state_doc)
|
|
136
|
+
if result["ok"]:
|
|
137
|
+
break
|
|
138
|
+
if result is None:
|
|
139
|
+
result = run_task(manifest, entries, task, state_doc)
|
|
140
|
+
result = dict(result)
|
|
141
|
+
result["entries"] = entries
|
|
142
|
+
return result
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""The ext-cinematography reference implementation (extension version 0.3), in Python.
|
|
2
|
+
|
|
3
|
+
Spec: spec/extensions/cinematography.md. An entity carrying
|
|
4
|
+
``ext-cinematography.camera`` is a camera — a filmback and a lens — and
|
|
5
|
+
one also carrying ``ext-cinematography.shot`` is a setup in the shot
|
|
6
|
+
list. What this module provides is the contract's executable half —
|
|
7
|
+
|
|
8
|
+
- the normative crop math (frame size, horizontal and vertical FOV) —
|
|
9
|
+
Unreal's crop-to-aspect rule written out: cropping never widens the
|
|
10
|
+
frame past the sensor, it only trims;
|
|
11
|
+
- the view (aim look-at, +Y up, else the entity's local −Z) and a
|
|
12
|
+
pinhole projection, so "is it in frame" is a predicate;
|
|
13
|
+
- entity origins in world space (the document's TRS hierarchy) — the
|
|
14
|
+
point every assertion projects, and the point ``aim_entity`` and
|
|
15
|
+
``focus_entity`` resolve to;
|
|
16
|
+
- the shot list (shot.order, ties by entity id);
|
|
17
|
+
- the outcome runner for conformance/outcomes/*.json.
|
|
18
|
+
|
|
19
|
+
Conformance is math, not pixels: implementations agree on the numbers,
|
|
20
|
+
within the stated tolerance — never on rendered output.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"EXTENSION_NAME",
|
|
30
|
+
"EXTENSION_VERSION",
|
|
31
|
+
"DEFAULT_SENSOR",
|
|
32
|
+
"DEFAULT_FOCAL_LENGTH",
|
|
33
|
+
"camera_of",
|
|
34
|
+
"frame_of",
|
|
35
|
+
"entity_of",
|
|
36
|
+
"world_origin",
|
|
37
|
+
"aim_point_of",
|
|
38
|
+
"focus_distance_of",
|
|
39
|
+
"view_of",
|
|
40
|
+
"project",
|
|
41
|
+
"shot_list",
|
|
42
|
+
"run_outcomes",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
#: The extension this module implements.
|
|
46
|
+
EXTENSION_NAME = "ext-cinematography"
|
|
47
|
+
|
|
48
|
+
#: The extension version this module implements.
|
|
49
|
+
EXTENSION_VERSION = "0.3.0"
|
|
50
|
+
|
|
51
|
+
#: The default sensor (filmback): Super 35, [w, h] in mm.
|
|
52
|
+
DEFAULT_SENSOR = (24.89, 18.66)
|
|
53
|
+
|
|
54
|
+
#: The default focal length, in mm.
|
|
55
|
+
DEFAULT_FOCAL_LENGTH = 35
|
|
56
|
+
|
|
57
|
+
_sub = lambda a, b: [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
58
|
+
_add = lambda a, b: [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
|
59
|
+
_dot = lambda a, b: a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
60
|
+
_len = lambda a: math.sqrt(_dot(a, a))
|
|
61
|
+
_cross = lambda a, b: [
|
|
62
|
+
a[1] * b[2] - a[2] * b[1],
|
|
63
|
+
a[2] * b[0] - a[0] * b[2],
|
|
64
|
+
a[0] * b[1] - a[1] * b[0],
|
|
65
|
+
]
|
|
66
|
+
_is_num = lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _norm(v: list) -> list:
|
|
70
|
+
length = _len(v)
|
|
71
|
+
return [x / length for x in v]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _rotate_intrinsic_xyz(v: list, degrees: list) -> list:
|
|
75
|
+
"""Rotate ``v`` by intrinsic XYZ Euler degrees — R = Rx·Ry·Rz, the
|
|
76
|
+
transform convention of spec/world.md "Conventions"."""
|
|
77
|
+
rx, ry, rz = (math.radians(d) for d in degrees)
|
|
78
|
+
x, y, z = v
|
|
79
|
+
# Rz first (rightmost), then Ry, then Rx — R = Rx·Ry·Rz applied to v.
|
|
80
|
+
c, s = math.cos(rz), math.sin(rz)
|
|
81
|
+
x, y = c * x - s * y, s * x + c * y
|
|
82
|
+
c, s = math.cos(ry), math.sin(ry)
|
|
83
|
+
x, z = c * x + s * z, -s * x + c * z
|
|
84
|
+
c, s = math.cos(rx), math.sin(rx)
|
|
85
|
+
y, z = c * y - s * z, s * y + c * z
|
|
86
|
+
return [x, y, z]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _position_of(entity: dict) -> list:
|
|
90
|
+
transform = entity.get("transform")
|
|
91
|
+
pos = transform.get("position") if isinstance(transform, dict) else None
|
|
92
|
+
return list(pos) if pos is not None else [0.0, 0.0, 0.0]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def camera_of(entity: dict) -> dict | None:
|
|
96
|
+
"""An entity's ``ext-cinematography.camera`` with defaults applied per
|
|
97
|
+
absent field (``{}`` is a 35 mm on Super 35), or None when the entity
|
|
98
|
+
carries no camera component.
|
|
99
|
+
|
|
100
|
+
:param entity: a parsed entity
|
|
101
|
+
:return: ``{"sensor_mm", "focal_length_mm", "aspect_ratio", "squeeze",
|
|
102
|
+
"aim", "aim_entity", "focus_distance_m", "focus_entity", "f_stop"}``
|
|
103
|
+
or None — ``aim_entity`` and ``focus_entity`` are entity
|
|
104
|
+
references, the id (bound) or the name (at intake)
|
|
105
|
+
"""
|
|
106
|
+
ext = entity.get(EXTENSION_NAME)
|
|
107
|
+
camera = ext.get("camera") if isinstance(ext, dict) else None
|
|
108
|
+
if not isinstance(camera, dict):
|
|
109
|
+
return None
|
|
110
|
+
sensor = camera.get("sensor_mm")
|
|
111
|
+
return {
|
|
112
|
+
"sensor_mm": list(sensor) if sensor is not None else list(DEFAULT_SENSOR),
|
|
113
|
+
"focal_length_mm": camera.get("focal_length_mm") if camera.get("focal_length_mm") is not None else DEFAULT_FOCAL_LENGTH,
|
|
114
|
+
"aspect_ratio": camera.get("aspect_ratio"),
|
|
115
|
+
"squeeze": camera.get("squeeze") if camera.get("squeeze") is not None else 1,
|
|
116
|
+
"aim": list(camera["aim"]) if camera.get("aim") is not None else None,
|
|
117
|
+
"aim_entity": camera.get("aim_entity"),
|
|
118
|
+
"focus_distance_m": camera.get("focus_distance_m"),
|
|
119
|
+
"focus_entity": camera.get("focus_entity"),
|
|
120
|
+
"f_stop": camera.get("f_stop"),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def frame_of(camera: dict) -> dict:
|
|
125
|
+
"""The normative crop math (spec "Derived values"): with sensor
|
|
126
|
+
``w × h``, squeeze ``s``, focal length ``f`` and aspect ``a`` —
|
|
127
|
+
desqueezed sensor aspect ``A = w·s / h``; frame ``W = w·s · min(1, a/A)``,
|
|
128
|
+
``H = h · min(1, A/a)`` (no aspect: the whole desqueezed sensor); FOVs
|
|
129
|
+
``2·atan(W / 2f)``, ``2·atan(H / 2f)``. Cropping never widens the frame
|
|
130
|
+
past the sensor, it only trims.
|
|
131
|
+
|
|
132
|
+
:param camera: a :func:`camera_of` result
|
|
133
|
+
:return: ``{"width_mm", "height_mm", "hfov_degrees", "vfov_degrees", "aspect"}``
|
|
134
|
+
"""
|
|
135
|
+
w, h = camera["sensor_mm"]
|
|
136
|
+
s, f = camera["squeeze"], camera["focal_length_mm"]
|
|
137
|
+
a = camera.get("aspect_ratio")
|
|
138
|
+
A = w * s / h
|
|
139
|
+
width_mm = w * s * min(1, a / A if a else 1)
|
|
140
|
+
height_mm = h * min(1, A / a if a else 1)
|
|
141
|
+
return {
|
|
142
|
+
"width_mm": width_mm,
|
|
143
|
+
"height_mm": height_mm,
|
|
144
|
+
"hfov_degrees": math.degrees(2 * math.atan(width_mm / (2 * f))),
|
|
145
|
+
"vfov_degrees": math.degrees(2 * math.atan(height_mm / (2 * f))),
|
|
146
|
+
"aspect": width_mm / height_mm,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def entity_of(reference, world: dict | None) -> dict | None:
|
|
151
|
+
"""The entity a reference names, in a world: by id (a bound reference,
|
|
152
|
+
what a document holds) or by name (an authoring-time one, before
|
|
153
|
+
ingestion binds it). None when nothing answers.
|
|
154
|
+
|
|
155
|
+
:param reference: an id, a name, or None
|
|
156
|
+
:param world: a manifest or a fold result (``{entities}``)
|
|
157
|
+
"""
|
|
158
|
+
if reference is None or world is None:
|
|
159
|
+
return None
|
|
160
|
+
entities = world.get("entities") or []
|
|
161
|
+
if _is_num(reference):
|
|
162
|
+
return next((e for e in entities if e.get("id") == reference), None)
|
|
163
|
+
if isinstance(reference, str):
|
|
164
|
+
return next((e for e in entities if e.get("name") == reference), None)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def world_origin(entity: dict, world: dict) -> list:
|
|
169
|
+
"""An entity's origin in world space: its ``transform.position``
|
|
170
|
+
composed through its parents — the document's TRS hierarchy,
|
|
171
|
+
``p = P + R·(S ⊙ p)`` up the chain (spec/world.md "Conventions"). An
|
|
172
|
+
unparented entity's origin is its position as written; a parent the
|
|
173
|
+
world doesn't hold ends the chain. This is the point every outcome
|
|
174
|
+
assertion projects and the point ``aim_entity`` and ``focus_entity``
|
|
175
|
+
resolve to.
|
|
176
|
+
|
|
177
|
+
:param entity: a parsed entity
|
|
178
|
+
:param world: the world it lives in (``{entities}``)
|
|
179
|
+
:return: the world point
|
|
180
|
+
"""
|
|
181
|
+
entities = world.get("entities") or []
|
|
182
|
+
point = _position_of(entity)
|
|
183
|
+
current = entity
|
|
184
|
+
for _ in range(4096):
|
|
185
|
+
parent_id = current.get("parent")
|
|
186
|
+
if parent_id is None:
|
|
187
|
+
break
|
|
188
|
+
parent = next((e for e in entities if e.get("id") == parent_id), None)
|
|
189
|
+
if parent is None:
|
|
190
|
+
break
|
|
191
|
+
transform = parent.get("transform") if isinstance(parent.get("transform"), dict) else {}
|
|
192
|
+
P = list(transform.get("position") or [0.0, 0.0, 0.0])
|
|
193
|
+
R = list(transform.get("rotation_degrees") or [0.0, 0.0, 0.0])
|
|
194
|
+
S = list(transform.get("scale") or [1.0, 1.0, 1.0])
|
|
195
|
+
point = _add(P, _rotate_intrinsic_xyz([point[0] * S[0], point[1] * S[1], point[2] * S[2]], R))
|
|
196
|
+
current = parent
|
|
197
|
+
return point
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def aim_point_of(camera: dict, world: dict | None = None) -> list | None:
|
|
201
|
+
"""The point a camera aims at: ``aim_entity``'s world origin while
|
|
202
|
+
that entity exists in the world, else ``aim``, else None (the camera
|
|
203
|
+
looks down its local −Z). Without a world only ``aim`` can answer.
|
|
204
|
+
|
|
205
|
+
:param camera: a :func:`camera_of` result
|
|
206
|
+
:param world: the world the reference resolves in
|
|
207
|
+
"""
|
|
208
|
+
target = entity_of(camera.get("aim_entity"), world)
|
|
209
|
+
if target is not None and world is not None:
|
|
210
|
+
return world_origin(target, world)
|
|
211
|
+
aim = camera.get("aim")
|
|
212
|
+
return list(aim) if aim is not None else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def focus_distance_of(entity: dict, camera: dict, world: dict | None = None) -> float | None:
|
|
216
|
+
"""A camera's focus distance, metres: the distance from the camera
|
|
217
|
+
entity's world origin to ``focus_entity``'s while that entity exists,
|
|
218
|
+
else ``focus_distance_m``, else None.
|
|
219
|
+
|
|
220
|
+
:param entity: the camera entity
|
|
221
|
+
:param camera: a :func:`camera_of` result
|
|
222
|
+
:param world: the world the reference resolves in
|
|
223
|
+
"""
|
|
224
|
+
target = entity_of(camera.get("focus_entity"), world)
|
|
225
|
+
if target is not None and world is not None:
|
|
226
|
+
return _len(_sub(world_origin(target, world), world_origin(entity, world)))
|
|
227
|
+
return camera.get("focus_distance_m")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def view_of(entity: dict, camera: dict, world: dict | None = None) -> dict:
|
|
231
|
+
"""A camera's view: with an aim point — ``aim_entity``'s origin when
|
|
232
|
+
the world is given and the entity exists, else ``aim`` — the camera
|
|
233
|
+
looks at it, +Y up; without one it looks down the entity's local −Z
|
|
234
|
+
(the glTF / three.js / Bevy convention), its frame carried by the
|
|
235
|
+
entity's rotation. The camera stands at its own world origin.
|
|
236
|
+
|
|
237
|
+
:param entity: the camera entity (its transform places it)
|
|
238
|
+
:param camera: a :func:`camera_of` result
|
|
239
|
+
:param world: the world the entity references resolve in
|
|
240
|
+
:return: ``{"position", "forward", "right", "up"}``
|
|
241
|
+
"""
|
|
242
|
+
position = world_origin(entity, world) if world is not None else _position_of(entity)
|
|
243
|
+
aim = aim_point_of(camera, world)
|
|
244
|
+
if aim is not None:
|
|
245
|
+
forward = _norm(_sub(aim, position))
|
|
246
|
+
right = _norm(_cross(forward, [0, 1, 0]))
|
|
247
|
+
up = _cross(right, forward)
|
|
248
|
+
return {"position": position, "forward": forward, "right": right, "up": up}
|
|
249
|
+
transform = entity.get("transform")
|
|
250
|
+
rot = transform.get("rotation_degrees") if isinstance(transform, dict) else None
|
|
251
|
+
rotation = list(rot) if rot is not None else [0.0, 0.0, 0.0]
|
|
252
|
+
return {
|
|
253
|
+
"position": position,
|
|
254
|
+
"forward": _rotate_intrinsic_xyz([0, 0, -1], rotation),
|
|
255
|
+
"right": _rotate_intrinsic_xyz([1, 0, 0], rotation),
|
|
256
|
+
"up": _rotate_intrinsic_xyz([0, 1, 0], rotation),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def project(view: dict, frame: dict, camera: dict, point: list) -> dict:
|
|
261
|
+
"""Project a world point through a camera: the point in the camera's
|
|
262
|
+
normalized frame — ``x`` and ``y`` in frame-half units (inside when
|
|
263
|
+
``|x| <= 1`` and ``|y| <= 1``), ``z`` the distance along the look
|
|
264
|
+
direction (in front when positive).
|
|
265
|
+
|
|
266
|
+
:param view: a :func:`view_of` result
|
|
267
|
+
:param frame: a :func:`frame_of` result
|
|
268
|
+
:param camera: a :func:`camera_of` result
|
|
269
|
+
:param point: the world point
|
|
270
|
+
:return: ``{"x", "y", "z"}``
|
|
271
|
+
"""
|
|
272
|
+
d = _sub(point, view["position"])
|
|
273
|
+
z = _dot(d, view["forward"])
|
|
274
|
+
scale = camera["focal_length_mm"] / z
|
|
275
|
+
return {
|
|
276
|
+
"x": (_dot(d, view["right"]) * scale) / (frame["width_mm"] / 2),
|
|
277
|
+
"y": (_dot(d, view["up"]) * scale) / (frame["height_mm"] / 2),
|
|
278
|
+
"z": z,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def shot_list(world: dict) -> list:
|
|
283
|
+
"""The shot list: every entity carrying ``ext-cinematography.shot``, as
|
|
284
|
+
``{id, name, shot}``, ordered by ``shot.order`` (absent last), ties by
|
|
285
|
+
entity id. The entity's name is the shot's name.
|
|
286
|
+
|
|
287
|
+
:param world: a manifest or a fold result (``{entities}``)
|
|
288
|
+
:return: the setups, in order
|
|
289
|
+
"""
|
|
290
|
+
shots = []
|
|
291
|
+
for entity in world.get("entities") or []:
|
|
292
|
+
ext = entity.get(EXTENSION_NAME)
|
|
293
|
+
shot = ext.get("shot") if isinstance(ext, dict) else None
|
|
294
|
+
if not isinstance(shot, dict):
|
|
295
|
+
continue
|
|
296
|
+
shots.append({"id": entity.get("id"), "name": entity.get("name"), "shot": shot})
|
|
297
|
+
|
|
298
|
+
def rank(s):
|
|
299
|
+
order = s["shot"].get("order")
|
|
300
|
+
return order if _is_num(order) else math.inf
|
|
301
|
+
|
|
302
|
+
shots.sort(key=lambda s: (rank(s), s["id"]))
|
|
303
|
+
return shots
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def run_outcomes(manifest: dict, outcomes: dict) -> dict:
|
|
307
|
+
"""Run a conformance outcomes document against a world: derive, then
|
|
308
|
+
check every assertion. This is the extension's conformance — math,
|
|
309
|
+
not pixels.
|
|
310
|
+
|
|
311
|
+
:param manifest: the world (parsed)
|
|
312
|
+
:param outcomes: ``{expect: [...]}``
|
|
313
|
+
:return: ``{"ok", "failures"}``
|
|
314
|
+
"""
|
|
315
|
+
failures: list = []
|
|
316
|
+
by_name = {e.get("name"): e for e in manifest.get("entities") or []}
|
|
317
|
+
|
|
318
|
+
def camera_entity(name):
|
|
319
|
+
entity = by_name.get(name)
|
|
320
|
+
camera = camera_of(entity) if entity else None
|
|
321
|
+
if not entity or not camera:
|
|
322
|
+
failures.append(f"no camera named {name}")
|
|
323
|
+
return None
|
|
324
|
+
return {
|
|
325
|
+
"entity": entity,
|
|
326
|
+
"camera": camera,
|
|
327
|
+
"frame": frame_of(camera),
|
|
328
|
+
"view": view_of(entity, camera, manifest),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
def origin_of(name):
|
|
332
|
+
entity = by_name.get(name)
|
|
333
|
+
if not entity:
|
|
334
|
+
failures.append(f"no entity named {name}")
|
|
335
|
+
return [0.0, 0.0, 0.0]
|
|
336
|
+
return world_origin(entity, manifest)
|
|
337
|
+
|
|
338
|
+
for assertion in outcomes.get("expect") or []:
|
|
339
|
+
fov = assertion.get("fov")
|
|
340
|
+
centred = assertion.get("centred")
|
|
341
|
+
focus = assertion.get("focus")
|
|
342
|
+
if isinstance(fov, dict):
|
|
343
|
+
name = fov.get("camera")
|
|
344
|
+
tolerance = fov.get("tolerance")
|
|
345
|
+
tolerance = 0.001 if tolerance is None else tolerance
|
|
346
|
+
c = camera_entity(name)
|
|
347
|
+
if not c:
|
|
348
|
+
continue
|
|
349
|
+
frame = c["frame"]
|
|
350
|
+
if abs(frame["hfov_degrees"] - fov["hfov_degrees"]) > tolerance:
|
|
351
|
+
failures.append(f"{name}: hfov {frame['hfov_degrees']} != {fov['hfov_degrees']} (tolerance {tolerance})")
|
|
352
|
+
if abs(frame["vfov_degrees"] - fov["vfov_degrees"]) > tolerance:
|
|
353
|
+
failures.append(f"{name}: vfov {frame['vfov_degrees']} != {fov['vfov_degrees']} (tolerance {tolerance})")
|
|
354
|
+
if abs(frame["aspect"] - fov["aspect"]) > tolerance:
|
|
355
|
+
failures.append(f"{name}: aspect {frame['aspect']} != {fov['aspect']} (tolerance {tolerance})")
|
|
356
|
+
elif assertion.get("in_frame") or assertion.get("out_of_frame"):
|
|
357
|
+
target = assertion.get("in_frame") or assertion.get("out_of_frame")
|
|
358
|
+
name, entity_name = target.get("camera"), target.get("entity")
|
|
359
|
+
c = camera_entity(name)
|
|
360
|
+
if not c:
|
|
361
|
+
continue
|
|
362
|
+
p = project(c["view"], c["frame"], c["camera"], origin_of(entity_name))
|
|
363
|
+
inside = p["z"] > 0 and abs(p["x"]) <= 1 and abs(p["y"]) <= 1
|
|
364
|
+
if assertion.get("in_frame") and not inside:
|
|
365
|
+
failures.append(f"{name}: {entity_name} projects outside the frame ({p['x']:.3f}, {p['y']:.3f})")
|
|
366
|
+
if assertion.get("out_of_frame") and inside:
|
|
367
|
+
failures.append(f"{name}: {entity_name} projects inside the frame ({p['x']:.3f}, {p['y']:.3f})")
|
|
368
|
+
elif isinstance(centred, dict):
|
|
369
|
+
# The camera aims at the entity: its origin projects at the
|
|
370
|
+
# frame's centre, in front — what ``aim_entity`` promises.
|
|
371
|
+
name, entity_name = centred.get("camera"), centred.get("entity")
|
|
372
|
+
tolerance = centred.get("tolerance")
|
|
373
|
+
tolerance = 0.001 if tolerance is None else tolerance
|
|
374
|
+
c = camera_entity(name)
|
|
375
|
+
if not c:
|
|
376
|
+
continue
|
|
377
|
+
p = project(c["view"], c["frame"], c["camera"], origin_of(entity_name))
|
|
378
|
+
if not (p["z"] > 0 and abs(p["x"]) <= tolerance and abs(p["y"]) <= tolerance):
|
|
379
|
+
failures.append(
|
|
380
|
+
f"{name}: {entity_name} projects at ({p['x']:.4f}, {p['y']:.4f}), z {p['z']:.3f}"
|
|
381
|
+
f" — not the frame's centre (tolerance {tolerance})"
|
|
382
|
+
)
|
|
383
|
+
elif isinstance(focus, dict):
|
|
384
|
+
# The derived focus distance — ``focus_entity``'s origin from
|
|
385
|
+
# the camera, else ``focus_distance_m`` — within ``tolerance``.
|
|
386
|
+
name = focus.get("camera")
|
|
387
|
+
tolerance = focus.get("tolerance")
|
|
388
|
+
tolerance = 0.001 if tolerance is None else tolerance
|
|
389
|
+
c = camera_entity(name)
|
|
390
|
+
if not c:
|
|
391
|
+
continue
|
|
392
|
+
actual = focus_distance_of(c["entity"], c["camera"], manifest)
|
|
393
|
+
if actual is None:
|
|
394
|
+
failures.append(f"{name}: no focus distance")
|
|
395
|
+
elif abs(actual - focus["distance_m"]) > tolerance:
|
|
396
|
+
failures.append(f"{name}: focus distance {actual} != {focus['distance_m']} (tolerance {tolerance})")
|
|
397
|
+
elif isinstance(assertion.get("shot_list"), list):
|
|
398
|
+
actual = [s["name"] for s in shot_list(manifest)]
|
|
399
|
+
expected = assertion["shot_list"]
|
|
400
|
+
if actual != expected:
|
|
401
|
+
failures.append(f"shot list {json.dumps(actual)} != {json.dumps(expected)}")
|
|
402
|
+
else:
|
|
403
|
+
failures.append(f"unknown assertion {json.dumps(assertion)}")
|
|
404
|
+
return {"ok": not failures, "failures": failures}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Dataset tooling: fold a corpus of recordings, compute one number.
|
|
2
|
+
|
|
3
|
+
The notebook pitch, as a module: a folder of `.world` packages is a
|
|
4
|
+
dataset — each ``ops.jsonl`` a recording, each fold a row.
|
|
5
|
+
:func:`summarize` computes the row; ``python -m openworldformat.dataset``
|
|
6
|
+
prints the table (walk a directory tree for recordings, or pass
|
|
7
|
+
explicit ones). Scale is the only difference from the demo: the fold
|
|
8
|
+
is linear in log length, the format is JSON all the way down, and
|
|
9
|
+
there is no engine to boot.
|
|
10
|
+
|
|
11
|
+
The branches column counts tips — recordings whose authors forked the
|
|
12
|
+
history. Fold each tip with :func:`openworldformat.fold_path` and the
|
|
13
|
+
counterfactuals come with the corpus.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import build_history, fold_log, parse_log_line, read_package
|
|
22
|
+
from .eval import entry_metrics
|
|
23
|
+
|
|
24
|
+
__all__ = ["summarize", "main"]
|
|
25
|
+
|
|
26
|
+
#: The columns the report prints, in order.
|
|
27
|
+
COLUMNS = ("world", "entities", "edits", "ops", "entries", "authors", "tips", "span_s")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _entries_of(world: Path) -> list:
|
|
31
|
+
log = world / "ops.jsonl"
|
|
32
|
+
if not log.exists():
|
|
33
|
+
return []
|
|
34
|
+
return [parse_log_line(l) for l in log.read_text().splitlines() if l.strip()]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def summarize(world_dir) -> dict:
|
|
38
|
+
"""One recording's row: fold it, measure the trajectory.
|
|
39
|
+
|
|
40
|
+
:param world_dir: a `.world` package directory (``manifest.json``
|
|
41
|
+
plus ``ops.jsonl`` when the world was recorded)
|
|
42
|
+
:return: ``{world, entities, edits, ops, entries, authors, tips,
|
|
43
|
+
span_s, revision, span_ms}``
|
|
44
|
+
"""
|
|
45
|
+
world = Path(world_dir)
|
|
46
|
+
manifest, entries, _head, _package = read_package(world)
|
|
47
|
+
state = fold_log(manifest, entries)
|
|
48
|
+
row = entry_metrics(entries, state["applied_edits"])
|
|
49
|
+
row["span_s"] = round(row["span_ms"] / 1000, 3)
|
|
50
|
+
row["world"] = world.name
|
|
51
|
+
row["entities"] = len(state["entities"])
|
|
52
|
+
row["tips"] = len(build_history(entries)["tips"])
|
|
53
|
+
return row
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list | None = None) -> int:
|
|
57
|
+
"""The CLI: report on a corpus. Arguments are roots to walk for
|
|
58
|
+
``ops.jsonl`` (default: the current directory)."""
|
|
59
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
60
|
+
roots = [Path(a) for a in argv] or [Path(".")]
|
|
61
|
+
dirs = sorted({log.parent for root in roots for log in root.rglob("ops.jsonl")})
|
|
62
|
+
if not dirs:
|
|
63
|
+
print("no recordings found (no ops.jsonl under the given roots)", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
rows = [summarize(d) for d in dirs]
|
|
66
|
+
|
|
67
|
+
def cell(row, column):
|
|
68
|
+
v = row[column]
|
|
69
|
+
return ", ".join(v) if isinstance(v, list) else str(v)
|
|
70
|
+
|
|
71
|
+
widths = {c: max(len(c), *(len(cell(r, c)) for r in rows)) for c in COLUMNS}
|
|
72
|
+
print(" ".join(c.ljust(widths[c]) for c in COLUMNS))
|
|
73
|
+
for r in rows:
|
|
74
|
+
print(" ".join(cell(r, c).ljust(widths[c]) for c in COLUMNS))
|
|
75
|
+
edits = sum(r["edits"] for r in rows)
|
|
76
|
+
tips = sum(r["tips"] for r in rows)
|
|
77
|
+
print(f"\n{len(rows)} recordings · {edits} edits · {tips} tips "
|
|
78
|
+
f"(branches included in the count)")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The one list of entity-reference fields (spec/world.md "Identity",
|
|
2
|
+
schema/entity-refs.json) — the package's embedded copy. The canonical
|
|
3
|
+
list is generated from world.schema.json's ``x-entity-ref`` markers by
|
|
4
|
+
schema/generate-entity-refs.mjs, but a published package can't read the
|
|
5
|
+
repo's schema/ at runtime, so the passes read this table instead, and
|
|
6
|
+
tests/test_entity_refs.py fails when it drifts from the canonical file.
|
|
7
|
+
|
|
8
|
+
``kind: "bindable"`` — a name is accepted at intake and MUST bind to an
|
|
9
|
+
id at ingestion; ``kind: "id"`` — numeric only. ``scope`` is the object
|
|
10
|
+
the path walks from: an entity (or its patch, the same fields), the
|
|
11
|
+
manifest's avatar, or one creation in ``creations[]``. ``"*"`` walks
|
|
12
|
+
every array element.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
ENTITY_REFS = [
|
|
18
|
+
{"scope": "entity", "path": ["behaviors", "*", "LookAt", "target"], "kind": "bindable"},
|
|
19
|
+
{"scope": "entity", "path": ["behaviors", "*", "Orbit", "center"], "kind": "bindable"},
|
|
20
|
+
{"scope": "entity", "path": ["ext-cinematography", "camera", "aim_entity"], "kind": "bindable"},
|
|
21
|
+
{"scope": "entity", "path": ["ext-cinematography", "camera", "focus_entity"], "kind": "bindable"},
|
|
22
|
+
{"scope": "entity", "path": ["parent"], "kind": "bindable"},
|
|
23
|
+
{"scope": "avatar", "path": ["model_entity"], "kind": "bindable"},
|
|
24
|
+
{"scope": "creation", "path": ["entities", "*"], "kind": "id"},
|
|
25
|
+
]
|