pymmcore-plus 0.16.0__py3-none-any.whl → 0.17.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.
- pymmcore_plus/_ipy_completion.py +1 -1
- pymmcore_plus/_logger.py +2 -2
- pymmcore_plus/core/_device.py +37 -6
- pymmcore_plus/core/_mmcore_plus.py +4 -15
- pymmcore_plus/core/_property.py +1 -1
- pymmcore_plus/core/_sequencing.py +2 -0
- pymmcore_plus/experimental/simulate/__init__.py +88 -0
- pymmcore_plus/experimental/simulate/_objects.py +670 -0
- pymmcore_plus/experimental/simulate/_render.py +510 -0
- pymmcore_plus/experimental/simulate/_sample.py +156 -0
- pymmcore_plus/experimental/unicore/__init__.py +2 -0
- pymmcore_plus/experimental/unicore/_device_manager.py +26 -0
- pymmcore_plus/experimental/unicore/core/_config.py +706 -0
- pymmcore_plus/experimental/unicore/core/_unicore.py +830 -17
- pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
- pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
- pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
- pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
- pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
- pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
- pymmcore_plus/metadata/_ome.py +75 -21
- pymmcore_plus/metadata/functions.py +2 -1
- {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +5 -3
- {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +27 -21
- {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
- {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.16.0.dist-info → pymmcore_plus-0.17.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""Sample objects that can be rendered by the simulation engine.
|
|
2
|
+
|
|
3
|
+
All objects are defined in world-space coordinates (typically microns).
|
|
4
|
+
The RenderEngine handles transformation to pixel coordinates.
|
|
5
|
+
|
|
6
|
+
Each object implements:
|
|
7
|
+
- `draw()`: Render using PIL (always available)
|
|
8
|
+
- `draw_cv2()`: Render using OpenCV (~20% faster, optional)
|
|
9
|
+
|
|
10
|
+
The RenderEngine automatically uses cv2 methods when opencv-python is installed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from PIL import Image, ImageDraw
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable, Sequence
|
|
24
|
+
from typing import TypeAlias
|
|
25
|
+
|
|
26
|
+
# A transformation function converts continuous (x, y) to pixel coordinates.
|
|
27
|
+
TransformFn: TypeAlias = Callable[[float, float], tuple[int, int]]
|
|
28
|
+
|
|
29
|
+
# Bounding box in continuous space: (left, top, right, bottom)
|
|
30
|
+
Bounds: TypeAlias = tuple[float, float, float, float]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rects_intersect(a: Bounds, b: Bounds) -> bool:
|
|
34
|
+
"""Check if two rectangles intersect.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
a : Bounds
|
|
39
|
+
First rectangle as (left, top, right, bottom).
|
|
40
|
+
b : Bounds
|
|
41
|
+
Second rectangle as (left, top, right, bottom).
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
bool
|
|
46
|
+
True if rectangles intersect.
|
|
47
|
+
"""
|
|
48
|
+
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SampleObject(ABC):
|
|
52
|
+
"""Base class for drawable sample objects.
|
|
53
|
+
|
|
54
|
+
All coordinates are in world-space (typically microns). Objects are rendered
|
|
55
|
+
by calling `draw()` with a transform function that converts world coordinates
|
|
56
|
+
to pixel coordinates.
|
|
57
|
+
|
|
58
|
+
Subclasses must implement:
|
|
59
|
+
- `draw()`: Render the object onto a PIL ImageDraw context
|
|
60
|
+
- `get_bounds()`: Return the bounding box in world coordinates
|
|
61
|
+
- `intensity`: Property returning the object's intensity value
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
intensity: int # All subclasses must have this attribute
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def draw(
|
|
68
|
+
self,
|
|
69
|
+
draw_context: ImageDraw.ImageDraw,
|
|
70
|
+
transform: TransformFn,
|
|
71
|
+
scale: float,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Draw this object onto the given context.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
draw_context : ImageDraw.ImageDraw
|
|
78
|
+
PIL ImageDraw context to draw on.
|
|
79
|
+
transform : TransformFn
|
|
80
|
+
Function to convert (world_x, world_y) to (pixel_x, pixel_y).
|
|
81
|
+
scale : float
|
|
82
|
+
Pixels per world unit (e.g., pixels per micron).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def get_bounds(self) -> Bounds:
|
|
87
|
+
"""Return bounding box in world coordinates.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
Bounds
|
|
92
|
+
Tuple of (left, top, right, bottom) in world units.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def draw_cv2(
|
|
96
|
+
self,
|
|
97
|
+
img: np.ndarray,
|
|
98
|
+
transform: TransformFn,
|
|
99
|
+
scale: float,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Draw this object using OpenCV (optional, for performance).
|
|
102
|
+
|
|
103
|
+
Default implementation falls back to PIL via a temporary image.
|
|
104
|
+
Subclasses can override for better performance.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
img : np.ndarray
|
|
109
|
+
Image array to draw on (modified in-place).
|
|
110
|
+
transform : TransformFn
|
|
111
|
+
Function to convert (world_x, world_y) to (pixel_x, pixel_y).
|
|
112
|
+
scale : float
|
|
113
|
+
Pixels per world unit.
|
|
114
|
+
"""
|
|
115
|
+
# Default: use PIL and copy to numpy (slower but always works)
|
|
116
|
+
from PIL import Image, ImageDraw
|
|
117
|
+
|
|
118
|
+
h, w = img.shape
|
|
119
|
+
layer = Image.new("L", (w, h), 0)
|
|
120
|
+
draw = ImageDraw.Draw(layer)
|
|
121
|
+
self.draw(draw, transform, scale)
|
|
122
|
+
# Add PIL result to the cv2 image
|
|
123
|
+
img += np.asarray(layer, dtype=np.uint8)
|
|
124
|
+
|
|
125
|
+
def should_draw(self, fov_rect: Bounds) -> bool:
|
|
126
|
+
"""Check if this object intersects the field of view.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
fov_rect : Bounds
|
|
131
|
+
Field of view rectangle in world coordinates.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
bool
|
|
136
|
+
True if object should be drawn (intersects FOV).
|
|
137
|
+
"""
|
|
138
|
+
return rects_intersect(fov_rect, self.get_bounds())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Point(SampleObject):
|
|
143
|
+
"""A circular point/spot in the sample.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
x : float
|
|
148
|
+
X coordinate in world units.
|
|
149
|
+
y : float
|
|
150
|
+
Y coordinate in world units.
|
|
151
|
+
intensity : int
|
|
152
|
+
Brightness value (0-255). Default 255.
|
|
153
|
+
radius : float
|
|
154
|
+
Radius in world units. Default 2.0.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
x: float
|
|
158
|
+
y: float
|
|
159
|
+
intensity: int = 255
|
|
160
|
+
radius: float = 2.0
|
|
161
|
+
|
|
162
|
+
def draw(
|
|
163
|
+
self,
|
|
164
|
+
draw_context: ImageDraw.ImageDraw,
|
|
165
|
+
transform: TransformFn,
|
|
166
|
+
scale: float,
|
|
167
|
+
) -> None:
|
|
168
|
+
cx, cy = transform(self.x, self.y)
|
|
169
|
+
r = self.radius * scale
|
|
170
|
+
draw_context.ellipse([cx - r, cy - r, cx + r, cy + r], fill=self.intensity)
|
|
171
|
+
|
|
172
|
+
def draw_cv2(
|
|
173
|
+
self,
|
|
174
|
+
img: np.ndarray,
|
|
175
|
+
transform: TransformFn,
|
|
176
|
+
scale: float,
|
|
177
|
+
) -> None:
|
|
178
|
+
import cv2
|
|
179
|
+
|
|
180
|
+
cx, cy = transform(self.x, self.y)
|
|
181
|
+
r = round(self.radius * scale)
|
|
182
|
+
cv2.circle(img, (cx, cy), r, self.intensity, -1)
|
|
183
|
+
|
|
184
|
+
def get_bounds(self) -> Bounds:
|
|
185
|
+
return (
|
|
186
|
+
self.x - self.radius,
|
|
187
|
+
self.y - self.radius,
|
|
188
|
+
self.x + self.radius,
|
|
189
|
+
self.y + self.radius,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class Ellipse(SampleObject):
|
|
195
|
+
"""An ellipse in the sample.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
center : tuple[float, float]
|
|
200
|
+
Center (x, y) in world units.
|
|
201
|
+
rx : float
|
|
202
|
+
X radius in world units.
|
|
203
|
+
ry : float
|
|
204
|
+
Y radius in world units.
|
|
205
|
+
intensity : int
|
|
206
|
+
Brightness value (0-255). Default 255.
|
|
207
|
+
fill : bool
|
|
208
|
+
If True, fill the ellipse. If False, draw outline only. Default False.
|
|
209
|
+
width : int
|
|
210
|
+
Outline width in pixels (only used if fill=False). Default 1.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
center: tuple[float, float]
|
|
214
|
+
rx: float
|
|
215
|
+
ry: float
|
|
216
|
+
intensity: int = 255
|
|
217
|
+
fill: bool = False
|
|
218
|
+
width: int = 1
|
|
219
|
+
|
|
220
|
+
def draw(
|
|
221
|
+
self,
|
|
222
|
+
draw_context: ImageDraw.ImageDraw,
|
|
223
|
+
transform: TransformFn,
|
|
224
|
+
scale: float,
|
|
225
|
+
) -> None:
|
|
226
|
+
cx, cy = transform(*self.center)
|
|
227
|
+
rx_pix = self.rx * scale
|
|
228
|
+
ry_pix = self.ry * scale
|
|
229
|
+
bbox = [cx - rx_pix, cy - ry_pix, cx + rx_pix, cy + ry_pix]
|
|
230
|
+
if self.fill:
|
|
231
|
+
draw_context.ellipse(bbox, fill=self.intensity)
|
|
232
|
+
else:
|
|
233
|
+
draw_context.ellipse(bbox, outline=self.intensity, width=self.width)
|
|
234
|
+
|
|
235
|
+
def draw_cv2(
|
|
236
|
+
self,
|
|
237
|
+
img: np.ndarray,
|
|
238
|
+
transform: TransformFn,
|
|
239
|
+
scale: float,
|
|
240
|
+
) -> None:
|
|
241
|
+
import cv2
|
|
242
|
+
|
|
243
|
+
cx, cy = transform(*self.center)
|
|
244
|
+
axes = (round(self.rx * scale), round(self.ry * scale))
|
|
245
|
+
thickness = -1 if self.fill else self.width
|
|
246
|
+
cv2.ellipse(img, (cx, cy), axes, 0, 0, 360, self.intensity, thickness)
|
|
247
|
+
|
|
248
|
+
def get_bounds(self) -> Bounds:
|
|
249
|
+
return (
|
|
250
|
+
self.center[0] - self.rx,
|
|
251
|
+
self.center[1] - self.ry,
|
|
252
|
+
self.center[0] + self.rx,
|
|
253
|
+
self.center[1] + self.ry,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class Rectangle(SampleObject):
|
|
259
|
+
"""A rectangle in the sample.
|
|
260
|
+
|
|
261
|
+
Parameters
|
|
262
|
+
----------
|
|
263
|
+
top_left : tuple[float, float]
|
|
264
|
+
Top-left corner (x, y) in world units.
|
|
265
|
+
width : float
|
|
266
|
+
Width in world units.
|
|
267
|
+
height : float
|
|
268
|
+
Height in world units.
|
|
269
|
+
intensity : int
|
|
270
|
+
Brightness value (0-255). Default 255.
|
|
271
|
+
fill : bool
|
|
272
|
+
If True, fill the rectangle. If False, draw outline only. Default False.
|
|
273
|
+
corner_radius : float
|
|
274
|
+
Corner radius for rounded rectangles, in world units. Default 0.
|
|
275
|
+
line_width : int
|
|
276
|
+
Outline width in pixels (only used if fill=False). Default 1.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
top_left: tuple[float, float]
|
|
280
|
+
width: float
|
|
281
|
+
height: float
|
|
282
|
+
intensity: int = 255
|
|
283
|
+
fill: bool = False
|
|
284
|
+
corner_radius: float = 0
|
|
285
|
+
line_width: int = 1
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def bottom_right(self) -> tuple[float, float]:
|
|
289
|
+
"""Bottom-right corner coordinates."""
|
|
290
|
+
return (self.top_left[0] + self.width, self.top_left[1] + self.height)
|
|
291
|
+
|
|
292
|
+
def draw(
|
|
293
|
+
self,
|
|
294
|
+
draw_context: ImageDraw.ImageDraw,
|
|
295
|
+
transform: TransformFn,
|
|
296
|
+
scale: float,
|
|
297
|
+
) -> None:
|
|
298
|
+
tl = transform(*self.top_left)
|
|
299
|
+
br = transform(*self.bottom_right)
|
|
300
|
+
radius = round(self.corner_radius * scale)
|
|
301
|
+
if self.fill:
|
|
302
|
+
draw_context.rounded_rectangle([tl, br], radius=radius, fill=self.intensity)
|
|
303
|
+
else:
|
|
304
|
+
draw_context.rounded_rectangle(
|
|
305
|
+
[tl, br], radius=radius, outline=self.intensity, width=self.line_width
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def draw_cv2(
|
|
309
|
+
self,
|
|
310
|
+
img: np.ndarray,
|
|
311
|
+
transform: TransformFn,
|
|
312
|
+
scale: float,
|
|
313
|
+
) -> None:
|
|
314
|
+
import cv2
|
|
315
|
+
|
|
316
|
+
tl = transform(*self.top_left)
|
|
317
|
+
br = transform(*self.bottom_right)
|
|
318
|
+
# cv2.rectangle doesn't support rounded corners, fall back to PIL for that
|
|
319
|
+
if self.corner_radius > 0:
|
|
320
|
+
super().draw_cv2(img, transform, scale)
|
|
321
|
+
return
|
|
322
|
+
thickness = -1 if self.fill else self.line_width
|
|
323
|
+
cv2.rectangle(img, tl, br, self.intensity, thickness)
|
|
324
|
+
|
|
325
|
+
def get_bounds(self) -> Bounds:
|
|
326
|
+
return (
|
|
327
|
+
self.top_left[0],
|
|
328
|
+
self.top_left[1],
|
|
329
|
+
self.top_left[0] + self.width,
|
|
330
|
+
self.top_left[1] + self.height,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass
|
|
335
|
+
class Line(SampleObject):
|
|
336
|
+
"""A line segment in the sample.
|
|
337
|
+
|
|
338
|
+
Parameters
|
|
339
|
+
----------
|
|
340
|
+
start : tuple[float, float]
|
|
341
|
+
Start point (x, y) in world units.
|
|
342
|
+
end : tuple[float, float]
|
|
343
|
+
End point (x, y) in world units.
|
|
344
|
+
intensity : int
|
|
345
|
+
Brightness value (0-255). Default 255.
|
|
346
|
+
width : int
|
|
347
|
+
Line width in pixels. Default 1.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
start: tuple[float, float]
|
|
351
|
+
end: tuple[float, float]
|
|
352
|
+
intensity: int = 255
|
|
353
|
+
width: int = 1
|
|
354
|
+
|
|
355
|
+
def draw(
|
|
356
|
+
self,
|
|
357
|
+
draw_context: ImageDraw.ImageDraw,
|
|
358
|
+
transform: TransformFn,
|
|
359
|
+
scale: float,
|
|
360
|
+
) -> None:
|
|
361
|
+
pt1 = transform(*self.start)
|
|
362
|
+
pt2 = transform(*self.end)
|
|
363
|
+
draw_context.line([pt1, pt2], fill=self.intensity, width=self.width)
|
|
364
|
+
|
|
365
|
+
def draw_cv2(
|
|
366
|
+
self,
|
|
367
|
+
img: np.ndarray,
|
|
368
|
+
transform: TransformFn,
|
|
369
|
+
scale: float,
|
|
370
|
+
) -> None:
|
|
371
|
+
import cv2
|
|
372
|
+
|
|
373
|
+
pt1 = transform(*self.start)
|
|
374
|
+
pt2 = transform(*self.end)
|
|
375
|
+
cv2.line(img, pt1, pt2, self.intensity, self.width)
|
|
376
|
+
|
|
377
|
+
def get_bounds(self) -> Bounds:
|
|
378
|
+
x1, y1 = self.start
|
|
379
|
+
x2, y2 = self.end
|
|
380
|
+
return (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass
|
|
384
|
+
class Polygon(SampleObject):
|
|
385
|
+
"""A polygon in the sample.
|
|
386
|
+
|
|
387
|
+
Parameters
|
|
388
|
+
----------
|
|
389
|
+
vertices : Sequence[tuple[float, float]]
|
|
390
|
+
List of (x, y) vertices in world units.
|
|
391
|
+
intensity : int
|
|
392
|
+
Brightness value (0-255). Default 255.
|
|
393
|
+
fill : bool
|
|
394
|
+
If True, fill the polygon. Default False.
|
|
395
|
+
width : int
|
|
396
|
+
Outline width in pixels (only used if fill=False). Default 1.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
vertices: Sequence[tuple[float, float]]
|
|
400
|
+
intensity: int = 255
|
|
401
|
+
fill: bool = False
|
|
402
|
+
width: int = 1
|
|
403
|
+
|
|
404
|
+
def draw(
|
|
405
|
+
self,
|
|
406
|
+
draw_context: ImageDraw.ImageDraw,
|
|
407
|
+
transform: TransformFn,
|
|
408
|
+
scale: float,
|
|
409
|
+
) -> None:
|
|
410
|
+
transformed = [transform(x, y) for x, y in self.vertices]
|
|
411
|
+
if self.fill:
|
|
412
|
+
draw_context.polygon(transformed, fill=self.intensity)
|
|
413
|
+
elif self.width == 1:
|
|
414
|
+
draw_context.polygon(transformed, outline=self.intensity)
|
|
415
|
+
else:
|
|
416
|
+
# ImageDraw.polygon doesn't support width, use lines instead
|
|
417
|
+
draw_context.line(
|
|
418
|
+
[*transformed, transformed[0]], fill=self.intensity, width=self.width
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def draw_cv2(
|
|
422
|
+
self,
|
|
423
|
+
img: np.ndarray,
|
|
424
|
+
transform: TransformFn,
|
|
425
|
+
scale: float,
|
|
426
|
+
) -> None:
|
|
427
|
+
import cv2
|
|
428
|
+
|
|
429
|
+
pts = np.array([transform(x, y) for x, y in self.vertices], dtype=np.int32)
|
|
430
|
+
if self.fill:
|
|
431
|
+
cv2.fillPoly(img, [pts], self.intensity)
|
|
432
|
+
else:
|
|
433
|
+
cv2.polylines(
|
|
434
|
+
img, [pts], isClosed=True, color=self.intensity, thickness=self.width
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def get_bounds(self) -> Bounds:
|
|
438
|
+
xs = [x for x, _ in self.vertices]
|
|
439
|
+
ys = [y for _, y in self.vertices]
|
|
440
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@dataclass
|
|
444
|
+
class RegularPolygon(SampleObject):
|
|
445
|
+
"""A regular polygon inscribed in a circle.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
center : tuple[float, float]
|
|
450
|
+
Center (x, y) in world units.
|
|
451
|
+
radius : float
|
|
452
|
+
Radius of bounding circle in world units.
|
|
453
|
+
n_sides : int
|
|
454
|
+
Number of sides.
|
|
455
|
+
rotation : float
|
|
456
|
+
Rotation angle in degrees. Default 0.
|
|
457
|
+
intensity : int
|
|
458
|
+
Brightness value (0-255). Default 255.
|
|
459
|
+
fill : bool
|
|
460
|
+
If True, fill the polygon. Default False.
|
|
461
|
+
width : int
|
|
462
|
+
Outline width in pixels (only used if fill=False). Default 1.
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
center: tuple[float, float]
|
|
466
|
+
radius: float
|
|
467
|
+
n_sides: int
|
|
468
|
+
rotation: float = 0
|
|
469
|
+
intensity: int = 255
|
|
470
|
+
fill: bool = False
|
|
471
|
+
width: int = 1
|
|
472
|
+
_vertices: list[tuple[float, float]] = field(default_factory=list, repr=False)
|
|
473
|
+
|
|
474
|
+
def __post_init__(self) -> None:
|
|
475
|
+
"""Compute vertices."""
|
|
476
|
+
import math
|
|
477
|
+
|
|
478
|
+
cx, cy = self.center
|
|
479
|
+
angle_offset = math.radians(self.rotation)
|
|
480
|
+
self._vertices = []
|
|
481
|
+
for i in range(self.n_sides):
|
|
482
|
+
angle = 2 * math.pi * i / self.n_sides + angle_offset
|
|
483
|
+
x = cx + self.radius * math.cos(angle)
|
|
484
|
+
y = cy + self.radius * math.sin(angle)
|
|
485
|
+
self._vertices.append((x, y))
|
|
486
|
+
|
|
487
|
+
def draw(
|
|
488
|
+
self,
|
|
489
|
+
draw_context: ImageDraw.ImageDraw,
|
|
490
|
+
transform: TransformFn,
|
|
491
|
+
scale: float,
|
|
492
|
+
) -> None:
|
|
493
|
+
transformed = [transform(x, y) for x, y in self._vertices]
|
|
494
|
+
if self.fill:
|
|
495
|
+
draw_context.polygon(transformed, fill=self.intensity)
|
|
496
|
+
elif self.width == 1:
|
|
497
|
+
draw_context.polygon(transformed, outline=self.intensity)
|
|
498
|
+
else:
|
|
499
|
+
draw_context.line(
|
|
500
|
+
[*transformed, transformed[0]], fill=self.intensity, width=self.width
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def draw_cv2(
|
|
504
|
+
self,
|
|
505
|
+
img: np.ndarray,
|
|
506
|
+
transform: TransformFn,
|
|
507
|
+
scale: float,
|
|
508
|
+
) -> None:
|
|
509
|
+
import cv2
|
|
510
|
+
|
|
511
|
+
pts = np.array([transform(x, y) for x, y in self._vertices], dtype=np.int32)
|
|
512
|
+
if self.fill:
|
|
513
|
+
cv2.fillPoly(img, [pts], self.intensity)
|
|
514
|
+
else:
|
|
515
|
+
cv2.polylines(
|
|
516
|
+
img, [pts], isClosed=True, color=self.intensity, thickness=self.width
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def get_bounds(self) -> Bounds:
|
|
520
|
+
return (
|
|
521
|
+
self.center[0] - self.radius,
|
|
522
|
+
self.center[1] - self.radius,
|
|
523
|
+
self.center[0] + self.radius,
|
|
524
|
+
self.center[1] + self.radius,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@dataclass
|
|
529
|
+
class Arc(SampleObject):
|
|
530
|
+
"""An arc (partial ellipse outline) in the sample.
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
center : tuple[float, float]
|
|
535
|
+
Center (x, y) in world units.
|
|
536
|
+
rx : float
|
|
537
|
+
X radius in world units.
|
|
538
|
+
ry : float
|
|
539
|
+
Y radius in world units.
|
|
540
|
+
start_angle : float
|
|
541
|
+
Start angle in degrees.
|
|
542
|
+
end_angle : float
|
|
543
|
+
End angle in degrees.
|
|
544
|
+
intensity : int
|
|
545
|
+
Brightness value (0-255). Default 255.
|
|
546
|
+
width : int
|
|
547
|
+
Arc width in pixels. Default 1.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
center: tuple[float, float]
|
|
551
|
+
rx: float
|
|
552
|
+
ry: float
|
|
553
|
+
start_angle: float
|
|
554
|
+
end_angle: float
|
|
555
|
+
intensity: int = 255
|
|
556
|
+
width: int = 1
|
|
557
|
+
|
|
558
|
+
def draw(
|
|
559
|
+
self,
|
|
560
|
+
draw_context: ImageDraw.ImageDraw,
|
|
561
|
+
transform: TransformFn,
|
|
562
|
+
scale: float,
|
|
563
|
+
) -> None:
|
|
564
|
+
cx, cy = transform(*self.center)
|
|
565
|
+
rx_pix = self.rx * scale
|
|
566
|
+
ry_pix = self.ry * scale
|
|
567
|
+
bbox = [cx - rx_pix, cy - ry_pix, cx + rx_pix, cy + ry_pix]
|
|
568
|
+
draw_context.arc(
|
|
569
|
+
bbox,
|
|
570
|
+
start=self.start_angle,
|
|
571
|
+
end=self.end_angle,
|
|
572
|
+
fill=self.intensity,
|
|
573
|
+
width=self.width,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def draw_cv2(
|
|
577
|
+
self,
|
|
578
|
+
img: np.ndarray,
|
|
579
|
+
transform: TransformFn,
|
|
580
|
+
scale: float,
|
|
581
|
+
) -> None:
|
|
582
|
+
import cv2
|
|
583
|
+
|
|
584
|
+
cx, cy = transform(*self.center)
|
|
585
|
+
axes = (round(self.rx * scale), round(self.ry * scale))
|
|
586
|
+
cv2.ellipse(
|
|
587
|
+
img,
|
|
588
|
+
(cx, cy),
|
|
589
|
+
axes,
|
|
590
|
+
0,
|
|
591
|
+
self.start_angle,
|
|
592
|
+
self.end_angle,
|
|
593
|
+
self.intensity,
|
|
594
|
+
self.width,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def get_bounds(self) -> Bounds:
|
|
598
|
+
return (
|
|
599
|
+
self.center[0] - self.rx,
|
|
600
|
+
self.center[1] - self.ry,
|
|
601
|
+
self.center[0] + self.rx,
|
|
602
|
+
self.center[1] + self.ry,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@dataclass
|
|
607
|
+
class Bitmap(SampleObject):
|
|
608
|
+
"""A bitmap image placed in the sample.
|
|
609
|
+
|
|
610
|
+
The bitmap is placed at a fixed position and scale in world coordinates.
|
|
611
|
+
Each pixel of the bitmap corresponds to one world unit.
|
|
612
|
+
|
|
613
|
+
Parameters
|
|
614
|
+
----------
|
|
615
|
+
top_left : tuple[float, float]
|
|
616
|
+
Top-left corner (x, y) in world units.
|
|
617
|
+
data : np.ndarray | Image.Image | str
|
|
618
|
+
Image data as numpy array, PIL Image, or path to image file.
|
|
619
|
+
scale : float
|
|
620
|
+
World units per bitmap pixel. Default 1.0 (1 pixel = 1 world unit).
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
top_left: tuple[float, float]
|
|
624
|
+
data: np.ndarray | Image.Image | str
|
|
625
|
+
bitmap_scale: float = 1.0
|
|
626
|
+
_image: Image.Image = field(init=False, repr=False)
|
|
627
|
+
|
|
628
|
+
def __post_init__(self) -> None:
|
|
629
|
+
"""Convert input to PIL Image."""
|
|
630
|
+
if isinstance(self.data, np.ndarray):
|
|
631
|
+
self._image = Image.fromarray(self.data)
|
|
632
|
+
elif isinstance(self.data, str):
|
|
633
|
+
self._image = Image.open(self.data)
|
|
634
|
+
elif isinstance(self.data, Image.Image):
|
|
635
|
+
self._image = self.data
|
|
636
|
+
else:
|
|
637
|
+
raise TypeError(
|
|
638
|
+
f"Invalid bitmap type: {type(self.data)}. "
|
|
639
|
+
"Expected np.ndarray, PIL.Image.Image, or str path."
|
|
640
|
+
)
|
|
641
|
+
# Convert to grayscale if needed
|
|
642
|
+
if self._image.mode != "L":
|
|
643
|
+
self._image = self._image.convert("L")
|
|
644
|
+
|
|
645
|
+
def draw(
|
|
646
|
+
self,
|
|
647
|
+
draw_context: ImageDraw.ImageDraw,
|
|
648
|
+
transform: TransformFn,
|
|
649
|
+
scale: float,
|
|
650
|
+
) -> None:
|
|
651
|
+
tl = transform(*self.top_left)
|
|
652
|
+
# Scale the bitmap to match world-to-pixel scale
|
|
653
|
+
new_width = int(self._image.width * self.bitmap_scale * scale)
|
|
654
|
+
new_height = int(self._image.height * self.bitmap_scale * scale)
|
|
655
|
+
if new_width > 0 and new_height > 0:
|
|
656
|
+
new_size = (new_width, new_height)
|
|
657
|
+
scaled = self._image.resize(new_size, Image.Resampling.NEAREST)
|
|
658
|
+
# Get the underlying image from the draw context
|
|
659
|
+
base_image: Image.Image = draw_context._image # noqa: SLF001
|
|
660
|
+
base_image.paste(scaled, tl)
|
|
661
|
+
|
|
662
|
+
def get_bounds(self) -> Bounds:
|
|
663
|
+
width = self._image.width * self.bitmap_scale
|
|
664
|
+
height = self._image.height * self.bitmap_scale
|
|
665
|
+
return (
|
|
666
|
+
self.top_left[0],
|
|
667
|
+
self.top_left[1],
|
|
668
|
+
self.top_left[0] + width,
|
|
669
|
+
self.top_left[1] + height,
|
|
670
|
+
)
|