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
« 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."""
3from collections import OrderedDict
4from collectra.utils import load_class_from_string
5from .image_segmentation import display_bounds
7# Share the few most recent encodings across contexts and panels.
8_PREVIEW_CACHE_SIZE = 6
9_preview_cache = OrderedDict()
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)
38def annotations(context, image):
39 from collectra.types.images import ImageCrop, ImageSegmentation
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
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
109def image(context, item):
110 from PIL import Image as PILImage
112 from collectra.types.images import (
113 ImageSegmentation,
114 image_channel_count,
115 read_tiff_channels,
116 )
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
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)
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
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
199def _image_view(context, item, path, source, view_count):
200 from collectra.types.images import ImageSegmentation
201 from .image_segmentation import segmentation
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