videopython 0.3.0__py3-none-any.whl → 0.4.1__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.

Potentially problematic release.


This version of videopython might be problematic. Click here for more details.

@@ -0,0 +1,727 @@
1
+ from enum import Enum
2
+ from typing import TypeAlias, Union
3
+
4
+ import numpy as np
5
+ from PIL import Image, ImageDraw, ImageFont
6
+
7
+ from videopython.base.exceptions import OutOfBoundsError
8
+
9
+ # Type aliases for clarity
10
+ MarginType: TypeAlias = Union[int, tuple[int, int, int, int]]
11
+ RGBColor: TypeAlias = tuple[int, int, int]
12
+ RGBAColor: TypeAlias = tuple[int, int, int, int]
13
+ PositionType: TypeAlias = Union[tuple[int, int], tuple[float, float]]
14
+
15
+
16
+ # Text alignment enum
17
+ class TextAlign(str, Enum):
18
+ """Defines text alignment options for positioning within containers."""
19
+
20
+ LEFT = "left"
21
+ RIGHT = "right"
22
+ CENTER = "center"
23
+
24
+
25
+ class AnchorPoint(str, Enum):
26
+ """Defines anchor points for positioning text elements."""
27
+
28
+ TOP_LEFT = "top-left"
29
+ TOP_CENTER = "top-center"
30
+ TOP_RIGHT = "top-right"
31
+ CENTER_LEFT = "center-left"
32
+ CENTER = "center"
33
+ CENTER_RIGHT = "center-right"
34
+ BOTTOM_LEFT = "bottom-left"
35
+ BOTTOM_CENTER = "bottom-center"
36
+ BOTTOM_RIGHT = "bottom-right"
37
+
38
+ # Group anchor points by their horizontal position
39
+ @classmethod
40
+ def left_anchors(cls) -> tuple["AnchorPoint", ...]:
41
+ return (cls.TOP_LEFT, cls.CENTER_LEFT, cls.BOTTOM_LEFT)
42
+
43
+ @classmethod
44
+ def center_anchors(cls) -> tuple["AnchorPoint", ...]:
45
+ return (cls.TOP_CENTER, cls.CENTER, cls.BOTTOM_CENTER)
46
+
47
+ @classmethod
48
+ def right_anchors(cls) -> tuple["AnchorPoint", ...]:
49
+ return (cls.TOP_RIGHT, cls.CENTER_RIGHT, cls.BOTTOM_RIGHT)
50
+
51
+ # Group anchor points by their vertical position
52
+ @classmethod
53
+ def top_anchors(cls) -> tuple["AnchorPoint", ...]:
54
+ return (cls.TOP_LEFT, cls.TOP_CENTER, cls.TOP_RIGHT)
55
+
56
+ @classmethod
57
+ def middle_anchors(cls) -> tuple["AnchorPoint", ...]:
58
+ return (cls.CENTER_LEFT, cls.CENTER, cls.CENTER_RIGHT)
59
+
60
+ @classmethod
61
+ def bottom_anchors(cls) -> tuple["AnchorPoint", ...]:
62
+ return (cls.BOTTOM_LEFT, cls.BOTTOM_CENTER, cls.BOTTOM_RIGHT)
63
+
64
+
65
+ class ImageText:
66
+ def __init__(
67
+ self,
68
+ image_size: tuple[int, int] = (1080, 1920), # (width, height)
69
+ mode: str = "RGBA",
70
+ background: RGBAColor = (0, 0, 0, 0), # Transparent background
71
+ ):
72
+ """
73
+ Initialize an image for text rendering.
74
+
75
+ Args:
76
+ image_size: Dimensions of the image (width, height)
77
+ mode: Image mode (RGB, RGBA, etc.)
78
+ background: Background color with alpha channel
79
+
80
+ Raises:
81
+ ValueError: If image_size dimensions are not positive
82
+ """
83
+ if image_size[0] <= 0 or image_size[1] <= 0:
84
+ raise ValueError("Image dimensions must be positive")
85
+
86
+ if len(background) != 4:
87
+ raise ValueError("Background color must be RGBA (4 values)")
88
+
89
+ self.image_size = image_size
90
+ self.image = Image.new(mode, image_size, color=background)
91
+ self._draw = ImageDraw.Draw(self.image)
92
+ self._font_cache: dict[tuple[str, int], ImageFont.FreeTypeFont] = {} # Cache for font objects
93
+
94
+ @property
95
+ def img_array(self) -> np.ndarray:
96
+ """Convert the PIL Image to a numpy array."""
97
+ return np.array(self.image)
98
+
99
+ def save(self, filename: str) -> None:
100
+ """Save the image to a file."""
101
+ if not filename:
102
+ raise ValueError("Filename cannot be empty")
103
+ self.image.save(filename)
104
+
105
+ def _fit_font_width(self, text: str, font: str, max_width: int) -> int:
106
+ """
107
+ Find the maximum font size where the text width is less than or equal to max_width.
108
+
109
+ Args:
110
+ text: The text to measure
111
+ font: Path to the font file
112
+ max_width: Maximum allowed width in pixels
113
+
114
+ Returns:
115
+ The maximum font size that fits within max_width
116
+
117
+ Raises:
118
+ ValueError: If text is empty or max_width is too small for any font size
119
+ """
120
+ if not text:
121
+ return 1 # Default to minimum size for empty text
122
+
123
+ if max_width <= 0:
124
+ raise ValueError("Maximum width must be positive")
125
+
126
+ font_size = 1
127
+ text_width = self.get_text_dimensions(font, font_size, text)[0]
128
+ while text_width < max_width:
129
+ font_size += 1
130
+ text_width = self.get_text_dimensions(font, font_size, text)[0]
131
+ max_font_size = font_size - 1
132
+ if max_font_size < 1:
133
+ raise ValueError(f"Max width {max_width} is too small for any font size!")
134
+ return max_font_size
135
+
136
+ def _fit_font_height(self, text: str, font: str, max_height: int) -> int:
137
+ """
138
+ Find the maximum font size where the text height is less than or equal to max_height.
139
+
140
+ Args:
141
+ text: The text to measure
142
+ font: Path to the font file
143
+ max_height: Maximum allowed height in pixels
144
+
145
+ Returns:
146
+ The maximum font size that fits within max_height
147
+
148
+ Raises:
149
+ ValueError: If text is empty or max_height is too small for any font size
150
+ """
151
+ if not text:
152
+ return 1 # Default to minimum size for empty text
153
+
154
+ if max_height <= 0:
155
+ raise ValueError("Maximum height must be positive")
156
+
157
+ font_size = 1
158
+ text_height = self.get_text_dimensions(font, font_size, text)[1]
159
+ while text_height < max_height:
160
+ font_size += 1
161
+ text_height = self.get_text_dimensions(font, font_size, text)[1]
162
+ max_font_size = font_size - 1
163
+ if max_font_size < 1:
164
+ raise ValueError(f"Max height {max_height} is too small for any font size!")
165
+ return max_font_size
166
+
167
+ def _get_font_size(
168
+ self,
169
+ text: str,
170
+ font: str,
171
+ max_width: int | None = None,
172
+ max_height: int | None = None,
173
+ ) -> int:
174
+ """
175
+ Get maximum font size for text to fit within given dimensions.
176
+
177
+ Args:
178
+ text: The text to fit
179
+ font: Path to the font file
180
+ max_width: Maximum allowed width in pixels
181
+ max_height: Maximum allowed height in pixels
182
+
183
+ Returns:
184
+ The maximum font size that fits within constraints
185
+
186
+ Raises:
187
+ ValueError: If neither max_width nor max_height is provided, or text is empty
188
+ """
189
+ if not text:
190
+ raise ValueError("Text cannot be empty")
191
+
192
+ if max_width is None and max_height is None:
193
+ raise ValueError("You need to pass max_width or max_height")
194
+
195
+ if max_width is not None and max_width <= 0:
196
+ raise ValueError("Maximum width must be positive")
197
+
198
+ if max_height is not None and max_height <= 0:
199
+ raise ValueError("Maximum height must be positive")
200
+
201
+ width_font_size = self._fit_font_width(text, font, max_width) if max_width is not None else None
202
+ height_font_size = self._fit_font_height(text, font, max_height) if max_height is not None else None
203
+
204
+ sizes = [size for size in [width_font_size, height_font_size] if size is not None]
205
+ if not sizes:
206
+ raise ValueError("No valid font size could be calculated")
207
+
208
+ return min(sizes)
209
+
210
+ def _process_margin(self, margin: MarginType) -> tuple[int, int, int, int]:
211
+ """
212
+ Process the margin parameter into individual top, right, bottom, left values.
213
+
214
+ Args:
215
+ margin: A single int for all sides, or a tuple of 4 values for each side
216
+
217
+ Returns:
218
+ Tuple of (top, right, bottom, left) margin values
219
+
220
+ Raises:
221
+ ValueError: If margin tuple doesn't have exactly 4 values
222
+ """
223
+ if isinstance(margin, int):
224
+ if margin < 0:
225
+ raise ValueError("Margin cannot be negative")
226
+ return margin, margin, margin, margin
227
+ elif isinstance(margin, tuple) and len(margin) == 4:
228
+ if any(m < 0 for m in margin):
229
+ raise ValueError("Margin values cannot be negative")
230
+ return margin
231
+ else:
232
+ raise ValueError("Margin must be an int or a tuple of 4 ints")
233
+
234
+ def _convert_position(
235
+ self, position: PositionType, margin_top: int, margin_left: int, available_width: int, available_height: int
236
+ ) -> tuple[float, float]:
237
+ """
238
+ Convert a position from relative (0-1) to absolute pixels.
239
+
240
+ Args:
241
+ position: Position as (x, y) coordinates, either as pixels or relative (0-1)
242
+ margin_top: Top margin in pixels
243
+ margin_left: Left margin in pixels
244
+ available_width: Available width considering margins
245
+ available_height: Available height considering margins
246
+
247
+ Returns:
248
+ Position in absolute pixel coordinates (might still be float)
249
+ """
250
+ x_pos, y_pos = position
251
+
252
+ # Convert relative position (0-1) to absolute pixels
253
+ if isinstance(x_pos, float) and 0 <= x_pos <= 1:
254
+ x_pos = margin_left + x_pos * available_width
255
+ if isinstance(y_pos, float) and 0 <= y_pos <= 1:
256
+ y_pos = margin_top + y_pos * available_height
257
+
258
+ return x_pos, y_pos
259
+
260
+ def _calculate_position(
261
+ self,
262
+ text_size: tuple[int, int],
263
+ position: PositionType,
264
+ anchor: AnchorPoint = AnchorPoint.TOP_LEFT,
265
+ margin: MarginType = 0,
266
+ ) -> tuple[int, int]:
267
+ """
268
+ Calculate the absolute position based on anchor point, relative positioning and margins.
269
+
270
+ Args:
271
+ text_size: Width and height of the text in pixels
272
+ position: Either absolute coordinates (int) or relative to frame size (float 0-1)
273
+ anchor: Which part of the text to anchor at the position
274
+ margin: Margin in pixels (single value or [top, right, bottom, left])
275
+
276
+ Returns:
277
+ Absolute x, y coordinates for text placement
278
+
279
+ Raises:
280
+ ValueError: If position or margin values are invalid
281
+ """
282
+ if not isinstance(text_size, tuple) or len(text_size) != 2:
283
+ raise ValueError("text_size must be a tuple of (width, height)")
284
+
285
+ text_width, text_height = text_size
286
+
287
+ # Process margins
288
+ margin_top, margin_right, margin_bottom, margin_left = self._process_margin(margin)
289
+
290
+ # Calculate available area considering margins
291
+ available_width = self.image_size[0] - margin_left - margin_right
292
+ available_height = self.image_size[1] - margin_top - margin_bottom
293
+
294
+ # Convert relative position to absolute if needed
295
+ x_pos, y_pos = self._convert_position(position, margin_top, margin_left, available_width, available_height)
296
+
297
+ # Apply margin to absolute position when using 0,0 as starting point
298
+ if x_pos == 0 and anchor in AnchorPoint.left_anchors():
299
+ x_pos = margin_left
300
+ if y_pos == 0 and anchor in AnchorPoint.top_anchors():
301
+ y_pos = margin_top
302
+
303
+ # Adjust position based on anchor point
304
+ if anchor in AnchorPoint.center_anchors():
305
+ x_pos -= text_width // 2
306
+ elif anchor in AnchorPoint.right_anchors():
307
+ x_pos -= text_width
308
+
309
+ if anchor in AnchorPoint.middle_anchors():
310
+ y_pos -= text_height // 2
311
+ elif anchor in AnchorPoint.bottom_anchors():
312
+ y_pos -= text_height
313
+
314
+ return int(x_pos), int(y_pos)
315
+
316
+ def write_text(
317
+ self,
318
+ text: str,
319
+ font_filename: str,
320
+ xy: PositionType,
321
+ font_size: int | None = 11,
322
+ color: RGBColor = (0, 0, 0),
323
+ max_width: int | None = None,
324
+ max_height: int | None = None,
325
+ anchor: AnchorPoint = AnchorPoint.TOP_LEFT,
326
+ margin: MarginType = 0,
327
+ ) -> tuple[int, int]:
328
+ """
329
+ Write text to the image with advanced positioning options.
330
+
331
+ Args:
332
+ text: Text to be written
333
+ font_filename: Path to the font file
334
+ xy: Position (x,y) either as absolute pixels (int) or relative to frame (float 0-1)
335
+ font_size: Size of the font in points, or None to auto-calculate
336
+ color: RGB color of the text
337
+ max_width: Maximum width for auto font sizing
338
+ max_height: Maximum height for auto font sizing
339
+ anchor: Which part of the text to anchor at the position
340
+ margin: Margin in pixels (single value or [top, right, bottom, left])
341
+
342
+ Returns:
343
+ Dimensions of the rendered text (width, height)
344
+
345
+ Raises:
346
+ ValueError: If text is empty or font parameters are invalid
347
+ OutOfBoundsError: If the text would be rendered outside the image bounds
348
+ """
349
+ if not text:
350
+ raise ValueError("Text cannot be empty")
351
+
352
+ if not font_filename:
353
+ raise ValueError("Font filename cannot be empty")
354
+
355
+ if font_size is not None and font_size <= 0:
356
+ raise ValueError("Font size must be positive")
357
+
358
+ if font_size is None and (max_width is None or max_height is None):
359
+ raise ValueError("Must set either `font_size`, or both `max_width` and `max_height`!")
360
+ elif font_size is None:
361
+ font_size = self._get_font_size(text, font_filename, max_width, max_height)
362
+
363
+ # Get or create the font object (with caching)
364
+ font = self._get_font(font_filename, font_size)
365
+ text_dimensions = self.get_text_dimensions(font_filename, font_size, text)
366
+
367
+ # Calculate the position based on anchor point and margins
368
+ x, y = self._calculate_position(text_dimensions, xy, anchor, margin)
369
+
370
+ # Verify text will fit within bounds
371
+ if x < 0 or y < 0 or x + text_dimensions[0] > self.image_size[0] or y + text_dimensions[1] > self.image_size[1]:
372
+ raise OutOfBoundsError(f"Text with size {text_dimensions} at position ({x}, {y}) is out of bounds!")
373
+
374
+ self._draw.text((x, y), text, font=font, fill=color)
375
+ return text_dimensions
376
+
377
+ def _get_font(self, font_filename: str, font_size: int) -> ImageFont.FreeTypeFont:
378
+ """
379
+ Get a font object, using cache if available.
380
+
381
+ Args:
382
+ font_filename: Path to the font file
383
+ font_size: Size of the font in points
384
+
385
+ Returns:
386
+ Font object for rendering text
387
+ """
388
+ key = (font_filename, font_size)
389
+ if key not in self._font_cache:
390
+ try:
391
+ self._font_cache[key] = ImageFont.truetype(font_filename, font_size)
392
+ except (OSError, IOError) as e:
393
+ raise ValueError(f"Error loading font '{font_filename}': {str(e)}")
394
+ return self._font_cache[key]
395
+
396
+ def get_text_dimensions(self, font_filename: str, font_size: int, text: str) -> tuple[int, int]:
397
+ """
398
+ Return dimensions (width, height) of the rendered text.
399
+
400
+ Args:
401
+ font_filename: Path to the font file
402
+ font_size: Size of the font in points
403
+ text: Text to measure
404
+
405
+ Returns:
406
+ Tuple of (width, height) for the rendered text
407
+
408
+ Raises:
409
+ ValueError: If font parameters are invalid or text is empty
410
+ """
411
+ if not text:
412
+ return (0, 0) # Empty text has no dimensions
413
+
414
+ if font_size <= 0:
415
+ raise ValueError("Font size must be positive")
416
+
417
+ font = self._get_font(font_filename, font_size)
418
+ try:
419
+ bbox = font.getbbox(text)
420
+ if bbox is None:
421
+ return (0, 0) # Handle case where getbbox returns None
422
+ return bbox[2:] if len(bbox) >= 4 else (0, 0)
423
+ except Exception as e:
424
+ raise ValueError(f"Error measuring text: {str(e)}")
425
+
426
+ def _split_lines_by_width(
427
+ self,
428
+ text: str,
429
+ font_filename: str,
430
+ font_size: int,
431
+ box_width: int,
432
+ ) -> list[str]:
433
+ """
434
+ Split the text into lines that fit within the specified width.
435
+
436
+ Args:
437
+ text: Text to split into lines
438
+ font_filename: Path to the font file
439
+ font_size: Size of the font in points
440
+ box_width: Maximum width for each line in pixels
441
+
442
+ Returns:
443
+ List of text lines that fit within box_width
444
+
445
+ Raises:
446
+ ValueError: If font parameters are invalid or box_width is too small
447
+ """
448
+ if not text:
449
+ return [] # Empty text produces no lines
450
+
451
+ if box_width <= 0:
452
+ raise ValueError("Box width must be positive")
453
+
454
+ if font_size <= 0:
455
+ raise ValueError("Font size must be positive")
456
+
457
+ words = text.split()
458
+ if not words:
459
+ return [] # No words means no lines
460
+
461
+ # Handle single-word case efficiently
462
+ if len(words) == 1:
463
+ return [text]
464
+
465
+ split_lines: list[list[str]] = []
466
+ current_line: list[str] = []
467
+
468
+ for word in words:
469
+ # If current line is empty and this word is too long for box_width,
470
+ # we'll have to split the word itself (not implemented)
471
+ if not current_line and self.get_text_dimensions(font_filename, font_size, word)[0] > box_width:
472
+ # Just add the word anyway, it'll overflow but we can't do better without splitting words
473
+ split_lines.append([word])
474
+ continue
475
+
476
+ # Try adding the word to current line
477
+ new_line = " ".join(current_line + [word]) if current_line else word
478
+ size = self.get_text_dimensions(font_filename, font_size, new_line)
479
+ if size[0] <= box_width:
480
+ current_line.append(word)
481
+ else:
482
+ # This word doesn't fit, start new line
483
+ if current_line: # Only if we have a current line to add
484
+ split_lines.append(current_line)
485
+ current_line = [word]
486
+
487
+ # Add the last line if it has content
488
+ if current_line:
489
+ split_lines.append(current_line)
490
+
491
+ # Join the words in each line with spaces
492
+ lines = [" ".join(line) for line in split_lines]
493
+ return lines
494
+
495
+ def write_text_box(
496
+ self,
497
+ text: str,
498
+ font_filename: str,
499
+ xy: PositionType,
500
+ box_width: Union[int, float] | None = None,
501
+ font_size: int = 11,
502
+ text_color: RGBColor = (0, 0, 0),
503
+ background_color: RGBAColor | None = None,
504
+ background_padding: int = 0,
505
+ place: TextAlign = TextAlign.LEFT,
506
+ anchor: AnchorPoint = AnchorPoint.TOP_LEFT,
507
+ margin: MarginType = 0,
508
+ ) -> tuple[int, int]:
509
+ """
510
+ Write text in a box with advanced positioning and alignment options.
511
+
512
+ Args:
513
+ text: Text to be written inside the box
514
+ font_filename: Path to the font file
515
+ xy: Position (x,y) either as absolute pixels (int) or relative to frame (float 0-1)
516
+ box_width: Width of the box in pixels (int) or relative to frame width (float 0-1)
517
+ font_size: Font size in points
518
+ text_color: RGB color of the text
519
+ background_color: If set, adds background color to the text box. Expects RGBA values.
520
+ background_padding: Number of padding pixels to add when adding text background color
521
+ place: Text alignment within the box (TextAlign.LEFT, TextAlign.RIGHT, TextAlign.CENTER)
522
+ anchor: Which part of the text box to anchor at the position
523
+ margin: Margin in pixels (single value or [top, right, bottom, left])
524
+
525
+ Returns:
526
+ Coordinates of the lower-right corner of the written text box (x, y)
527
+
528
+ Raises:
529
+ ValueError: If text is empty or parameters are invalid
530
+ OutOfBoundsError: If text box would be outside image bounds
531
+ """
532
+ if not text:
533
+ raise ValueError("Text cannot be empty")
534
+
535
+ if not font_filename:
536
+ raise ValueError("Font filename cannot be empty")
537
+
538
+ if font_size <= 0:
539
+ raise ValueError("Font size must be positive")
540
+
541
+ if background_padding < 0:
542
+ raise ValueError("Background padding cannot be negative")
543
+
544
+ # Process margins to determine available area
545
+ margin_top, margin_right, margin_bottom, margin_left = self._process_margin(margin)
546
+ available_width = self.image_size[0] - margin_left - margin_right
547
+ available_height = self.image_size[1] - margin_top - margin_bottom
548
+
549
+ # Handle relative box width
550
+ if box_width is None:
551
+ box_width = available_width
552
+ elif isinstance(box_width, float) and 0 < box_width <= 1:
553
+ box_width = int(available_width * box_width)
554
+ elif isinstance(box_width, int) and box_width <= 0:
555
+ raise ValueError("Box width must be positive")
556
+
557
+ # Calculate initial position based on margin and anchor before splitting text
558
+ x_pos, y_pos = self._convert_position(xy, margin_top, margin_left, available_width, available_height)
559
+
560
+ # Split text into lines that fit within box_width
561
+ lines = self._split_lines_by_width(text, font_filename, font_size, int(box_width))
562
+
563
+ # Calculate total height of all lines
564
+ lines_height = sum([self.get_text_dimensions(font_filename, font_size, line)[1] for line in lines])
565
+ if lines_height == 0:
566
+ # If we have no valid lines or zero height, return the position
567
+ return (int(x_pos), int(y_pos))
568
+
569
+ # Final position calculation based on anchor point
570
+ if anchor in AnchorPoint.center_anchors():
571
+ x_pos -= box_width // 2
572
+ elif anchor in AnchorPoint.right_anchors():
573
+ x_pos -= box_width
574
+
575
+ if anchor in AnchorPoint.middle_anchors():
576
+ y_pos -= lines_height // 2
577
+ elif anchor in AnchorPoint.bottom_anchors():
578
+ y_pos -= lines_height
579
+
580
+ # Verify box will fit within bounds
581
+ if (
582
+ x_pos < 0
583
+ or y_pos < 0
584
+ or x_pos + box_width > self.image_size[0]
585
+ or y_pos + lines_height > self.image_size[1]
586
+ ):
587
+ raise OutOfBoundsError(
588
+ f"Text box with size ({box_width}x{lines_height}) at position ({x_pos}, {y_pos}) is out of bounds!"
589
+ )
590
+
591
+ # Write lines
592
+ current_text_height = y_pos
593
+ for line in lines:
594
+ line_dimensions = self.get_text_dimensions(font_filename, font_size, line)
595
+
596
+ # Calculate horizontal position based on alignment
597
+ if place == TextAlign.LEFT:
598
+ x_left = x_pos
599
+ elif place == TextAlign.RIGHT:
600
+ x_left = x_pos + box_width - line_dimensions[0]
601
+ elif place == TextAlign.CENTER:
602
+ x_left = int(x_pos + ((box_width - line_dimensions[0]) / 2))
603
+ else:
604
+ valid_places = [e.value for e in TextAlign]
605
+ raise ValueError(f"Place '{place}' is not supported. Must be one of: {', '.join(valid_places)}")
606
+
607
+ # Write the line
608
+ self.write_text(
609
+ text=line,
610
+ font_filename=font_filename,
611
+ xy=(x_left, current_text_height),
612
+ font_size=font_size,
613
+ color=text_color,
614
+ )
615
+
616
+ # Increment vertical position for next line
617
+ current_text_height += line_dimensions[1]
618
+
619
+ # Add background color for the text if specified
620
+ if background_color is not None:
621
+ if len(background_color) != 4:
622
+ raise ValueError(f"Text background color {background_color} must be RGBA (4 values)!")
623
+
624
+ img = self.img_array
625
+
626
+ # Find bounding rectangle for written text
627
+ # Skip if the box is empty
628
+ if y_pos >= current_text_height or x_pos >= x_pos + box_width:
629
+ return (int(x_pos + box_width), int(current_text_height))
630
+
631
+ # Get the slice of the image containing the text box
632
+ box_slice = img[int(y_pos) : int(current_text_height), int(x_pos) : int(x_pos + box_width)]
633
+ if box_slice.size == 0: # Empty slice
634
+ return (int(x_pos + box_width), int(current_text_height))
635
+
636
+ # Create mask of non-zero pixels (text)
637
+ text_mask = np.any(box_slice != 0, axis=2).astype(np.uint8)
638
+ if not isinstance(text_mask, np.ndarray):
639
+ raise TypeError(f"The returned text mask is of type {type(text_mask)}, but it should be numpy array!")
640
+
641
+ # If no text pixels found, return without background
642
+ if not np.any(text_mask):
643
+ return (int(x_pos + box_width), int(current_text_height))
644
+
645
+ # Find the smallest rectangle containing text
646
+ try:
647
+ xmin, xmax, ymin, ymax = self._find_smallest_bounding_rect(text_mask)
648
+ except Exception:
649
+ # If bounding rectangle calculation fails, use the whole box
650
+ xmin, xmax, ymin, ymax = 0, box_slice.shape[1] - 1, 0, box_slice.shape[0] - 1
651
+
652
+ # Get global bounding box position
653
+ xmin = int(xmin + x_pos - background_padding)
654
+ xmax = int(xmax + x_pos + background_padding)
655
+ ymin = int(ymin + y_pos - background_padding)
656
+ ymax = int(ymax + y_pos + background_padding)
657
+
658
+ # Make sure we are inside image bounds
659
+ xmin = max(0, xmin)
660
+ ymin = max(0, ymin)
661
+ xmax = min(xmax, self.image_size[0])
662
+ ymax = min(ymax, self.image_size[1])
663
+
664
+ # Skip if bounding box is invalid
665
+ if xmin >= xmax or ymin >= ymax:
666
+ return (int(x_pos + box_width), int(current_text_height))
667
+
668
+ # Slice the bounding box and find text mask
669
+ bbox_slice = img[ymin:ymax, xmin:xmax]
670
+ if bbox_slice.size == 0: # Empty slice
671
+ return (int(x_pos + box_width), int(current_text_height))
672
+
673
+ bbox_text_mask = np.any(bbox_slice != 0, axis=2).astype(np.uint8)
674
+
675
+ # Add background color outside of text
676
+ bbox_slice[~bbox_text_mask.astype(bool)] = background_color
677
+
678
+ # Handle semi-transparent pixels for smooth text blending
679
+ text_slice = bbox_slice[bbox_text_mask.astype(bool)]
680
+ if text_slice.size > 0:
681
+ text_background = text_slice[:, :3] * (np.expand_dims(text_slice[:, -1], axis=1) / 255)
682
+ color_background = (1 - (np.expand_dims(text_slice[:, -1], axis=1) / 255)) * background_color
683
+ faded_background = text_background[:, :3] + color_background[:, :3]
684
+ text_slice[:, :3] = faded_background
685
+ text_slice[:, -1] = 255 # Full opacity
686
+ bbox_slice[bbox_text_mask.astype(bool)] = text_slice
687
+
688
+ # Update the image with the background color
689
+ self.image = Image.fromarray(img)
690
+
691
+ return (int(x_pos + box_width), int(current_text_height))
692
+
693
+ def _find_smallest_bounding_rect(self, mask: np.ndarray) -> tuple[int, int, int, int]:
694
+ """
695
+ Find the smallest bounding rectangle containing non-zero values in the mask.
696
+
697
+ Args:
698
+ mask: 2D numpy array with non-zero values representing pixels of interest
699
+
700
+ Returns:
701
+ Tuple of (xmin, xmax, ymin, ymax) coordinates
702
+
703
+ Raises:
704
+ ValueError: If mask is empty or has no non-zero values
705
+ """
706
+ if mask.size == 0:
707
+ raise ValueError("Mask is empty")
708
+
709
+ # Check if mask has any non-zero values
710
+ if not np.any(mask):
711
+ raise ValueError("Mask has no non-zero values")
712
+
713
+ rows = np.any(mask, axis=1)
714
+ cols = np.any(mask, axis=0)
715
+
716
+ # Find indices of first and last True values
717
+ row_indices = np.where(rows)[0]
718
+ col_indices = np.where(cols)[0]
719
+
720
+ # Handle empty results
721
+ if len(row_indices) == 0 or len(col_indices) == 0:
722
+ raise ValueError("No bounding rectangle found")
723
+
724
+ ymin, ymax = row_indices[[0, -1]]
725
+ xmin, xmax = col_indices[[0, -1]]
726
+
727
+ return xmin, xmax, ymin, ymax