Coverage for collectra/display.py: 98.65%
74 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1"""Artefact display protocol, independent of the desktop GUI.
3``display(context)`` returns a JSON-compatible dict with ``kind`` set to
4``image``, ``text``, or ``properties``. Image sources are PNG data URLs; image
5annotations use viewer-relative normalized coordinates. Text formats are
6``plain``, ``markdown``, or ``xml``. Custom classes may reuse these views.
7"""
9import base64
10import io
11from dataclasses import fields
12from pathlib import Path
14from collectra.utils import load_class_from_string
17class DisplayContext:
18 def __init__(self, records: dict, directory: Path, rgb_view: int = 0):
19 if type(rgb_view) is not int or rgb_view < 0:
20 raise ValueError("RGB view index must be a non-negative integer")
21 self.records = records
22 self.directory = Path(directory).resolve()
23 self.instances = {}
24 self.rgb_view = rgb_view
26 def path(self, value):
27 path = Path(value)
28 return path if path.is_absolute() else self.directory / path
30 def artefact(self, node_id):
31 from collectra.types.images import Image, ImageSegmentation
32 from collectra.types.links import Link
33 from collectra.types.texts import Text
35 if node_id in self.instances:
36 return self.instances[node_id]
37 record = dict(self.records[node_id])
38 cls = load_class_from_string(record.pop("type"))
39 record["name"] = record.pop("label", record.get("name", ""))
40 if isinstance(record.get("parents"), str):
41 record["parents"] = [record["parents"]]
42 if issubclass(cls, Image):
43 record["data"] = self.path(record.get("data", record.get("path", "")))
44 if issubclass(cls, ImageSegmentation):
45 record["mask"] = self.path(record.get("mask", ""))
46 elif issubclass(cls, Text):
47 source = self.text_path(node_id)
48 if source:
49 if "path" not in record:
50 record.pop("data", None)
51 record["path"] = source
52 accepted = {f.name for f in fields(cls) if f.init}
53 item = cls(**{k: v for k, v in record.items() if k in accepted})
54 self.instances[node_id] = item
55 if type(item) is Link:
56 item.target = self.artefact(item.parent_id)
57 return item
59 def text_path(self, node_id):
60 record = self.records[node_id]
61 value = record.get("path", record.get("data", ""))
62 if not isinstance(value, (str, Path)) or not value:
63 return None
64 try:
65 path = self.path(value)
66 return path if path.is_file() else None
67 except (OSError, ValueError):
68 return None
70 def text(self, item):
71 from collectra.displays.texts import text
73 return text(self, item)
75 @staticmethod
76 def publish_image(image):
77 buffer = io.BytesIO()
78 image.save(buffer, format="PNG")
79 return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
81 @staticmethod
82 def renderer(module_path):
83 """Publish a local JavaScript ES module supplied by an artefact package.
85 Return this URL as ``renderer`` in a display descriptor. The module
86 exports a default render function; see docs/artefacts/display.rst.
87 Use a path relative to the artefact's Python module, not results data.
88 """
89 source = Path(module_path).read_bytes()
90 return "data:text/javascript;base64," + base64.b64encode(source).decode()
92 def region(self, image, region, inverse=False):
93 from collectra.displays.images import region as transform_region
95 return transform_region(self, image, region, inverse=inverse)
97 def annotations(self, image):
98 from collectra.displays.images import annotations
100 return annotations(self, image)
102 def image(self, item):
103 from collectra.displays.images import image
105 return image(self, item)