ocr_validator_layout.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071
  1. #!/usr/bin/env python3
  2. """
  3. OCR验证工具的布局管理模块
  4. 包含标准布局、滚动布局、紧凑布局的实现
  5. """
  6. import streamlit as st
  7. from pathlib import Path
  8. from PIL import Image
  9. from typing import Any, Dict, List, Optional
  10. import plotly.graph_objects as go
  11. from typing import Tuple
  12. import re
  13. import html
  14. from ocr_validator_utils import (
  15. rotate_image_and_coordinates,
  16. get_ocr_tool_rotation_config,
  17. )
  18. # 添加 ocr_platform 根目录到 Python 路径(用于导入 ocr_utils)
  19. # 使用 resolve() 确保路径是绝对路径,避免相对路径导致的 IndexError
  20. import sys
  21. _file_path = Path(__file__).resolve()
  22. ocr_platform_root = _file_path.parents[1] # ocr_validator_layout.py -> ocr_validator -> ocr_platform
  23. if str(ocr_platform_root) not in sys.path:
  24. sys.path.insert(0, str(ocr_platform_root))
  25. # 从 ocr_utils 导入通用工具
  26. from ocr_utils.html_utils import convert_html_table_to_markdown, parse_html_tables
  27. from ocr_utils.visualization_utils import VisualizationUtils
  28. from ocr_utils.module_debug_viz import (
  29. OCR_BOX_LINE_THICKNESS,
  30. ocr_box_color_rgb,
  31. )
  32. # BeautifulSoup用于精确HTML表格处理
  33. from bs4 import BeautifulSoup
  34. # 从本地文件导入 Streamlit 特定函数
  35. from ocr_validator_file_utils import load_css_styles
  36. # 为了向后兼容,提供函数别名
  37. draw_bbox_on_image = VisualizationUtils.draw_bbox_on_image
  38. def category_to_plotly_rgba(category: str, alpha: float = 0.85) -> str:
  39. """将 VisualizationUtils.COLOR_MAP 中的 RGB 转为 Plotly 线条颜色。"""
  40. rgb = VisualizationUtils.COLOR_MAP.get(category)
  41. if rgb is None:
  42. rgb = (128, 128, 128)
  43. r, g, b = rgb
  44. return f"rgba({r}, {g}, {b}, {alpha})"
  45. def ocr_box_plotly_rgba(alpha: float = 0.85) -> str:
  46. """OCR 亮蓝(与 module_debug_viz / *_ocr_spans 一致)。"""
  47. r, g, b = ocr_box_color_rgb()
  48. return f"rgba({r}, {g}, {b}, {alpha})"
  49. # 仅 layout 结构框按类别着色;其余按 OCR 亮蓝实线/虚线
  50. LAYOUT_STRUCTURE_CATEGORIES = frozenset({
  51. 'table_body', 'table', 'image_body', 'image', 'figure', 'chart',
  52. })
  53. # detect_image_orientation_by_opencv 保留在 ocr_validator_file_utils
  54. from ocr_validator_file_utils import detect_image_orientation_by_opencv
  55. class OCRLayoutManager:
  56. """OCR布局管理器"""
  57. def __init__(self, validator):
  58. self.validator = validator
  59. self.config = validator.config
  60. self._rotated_image_cache = {}
  61. self._cache_max_size = 10
  62. self._orientation_cache = {} # 缓存方向检测结果
  63. self.rotated_angle = 0.0 # 自动检测的旋转角度缓存
  64. self.show_all_boxes = False
  65. self.fit_to_container = False
  66. self.zoom_level = 1.0
  67. def _clear_selection_callback(self, layout_type: str):
  68. """在按钮回调中清理选择/搜索状态(避免 widget 实例化后修改报错)"""
  69. # 业务态
  70. st.session_state.selected_text = ""
  71. # 紧凑布局的搜索态
  72. st.session_state.compact_search_query = ""
  73. # widget key 对应的状态(必须在 on_click 回调里改)
  74. search_key = f"{layout_type}_search_input"
  75. quick_select_key = f"{layout_type}_quick_text_selector"
  76. st.session_state[search_key] = ""
  77. st.session_state[quick_select_key] = 0
  78. def _highlight_text_safely(self, content: str, text_to_highlight: str,
  79. highlight_class: str, title: Optional[str] = None) -> str:
  80. """
  81. 安全地高亮文本,保护Markdown语法(特别是图片)
  82. 策略:
  83. 1. 保护特殊内容(HTML注释、Markdown图片)
  84. 2. 只对HTML表格使用BeautifulSoup精确处理
  85. 3. 其他部分使用简单字符串替换,保持Markdown格式
  86. Args:
  87. content: 要处理的Markdown/HTML混合内容
  88. text_to_highlight: 要高亮的文本
  89. highlight_class: 高亮样式类名
  90. title: 鼠标悬停提示文本
  91. Returns:
  92. 处理后的内容
  93. """
  94. if not text_to_highlight or text_to_highlight not in content:
  95. return content
  96. if title is None:
  97. title = text_to_highlight
  98. try:
  99. import re
  100. # 1. 提取并保护特殊内容
  101. protected_parts = []
  102. # 保护 HTML 注释
  103. def protect_comment(match):
  104. protected_parts.append(match.group(0))
  105. return f"__PROTECTED_{len(protected_parts) - 1}__"
  106. content = re.sub(r'<!--.*?-->', protect_comment, content, flags=re.DOTALL)
  107. # 保护 Markdown 图片(完整语法)
  108. def protect_image(match):
  109. protected_parts.append(match.group(0))
  110. return f"__PROTECTED_{len(protected_parts) - 1}__"
  111. content = re.sub(r'!\[.*?\]\([^)]+\)', protect_image, content)
  112. # 2. 提取表格并单独处理
  113. tables = []
  114. def extract_table(match):
  115. tables.append(match.group(0))
  116. return f"__TABLE_{len(tables) - 1}__"
  117. content = re.sub(r'<table[^>]*>.*?</table>', extract_table, content, flags=re.DOTALL)
  118. # 3. 对表格使用 BeautifulSoup 精确处理(只高亮文本,不高亮整个单元格)
  119. highlighted_tables = []
  120. for table_html in tables:
  121. soup = BeautifulSoup(table_html, 'html.parser')
  122. # 在表格单元格中查找完全匹配
  123. for td in soup.find_all(['td', 'th']):
  124. cell_text = td.get_text(strip=True)
  125. if cell_text == text_to_highlight:
  126. # 🎯 只高亮文本,不高亮整个单元格
  127. # 清空单元格内容
  128. td.clear()
  129. # 创建高亮 span 包裹文本
  130. span = soup.new_tag('span')
  131. span['class'] = highlight_class.split()
  132. if title:
  133. span['title'] = title
  134. span.string = text_to_highlight
  135. # 将 span 添加到单元格
  136. td.append(span)
  137. highlighted_tables.append(str(soup))
  138. # 4. 对普通文本进行简单替换(保持Markdown格式,跳过占位符)
  139. if text_to_highlight in content:
  140. highlight_span = f'<span class="{highlight_class}"'
  141. if title:
  142. highlight_span += f' title="{title}"'
  143. highlight_span += f'>{text_to_highlight}</span>'
  144. # 🎯 安全替换:使用正则表达式,排除占位符内的匹配
  145. # 负向前瞻:确保前面不是占位符的一部分
  146. pattern = f'(?<!__PROTECTED_)(?<!__TABLE_){re.escape(text_to_highlight)}(?!__)'
  147. content = re.sub(pattern, highlight_span, content)
  148. # 5. 恢复表格
  149. for i, table in enumerate(highlighted_tables):
  150. content = content.replace(f"__TABLE_{i}__", table)
  151. # 6. 恢复受保护的内容(图片和注释)
  152. for i, protected in enumerate(protected_parts):
  153. content = content.replace(f"__PROTECTED_{i}__", protected)
  154. return content
  155. except Exception as e:
  156. st.warning(f"文本高亮时出错: {str(e)}")
  157. return content
  158. def clear_image_cache(self):
  159. """清理所有图像缓存"""
  160. self._rotated_image_cache.clear()
  161. def clear_cache_for_image(self, image_path: str):
  162. """清理指定图像的所有缓存"""
  163. keys_to_remove = [key for key in self._rotated_image_cache.keys() if key.startswith(image_path)]
  164. for key in keys_to_remove:
  165. del self._rotated_image_cache[key]
  166. def get_cache_info(self) -> dict:
  167. """获取缓存信息"""
  168. return {
  169. 'cache_size': len(self._rotated_image_cache),
  170. 'cached_images': list(self._rotated_image_cache.keys()),
  171. 'max_size': self._cache_max_size
  172. }
  173. def _manage_cache_size(self):
  174. """管理缓存大小,超出限制时清理最旧的缓存"""
  175. if len(self._rotated_image_cache) > self._cache_max_size:
  176. # 删除最旧的缓存项(FIFO策略)
  177. oldest_key = next(iter(self._rotated_image_cache))
  178. del self._rotated_image_cache[oldest_key]
  179. def detect_and_suggest_rotation(self, image_path: str) -> Dict:
  180. """检测并建议图片旋转角度"""
  181. if image_path in self._orientation_cache:
  182. return self._orientation_cache[image_path]
  183. # 使用自动检测功能
  184. detection_result = detect_image_orientation_by_opencv(image_path)
  185. # 缓存结果
  186. self._orientation_cache[image_path] = detection_result
  187. return detection_result
  188. def get_rotation_angle(self) -> float:
  189. """获取旋转角度 - 增强版本支持自动检测"""
  190. # 如果没有预设角度,优先人工设置
  191. if hasattr(self, 'rotated_angle') and self.rotated_angle != 0:
  192. return self.rotated_angle
  193. # 尝试从OCR数据中获取(PPStructV3等)
  194. if self.validator.ocr_data:
  195. for item in self.validator.ocr_data:
  196. if isinstance(item, dict) and 'rotation_angle' in item:
  197. return item['rotation_angle']
  198. return 0.0
  199. def load_and_rotate_image(self, image_path: str) -> Optional[Image.Image]:
  200. """加载并根据需要旋转图像"""
  201. if not image_path or not Path(image_path).exists():
  202. return None
  203. # 检查缓存
  204. rotation_angle = self.get_rotation_angle()
  205. cache_key = f"{image_path}_{rotation_angle}"
  206. if cache_key in self._rotated_image_cache:
  207. self.validator.text_bbox_mapping = self._rotated_image_cache[cache_key]['text_bbox_mapping']
  208. return self._rotated_image_cache[cache_key]['image']
  209. try:
  210. image = Image.open(image_path)
  211. # 如果需要旋转
  212. if rotation_angle != 0:
  213. # 获取OCR工具的旋转配置
  214. rotation_config = get_ocr_tool_rotation_config(self.validator.ocr_data, self.config)
  215. # st.info(f"🔄 检测到文档旋转角度: {rotation_angle}°,正在处理图像和坐标...")
  216. # st.info(f"📋 OCR工具配置: 坐标{'已预旋转' if rotation_config['coordinates_are_pre_rotated'] else '需要旋转'}")
  217. # 判断是否需要旋转坐标
  218. if rotation_config['coordinates_are_pre_rotated']:
  219. # 图片的角度与坐标的角度不一致,比如PPStructV3,图片0度,坐标已旋转270度
  220. # 这种情况下,只需要旋转图片,坐标不变
  221. # PPStructV3: 坐标已经是旋转后的,只旋转图像
  222. img_rotation_angle = (rotation_angle + self.rotated_angle) % 360
  223. if img_rotation_angle == 270:
  224. rotated_image = image.rotate(-90, expand=True) # 顺时针90度
  225. elif img_rotation_angle == 90:
  226. rotated_image = image.rotate(90, expand=True) # 逆时针90度
  227. elif img_rotation_angle == 180:
  228. rotated_image = image.rotate(180, expand=True) # 180度
  229. else:
  230. rotated_image = image.rotate(-img_rotation_angle, expand=True)
  231. if self.rotated_angle == 0:
  232. # 坐标不需要变换,因为JSON中已经是正确的坐标
  233. self._rotated_image_cache[cache_key] = {'image': rotated_image, 'text_bbox_mapping': self.validator.text_bbox_mapping}
  234. self._manage_cache_size()
  235. return rotated_image
  236. image = rotated_image # 继续使用旋转后的图像进行后续处理
  237. # VLM: 需要同时旋转图像和坐标
  238. # 收集所有bbox坐标
  239. all_bboxes = []
  240. text_to_bbox_map = {} # 记录文本到bbox索引的映射
  241. bbox_index = 0
  242. for text, info_list in self.validator.text_bbox_mapping.items():
  243. text_to_bbox_map[text] = []
  244. for info in info_list:
  245. all_bboxes.append(info['bbox'])
  246. text_to_bbox_map[text].append(bbox_index)
  247. bbox_index += 1
  248. # 旋转图像和坐标
  249. rotated_image, rotated_bboxes = rotate_image_and_coordinates(
  250. image, rotation_angle, all_bboxes,
  251. rotate_coordinates=not rotation_config['coordinates_are_pre_rotated']
  252. )
  253. # 更新bbox映射 - 使用映射关系确保正确对应
  254. for text, bbox_indices in text_to_bbox_map.items():
  255. for i, bbox_idx in enumerate(bbox_indices):
  256. if bbox_idx < len(rotated_bboxes) and i < len(self.validator.text_bbox_mapping[text]):
  257. self.validator.text_bbox_mapping[text][i]['bbox'] = rotated_bboxes[bbox_idx]
  258. # 缓存结果
  259. self._rotated_image_cache[cache_key] = {'image': rotated_image, 'text_bbox_mapping': self.validator.text_bbox_mapping}
  260. self._manage_cache_size()
  261. return rotated_image
  262. else:
  263. # 无需旋转,直接缓存原图
  264. self._rotated_image_cache[cache_key] = {'image': image, 'text_bbox_mapping': self.validator.text_bbox_mapping}
  265. self._manage_cache_size() # 检查并管理缓存大小
  266. return image
  267. except Exception as e:
  268. st.error(f"❌ 图像加载失败: {e}")
  269. return None
  270. def render_content_by_mode(self, content: str, render_mode: str, font_size: int,
  271. container_height: int, layout_type: str,
  272. highlight_config: Optional[Dict] = None):
  273. """
  274. 根据渲染模式显示内容 - 增强版本
  275. Args:
  276. content: 要渲染的内容
  277. render_mode: 渲染模式
  278. font_size: 字体大小
  279. container_height: 容器高度
  280. layout_type: 布局类型
  281. highlight_config: 高亮配置 {'has_bbox': bool, 'match_type': str}
  282. """
  283. if content is None or render_mode is None:
  284. return
  285. if render_mode == "HTML渲染":
  286. # 🎯 构建样式 - 包含基础样式和高亮样式
  287. content_style = f"""
  288. <style>
  289. /* ========== 基础容器样式 ========== */
  290. .{layout_type}-content-display {{
  291. height: {container_height}px;
  292. overflow-x: auto;
  293. overflow-y: auto;
  294. font-size: {font_size}px !important;
  295. line-height: 1.4;
  296. color: #333333 !important;
  297. background-color: #fafafa !important;
  298. padding: 10px;
  299. border-radius: 5px;
  300. border: 1px solid #ddd;
  301. max-width: 100%;
  302. }}
  303. /* ========== 表格样式 ========== */
  304. .{layout_type}-content-display table {{
  305. width: 100%;
  306. border-collapse: collapse;
  307. margin: 10px 0;
  308. white-space: nowrap;
  309. }}
  310. .{layout_type}-content-display th,
  311. .{layout_type}-content-display td {{
  312. border: 1px solid #ddd;
  313. padding: 8px;
  314. text-align: left;
  315. max-width: 300px;
  316. word-wrap: break-word;
  317. word-break: break-all;
  318. vertical-align: top;
  319. }}
  320. .{layout_type}-content-display th {{
  321. background-color: #f5f5f5;
  322. position: sticky;
  323. top: 0;
  324. z-index: 1;
  325. font-weight: bold;
  326. }}
  327. /* 数字列右对齐 */
  328. .{layout_type}-content-display td.number {{
  329. text-align: right;
  330. white-space: nowrap;
  331. font-family: 'Monaco', 'Menlo', monospace;
  332. }}
  333. /* 短文本列不换行 */
  334. .{layout_type}-content-display td.short-text {{
  335. white-space: nowrap;
  336. min-width: 80px;
  337. }}
  338. /* ========== 图片样式 ========== */
  339. .{layout_type}-content-display img {{
  340. max-width: 100%;
  341. height: auto;
  342. border-radius: 4px;
  343. margin: 10px 0;
  344. }}
  345. /* ========== 响应式设计 ========== */
  346. @media (max-width: 768px) {{
  347. .{layout_type}-content-display table {{
  348. font-size: {max(font_size-2, 8)}px;
  349. }}
  350. .{layout_type}-content-display th,
  351. .{layout_type}-content-display td {{
  352. padding: 4px;
  353. max-width: 150px;
  354. }}
  355. }}
  356. /* ========== 高亮文本样式 ========== */
  357. .{layout_type}-content-display .highlight-text {{
  358. padding: 2px 4px;
  359. border-radius: 3px;
  360. cursor: pointer;
  361. font-weight: 500;
  362. transition: all 0.2s ease;
  363. }}
  364. .{layout_type}-content-display .highlight-text:hover {{
  365. opacity: 0.8;
  366. transform: scale(1.02);
  367. }}
  368. /* 🎯 精确匹配且有框 - 绿色 */
  369. .{layout_type}-content-display .highlight-text.selected-highlight {{
  370. background-color: #4caf50 !important;
  371. color: white !important;
  372. border: 1px solid #2e7d32 !important;
  373. }}
  374. /* 🎯 OCR匹配 - 蓝色 */
  375. .{layout_type}-content-display .highlight-text.ocr-match {{
  376. background-color: #2196f3 !important;
  377. color: white !important;
  378. border: 1px solid #1565c0 !important;
  379. }}
  380. /* 🎯 无边界框 - 橙色虚线 */
  381. .{layout_type}-content-display .highlight-text.no-bbox {{
  382. background-color: #ff9800 !important;
  383. color: white !important;
  384. border: 1px dashed #f57c00 !important;
  385. }}
  386. /* 🎯 默认高亮 - 黄色 */
  387. .{layout_type}-content-display .highlight-text.default {{
  388. background-color: #ffeb3b !important;
  389. color: #333333 !important;
  390. border: 1px solid #fbc02d !important;
  391. }}
  392. </style>
  393. """
  394. st.markdown(content_style, unsafe_allow_html=True)
  395. st.markdown(f'<div class="{layout_type}-content-display">{content}</div>',
  396. unsafe_allow_html=True)
  397. elif render_mode == "Markdown渲染":
  398. converted_content = convert_html_table_to_markdown(content)
  399. st.markdown(converted_content, unsafe_allow_html=True)
  400. elif render_mode == "DataFrame表格":
  401. if '<table' in content.lower():
  402. self.validator.display_html_table_as_dataframe(content)
  403. else:
  404. st.info("当前内容中没有检测到HTML表格")
  405. st.markdown(content, unsafe_allow_html=True)
  406. else: # 原始文本
  407. st.text_area(
  408. "MD内容预览",
  409. content,
  410. height=300,
  411. key=f"{layout_type}_text_area"
  412. )
  413. def create_compact_layout(self, config: Dict):
  414. """创建紧凑的对比布局 - 增强搜索功能"""
  415. layout = config['styles']['layout']
  416. font_size = config['styles'].get('font_size', 10)
  417. container_height = layout.get('default_height', 600)
  418. zoom_level = layout.get('default_zoom', 1.0)
  419. layout_type = "compact"
  420. left_col, right_col = st.columns([layout['content_width'], layout['sidebar_width']],
  421. vertical_alignment='top', border=True)
  422. with left_col:
  423. if self.validator.text_bbox_mapping:
  424. # 搜索输入框
  425. search_col, select_col = st.columns([1, 2])
  426. if "compact_search_query" not in st.session_state:
  427. st.session_state.compact_search_query = ""
  428. with search_col:
  429. search_query = st.text_input(
  430. "搜索文本",
  431. placeholder="输入关键词...",
  432. value=st.session_state.compact_search_query,
  433. key=f"{layout_type}_search_input",
  434. label_visibility="collapsed"
  435. )
  436. st.session_state.compact_search_query = search_query
  437. # 🎯 增强搜索逻辑:构建选项列表
  438. text_options = ["请选择文本..."]
  439. text_display = ["请选择文本..."]
  440. match_info = [None] # 记录匹配信息
  441. for text, info_list in self.validator.text_bbox_mapping.items():
  442. # 🔑 关键改进:同时搜索 text 和 matched_text
  443. if search_query and search_query.strip():
  444. query_lower = search_query.lower()
  445. # 1. 检查原始文本
  446. text_match = query_lower in text.lower()
  447. # 2. 检查 matched_text(OCR识别文本)
  448. matched_text_match = False
  449. matched_text = None
  450. if info_list and isinstance(info_list[0], dict):
  451. matched_text = info_list[0].get('matched_text', '')
  452. matched_text_match = query_lower in matched_text.lower() if matched_text else False
  453. # 如果都不匹配,跳过
  454. if not text_match and not matched_text_match:
  455. continue
  456. # 记录匹配类型
  457. if text_match:
  458. match_type = "exact"
  459. match_source = text
  460. else:
  461. match_type = "ocr"
  462. match_source = matched_text
  463. else:
  464. match_type = None
  465. match_source = text
  466. text_options.append(text)
  467. # 🎯 构建显示文本(带匹配提示)
  468. if info_list and isinstance(info_list[0], dict):
  469. first_info = info_list[0]
  470. # 检查是否有 bbox
  471. has_bbox = 'bbox' in first_info and first_info['bbox']
  472. # 表格单元格显示
  473. if 'row' in first_info and 'col' in first_info:
  474. display_text = f"[R{first_info['row']},C{first_info['col']}] {text}"
  475. else:
  476. display_text = text
  477. # 🎯 添加匹配提示
  478. if match_type == "ocr":
  479. display_text = f"🔍 {display_text} (OCR: {match_source[:20]}...)"
  480. elif not has_bbox:
  481. display_text = f"⚠️ {display_text} (无框)"
  482. # 截断过长文本
  483. if len(display_text) > 60:
  484. display_text = display_text[:57] + "..."
  485. else:
  486. display_text = text[:57] + "..." if len(text) > 60 else text
  487. text_display.append(display_text)
  488. match_info.append({
  489. 'type': match_type,
  490. 'source': match_source,
  491. 'has_bbox': has_bbox if info_list else False
  492. })
  493. # 🎯 显示搜索统计
  494. if search_query and search_query.strip():
  495. ocr_matches = sum(1 for m in match_info[1:] if m and m['type'] == 'ocr')
  496. no_bbox_count = sum(1 for m in match_info[1:] if m and not m['has_bbox'])
  497. stat_parts = [f"找到 {len(text_options)-1} 个匹配项"]
  498. if ocr_matches > 0:
  499. stat_parts.append(f"🔍 {ocr_matches} 个OCR匹配")
  500. if no_bbox_count > 0:
  501. stat_parts.append(f"⚠️ {no_bbox_count} 个无框")
  502. st.caption(" | ".join(stat_parts))
  503. # 确定默认选中的索引
  504. default_index = 0
  505. if st.session_state.selected_text and st.session_state.selected_text in text_options:
  506. default_index = text_options.index(st.session_state.selected_text)
  507. with select_col:
  508. selected_index = st.selectbox(
  509. "快速定位文本",
  510. range(len(text_options)),
  511. index=default_index,
  512. format_func=lambda x: text_display[x] if x < len(text_display) else "",
  513. label_visibility="collapsed",
  514. key=f"{layout_type}_quick_text_selector"
  515. )
  516. # 🎯 显示匹配详情
  517. if selected_index > 0:
  518. st.session_state.selected_text = text_options[selected_index]
  519. # 获取匹配信息
  520. selected_match_info = match_info[selected_index]
  521. if selected_match_info:
  522. if selected_match_info['type'] == 'ocr':
  523. st.info(f"🔍 **OCR识别文本匹配**: `{selected_match_info['source']}`")
  524. elif not selected_match_info['has_bbox']:
  525. st.warning(f"⚠️ **未找到边界框**: 文本在MD中存在,但没有对应的坐标信息")
  526. # 🎯 增强高亮显示逻辑
  527. if self.validator.md_content:
  528. highlighted_content = self.validator.md_content
  529. if st.session_state.selected_text:
  530. selected_text = st.session_state.selected_text
  531. # 获取匹配信息
  532. info_list = self.validator.text_bbox_mapping.get(selected_text, [])
  533. has_bbox = False
  534. matched_text = None
  535. match_type = None
  536. if info_list and isinstance(info_list[0], dict):
  537. has_bbox = 'bbox' in info_list[0] and info_list[0]['bbox']
  538. matched_text = info_list[0].get('matched_text', '')
  539. # 🔑 判断匹配类型
  540. if matched_text and matched_text != selected_text:
  541. match_type = "ocr"
  542. elif has_bbox:
  543. match_type = "exact"
  544. else:
  545. match_type = "no_bbox"
  546. if info_list[0].get('category') == 'seal':
  547. conf = info_list[0].get('confidence', 0)
  548. method = info_list[0].get('recognition_method', '')
  549. hint = f"🔖 **印章** | 置信度 {conf:.2f}"
  550. if method:
  551. hint += f" | 识别方式 `{method}`"
  552. st.info(hint)
  553. # 🎯 应用高亮
  554. if len(selected_text) >= self.config.get('ocr', {}).get('min_text_length', 2):
  555. # 1. 高亮原始文本
  556. if selected_text in highlighted_content:
  557. if match_type == "exact":
  558. highlight_class = "highlight-text selected-highlight"
  559. elif match_type == "no_bbox":
  560. highlight_class = "highlight-text no-bbox"
  561. else:
  562. highlight_class = "highlight-text default"
  563. # 使用正则表达式避免替换base64编码中的内容
  564. highlighted_content = self._highlight_text_safely(
  565. highlighted_content,
  566. selected_text,
  567. highlight_class
  568. )
  569. # 2. 如果有 matched_text 且不同,也高亮
  570. if matched_text and matched_text != selected_text and matched_text in highlighted_content:
  571. # 使用正则表达式避免替换base64编码中的内容
  572. highlighted_content = self._highlight_text_safely(
  573. highlighted_content,
  574. matched_text,
  575. "highlight-text ocr-match",
  576. f"OCR: {matched_text}"
  577. )
  578. # 🎯 调用渲染方法(样式已内置)
  579. self.render_content_by_mode(
  580. highlighted_content,
  581. "HTML渲染",
  582. font_size,
  583. container_height,
  584. layout_type
  585. )
  586. with right_col:
  587. self.create_aligned_image_display(zoom_level, "compact")
  588. def create_aligned_image_display(self, zoom_level: float = 1.0, layout_type: str = "aligned"):
  589. """创建响应式图片显示"""
  590. # st.header("🖼️ 原图标注")
  591. # 图片控制选项
  592. col1, col2, col3, col4, col5 = st.columns(5, vertical_alignment="center", border= False)
  593. with col1:
  594. # 判断{layout_type}_show_all_boxes是否有值,如果有值直接使用,否则默认False
  595. # if f"{layout_type}_show_all_boxes" not in st.session_state:
  596. # st.session_state[f"{layout_type}_show_all_boxes"] = False
  597. show_all_boxes = st.checkbox(
  598. "显示所有框",
  599. # value=st.session_state[f"{layout_type}_show_all_boxes"],
  600. value = self.show_all_boxes,
  601. key=f"{layout_type}_show_all_boxes"
  602. )
  603. if show_all_boxes != self.show_all_boxes:
  604. self.show_all_boxes = show_all_boxes
  605. with col2:
  606. if st.button("🔄 旋转90度", type="secondary", key=f"{layout_type}_manual_angle"):
  607. self.rotated_angle = (self.rotated_angle + 90) % 360
  608. # 需要清除图片缓存,以及text_bbox_mapping中的bbox
  609. self.clear_image_cache()
  610. self.validator.process_data()
  611. st.rerun()
  612. with col3:
  613. # 显示当前角度状态
  614. current_angle = self.get_rotation_angle()
  615. st.metric("当前角度", f"{current_angle}°", label_visibility="collapsed")
  616. with col4:
  617. if st.button("↺ 重置角度", key=f"{layout_type}_reset_angle"):
  618. self.rotated_angle = 0.0
  619. st.success("已重置旋转角度")
  620. # 需要清除图片缓存,以及text_bbox_mapping中的bbox
  621. self.clear_image_cache()
  622. self.validator.process_data()
  623. st.rerun()
  624. with col5:
  625. st.button(
  626. "🧹 清除选择",
  627. key=f"{layout_type}_clear_selection",
  628. on_click=self._clear_selection_callback,
  629. kwargs={"layout_type": layout_type},
  630. )
  631. # 使用增强的图像加载方法
  632. image = self.load_and_rotate_image(self.validator.image_path)
  633. if image:
  634. try:
  635. resized_image, all_boxes, selected_boxes = self.zoom_image(image, self.zoom_level)
  636. # 创建交互式图片
  637. fig = self.create_resized_interactive_plot(resized_image, selected_boxes, self.zoom_level, all_boxes)
  638. plot_config = {
  639. 'displayModeBar': True,
  640. 'modeBarButtonsToRemove': ['zoom2d', 'select2d', 'lasso2d', 'autoScale2d'],
  641. 'scrollZoom': True,
  642. 'doubleClick': 'reset',
  643. 'responsive': False, # 关键:禁用响应式,使用固定尺寸
  644. 'toImageButtonOptions': {
  645. 'format': 'png',
  646. 'filename': 'ocr_image',
  647. 'height': None, # 使用当前高度
  648. 'width': None, # 使用当前宽度
  649. 'scale': 1
  650. }
  651. }
  652. # 🔧 修复:使用 use_container_width 替代废弃的参数
  653. st.plotly_chart(
  654. fig,
  655. width='stretch', # 🎯 使用容器宽度
  656. config=plot_config,
  657. key=f"{layout_type}_plot"
  658. )
  659. except Exception as e:
  660. st.error(f"❌ 图片处理失败: {e}")
  661. st.exception(e)
  662. else:
  663. st.error("未找到对应的图片文件")
  664. if self.validator.image_path:
  665. st.write(f"期望路径: {self.validator.image_path}")
  666. # st.markdown('</div>', unsafe_allow_html=True)
  667. def zoom_image(
  668. self, image: Image.Image, current_zoom: float
  669. ) -> Tuple[Image.Image, List[Dict[str, Any]], List[List[int]]]:
  670. """缩放图像;all_boxes 为带 category 的框列表,供按类着色。"""
  671. # 根据缩放级别调整图片大小
  672. new_width = int(image.width * current_zoom)
  673. new_height = int(image.height * current_zoom)
  674. resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
  675. # 计算选中的bbox
  676. selected_boxes = []
  677. if st.session_state.selected_text and st.session_state.selected_text in self.validator.text_bbox_mapping:
  678. info_list = self.validator.text_bbox_mapping[st.session_state.selected_text]
  679. for info in info_list:
  680. if 'bbox' in info:
  681. bbox = info['bbox']
  682. selected_box = [int(coord * current_zoom) for coord in bbox]
  683. selected_boxes.append(selected_box)
  684. # 收集所有框(含类别,用于按类着色)
  685. all_boxes: List[Dict[str, Any]] = []
  686. if self.show_all_boxes:
  687. for text, info_list in self.validator.text_bbox_mapping.items():
  688. for info in info_list:
  689. bbox = info.get('bbox', [])
  690. if len(bbox) >= 4:
  691. scaled_bbox = [coord * current_zoom for coord in bbox]
  692. all_boxes.append({
  693. 'bbox': scaled_bbox,
  694. 'category': info.get('category', 'text'),
  695. 'has_text': bool(text and str(text).strip()),
  696. })
  697. return resized_image, all_boxes, selected_boxes
  698. def _add_bboxes_to_plot_batch(self, fig: go.Figure, bboxes: List[List[int]],
  699. image_height: int,
  700. line_color: str = "blue",
  701. line_width: int = 2,
  702. fill_color: str = "rgba(0, 100, 200, 0.2)"):
  703. """
  704. 批量添加边界框(性能优化版)
  705. """
  706. if not bboxes or len(bboxes) == 0:
  707. return
  708. # 🎯 关键优化:构建 shapes 列表,一次性添加
  709. shapes = []
  710. for bbox in bboxes:
  711. if len(bbox) < 4:
  712. continue
  713. x1, y1, x2, y2 = bbox[:4]
  714. # 转换坐标
  715. plot_x1 = x1
  716. plot_x2 = x2
  717. plot_y1 = image_height - y2
  718. plot_y2 = image_height - y1
  719. shapes.append(dict(
  720. type="rect",
  721. x0=plot_x1, y0=plot_y1,
  722. x1=plot_x2, y1=plot_y2,
  723. line=dict(color=line_color, width=line_width),
  724. fillcolor=fill_color,
  725. ))
  726. # 🎯 一次性更新所有形状
  727. fig.update_layout(shapes=fig.layout.shapes + tuple(shapes))
  728. def _add_bboxes_as_scatter(
  729. self,
  730. fig: go.Figure,
  731. bboxes: List[List[int]],
  732. image_height: int,
  733. line_color: str = "blue",
  734. line_width: int = 2,
  735. name: str = "boxes",
  736. *,
  737. dashed: bool = False,
  738. ):
  739. """使用 Scatter 绘制边界框(极致性能优化)。"""
  740. if not bboxes or len(bboxes) == 0:
  741. return
  742. x_coords = []
  743. y_coords = []
  744. for bbox in bboxes:
  745. if len(bbox) < 4:
  746. continue
  747. x1, y1, x2, y2 = bbox[:4]
  748. plot_y1 = image_height - y2
  749. plot_y2 = image_height - y1
  750. x_coords.extend([x1, x2, x2, x1, x1, None])
  751. y_coords.extend([plot_y1, plot_y1, plot_y2, plot_y2, plot_y1, None])
  752. line_style = dict(
  753. color=line_color,
  754. width=line_width,
  755. dash='dash' if dashed else 'solid',
  756. )
  757. fig.add_trace(go.Scatter(
  758. x=x_coords,
  759. y=y_coords,
  760. mode='lines',
  761. line=line_style,
  762. name=name,
  763. showlegend=False,
  764. hoverinfo='skip',
  765. ))
  766. def create_resized_interactive_plot(
  767. self,
  768. image: Image.Image,
  769. selected_boxes: List[List[int]],
  770. zoom_level: float,
  771. all_boxes: List[Dict[str, Any]],
  772. ) -> go.Figure:
  773. """创建可调整大小的交互式图片 - 修复容器溢出问题"""
  774. fig = go.Figure()
  775. # 添加图片 - Plotly坐标系,原点在左下角
  776. fig.add_layout_image(
  777. dict(
  778. source=image,
  779. xref="x", yref="y",
  780. x=0, y=image.height, # 图片左下角在Plotly坐标系中的位置
  781. sizex=image.width,
  782. sizey=image.height,
  783. sizing="stretch",
  784. opacity=1.0,
  785. layer="below",
  786. yanchor="top" # 确保图片顶部对齐
  787. )
  788. )
  789. # 显示所有框:layout 结构按 COLOR_MAP;OCR 文字亮蓝实线/无文字虚线
  790. if all_boxes:
  791. layout_by_category: Dict[str, List[List[float]]] = {}
  792. ocr_solid: List[List[float]] = []
  793. ocr_dashed: List[List[float]] = []
  794. ocr_color = ocr_box_plotly_rgba()
  795. ocr_width = OCR_BOX_LINE_THICKNESS
  796. for box_item in all_boxes:
  797. cat = box_item.get('category', 'text')
  798. bbox = box_item.get('bbox', [])
  799. if len(bbox) < 4:
  800. continue
  801. if cat in LAYOUT_STRUCTURE_CATEGORIES:
  802. layout_by_category.setdefault(cat, []).append(bbox)
  803. else:
  804. if box_item.get('has_text', True):
  805. ocr_solid.append(bbox)
  806. else:
  807. ocr_dashed.append(bbox)
  808. for cat, bboxes in layout_by_category.items():
  809. line_width = 4 if cat == 'seal' else 2
  810. self._add_bboxes_as_scatter(
  811. fig=fig,
  812. bboxes=bboxes,
  813. image_height=image.height,
  814. line_color=category_to_plotly_rgba(cat),
  815. line_width=line_width,
  816. name=f"all_{cat}",
  817. )
  818. if ocr_solid:
  819. self._add_bboxes_as_scatter(
  820. fig=fig,
  821. bboxes=ocr_solid,
  822. image_height=image.height,
  823. line_color=ocr_color,
  824. line_width=ocr_width,
  825. name="ocr_text",
  826. dashed=False,
  827. )
  828. if ocr_dashed:
  829. self._add_bboxes_as_scatter(
  830. fig=fig,
  831. bboxes=ocr_dashed,
  832. image_height=image.height,
  833. line_color=ocr_color,
  834. line_width=ocr_width,
  835. name="ocr_detect_only",
  836. dashed=True,
  837. )
  838. # 高亮显示选中的bbox(红色)
  839. if selected_boxes:
  840. self._add_bboxes_to_plot_batch(
  841. fig=fig,
  842. bboxes=selected_boxes,
  843. image_height=image.height,
  844. line_color="red",
  845. line_width=2,
  846. fill_color="rgba(255, 0, 0, 0.3)"
  847. )
  848. # 修复:优化显示尺寸计算
  849. max_display_width = 1500
  850. max_display_height = 1000
  851. # 计算合适的显示尺寸,保持宽高比
  852. aspect_ratio = image.width / image.height
  853. if self.fit_to_container:
  854. # 自适应容器模式
  855. if aspect_ratio > 1: # 宽图
  856. display_width = min(max_display_width, image.width)
  857. display_height = int(display_width / aspect_ratio)
  858. else: # 高图
  859. display_height = min(max_display_height, image.height)
  860. display_width = int(display_height * aspect_ratio)
  861. # 确保不会太小
  862. display_width = max(display_width, 800)
  863. display_height = max(display_height, 600)
  864. else:
  865. # 固定尺寸模式,但仍要考虑容器限制
  866. display_width = min(image.width, max_display_width)
  867. display_height = min(image.height, max_display_height)
  868. # 设置布局 - 关键修改
  869. fig.update_layout(
  870. width=display_width,
  871. height=display_height,
  872. margin=dict(l=0, r=0, t=0, b=0),
  873. showlegend=False,
  874. plot_bgcolor='white',
  875. dragmode="pan",
  876. # 关键:让图表自适应容器
  877. # autosize=True, # 启用自动调整大小
  878. xaxis=dict(
  879. visible=False,
  880. range=[0, image.width],
  881. constrain="domain",
  882. fixedrange=False,
  883. autorange=False,
  884. showgrid=False,
  885. zeroline=False,
  886. ),
  887. # 修复:Y轴设置,确保范围正确
  888. yaxis=dict(
  889. visible=False,
  890. range=[0, image.height], # 确保Y轴范围从0到图片高度
  891. constrain="domain",
  892. scaleanchor="x",
  893. scaleratio=1,
  894. fixedrange=False,
  895. autorange=False,
  896. showgrid=False,
  897. zeroline=False
  898. )
  899. )
  900. return fig