ocr_utils.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. """
  2. OCR 工具函数 - 从 MinerU 完整迁移
  3. 提供文本检测框处理、图像预处理等功能
  4. """
  5. import copy
  6. import cv2
  7. import numpy as np
  8. from typing import List, Tuple, Union
  9. # ==================== 配置常量 ====================
  10. class OcrConfidence:
  11. """OCR 置信度配置"""
  12. min_confidence = 0.5
  13. min_width = 3
  14. # 一般情况下,行宽度超过高度4倍时才是一个正常的横向文本块
  15. LINE_WIDTH_TO_HEIGHT_RATIO_THRESHOLD = 4
  16. # ==================== 图像基础处理 ====================
  17. def img_decode(content: bytes):
  18. """
  19. 解码字节流为图像
  20. Args:
  21. content: 图像字节流
  22. Returns:
  23. np.ndarray: 解码后的图像
  24. """
  25. np_arr = np.frombuffer(content, dtype=np.uint8)
  26. return cv2.imdecode(np_arr, cv2.IMREAD_UNCHANGED)
  27. def check_img(img):
  28. """
  29. 检查并转换图像格式
  30. Args:
  31. img: 图像(可以是 bytes 或 np.ndarray)
  32. Returns:
  33. np.ndarray: BGR 格式图像
  34. """
  35. if isinstance(img, bytes):
  36. img = img_decode(img)
  37. if isinstance(img, np.ndarray) and len(img.shape) == 2:
  38. img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
  39. return img
  40. def alpha_to_color(img, alpha_color=(255, 255, 255)):
  41. """
  42. 将带 alpha 通道的图像转换为 RGB
  43. Args:
  44. img: 输入图像
  45. alpha_color: 背景颜色 (B, G, R)
  46. Returns:
  47. np.ndarray: RGB 图像
  48. """
  49. if len(img.shape) == 3 and img.shape[2] == 4:
  50. B, G, R, A = cv2.split(img)
  51. alpha = A / 255
  52. R = (alpha_color[0] * (1 - alpha) + R * alpha).astype(np.uint8)
  53. G = (alpha_color[1] * (1 - alpha) + G * alpha).astype(np.uint8)
  54. B = (alpha_color[2] * (1 - alpha) + B * alpha).astype(np.uint8)
  55. img = cv2.merge((B, G, R))
  56. return img
  57. def preprocess_image(_image):
  58. """
  59. 预处理图像(去除 alpha 通道)
  60. Args:
  61. _image: 输入图像
  62. Returns:
  63. np.ndarray: 预处理后的图像
  64. """
  65. alpha_color = (255, 255, 255)
  66. _image = alpha_to_color(_image, alpha_color)
  67. return _image
  68. # ==================== 坐标转换工具 ====================
  69. def bbox_to_points(bbox):
  70. """
  71. 将 bbox 格式转换为四个顶点的数组
  72. Args:
  73. bbox: [x0, y0, x1, y1]
  74. Returns:
  75. np.ndarray: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
  76. """
  77. x0, y0, x1, y1 = bbox
  78. return np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]).astype('float32')
  79. def points_to_bbox(points):
  80. """
  81. 将四个顶点的数组转换为 bbox 格式
  82. Args:
  83. points: [[x0, y0], [x1, y1], [x2, y2], [x3, y3]]
  84. Returns:
  85. list: [x0, y0, x1, y1]
  86. """
  87. x0, y0 = points[0]
  88. x1, _ = points[1]
  89. _, y1 = points[2]
  90. return [x0, y0, x1, y1]
  91. # ==================== 检测框排序和合并 ====================
  92. def sorted_boxes(dt_boxes):
  93. """
  94. 按从上到下、从左到右的顺序排序文本框
  95. Args:
  96. dt_boxes (array): 检测到的文本框,形状为 [num, 4, 2]
  97. Returns:
  98. list: 排序后的文本框列表
  99. """
  100. num_boxes = dt_boxes.shape[0]
  101. sorted_boxes_list = sorted(dt_boxes, key=lambda x: (x[0][1], x[0][0]))
  102. _boxes = list(sorted_boxes_list)
  103. for i in range(num_boxes - 1):
  104. for j in range(i, -1, -1):
  105. if abs(_boxes[j + 1][0][1] - _boxes[j][0][1]) < 10 and \
  106. (_boxes[j + 1][0][0] < _boxes[j][0][0]):
  107. tmp = _boxes[j]
  108. _boxes[j] = _boxes[j + 1]
  109. _boxes[j + 1] = tmp
  110. else:
  111. break
  112. return _boxes
  113. def _is_overlaps_y_exceeds_threshold(bbox1, bbox2, overlap_ratio_threshold=0.8):
  114. """
  115. 检查两个 bbox 在 y 轴上是否有重叠,并且该重叠区域的高度占两个 bbox 高度更低的那个超过阈值
  116. Args:
  117. bbox1: [x0, y0, x1, y1]
  118. bbox2: [x0, y0, x1, y1]
  119. overlap_ratio_threshold: 重叠比例阈值
  120. Returns:
  121. bool: 是否满足重叠条件
  122. """
  123. _, y0_1, _, y1_1 = bbox1
  124. _, y0_2, _, y1_2 = bbox2
  125. overlap = max(0, min(y1_1, y1_2) - max(y0_1, y0_2))
  126. height1, height2 = y1_1 - y0_1, y1_2 - y0_2
  127. min_height = min(height1, height2)
  128. return (overlap / min_height) > overlap_ratio_threshold if min_height > 0 else False
  129. def _is_overlaps_x_exceeds_threshold(bbox1, bbox2, overlap_ratio_threshold=0.8):
  130. """
  131. 检查两个 bbox 在 x 轴上是否有重叠
  132. Args:
  133. bbox1: [x0, y0, x1, y1]
  134. bbox2: [x0, y0, x1, y1]
  135. overlap_ratio_threshold: 重叠比例阈值
  136. Returns:
  137. bool: 是否满足重叠条件
  138. """
  139. x0_1, _, x1_1, _ = bbox1
  140. x0_2, _, x1_2, _ = bbox2
  141. overlap = max(0, min(x1_1, x1_2) - max(x0_1, x0_2))
  142. width1, width2 = x1_1 - x0_1, x1_2 - x0_2
  143. min_width = min(width1, width2)
  144. return (overlap / min_width) > overlap_ratio_threshold if min_width > 0 else False
  145. def merge_spans_to_line(spans, threshold=0.6):
  146. """
  147. 将 spans 合并为行
  148. Args:
  149. spans: span 列表,每个 span 包含 'bbox' 字段
  150. threshold: y 轴重叠阈值
  151. Returns:
  152. list: 行列表,每行包含多个 span
  153. """
  154. if len(spans) == 0:
  155. return []
  156. # 按照 y0 坐标排序
  157. spans.sort(key=lambda span: span['bbox'][1])
  158. lines = []
  159. current_line = [spans[0]]
  160. for span in spans[1:]:
  161. # 如果当前的 span 与当前行的最后一个 span 在 y 轴上重叠,则添加到当前行
  162. if _is_overlaps_y_exceeds_threshold(span['bbox'], current_line[-1]['bbox'], threshold):
  163. current_line.append(span)
  164. else:
  165. # 否则,开始新行
  166. lines.append(current_line)
  167. current_line = [span]
  168. # 添加最后一行
  169. if current_line:
  170. lines.append(current_line)
  171. return lines
  172. def merge_overlapping_spans(spans):
  173. """
  174. 合并同一行上重叠的 spans
  175. Args:
  176. spans: span 坐标列表 [(x1, y1, x2, y2), ...]
  177. Returns:
  178. list: 合并后的 spans
  179. """
  180. if not spans:
  181. return []
  182. # 按起始 x 坐标排序
  183. spans.sort(key=lambda x: x[0])
  184. merged = []
  185. for span in spans:
  186. x1, y1, x2, y2 = span
  187. # 如果合并列表为空或没有水平重叠,直接添加
  188. if not merged or merged[-1][2] < x1:
  189. merged.append(span)
  190. else:
  191. # 如果有水平重叠,合并当前 span 和前一个
  192. last_span = merged.pop()
  193. x1 = min(last_span[0], x1)
  194. y1 = min(last_span[1], y1)
  195. x2 = max(last_span[2], x2)
  196. y2 = max(last_span[3], y2)
  197. merged.append((x1, y1, x2, y2))
  198. return merged
  199. def merge_det_boxes(dt_boxes):
  200. """
  201. 合并检测框为更大的文本区域
  202. Args:
  203. dt_boxes (list): 检测框列表,每个框由四个角点定义
  204. Returns:
  205. list: 合并后的文本区域列表
  206. """
  207. # 转换检测框为字典格式
  208. dt_boxes_dict_list = []
  209. angle_boxes_list = []
  210. for text_box in dt_boxes:
  211. text_bbox = points_to_bbox(text_box)
  212. # 跳过倾斜的框
  213. if calculate_is_angle(text_box):
  214. angle_boxes_list.append(text_box)
  215. continue
  216. text_box_dict = {'bbox': text_bbox}
  217. dt_boxes_dict_list.append(text_box_dict)
  218. # 合并相邻文本区域为行
  219. lines = merge_spans_to_line(dt_boxes_dict_list)
  220. # 初始化合并后的文本区域列表
  221. new_dt_boxes = []
  222. for line in lines:
  223. line_bbox_list = []
  224. for span in line:
  225. line_bbox_list.append(span['bbox'])
  226. # 计算整行的宽度和高度
  227. min_x = min(bbox[0] for bbox in line_bbox_list)
  228. max_x = max(bbox[2] for bbox in line_bbox_list)
  229. min_y = min(bbox[1] for bbox in line_bbox_list)
  230. max_y = max(bbox[3] for bbox in line_bbox_list)
  231. line_width = max_x - min_x
  232. line_height = max_y - min_y
  233. # 只有当行宽度超过高度4倍时才进行合并
  234. if line_width > line_height * LINE_WIDTH_TO_HEIGHT_RATIO_THRESHOLD:
  235. # 合并同一行内重叠的文本区域
  236. merged_spans = merge_overlapping_spans(line_bbox_list)
  237. # 转换回点格式并添加到新检测框列表
  238. for span in merged_spans:
  239. new_dt_boxes.append(bbox_to_points(span))
  240. else:
  241. # 不进行合并,直接添加原始区域
  242. for bbox in line_bbox_list:
  243. new_dt_boxes.append(bbox_to_points(bbox))
  244. # 添加倾斜的框
  245. new_dt_boxes.extend(angle_boxes_list)
  246. return new_dt_boxes
  247. # ==================== 区间处理工具 ====================
  248. def merge_intervals(intervals):
  249. """
  250. 合并重叠的区间
  251. Args:
  252. intervals: 区间列表 [[start, end], ...]
  253. Returns:
  254. list: 合并后的区间列表
  255. """
  256. # 按起始值排序
  257. intervals.sort(key=lambda x: x[0])
  258. merged = []
  259. for interval in intervals:
  260. # 如果合并列表为空或当前区间不重叠,直接添加
  261. if not merged or merged[-1][1] < interval[0]:
  262. merged.append(interval)
  263. else:
  264. # 否则合并当前和前一个区间
  265. merged[-1][1] = max(merged[-1][1], interval[1])
  266. return merged
  267. def remove_intervals(original, masks):
  268. """
  269. 从原始区间中移除掩码区间
  270. Args:
  271. original: 原始区间 [start, end]
  272. masks: 掩码区间列表 [[start, end], ...]
  273. Returns:
  274. list: 移除掩码后的区间列表
  275. """
  276. # 合并所有掩码区间
  277. merged_masks = merge_intervals(masks)
  278. result = []
  279. original_start, original_end = original
  280. for mask in merged_masks:
  281. mask_start, mask_end = mask
  282. # 如果掩码在原始区间之外,忽略
  283. if mask_start > original_end:
  284. continue
  285. if mask_end < original_start:
  286. continue
  287. # 移除掩码部分
  288. if original_start < mask_start:
  289. result.append([original_start, mask_start - 1])
  290. original_start = max(mask_end + 1, original_start)
  291. # 添加剩余部分
  292. if original_start <= original_end:
  293. result.append([original_start, original_end])
  294. return result
  295. # ==================== 公式检测结果处理 ====================
  296. def update_det_boxes(dt_boxes, mfd_res):
  297. """
  298. 更新检测框(移除与公式区域重叠的框)
  299. Args:
  300. dt_boxes: 文本检测框列表
  301. mfd_res: 公式检测结果列表
  302. Returns:
  303. list: 更新后的检测框列表
  304. """
  305. if mfd_res is None or len(mfd_res) == 0:
  306. return dt_boxes
  307. new_dt_boxes = []
  308. angle_boxes_list = []
  309. for text_box in dt_boxes:
  310. # 跳过倾斜的框
  311. if calculate_is_angle(text_box):
  312. angle_boxes_list.append(text_box)
  313. continue
  314. text_bbox = points_to_bbox(text_box)
  315. masks_list = []
  316. # 找出所有与文本框在 y 轴上重叠的公式框
  317. for mf_box in mfd_res:
  318. mf_bbox = mf_box['bbox']
  319. if _is_overlaps_y_exceeds_threshold(text_bbox, mf_bbox):
  320. masks_list.append([mf_bbox[0], mf_bbox[2]])
  321. # 从文本框的 x 范围中移除公式框
  322. text_x_range = [text_bbox[0], text_bbox[2]]
  323. text_remove_mask_range = remove_intervals(text_x_range, masks_list)
  324. # 为每个剩余的 x 范围创建新的文本框
  325. temp_dt_box = []
  326. for text_remove_mask in text_remove_mask_range:
  327. temp_dt_box.append(bbox_to_points([
  328. text_remove_mask[0], text_bbox[1],
  329. text_remove_mask[1], text_bbox[3]
  330. ]))
  331. if len(temp_dt_box) > 0:
  332. new_dt_boxes.extend(temp_dt_box)
  333. # 添加倾斜的框
  334. new_dt_boxes.extend(angle_boxes_list)
  335. return new_dt_boxes
  336. def get_adjusted_mfdetrec_res(single_page_mfdetrec_res, useful_list):
  337. """
  338. 调整公式检测结果的坐标
  339. Args:
  340. single_page_mfdetrec_res: 公式检测结果
  341. useful_list: 坐标调整参数 [paste_x, paste_y, xmin, ymin, xmax, ymax, new_width, new_height]
  342. Returns:
  343. list: 调整后的公式检测结果
  344. """
  345. paste_x, paste_y, xmin, ymin, xmax, ymax, new_width, new_height = useful_list
  346. adjusted_mfdetrec_res = []
  347. for mf_res in single_page_mfdetrec_res:
  348. mf_xmin, mf_ymin, mf_xmax, mf_ymax = mf_res["bbox"]
  349. # 调整坐标
  350. x0 = mf_xmin - xmin + paste_x
  351. y0 = mf_ymin - ymin + paste_y
  352. x1 = mf_xmax - xmin + paste_x
  353. y1 = mf_ymax - ymin + paste_y
  354. # 过滤图外的公式块
  355. if any([x1 < 0, y1 < 0]) or any([x0 > new_width, y0 > new_height]):
  356. continue
  357. else:
  358. adjusted_mfdetrec_res.append({
  359. "bbox": [x0, y0, x1, y1],
  360. })
  361. return adjusted_mfdetrec_res
  362. # ==================== 角度检测 ====================
  363. def calculate_is_angle(poly):
  364. """
  365. 判断多边形是否倾斜
  366. Args:
  367. poly: 四个顶点 [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
  368. Returns:
  369. bool: 是否倾斜
  370. """
  371. p1, p2, p3, p4 = poly
  372. height = ((p4[1] - p1[1]) + (p3[1] - p2[1])) / 2
  373. if 0.8 * height <= (p3[1] - p1[1]) <= 1.2 * height:
  374. return False
  375. else:
  376. return True
  377. def is_bbox_aligned_rect(points):
  378. """
  379. 判断边界框是否是轴对齐矩形
  380. Args:
  381. points: 四个顶点坐标
  382. Returns:
  383. bool: 是否是轴对齐矩形
  384. """
  385. x_coords = points[:, 0]
  386. y_coords = points[:, 1]
  387. unique_x = np.unique(x_coords)
  388. unique_y = np.unique(y_coords)
  389. return len(unique_x) == 2 and len(unique_y) == 2
  390. # ==================== 图像裁剪和旋转 ====================
  391. def get_rotate_crop_image(img, points):
  392. """
  393. 根据四个点裁剪并矫正文本区域
  394. Args:
  395. img: 输入图像
  396. points: 四个角点坐标 [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
  397. Returns:
  398. np.ndarray: 裁剪并矫正后的图像
  399. """
  400. assert len(points) == 4, "shape of points must be 4*2"
  401. # 如果是轴对齐矩形,直接裁剪
  402. if is_bbox_aligned_rect(points):
  403. xmin = int(np.min(points[:, 0]))
  404. xmax = int(np.max(points[:, 0]))
  405. ymin = int(np.min(points[:, 1]))
  406. ymax = int(np.max(points[:, 1]))
  407. new_img = img[ymin:ymax, xmin:xmax].copy()
  408. if new_img.shape[0] > 0 and new_img.shape[1] > 0:
  409. return new_img
  410. # 计算裁剪区域的宽高
  411. img_crop_width = int(
  412. max(
  413. np.linalg.norm(points[0] - points[1]),
  414. np.linalg.norm(points[2] - points[3])
  415. )
  416. )
  417. img_crop_height = int(
  418. max(
  419. np.linalg.norm(points[0] - points[3]),
  420. np.linalg.norm(points[1] - points[2])
  421. )
  422. )
  423. # 定义标准矩形的四个角点
  424. pts_std = np.float32([
  425. [0, 0],
  426. [img_crop_width, 0],
  427. [img_crop_width, img_crop_height],
  428. [0, img_crop_height]
  429. ])
  430. # 透视变换
  431. M = cv2.getPerspectiveTransform(points, pts_std)
  432. dst_img = cv2.warpPerspective(
  433. img,
  434. M,
  435. (img_crop_width, img_crop_height),
  436. borderMode=cv2.BORDER_REPLICATE,
  437. flags=cv2.INTER_CUBIC
  438. )
  439. # 如果高度远大于宽度,旋转90度
  440. dst_img_height, dst_img_width = dst_img.shape[0:2]
  441. rotate_radio = 2
  442. if dst_img_height * 1.0 / dst_img_width >= rotate_radio:
  443. dst_img = np.rot90(dst_img)
  444. return dst_img
  445. # ==================== OCR 结果处理 ====================
  446. def get_ocr_result_list(ocr_res, useful_list, ocr_enable, bgr_image, lang):
  447. """
  448. 处理 OCR 结果列表
  449. Args:
  450. ocr_res: OCR 原始结果
  451. useful_list: 坐标调整参数
  452. ocr_enable: 是否启用 OCR
  453. bgr_image: 原始图像
  454. lang: 语言
  455. Returns:
  456. list: 处理后的 OCR 结果列表
  457. """
  458. paste_x, paste_y, xmin, ymin, xmax, ymax, new_width, new_height = useful_list
  459. ocr_result_list = []
  460. ori_im = bgr_image.copy()
  461. for box_ocr_res in ocr_res:
  462. if len(box_ocr_res) == 2:
  463. p1, p2, p3, p4 = box_ocr_res[0]
  464. text, score = box_ocr_res[1]
  465. # 过滤低置信度的结果
  466. if score < OcrConfidence.min_confidence:
  467. continue
  468. else:
  469. p1, p2, p3, p4 = box_ocr_res
  470. text, score = "", 1
  471. if ocr_enable:
  472. tmp_box = copy.deepcopy(np.array([p1, p2, p3, p4]).astype('float32'))
  473. img_crop = get_rotate_crop_image(ori_im, tmp_box)
  474. poly = [p1, p2, p3, p4]
  475. # 过滤宽度太小的框
  476. if (p3[0] - p1[0]) < OcrConfidence.min_width:
  477. continue
  478. # 矫正倾斜的框
  479. if calculate_is_angle(poly):
  480. # 计算几何中心
  481. x_center = sum(point[0] for point in poly) / 4
  482. y_center = sum(point[1] for point in poly) / 4
  483. new_height = ((p4[1] - p1[1]) + (p3[1] - p2[1])) / 2
  484. new_width = p3[0] - p1[0]
  485. p1 = [x_center - new_width / 2, y_center - new_height / 2]
  486. p2 = [x_center + new_width / 2, y_center - new_height / 2]
  487. p3 = [x_center + new_width / 2, y_center + new_height / 2]
  488. p4 = [x_center - new_width / 2, y_center + new_height / 2]
  489. # 转换回原始坐标系
  490. p1 = [p1[0] - paste_x + xmin, p1[1] - paste_y + ymin]
  491. p2 = [p2[0] - paste_x + xmin, p2[1] - paste_y + ymin]
  492. p3 = [p3[0] - paste_x + xmin, p3[1] - paste_y + ymin]
  493. p4 = [p4[0] - paste_x + xmin, p4[1] - paste_y + ymin]
  494. if ocr_enable:
  495. ocr_result_list.append({
  496. 'category_id': 15,
  497. 'poly': p1 + p2 + p3 + p4,
  498. 'score': 1,
  499. 'text': text,
  500. 'np_img': img_crop,
  501. 'lang': lang,
  502. })
  503. else:
  504. ocr_result_list.append({
  505. 'category_id': 15,
  506. 'poly': p1 + p2 + p3 + p4,
  507. 'score': float(round(score, 2)),
  508. 'text': text,
  509. })
  510. return ocr_result_list
  511. # ==================== 测试代码 ====================
  512. if __name__ == "__main__":
  513. """测试 OCR 工具函数"""
  514. print("🧪 Testing OCR Utils...")
  515. # 测试 check_img
  516. print("\n1. Testing check_img")
  517. img = np.ones((100, 100, 3), dtype=np.uint8)
  518. assert check_img(img) is not None
  519. print(" ✅ check_img: PASS")
  520. # 测试 sorted_boxes
  521. print("\n2. Testing sorted_boxes")
  522. boxes = np.array([
  523. [[50, 50], [100, 50], [100, 100], [50, 100]],
  524. [[10, 10], [60, 10], [60, 60], [10, 60]],
  525. ])
  526. sorted_result = sorted_boxes(boxes)
  527. print(f" ✅ sorted_boxes: PASS (got {len(sorted_result)} boxes)")
  528. # 测试 bbox_to_points / points_to_bbox
  529. print("\n3. Testing bbox_to_points / points_to_bbox")
  530. bbox = [10, 20, 100, 200]
  531. points = bbox_to_points(bbox)
  532. bbox_back = points_to_bbox(points)
  533. assert bbox == bbox_back
  534. print(" ✅ bbox conversion: PASS")
  535. # 测试 merge_intervals
  536. print("\n4. Testing merge_intervals")
  537. intervals = [[1, 3], [2, 6], [8, 10], [15, 18]]
  538. merged = merge_intervals(intervals)
  539. print(f" ✅ merge_intervals: {merged}")
  540. # 测试 calculate_is_angle
  541. print("\n5. Testing calculate_is_angle")
  542. poly_straight = [[0, 0], [100, 0], [100, 50], [0, 50]]
  543. poly_angle = [[0, 0], [100, 0], [100, 80], [0, 20]]
  544. print(f" Straight poly is_angle: {calculate_is_angle(poly_straight)}")
  545. print(f" Angled poly is_angle: {calculate_is_angle(poly_angle)}")
  546. print("\n✅ All tests passed!")