pymmcore-plus 0.15.4__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.
Files changed (35) hide show
  1. pymmcore_plus/__init__.py +20 -1
  2. pymmcore_plus/_accumulator.py +23 -5
  3. pymmcore_plus/_cli.py +44 -26
  4. pymmcore_plus/_discovery.py +344 -0
  5. pymmcore_plus/_ipy_completion.py +1 -1
  6. pymmcore_plus/_logger.py +3 -3
  7. pymmcore_plus/_util.py +9 -245
  8. pymmcore_plus/core/_device.py +57 -13
  9. pymmcore_plus/core/_mmcore_plus.py +20 -23
  10. pymmcore_plus/core/_property.py +35 -29
  11. pymmcore_plus/core/_sequencing.py +2 -0
  12. pymmcore_plus/core/events/_device_signal_view.py +8 -1
  13. pymmcore_plus/experimental/simulate/__init__.py +88 -0
  14. pymmcore_plus/experimental/simulate/_objects.py +670 -0
  15. pymmcore_plus/experimental/simulate/_render.py +510 -0
  16. pymmcore_plus/experimental/simulate/_sample.py +156 -0
  17. pymmcore_plus/experimental/unicore/__init__.py +2 -0
  18. pymmcore_plus/experimental/unicore/_device_manager.py +46 -13
  19. pymmcore_plus/experimental/unicore/core/_config.py +706 -0
  20. pymmcore_plus/experimental/unicore/core/_unicore.py +834 -18
  21. pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
  22. pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
  23. pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
  24. pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
  25. pymmcore_plus/install.py +149 -18
  26. pymmcore_plus/mda/_engine.py +268 -73
  27. pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
  28. pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
  29. pymmcore_plus/metadata/_ome.py +553 -0
  30. pymmcore_plus/metadata/functions.py +2 -1
  31. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +7 -4
  32. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +35 -27
  33. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
  34. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
  35. {pymmcore_plus-0.15.4.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
+ )