qqwjq1981 commited on
Commit
b1767f3
·
verified ·
1 Parent(s): a263a61

Update utils/polygon_utils.py

Browse files
Files changed (1) hide show
  1. utils/polygon_utils.py +172 -160
utils/polygon_utils.py CHANGED
@@ -1,177 +1,159 @@
1
  """
2
  Enhanced polygon utilities with bubble-based correction
3
  """
4
- from shapely.geometry import Polygon, MultiPoint, Point
5
  import os
6
  import cv2
7
  import numpy as np
8
- from PIL import Image, ImageDraw, ImageFont
9
  import textwrap
 
 
10
 
11
- FONT_PATH = os.path.join(os.path.dirname(__file__), "..", "NotoSansSC-Regular.ttf")
 
 
12
 
13
 
 
 
14
  def calculate_iou(poly1, poly2):
15
  """Calculate Intersection over Union between two polygons"""
16
  try:
17
  p1 = Polygon(poly1)
18
  p2 = Polygon(poly2)
19
-
20
  if not p1.is_valid:
21
  p1 = p1.buffer(0)
22
  if not p2.is_valid:
23
  p2 = p2.buffer(0)
24
-
25
  intersection = p1.intersection(p2).area
26
  union = p1.union(p2).area
27
-
28
- return intersection / union if union > 0 else 0
29
- except:
30
- return 0
 
31
 
32
 
33
  def calculate_polygon_overlap(ocr_poly, bubble_poly):
34
  """
35
- Calculate what percentage of OCR polygon is inside bubble polygon
 
36
  """
37
  try:
38
  ocr_shape = Polygon(ocr_poly)
39
  bubble_shape = Polygon(bubble_poly)
40
-
41
  if not ocr_shape.is_valid:
42
  ocr_shape = ocr_shape.buffer(0)
43
  if not bubble_shape.is_valid:
44
  bubble_shape = bubble_shape.buffer(0)
45
-
46
  intersection = ocr_shape.intersection(bubble_shape).area
47
  ocr_area = ocr_shape.area
48
-
49
- return intersection / ocr_area if ocr_area > 0 else 0
50
- except:
51
- return 0
 
52
 
53
 
54
- def find_matching_bubble(ocr_polygon, bubble_polygons, iou_threshold=0.3, overlap_threshold=0.5):
55
  """
56
- Find the best matching bubble for an OCR polygon.
57
-
58
- Uses two criteria:
59
- 1. IoU (Intersection over Union) - measures geometric similarity
60
- 2. Overlap ratio - what % of OCR box is inside bubble
61
-
62
- Returns: (best_bubble_index, best_bubble_polygon) or (None, None)
63
  """
64
  if not bubble_polygons:
65
- return None, None
66
-
67
- ocr_center = np.mean(ocr_polygon, axis=0)
68
  best_idx = None
69
- best_score = 0
70
-
71
- for idx, bubble_poly in enumerate(bubble_polygons):
72
- # Check if OCR center is inside bubble (strong indicator)
73
- try:
74
- bubble_shape = Polygon(bubble_poly)
75
- if not bubble_shape.is_valid:
76
- bubble_shape = bubble_shape.buffer(0)
77
- center_inside = bubble_shape.contains(Point(ocr_center))
78
- except:
79
- center_inside = False
80
-
81
- # Calculate overlap and IoU
82
- overlap = calculate_polygon_overlap(ocr_polygon, bubble_poly)
83
- iou = calculate_iou(ocr_polygon, bubble_poly)
84
-
85
- # Scoring: prioritize center inside + high overlap
86
- score = 0
87
- if center_inside:
88
- score += 0.5
89
- score += overlap * 0.3
90
- score += iou * 0.2
91
-
92
- if score > best_score and (overlap > overlap_threshold or iou > iou_threshold or center_inside):
93
- best_score = score
94
  best_idx = idx
95
-
96
- if best_idx is not None:
97
- return best_idx, bubble_polygons[best_idx]
98
-
99
- return None, None
100
 
 
 
101
 
102
- def correct_polygon_with_bubble(ocr_polygon, bubble_polygon, strategy="intersect"):
 
 
 
 
 
103
  """
104
  Correct OCR polygon using bubble polygon.
105
-
106
  Strategies:
107
- - "bubble": Use bubble polygon directly
108
- - "intersect": Use intersection of OCR and bubble
109
- - "expand": Expand OCR polygon to fit bubble better
110
- - "hybrid": Smart blend based on relative sizes
111
  """
112
  try:
113
  ocr_shape = Polygon(ocr_polygon)
114
  bubble_shape = Polygon(bubble_polygon)
115
-
116
  if not ocr_shape.is_valid:
117
  ocr_shape = ocr_shape.buffer(0)
118
  if not bubble_shape.is_valid:
119
  bubble_shape = bubble_shape.buffer(0)
120
-
 
121
  if strategy == "bubble":
122
- # Use bubble directly (safest for ensuring text stays in bounds)
123
- return list(bubble_shape.exterior.coords)[:-1]
124
-
125
- elif strategy == "intersect":
126
- # Use intersection region
127
- intersection = ocr_shape.intersection(bubble_shape)
128
- if intersection.is_empty or intersection.area < ocr_shape.area * 0.3:
129
- # Intersection too small, fall back to bubble
130
- return list(bubble_shape.exterior.coords)[:-1]
131
-
132
- if intersection.geom_type == 'Polygon':
133
- return list(intersection.exterior.coords)[:-1]
134
- else:
135
- # MultiPolygon or other - use largest piece
136
- polys = list(intersection.geoms) if hasattr(intersection, 'geoms') else [intersection]
137
- largest = max(polys, key=lambda p: p.area if hasattr(p, 'area') else 0)
138
- return list(largest.exterior.coords)[:-1]
139
-
140
- elif strategy == "expand":
141
- # Expand OCR box slightly toward bubble edges
142
- ocr_center = np.array(ocr_shape.centroid.coords[0])
143
- bubble_center = np.array(bubble_shape.centroid.coords[0])
144
-
145
- # If OCR is much smaller than bubble, expand it
146
- if bubble_shape.area > ocr_shape.area * 2:
147
- expanded = ocr_shape.buffer(10) # expand by 10 pixels
148
- clipped = expanded.intersection(bubble_shape)
149
- if clipped.geom_type == 'Polygon':
150
- return list(clipped.exterior.coords)[:-1]
151
-
152
- return list(bubble_shape.exterior.coords)[:-1]
153
-
154
- elif strategy == "hybrid":
155
- # Smart strategy: choose based on size ratio
156
- size_ratio = bubble_shape.area / ocr_shape.area if ocr_shape.area > 0 else 999
157
-
158
  if size_ratio > 3:
159
- # Bubble much larger - likely OCR missed some text area
160
- # Use slightly shrunk bubble
161
  shrunk = bubble_shape.buffer(-5)
162
  if shrunk.is_empty:
163
- return list(bubble_shape.exterior.coords)[:-1]
164
- return list(shrunk.exterior.coords)[:-1]
165
  elif size_ratio < 1.5:
166
- # Similar sizes - use intersection
167
  return correct_polygon_with_bubble(ocr_polygon, bubble_polygon, "intersect")
168
  else:
169
- # Moderate difference - use bubble
170
- return list(bubble_shape.exterior.coords)[:-1]
171
-
172
  # Fallback
173
- return list(bubble_shape.exterior.coords)[:-1]
174
-
175
  except Exception as e:
176
  print(f"⚠️ Polygon correction failed: {e}, using original OCR polygon")
177
  return ocr_polygon
@@ -180,58 +162,58 @@ def correct_polygon_with_bubble(ocr_polygon, bubble_polygon, strategy="intersect
180
  def correct_ocr_polygons_with_bubbles(translations, bubble_polygons, strategy="hybrid"):
181
  """
182
  Correct all OCR polygons using detected bubbles.
183
-
184
- Args:
185
- translations: List of OCR results with 'polygon' key
186
- bubble_polygons: List of detected bubble polygons
187
- strategy: Correction strategy (see correct_polygon_with_bubble)
188
-
189
  Returns:
190
- Updated translations with corrected polygons
191
  """
192
  corrected = []
193
- unmatched_ocr = []
194
-
195
  for t in translations:
196
  ocr_poly = t.get("polygon") or t.get("polygons")
197
  if not ocr_poly:
198
  corrected.append(t)
199
  continue
200
-
201
- bubble_idx, bubble_poly = find_matching_bubble(ocr_poly, bubble_polygons)
202
-
203
- if bubble_poly is not None:
204
- # Correct the polygon
 
 
 
205
  corrected_poly = correct_polygon_with_bubble(ocr_poly, bubble_poly, strategy)
206
-
207
- t_copy = t.copy()
208
  t_copy["polygon"] = corrected_poly
209
- t_copy["original_polygon"] = ocr_poly # keep for debugging
210
- t_copy["matched_bubble_idx"] = bubble_idx
211
- corrected.append(t_copy)
212
  else:
213
- # No matching bubble - keep original
214
- t_copy = t.copy()
215
  t_copy["matched_bubble_idx"] = None
216
- corrected.append(t_copy)
217
- unmatched_ocr.append(t)
218
-
219
- if unmatched_ocr:
220
- print(f"ℹ️ {len(unmatched_ocr)}/{len(translations)} OCR regions had no matching bubble")
221
-
 
 
222
  return corrected
223
 
224
 
225
- # ========================================================================
226
- # Existing utility functions (kept for compatibility)
227
- # ========================================================================
228
 
229
  def shrink_or_expand_polygon(polygon, shrink_ratio=0.9):
230
  """
231
  Resize a polygon around its centroid.
232
- ratio < 1 → shrink
233
- ratio > 1 → expand
234
  """
 
 
 
235
  ratio = shrink_ratio
236
  cx = sum(x for x, _ in polygon) / len(polygon)
237
  cy = sum(y for _, y in polygon) / len(polygon)
@@ -249,12 +231,7 @@ def inpaint_polygon(img: Image.Image, polygon, mode="auto", fallback_color=(255,
249
  pts = np.array(polygon, np.int32).reshape((-1, 1, 2))
250
  cv2.fillPoly(mask, [pts], 255)
251
 
252
- if mode == "fill":
253
- img_copy = img.copy()
254
- draw = ImageDraw.Draw(img_copy)
255
- draw.polygon(polygon, fill=fallback_color)
256
- return img_copy
257
-
258
  img_copy = img.copy()
259
  draw = ImageDraw.Draw(img_copy)
260
  draw.polygon(polygon, fill=fallback_color)
@@ -263,37 +240,65 @@ def inpaint_polygon(img: Image.Image, polygon, mode="auto", fallback_color=(255,
263
 
264
  def merge_polygons_to_convex_hull(polygons):
265
  points = [pt for poly in polygons for pt in poly]
 
 
266
  hull = MultiPoint(points).convex_hull
267
- return list(hull.exterior.coords)
268
 
269
 
270
- def render_translated_chunk(img: Image.Image, translations, font_path="NotoSansSC-Regular.ttf", font_scale=1.0):
271
- img_copy = img.copy()
272
 
 
 
 
 
 
273
  for entry in translations:
274
  polygon = entry.get("polygon") or entry.get("polygons")
275
  text = entry.get("translated", "")
276
 
277
  if polygon and text:
278
- img_copy = draw_translated_text_convex(img_copy, polygon, text, font_path, font_scale)
279
-
 
 
 
 
 
280
  return img_copy
281
 
282
 
283
- def draw_translated_text_convex(img, polygon_coords, text, font_path="NotoSansSC-Regular.ttf", font_scale=1.0):
 
 
 
 
 
 
284
  font_polygon = polygon_coords
285
  render_polygon = shrink_or_expand_polygon(polygon_coords, shrink_ratio=0.9)
 
286
  img = inpaint_polygon(img, render_polygon, mode="auto", fallback_color=(255, 255, 255))
 
287
  debug_color = (180, 255, 180)
288
  draw = ImageDraw.Draw(img)
289
  draw.line(render_polygon + [render_polygon[0]], fill=debug_color, width=1)
290
- draw_wrapped_text(img, render_polygon, text, font_path, polygon_for_size=font_polygon, font_scale=font_scale)
 
 
 
 
 
 
 
 
291
  return img
292
 
293
 
294
  def draw_wrapped_text(img, polygon, text, font_path, polygon_for_size=None, font_scale=1.0):
295
- from PIL import ImageDraw, ImageFont
296
- import textwrap
 
297
  polygon_for_size = polygon_for_size or polygon
298
  draw = ImageDraw.Draw(img)
299
  xs, ys = zip(*polygon_for_size)
@@ -301,15 +306,22 @@ def draw_wrapped_text(img, polygon, text, font_path, polygon_for_size=None, font
301
  y_min, y_max = min(ys), max(ys)
302
  box_width = x_max - x_min
303
  box_height = y_max - y_min
 
 
 
 
304
  avg_char_width = 0.4
305
- estimated_size = int(min(box_height / 1.2, box_width / (len(text) * avg_char_width)))
306
  estimated_size = max(6, estimated_size)
307
  font_size = int(estimated_size * font_scale)
 
308
  font = ImageFont.truetype(font_path, font_size)
309
  max_chars = max(1, int(box_width / (font.getbbox("A")[2] + 1)))
 
310
  wrapped = textwrap.fill(text, width=max_chars)
311
  bbox = draw.textbbox((0, 0), wrapped, font=font)
312
  text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
313
  x = x_min + (box_width - text_w) / 2
314
  y = y_min + (box_height - text_h) / 2
315
- draw.text((x, y), wrapped, font=font, fill="black", align="center")
 
 
1
  """
2
  Enhanced polygon utilities with bubble-based correction
3
  """
 
4
  import os
5
  import cv2
6
  import numpy as np
 
7
  import textwrap
8
+ from shapely.geometry import Polygon, MultiPoint, Point
9
+ from PIL import Image, ImageDraw, ImageFont
10
 
11
+ FONT_PATH = os.path.abspath(
12
+ os.path.join(os.path.dirname(__file__), "..", "NotoSansSC-Regular.ttf")
13
+ )
14
 
15
 
16
+ # ============================ Geometry Helpers ============================
17
+
18
  def calculate_iou(poly1, poly2):
19
  """Calculate Intersection over Union between two polygons"""
20
  try:
21
  p1 = Polygon(poly1)
22
  p2 = Polygon(poly2)
23
+
24
  if not p1.is_valid:
25
  p1 = p1.buffer(0)
26
  if not p2.is_valid:
27
  p2 = p2.buffer(0)
28
+
29
  intersection = p1.intersection(p2).area
30
  union = p1.union(p2).area
31
+
32
+ return intersection / union if union > 0 else 0.0
33
+ except Exception as e:
34
+ print(f"⚠️ calculate_iou failed: {e}")
35
+ return 0.0
36
 
37
 
38
  def calculate_polygon_overlap(ocr_poly, bubble_poly):
39
  """
40
+ Calculate what percentage of OCR polygon is inside bubble polygon.
41
+ overlap = area(ocr ∩ bubble) / area(ocr)
42
  """
43
  try:
44
  ocr_shape = Polygon(ocr_poly)
45
  bubble_shape = Polygon(bubble_poly)
46
+
47
  if not ocr_shape.is_valid:
48
  ocr_shape = ocr_shape.buffer(0)
49
  if not bubble_shape.is_valid:
50
  bubble_shape = bubble_shape.buffer(0)
51
+
52
  intersection = ocr_shape.intersection(bubble_shape).area
53
  ocr_area = ocr_shape.area
54
+
55
+ return intersection / ocr_area if ocr_area > 0 else 0.0
56
+ except Exception as e:
57
+ print(f"⚠️ calculate_polygon_overlap failed: {e}")
58
+ return 0.0
59
 
60
 
61
+ def match_polygon_to_bubble_by_overlap(ocr_poly, bubble_polygons, min_overlap=0.15):
62
  """
63
+ Return index of bubble with the highest overlap ratio with OCR polygon.
64
+ overlap = area(ocr ∩ bubble) / area(ocr)
65
+
66
+ If best overlap < min_overlap no match.
 
 
 
67
  """
68
  if not bubble_polygons:
69
+ return None
70
+
 
71
  best_idx = None
72
+ best_overlap = 0.0
73
+
74
+ for idx, bp in enumerate(bubble_polygons):
75
+ overlap = calculate_polygon_overlap(ocr_poly, bp)
76
+ if overlap > best_overlap:
77
+ best_overlap = overlap
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  best_idx = idx
 
 
 
 
 
79
 
80
+ if best_idx is not None and best_overlap >= min_overlap:
81
+ return best_idx
82
 
83
+ return None
84
+
85
+
86
+ # ====================== Polygon Correction with Bubbles ===================
87
+
88
+ def correct_polygon_with_bubble(ocr_polygon, bubble_polygon, strategy="hybrid"):
89
  """
90
  Correct OCR polygon using bubble polygon.
91
+
92
  Strategies:
93
+ - "bubble": Use bubble polygon directly
94
+ - "intersect": Use intersection of OCR and bubble
95
+ - "expand": Slightly expand OCR region inside bubble
96
+ - "hybrid": Choose based on relative sizes & intersection
97
  """
98
  try:
99
  ocr_shape = Polygon(ocr_polygon)
100
  bubble_shape = Polygon(bubble_polygon)
101
+
102
  if not ocr_shape.is_valid:
103
  ocr_shape = ocr_shape.buffer(0)
104
  if not bubble_shape.is_valid:
105
  bubble_shape = bubble_shape.buffer(0)
106
+
107
+ # ---- Strategy: use bubble fully ----
108
  if strategy == "bubble":
109
+ return [(int(x), int(y)) for x, y in bubble_shape.exterior.coords[:-1]]
110
+
111
+ # ---- Strategy: intersection region ----
112
+ if strategy == "intersect":
113
+ inter = ocr_shape.intersection(bubble_shape)
114
+ if inter.is_empty or inter.area < ocr_shape.area * 0.3:
115
+ # Intersection too small, bubble is safer
116
+ return [(int(x), int(y)) for x, y in bubble_shape.exterior.coords[:-1]]
117
+
118
+ if inter.geom_type == "Polygon":
119
+ return [(int(x), int(y)) for x, y in inter.exterior.coords[:-1]]
120
+
121
+ polys = list(inter.geoms) if hasattr(inter, "geoms") else [inter]
122
+ largest = max(polys, key=lambda p: p.area if hasattr(p, "area") else 0)
123
+ return [(int(x), int(y)) for x, y in largest.exterior.coords[:-1]]
124
+
125
+ # ---- Strategy: expand OCR slightly toward bubble ----
126
+ if strategy == "expand":
127
+ expanded = ocr_shape.buffer(10) # ~10px expansion
128
+ clipped = expanded.intersection(bubble_shape)
129
+ if not clipped.is_empty and clipped.area > ocr_shape.area * 0.5:
130
+ if clipped.geom_type == "Polygon":
131
+ return [(int(x), int(y)) for x, y in clipped.exterior.coords[:-1]]
132
+
133
+ return [(int(x), int(y)) for x, y in bubble_shape.exterior.coords[:-1]]
134
+
135
+ # ---- Strategy: hybrid ----
136
+ if strategy == "hybrid":
137
+ size_ratio = (
138
+ bubble_shape.area / ocr_shape.area if ocr_shape.area > 0 else 999
139
+ )
140
+
 
 
 
 
141
  if size_ratio > 3:
142
+ # Bubble is much larger than OCR region: likely multi-line speech
 
143
  shrunk = bubble_shape.buffer(-5)
144
  if shrunk.is_empty:
145
+ return [(int(x), int(y)) for x, y in bubble_shape.exterior.coords[:-1]]
146
+ return [(int(x), int(y)) for x, y in shrunk.exterior.coords[:-1]]
147
  elif size_ratio < 1.5:
148
+ # Similar sizes: use intersection
149
  return correct_polygon_with_bubble(ocr_polygon, bubble_polygon, "intersect")
150
  else:
151
+ # Moderate difference bubble is still safer
152
+ return [(int(x), int(y)) for x, y in bubble_shape.exterior.coords[:-1]]
153
+
154
  # Fallback
155
+ return [(int(x), int(y)) for x, y in bubble_shape.exterior.coords[:-1]]
156
+
157
  except Exception as e:
158
  print(f"⚠️ Polygon correction failed: {e}, using original OCR polygon")
159
  return ocr_polygon
 
162
  def correct_ocr_polygons_with_bubbles(translations, bubble_polygons, strategy="hybrid"):
163
  """
164
  Correct all OCR polygons using detected bubbles.
165
+
166
+ Adds:
167
+ - "original_polygon"
168
+ - "matched_bubble_idx"
169
+
 
170
  Returns:
171
+ updated translations list
172
  """
173
  corrected = []
174
+ unmatched = 0
175
+
176
  for t in translations:
177
  ocr_poly = t.get("polygon") or t.get("polygons")
178
  if not ocr_poly:
179
  corrected.append(t)
180
  continue
181
+
182
+ best_idx = match_polygon_to_bubble_by_overlap(ocr_poly, bubble_polygons)
183
+
184
+ t_copy = t.copy()
185
+ t_copy["original_polygon"] = ocr_poly
186
+
187
+ if best_idx is not None:
188
+ bubble_poly = bubble_polygons[best_idx]
189
  corrected_poly = correct_polygon_with_bubble(ocr_poly, bubble_poly, strategy)
 
 
190
  t_copy["polygon"] = corrected_poly
191
+ t_copy["matched_bubble_idx"] = best_idx
 
 
192
  else:
193
+ # No match keep original OCR polygon
 
194
  t_copy["matched_bubble_idx"] = None
195
+ t_copy["polygon"] = ocr_poly
196
+ unmatched += 1
197
+
198
+ corrected.append(t_copy)
199
+
200
+ if unmatched:
201
+ print(f"ℹ️ {unmatched}/{len(translations)} OCR regions had no matching bubble")
202
+
203
  return corrected
204
 
205
 
206
+ # ========================= Basic Polygon Utilities =======================
 
 
207
 
208
  def shrink_or_expand_polygon(polygon, shrink_ratio=0.9):
209
  """
210
  Resize a polygon around its centroid.
211
+ shrink_ratio < 1 → shrink
212
+ shrink_ratio > 1 → expand
213
  """
214
+ if not polygon:
215
+ return polygon
216
+
217
  ratio = shrink_ratio
218
  cx = sum(x for x, _ in polygon) / len(polygon)
219
  cy = sum(y for _, y in polygon) / len(polygon)
 
231
  pts = np.array(polygon, np.int32).reshape((-1, 1, 2))
232
  cv2.fillPoly(mask, [pts], 255)
233
 
234
+ # Could use cv2.inpaint for fancy filling; for manga bubbles simple fill is OK
 
 
 
 
 
235
  img_copy = img.copy()
236
  draw = ImageDraw.Draw(img_copy)
237
  draw.polygon(polygon, fill=fallback_color)
 
240
 
241
  def merge_polygons_to_convex_hull(polygons):
242
  points = [pt for poly in polygons for pt in poly]
243
+ if not points:
244
+ return []
245
  hull = MultiPoint(points).convex_hull
246
+ return [(int(x), int(y)) for x, y in hull.exterior.coords[:-1]]
247
 
248
 
249
+ # ======================== Rendering / Text Drawing =======================
 
250
 
251
+ def render_translated_chunk(img: Image.Image, translations, font_path=None, font_scale=1.0):
252
+ """
253
+ Render list of translations (with 'polygon' and 'translated') onto image.
254
+ """
255
+ img_copy = img.copy()
256
  for entry in translations:
257
  polygon = entry.get("polygon") or entry.get("polygons")
258
  text = entry.get("translated", "")
259
 
260
  if polygon and text:
261
+ img_copy = draw_translated_text_convex(
262
+ img_copy,
263
+ polygon,
264
+ text,
265
+ font_path=font_path or FONT_PATH,
266
+ font_scale=font_scale
267
+ )
268
  return img_copy
269
 
270
 
271
+ def draw_translated_text_convex(img, polygon_coords, text, font_path=None, font_scale=1.0):
272
+ """
273
+ Inpaint inside polygon and draw wrapped text centered in it.
274
+ """
275
+ if font_path is None:
276
+ font_path = FONT_PATH
277
+
278
  font_polygon = polygon_coords
279
  render_polygon = shrink_or_expand_polygon(polygon_coords, shrink_ratio=0.9)
280
+
281
  img = inpaint_polygon(img, render_polygon, mode="auto", fallback_color=(255, 255, 255))
282
+
283
  debug_color = (180, 255, 180)
284
  draw = ImageDraw.Draw(img)
285
  draw.line(render_polygon + [render_polygon[0]], fill=debug_color, width=1)
286
+
287
+ draw_wrapped_text(
288
+ img,
289
+ render_polygon,
290
+ text,
291
+ font_path,
292
+ polygon_for_size=font_polygon,
293
+ font_scale=font_scale,
294
+ )
295
  return img
296
 
297
 
298
  def draw_wrapped_text(img, polygon, text, font_path, polygon_for_size=None, font_scale=1.0):
299
+ """
300
+ Draw wrapped text centered in the polygon bounding box.
301
+ """
302
  polygon_for_size = polygon_for_size or polygon
303
  draw = ImageDraw.Draw(img)
304
  xs, ys = zip(*polygon_for_size)
 
306
  y_min, y_max = min(ys), max(ys)
307
  box_width = x_max - x_min
308
  box_height = y_max - y_min
309
+
310
+ if box_width <= 0 or box_height <= 0:
311
+ return
312
+
313
  avg_char_width = 0.4
314
+ estimated_size = int(min(box_height / 1.2, box_width / (len(text) * avg_char_width + 1)))
315
  estimated_size = max(6, estimated_size)
316
  font_size = int(estimated_size * font_scale)
317
+
318
  font = ImageFont.truetype(font_path, font_size)
319
  max_chars = max(1, int(box_width / (font.getbbox("A")[2] + 1)))
320
+
321
  wrapped = textwrap.fill(text, width=max_chars)
322
  bbox = draw.textbbox((0, 0), wrapped, font=font)
323
  text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
324
  x = x_min + (box_width - text_w) / 2
325
  y = y_min + (box_height - text_h) / 2
326
+
327
+ draw.text((x, y), wrapped, font=font, fill="black", align="center")