textblock.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. from typing import List
  2. import numpy as np
  3. from shapely.geometry import Polygon
  4. import math
  5. import copy
  6. from utils.imgproc_utils import union_area, xywh2xyxypoly, rotate_polygons
  7. import cv2
  8. LANG_LIST = ['eng', 'ja', 'unknown']
  9. LANGCLS2IDX = {'eng': 0, 'ja': 1, 'unknown': 2}
  10. class TextBlock(object):
  11. def __init__(self, xyxy: List,
  12. lines: List = None,
  13. language: str = 'unknown',
  14. vertical: bool = False,
  15. font_size: float = -1,
  16. distance: List = None,
  17. angle: int = 0,
  18. vec: List = None,
  19. norm: float = -1,
  20. merged: bool = False,
  21. weight: float = -1,
  22. text: List = None,
  23. translation: str = "",
  24. fg_r = 0,
  25. fg_g = 0,
  26. fg_b = 0,
  27. bg_r = 0,
  28. bg_g = 0,
  29. bg_b = 0,
  30. line_spacing = 1.,
  31. font_family: str = "",
  32. bold: bool = False,
  33. underline: bool = False,
  34. italic: bool = False,
  35. alignment: int = -1,
  36. alpha: float = 255,
  37. rich_text: str = "",
  38. _bounding_rect: List = None,
  39. accumulate_color = True,
  40. default_stroke_width = 0.2,
  41. target_lang: str = "",
  42. **kwargs) -> None:
  43. self.xyxy = [int(num) for num in xyxy] # boundingbox of textblock
  44. self.lines = [] if lines is None else lines # polygons of textlines
  45. self.vertical = vertical # orientation of textlines
  46. self.language = language
  47. self.font_size = font_size # font pixel size
  48. self.distance = None if distance is None else np.array(distance, np.float64) # distance between textlines and "origin"
  49. self.angle = angle # rotation angle of textlines
  50. self.vec = None if vec is None else np.array(vec, np.float64) # primary vector of textblock
  51. self.norm = norm # primary norm of textblock
  52. self.merged = merged
  53. self.weight = weight
  54. self.text = text if text is not None else []
  55. self.prob = 1
  56. self.translation = translation
  57. # note they're accumulative rgb values of textlines
  58. self.fg_r = fg_r
  59. self.fg_g = fg_g
  60. self.fg_b = fg_b
  61. self.bg_r = bg_r
  62. self.bg_g = bg_g
  63. self.bg_b = bg_b
  64. # self.stroke_width = stroke_width
  65. self.font_family: str = font_family
  66. self.bold: bool = bold
  67. self.underline: bool = underline
  68. self.italic: bool = italic
  69. self.alpha = alpha
  70. self.rich_text = rich_text
  71. self.line_spacing = line_spacing
  72. # self.alignment = alignment
  73. self._alignment = alignment
  74. self._target_lang = target_lang
  75. self._bounding_rect = _bounding_rect
  76. self.default_stroke_width = default_stroke_width
  77. self.accumulate_color = accumulate_color
  78. def adjust_bbox(self, with_bbox=False):
  79. lines = self.lines_array().astype(np.int32)
  80. if with_bbox:
  81. self.xyxy[0] = min(lines[..., 0].min(), self.xyxy[0])
  82. self.xyxy[1] = min(lines[..., 1].min(), self.xyxy[1])
  83. self.xyxy[2] = max(lines[..., 0].max(), self.xyxy[2])
  84. self.xyxy[3] = max(lines[..., 1].max(), self.xyxy[3])
  85. else:
  86. self.xyxy[0] = lines[..., 0].min()
  87. self.xyxy[1] = lines[..., 1].min()
  88. self.xyxy[2] = lines[..., 0].max()
  89. self.xyxy[3] = lines[..., 1].max()
  90. def sort_lines(self):
  91. if self.distance is not None:
  92. idx = np.argsort(self.distance)
  93. self.distance = self.distance[idx]
  94. lines = np.array(self.lines, dtype=np.int32)
  95. self.lines = lines[idx].tolist()
  96. def lines_array(self, dtype=np.float64):
  97. return np.array(self.lines, dtype=dtype)
  98. def aspect_ratio(self) -> float:
  99. min_rect = self.min_rect()
  100. middle_pnts = (min_rect[:, [1, 2, 3, 0]] + min_rect) / 2
  101. norm_v = np.linalg.norm(middle_pnts[:, 2] - middle_pnts[:, 0])
  102. norm_h = np.linalg.norm(middle_pnts[:, 1] - middle_pnts[:, 3])
  103. return norm_v / norm_h
  104. def center(self):
  105. xyxy = np.array(self.xyxy)
  106. return (xyxy[:2] + xyxy[2:]) / 2
  107. def min_rect(self, rotate_back=True):
  108. angled = self.angle != 0
  109. center = self.center()
  110. polygons = self.lines_array().reshape(-1, 8)
  111. if angled:
  112. polygons = rotate_polygons(center, polygons, self.angle)
  113. min_x = polygons[:, ::2].min()
  114. min_y = polygons[:, 1::2].min()
  115. max_x = polygons[:, ::2].max()
  116. max_y = polygons[:, 1::2].max()
  117. min_bbox = np.array([[min_x, min_y, max_x, min_y, max_x, max_y, min_x, max_y]])
  118. if angled and rotate_back:
  119. min_bbox = rotate_polygons(center, min_bbox, -self.angle)
  120. return min_bbox.reshape(-1, 4, 2).astype(np.int64)
  121. # equivalent to qt's boundingRect, ignore angle
  122. def bounding_rect(self):
  123. if self._bounding_rect is None:
  124. # if True:
  125. min_bbox = self.min_rect(rotate_back=False)[0]
  126. x, y = min_bbox[0]
  127. w, h = min_bbox[2] - min_bbox[0]
  128. return [x, y, w, h]
  129. return self._bounding_rect
  130. def __getattribute__(self, name: str):
  131. if name == 'pts':
  132. return self.lines_array()
  133. # else:
  134. return object.__getattribute__(self, name)
  135. def __len__(self):
  136. return len(self.lines)
  137. def __getitem__(self, idx):
  138. return self.lines[idx]
  139. def to_dict(self):
  140. blk_dict = copy.deepcopy(vars(self))
  141. return blk_dict
  142. def get_transformed_region(self, img, idx, textheight) -> np.ndarray :
  143. im_h, im_w = img.shape[:2]
  144. direction = 'v' if self.vertical else 'h'
  145. src_pts = np.array(self.lines[idx], dtype=np.float64)
  146. if self.language == 'eng' or (self.language == 'unknown' and not self.vertical):
  147. e_size = self.font_size / 3
  148. src_pts[..., 0] += np.array([-e_size, e_size, e_size, -e_size])
  149. src_pts[..., 1] += np.array([-e_size, -e_size, e_size, e_size])
  150. src_pts[..., 0] = np.clip(src_pts[..., 0], 0, im_w)
  151. src_pts[..., 1] = np.clip(src_pts[..., 1], 0, im_h)
  152. middle_pnt = (src_pts[[1, 2, 3, 0]] + src_pts) / 2
  153. vec_v = middle_pnt[2] - middle_pnt[0] # vertical vectors of textlines
  154. vec_h = middle_pnt[1] - middle_pnt[3] # horizontal vectors of textlines
  155. ratio = np.linalg.norm(vec_v) / np.linalg.norm(vec_h)
  156. if direction == 'h' :
  157. h = int(textheight)
  158. w = int(round(textheight / ratio))
  159. dst_pts = np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]]).astype(np.float32)
  160. M, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
  161. region = cv2.warpPerspective(img, M, (w, h))
  162. elif direction == 'v' :
  163. w = int(textheight)
  164. h = int(round(textheight * ratio))
  165. dst_pts = np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]]).astype(np.float32)
  166. M, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
  167. region = cv2.warpPerspective(img, M, (w, h))
  168. region = cv2.rotate(region, cv2.ROTATE_90_COUNTERCLOCKWISE)
  169. # cv2.imshow('region'+str(idx), region)
  170. # cv2.waitKey(0)
  171. return region
  172. def get_text(self):
  173. if isinstance(self.text, str):
  174. return self.text
  175. return ' '.join(self.text).strip()
  176. def set_font_colors(self, frgb, srgb, accumulate=True):
  177. self.accumulate_color = accumulate
  178. num_lines = len(self.lines) if accumulate and len(self.lines) > 0 else 1
  179. # set font color
  180. frgb = np.array(frgb) * num_lines
  181. self.fg_r, self.fg_g, self.fg_b = frgb
  182. # set stroke color
  183. srgb = np.array(srgb) * num_lines
  184. self.bg_r, self.bg_g, self.bg_b = srgb
  185. def get_font_colors(self, bgr=False):
  186. num_lines = len(self.lines)
  187. frgb = np.array([self.fg_r, self.fg_g, self.fg_b])
  188. brgb = np.array([self.bg_r, self.bg_g, self.bg_b])
  189. if self.accumulate_color:
  190. if num_lines > 0:
  191. frgb = (frgb / num_lines).astype(np.int32)
  192. brgb = (brgb / num_lines).astype(np.int32)
  193. if bgr:
  194. return frgb[::-1], brgb[::-1]
  195. else:
  196. return frgb, brgb
  197. else:
  198. return [0, 0, 0], [0, 0, 0]
  199. else:
  200. return frgb, brgb
  201. def xywh(self):
  202. x, y, w, h = self.xyxy
  203. return [x, y, w-x, h-y]
  204. # alignleft: 0, center: 1, right: 2
  205. def alignment(self):
  206. if self._alignment >= 0:
  207. return self._alignment
  208. elif self.vertical:
  209. return 0
  210. lines = self.lines_array()
  211. if len(lines) == 1:
  212. return 0
  213. angled = self.angle != 0
  214. polygons = lines.reshape(-1, 8)
  215. if angled:
  216. polygons = rotate_polygons((0, 0), polygons, self.angle)
  217. polygons = polygons.reshape(-1, 4, 2)
  218. left_std = np.std(polygons[:, 0, 0])
  219. # right_std = np.std(polygons[:, 1, 0])
  220. center_std = np.std((polygons[:, 0, 0] + polygons[:, 1, 0]) / 2)
  221. if left_std < center_std:
  222. return 0
  223. else:
  224. return 1
  225. def target_lang(self):
  226. return self.target_lang
  227. @property
  228. def stroke_width(self):
  229. var = np.array([self.fg_r, self.fg_g, self.fg_b]) \
  230. - np.array([self.bg_r, self.bg_g, self.bg_b])
  231. var = np.abs(var).sum()
  232. if var > 40:
  233. return self.default_stroke_width
  234. return 0
  235. def sort_textblk_list(blk_list: List[TextBlock], im_w: int, im_h: int) -> List[TextBlock]:
  236. if len(blk_list) == 0:
  237. return blk_list
  238. num_ja = 0
  239. xyxy = []
  240. for blk in blk_list:
  241. if blk.language == 'ja':
  242. num_ja += 1
  243. xyxy.append(blk.xyxy)
  244. xyxy = np.array(xyxy)
  245. flip_lr = num_ja > len(blk_list) / 2
  246. im_oriw = im_w
  247. if im_w > im_h:
  248. im_w /= 2
  249. num_gridy, num_gridx = 4, 3
  250. img_area = im_h * im_w
  251. center_x = (xyxy[:, 0] + xyxy[:, 2]) / 2
  252. if flip_lr:
  253. if im_w != im_oriw:
  254. center_x = im_oriw - center_x
  255. else:
  256. center_x = im_w - center_x
  257. grid_x = (center_x / im_w * num_gridx).astype(np.int32)
  258. center_y = (xyxy[:, 1] + xyxy[:, 3]) / 2
  259. grid_y = (center_y / im_h * num_gridy).astype(np.int32)
  260. grid_indices = grid_y * num_gridx + grid_x
  261. grid_weights = grid_indices * img_area + 1.2 * (center_x - grid_x * im_w / num_gridx) + (center_y - grid_y * im_h / num_gridy)
  262. if im_w != im_oriw:
  263. grid_weights[np.where(grid_x >= num_gridx)] += img_area * num_gridy * num_gridx
  264. for blk, weight in zip(blk_list, grid_weights):
  265. blk.weight = weight
  266. blk_list.sort(key=lambda blk: blk.weight)
  267. return blk_list
  268. def examine_textblk(blk: TextBlock, im_w: int, im_h: int, sort: bool = False) -> None:
  269. lines = blk.lines_array()
  270. middle_pnts = (lines[:, [1, 2, 3, 0]] + lines) / 2
  271. vec_v = middle_pnts[:, 2] - middle_pnts[:, 0] # vertical vectors of textlines
  272. vec_h = middle_pnts[:, 1] - middle_pnts[:, 3] # horizontal vectors of textlines
  273. # if sum of vertical vectors is longer, then text orientation is vertical, and vice versa.
  274. center_pnts = (lines[:, 0] + lines[:, 2]) / 2
  275. v = np.sum(vec_v, axis=0)
  276. h = np.sum(vec_h, axis=0)
  277. norm_v, norm_h = np.linalg.norm(v), np.linalg.norm(h)
  278. if blk.language == 'ja':
  279. vertical = norm_v > norm_h
  280. else:
  281. vertical = norm_v > norm_h * 2
  282. # calculate distance between textlines and origin
  283. if vertical:
  284. primary_vec, primary_norm = v, norm_v
  285. distance_vectors = center_pnts - np.array([[im_w, 0]], dtype=np.float64) # vertical manga text is read from right to left, so origin is (imw, 0)
  286. font_size = int(round(norm_h / len(lines)))
  287. else:
  288. primary_vec, primary_norm = h, norm_h
  289. distance_vectors = center_pnts - np.array([[0, 0]], dtype=np.float64)
  290. font_size = int(round(norm_v / len(lines)))
  291. rotation_angle = int(math.atan2(primary_vec[1], primary_vec[0]) / math.pi * 180) # rotation angle of textlines
  292. distance = np.linalg.norm(distance_vectors, axis=1) # distance between textlinecenters and origin
  293. rad_matrix = np.arccos(np.einsum('ij, j->i', distance_vectors, primary_vec) / (distance * primary_norm))
  294. distance = np.abs(np.sin(rad_matrix) * distance)
  295. blk.lines = lines.astype(np.int32).tolist()
  296. blk.distance = distance
  297. blk.angle = rotation_angle
  298. if vertical:
  299. blk.angle -= 90
  300. if abs(blk.angle) < 3:
  301. blk.angle = 0
  302. blk.font_size = font_size
  303. blk.vertical = vertical
  304. blk.vec = primary_vec
  305. blk.norm = primary_norm
  306. if sort:
  307. blk.sort_lines()
  308. def try_merge_textline(blk: TextBlock, blk2: TextBlock, fntsize_tol=1.3, distance_tol=2) -> bool:
  309. if blk2.merged:
  310. return False
  311. fntsize_div = blk.font_size / blk2.font_size
  312. num_l1, num_l2 = len(blk), len(blk2)
  313. fntsz_avg = (blk.font_size * num_l1 + blk2.font_size * num_l2) / (num_l1 + num_l2)
  314. vec_prod = blk.vec @ blk2.vec
  315. vec_sum = blk.vec + blk2.vec
  316. cos_vec = vec_prod / blk.norm / blk2.norm
  317. distance = blk2.distance[-1] - blk.distance[-1]
  318. distance_p1 = np.linalg.norm(np.array(blk2.lines[-1][0]) - np.array(blk.lines[-1][0]))
  319. l1, l2 = Polygon(blk.lines[-1]), Polygon(blk2.lines[-1])
  320. if not l1.intersects(l2):
  321. if fntsize_div > fntsize_tol or 1 / fntsize_div > fntsize_tol:
  322. return False
  323. if abs(cos_vec) < 0.866: # cos30
  324. return False
  325. if distance > distance_tol * fntsz_avg or distance_p1 > fntsz_avg * 2.5:
  326. return False
  327. # merge
  328. blk.lines.append(blk2.lines[0])
  329. blk.vec = vec_sum
  330. blk.angle = int(round(np.rad2deg(math.atan2(vec_sum[1], vec_sum[0]))))
  331. if blk.vertical:
  332. blk.angle -= 90
  333. blk.norm = np.linalg.norm(vec_sum)
  334. blk.distance = np.append(blk.distance, blk2.distance[-1])
  335. blk.font_size = fntsz_avg
  336. blk2.merged = True
  337. return True
  338. def merge_textlines(blk_list: List[TextBlock]) -> List[TextBlock]:
  339. if len(blk_list) < 2:
  340. return blk_list
  341. blk_list.sort(key=lambda blk: blk.distance[0])
  342. merged_list = []
  343. for ii, current_blk in enumerate(blk_list):
  344. if current_blk.merged:
  345. continue
  346. for jj, blk in enumerate(blk_list[ii+1:]):
  347. try_merge_textline(current_blk, blk)
  348. merged_list.append(current_blk)
  349. for blk in merged_list:
  350. blk.adjust_bbox(with_bbox=False)
  351. return merged_list
  352. def split_textblk(blk: TextBlock):
  353. font_size, distance, lines = blk.font_size, blk.distance, blk.lines
  354. l0 = np.array(blk.lines[0])
  355. lines.sort(key=lambda line: np.linalg.norm(np.array(line[0]) - l0[0]))
  356. distance_tol = font_size * 2
  357. current_blk = copy.deepcopy(blk)
  358. current_blk.lines = [l0]
  359. sub_blk_list = [current_blk]
  360. textblock_splitted = False
  361. for jj, line in enumerate(lines[1:]):
  362. l1, l2 = Polygon(lines[jj]), Polygon(line)
  363. split = False
  364. if not l1.intersects(l2):
  365. line_disance = abs(distance[jj+1] - distance[jj])
  366. if line_disance > distance_tol:
  367. split = True
  368. elif blk.vertical and abs(blk.angle) < 15:
  369. if len(current_blk.lines) > 1 or line_disance > font_size:
  370. split = abs(lines[jj][0][1] - line[0][1]) > font_size
  371. if split:
  372. current_blk = copy.deepcopy(current_blk)
  373. current_blk.lines = [line]
  374. sub_blk_list.append(current_blk)
  375. else:
  376. current_blk.lines.append(line)
  377. if len(sub_blk_list) > 1:
  378. textblock_splitted = True
  379. for current_blk in sub_blk_list:
  380. current_blk.adjust_bbox(with_bbox=False)
  381. return textblock_splitted, sub_blk_list
  382. def group_output(blks, lines, im_w, im_h, mask=None, sort_blklist=True) -> List[TextBlock]:
  383. blk_list: List[TextBlock] = []
  384. scattered_lines = {'ver': [], 'hor': []}
  385. for bbox, cls, conf in zip(*blks):
  386. # cls could give wrong result
  387. blk_list.append(TextBlock(bbox, language=LANG_LIST[cls]))
  388. # step1: filter & assign lines to textblocks
  389. bbox_score_thresh = 0.4
  390. mask_score_thresh = 0.1
  391. for ii, line in enumerate(lines):
  392. bx1, bx2 = line[:, 0].min(), line[:, 0].max()
  393. by1, by2 = line[:, 1].min(), line[:, 1].max()
  394. bbox_score, bbox_idx = -1, -1
  395. line_area = (by2-by1) * (bx2-bx1)
  396. for jj, blk in enumerate(blk_list):
  397. score = union_area(blk.xyxy, [bx1, by1, bx2, by2]) / line_area
  398. if bbox_score < score:
  399. bbox_score = score
  400. bbox_idx = jj
  401. if bbox_score > bbox_score_thresh:
  402. blk_list[bbox_idx].lines.append(line)
  403. else: # if no textblock was assigned, check whether there is "enough" textmask
  404. if mask is not None:
  405. mask_score = mask[by1: by2, bx1: bx2].mean() / 255
  406. if mask_score < mask_score_thresh:
  407. continue
  408. blk = TextBlock([bx1, by1, bx2, by2], [line])
  409. examine_textblk(blk, im_w, im_h, sort=False)
  410. if blk.vertical:
  411. scattered_lines['ver'].append(blk)
  412. else:
  413. scattered_lines['hor'].append(blk)
  414. # step2: filter textblocks, sort & split textlines
  415. final_blk_list = []
  416. for blk in blk_list:
  417. # filter textblocks
  418. if len(blk.lines) == 0:
  419. bx1, by1, bx2, by2 = blk.xyxy
  420. if mask is not None:
  421. mask_score = mask[by1: by2, bx1: bx2].mean() / 255
  422. if mask_score < mask_score_thresh:
  423. continue
  424. xywh = np.array([[bx1, by1, bx2-bx1, by2-by1]])
  425. blk.lines = xywh2xyxypoly(xywh).reshape(-1, 4, 2).tolist()
  426. examine_textblk(blk, im_w, im_h, sort=True)
  427. # split manga text if there is a distance gap
  428. textblock_splitted = False
  429. if len(blk.lines) > 1:
  430. if blk.language == 'ja':
  431. textblock_splitted = True
  432. elif blk.vertical:
  433. textblock_splitted = True
  434. if textblock_splitted:
  435. textblock_splitted, sub_blk_list = split_textblk(blk)
  436. else:
  437. sub_blk_list = [blk]
  438. # modify textblock to fit its textlines
  439. if not textblock_splitted:
  440. for blk in sub_blk_list:
  441. blk.adjust_bbox(with_bbox=True)
  442. final_blk_list += sub_blk_list
  443. # step3: merge scattered lines, sort textblocks by "grid"
  444. final_blk_list += merge_textlines(scattered_lines['hor'])
  445. final_blk_list += merge_textlines(scattered_lines['ver'])
  446. if sort_blklist:
  447. final_blk_list = sort_textblk_list(final_blk_list, im_w, im_h)
  448. for blk in final_blk_list:
  449. if blk.language == 'eng' and not blk.vertical:
  450. num_lines = len(blk.lines)
  451. if num_lines == 0:
  452. continue
  453. # blk.line_spacing = blk.bounding_rect()[3] / num_lines / blk.font_size
  454. expand_size = max(int(blk.font_size * 0.1), 2)
  455. rad = np.deg2rad(blk.angle)
  456. shifted_vec = np.array([[[-1, -1],[1, -1],[1, 1],[-1, 1]]])
  457. shifted_vec = shifted_vec * np.array([[[np.sin(rad), np.cos(rad)]]]) * expand_size
  458. lines = blk.lines_array() + shifted_vec
  459. lines[..., 0] = np.clip(lines[..., 0], 0, im_w-1)
  460. lines[..., 1] = np.clip(lines[..., 1], 0, im_h-1)
  461. blk.lines = lines.astype(np.int64).tolist()
  462. blk.font_size += expand_size
  463. return final_blk_list
  464. def visualize_textblocks(canvas, blk_list: List[TextBlock]):
  465. lw = max(round(sum(canvas.shape) / 2 * 0.003), 2) # line width
  466. for ii, blk in enumerate(blk_list):
  467. bx1, by1, bx2, by2 = blk.xyxy
  468. cv2.rectangle(canvas, (bx1, by1), (bx2, by2), (127, 255, 127), lw)
  469. lines = blk.lines_array(dtype=np.int32)
  470. for jj, line in enumerate(lines):
  471. cv2.putText(canvas, str(jj), line[0], cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255,127,0), 1)
  472. cv2.polylines(canvas, [line], True, (0,127,255), 2)
  473. cv2.polylines(canvas, [blk.min_rect()], True, (127,127,0), 2)
  474. center = [int((bx1 + bx2)/2), int((by1 + by2)/2)]
  475. cv2.putText(canvas, str(blk.angle), center, cv2.FONT_HERSHEY_SIMPLEX, 1, (127,127,255), 2)
  476. cv2.putText(canvas, str(ii), (bx1, by1 + lw + 2), 0, lw / 3, (255,127,127), max(lw-1, 1), cv2.LINE_AA)
  477. return canvas