ocr_validator_layout.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  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 Dict, List, Optional
  10. import plotly.graph_objects as go
  11. from typing import Tuple
  12. from ocr_validator_utils import (
  13. convert_html_table_to_markdown,
  14. parse_html_tables,
  15. draw_bbox_on_image,
  16. rotate_image_and_coordinates,
  17. get_ocr_tool_rotation_config,
  18. detect_image_orientation_by_opencv # 新增导入
  19. )
  20. class OCRLayoutManager:
  21. """OCR布局管理器"""
  22. def __init__(self, validator):
  23. self.validator = validator
  24. self.config = validator.config
  25. self._rotated_image_cache = {}
  26. self._cache_max_size = 10
  27. self._orientation_cache = {} # 缓存方向检测结果
  28. # self._auto_detected_angle = 0.0 # 自动检测的旋转角度缓存
  29. def clear_image_cache(self):
  30. """清理所有图像缓存"""
  31. self._rotated_image_cache.clear()
  32. def clear_cache_for_image(self, image_path: str):
  33. """清理指定图像的所有缓存"""
  34. keys_to_remove = [key for key in self._rotated_image_cache.keys() if key.startswith(image_path)]
  35. for key in keys_to_remove:
  36. del self._rotated_image_cache[key]
  37. def get_cache_info(self) -> dict:
  38. """获取缓存信息"""
  39. return {
  40. 'cache_size': len(self._rotated_image_cache),
  41. 'cached_images': list(self._rotated_image_cache.keys()),
  42. 'max_size': self._cache_max_size
  43. }
  44. def _manage_cache_size(self):
  45. """管理缓存大小,超出限制时清理最旧的缓存"""
  46. if len(self._rotated_image_cache) > self._cache_max_size:
  47. # 删除最旧的缓存项(FIFO策略)
  48. oldest_key = next(iter(self._rotated_image_cache))
  49. del self._rotated_image_cache[oldest_key]
  50. def detect_and_suggest_rotation(self, image_path: str) -> Dict:
  51. """检测并建议图片旋转角度"""
  52. if image_path in self._orientation_cache:
  53. return self._orientation_cache[image_path]
  54. # 使用自动检测功能
  55. detection_result = detect_image_orientation_by_opencv(image_path)
  56. # 缓存结果
  57. self._orientation_cache[image_path] = detection_result
  58. return detection_result
  59. def get_rotation_angle(self) -> float:
  60. """获取旋转角度 - 增强版本支持自动检测"""
  61. # 首先尝试从OCR数据中获取(PPStructV3等)
  62. if self.validator.ocr_data:
  63. for item in self.validator.ocr_data:
  64. if isinstance(item, dict) and 'rotation_angle' in item:
  65. return item['rotation_angle']
  66. # 如果没有预设角度,尝试自动检测
  67. if hasattr(self, '_auto_detected_angle'):
  68. return self._auto_detected_angle
  69. return 0.0
  70. def load_and_rotate_image(self, image_path: str) -> Optional[Image.Image]:
  71. """加载并根据需要旋转图像"""
  72. if not image_path or not Path(image_path).exists():
  73. return None
  74. # 检查缓存
  75. rotation_angle = self.get_rotation_angle()
  76. cache_key = f"{image_path}_{rotation_angle}"
  77. if cache_key in self._rotated_image_cache:
  78. return self._rotated_image_cache[cache_key]
  79. try:
  80. image = Image.open(image_path)
  81. # 如果需要旋转
  82. if rotation_angle != 0:
  83. # 获取OCR工具的旋转配置
  84. rotation_config = get_ocr_tool_rotation_config(self.validator.ocr_data, self.config)
  85. st.info(f"🔄 检测到文档旋转角度: {rotation_angle}°,正在处理图像和坐标...")
  86. st.info(f"📋 OCR工具配置: 坐标{'已预旋转' if rotation_config['coordinates_are_pre_rotated'] else '需要旋转'}")
  87. # 判断是否需要旋转坐标
  88. if rotation_config['coordinates_are_pre_rotated']:
  89. # PPStructV3: 坐标已经是旋转后的,只旋转图像
  90. if rotation_angle == 270:
  91. rotated_image = image.rotate(-90, expand=True) # 顺时针90度
  92. elif rotation_angle == 90:
  93. rotated_image = image.rotate(90, expand=True) # 逆时针90度
  94. elif rotation_angle == 180:
  95. rotated_image = image.rotate(180, expand=True) # 180度
  96. else:
  97. rotated_image = image.rotate(-rotation_angle, expand=True)
  98. # 坐标不需要变换,因为JSON中已经是正确的坐标
  99. self._rotated_image_cache[cache_key] = rotated_image
  100. self._manage_cache_size()
  101. return rotated_image
  102. else:
  103. # Dots OCR: 需要同时旋转图像和坐标
  104. # 收集所有bbox坐标
  105. all_bboxes = []
  106. text_to_bbox_map = {} # 记录文本到bbox索引的映射
  107. bbox_index = 0
  108. for text, info_list in self.validator.text_bbox_mapping.items():
  109. text_to_bbox_map[text] = []
  110. for info in info_list:
  111. all_bboxes.append(info['bbox'])
  112. text_to_bbox_map[text].append(bbox_index)
  113. bbox_index += 1
  114. # 旋转图像和坐标
  115. rotated_image, rotated_bboxes = rotate_image_and_coordinates(
  116. image, rotation_angle, all_bboxes,
  117. rotate_coordinates=not rotation_config['coordinates_are_pre_rotated']
  118. )
  119. # 更新bbox映射 - 使用映射关系确保正确对应
  120. for text, bbox_indices in text_to_bbox_map.items():
  121. for i, bbox_idx in enumerate(bbox_indices):
  122. if bbox_idx < len(rotated_bboxes) and i < len(self.validator.text_bbox_mapping[text]):
  123. self.validator.text_bbox_mapping[text][i]['bbox'] = rotated_bboxes[bbox_idx]
  124. # 缓存结果
  125. self._rotated_image_cache[cache_key] = rotated_image
  126. self._manage_cache_size()
  127. return rotated_image
  128. else:
  129. # 无需旋转,直接缓存原图
  130. self._rotated_image_cache[cache_key] = image
  131. self._manage_cache_size() # 检查并管理缓存大小
  132. return image
  133. except Exception as e:
  134. st.error(f"❌ 图像加载失败: {e}")
  135. return None
  136. def render_content_section(self, layout_type: str = "standard"):
  137. """渲染内容区域 - 统一方法"""
  138. st.header("📄 OCR识别内容")
  139. # 显示旋转信息
  140. # rotation_angle = self.get_rotation_angle()
  141. # if rotation_angle != 0:
  142. # st.info(f"📐 文档旋转角度: {rotation_angle}°")
  143. # 文本选择器
  144. if self.validator.text_bbox_mapping:
  145. text_options = ["请选择文本..."] + list(self.validator.text_bbox_mapping.keys())
  146. selected_index = st.selectbox(
  147. "选择要校验的文本",
  148. range(len(text_options)),
  149. format_func=lambda x: text_options[x][:50] + "..." if len(text_options[x]) > 50 else text_options[x],
  150. key=f"{layout_type}_text_selector"
  151. )
  152. if selected_index > 0:
  153. st.session_state.selected_text = text_options[selected_index]
  154. else:
  155. st.warning("没有找到可点击的文本")
  156. def render_md_content(self, layout_type: str):
  157. """渲染Markdown内容 - 统一方法"""
  158. if not self.validator.md_content:
  159. return None, None
  160. # 搜索功能
  161. search_term = st.text_input(
  162. "🔍 搜索文本内容",
  163. placeholder="输入关键词搜索...",
  164. key=f"{layout_type}_search"
  165. )
  166. display_content = self.validator.md_content
  167. if search_term:
  168. lines = display_content.split('\n')
  169. filtered_lines = [line for line in lines if search_term.lower() in line.lower()]
  170. display_content = '\n'.join(filtered_lines)
  171. if filtered_lines:
  172. st.success(f"找到 {len(filtered_lines)} 行包含 '{search_term}'")
  173. else:
  174. st.warning(f"未找到包含 '{search_term}' 的内容")
  175. # 渲染方式选择
  176. render_mode = st.radio(
  177. "选择渲染方式",
  178. ["HTML渲染", "Markdown渲染", "DataFrame表格", "原始文本"],
  179. horizontal=True,
  180. key=f"{layout_type}_render_mode"
  181. )
  182. return display_content, render_mode
  183. def render_content_by_mode(self, content: str, render_mode: str, font_size: int, layout_type: str):
  184. """根据渲染模式显示内容 - 增强版本"""
  185. if content is None or render_mode is None:
  186. return
  187. if render_mode == "HTML渲染":
  188. # 增强的HTML渲染样式,支持横向滚动
  189. content_style = f"""
  190. <style>
  191. .{layout_type}-content-display {{
  192. font-size: {font_size}px !important;
  193. line-height: 1.4;
  194. color: #333333 !important;
  195. background-color: #fafafa !important;
  196. padding: 10px;
  197. border-radius: 5px;
  198. border: 1px solid #ddd;
  199. overflow-x: auto;
  200. max-width: 100%;
  201. }}
  202. .{layout_type}-content-display table {{
  203. width: 100%;
  204. border-collapse: collapse;
  205. margin: 10px 0;
  206. white-space: nowrap;
  207. }}
  208. .{layout_type}-content-display th,
  209. .{layout_type}-content-display td {{
  210. border: 1px solid #ddd;
  211. padding: 8px;
  212. text-align: left;
  213. min-width: 100px;
  214. }}
  215. .{layout_type}-content-display th {{
  216. background-color: #f5f5f5;
  217. position: sticky;
  218. top: 0;
  219. z-index: 1;
  220. }}
  221. .{layout_type}-content-display img {{
  222. max-width: 100%;
  223. height: auto;
  224. border-radius: 4px;
  225. margin: 10px 0;
  226. }}
  227. </style>
  228. """
  229. st.markdown(content_style, unsafe_allow_html=True)
  230. st.markdown(f'<div class="{layout_type}-content-display">{content}</div>', unsafe_allow_html=True)
  231. elif render_mode == "Markdown渲染":
  232. converted_content = convert_html_table_to_markdown(content)
  233. st.markdown(converted_content, unsafe_allow_html=True)
  234. elif render_mode == "DataFrame表格":
  235. if '<table' in content.lower():
  236. self.validator.display_html_table_as_dataframe(content)
  237. else:
  238. st.info("当前内容中没有检测到HTML表格")
  239. st.markdown(content, unsafe_allow_html=True)
  240. else: # 原始文本
  241. st.text_area(
  242. "MD内容预览",
  243. content,
  244. height=300,
  245. key=f"{layout_type}_text_area"
  246. )
  247. # 布局实现
  248. def create_standard_layout(self, font_size: int = 10, zoom_level: float = 1.0):
  249. """创建标准布局"""
  250. if zoom_level is None:
  251. zoom_level = self.config['styles']['layout']['default_zoom']
  252. # 主要内容区域
  253. layout = self.config['styles']['layout']
  254. left_col, right_col = st.columns([layout['content_width'], layout['sidebar_width']])
  255. with left_col:
  256. self.render_content_section("standard")
  257. # 显示内容
  258. if self.validator.md_content:
  259. display_content, render_mode = self.render_md_content("standard")
  260. self.render_content_by_mode(display_content, render_mode, font_size, "standard")
  261. with right_col:
  262. self.create_aligned_image_display(zoom_level, "compact")
  263. def create_compact_layout(self, font_size: int = 10, zoom_level: float = 1.0):
  264. """创建紧凑的对比布局"""
  265. # 主要内容区域
  266. layout = self.config['styles']['layout']
  267. left_col, right_col = st.columns([layout['content_width'], layout['sidebar_width']])
  268. with left_col:
  269. self.render_content_section("compact")
  270. # 只保留一个内容区域高度选择
  271. container_height = st.selectbox(
  272. "选择内容区域高度",
  273. [400, 600, 800, 1000, 1200],
  274. index=2,
  275. key="compact_content_height"
  276. )
  277. # 快速定位文本选择器(使用不同的key)
  278. if self.validator.text_bbox_mapping:
  279. text_options = ["请选择文本..."] + list(self.validator.text_bbox_mapping.keys())
  280. selected_index = st.selectbox(
  281. "快速定位文本",
  282. range(len(text_options)),
  283. format_func=lambda x: text_options[x][:30] + "..." if len(text_options[x]) > 30 else text_options[x],
  284. key="compact_quick_text_selector" # 使用不同的key
  285. )
  286. if selected_index > 0:
  287. st.session_state.selected_text = text_options[selected_index]
  288. # 自定义CSS样式
  289. st.markdown(f"""
  290. <style>
  291. .compact-content {{
  292. height: {container_height}px;
  293. overflow-y: auto;
  294. font-size: {font_size}px !important;
  295. line-height: 1.4;
  296. border: 1px solid #ddd;
  297. padding: 10px;
  298. background-color: #fafafa !important;
  299. font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  300. color: #333333 !important;
  301. }}
  302. .highlight-text {{
  303. background-color: #ffeb3b !important;
  304. padding: 2px 4px;
  305. border-radius: 3px;
  306. cursor: pointer;
  307. color: #333333 !important;
  308. }}
  309. .selected-highlight {{
  310. background-color: #4caf50 !important;
  311. color: white !important;
  312. }}
  313. </style>
  314. """, unsafe_allow_html=True)
  315. # 处理并显示OCR内容
  316. if self.validator.md_content:
  317. # 高亮可点击文本
  318. highlighted_content = self.validator.md_content
  319. for text in self.validator.text_bbox_mapping.keys():
  320. if len(text) > 2: # 避免高亮过短的文本
  321. css_class = "highlight-text selected-highlight" if text == st.session_state.selected_text else "highlight-text"
  322. highlighted_content = highlighted_content.replace(
  323. text,
  324. f'<span class="{css_class}" title="{text[:50]}...">{text}</span>'
  325. )
  326. st.markdown(
  327. f'<div class="compact-content">{highlighted_content}</div>',
  328. unsafe_allow_html=True
  329. )
  330. with right_col:
  331. # 修复的对齐图片显示
  332. self.create_aligned_image_display(zoom_level, "compact")
  333. def create_aligned_image_display(self, zoom_level: float = 1.0, layout_type: str = "aligned"):
  334. """创建与左侧对齐的图片显示 - 修复显示问题"""
  335. # 精确对齐CSS
  336. st.markdown(f"""
  337. <style>
  338. .aligned-image-container-{layout_type} {{
  339. margin-top: -70px;
  340. padding-top: 0px;
  341. }}
  342. .aligned-image-container-{layout_type} h1 {{
  343. margin-top: 0px !important;
  344. padding-top: 0px !important;
  345. }}
  346. /* 修复:确保Plotly图表容器没有额外边距 */
  347. .js-plotly-plot, .plotly {{
  348. margin: 0 !important;
  349. padding: 0 !important;
  350. }}
  351. </style>
  352. """, unsafe_allow_html=True)
  353. st.markdown(f'<div class="aligned-image-container-{layout_type}">', unsafe_allow_html=True)
  354. st.header("🖼️ 原图标注")
  355. # 方向检测控制面板
  356. with st.expander("🔄 图片方向检测", expanded=False):
  357. col1, col2, col3 = st.columns(3)
  358. with col1:
  359. if st.button("🔍 自动检测方向", key=f"{layout_type}_detect_orientation"):
  360. if self.validator.image_path:
  361. with st.spinner("正在检测图片方向..."):
  362. detection_result = self.detect_and_suggest_rotation(self.validator.image_path)
  363. st.session_state[f'{layout_type}_detection_result'] = detection_result
  364. st.rerun()
  365. with col2:
  366. manual_angle = st.selectbox(
  367. "手动设置角度",
  368. [0, 90, 180, 270],
  369. key=f"{layout_type}_manual_angle"
  370. )
  371. if st.button("应用手动角度", key=f"{layout_type}_apply_manual"):
  372. self._auto_detected_angle = float(manual_angle)
  373. st.success(f"已设置旋转角度为 {manual_angle}°")
  374. # 需要清除图片缓存,以及text_bbox_mapping中的bbox
  375. self.clear_image_cache()
  376. self.validator.process_data()
  377. st.rerun()
  378. with col3:
  379. if st.button("🔄 重置角度", key=f"{layout_type}_reset_angle"):
  380. if hasattr(self, '_auto_detected_angle'):
  381. delattr(self, '_auto_detected_angle')
  382. st.success("已重置旋转角度")
  383. # 需要清除图片缓存,以及text_bbox_mapping中的bbox
  384. self.clear_image_cache()
  385. self.validator.process_data()
  386. st.rerun()
  387. # 显示检测结果
  388. if f'{layout_type}_detection_result' in st.session_state:
  389. result = st.session_state[f'{layout_type}_detection_result']
  390. st.markdown("### 🎯 检测结果")
  391. # 结果概览
  392. result_col1, result_col2, result_col3 = st.columns(3)
  393. with result_col1:
  394. st.metric("建议角度", f"{result['detected_angle']}°")
  395. with result_col2:
  396. st.metric("置信度", f"{result['confidence']:.2%}")
  397. with result_col3:
  398. confidence_color = "🟢" if result['confidence'] > 0.7 else "🟡" if result['confidence'] > 0.4 else "🔴"
  399. st.metric("可信度", f"{confidence_color}")
  400. # 详细信息
  401. st.write(f"**检测信息:** {result['message']}")
  402. if 'method_details' in result:
  403. st.write("**方法详情:**")
  404. for detail in result['method_details']:
  405. st.write(f"• {detail}")
  406. # 应用建议角度
  407. if result['confidence'] > 0.3 and result['detected_angle'] != 0:
  408. if st.button(f"✅ 应用建议角度 {result['detected_angle']}°", key=f"{layout_type}_apply_suggested"):
  409. self._auto_detected_angle = result['detected_angle']
  410. st.success(f"已应用建议角度 {result['detected_angle']}°")
  411. # 需要清除图片缓存,以及text_bbox_mapping中的bbox
  412. self.clear_image_cache()
  413. self.validator.process_data()
  414. st.rerun()
  415. # 显示个别方法的结果
  416. if 'individual_results' in result and len(result['individual_results']) > 1:
  417. with st.expander("📊 各方法检测详情", expanded=False):
  418. for i, individual in enumerate(result['individual_results']):
  419. st.write(f"**方法 {i+1}: {individual['method']}**")
  420. st.write(f"角度: {individual['detected_angle']}°, 置信度: {individual['confidence']:.2f}")
  421. st.write(f"信息: {individual['message']}")
  422. if 'error' in individual:
  423. st.error(f"错误: {individual['error']}")
  424. st.write("---")
  425. # 图片控制选项
  426. col1, col2, col3, col4 = st.columns(4)
  427. with col1:
  428. current_zoom = st.slider("图片缩放", 0.3, 2.0, zoom_level, 0.1, key=f"{layout_type}_zoom_level")
  429. with col2:
  430. show_all_boxes = st.checkbox("显示所有框", value=False, key=f"{layout_type}_show_all_boxes")
  431. with col3:
  432. fit_to_container = st.checkbox("适应容器", value=True, key=f"{layout_type}_fit_container")
  433. with col4:
  434. # 显示当前角度状态
  435. current_angle = self.get_rotation_angle()
  436. st.metric("当前角度", f"{current_angle}°")
  437. # 使用增强的图像加载方法
  438. image = self.load_and_rotate_image(self.validator.image_path)
  439. if image:
  440. try:
  441. # 根据缩放级别调整图片大小
  442. new_width = int(image.width * current_zoom)
  443. new_height = int(image.height * current_zoom)
  444. resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
  445. # 计算选中的bbox
  446. selected_bbox = None
  447. if st.session_state.selected_text and st.session_state.selected_text in self.validator.text_bbox_mapping:
  448. info = self.validator.text_bbox_mapping[st.session_state.selected_text][0]
  449. bbox = info['bbox']
  450. selected_bbox = [int(coord * current_zoom) for coord in bbox]
  451. # 收集所有框
  452. all_boxes = []
  453. if show_all_boxes:
  454. for text, info_list in self.validator.text_bbox_mapping.items():
  455. for info in info_list:
  456. bbox = info['bbox']
  457. if len(bbox) >= 4:
  458. scaled_bbox = [coord * current_zoom for coord in bbox]
  459. all_boxes.append(scaled_bbox)
  460. # 增强的调试信息
  461. with st.expander("🔍 图像和坐标调试信息", expanded=False):
  462. rotation_angle = self.get_rotation_angle()
  463. rotation_config = get_ocr_tool_rotation_config(self.validator.ocr_data, self.config)
  464. col_debug1, col_debug2, col_debug3 = st.columns(3)
  465. with col_debug1:
  466. st.write("**图像信息:**")
  467. st.write(f"原始尺寸: {image.width} x {image.height}")
  468. st.write(f"缩放后尺寸: {resized_image.width} x {resized_image.height}")
  469. st.write(f"当前角度: {rotation_angle}°")
  470. with col_debug2:
  471. st.write("**坐标信息:**")
  472. if selected_bbox:
  473. st.write(f"选中框: {selected_bbox}")
  474. st.write(f"总框数: {len(all_boxes)}")
  475. st.write(f"文本框数: {len(self.validator.text_bbox_mapping)}")
  476. with col_debug3:
  477. st.write("**配置信息:**")
  478. st.write(f"工具类型: {rotation_config.get('coordinates_are_pre_rotated', 'unknown')}")
  479. st.write(f"缓存状态: {len(self._rotated_image_cache)} 项")
  480. if hasattr(self, '_auto_detected_angle'):
  481. st.write(f"自动检测角度: {self._auto_detected_angle}°")
  482. # 创建交互式图片
  483. fig = self.create_resized_interactive_plot(resized_image, selected_bbox, current_zoom, all_boxes)
  484. plot_config = {
  485. 'displayModeBar': True,
  486. 'modeBarButtonsToRemove': ['zoom2d', 'select2d', 'lasso2d', 'autoScale2d'],
  487. 'scrollZoom': True,
  488. 'doubleClick': 'reset'
  489. }
  490. st.plotly_chart(
  491. fig,
  492. use_container_width=fit_to_container,
  493. config=plot_config,
  494. key=f"{layout_type}_plot"
  495. )
  496. # 显示选中文本的详细信息
  497. if st.session_state.selected_text and st.session_state.selected_text in self.validator.text_bbox_mapping:
  498. st.subheader("📍 选中文本详情")
  499. info = self.validator.text_bbox_mapping[st.session_state.selected_text][0]
  500. bbox = info['bbox']
  501. info_col1, info_col2 = st.columns(2)
  502. with info_col1:
  503. st.write(f"**文本内容:** {st.session_state.selected_text[:30]}...")
  504. st.write(f"**类别:** {info['category']}")
  505. # 显示旋转信息
  506. rotation_angle = self.get_rotation_angle()
  507. if rotation_angle != 0:
  508. st.write(f"**旋转角度:** {rotation_angle}°")
  509. with info_col2:
  510. st.write(f"**位置:** [{', '.join(map(str, bbox))}]")
  511. if len(bbox) >= 4:
  512. st.write(f"**大小:** {bbox[2] - bbox[0]} x {bbox[3] - bbox[1]} px")
  513. # 错误标记功能
  514. col1, col2 = st.columns(2)
  515. with col1:
  516. if st.button("❌ 标记为错误", key=f"{layout_type}_mark_error"):
  517. st.session_state.marked_errors.add(st.session_state.selected_text)
  518. st.rerun()
  519. with col2:
  520. if st.button("✅ 取消错误标记", key=f"{layout_type}_unmark_error"):
  521. st.session_state.marked_errors.discard(st.session_state.selected_text)
  522. st.rerun()
  523. except Exception as e:
  524. st.error(f"❌ 图片处理失败: {e}")
  525. st.exception(e)
  526. else:
  527. st.error("未找到对应的图片文件")
  528. if self.validator.image_path:
  529. st.write(f"期望路径: {self.validator.image_path}")
  530. st.markdown('</div>', unsafe_allow_html=True)
  531. def create_resized_interactive_plot(self, image: Image.Image, selected_bbox: Optional[List[int]], zoom_level: float, all_boxes: list[tuple]) -> go.Figure:
  532. """
  533. 创建可调整大小的交互式图片 - 修复图像显示和bbox对齐问题
  534. 图片,box坐标全部是已缩放,旋转后的坐标
  535. """
  536. fig = go.Figure()
  537. # 添加图片 - Plotly坐标系,原点在左下角
  538. fig.add_layout_image(
  539. dict(
  540. source=image,
  541. xref="x", yref="y",
  542. x=0, y=image.height, # 图片左下角在Plotly坐标系中的位置
  543. sizex=image.width,
  544. sizey=image.height,
  545. sizing="stretch",
  546. opacity=1.0,
  547. layer="below"
  548. )
  549. )
  550. # 显示所有bbox - 需要坐标转换
  551. if len(all_boxes) > 0:
  552. for bbox in all_boxes:
  553. if len(bbox) >= 4:
  554. x1, y1, x2, y2 = bbox[:4]
  555. # 转换为Plotly坐标系(翻转Y轴)
  556. plot_x1 = x1
  557. plot_x2 = x2
  558. plot_y1 = image.height - y2 # JSON的y2 -> Plotly的底部
  559. plot_y2 = image.height - y1 # JSON的y1 -> Plotly的顶部
  560. color = "rgba(0, 100, 200, 0.2)"
  561. fig.add_shape(
  562. type="rect",
  563. x0=plot_x1, y0=plot_y1,
  564. x1=plot_x2, y1=plot_y2,
  565. line=dict(color="blue", width=1),
  566. fillcolor=color,
  567. )
  568. # 高亮显示选中的bbox
  569. if selected_bbox and len(selected_bbox) >= 4:
  570. x1, y1, x2, y2 = selected_bbox[:4]
  571. # 转换为Plotly坐标系
  572. plot_x1 = x1
  573. plot_x2 = x2
  574. plot_y1 = image.height - y2 # 翻转Y坐坐标
  575. plot_y2 = image.height - y1 # 翻转Y坐标
  576. fig.add_shape(
  577. type="rect",
  578. x0=plot_x1, y0=plot_y1,
  579. x1=plot_x2, y1=plot_y2,
  580. line=dict(color="red", width=3),
  581. fillcolor="rgba(255, 0, 0, 0.3)",
  582. )
  583. # 修复:优化显示尺寸计算
  584. max_display_width = 800
  585. max_display_height = 600
  586. # 计算合适的显示尺寸,保持宽高比
  587. aspect_ratio = image.width / image.height
  588. if aspect_ratio > 1: # 宽图
  589. display_width = min(max_display_width, image.width)
  590. display_height = int(display_width / aspect_ratio)
  591. else: # 高图
  592. display_height = min(max_display_height, image.height)
  593. display_width = int(display_height * aspect_ratio)
  594. # 修复:设置合理的布局参数
  595. fig.update_layout(
  596. width=display_width,
  597. height=display_height,
  598. margin=dict(l=0, r=0, t=0, b=0), # 移除所有边距
  599. showlegend=False,
  600. plot_bgcolor='white',
  601. dragmode="pan",
  602. # 修复:X轴设置
  603. xaxis=dict(
  604. visible=False,
  605. range=[0, image.width],
  606. constrain="domain",
  607. fixedrange=False,
  608. autorange=False,
  609. showgrid=False,
  610. zeroline=False
  611. ),
  612. # 修复:Y轴设置,确保范围正确
  613. yaxis=dict(
  614. visible=False,
  615. range=[0, image.height], # 确保Y轴范围从0到图片高度
  616. constrain="domain",
  617. scaleanchor="x",
  618. scaleratio=1,
  619. fixedrange=False,
  620. autorange=False,
  621. showgrid=False,
  622. zeroline=False
  623. )
  624. )
  625. return fig