hudini 0.1.0__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.
- hudini/__init__.py +1 -0
- hudini/archive.py +414 -0
- hudini/assets/pods.json +266 -0
- hudini/assets/sensitive_popups.json +9 -0
- hudini/catalog.py +294 -0
- hudini/cli.py +689 -0
- hudini/cnn.py +310 -0
- hudini/corrections.py +421 -0
- hudini/detection.py +185 -0
- hudini/hsv.py +190 -0
- hudini/layout.py +233 -0
- hudini/ocr.py +164 -0
- hudini/parser.py +354 -0
- hudini/privacy.py +336 -0
- hudini/schema.py +493 -0
- hudini/sensors.py +949 -0
- hudini/session.py +274 -0
- hudini/storage.py +338 -0
- hudini/video.py +116 -0
- hudini/views.py +946 -0
- hudini/web/__init__.py +1 -0
- hudini/web/render.py +56 -0
- hudini/web/server.py +244 -0
- hudini/web/static/app.css +373 -0
- hudini/web/static/app.html +19 -0
- hudini/web/static/app.js +33 -0
- hudini/web/static/data.js +67 -0
- hudini/web/static/favicon.svg +37 -0
- hudini/web/static/shared.js +179 -0
- hudini/web/static/view_index.js +471 -0
- hudini/web/static/view_timeline.js +1086 -0
- hudini-0.1.0.dist-info/METADATA +293 -0
- hudini-0.1.0.dist-info/RECORD +36 -0
- hudini-0.1.0.dist-info/WHEEL +4 -0
- hudini-0.1.0.dist-info/entry_points.txt +2 -0
- hudini-0.1.0.dist-info/licenses/LICENSE +202 -0
hudini/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reads da Vinci Xi system state from the heads-up display in surgical video."""
|
hudini/archive.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Reads one directory tree of logs for the web viewer.
|
|
2
|
+
|
|
3
|
+
:class:`Archive` keeps an inventory of the ``.hudini.jsonl.gz`` logs under
|
|
4
|
+
a directory. It answers what the index page asks: which videos exist,
|
|
5
|
+
their summaries, and which entries match a query. A scan reads only the
|
|
6
|
+
first and last lines of each file. This module does not import torch or
|
|
7
|
+
paddleocr.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import gzip
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import zlib
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import msgspec
|
|
20
|
+
from rapidfuzz import fuzz
|
|
21
|
+
|
|
22
|
+
from hudini.catalog import Catalog
|
|
23
|
+
from hudini.corrections import correct, rules_from_settings
|
|
24
|
+
from hudini.schema import (
|
|
25
|
+
APPENDIX_MARK,
|
|
26
|
+
FOOTER_MARK,
|
|
27
|
+
Engine,
|
|
28
|
+
Footer,
|
|
29
|
+
Header,
|
|
30
|
+
decode_appendix,
|
|
31
|
+
decode_footer,
|
|
32
|
+
decode_header,
|
|
33
|
+
)
|
|
34
|
+
from hudini.storage import load, tail_lengths
|
|
35
|
+
from hudini.views import Interval, Summary, interval_view, intervals_from_appendix
|
|
36
|
+
|
|
37
|
+
LOG_SUFFIX = ".hudini.jsonl.gz"
|
|
38
|
+
PARTIAL_LOG_SUFFIX = ".hudini.jsonl.partial"
|
|
39
|
+
VIDEO_SUFFIXES = (".mp4", ".mkv", ".mov", ".avi")
|
|
40
|
+
FUZZY_MATCH_THRESHOLD = 80.0
|
|
41
|
+
TAIL_WINDOW_BYTES = 2**20
|
|
42
|
+
HEAD_PROBE_BYTES = 2**16
|
|
43
|
+
SCAN_WORKERS = 8
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EntryState(StrEnum):
|
|
47
|
+
"""What the archive knows about one log."""
|
|
48
|
+
|
|
49
|
+
COMPLETE = "complete"
|
|
50
|
+
RUNNING = "running"
|
|
51
|
+
OUTDATED = "outdated"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SearchField(StrEnum):
|
|
55
|
+
"""The corpus fields a search token can be narrowed to.
|
|
56
|
+
|
|
57
|
+
``TEXT`` is the union of the popup and banner fields, for queries
|
|
58
|
+
that do not care which kind of message carried a string.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
NAME = "name"
|
|
62
|
+
INSTRUMENT = "instrument"
|
|
63
|
+
ACTION = "action"
|
|
64
|
+
POPUP = "popup"
|
|
65
|
+
BANNER = "banner"
|
|
66
|
+
TEXT = "text"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ArchiveEntry:
|
|
71
|
+
"""One log in the archive, as the index page shows it.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
name: the log's path inside the archive without the log suffix,
|
|
75
|
+
in POSIX form. This is the archive-wide identity.
|
|
76
|
+
state: COMPLETE for a sealed log with a current summary,
|
|
77
|
+
RUNNING for a partial one, OUTDATED for a sealed log whose
|
|
78
|
+
summary version is not current.
|
|
79
|
+
summary: the typed summary for a COMPLETE entry, else None.
|
|
80
|
+
progress: parsed fraction 0-1 for a RUNNING entry with a known
|
|
81
|
+
video duration, else None.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
path: Path
|
|
86
|
+
state: EntryState
|
|
87
|
+
header: Header
|
|
88
|
+
footer: Footer | None = None
|
|
89
|
+
summary: Summary | None = None
|
|
90
|
+
progress: float | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def log_locale(header: Header) -> str:
|
|
94
|
+
"""The catalog locale a log was parsed with. ``en`` when none is
|
|
95
|
+
recorded."""
|
|
96
|
+
catalog_engine = header.engines.get(Engine.CATALOG)
|
|
97
|
+
if catalog_engine is None or catalog_engine.locale is None:
|
|
98
|
+
return "en"
|
|
99
|
+
return catalog_engine.locale
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _head_line(head: bytes) -> bytes | None:
|
|
103
|
+
"""The first decompressed line inside a gzip head probe, or None
|
|
104
|
+
when the probe did not reach a newline."""
|
|
105
|
+
decompressor = zlib.decompressobj(wbits=zlib.MAX_WBITS | 16)
|
|
106
|
+
text = decompressor.decompress(head)
|
|
107
|
+
newline = text.find(b"\n")
|
|
108
|
+
if newline < 0:
|
|
109
|
+
return None
|
|
110
|
+
return text[:newline]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _edge_lines(path: Path) -> tuple[bytes, bytes, bytes]:
|
|
114
|
+
"""Line 1 and the last two non-empty lines, no body parsing.
|
|
115
|
+
|
|
116
|
+
A ``.gz`` file with a tail pointer costs three small reads: the
|
|
117
|
+
head probe, then one seek each for the footer and appendix
|
|
118
|
+
members. A ``.gz`` file without one is one streaming decompression
|
|
119
|
+
pass. A plain path, the ``.partial`` form, reads only the head and
|
|
120
|
+
a tail window. A missing line is empty bytes."""
|
|
121
|
+
if path.suffix == ".gz":
|
|
122
|
+
with path.open("rb") as file:
|
|
123
|
+
head = file.read(HEAD_PROBE_BYTES)
|
|
124
|
+
lengths = tail_lengths(head)
|
|
125
|
+
first = _head_line(head) if lengths is not None else None
|
|
126
|
+
if lengths is not None and first is not None:
|
|
127
|
+
appendix_length, footer_length = lengths
|
|
128
|
+
size = file.seek(0, os.SEEK_END)
|
|
129
|
+
file.seek(size - footer_length)
|
|
130
|
+
last = gzip.decompress(file.read(footer_length)).strip()
|
|
131
|
+
second_last = b""
|
|
132
|
+
if appendix_length:
|
|
133
|
+
file.seek(size - footer_length - appendix_length)
|
|
134
|
+
second_last = gzip.decompress(file.read(appendix_length)).strip()
|
|
135
|
+
return first, second_last, last
|
|
136
|
+
first = b""
|
|
137
|
+
second_last = b""
|
|
138
|
+
last = b""
|
|
139
|
+
with gzip.open(path, "rb") as file:
|
|
140
|
+
for line in file:
|
|
141
|
+
if not line.strip():
|
|
142
|
+
continue
|
|
143
|
+
if not first:
|
|
144
|
+
first = line
|
|
145
|
+
second_last = last
|
|
146
|
+
last = line
|
|
147
|
+
return first, second_last, last
|
|
148
|
+
with path.open("rb") as file:
|
|
149
|
+
first = file.readline()
|
|
150
|
+
file.seek(0, os.SEEK_END)
|
|
151
|
+
size = file.tell()
|
|
152
|
+
window = min(size, TAIL_WINDOW_BYTES)
|
|
153
|
+
file.seek(size - window)
|
|
154
|
+
chunk = file.read(window)
|
|
155
|
+
lines = [line for line in chunk.splitlines() if line.strip()]
|
|
156
|
+
if window < size and lines:
|
|
157
|
+
lines = lines[1:]
|
|
158
|
+
last = lines[-1] if lines else b""
|
|
159
|
+
second_last = lines[-2] if len(lines) >= 2 else b""
|
|
160
|
+
if not first.strip():
|
|
161
|
+
first = b""
|
|
162
|
+
return first, second_last, last
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _maybe_footer(line: bytes) -> Footer | None:
|
|
166
|
+
entry = msgspec.json.decode(line)
|
|
167
|
+
if isinstance(entry, dict) and entry.get("hudini") == FOOTER_MARK:
|
|
168
|
+
return decode_footer(line)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _cached_intervals(second_last: bytes) -> list[Interval] | None:
|
|
173
|
+
"""The appendix intervals from the second-to-last line, or None
|
|
174
|
+
when the line is not an appendix or its version is stale."""
|
|
175
|
+
if not second_last:
|
|
176
|
+
return None
|
|
177
|
+
entry = msgspec.json.decode(second_last)
|
|
178
|
+
if not (isinstance(entry, dict) and entry.get("hudini") == APPENDIX_MARK):
|
|
179
|
+
return None
|
|
180
|
+
return intervals_from_appendix(decode_appendix(second_last))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _maybe_time_s(line: bytes) -> float | None:
|
|
184
|
+
"""The ``t`` of an observation line, or None when the line is not
|
|
185
|
+
one. The live tail of a partial file can end mid-write."""
|
|
186
|
+
try:
|
|
187
|
+
entry = msgspec.json.decode(line)
|
|
188
|
+
except msgspec.DecodeError:
|
|
189
|
+
return None
|
|
190
|
+
if isinstance(entry, dict) and isinstance(entry.get("t"), (int, float)):
|
|
191
|
+
return float(entry["t"])
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def tokenize(query: str) -> list[tuple[SearchField | None, str]]:
|
|
196
|
+
"""Query tokens as (field, text). A quoted phrase is one token, and
|
|
197
|
+
a ``field:`` prefix narrows a token to one corpus field."""
|
|
198
|
+
tokens: list[tuple[SearchField | None, str]] = []
|
|
199
|
+
fields = {member.value: member for member in SearchField}
|
|
200
|
+
rest = query.strip()
|
|
201
|
+
while rest:
|
|
202
|
+
field = None
|
|
203
|
+
prefix, colon, after = rest.partition(":")
|
|
204
|
+
if colon and prefix.casefold() in fields and " " not in prefix:
|
|
205
|
+
field = fields[prefix.casefold()]
|
|
206
|
+
rest = after.lstrip()
|
|
207
|
+
if rest.startswith('"'):
|
|
208
|
+
text, _quote, rest = rest[1:].partition('"')
|
|
209
|
+
else:
|
|
210
|
+
text, _space, rest = rest.partition(" ")
|
|
211
|
+
rest = rest.lstrip()
|
|
212
|
+
if text:
|
|
213
|
+
tokens.append((field, text))
|
|
214
|
+
return tokens
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Archive:
|
|
218
|
+
"""A reader over one directory tree of logs, with an inventory.
|
|
219
|
+
|
|
220
|
+
Discovery is explicit: :meth:`scan` walks the tree and rebuilds
|
|
221
|
+
the inventory, and :meth:`entries` and :meth:`search` read the
|
|
222
|
+
inventory without touching the filesystem. Per-file reads are
|
|
223
|
+
memoized across scans, keyed by name, mtime, and size. A changed
|
|
224
|
+
file is read again, an unchanged one is not.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def __init__(self, directory: Path | str) -> None:
|
|
228
|
+
self.directory = Path(directory).expanduser().resolve()
|
|
229
|
+
self._memo: dict[str, tuple[tuple[float, int], ArchiveEntry]] = {}
|
|
230
|
+
self._inventory: list[ArchiveEntry] = []
|
|
231
|
+
self._scan_lock = threading.Lock()
|
|
232
|
+
self._intervals: dict[str, tuple[tuple[float, int], list[Interval]]] = {}
|
|
233
|
+
self._catalogs: dict[str, Catalog] = {}
|
|
234
|
+
|
|
235
|
+
def scan(self) -> list[ArchiveEntry]:
|
|
236
|
+
"""Walk the tree and rebuild the inventory.
|
|
237
|
+
|
|
238
|
+
Finds every log under the directory, nested included, and reads
|
|
239
|
+
the edges of new and changed files on a thread pool. One scan
|
|
240
|
+
runs at a time: a call that arrives while another scan runs
|
|
241
|
+
waits for that scan and returns its inventory.
|
|
242
|
+
"""
|
|
243
|
+
if not self._scan_lock.acquire(blocking=False):
|
|
244
|
+
with self._scan_lock:
|
|
245
|
+
return self._inventory
|
|
246
|
+
try:
|
|
247
|
+
self._inventory = self._walk()
|
|
248
|
+
finally:
|
|
249
|
+
self._scan_lock.release()
|
|
250
|
+
return self._inventory
|
|
251
|
+
|
|
252
|
+
def _walk(self) -> list[ArchiveEntry]:
|
|
253
|
+
found: list[ArchiveEntry] = []
|
|
254
|
+
unread: list[tuple[Path, str, tuple[float, int]]] = []
|
|
255
|
+
for parent, _subdirectories, files in os.walk(self.directory):
|
|
256
|
+
for file_name in files:
|
|
257
|
+
if not file_name.endswith((LOG_SUFFIX, PARTIAL_LOG_SUFFIX)):
|
|
258
|
+
continue
|
|
259
|
+
path = Path(parent) / file_name
|
|
260
|
+
relative = path.relative_to(self.directory).as_posix()
|
|
261
|
+
stat = path.stat()
|
|
262
|
+
key = (stat.st_mtime, stat.st_size)
|
|
263
|
+
memoized = self._memo.get(relative)
|
|
264
|
+
if memoized is not None and memoized[0] == key:
|
|
265
|
+
found.append(memoized[1])
|
|
266
|
+
continue
|
|
267
|
+
unread.append((path, relative, key))
|
|
268
|
+
if unread:
|
|
269
|
+
with ThreadPoolExecutor(max_workers=SCAN_WORKERS) as pool:
|
|
270
|
+
read = pool.map(lambda task: self._read_entry(task[0], task[1]), unread)
|
|
271
|
+
for (_path, relative, key), entry in zip(unread, read, strict=True):
|
|
272
|
+
self._memo[relative] = (key, entry)
|
|
273
|
+
found.append(entry)
|
|
274
|
+
found.sort(key=lambda entry: entry.name)
|
|
275
|
+
return found
|
|
276
|
+
|
|
277
|
+
def entries(self) -> list[ArchiveEntry]:
|
|
278
|
+
"""The inventory of the last scan, sorted by name.
|
|
279
|
+
|
|
280
|
+
Empty before the first :meth:`scan`. Reads no file.
|
|
281
|
+
"""
|
|
282
|
+
return self._inventory
|
|
283
|
+
|
|
284
|
+
def search(self, query: str) -> list[ArchiveEntry]:
|
|
285
|
+
"""The inventory entries whose corpus matches every token of
|
|
286
|
+
``query``.
|
|
287
|
+
|
|
288
|
+
A token matches by substring first, then by fuzzy ratio. Entries
|
|
289
|
+
without a summary match on the file name only. An empty query
|
|
290
|
+
matches everything. Reads no file.
|
|
291
|
+
"""
|
|
292
|
+
tokens = tokenize(query)
|
|
293
|
+
return [
|
|
294
|
+
entry
|
|
295
|
+
for entry in self._inventory
|
|
296
|
+
if all(self._matches(entry, field, text) for field, text in tokens)
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
def intervals(self, name: str) -> list[Interval]:
|
|
300
|
+
"""One video's intervals, with the log's own corrections applied.
|
|
301
|
+
|
|
302
|
+
A log with a current appendix answers from its cached
|
|
303
|
+
intervals, one tail read, no derivation. A log without one, or
|
|
304
|
+
with a stale ``intervals_version``, derives live from the
|
|
305
|
+
body. Results are memoized by name, mtime, and size.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
FileNotFoundError: no finished log named ``name``.
|
|
309
|
+
"""
|
|
310
|
+
path = self.directory / f"{name}{LOG_SUFFIX}"
|
|
311
|
+
stat = path.stat()
|
|
312
|
+
key = (stat.st_mtime, stat.st_size)
|
|
313
|
+
memoized = self._intervals.get(name)
|
|
314
|
+
if memoized is not None and memoized[0] == key:
|
|
315
|
+
return memoized[1]
|
|
316
|
+
_first, second_last, _last = _edge_lines(path)
|
|
317
|
+
intervals = _cached_intervals(second_last)
|
|
318
|
+
if intervals is None:
|
|
319
|
+
log = load(path)
|
|
320
|
+
catalog = self._catalog(log_locale(log.header))
|
|
321
|
+
patches = correct(log, rules_from_settings(log.header.corrections), catalog)
|
|
322
|
+
intervals = interval_view(log, patches, catalog)
|
|
323
|
+
self._intervals[name] = (key, intervals)
|
|
324
|
+
return intervals
|
|
325
|
+
|
|
326
|
+
def video_path(self, name: str) -> Path | None:
|
|
327
|
+
"""The video file for ``name``, next to its log, or None.
|
|
328
|
+
|
|
329
|
+
Looks for the filename the log's header records, then for the
|
|
330
|
+
log's own basename with a common video suffix.
|
|
331
|
+
"""
|
|
332
|
+
memoized = self._memo.get(f"{name}{LOG_SUFFIX}") or self._memo.get(
|
|
333
|
+
f"{name}{PARTIAL_LOG_SUFFIX}"
|
|
334
|
+
)
|
|
335
|
+
candidates = []
|
|
336
|
+
if memoized is not None:
|
|
337
|
+
candidates.append((self.directory / name).parent / memoized[1].header.video.filename)
|
|
338
|
+
candidates.extend(self.directory / f"{name}{suffix}" for suffix in VIDEO_SUFFIXES)
|
|
339
|
+
for candidate in candidates:
|
|
340
|
+
if candidate.is_file():
|
|
341
|
+
return candidate
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def _catalog(self, locale: str) -> Catalog:
|
|
345
|
+
if locale not in self._catalogs:
|
|
346
|
+
self._catalogs[locale] = Catalog.load(locale=locale)
|
|
347
|
+
return self._catalogs[locale]
|
|
348
|
+
|
|
349
|
+
def _read_entry(self, path: Path, relative: str) -> ArchiveEntry:
|
|
350
|
+
first, _second_last, last = _edge_lines(path)
|
|
351
|
+
header = decode_header(first)
|
|
352
|
+
name = relative.removesuffix(LOG_SUFFIX).removesuffix(PARTIAL_LOG_SUFFIX)
|
|
353
|
+
footer = None if path.name.endswith(PARTIAL_LOG_SUFFIX) else _maybe_footer(last)
|
|
354
|
+
if footer is None:
|
|
355
|
+
return ArchiveEntry(
|
|
356
|
+
name=name,
|
|
357
|
+
path=path,
|
|
358
|
+
state=EntryState.RUNNING,
|
|
359
|
+
header=header,
|
|
360
|
+
progress=self._progress(header, last),
|
|
361
|
+
)
|
|
362
|
+
summary = Summary.from_footer(footer)
|
|
363
|
+
if summary is None:
|
|
364
|
+
return ArchiveEntry(
|
|
365
|
+
name=name, path=path, state=EntryState.OUTDATED, header=header, footer=footer
|
|
366
|
+
)
|
|
367
|
+
return ArchiveEntry(
|
|
368
|
+
name=name,
|
|
369
|
+
path=path,
|
|
370
|
+
state=EntryState.COMPLETE,
|
|
371
|
+
header=header,
|
|
372
|
+
footer=footer,
|
|
373
|
+
summary=summary,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _progress(self, header: Header, last: bytes) -> float | None:
|
|
377
|
+
duration = header.video.duration_s
|
|
378
|
+
time_s = _maybe_time_s(last)
|
|
379
|
+
if duration is None or duration <= 0 or time_s is None:
|
|
380
|
+
return None
|
|
381
|
+
return min(1.0, round(time_s / duration, 4))
|
|
382
|
+
|
|
383
|
+
def _matches(self, entry: ArchiveEntry, field: SearchField | None, text: str) -> bool:
|
|
384
|
+
corpus = self._corpus(entry)
|
|
385
|
+
if field is None:
|
|
386
|
+
values = [value for values in corpus.values() for value in values]
|
|
387
|
+
elif field is SearchField.TEXT:
|
|
388
|
+
values = [*corpus[SearchField.POPUP], *corpus[SearchField.BANNER]]
|
|
389
|
+
else:
|
|
390
|
+
values = corpus[field]
|
|
391
|
+
needle = text.casefold()
|
|
392
|
+
if any(needle in value.casefold() for value in values):
|
|
393
|
+
return True
|
|
394
|
+
return any(
|
|
395
|
+
fuzz.partial_ratio(needle, value.casefold()) >= FUZZY_MATCH_THRESHOLD
|
|
396
|
+
for value in values
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _corpus(self, entry: ArchiveEntry) -> dict[SearchField, list[str]]:
|
|
400
|
+
corpus: dict[SearchField, list[str]] = {
|
|
401
|
+
SearchField.NAME: [entry.name],
|
|
402
|
+
SearchField.INSTRUMENT: [],
|
|
403
|
+
SearchField.ACTION: [],
|
|
404
|
+
SearchField.POPUP: [],
|
|
405
|
+
SearchField.BANNER: [],
|
|
406
|
+
}
|
|
407
|
+
if entry.summary is None:
|
|
408
|
+
return corpus
|
|
409
|
+
for use in entry.summary.instruments:
|
|
410
|
+
corpus[SearchField.INSTRUMENT].extend((use.name, use.type))
|
|
411
|
+
corpus[SearchField.ACTION].extend(entry.summary.presses_by_action)
|
|
412
|
+
corpus[SearchField.POPUP].extend(entry.summary.popup_texts)
|
|
413
|
+
corpus[SearchField.BANNER].extend(entry.summary.banner_texts)
|
|
414
|
+
return corpus
|
hudini/assets/pods.json
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_description": "Da Vinci Xi pod strings — instrument names and system messages as they appear in the surgical UI. Stored lowercase; UI rendering should apply .title() (or similar) at display boundaries. Loading expands each entry into its aliases — the string forms it can be found under (canonical name, localized name, reload-color variants). Aliases are derived at load time and are not stored here.",
|
|
3
|
+
"_schema": {
|
|
4
|
+
"entries[i].name": "Canonical English UI name (required). Lowercase; how the instrument appears in the da Vinci surgeon-console UI.",
|
|
5
|
+
"entries[i].type": "Required. Taxonomy of the pod string (snake_case). For instruments it is the clinical/functional class — e.g. bipolar_grasper, needle_driver, stapler ('what does this tool do?'); non-instrument pod strings use system_message.",
|
|
6
|
+
"entries[i].localized": "Optional. {locale: name} for entries whose UI display is locale-specific (e.g. system messages rendered in German).",
|
|
7
|
+
"entries[i].reload_colors": "Optional. List of English color codes; expanded to 'name [color_locales[locale][c]]' at load time. Only present on stapler entries that have reload variants.",
|
|
8
|
+
"entries[i].pedals": "Optional. Per-pedal labels seen in the UI for this instrument, stored lowercase like other catalog strings. Shape: {yellow: [labels...], blue: [labels...]}. Each list is the labels that can appear on that pedal across instrument states, ORDERED WITH THE IDLE LABEL FIRST — what the pod shows while the pedal is not pressed. Multiple labels indicate state-dependent behavior (e.g. force bipolar yellow reads 'grip' idle and 'strong' held), and the reader only OCRs the label while a pedal is pressed, taking the idle one from here. An empty list means that pedal has no action for this instrument (e.g. maryland bipolar has no yellow pedal). Omitting the whole `pedals` field means unknown — do not infer from type, since some mechanical instruments may still have pedal actions."
|
|
9
|
+
},
|
|
10
|
+
"color_locales": {
|
|
11
|
+
"de": {
|
|
12
|
+
"gray": "grau",
|
|
13
|
+
"white": "weiss",
|
|
14
|
+
"blue": "blau",
|
|
15
|
+
"green": "grün",
|
|
16
|
+
"black": "schwarz"
|
|
17
|
+
},
|
|
18
|
+
"en": {
|
|
19
|
+
"gray": "gray",
|
|
20
|
+
"white": "white",
|
|
21
|
+
"blue": "blue",
|
|
22
|
+
"green": "green",
|
|
23
|
+
"black": "black"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"entries": [
|
|
27
|
+
{
|
|
28
|
+
"name": "maryland bipolar forceps",
|
|
29
|
+
"type": "bipolar_grasper",
|
|
30
|
+
"pedals": { "yellow": [], "blue": ["bipolar"] }
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "curved bipolar dissector",
|
|
34
|
+
"type": "bipolar_grasper",
|
|
35
|
+
"pedals": { "yellow": [], "blue": ["coag"] }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "long bipolar grasper",
|
|
39
|
+
"type": "bipolar_grasper",
|
|
40
|
+
"pedals": { "yellow": [], "blue": ["coag"] }
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "force bipolar",
|
|
44
|
+
"type": "bipolar_grasper",
|
|
45
|
+
"pedals": { "yellow": ["grip", "strong"], "blue": ["coag"] }
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "micro bipolar forceps",
|
|
49
|
+
"type": "bipolar_grasper",
|
|
50
|
+
"pedals": { "yellow": [], "blue": ["coag"] }
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "fenestrated bipolar forceps",
|
|
54
|
+
"type": "bipolar_grasper",
|
|
55
|
+
"pedals": { "yellow": [], "blue": ["coag"] }
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
"name": "medium-large clip applier",
|
|
60
|
+
"type": "clip_applier",
|
|
61
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "large clip applier",
|
|
65
|
+
"type": "clip_applier",
|
|
66
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "small clip applier",
|
|
70
|
+
"type": "clip_applier",
|
|
71
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
"name": "potts scissors",
|
|
76
|
+
"type": "cold_scissors",
|
|
77
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
78
|
+
},
|
|
79
|
+
{ "name": "round tip scissors", "type": "cold_scissors" },
|
|
80
|
+
{ "name": "curved scissors", "type": "cold_scissors" },
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
"name": "prograsp forceps",
|
|
84
|
+
"type": "grasper",
|
|
85
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
86
|
+
},
|
|
87
|
+
{ "name": "long tip forceps", "type": "grasper" },
|
|
88
|
+
{
|
|
89
|
+
"name": "small grasping retractor",
|
|
90
|
+
"type": "grasper",
|
|
91
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
92
|
+
},
|
|
93
|
+
{ "name": "cobra grasper", "type": "grasper" },
|
|
94
|
+
{
|
|
95
|
+
"name": "crocodile grasper",
|
|
96
|
+
"type": "grasper",
|
|
97
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "tip-up fenestrated grasper",
|
|
101
|
+
"type": "grasper",
|
|
102
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "cadiere forceps",
|
|
106
|
+
"type": "grasper",
|
|
107
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "tenaculum forceps",
|
|
111
|
+
"type": "grasper",
|
|
112
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
113
|
+
},
|
|
114
|
+
{ "name": "fundus grasper", "type": "grasper" },
|
|
115
|
+
{ "name": "maryland dissector", "type": "grasper" },
|
|
116
|
+
{
|
|
117
|
+
"name": "resano forceps",
|
|
118
|
+
"type": "grasper",
|
|
119
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
120
|
+
},
|
|
121
|
+
{ "name": "dual blade retractor", "type": "grasper" },
|
|
122
|
+
{ "name": "cardiac probe grasper", "type": "grasper" },
|
|
123
|
+
{ "name": "atrial retractor short right", "type": "grasper" },
|
|
124
|
+
{
|
|
125
|
+
"name": "black diamond micro forceps",
|
|
126
|
+
"type": "grasper",
|
|
127
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "debakey forceps",
|
|
131
|
+
"type": "grasper",
|
|
132
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
"name": "monopolar curved scissors",
|
|
137
|
+
"type": "monopolar_cautery",
|
|
138
|
+
"pedals": { "yellow": ["cut"], "blue": ["coag"] }
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "permanent cautery spatula",
|
|
142
|
+
"type": "monopolar_cautery",
|
|
143
|
+
"pedals": { "yellow": ["cut"], "blue": ["coag"] }
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"name": "permanent cautery hook",
|
|
147
|
+
"type": "monopolar_cautery",
|
|
148
|
+
"pedals": { "yellow": ["cut"], "blue": ["coag"] }
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
"name": "large needle driver",
|
|
153
|
+
"type": "needle_driver",
|
|
154
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"name": "mega needle driver",
|
|
158
|
+
"type": "needle_driver",
|
|
159
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"name": "large suturecut needle driver",
|
|
163
|
+
"type": "needle_driver",
|
|
164
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"name": "mega suturecut needle driver",
|
|
168
|
+
"type": "needle_driver",
|
|
169
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
170
|
+
},
|
|
171
|
+
{ "name": "wristed needle driver", "type": "needle_driver" },
|
|
172
|
+
{
|
|
173
|
+
"name": "curved needle driver",
|
|
174
|
+
"type": "needle_driver",
|
|
175
|
+
"pedals": { "yellow": [], "blue": [] }
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
"name": "sureform 60",
|
|
180
|
+
"type": "stapler",
|
|
181
|
+
"reload_colors": ["white", "blue", "green", "black"],
|
|
182
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"name": "sureform 45",
|
|
186
|
+
"type": "stapler",
|
|
187
|
+
"reload_colors": ["gray", "white", "blue", "green", "black"],
|
|
188
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"name": "sureform 45 curved-tip",
|
|
192
|
+
"type": "stapler",
|
|
193
|
+
"reload_colors": ["gray", "white", "blue", "green", "black"],
|
|
194
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"name": "sureform 30 curved-tip",
|
|
198
|
+
"type": "stapler",
|
|
199
|
+
"reload_colors": ["gray", "white", "blue"],
|
|
200
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"name": "stapler 30",
|
|
204
|
+
"type": "stapler",
|
|
205
|
+
"reload_colors": ["gray", "white", "blue", "green"],
|
|
206
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"name": "stapler 30 curved-tip",
|
|
210
|
+
"type": "stapler",
|
|
211
|
+
"reload_colors": ["gray", "white", "blue", "green"],
|
|
212
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"name": "stapler 45",
|
|
216
|
+
"type": "stapler",
|
|
217
|
+
"reload_colors": ["white", "blue", "green"],
|
|
218
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "stapler 45 curved-tip",
|
|
222
|
+
"type": "stapler",
|
|
223
|
+
"reload_colors": ["white", "blue", "green"],
|
|
224
|
+
"pedals": { "yellow": ["fire"], "blue": ["clamp"] }
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
{
|
|
228
|
+
"name": "suction irrigator",
|
|
229
|
+
"type": "suction_irrigator",
|
|
230
|
+
"pedals": { "yellow": ["irrigate"], "blue": ["suction"] }
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
"name": "remove instrument",
|
|
235
|
+
"type": "system_message",
|
|
236
|
+
"localized": { "de": "instrument entfernen" }
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"name": "arm stowed",
|
|
240
|
+
"type": "system_message",
|
|
241
|
+
"localized": { "de": "arm in stau-position" }
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
{
|
|
245
|
+
"name": "harmonic ace",
|
|
246
|
+
"type": "ultrasonic_shears",
|
|
247
|
+
"pedals": { "yellow": ["min"], "blue": ["max"] }
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
"name": "vessel sealer",
|
|
252
|
+
"type": "vessel_sealer",
|
|
253
|
+
"pedals": { "yellow": ["cut"], "blue": ["bipolar", "seal"] }
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"name": "vessel sealer extend",
|
|
257
|
+
"type": "vessel_sealer",
|
|
258
|
+
"pedals": { "yellow": ["cut"], "blue": ["seal"] }
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"name": "synchroseal",
|
|
262
|
+
"type": "vessel_sealer",
|
|
263
|
+
"pedals": { "yellow": ["sync"], "blue": ["seal", "coag"] }
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
}
|