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

1"""Artefact display protocol, independent of the desktop GUI. 

2 

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""" 

8 

9import base64 

10import io 

11from dataclasses import fields 

12from pathlib import Path 

13 

14from collectra.utils import load_class_from_string 

15 

16 

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 

25 

26 def path(self, value): 

27 path = Path(value) 

28 return path if path.is_absolute() else self.directory / path 

29 

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 

34 

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 

58 

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 

69 

70 def text(self, item): 

71 from collectra.displays.texts import text 

72 

73 return text(self, item) 

74 

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() 

80 

81 @staticmethod 

82 def renderer(module_path): 

83 """Publish a local JavaScript ES module supplied by an artefact package. 

84 

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() 

91 

92 def region(self, image, region, inverse=False): 

93 from collectra.displays.images import region as transform_region 

94 

95 return transform_region(self, image, region, inverse=inverse) 

96 

97 def annotations(self, image): 

98 from collectra.displays.images import annotations 

99 

100 return annotations(self, image) 

101 

102 def image(self, item): 

103 from collectra.displays.images import image 

104 

105 return image(self, item)