ocr_validator_layout.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  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. rotate_image_and_coordinates,
  14. get_ocr_tool_rotation_config,
  15. )
  16. from ocr_validator_file_utils import (
  17. convert_html_table_to_markdown,
  18. parse_html_tables,
  19. draw_bbox_on_image,
  20. detect_image_orientation_by_opencv # 新增导入
  21. )
  22. class OCRLayoutManager:
  23. """OCR布局管理器"""
  24. def __init__(self, validator):
  25. self.validator = validator
  26. self.config = validator.config
  27. self._rotated_image_cache = {}
  28. self._cache_max_size = 10
  29. self._orientation_cache = {} # 缓存方向检测结果
  30. self.rotated_angle = 0.0 # 自动检测的旋转角度缓存
  31. self.show_all_boxes = False
  32. self.fit_to_container = False
  33. self.zoom_level = 1.0
  34. def clear_image_cache(self):
  35. """清理所有图像缓存"""
  36. self._rotated_image_cache.clear()
  37. def clear_cache_for_image(self, image_path: str):
  38. """清理指定图像的所有缓存"""
  39. keys_to_remove = [key for key in self._rotated_image_cache.keys() if key.startswith(image_path)]
  40. for key in keys_to_remove:
  41. del self._rotated_image_cache[key]
  42. def get_cache_info(self) -> dict:
  43. """获取缓存信息"""
  44. return {
  45. 'cache_size': len(self._rotated_image_cache),
  46. 'cached_images': list(self._rotated_image_cache.keys()),
  47. 'max_size': self._cache_max_size
  48. }
  49. def _manage_cache_size(self):
  50. """管理缓存大小,超出限制时清理最旧的缓存"""
  51. if len(self._rotated_image_cache) > self._cache_max_size:
  52. # 删除最旧的缓存项(FIFO策略)
  53. oldest_key = next(iter(self._rotated_image_cache))
  54. del self._rotated_image_cache[oldest_key]
  55. def detect_and_suggest_rotation(self, image_path: str) -> Dict:
  56. """检测并建议图片旋转角度"""
  57. if image_path in self._orientation_cache:
  58. return self._orientation_cache[image_path]
  59. # 使用自动检测功能
  60. detection_result = detect_image_orientation_by_opencv(image_path)
  61. # 缓存结果
  62. self._orientation_cache[image_path] = detection_result
  63. return detection_result
  64. def get_rotation_angle(self) -> float:
  65. """获取旋转角度 - 增强版本支持自动检测"""
  66. # 如果没有预设角度,优先人工设置
  67. if hasattr(self, 'rotated_angle') and self.rotated_angle != 0:
  68. return self.rotated_angle
  69. # 尝试从OCR数据中获取(PPStructV3等)
  70. if self.validator.ocr_data:
  71. for item in self.validator.ocr_data:
  72. if isinstance(item, dict) and 'rotation_angle' in item:
  73. return item['rotation_angle']
  74. # 如果没有预设角度,尝试自动检测
  75. if hasattr(self, 'rotated_angle'):
  76. return self.rotated_angle
  77. return 0.0
  78. def load_and_rotate_image(self, image_path: str) -> Optional[Image.Image]:
  79. """加载并根据需要旋转图像"""
  80. if not image_path or not Path(image_path).exists():
  81. return None
  82. # 检查缓存
  83. rotation_angle = self.get_rotation_angle()
  84. cache_key = f"{image_path}_{rotation_angle}"
  85. if cache_key in self._rotated_image_cache:
  86. return self._rotated_image_cache[cache_key]
  87. try:
  88. image = Image.open(image_path)
  89. # 如果需要旋转
  90. if rotation_angle != 0:
  91. # 获取OCR工具的旋转配置
  92. rotation_config = get_ocr_tool_rotation_config(self.validator.ocr_data, self.config)
  93. # st.info(f"🔄 检测到文档旋转角度: {rotation_angle}°,正在处理图像和坐标...")
  94. # st.info(f"📋 OCR工具配置: 坐标{'已预旋转' if rotation_config['coordinates_are_pre_rotated'] else '需要旋转'}")
  95. # 判断是否需要旋转坐标
  96. if rotation_config['coordinates_are_pre_rotated']:
  97. # 图片的角度与坐标的角度不一致,比如PPStructV3,图片0度,坐标已旋转270度
  98. # 这种情况下,只需要旋转图片,坐标不变
  99. # PPStructV3: 坐标已经是旋转后的,只旋转图像
  100. img_rotation_angle = (rotation_angle + self.rotated_angle) % 360
  101. if img_rotation_angle == 270:
  102. rotated_image = image.rotate(-90, expand=True) # 顺时针90度
  103. elif img_rotation_angle == 90:
  104. rotated_image = image.rotate(90, expand=True) # 逆时针90度
  105. elif img_rotation_angle == 180:
  106. rotated_image = image.rotate(180, expand=True) # 180度
  107. else:
  108. rotated_image = image.rotate(-img_rotation_angle, expand=True)
  109. if self.rotated_angle == 0:
  110. # 坐标不需要变换,因为JSON中已经是正确的坐标
  111. self._rotated_image_cache[cache_key] = rotated_image
  112. self._manage_cache_size()
  113. return rotated_image
  114. image = rotated_image # 继续使用旋转后的图像进行后续处理
  115. # Dots OCR: 需要同时旋转图像和坐标
  116. # 收集所有bbox坐标
  117. all_bboxes = []
  118. text_to_bbox_map = {} # 记录文本到bbox索引的映射
  119. bbox_index = 0
  120. for text, info_list in self.validator.text_bbox_mapping.items():
  121. text_to_bbox_map[text] = []
  122. for info in info_list:
  123. all_bboxes.append(info['bbox'])
  124. text_to_bbox_map[text].append(bbox_index)
  125. bbox_index += 1
  126. # 旋转图像和坐标
  127. rotated_image, rotated_bboxes = rotate_image_and_coordinates(
  128. image, rotation_angle, all_bboxes,
  129. rotate_coordinates=not rotation_config['coordinates_are_pre_rotated']
  130. )
  131. # 更新bbox映射 - 使用映射关系确保正确对应
  132. for text, bbox_indices in text_to_bbox_map.items():
  133. for i, bbox_idx in enumerate(bbox_indices):
  134. if bbox_idx < len(rotated_bboxes) and i < len(self.validator.text_bbox_mapping[text]):
  135. self.validator.text_bbox_mapping[text][i]['bbox'] = rotated_bboxes[bbox_idx]
  136. # 缓存结果
  137. self._rotated_image_cache[cache_key] = rotated_image
  138. self._manage_cache_size()
  139. return rotated_image
  140. else:
  141. # 无需旋转,直接缓存原图
  142. self._rotated_image_cache[cache_key] = image
  143. self._manage_cache_size() # 检查并管理缓存大小
  144. return image
  145. except Exception as e:
  146. st.error(f"❌ 图像加载失败: {e}")
  147. return None
  148. def render_content_by_mode(self, content: str, render_mode: str, font_size: int, container_height: int, layout_type: str):
  149. """根据渲染模式显示内容 - 增强版本"""
  150. if content is None or render_mode is None:
  151. return
  152. if render_mode == "HTML渲染":
  153. # 增强的HTML渲染样式,支持横向滚动
  154. content_style = f"""
  155. <style>
  156. .{layout_type}-content-display {{
  157. height: {container_height}px;
  158. overflow-x: auto;
  159. overflow-y: auto;
  160. font-size: {font_size}px !important;
  161. line-height: 1.4;
  162. color: #333333 !important;
  163. background-color: #fafafa !important;
  164. padding: 10px;
  165. border-radius: 5px;
  166. border: 1px solid #ddd;
  167. max-width: 100%;
  168. }}
  169. .{layout_type}-content-display table {{
  170. width: 100%; /* 修改:从100%改为auto,让表格自适应内容 */
  171. border-collapse: collapse;
  172. margin: 10px 0;
  173. white-space: nowrap; /* 修改:允许文字换行 */
  174. /* table-layout: auto; *? /* 新增:自动表格布局 */
  175. }}
  176. .{layout_type}-content-display th,
  177. .{layout_type}-content-display td {{
  178. border: 1px solid #ddd;
  179. padding: 8px;
  180. text-align: left;
  181. /* 移除:min-width固定限制 */
  182. max-width: 300px; /* 新增:设置最大宽度避免过宽 */
  183. word-wrap: break-word; /* 新增:长单词自动换行 */
  184. word-break: break-all; /* 新增:允许在任意字符间换行 */
  185. vertical-align: top; /* 新增:顶部对齐 */
  186. }}
  187. .{layout_type}-content-display th {{
  188. background-color: #f5f5f5;
  189. position: sticky;
  190. top: 0;
  191. z-index: 1;
  192. font-weight: bold; /* 新增:表头加粗 */
  193. }}
  194. /* 新增:针对数字列的特殊处理 */
  195. .{layout_type}-content-display td.number {{
  196. text-align: right;
  197. white-space: nowrap;
  198. font-family: 'Monaco', 'Menlo', monospace;
  199. }}
  200. /* 新增:针对短文本列的处理 */
  201. .{layout_type}-content-display td.short-text {{
  202. white-space: nowrap;
  203. min-width: 80px;
  204. }}
  205. .{layout_type}-content-display img {{
  206. max-width: 100%;
  207. height: auto;
  208. border-radius: 4px;
  209. margin: 10px 0;
  210. }}
  211. /* 新增:响应式表格 */
  212. @media (max-width: 768px) {{
  213. .{layout_type}-content-display table {{
  214. font-size: {max(font_size-2, 8)}px;
  215. }}
  216. .{layout_type}-content-display th,
  217. .{layout_type}-content-display td {{
  218. padding: 4px;
  219. max-width: 150px;
  220. }}
  221. }}
  222. .highlight-text {{
  223. background-color: #ffeb3b !important;
  224. padding: 2px 4px;
  225. border-radius: 3px;
  226. cursor: pointer;
  227. color: #333333 !important;
  228. }}
  229. .selected-highlight {{
  230. background-color: #4caf50 !important;
  231. color: white !important;
  232. }}
  233. </style>
  234. """
  235. st.markdown(content_style, unsafe_allow_html=True)
  236. st.markdown(f'<div class="{layout_type}-content-display">{content}</div>', unsafe_allow_html=True)
  237. elif render_mode == "Markdown渲染":
  238. converted_content = convert_html_table_to_markdown(content)
  239. st.markdown(converted_content, unsafe_allow_html=True)
  240. elif render_mode == "DataFrame表格":
  241. if '<table' in content.lower():
  242. self.validator.display_html_table_as_dataframe(content)
  243. else:
  244. st.info("当前内容中没有检测到HTML表格")
  245. st.markdown(content, unsafe_allow_html=True)
  246. else: # 原始文本
  247. st.text_area(
  248. "MD内容预览",
  249. content,
  250. height=300,
  251. key=f"{layout_type}_text_area"
  252. )
  253. def create_compact_layout(self, config: Dict):
  254. """创建紧凑的对比布局"""
  255. # 主要内容区域
  256. layout = config['styles']['layout']
  257. font_size = config['styles'].get('font_size', 10)
  258. container_height = layout.get('default_height', 600)
  259. zoom_level = layout.get('default_zoom', 1.0)
  260. layout_type = "compact"
  261. left_col, right_col = st.columns([layout['content_width'], layout['sidebar_width']], vertical_alignment='top', border=True)
  262. with left_col:
  263. # 快速定位文本选择器 - 增强版(搜索+下拉)
  264. if self.validator.text_bbox_mapping:
  265. # 搜索输入框
  266. search_col, select_col = st.columns([1, 2])
  267. # 初始化session state
  268. if "compact_search_query" not in st.session_state:
  269. st.session_state.compact_search_query = None
  270. with search_col:
  271. search_query = st.text_input(
  272. "搜索文本",
  273. placeholder="输入关键词...",
  274. value=st.session_state.compact_search_query,
  275. key=f"{layout_type}_search_input",
  276. label_visibility="collapsed"
  277. )
  278. # 更新session state
  279. st.session_state.compact_search_query = search_query
  280. # 构建选项列表
  281. text_options = ["请选择文本..."]
  282. text_display = ["请选择文本..."]
  283. for text, info_list in self.validator.text_bbox_mapping.items():
  284. # 如果有搜索条件,进行过滤
  285. if search_query and search_query.strip():
  286. if search_query.lower() not in text.lower():
  287. continue # 跳过不匹配的项
  288. text_options.append(text)
  289. # 检查是否是表格单元格
  290. if info_list and isinstance(info_list[0], dict):
  291. first_info = info_list[0]
  292. if 'row' in first_info and 'col' in first_info:
  293. display_text = f"[R{first_info['row']},C{first_info['col']}] {text}"
  294. if len(display_text) > 47:
  295. display_text = display_text[:44] + "..."
  296. else:
  297. display_text = text[:47] + "..." if len(text) > 50 else text
  298. else:
  299. display_text = text[:47] + "..." if len(text) > 50 else text
  300. text_display.append(display_text)
  301. # 显示匹配数量
  302. if search_query and search_query.strip():
  303. st.caption(f"找到 {len(text_options)-1} 个匹配项")
  304. # 确定默认选中的索引
  305. default_index = 0
  306. if st.session_state.selected_text and st.session_state.selected_text in text_options:
  307. default_index = text_options.index(st.session_state.selected_text)
  308. with select_col:
  309. selected_index = st.selectbox(
  310. "快速定位文本",
  311. range(len(text_options)),
  312. index=default_index,
  313. format_func=lambda x: text_display[x] if x < len(text_display) else "",
  314. label_visibility="collapsed",
  315. key=f"{layout_type}_quick_text_selector"
  316. )
  317. if selected_index > 0:
  318. st.session_state.selected_text = text_options[selected_index]
  319. # 处理并显示OCR内容 - 只高亮选中的文本
  320. if self.validator.md_content:
  321. highlighted_content = self.validator.md_content
  322. # 只高亮选中的文本
  323. if st.session_state.selected_text:
  324. selected_text = st.session_state.selected_text
  325. if len(selected_text) > 2:
  326. highlighted_content = highlighted_content.replace(
  327. selected_text,
  328. f'<span class="highlight-text selected-highlight" title="{selected_text}">{selected_text}</span>'
  329. )
  330. self.render_content_by_mode(highlighted_content, "HTML渲染", font_size, container_height, layout_type)
  331. with right_col:
  332. # 修复的对齐图片显示
  333. self.create_aligned_image_display(zoom_level, "compact")
  334. def create_aligned_image_display(self, zoom_level: float = 1.0, layout_type: str = "aligned"):
  335. """创建响应式图片显示"""
  336. # st.header("🖼️ 原图标注")
  337. # 图片控制选项
  338. col1, col2, col3, col4, col5 = st.columns(5, vertical_alignment="center", border= False)
  339. with col1:
  340. # 判断{layout_type}_show_all_boxes是否有值,如果有值直接使用,否则默认False
  341. # if f"{layout_type}_show_all_boxes" not in st.session_state:
  342. # st.session_state[f"{layout_type}_show_all_boxes"] = False
  343. show_all_boxes = st.checkbox(
  344. "显示所有框",
  345. # value=st.session_state[f"{layout_type}_show_all_boxes"],
  346. value = self.show_all_boxes,
  347. key=f"{layout_type}_show_all_boxes"
  348. )
  349. if show_all_boxes != self.show_all_boxes:
  350. self.show_all_boxes = show_all_boxes
  351. with col2:
  352. if st.button("🔄 旋转90度", type="secondary", key=f"{layout_type}_manual_angle"):
  353. self.rotated_angle = (self.rotated_angle + 90) % 360
  354. # 需要清除图片缓存,以及text_bbox_mapping中的bbox
  355. self.clear_image_cache()
  356. self.validator.process_data()
  357. st.rerun()
  358. with col3:
  359. # 显示当前角度状态
  360. current_angle = self.get_rotation_angle()
  361. st.metric("当前角度", f"{current_angle}°", label_visibility="collapsed")
  362. with col4:
  363. if st.button("↺ 重置角度", key=f"{layout_type}_reset_angle"):
  364. self.rotated_angle = 0.0
  365. st.success("已重置旋转角度")
  366. # 需要清除图片缓存,以及text_bbox_mapping中的bbox
  367. self.clear_image_cache()
  368. self.validator.process_data()
  369. st.rerun()
  370. with col5:
  371. if st.button("🧹 清除选择", key=f"{layout_type}_clear_selection"):
  372. # 清除选中的文本
  373. st.session_state.selected_text = None
  374. # 清除搜索框内容
  375. st.session_state.compact_search_query = None
  376. st.rerun()
  377. # 使用增强的图像加载方法
  378. image = self.load_and_rotate_image(self.validator.image_path)
  379. if image:
  380. try:
  381. resized_image, all_boxes, selected_boxes = self.zoom_image(image, self.zoom_level)
  382. # 创建交互式图片
  383. fig = self.create_resized_interactive_plot(resized_image, selected_boxes, self.zoom_level, all_boxes)
  384. plot_config = {
  385. 'displayModeBar': True,
  386. 'modeBarButtonsToRemove': ['zoom2d', 'select2d', 'lasso2d', 'autoScale2d'],
  387. 'scrollZoom': True,
  388. 'doubleClick': 'reset',
  389. 'responsive': False, # 关键:禁用响应式,使用固定尺寸
  390. 'toImageButtonOptions': {
  391. 'format': 'png',
  392. 'filename': 'ocr_image',
  393. 'height': None, # 使用当前高度
  394. 'width': None, # 使用当前宽度
  395. 'scale': 1
  396. }
  397. }
  398. st.plotly_chart(
  399. fig,
  400. width='content',
  401. config=plot_config,
  402. key=f"{layout_type}_plot"
  403. )
  404. except Exception as e:
  405. st.error(f"❌ 图片处理失败: {e}")
  406. st.exception(e)
  407. else:
  408. st.error("未找到对应的图片文件")
  409. if self.validator.image_path:
  410. st.write(f"期望路径: {self.validator.image_path}")
  411. # st.markdown('</div>', unsafe_allow_html=True)
  412. def zoom_image(self, image: Image.Image, current_zoom: float) -> Tuple[Image.Image, List[List[int]], List[List[int]]]:
  413. """缩放图像"""
  414. # 根据缩放级别调整图片大小
  415. new_width = int(image.width * current_zoom)
  416. new_height = int(image.height * current_zoom)
  417. resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
  418. # 计算选中的bbox
  419. selected_boxes = []
  420. if st.session_state.selected_text and st.session_state.selected_text in self.validator.text_bbox_mapping:
  421. info_list = self.validator.text_bbox_mapping[st.session_state.selected_text]
  422. for info in info_list:
  423. if 'bbox' in info:
  424. bbox = info['bbox']
  425. selected_box = [int(coord * current_zoom) for coord in bbox]
  426. selected_boxes.append(selected_box)
  427. # 收集所有框
  428. all_boxes = []
  429. if self.show_all_boxes:
  430. for text, info_list in self.validator.text_bbox_mapping.items():
  431. for info in info_list:
  432. bbox = info['bbox']
  433. if len(bbox) >= 4:
  434. scaled_bbox = [coord * current_zoom for coord in bbox]
  435. all_boxes.append(scaled_bbox)
  436. return resized_image, all_boxes, selected_boxes
  437. def _add_bboxes_to_plot(self, fig: go.Figure, bboxes: List[List[int]], image_height: int,
  438. line_color: str = "blue", line_width: int = 1,
  439. fill_color: str = "rgba(0, 100, 200, 0.2)"):
  440. """
  441. 在plotly图表上添加边界框
  442. Args:
  443. fig: plotly图表对象
  444. bboxes: 边界框列表,每个bbox格式为[x1, y1, x2, y2]
  445. image_height: 图片高度,用于Y轴坐标转换
  446. line_color: 边框线颜色
  447. line_width: 边框线宽度
  448. fill_color: 填充颜色(RGBA格式)
  449. """
  450. if not bboxes or len(bboxes) == 0:
  451. return
  452. for bbox in bboxes:
  453. if len(bbox) < 4:
  454. continue
  455. x1, y1, x2, y2 = bbox[:4]
  456. # 转换为Plotly坐标系(翻转Y轴)
  457. # JSON格式: 原点在左上角, y向下增加
  458. # Plotly格式: 原点在左下角, y向上增加
  459. plot_x1 = x1
  460. plot_x2 = x2
  461. plot_y1 = image_height - y2 # JSON的y2(底部) -> Plotly的底部
  462. plot_y2 = image_height - y1 # JSON的y1(顶部) -> Plotly的顶部
  463. fig.add_shape(
  464. type="rect",
  465. x0=plot_x1, y0=plot_y1,
  466. x1=plot_x2, y1=plot_y2,
  467. line=dict(color=line_color, width=line_width),
  468. fillcolor=fill_color,
  469. )
  470. def create_resized_interactive_plot(self, image: Image.Image, selected_boxes: List[List[int]],
  471. zoom_level: float, all_boxes: List[List[int]]) -> go.Figure:
  472. """创建可调整大小的交互式图片 - 修复容器溢出问题"""
  473. fig = go.Figure()
  474. # 添加图片 - Plotly坐标系,原点在左下角
  475. fig.add_layout_image(
  476. dict(
  477. source=image,
  478. xref="x", yref="y",
  479. x=0, y=image.height, # 图片左下角在Plotly坐标系中的位置
  480. sizex=image.width,
  481. sizey=image.height,
  482. sizing="stretch",
  483. opacity=1.0,
  484. layer="below",
  485. yanchor="top" # 确保图片顶部对齐
  486. )
  487. )
  488. # 显示所有bbox(淡蓝色)
  489. if all_boxes:
  490. self._add_bboxes_to_plot(
  491. fig=fig,
  492. bboxes=all_boxes,
  493. image_height=image.height,
  494. line_color="blue",
  495. line_width=1,
  496. fill_color="rgba(0, 100, 200, 0.2)"
  497. )
  498. # 高亮显示选中的bbox(红色)
  499. if selected_boxes:
  500. self._add_bboxes_to_plot(
  501. fig=fig,
  502. bboxes=selected_boxes,
  503. image_height=image.height,
  504. line_color="red",
  505. line_width=3,
  506. fill_color="rgba(255, 0, 0, 0.3)"
  507. )
  508. # 修复:优化显示尺寸计算
  509. max_display_width = 1500
  510. max_display_height = 1000
  511. # 计算合适的显示尺寸,保持宽高比
  512. aspect_ratio = image.width / image.height
  513. if self.fit_to_container:
  514. # 自适应容器模式
  515. if aspect_ratio > 1: # 宽图
  516. display_width = min(max_display_width, image.width)
  517. display_height = int(display_width / aspect_ratio)
  518. else: # 高图
  519. display_height = min(max_display_height, image.height)
  520. display_width = int(display_height * aspect_ratio)
  521. # 确保不会太小
  522. display_width = max(display_width, 800)
  523. display_height = max(display_height, 600)
  524. else:
  525. # 固定尺寸模式,但仍要考虑容器限制
  526. display_width = min(image.width, max_display_width)
  527. display_height = min(image.height, max_display_height)
  528. # 设置布局 - 关键修改
  529. fig.update_layout(
  530. width=display_width,
  531. height=display_height,
  532. margin=dict(l=0, r=0, t=0, b=0),
  533. showlegend=False,
  534. plot_bgcolor='white',
  535. dragmode="pan",
  536. # 关键:让图表自适应容器
  537. # autosize=True, # 启用自动调整大小
  538. xaxis=dict(
  539. visible=False,
  540. range=[0, image.width],
  541. constrain="domain",
  542. fixedrange=False,
  543. autorange=False,
  544. showgrid=False,
  545. zeroline=False,
  546. ),
  547. # 修复:Y轴设置,确保范围正确
  548. yaxis=dict(
  549. visible=False,
  550. range=[0, image.height], # 确保Y轴范围从0到图片高度
  551. constrain="domain",
  552. scaleanchor="x",
  553. scaleratio=1,
  554. fixedrange=False,
  555. autorange=False,
  556. showgrid=False,
  557. zeroline=False
  558. )
  559. )
  560. return fig