simscope 0.1.1__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.
- simscope/__init__.py +6 -0
- simscope/__main__.py +8 -0
- simscope/_assets/simscope-app.css +2 -0
- simscope/_assets/simscope-app.js +4311 -0
- simscope/_assets/simscope-player.js +4325 -0
- simscope/_assets/simscope-web.LICENSES.txt +407 -0
- simscope/_icon.py +22 -0
- simscope/_mjviser.py +203 -0
- simscope/annotations.py +1132 -0
- simscope/cli.py +482 -0
- simscope/core.py +257 -0
- simscope/derived.py +697 -0
- simscope/export.py +799 -0
- simscope/highlights.py +947 -0
- simscope/importers.py +874 -0
- simscope/index.py +579 -0
- simscope/io/__init__.py +45 -0
- simscope/io/blockfile.py +938 -0
- simscope/io/cas.py +294 -0
- simscope/io/codecs.py +566 -0
- simscope/io/errors.py +9 -0
- simscope/io/manifest.py +358 -0
- simscope/io/pack.py +563 -0
- simscope/io/scene.py +239 -0
- simscope/isaaclab.py +1460 -0
- simscope/library.py +705 -0
- simscope/mujoco.py +578 -0
- simscope/py.typed +0 -0
- simscope/recorder.py +784 -0
- simscope/server/__init__.py +9 -0
- simscope/server/app.py +149 -0
- simscope/server/blocks.py +191 -0
- simscope/server/jobs.py +166 -0
- simscope/server/routes.py +707 -0
- simscope/server/security.py +218 -0
- simscope/server/state.py +751 -0
- simscope/server/static.py +84 -0
- simscope/transforms.py +147 -0
- simscope-0.1.1.dist-info/METADATA +132 -0
- simscope-0.1.1.dist-info/RECORD +45 -0
- simscope-0.1.1.dist-info/WHEEL +4 -0
- simscope-0.1.1.dist-info/entry_points.txt +3 -0
- simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
- simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
- simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
simscope/annotations.py
ADDED
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
"""Curation sidecars, the event-type registry and run groups (annotations v1).
|
|
2
|
+
|
|
3
|
+
``runs/<name>/annotations.json`` holds marks, notes, ratings, events and
|
|
4
|
+
spatial annotations for one run. ``.simscope/event_types.json`` holds the
|
|
5
|
+
library's event vocabulary and ``.simscope/groups.json`` the ordered list of
|
|
6
|
+
run groups (a run belongs to at most one, ``marks.group``). All are
|
|
7
|
+
pretty-printed with sorted keys and written atomically, so git can diff and
|
|
8
|
+
merge them (decision D8).
|
|
9
|
+
|
|
10
|
+
Every record carries a ULID ``id`` plus ``author``, ``created`` and
|
|
11
|
+
``updated``. Lists are sorted by ``id`` when written, so two people's
|
|
12
|
+
appends merge cleanly; :func:`merge` is the programmatic equivalent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import dataclasses
|
|
17
|
+
import getpass
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any, Literal, Self, cast
|
|
25
|
+
|
|
26
|
+
from simscope.io import cas, errors, manifest
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
ANNOTATIONS_FORMAT = "simscope-annotations/1"
|
|
31
|
+
EVENT_TYPES_FORMAT = "simscope-event-types/1"
|
|
32
|
+
GROUPS_FORMAT = "simscope-groups/1"
|
|
33
|
+
FILE_NAME = "annotations.json"
|
|
34
|
+
MAX_GROUP_NAME = 64
|
|
35
|
+
"""Longest group name, in characters."""
|
|
36
|
+
DEFAULT_CRITERION = "overall"
|
|
37
|
+
|
|
38
|
+
RatingScale = Literal["stars5", "score100", "thumb"]
|
|
39
|
+
RATING_SCALES: tuple[RatingScale, ...] = ("stars5", "score100", "thumb")
|
|
40
|
+
_SCALE_RANGES: dict[str, tuple[float, float]] = {
|
|
41
|
+
"stars5": (1, 5),
|
|
42
|
+
"score100": (0, 100),
|
|
43
|
+
"thumb": (-1, 1),
|
|
44
|
+
}
|
|
45
|
+
SPATIAL_KINDS = ("point", "box")
|
|
46
|
+
PROP_TYPES = ("text", "number", "bool", "select")
|
|
47
|
+
|
|
48
|
+
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
49
|
+
_FRAME_RE = re.compile(r"^(world|body:\d+)$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_author() -> str:
|
|
53
|
+
"""Returns the OS user name, which is the default record author."""
|
|
54
|
+
try:
|
|
55
|
+
return getpass.getuser()
|
|
56
|
+
except (OSError, KeyError, ImportError):
|
|
57
|
+
return "unknown"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _dump(obj: dict[str, Any]) -> bytes:
|
|
61
|
+
"""Serializes to the spec's on-disk form (indent 2, sorted, newline)."""
|
|
62
|
+
text = json.dumps(
|
|
63
|
+
obj, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False
|
|
64
|
+
)
|
|
65
|
+
return (text + "\n").encode("utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_json(path: pathlib.Path) -> dict[str, Any]:
|
|
69
|
+
"""Reads a JSON object from ``path``.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
errors.FormatError: If the file is not a JSON object.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
obj = json.loads(path.read_bytes())
|
|
76
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
77
|
+
raise errors.FormatError(f"{path}: invalid JSON: {exc}") from exc
|
|
78
|
+
if not isinstance(obj, dict):
|
|
79
|
+
raise errors.FormatError(f"{path}: expected a JSON object")
|
|
80
|
+
return obj
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_format(obj: dict[str, Any], prefix: str, path: object) -> None:
|
|
84
|
+
"""Rejects files whose format tag or major version is unknown."""
|
|
85
|
+
fmt = obj.get("format")
|
|
86
|
+
if not isinstance(fmt, str) or not fmt.startswith(prefix + "/"):
|
|
87
|
+
raise errors.FormatError(f"{path}: not a {prefix} file: {fmt!r}")
|
|
88
|
+
if fmt.split("/", 1)[1].split(".")[0] != "1":
|
|
89
|
+
raise errors.FormatError(f"{path}: unknown version {fmt!r}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------- records
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclasses.dataclass(kw_only=True)
|
|
96
|
+
class _JsonRecord:
|
|
97
|
+
"""Base: dataclass <-> JSON with unknown-field preservation.
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
extra: Fields the reader did not know, written back unchanged so
|
|
101
|
+
newer tools do not lose data.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
extra: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def to_json(self) -> dict[str, Any]:
|
|
107
|
+
"""Returns the JSON object, known fields over unknown ones."""
|
|
108
|
+
out = copy.deepcopy(self.extra)
|
|
109
|
+
for f in dataclasses.fields(self):
|
|
110
|
+
if f.name != "extra":
|
|
111
|
+
out[f.name] = copy.deepcopy(getattr(self, f.name))
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_json(cls, obj: dict[str, Any]) -> Self:
|
|
116
|
+
"""Parses a JSON object, keeping unknown fields.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
errors.FormatError: If a required field is missing or invalid.
|
|
120
|
+
"""
|
|
121
|
+
names = {f.name for f in dataclasses.fields(cls)}
|
|
122
|
+
names.discard("extra")
|
|
123
|
+
known = {k: v for k, v in obj.items() if k in names}
|
|
124
|
+
extra = {k: v for k, v in obj.items() if k not in names}
|
|
125
|
+
try:
|
|
126
|
+
return cls(**known, extra=extra)
|
|
127
|
+
except (TypeError, ValueError) as exc:
|
|
128
|
+
raise errors.FormatError(
|
|
129
|
+
f"bad {cls.__name__} record {obj!r}: {exc}"
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclasses.dataclass(kw_only=True)
|
|
134
|
+
class Record(_JsonRecord):
|
|
135
|
+
"""Fields common to every list record.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
id: ULID, unique within the file.
|
|
139
|
+
author: Who made the record.
|
|
140
|
+
created: RFC 3339 UTC creation time.
|
|
141
|
+
updated: RFC 3339 UTC time of the last change.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
id: str
|
|
145
|
+
author: str
|
|
146
|
+
created: str
|
|
147
|
+
updated: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclasses.dataclass(kw_only=True)
|
|
151
|
+
class Marks(_JsonRecord):
|
|
152
|
+
"""Run-level marks. The last write wins.
|
|
153
|
+
|
|
154
|
+
``flag``, ``status`` and ``tags`` are deprecated since viewer v3.1
|
|
155
|
+
(decision D23): they are read and written back unchanged so that no data
|
|
156
|
+
is lost, but nothing sets them and the viewer does not show them.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
favorite: Whether the run is starred.
|
|
160
|
+
group: Name of the group the run belongs to, or ``None``.
|
|
161
|
+
flag: Deprecated. Free-text flag such as ``"review"``, or ``None``.
|
|
162
|
+
status: Deprecated. Free-text status, or ``None``.
|
|
163
|
+
tags: Deprecated. Curation tags, stored sorted and unique.
|
|
164
|
+
extra: Unknown fields read from disk.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
favorite: bool = False
|
|
168
|
+
group: str | None = None
|
|
169
|
+
flag: str | None = None
|
|
170
|
+
status: str | None = None
|
|
171
|
+
tags: list[str] = dataclasses.field(default_factory=list)
|
|
172
|
+
extra: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
173
|
+
|
|
174
|
+
def __post_init__(self) -> None:
|
|
175
|
+
"""Normalizes tags to a sorted, unique list."""
|
|
176
|
+
self.favorite = bool(self.favorite)
|
|
177
|
+
self.tags = sorted({str(t) for t in self.tags})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclasses.dataclass(kw_only=True)
|
|
181
|
+
class Note(Record):
|
|
182
|
+
"""A Markdown note.
|
|
183
|
+
|
|
184
|
+
Attributes:
|
|
185
|
+
text: The note body, Markdown.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
text: str = ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclasses.dataclass(kw_only=True)
|
|
192
|
+
class Rating(Record):
|
|
193
|
+
"""A rating on one criterion.
|
|
194
|
+
|
|
195
|
+
Attributes:
|
|
196
|
+
criterion: Free string, ``"overall"`` by default.
|
|
197
|
+
scale: ``"stars5"``, ``"score100"`` or ``"thumb"``.
|
|
198
|
+
value: Number on that scale.
|
|
199
|
+
rationale: Optional explanation.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
criterion: str = DEFAULT_CRITERION
|
|
203
|
+
scale: RatingScale = "stars5"
|
|
204
|
+
value: float = 0
|
|
205
|
+
rationale: str | None = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclasses.dataclass(kw_only=True)
|
|
209
|
+
class Event(Record):
|
|
210
|
+
"""A time instant or segment on the timeline.
|
|
211
|
+
|
|
212
|
+
Attributes:
|
|
213
|
+
type: Key into the event-type registry, or ``""`` if untyped.
|
|
214
|
+
label: Short display string.
|
|
215
|
+
t0: Start in seconds.
|
|
216
|
+
t1: End in seconds (``t0 == t1`` for an instant).
|
|
217
|
+
f0: Frame nearest ``t0``.
|
|
218
|
+
f1: Frame nearest ``t1``.
|
|
219
|
+
env: Env index, or ``None`` for all envs.
|
|
220
|
+
props: Typed property values.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
type: str = ""
|
|
224
|
+
label: str = ""
|
|
225
|
+
t0: float = 0.0
|
|
226
|
+
t1: float = 0.0
|
|
227
|
+
f0: int = 0
|
|
228
|
+
f1: int = 0
|
|
229
|
+
env: int | None = None
|
|
230
|
+
props: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclasses.dataclass(kw_only=True)
|
|
234
|
+
class Spatial(Record):
|
|
235
|
+
"""A 3D point or box at a moment in time.
|
|
236
|
+
|
|
237
|
+
Attributes:
|
|
238
|
+
kind: ``"point"`` or ``"box"``.
|
|
239
|
+
frame: ``"world"`` or ``"body:<index>"``.
|
|
240
|
+
t: Time in seconds.
|
|
241
|
+
f: Frame nearest ``t``.
|
|
242
|
+
env: Env index.
|
|
243
|
+
position: ``[x, y, z]`` in metres.
|
|
244
|
+
size: Box half-extents ``[hx, hy, hz]``, or ``None`` for a point.
|
|
245
|
+
label: Short display string.
|
|
246
|
+
event: Optional id of a linked event.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
kind: str = "point"
|
|
250
|
+
frame: str = "world"
|
|
251
|
+
t: float = 0.0
|
|
252
|
+
f: int = 0
|
|
253
|
+
env: int = 0
|
|
254
|
+
position: list[float] = dataclasses.field(default_factory=lambda: [0.0] * 3)
|
|
255
|
+
size: list[float] | None = None
|
|
256
|
+
label: str = ""
|
|
257
|
+
event: str | None = None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
_LIST_TYPES: dict[str, type[Record]] = {
|
|
261
|
+
"notes": Note,
|
|
262
|
+
"ratings": Rating,
|
|
263
|
+
"events": Event,
|
|
264
|
+
"spatial": Spatial,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --------------------------------------------------------- event registry
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclasses.dataclass(kw_only=True)
|
|
272
|
+
class EventType(_JsonRecord):
|
|
273
|
+
"""One entry of the event vocabulary.
|
|
274
|
+
|
|
275
|
+
Attributes:
|
|
276
|
+
type_id: The key the entry is stored under, such as ``"fall"``.
|
|
277
|
+
name: Display name.
|
|
278
|
+
color: ``#rrggbb`` hex color.
|
|
279
|
+
key: Optional single-character hotkey.
|
|
280
|
+
props: Property specs by name; each has a ``type`` of ``"text"``,
|
|
281
|
+
``"number"``, ``"bool"`` or ``"select"`` (with ``options``).
|
|
282
|
+
extra: Unknown fields read from disk.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
type_id: str
|
|
286
|
+
name: str
|
|
287
|
+
color: str = "#888888"
|
|
288
|
+
key: str | None = None
|
|
289
|
+
props: dict[str, dict[str, Any]] = dataclasses.field(default_factory=dict)
|
|
290
|
+
extra: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
291
|
+
|
|
292
|
+
def __post_init__(self) -> None:
|
|
293
|
+
"""Validates color, hotkey and property specs.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
ValueError: If a field is invalid.
|
|
297
|
+
"""
|
|
298
|
+
if not _COLOR_RE.match(self.color):
|
|
299
|
+
raise ValueError(f"event type {self.type_id!r}: bad color")
|
|
300
|
+
if self.key is not None and len(self.key) != 1:
|
|
301
|
+
raise ValueError(f"event type {self.type_id!r}: key must be 1 char")
|
|
302
|
+
for pname, spec in self.props.items():
|
|
303
|
+
ptype = spec.get("type")
|
|
304
|
+
if ptype not in PROP_TYPES:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"event type {self.type_id!r} prop {pname!r}: "
|
|
307
|
+
f"unknown type {ptype!r}"
|
|
308
|
+
)
|
|
309
|
+
if ptype == "select" and not isinstance(spec.get("options"), list):
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"event type {self.type_id!r} prop {pname!r}: "
|
|
312
|
+
"select needs options"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def to_json(self) -> dict[str, Any]:
|
|
316
|
+
"""Returns the JSON object (without ``type_id``)."""
|
|
317
|
+
out = copy.deepcopy(self.extra)
|
|
318
|
+
out.update(
|
|
319
|
+
name=self.name,
|
|
320
|
+
color=self.color,
|
|
321
|
+
key=self.key,
|
|
322
|
+
props=copy.deepcopy(self.props),
|
|
323
|
+
)
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def from_json(cls, obj: dict[str, Any], type_id: str = "") -> Self:
|
|
328
|
+
"""Parses one entry.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
obj: The entry's JSON object.
|
|
332
|
+
type_id: The key it was stored under.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
errors.FormatError: If the entry is invalid.
|
|
336
|
+
"""
|
|
337
|
+
known = {"name", "color", "key", "props"}
|
|
338
|
+
try:
|
|
339
|
+
return cls(
|
|
340
|
+
type_id=type_id,
|
|
341
|
+
name=str(obj.get("name", type_id)),
|
|
342
|
+
color=str(obj.get("color", "#888888")),
|
|
343
|
+
key=obj.get("key"),
|
|
344
|
+
props=dict(obj.get("props") or {}),
|
|
345
|
+
extra={k: v for k, v in obj.items() if k not in known},
|
|
346
|
+
)
|
|
347
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
348
|
+
raise errors.FormatError(
|
|
349
|
+
f"bad event type {type_id!r}: {exc}"
|
|
350
|
+
) from exc
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def default_event_types() -> dict[str, EventType]:
|
|
354
|
+
"""Returns the built-in vocabulary used when the file is missing."""
|
|
355
|
+
severity = {"type": "select", "options": ["minor", "major"]}
|
|
356
|
+
return {
|
|
357
|
+
t.type_id: t
|
|
358
|
+
for t in (
|
|
359
|
+
EventType(
|
|
360
|
+
type_id="fall",
|
|
361
|
+
name="Fall",
|
|
362
|
+
color="#d33b3b",
|
|
363
|
+
key="f",
|
|
364
|
+
props={"severity": severity},
|
|
365
|
+
),
|
|
366
|
+
EventType(type_id="slip", name="Slip", color="#e59a1c", key="s"),
|
|
367
|
+
EventType(
|
|
368
|
+
type_id="success", name="Success", color="#2f9e44", key="g"
|
|
369
|
+
),
|
|
370
|
+
EventType(type_id="note", name="Note", color="#4c6ef5", key="o"),
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def event_types_path(root: os.PathLike[str] | str) -> pathlib.Path:
|
|
376
|
+
"""Returns ``<root>/.simscope/event_types.json``."""
|
|
377
|
+
return pathlib.Path(root) / ".simscope" / "event_types.json"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def load_event_types(
|
|
381
|
+
root: os.PathLike[str] | str,
|
|
382
|
+
) -> tuple[dict[str, EventType], dict[str, Any]]:
|
|
383
|
+
"""Loads the event vocabulary of a library.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
root: The library root.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
``(types, extra)``: the entries by id, and unknown top-level fields.
|
|
390
|
+
The built-in default (and no extra) when the file is missing.
|
|
391
|
+
|
|
392
|
+
Raises:
|
|
393
|
+
errors.FormatError: If the file is invalid.
|
|
394
|
+
"""
|
|
395
|
+
path = event_types_path(root)
|
|
396
|
+
if not path.is_file():
|
|
397
|
+
return default_event_types(), {}
|
|
398
|
+
obj = _read_json(path)
|
|
399
|
+
_check_format(obj, "simscope-event-types", path)
|
|
400
|
+
raw = obj.get("types") or {}
|
|
401
|
+
if not isinstance(raw, dict):
|
|
402
|
+
raise errors.FormatError(f"{path}: 'types' must be an object")
|
|
403
|
+
types = {
|
|
404
|
+
str(k): EventType.from_json(v, str(k)) for k, v in sorted(raw.items())
|
|
405
|
+
}
|
|
406
|
+
extra = {k: v for k, v in obj.items() if k not in ("format", "types")}
|
|
407
|
+
return types, extra
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def save_event_types(
|
|
411
|
+
root: os.PathLike[str] | str,
|
|
412
|
+
types: dict[str, EventType],
|
|
413
|
+
extra: dict[str, Any] | None = None,
|
|
414
|
+
) -> pathlib.Path:
|
|
415
|
+
"""Writes the event vocabulary atomically.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
root: The library root.
|
|
419
|
+
types: Entries by id.
|
|
420
|
+
extra: Unknown top-level fields to keep.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
The path written.
|
|
424
|
+
"""
|
|
425
|
+
obj = dict(extra or {})
|
|
426
|
+
obj["format"] = EVENT_TYPES_FORMAT
|
|
427
|
+
obj["types"] = {k: v.to_json() for k, v in sorted(types.items())}
|
|
428
|
+
path = event_types_path(root)
|
|
429
|
+
cas.atomic_write(path, _dump(obj))
|
|
430
|
+
return path
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------ groups
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def check_group_name(name: object) -> str:
|
|
437
|
+
"""Validates a group name and returns it without surrounding spaces.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
name: The proposed name.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
The name, 1 to ``MAX_GROUP_NAME`` printable characters.
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
ValueError: If it is not a string, is empty or too long, or has
|
|
447
|
+
characters that cannot be printed (such as a newline).
|
|
448
|
+
"""
|
|
449
|
+
if not isinstance(name, str):
|
|
450
|
+
raise ValueError("a group name must be a string")
|
|
451
|
+
name = name.strip()
|
|
452
|
+
if not 1 <= len(name) <= MAX_GROUP_NAME:
|
|
453
|
+
raise ValueError(f"a group name has 1 to {MAX_GROUP_NAME} characters")
|
|
454
|
+
if not name.isprintable():
|
|
455
|
+
raise ValueError("a group name must be printable text")
|
|
456
|
+
return name
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@dataclasses.dataclass(kw_only=True)
|
|
460
|
+
class Group(_JsonRecord):
|
|
461
|
+
"""One entry of ``.simscope/groups.json``.
|
|
462
|
+
|
|
463
|
+
Attributes:
|
|
464
|
+
name: The group's name, unique case-insensitively.
|
|
465
|
+
created: RFC 3339 UTC creation time.
|
|
466
|
+
extra: Unknown fields read from disk.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
name: str
|
|
470
|
+
created: str = ""
|
|
471
|
+
extra: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def groups_path(root: os.PathLike[str] | str) -> pathlib.Path:
|
|
475
|
+
"""Returns ``<root>/.simscope/groups.json``."""
|
|
476
|
+
return pathlib.Path(root) / ".simscope" / "groups.json"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def load_groups(
|
|
480
|
+
root: os.PathLike[str] | str,
|
|
481
|
+
) -> tuple[list[Group], dict[str, Any]]:
|
|
482
|
+
"""Loads the ordered groups of a library.
|
|
483
|
+
|
|
484
|
+
A group whose name repeats an earlier one (ignoring case) is dropped.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
root: The library root.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
``(groups, extra)``: the groups in library order, and unknown
|
|
491
|
+
top-level fields. No groups when the file is missing.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
errors.FormatError: If the file is invalid.
|
|
495
|
+
"""
|
|
496
|
+
path = groups_path(root)
|
|
497
|
+
if not path.is_file():
|
|
498
|
+
return [], {}
|
|
499
|
+
obj = _read_json(path)
|
|
500
|
+
_check_format(obj, "simscope-groups", path)
|
|
501
|
+
raw = obj.get("groups") or []
|
|
502
|
+
if not isinstance(raw, list) or not all(isinstance(g, dict) for g in raw):
|
|
503
|
+
raise errors.FormatError(f"{path}: 'groups' must be a list of objects")
|
|
504
|
+
groups: list[Group] = []
|
|
505
|
+
seen: set[str] = set()
|
|
506
|
+
for entry in raw:
|
|
507
|
+
group = Group.from_json(entry)
|
|
508
|
+
try:
|
|
509
|
+
group.name = check_group_name(group.name)
|
|
510
|
+
except ValueError as exc:
|
|
511
|
+
raise errors.FormatError(f"{path}: {exc}") from exc
|
|
512
|
+
if group.name.casefold() not in seen:
|
|
513
|
+
seen.add(group.name.casefold())
|
|
514
|
+
groups.append(group)
|
|
515
|
+
extra = {k: v for k, v in obj.items() if k not in ("format", "groups")}
|
|
516
|
+
return groups, extra
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def save_groups(
|
|
520
|
+
root: os.PathLike[str] | str,
|
|
521
|
+
groups: list[Group],
|
|
522
|
+
extra: dict[str, Any] | None = None,
|
|
523
|
+
) -> pathlib.Path:
|
|
524
|
+
"""Writes the ordered groups atomically.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
root: The library root.
|
|
528
|
+
groups: The groups in library order.
|
|
529
|
+
extra: Unknown top-level fields to keep.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
The path written.
|
|
533
|
+
"""
|
|
534
|
+
obj = dict(extra or {})
|
|
535
|
+
obj["format"] = GROUPS_FORMAT
|
|
536
|
+
obj["groups"] = [g.to_json() for g in groups]
|
|
537
|
+
path = groups_path(root)
|
|
538
|
+
cas.atomic_write(path, _dump(obj))
|
|
539
|
+
return path
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def utc_now() -> str:
|
|
543
|
+
"""Returns the current UTC time as RFC 3339 with seconds precision."""
|
|
544
|
+
return manifest.utc_now()
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _finite(name: str, value: float) -> float:
|
|
548
|
+
"""Returns ``value`` as a finite float.
|
|
549
|
+
|
|
550
|
+
Raises:
|
|
551
|
+
ValueError: If it is NaN or infinite.
|
|
552
|
+
"""
|
|
553
|
+
v = float(value)
|
|
554
|
+
if not math.isfinite(v):
|
|
555
|
+
raise ValueError(f"{name} must be finite, got {value!r}")
|
|
556
|
+
return v
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# ------------------------------------------------------------ Annotations
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class Annotations:
|
|
563
|
+
"""The curation sidecar of one run, loaded into memory.
|
|
564
|
+
|
|
565
|
+
Mutators change the in-memory state and mark it dirty; :meth:`save`
|
|
566
|
+
writes it. ``author`` arguments default to the OS user name.
|
|
567
|
+
|
|
568
|
+
Attributes:
|
|
569
|
+
path: Location of ``annotations.json``.
|
|
570
|
+
run_id: The run's id from ``rollout.json``.
|
|
571
|
+
dt: Seconds per frame, used to snap events to frames.
|
|
572
|
+
n_frames: Frames in the run, used to clip snapped frames.
|
|
573
|
+
marks: Run-level marks.
|
|
574
|
+
notes: Notes, sorted by id.
|
|
575
|
+
ratings: Ratings, sorted by id.
|
|
576
|
+
events: Events, sorted by id.
|
|
577
|
+
spatial: Spatial annotations, sorted by id.
|
|
578
|
+
extra: Unknown top-level fields read from disk.
|
|
579
|
+
id_mismatch: True if the file on disk belonged to another run id.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
def __init__(
|
|
583
|
+
self,
|
|
584
|
+
path: os.PathLike[str] | str,
|
|
585
|
+
run_id: str,
|
|
586
|
+
dt: float,
|
|
587
|
+
n_frames: int,
|
|
588
|
+
) -> None:
|
|
589
|
+
"""Creates an empty annotation set (nothing is read or written).
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
path: Location of ``annotations.json``.
|
|
593
|
+
run_id: The run's id.
|
|
594
|
+
dt: Seconds per frame (positive).
|
|
595
|
+
n_frames: Frames in the run.
|
|
596
|
+
|
|
597
|
+
Raises:
|
|
598
|
+
ValueError: If ``dt`` is not positive.
|
|
599
|
+
"""
|
|
600
|
+
if not dt > 0:
|
|
601
|
+
raise ValueError(f"dt must be positive, got {dt!r}")
|
|
602
|
+
self.path = pathlib.Path(path)
|
|
603
|
+
self.run_id = run_id
|
|
604
|
+
self.dt = float(dt)
|
|
605
|
+
self.n_frames = int(n_frames)
|
|
606
|
+
self.marks = Marks()
|
|
607
|
+
self.notes: list[Note] = []
|
|
608
|
+
self.ratings: list[Rating] = []
|
|
609
|
+
self.events: list[Event] = []
|
|
610
|
+
self.spatial: list[Spatial] = []
|
|
611
|
+
self.extra: dict[str, Any] = {}
|
|
612
|
+
self.id_mismatch = False
|
|
613
|
+
self._dirty = False
|
|
614
|
+
|
|
615
|
+
# -- persistence --
|
|
616
|
+
|
|
617
|
+
@classmethod
|
|
618
|
+
def load(
|
|
619
|
+
cls,
|
|
620
|
+
run_dir: os.PathLike[str] | str,
|
|
621
|
+
run_id: str,
|
|
622
|
+
dt: float,
|
|
623
|
+
n_frames: int,
|
|
624
|
+
) -> Self:
|
|
625
|
+
"""Loads ``annotations.json`` from a run directory.
|
|
626
|
+
|
|
627
|
+
A missing file means "no annotations". If the file's ``run_id``
|
|
628
|
+
differs from ``run_id`` a warning is logged and ``id_mismatch`` is
|
|
629
|
+
set; the records are kept and ``save`` will write the current id.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
run_dir: The run directory.
|
|
633
|
+
run_id: The run's id.
|
|
634
|
+
dt: Seconds per frame.
|
|
635
|
+
n_frames: Frames in the run.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
The annotations.
|
|
639
|
+
|
|
640
|
+
Raises:
|
|
641
|
+
errors.FormatError: If the file exists but is invalid.
|
|
642
|
+
"""
|
|
643
|
+
ann = cls(pathlib.Path(run_dir) / FILE_NAME, run_id, dt, n_frames)
|
|
644
|
+
if ann.path.is_file():
|
|
645
|
+
ann._fill(_read_json(ann.path))
|
|
646
|
+
return ann
|
|
647
|
+
|
|
648
|
+
def _fill(self, obj: dict[str, Any]) -> None:
|
|
649
|
+
"""Populates this object from a parsed file."""
|
|
650
|
+
_check_format(obj, "simscope-annotations", self.path)
|
|
651
|
+
file_id = obj.get("run_id")
|
|
652
|
+
if file_id not in (None, self.run_id):
|
|
653
|
+
logger.warning(
|
|
654
|
+
"%s belongs to run %s, not %s", self.path, file_id, self.run_id
|
|
655
|
+
)
|
|
656
|
+
self.id_mismatch = True
|
|
657
|
+
marks = obj.get("marks")
|
|
658
|
+
self.marks = (
|
|
659
|
+
Marks.from_json(marks) if isinstance(marks, dict) else Marks()
|
|
660
|
+
)
|
|
661
|
+
for key, rec_type in _LIST_TYPES.items():
|
|
662
|
+
items = obj.get(key) or []
|
|
663
|
+
if not isinstance(items, list):
|
|
664
|
+
raise errors.FormatError(f"{self.path}: {key!r} must be a list")
|
|
665
|
+
records = [rec_type.from_json(o) for o in items]
|
|
666
|
+
records.sort(key=lambda r: r.id)
|
|
667
|
+
getattr(self, key)[:] = records
|
|
668
|
+
known = {"format", "run_id", "marks", *_LIST_TYPES}
|
|
669
|
+
self.extra = {k: v for k, v in obj.items() if k not in known}
|
|
670
|
+
|
|
671
|
+
def to_json(self) -> dict[str, Any]:
|
|
672
|
+
"""Returns the file contents, lists sorted by id."""
|
|
673
|
+
out = copy.deepcopy(self.extra)
|
|
674
|
+
out["format"] = ANNOTATIONS_FORMAT
|
|
675
|
+
out["run_id"] = self.run_id
|
|
676
|
+
out["marks"] = self.marks.to_json()
|
|
677
|
+
for key in _LIST_TYPES:
|
|
678
|
+
records = sorted(getattr(self, key), key=lambda r: r.id)
|
|
679
|
+
out[key] = [r.to_json() for r in records]
|
|
680
|
+
return out
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def dirty(self) -> bool:
|
|
684
|
+
"""True if there are changes that ``save`` has not written."""
|
|
685
|
+
return self._dirty
|
|
686
|
+
|
|
687
|
+
def save(self) -> bool:
|
|
688
|
+
"""Writes ``annotations.json`` atomically.
|
|
689
|
+
|
|
690
|
+
Nothing is written if there are no changes and no file exists yet,
|
|
691
|
+
because writers create the file on the first change.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
True if the file was written.
|
|
695
|
+
"""
|
|
696
|
+
if not self._dirty and not self.path.exists():
|
|
697
|
+
return False
|
|
698
|
+
cas.atomic_write(self.path, _dump(self.to_json()))
|
|
699
|
+
self._dirty = False
|
|
700
|
+
self.id_mismatch = False
|
|
701
|
+
return True
|
|
702
|
+
|
|
703
|
+
# -- helpers --
|
|
704
|
+
|
|
705
|
+
def _touch(self) -> str:
|
|
706
|
+
"""Marks the state dirty and returns the current timestamp."""
|
|
707
|
+
self._dirty = True
|
|
708
|
+
return utc_now()
|
|
709
|
+
|
|
710
|
+
def _new(
|
|
711
|
+
self, rec_type: type[Record], author: str | None, **fields: Any
|
|
712
|
+
) -> Any:
|
|
713
|
+
"""Builds a record with a fresh id and timestamps and appends it."""
|
|
714
|
+
now = self._touch()
|
|
715
|
+
rec = rec_type(
|
|
716
|
+
id=manifest.new_ulid(),
|
|
717
|
+
author=author or default_author(),
|
|
718
|
+
created=now,
|
|
719
|
+
updated=now,
|
|
720
|
+
**fields,
|
|
721
|
+
)
|
|
722
|
+
for key, cls in _LIST_TYPES.items():
|
|
723
|
+
if cls is rec_type:
|
|
724
|
+
getattr(self, key).append(rec)
|
|
725
|
+
return rec
|
|
726
|
+
|
|
727
|
+
def snap(self, t: float) -> int:
|
|
728
|
+
"""Returns the frame nearest to ``t`` seconds, clipped to the run.
|
|
729
|
+
|
|
730
|
+
Uses ``floor(t / dt + 0.5)`` (round half up, as JavaScript's
|
|
731
|
+
``Math.round``) so the Python and player sides agree.
|
|
732
|
+
|
|
733
|
+
Args:
|
|
734
|
+
t: Time in seconds.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
A frame index in ``[0, max(n_frames - 1, 0)]``.
|
|
738
|
+
"""
|
|
739
|
+
f = math.floor(_finite("t", t) / self.dt + 0.5)
|
|
740
|
+
return int(min(max(f, 0), max(self.n_frames - 1, 0)))
|
|
741
|
+
|
|
742
|
+
def get(self, record_id: str) -> Any:
|
|
743
|
+
"""Returns the record with this id.
|
|
744
|
+
|
|
745
|
+
Raises:
|
|
746
|
+
KeyError: If there is none.
|
|
747
|
+
"""
|
|
748
|
+
for key in _LIST_TYPES:
|
|
749
|
+
for rec in getattr(self, key):
|
|
750
|
+
if rec.id == record_id:
|
|
751
|
+
return rec
|
|
752
|
+
raise KeyError(record_id)
|
|
753
|
+
|
|
754
|
+
def remove(self, record_id: str) -> bool:
|
|
755
|
+
"""Removes the record with this id.
|
|
756
|
+
|
|
757
|
+
Spatial annotations linked to a removed event keep their ``event``
|
|
758
|
+
field; readers treat a dangling link as unlinked.
|
|
759
|
+
|
|
760
|
+
Args:
|
|
761
|
+
record_id: A note, rating, event or spatial id.
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
True if a record was removed.
|
|
765
|
+
"""
|
|
766
|
+
for key in _LIST_TYPES:
|
|
767
|
+
records = getattr(self, key)
|
|
768
|
+
for i, rec in enumerate(records):
|
|
769
|
+
if rec.id == record_id:
|
|
770
|
+
del records[i]
|
|
771
|
+
self._dirty = True
|
|
772
|
+
return True
|
|
773
|
+
return False
|
|
774
|
+
|
|
775
|
+
# -- marks --
|
|
776
|
+
|
|
777
|
+
def set_favorite(self, value: bool = True) -> None:
|
|
778
|
+
"""Sets or clears the favorite mark."""
|
|
779
|
+
self.marks.favorite = bool(value)
|
|
780
|
+
self._dirty = True
|
|
781
|
+
|
|
782
|
+
def set_group(self, group: str | None) -> None:
|
|
783
|
+
"""Moves the run into a group, or out of every group with ``None``.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
group: The group's name (see :func:`check_group_name`), or
|
|
787
|
+
``None``.
|
|
788
|
+
|
|
789
|
+
Raises:
|
|
790
|
+
ValueError: If the name is invalid.
|
|
791
|
+
"""
|
|
792
|
+
self.marks.group = None if group is None else check_group_name(group)
|
|
793
|
+
self._dirty = True
|
|
794
|
+
|
|
795
|
+
# -- notes --
|
|
796
|
+
|
|
797
|
+
def add_note(self, text: str, *, author: str | None = None) -> Note:
|
|
798
|
+
"""Adds a Markdown note.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
text: The note body.
|
|
802
|
+
author: Defaults to the OS user name.
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
The new note.
|
|
806
|
+
"""
|
|
807
|
+
return self._new(Note, author, text=text)
|
|
808
|
+
|
|
809
|
+
def update_note(self, note_id: str, text: str) -> Note:
|
|
810
|
+
"""Replaces a note's text.
|
|
811
|
+
|
|
812
|
+
Raises:
|
|
813
|
+
KeyError: If there is no note with this id.
|
|
814
|
+
"""
|
|
815
|
+
for note in self.notes:
|
|
816
|
+
if note.id == note_id:
|
|
817
|
+
note.text = text
|
|
818
|
+
note.updated = self._touch()
|
|
819
|
+
return note
|
|
820
|
+
raise KeyError(note_id)
|
|
821
|
+
|
|
822
|
+
# -- ratings --
|
|
823
|
+
|
|
824
|
+
def rate(
|
|
825
|
+
self,
|
|
826
|
+
value: float,
|
|
827
|
+
*,
|
|
828
|
+
criterion: str = DEFAULT_CRITERION,
|
|
829
|
+
scale: RatingScale = "stars5",
|
|
830
|
+
rationale: str | None = None,
|
|
831
|
+
author: str | None = None,
|
|
832
|
+
) -> Rating:
|
|
833
|
+
"""Rates the run, replacing the author's rating for the criterion.
|
|
834
|
+
|
|
835
|
+
A replacement keeps the old ``id`` and ``created``.
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
value: A number on ``scale``: 1-5 for ``stars5``, 0-100 for
|
|
839
|
+
``score100``, -1 or 1 for ``thumb``.
|
|
840
|
+
criterion: What is rated.
|
|
841
|
+
scale: The scale of ``value``.
|
|
842
|
+
rationale: Optional explanation.
|
|
843
|
+
author: Defaults to the OS user name.
|
|
844
|
+
|
|
845
|
+
Returns:
|
|
846
|
+
The new or updated rating.
|
|
847
|
+
|
|
848
|
+
Raises:
|
|
849
|
+
ValueError: If the scale or value is invalid.
|
|
850
|
+
"""
|
|
851
|
+
if scale not in _SCALE_RANGES:
|
|
852
|
+
raise ValueError(f"unknown rating scale {scale!r}")
|
|
853
|
+
value = _finite("value", value)
|
|
854
|
+
lo, hi = _SCALE_RANGES[scale]
|
|
855
|
+
if (
|
|
856
|
+
not lo <= value <= hi
|
|
857
|
+
or (scale == "stars5" and value % 1)
|
|
858
|
+
or (scale == "thumb" and value not in (-1, 1))
|
|
859
|
+
):
|
|
860
|
+
raise ValueError(f"value {value!r} is invalid for scale {scale!r}")
|
|
861
|
+
num: float = int(value) if value == int(value) else value
|
|
862
|
+
who = author or default_author()
|
|
863
|
+
for rating in self.ratings:
|
|
864
|
+
if rating.author == who and rating.criterion == criterion:
|
|
865
|
+
rating.scale, rating.value = scale, num
|
|
866
|
+
rating.rationale = rationale
|
|
867
|
+
rating.updated = self._touch()
|
|
868
|
+
return rating
|
|
869
|
+
return self._new(
|
|
870
|
+
Rating,
|
|
871
|
+
who,
|
|
872
|
+
criterion=criterion,
|
|
873
|
+
scale=scale,
|
|
874
|
+
value=num,
|
|
875
|
+
rationale=rationale,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
# -- events --
|
|
879
|
+
|
|
880
|
+
def _event_times(self, t0: float, t1: float | None) -> tuple[float, float]:
|
|
881
|
+
"""Validates ``t0 <= t1`` and defaults ``t1`` to ``t0``."""
|
|
882
|
+
a = _finite("t0", t0)
|
|
883
|
+
b = a if t1 is None else _finite("t1", t1)
|
|
884
|
+
if a > b:
|
|
885
|
+
raise ValueError(f"event needs t0 <= t1, got {a} > {b}")
|
|
886
|
+
return a, b
|
|
887
|
+
|
|
888
|
+
def add_event(
|
|
889
|
+
self,
|
|
890
|
+
type: str = "", # noqa: A002
|
|
891
|
+
*,
|
|
892
|
+
t0: float,
|
|
893
|
+
t1: float | None = None,
|
|
894
|
+
label: str = "",
|
|
895
|
+
env: int | None = None,
|
|
896
|
+
props: dict[str, Any] | None = None,
|
|
897
|
+
author: str | None = None,
|
|
898
|
+
) -> Event:
|
|
899
|
+
"""Adds an event, snapping ``f0`` and ``f1`` to the nearest frames.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
type: Key into the event-type registry, or ``""``.
|
|
903
|
+
t0: Start in seconds.
|
|
904
|
+
t1: End in seconds; defaults to ``t0`` (an instant).
|
|
905
|
+
label: Short display string.
|
|
906
|
+
env: Env index, or ``None`` for all envs.
|
|
907
|
+
props: Typed property values.
|
|
908
|
+
author: Defaults to the OS user name.
|
|
909
|
+
|
|
910
|
+
Returns:
|
|
911
|
+
The new event.
|
|
912
|
+
|
|
913
|
+
Raises:
|
|
914
|
+
ValueError: If ``t0 > t1`` or a time is not finite.
|
|
915
|
+
"""
|
|
916
|
+
a, b = self._event_times(t0, t1)
|
|
917
|
+
return self._new(
|
|
918
|
+
Event,
|
|
919
|
+
author,
|
|
920
|
+
type=type,
|
|
921
|
+
label=label,
|
|
922
|
+
t0=a,
|
|
923
|
+
t1=b,
|
|
924
|
+
f0=self.snap(a),
|
|
925
|
+
f1=self.snap(b),
|
|
926
|
+
env=env,
|
|
927
|
+
props=dict(props or {}),
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
def update_event(self, event_id: str, **changes: Any) -> Event:
|
|
931
|
+
"""Changes fields of an event and re-snaps its frames.
|
|
932
|
+
|
|
933
|
+
Args:
|
|
934
|
+
event_id: The event's id.
|
|
935
|
+
**changes: Any of ``type``, ``label``, ``t0``, ``t1``, ``env``
|
|
936
|
+
and ``props``.
|
|
937
|
+
|
|
938
|
+
Returns:
|
|
939
|
+
The updated event.
|
|
940
|
+
|
|
941
|
+
Raises:
|
|
942
|
+
KeyError: If there is no event with this id.
|
|
943
|
+
ValueError: If a field is unknown or the times are invalid.
|
|
944
|
+
"""
|
|
945
|
+
allowed = {"type", "label", "t0", "t1", "env", "props"}
|
|
946
|
+
bad = set(changes) - allowed
|
|
947
|
+
if bad:
|
|
948
|
+
raise ValueError(f"cannot update event fields {sorted(bad)}")
|
|
949
|
+
for ev in self.events:
|
|
950
|
+
if ev.id != event_id:
|
|
951
|
+
continue
|
|
952
|
+
a, b = self._event_times(
|
|
953
|
+
changes.get("t0", ev.t0), changes.get("t1", ev.t1)
|
|
954
|
+
)
|
|
955
|
+
for name in ("type", "label", "env"):
|
|
956
|
+
if name in changes:
|
|
957
|
+
setattr(ev, name, changes[name])
|
|
958
|
+
if "props" in changes:
|
|
959
|
+
ev.props = dict(changes["props"])
|
|
960
|
+
ev.t0, ev.t1 = a, b
|
|
961
|
+
ev.f0, ev.f1 = self.snap(a), self.snap(b)
|
|
962
|
+
ev.updated = self._touch()
|
|
963
|
+
return ev
|
|
964
|
+
raise KeyError(event_id)
|
|
965
|
+
|
|
966
|
+
# -- spatial --
|
|
967
|
+
|
|
968
|
+
def add_spatial(
|
|
969
|
+
self,
|
|
970
|
+
kind: str,
|
|
971
|
+
position: tuple[float, float, float] | list[float],
|
|
972
|
+
*,
|
|
973
|
+
t: float,
|
|
974
|
+
env: int = 0,
|
|
975
|
+
frame: str = "world",
|
|
976
|
+
size: tuple[float, float, float] | list[float] | None = None,
|
|
977
|
+
label: str = "",
|
|
978
|
+
event: str | None = None,
|
|
979
|
+
author: str | None = None,
|
|
980
|
+
) -> Spatial:
|
|
981
|
+
"""Adds a 3D point or box, snapping ``f`` to the nearest frame.
|
|
982
|
+
|
|
983
|
+
Args:
|
|
984
|
+
kind: ``"point"`` or ``"box"``.
|
|
985
|
+
position: ``[x, y, z]`` in metres.
|
|
986
|
+
t: Time in seconds.
|
|
987
|
+
env: Env index.
|
|
988
|
+
frame: ``"world"`` or ``"body:<index>"``.
|
|
989
|
+
size: Half-extents of a box (required for ``"box"``).
|
|
990
|
+
label: Short display string.
|
|
991
|
+
event: Optional id of a linked event.
|
|
992
|
+
author: Defaults to the OS user name.
|
|
993
|
+
|
|
994
|
+
Returns:
|
|
995
|
+
The new annotation.
|
|
996
|
+
|
|
997
|
+
Raises:
|
|
998
|
+
ValueError: If an argument is invalid.
|
|
999
|
+
"""
|
|
1000
|
+
if kind not in SPATIAL_KINDS:
|
|
1001
|
+
raise ValueError(f"spatial kind must be one of {SPATIAL_KINDS}")
|
|
1002
|
+
if not _FRAME_RE.match(frame):
|
|
1003
|
+
raise ValueError(f"frame must be 'world' or 'body:<i>': {frame!r}")
|
|
1004
|
+
pos = [_finite("position", v) for v in position]
|
|
1005
|
+
if len(pos) != 3:
|
|
1006
|
+
raise ValueError("position needs 3 components")
|
|
1007
|
+
dims = None
|
|
1008
|
+
if kind == "box":
|
|
1009
|
+
if size is None:
|
|
1010
|
+
raise ValueError("a box needs a size")
|
|
1011
|
+
dims = [_finite("size", v) for v in size]
|
|
1012
|
+
if len(dims) != 3:
|
|
1013
|
+
raise ValueError("size needs 3 components")
|
|
1014
|
+
elif size is not None:
|
|
1015
|
+
raise ValueError("a point has no size")
|
|
1016
|
+
t = _finite("t", t)
|
|
1017
|
+
return self._new(
|
|
1018
|
+
Spatial,
|
|
1019
|
+
author,
|
|
1020
|
+
kind=kind,
|
|
1021
|
+
frame=frame,
|
|
1022
|
+
t=t,
|
|
1023
|
+
f=self.snap(t),
|
|
1024
|
+
env=int(env),
|
|
1025
|
+
position=pos,
|
|
1026
|
+
size=dims,
|
|
1027
|
+
label=label,
|
|
1028
|
+
event=event,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# ------------------------------------------------------------------ merge
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _newer(a: Record, b: Record) -> Record:
|
|
1036
|
+
"""Picks the record with the later ``updated`` (ties: larger JSON)."""
|
|
1037
|
+
ka = (a.updated, json.dumps(a.to_json(), sort_keys=True))
|
|
1038
|
+
kb = (b.updated, json.dumps(b.to_json(), sort_keys=True))
|
|
1039
|
+
return a if ka >= kb else b
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _merge_lists(
|
|
1043
|
+
a: list[Any], b: list[Any], base: list[Any] | None
|
|
1044
|
+
) -> list[Any]:
|
|
1045
|
+
"""Unions two record lists by id; newer ``updated`` wins on a clash.
|
|
1046
|
+
|
|
1047
|
+
With a ``base``, an id that was in the base but is absent from one side
|
|
1048
|
+
counts as deleted there and is dropped from the result.
|
|
1049
|
+
"""
|
|
1050
|
+
a_by, b_by = {r.id: r for r in a}, {r.id: r for r in b}
|
|
1051
|
+
dropped: set[str] = set()
|
|
1052
|
+
if base is not None:
|
|
1053
|
+
base_ids = {r.id for r in base}
|
|
1054
|
+
dropped = (base_ids - a_by.keys()) | (base_ids - b_by.keys())
|
|
1055
|
+
out = []
|
|
1056
|
+
for rid in sorted(a_by.keys() | b_by.keys()):
|
|
1057
|
+
if rid in dropped:
|
|
1058
|
+
continue
|
|
1059
|
+
if rid in a_by and rid in b_by:
|
|
1060
|
+
out.append(copy.deepcopy(_newer(a_by[rid], b_by[rid])))
|
|
1061
|
+
else:
|
|
1062
|
+
out.append(copy.deepcopy(a_by.get(rid) or b_by[rid]))
|
|
1063
|
+
return out
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _dedupe_ratings(ratings: list[Rating]) -> list[Rating]:
|
|
1067
|
+
"""Keeps one rating per (author, criterion): the most recently updated."""
|
|
1068
|
+
best: dict[tuple[str, str], Rating] = {}
|
|
1069
|
+
for r in ratings:
|
|
1070
|
+
key = (r.author, r.criterion)
|
|
1071
|
+
best[key] = cast(Rating, _newer(best[key], r)) if key in best else r
|
|
1072
|
+
return sorted(best.values(), key=lambda r: r.id)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _merge_marks(a: Marks, b: Marks, base: Marks | None) -> Marks:
|
|
1076
|
+
"""Merges marks; favorite is OR-ed and deprecated tags are unioned.
|
|
1077
|
+
|
|
1078
|
+
``group`` (like the deprecated ``flag`` and ``status``) takes the side
|
|
1079
|
+
that changed relative to ``base``; without a base (or if both changed)
|
|
1080
|
+
``b``'s value wins when it is set.
|
|
1081
|
+
"""
|
|
1082
|
+
out = copy.deepcopy(a)
|
|
1083
|
+
out.extra = {**b.extra, **a.extra}
|
|
1084
|
+
out.favorite = a.favorite or b.favorite
|
|
1085
|
+
tags = set(a.tags) | set(b.tags)
|
|
1086
|
+
if base is not None:
|
|
1087
|
+
tags -= set(base.tags) - (set(a.tags) & set(b.tags))
|
|
1088
|
+
out.tags = sorted(tags)
|
|
1089
|
+
for name in ("group", "flag", "status"):
|
|
1090
|
+
va, vb = getattr(a, name), getattr(b, name)
|
|
1091
|
+
if base is not None:
|
|
1092
|
+
v0 = getattr(base, name)
|
|
1093
|
+
chosen = va if vb == v0 else vb
|
|
1094
|
+
else:
|
|
1095
|
+
chosen = vb if vb is not None else va
|
|
1096
|
+
setattr(out, name, chosen)
|
|
1097
|
+
return out
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def merge(
|
|
1101
|
+
a: Annotations, b: Annotations, base: Annotations | None = None
|
|
1102
|
+
) -> Annotations:
|
|
1103
|
+
"""Merges two annotation sets that diverged from a common base (D8).
|
|
1104
|
+
|
|
1105
|
+
List entries merge by id: the union of both sides, and where the same id
|
|
1106
|
+
exists on both sides the one with the later ``updated`` wins. Ratings are
|
|
1107
|
+
then reduced to one per author and criterion. This is what a textual git
|
|
1108
|
+
merge of the two sorted files would do, without conflicts.
|
|
1109
|
+
|
|
1110
|
+
Args:
|
|
1111
|
+
a: One side. The result takes its path, run id and dt.
|
|
1112
|
+
b: The other side ("theirs"; wins mark conflicts).
|
|
1113
|
+
base: The common ancestor, if known. It lets the merge propagate
|
|
1114
|
+
deletions and tell which side changed a mark.
|
|
1115
|
+
|
|
1116
|
+
Returns:
|
|
1117
|
+
A new, dirty ``Annotations``. Neither input is modified.
|
|
1118
|
+
"""
|
|
1119
|
+
out = Annotations(a.path, a.run_id, a.dt, a.n_frames)
|
|
1120
|
+
out.marks = _merge_marks(a.marks, b.marks, base.marks if base else None)
|
|
1121
|
+
for key in _LIST_TYPES:
|
|
1122
|
+
merged = _merge_lists(
|
|
1123
|
+
getattr(a, key),
|
|
1124
|
+
getattr(b, key),
|
|
1125
|
+
getattr(base, key) if base is not None else None,
|
|
1126
|
+
)
|
|
1127
|
+
if key == "ratings":
|
|
1128
|
+
merged = _dedupe_ratings(merged)
|
|
1129
|
+
getattr(out, key)[:] = merged
|
|
1130
|
+
out.extra = {**copy.deepcopy(b.extra), **copy.deepcopy(a.extra)}
|
|
1131
|
+
out._dirty = True
|
|
1132
|
+
return out
|