Coverage for collectra/displays/images.py: 90.52%

116 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 09:56 +0000

1"""Image previews, crop coordinates and child image annotations.""" 

2 

3from collections import OrderedDict 

4from collectra.utils import load_class_from_string 

5from .image_segmentation import display_bounds 

6 

7# Share the few most recent encodings across contexts and panels. 

8_PREVIEW_CACHE_SIZE = 6 

9_preview_cache = OrderedDict() 

10 

11 

12def region(context, image, region, inverse=False): 

13 """Transform source-normalized boxes to/from the rotated crop viewer.""" 

14 left, top, right, bottom = display_bounds(image) 

15 width, height = right - left, bottom - top 

16 if width <= 0 or height <= 0: 

17 raise ValueError("Cannot display an empty image crop") 

18 x, y = region["x_center"], region["y_center"] 

19 w, h = region["width_relative"], region["height_relative"] 

20 turns = (image.orientation.to_degree() // 90) % 4 

21 if inverse: 

22 for _ in range((-turns) % 4): 

23 x, y, w, h = y, 1 - x, h, w 

24 x, y = (left + x * width) / image.raw_width, ( 

25 top + y * height 

26 ) / image.raw_height 

27 w, h = w * width / image.raw_width, h * height / image.raw_height 

28 else: 

29 x, y = (x * image.raw_width - left) / width, ( 

30 y * image.raw_height - top 

31 ) / height 

32 w, h = w * image.raw_width / width, h * image.raw_height / height 

33 for _ in range(turns): 

34 x, y, w, h = y, 1 - x, h, w 

35 return dict(x_center=x, y_center=y, width_relative=w, height_relative=h) 

36 

37 

38def annotations(context, image): 

39 from collectra.types.images import ImageCrop, ImageSegmentation 

40 

41 rows = [] 

42 for node_id, record in context.records.items(): 

43 parents = record.get("parents", []) 

44 parents = [parents] if isinstance(parents, str) else parents 

45 if image.id not in parents: 

46 continue 

47 try: 

48 if not issubclass( 

49 load_class_from_string(record["type"]), 

50 (ImageCrop, ImageSegmentation), 

51 ): 

52 continue 

53 except (ImportError, AttributeError): 

54 continue 

55 child = context.artefact(node_id) 

56 if ( 

57 not isinstance(child, (ImageCrop, ImageSegmentation)) 

58 or child.get_path().resolve() != image.get_path().resolve() 

59 ): 

60 continue 

61 if isinstance(child, ImageSegmentation): 

62 from PIL import Image as PILImage, ImageChops 

63 

64 alpha = child._mask_image() 

65 if isinstance(image, ImageSegmentation): 

66 alpha = ImageChops.multiply(alpha, image._mask_image()) 

67 alpha = alpha.crop(display_bounds(image)).rotate( 

68 image.orientation.to_degree(), expand=True 

69 ) 

70 bounds = alpha.getbbox() 

71 if bounds is None: 

72 continue 

73 left, top, right, bottom = bounds 

74 overlay = PILImage.new("RGBA", (right - left, bottom - top), "white") 

75 overlay.putalpha(alpha.crop(bounds)) 

76 rows.append( 

77 { 

78 "id": node_id, 

79 "type": record["type"], 

80 "label": record.get("label", ""), 

81 "name": record.get("name", ""), 

82 "mask_source": context.publish_image(overlay), 

83 "mask_region": { 

84 "x": left / alpha.width, 

85 "y": top / alpha.height, 

86 "width": (right - left) / alpha.width, 

87 "height": (bottom - top) / alpha.height, 

88 }, 

89 } 

90 ) 

91 continue 

92 region = { 

93 key: getattr(child, key) 

94 for key in ("x_center", "y_center", "width_relative", "height_relative") 

95 } 

96 rows.append( 

97 { 

98 "id": node_id, 

99 "type": record["type"], 

100 "label": record.get("label", ""), 

101 "name": record.get("name", ""), 

102 "confidence": child.confidence, 

103 "crop_region": context.region(image, region), 

104 } 

105 ) 

106 return rows 

107 

108 

109def image(context, item): 

110 from PIL import Image as PILImage 

111 

112 from collectra.types.images import ( 

113 ImageSegmentation, 

114 image_channel_count, 

115 read_tiff_channels, 

116 ) 

117 

118 path = item.get_path() 

119 view_count = 1 

120 key = _preview_key(context, item, path) 

121 if key in _preview_cache: 

122 _preview_cache.move_to_end(key) 

123 source, view_count = _preview_cache[key] 

124 return _image_view(context, item, path, source, view_count) 

125 if path.suffix.lower() in {".tif", ".tiff"}: 

126 import numpy as np 

127 from tifffile import TiffFile 

128 

129 channels = image_channel_count(path) 

130 # Only complete RGB triplets form multiple views. Other spectral 

131 # layouts keep the first-triplet/gray preview and can override display. 

132 if channels >= 3 and channels % 3 == 0: 

133 view_count = channels // 3 

134 if context.rgb_view >= view_count: 

135 raise ValueError(f"RGB view index must be less than {view_count}") 

136 start = context.rgb_view * 3 

137 stop = start + (3 if channels >= 3 else 1) 

138 with TiffFile(path) as tiff: 

139 series = tiff.series[0] 

140 # CYX/QYX stacks store each channel on a separate page. Decode 

141 # just this view instead of all 75 channels on every button click. 

142 pages = series.pages 

143 if len(pages) == channels and all( 

144 pages[i].axes == "YX" for i in range(start, stop) 

145 ): 

146 pixels = np.stack( 

147 [pages[i].asarray() for i in range(start, stop)], axis=-1 

148 ) 

149 else: 

150 pixels = read_tiff_channels(path)[..., start:stop] 

151 if pixels.shape[2] == 1: 

152 pixels = pixels[..., 0] 

153 if pixels.dtype != np.uint8: 

154 pixels = pixels.astype(float) 

155 low, high = np.nanmin(pixels), np.nanmax(pixels) 

156 pixels = np.nan_to_num((pixels - low) / (high - low or 1) * 255).astype( 

157 np.uint8 

158 ) 

159 preview = PILImage.fromarray(pixels) 

160 else: 

161 if context.rgb_view: 

162 raise ValueError("This image has only one RGB view") 

163 with PILImage.open(path) as source: 

164 preview = source.convert( 

165 "RGBA" if isinstance(item, ImageSegmentation) else "RGB" 

166 ) 

167 preview = preview.crop(display_bounds(item)).rotate( 

168 item.orientation.to_degree(), expand=True 

169 ) 

170 source = context.publish_image(preview) 

171 if key is not None: 

172 _preview_cache[key] = (source, view_count) 

173 while len(_preview_cache) > _PREVIEW_CACHE_SIZE: 

174 _preview_cache.popitem(last=False) 

175 return _image_view(context, item, path, source, view_count) 

176 

177 

178def _preview_key(context, item, path): 

179 """What the encoded preview depends on, or None when it is not cached 

180 (segmentations also depend on their mask file).""" 

181 from collectra.types.images import ImageSegmentation 

182 

183 if isinstance(item, ImageSegmentation): 

184 return None 

185 try: 

186 stat = path.stat() 

187 return ( 

188 str(path.resolve()), 

189 stat.st_mtime_ns, 

190 stat.st_size, 

191 context.rgb_view, 

192 tuple(item.display_bounds()), 

193 item.orientation.to_degree(), 

194 ) 

195 except OSError: 

196 return None 

197 

198 

199def _image_view(context, item, path, source, view_count): 

200 from collectra.types.images import ImageSegmentation 

201 from .image_segmentation import segmentation 

202 

203 view = { 

204 "kind": "image", 

205 "source": source, 

206 "image_path": str(path), 

207 "target_id": item.id, 

208 "annotations": context.annotations(item), 

209 "can_create_crop": True, 

210 "can_rotate": True, 

211 "rgb_view_index": context.rgb_view, 

212 "rgb_view_count": view_count, 

213 } 

214 if isinstance(item, ImageSegmentation): 

215 view["segmentation"] = segmentation(context, item) 

216 return view