streamlit_table_line_editor.py 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  1. """
  2. 表格线可视化编辑器
  3. 支持人工调整表格线位置
  4. """
  5. import streamlit as st
  6. from pathlib import Path
  7. import json
  8. from PIL import Image, ImageDraw, ImageFont
  9. import numpy as np
  10. import copy
  11. try:
  12. from .table_line_generator import TableLineGenerator
  13. except ImportError:
  14. from table_line_generator import TableLineGenerator
  15. def parse_ocr_data(ocr_data):
  16. """解析OCR数据,支持多种格式"""
  17. # 如果是字符串,尝试解析
  18. if isinstance(ocr_data, str):
  19. try:
  20. ocr_data = json.loads(ocr_data)
  21. except json.JSONDecodeError:
  22. st.error("❌ JSON 格式错误,无法解析")
  23. return []
  24. # 检查是否为 PPStructure V3 格式
  25. if isinstance(ocr_data, dict) and 'parsing_res_list' in ocr_data and 'overall_ocr_res' in ocr_data:
  26. st.info("🔍 检测到 PPStructure V3 格式")
  27. try:
  28. table_bbox, text_boxes = TableLineGenerator.parse_ppstructure_result(ocr_data)
  29. st.success(f"✅ 表格区域: {table_bbox}")
  30. st.success(f"✅ 表格内文本框: {len(text_boxes)} 个")
  31. return text_boxes
  32. except Exception as e:
  33. st.error(f"❌ 解析 PPStructure 结果失败: {e}")
  34. return []
  35. # 确保是列表
  36. if not isinstance(ocr_data, list):
  37. st.error(f"❌ OCR 数据应该是列表,实际类型: {type(ocr_data)}")
  38. return []
  39. if not ocr_data:
  40. st.warning("⚠️ OCR 数据为空")
  41. return []
  42. first_item = ocr_data[0]
  43. if not isinstance(first_item, dict):
  44. st.error(f"❌ OCR 数据项应该是字典,实际类型: {type(first_item)}")
  45. return []
  46. if 'bbox' not in first_item:
  47. st.error("❌ OCR 数据缺少 'bbox' 字段")
  48. st.info("💡 支持的格式示例:\n```json\n[\n {\n \"text\": \"文本\",\n \"bbox\": [x1, y1, x2, y2]\n }\n]\n```")
  49. return []
  50. return ocr_data
  51. def draw_table_lines_with_numbers(image, structure, line_width=2, show_numbers=True):
  52. """
  53. 绘制带编号的表格线(使用线坐标列表)
  54. Args:
  55. image: PIL Image 对象
  56. structure: 表格结构字典(包含 horizontal_lines 和 vertical_lines)
  57. line_width: 线条宽度
  58. show_numbers: 是否显示编号
  59. Returns:
  60. 绘制了表格线和编号的图片
  61. """
  62. img_with_lines = image.copy()
  63. draw = ImageDraw.Draw(img_with_lines)
  64. # 尝试加载字体
  65. try:
  66. font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 20)
  67. except:
  68. font = ImageFont.load_default()
  69. # 🆕 使用线坐标列表
  70. horizontal_lines = structure.get('horizontal_lines', [])
  71. vertical_lines = structure.get('vertical_lines', [])
  72. modified_h_lines = structure.get('modified_h_lines', set())
  73. modified_v_lines = structure.get('modified_v_lines', set())
  74. # 计算绘制范围
  75. x_start = vertical_lines[0] if vertical_lines else 0
  76. x_end = vertical_lines[-1] if vertical_lines else img_with_lines.width
  77. y_start = horizontal_lines[0] if horizontal_lines else 0
  78. y_end = horizontal_lines[-1] if horizontal_lines else img_with_lines.height
  79. # 🎨 绘制横线
  80. for idx, y in enumerate(horizontal_lines):
  81. color = (255, 0, 0) if idx in modified_h_lines else (0, 0, 255)
  82. draw.line([(x_start, y), (x_end, y)], fill=color, width=line_width)
  83. # 🔢 绘制行编号
  84. if show_numbers:
  85. text = f"R{idx+1}"
  86. bbox = draw.textbbox((x_start - 35, y - 10), text, font=font)
  87. draw.rectangle(bbox, fill='white', outline='black')
  88. draw.text((x_start - 35, y - 10), text, fill=color, font=font)
  89. # 🎨 绘制竖线
  90. for idx, x in enumerate(vertical_lines):
  91. color = (255, 0, 0) if idx in modified_v_lines else (0, 0, 255)
  92. draw.line([(x, y_start), (x, y_end)], fill=color, width=line_width)
  93. # 🔢 绘制列编号
  94. if show_numbers:
  95. text = f"C{idx+1}"
  96. bbox = draw.textbbox((x - 10, y_start - 25), text, font=font)
  97. draw.rectangle(bbox, fill='white', outline='black')
  98. draw.text((x - 10, y_start - 25), text, fill=color, font=font)
  99. bbox = draw.textbbox((x - 10, y_end + 25), text, font=font)
  100. draw.rectangle(bbox, fill='white', outline='black')
  101. draw.text((x - 10, y_end + 25), text, fill=color, font=font)
  102. return img_with_lines
  103. # 🆕 新增:用于保存的纯净表格线绘制函数
  104. def draw_clean_table_lines(image, structure, line_width=2, line_color=(0, 0, 0)):
  105. """
  106. 绘制纯净的表格线(用于保存)
  107. - 所有线用黑色
  108. - 不显示编号
  109. Args:
  110. image: PIL Image 对象
  111. structure: 表格结构字典
  112. line_width: 线条宽度
  113. line_color: 线条颜色,默认黑色 (0, 0, 0)
  114. Returns:
  115. 绘制了纯净表格线的图片
  116. """
  117. img_with_lines = image.copy()
  118. draw = ImageDraw.Draw(img_with_lines)
  119. horizontal_lines = structure.get('horizontal_lines', [])
  120. vertical_lines = structure.get('vertical_lines', [])
  121. if not horizontal_lines or not vertical_lines:
  122. return img_with_lines
  123. # 计算绘制范围
  124. x_start = vertical_lines[0]
  125. x_end = vertical_lines[-1]
  126. y_start = horizontal_lines[0]
  127. y_end = horizontal_lines[-1]
  128. # 🖤 绘制横线(统一黑色)
  129. for y in horizontal_lines:
  130. draw.line([(x_start, y), (x_end, y)], fill=line_color, width=line_width)
  131. # 🖤 绘制竖线(统一黑色)
  132. for x in vertical_lines:
  133. draw.line([(x, y_start), (x, y_end)], fill=line_color, width=line_width)
  134. return img_with_lines
  135. def init_undo_stack():
  136. """初始化撤销/重做栈"""
  137. if 'undo_stack' not in st.session_state:
  138. st.session_state.undo_stack = []
  139. if 'redo_stack' not in st.session_state:
  140. st.session_state.redo_stack = []
  141. def save_state_for_undo(structure):
  142. """保存当前状态到撤销栈"""
  143. # 深拷贝当前结构
  144. state = copy.deepcopy(structure)
  145. st.session_state.undo_stack.append(state)
  146. # 清空重做栈
  147. st.session_state.redo_stack = []
  148. # 限制栈深度(最多保存20个历史状态)
  149. if len(st.session_state.undo_stack) > 20:
  150. st.session_state.undo_stack.pop(0)
  151. def undo_last_action():
  152. """撤销上一个操作"""
  153. if st.session_state.undo_stack:
  154. # 保存当前状态到重做栈
  155. current_state = copy.deepcopy(st.session_state.structure)
  156. st.session_state.redo_stack.append(current_state)
  157. # 恢复上一个状态
  158. st.session_state.structure = st.session_state.undo_stack.pop()
  159. return True
  160. return False
  161. def redo_last_action():
  162. """重做上一个操作"""
  163. if st.session_state.redo_stack:
  164. # 保存当前状态到撤销栈
  165. current_state = copy.deepcopy(st.session_state.structure)
  166. st.session_state.undo_stack.append(current_state)
  167. # 恢复重做的状态
  168. st.session_state.structure = st.session_state.redo_stack.pop()
  169. return True
  170. return False
  171. def get_structure_hash(structure, line_width, show_numbers):
  172. """生成结构的哈希值,用于判断是否需要重新绘制"""
  173. import hashlib
  174. # 🔧 使用线坐标列表生成哈希
  175. key_data = {
  176. 'horizontal_lines': structure.get('horizontal_lines', []),
  177. 'vertical_lines': structure.get('vertical_lines', []),
  178. 'modified_h_lines': sorted(list(structure.get('modified_h_lines', set()))),
  179. 'modified_v_lines': sorted(list(structure.get('modified_v_lines', set()))),
  180. 'line_width': line_width,
  181. 'show_numbers': show_numbers
  182. }
  183. key_str = json.dumps(key_data, sort_keys=True)
  184. return hashlib.md5(key_str.encode()).hexdigest()
  185. def get_cached_table_lines_image(image, structure, line_width, show_numbers):
  186. """
  187. 获取缓存的表格线图片,如果缓存不存在或失效则重新绘制
  188. Args:
  189. image: PIL Image 对象
  190. structure: 表格结构字典
  191. line_width: 线条宽度
  192. show_numbers: 是否显示编号
  193. Returns:
  194. 绘制了表格线和编号的图片
  195. """
  196. # 初始化缓存
  197. if 'cached_table_image' not in st.session_state:
  198. st.session_state.cached_table_image = None
  199. if 'cached_table_hash' not in st.session_state:
  200. st.session_state.cached_table_hash = None
  201. # 计算当前结构的哈希
  202. current_hash = get_structure_hash(structure, line_width, show_numbers)
  203. # 检查缓存是否有效
  204. if (st.session_state.cached_table_hash == current_hash and
  205. st.session_state.cached_table_image is not None):
  206. # 缓存有效,直接返回
  207. return st.session_state.cached_table_image
  208. # 缓存失效,重新绘制
  209. img_with_lines = draw_table_lines_with_numbers(
  210. image,
  211. structure,
  212. line_width=line_width,
  213. show_numbers=show_numbers
  214. )
  215. # 更新缓存
  216. st.session_state.cached_table_image = img_with_lines
  217. st.session_state.cached_table_hash = current_hash
  218. return img_with_lines
  219. def clear_table_image_cache():
  220. """清除表格图片缓存"""
  221. if 'cached_table_image' in st.session_state:
  222. st.session_state.cached_table_image = None
  223. if 'cached_table_hash' in st.session_state:
  224. st.session_state.cached_table_hash = None
  225. def load_structure_from_config(config_path: Path) -> dict:
  226. """
  227. 从配置文件加载表格结构
  228. Args:
  229. config_path: 配置文件路径
  230. Returns:
  231. 表格结构字典
  232. """
  233. with open(config_path, 'r', encoding='utf-8') as f:
  234. structure = json.load(f)
  235. # 🔧 兼容旧版配置(补充缺失字段)
  236. if 'horizontal_lines' not in structure:
  237. # 从 rows 生成横线坐标
  238. horizontal_lines = []
  239. for row in structure.get('rows', []):
  240. horizontal_lines.append(row['y_start'])
  241. if structure.get('rows'):
  242. horizontal_lines.append(structure['rows'][-1]['y_end'])
  243. structure['horizontal_lines'] = horizontal_lines
  244. if 'vertical_lines' not in structure:
  245. # 从 columns 生成竖线坐标
  246. vertical_lines = []
  247. for col in structure.get('columns', []):
  248. vertical_lines.append(col['x_start'])
  249. if structure.get('columns'):
  250. vertical_lines.append(structure['columns'][-1]['x_end'])
  251. structure['vertical_lines'] = vertical_lines
  252. # 🔧 转换修改标记(从列表转为集合)
  253. if 'modified_h_lines' in structure:
  254. structure['modified_h_lines'] = set(structure['modified_h_lines'])
  255. else:
  256. structure['modified_h_lines'] = set()
  257. if 'modified_v_lines' in structure:
  258. structure['modified_v_lines'] = set(structure['modified_v_lines'])
  259. else:
  260. structure['modified_v_lines'] = set()
  261. # 🔧 转换旧版的 modified_rows/modified_cols(如果存在)
  262. if 'modified_rows' in structure and not structure['modified_h_lines']:
  263. structure['modified_h_lines'] = set(structure.get('modified_rows', []))
  264. if 'modified_cols' in structure and not structure['modified_v_lines']:
  265. structure['modified_v_lines'] = set(structure.get('modified_cols', []))
  266. return structure
  267. def create_table_line_editor():
  268. """创建表格线编辑器界面"""
  269. # 🆕 配置页面为宽屏模式
  270. st.set_page_config(
  271. page_title="表格线编辑器",
  272. page_icon="📏",
  273. layout="wide",
  274. initial_sidebar_state="expanded"
  275. )
  276. st.title("📏 表格线编辑器")
  277. # 初始化 session_state
  278. if 'loaded_json_name' not in st.session_state:
  279. st.session_state.loaded_json_name = None
  280. if 'loaded_image_name' not in st.session_state:
  281. st.session_state.loaded_image_name = None
  282. if 'loaded_config_name' not in st.session_state:
  283. st.session_state.loaded_config_name = None
  284. if 'ocr_data' not in st.session_state:
  285. st.session_state.ocr_data = None
  286. if 'image' not in st.session_state:
  287. st.session_state.image = None
  288. # 初始化撤销/重做栈
  289. init_undo_stack()
  290. # 🆕 添加工作模式选择
  291. st.sidebar.header("📂 工作模式")
  292. work_mode = st.sidebar.radio(
  293. "选择模式",
  294. ["🆕 新建标注", "📂 加载已有标注"],
  295. index=0
  296. )
  297. if work_mode == "🆕 新建标注":
  298. # 原有的上传流程
  299. st.sidebar.subheader("上传文件")
  300. uploaded_json = st.sidebar.file_uploader("上传OCR结果JSON", type=['json'], key="new_json")
  301. uploaded_image = st.sidebar.file_uploader("上传对应图片", type=['jpg', 'png'], key="new_image")
  302. # 检查是否需要重新加载 JSON
  303. if uploaded_json is not None:
  304. if st.session_state.loaded_json_name != uploaded_json.name:
  305. try:
  306. raw_data = json.load(uploaded_json)
  307. with st.expander("🔍 原始数据结构"):
  308. if isinstance(raw_data, dict):
  309. st.json({k: f"<{type(v).__name__}>" if not isinstance(v, (str, int, float, bool, type(None))) else v
  310. for k, v in list(raw_data.items())[:5]})
  311. else:
  312. st.json(raw_data[:3] if len(raw_data) > 3 else raw_data)
  313. ocr_data = parse_ocr_data(raw_data)
  314. if not ocr_data:
  315. st.error("❌ 无法解析 OCR 数据,请检查 JSON 格式")
  316. st.stop()
  317. st.session_state.ocr_data = ocr_data
  318. st.session_state.loaded_json_name = uploaded_json.name
  319. st.session_state.loaded_config_name = None # 清除配置文件标记
  320. # 清除旧的分析结果、历史记录和缓存
  321. if 'structure' in st.session_state:
  322. del st.session_state.structure
  323. if 'generator' in st.session_state:
  324. del st.session_state.generator
  325. st.session_state.undo_stack = []
  326. st.session_state.redo_stack = []
  327. clear_table_image_cache()
  328. st.success(f"✅ 成功加载 {len(ocr_data)} 条 OCR 记录")
  329. except Exception as e:
  330. st.error(f"❌ 加载数据失败: {e}")
  331. st.stop()
  332. # 检查是否需要重新加载图片
  333. if uploaded_image is not None:
  334. if st.session_state.loaded_image_name != uploaded_image.name:
  335. try:
  336. image = Image.open(uploaded_image)
  337. st.session_state.image = image
  338. st.session_state.loaded_image_name = uploaded_image.name
  339. if 'structure' in st.session_state:
  340. del st.session_state.structure
  341. if 'generator' in st.session_state:
  342. del st.session_state.generator
  343. st.session_state.undo_stack = []
  344. st.session_state.redo_stack = []
  345. clear_table_image_cache()
  346. st.success(f"✅ 成功加载图片: {uploaded_image.name}")
  347. except Exception as e:
  348. st.error(f"❌ 加载图片失败: {e}")
  349. st.stop()
  350. else: # 📂 加载已有标注
  351. st.sidebar.subheader("加载已保存的标注")
  352. # 🆕 上传配置文件
  353. uploaded_config = st.sidebar.file_uploader(
  354. "上传配置文件 (*_structure.json)",
  355. type=['json'],
  356. key="load_config"
  357. )
  358. # 🆕 上传对应的图片(可选,用于重新标注)
  359. uploaded_image_for_config = st.sidebar.file_uploader(
  360. "上传对应图片(可选)",
  361. type=['jpg', 'png'],
  362. key="load_image"
  363. )
  364. # 处理配置文件加载
  365. if uploaded_config is not None:
  366. if st.session_state.loaded_config_name != uploaded_config.name:
  367. try:
  368. # 🔧 直接从配置文件路径加载
  369. import tempfile
  370. # 创建临时文件
  371. with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as tmp:
  372. tmp.write(uploaded_config.getvalue().decode('utf-8'))
  373. tmp_path = tmp.name
  374. # 加载结构
  375. structure = load_structure_from_config(Path(tmp_path))
  376. # 清理临时文件
  377. Path(tmp_path).unlink()
  378. st.session_state.structure = structure
  379. st.session_state.loaded_config_name = uploaded_config.name
  380. # 清除历史记录和缓存
  381. st.session_state.undo_stack = []
  382. st.session_state.redo_stack = []
  383. clear_table_image_cache()
  384. st.success(f"✅ 成功加载配置: {uploaded_config.name}")
  385. st.info(
  386. f"📊 表格结构: {len(structure['rows'])}行 x {len(structure['columns'])}列\n\n"
  387. f"📏 横线数: {len(structure.get('horizontal_lines', []))}\n\n"
  388. f"📏 竖线数: {len(structure.get('vertical_lines', []))}"
  389. )
  390. # 🆕 显示配置文件详情
  391. with st.expander("📋 配置详情"):
  392. st.json({
  393. "行数": len(structure['rows']),
  394. "列数": len(structure['columns']),
  395. "横线数": len(structure.get('horizontal_lines', [])),
  396. "竖线数": len(structure.get('vertical_lines', [])),
  397. "行高": structure.get('row_height'),
  398. "列宽": structure.get('col_widths'),
  399. "已修改的横线": list(structure.get('modified_h_lines', set())),
  400. "已修改的竖线": list(structure.get('modified_v_lines', set()))
  401. })
  402. except Exception as e:
  403. st.error(f"❌ 加载配置失败: {e}")
  404. import traceback
  405. st.code(traceback.format_exc())
  406. st.stop()
  407. # 处理图片加载(用于显示)
  408. if uploaded_image_for_config is not None:
  409. if st.session_state.loaded_image_name != uploaded_image_for_config.name:
  410. try:
  411. image = Image.open(uploaded_image_for_config)
  412. st.session_state.image = image
  413. st.session_state.loaded_image_name = uploaded_image_for_config.name
  414. clear_table_image_cache()
  415. st.success(f"✅ 成功加载图片: {uploaded_image_for_config.name}")
  416. except Exception as e:
  417. st.error(f"❌ 加载图片失败: {e}")
  418. st.stop()
  419. # 🆕 如果配置已加载但没有图片,提示用户
  420. if 'structure' in st.session_state and st.session_state.image is None:
  421. st.warning("⚠️ 已加载配置,但未加载对应图片。请上传图片以查看效果。")
  422. st.info("💡 提示:配置文件已加载,您可以:\n1. 上传对应图片查看效果\n2. 直接编辑配置并保存")
  423. # 检查必要条件
  424. if work_mode == "🆕 新建标注":
  425. if st.session_state.ocr_data is None or st.session_state.image is None:
  426. st.info("👆 请在左侧上传 OCR 结果 JSON 文件和对应的图片")
  427. with st.expander("📖 使用说明"):
  428. st.markdown("""
  429. ### 🆕 新建标注模式
  430. **支持的OCR格式**
  431. **1. PPStructure V3 格式 (推荐)**
  432. ```json
  433. {
  434. "parsing_res_list": [...],
  435. "overall_ocr_res": {
  436. "rec_boxes": [[x1, y1, x2, y2], ...],
  437. "rec_texts": ["文本1", "文本2", ...]
  438. }
  439. }
  440. ```
  441. **2. 标准格式**
  442. ```json
  443. [
  444. {
  445. "text": "文本内容",
  446. "bbox": [x1, y1, x2, y2]
  447. }
  448. ]
  449. ```
  450. ### 📂 加载已有标注模式
  451. 1. 上传之前保存的 `*_structure.json` 配置文件
  452. 2. 上传对应的图片(可选)
  453. 3. 继续调整表格线位置
  454. 4. 保存更新后的配置
  455. """)
  456. return
  457. ocr_data = st.session_state.ocr_data
  458. image = st.session_state.image
  459. st.info(f"📂 已加载: {st.session_state.loaded_json_name} + {st.session_state.loaded_image_name}")
  460. if 'generator' not in st.session_state or st.session_state.generator is None:
  461. try:
  462. generator = TableLineGenerator(image, ocr_data)
  463. st.session_state.generator = generator
  464. except Exception as e:
  465. st.error(f"❌ 初始化失败: {e}")
  466. st.stop()
  467. else: # 加载已有标注模式
  468. if 'structure' not in st.session_state:
  469. st.info("👆 请在左侧上传配置文件 (*_structure.json)")
  470. with st.expander("📖 使用说明"):
  471. st.markdown("""
  472. ### 📂 加载已有标注
  473. **步骤:**
  474. 1. **上传配置文件**:选择之前保存的 `*_structure.json`
  475. 2. **上传图片**(可选):上传对应的图片以查看效果
  476. 3. **调整表格线**:使用下方的工具调整横线/竖线位置
  477. 4. **保存更新**:保存修改后的配置
  478. **提示:**
  479. - 即使没有图片,也可以直接编辑配置文件中的坐标
  480. - 配置文件包含完整的表格结构信息
  481. - 可以应用到同类型的其他页面
  482. """)
  483. return
  484. if st.session_state.image is None:
  485. st.warning("⚠️ 仅加载了配置,未加载图片。部分功能受限。")
  486. # 🆕 使用配置中的信息
  487. structure = st.session_state.structure
  488. image = st.session_state.image
  489. if image is None:
  490. # 如果没有图片,创建一个虚拟的空白图片用于显示坐标信息
  491. if 'table_bbox' in structure:
  492. bbox = structure['table_bbox']
  493. dummy_width = bbox[2] + 100
  494. dummy_height = bbox[3] + 100
  495. else:
  496. dummy_width = 2000
  497. dummy_height = 2000
  498. image = Image.new('RGB', (dummy_width, dummy_height), color='white')
  499. st.info(f"💡 使用虚拟画布 ({dummy_width}x{dummy_height}) 显示表格结构")
  500. # 显示设置
  501. st.sidebar.divider()
  502. st.sidebar.subheader("🖼️ 显示设置")
  503. line_width = st.sidebar.slider("线条宽度", 1, 5, 2)
  504. display_mode = st.sidebar.radio("显示模式", ["对比显示", "仅显示划线图", "仅显示原图"], index=1)
  505. zoom_level = st.sidebar.slider("图片缩放", 0.25, 2.0, 1.0, 0.25)
  506. show_line_numbers = st.sidebar.checkbox("显示线条编号", value=True)
  507. # 撤销/重做按钮
  508. st.sidebar.divider()
  509. st.sidebar.subheader("↩️ 撤销/重做")
  510. col1, col2 = st.sidebar.columns(2)
  511. with col1:
  512. if st.button("↩️ 撤销", disabled=len(st.session_state.undo_stack) == 0):
  513. if undo_last_action():
  514. clear_table_image_cache()
  515. st.success("✅ 已撤销")
  516. st.rerun()
  517. with col2:
  518. if st.button("↪️ 重做", disabled=len(st.session_state.redo_stack) == 0):
  519. if redo_last_action():
  520. clear_table_image_cache()
  521. st.success("✅ 已重做")
  522. st.rerun()
  523. st.sidebar.info(f"📚 历史记录: {len(st.session_state.undo_stack)} 条")
  524. # 分析表格结构(仅在新建模式显示)
  525. if work_mode == "🆕 新建标注" and st.button("🔍 分析表格结构"):
  526. with st.spinner("分析中..."):
  527. try:
  528. generator = st.session_state.generator
  529. structure = generator.analyze_table_structure(
  530. y_tolerance=y_tolerance,
  531. x_tolerance=x_tolerance,
  532. min_row_height=min_row_height
  533. )
  534. if not structure:
  535. st.warning("⚠️ 未检测到表格结构")
  536. st.stop()
  537. structure['modified_h_lines'] = set()
  538. structure['modified_v_lines'] = set()
  539. st.session_state.structure = structure
  540. st.session_state.undo_stack = []
  541. st.session_state.redo_stack = []
  542. clear_table_image_cache()
  543. st.success(
  544. f"✅ 检测到 {len(structure['rows'])} 行({len(structure['horizontal_lines'])} 条横线),"
  545. f"{len(structure['columns'])} 列({len(structure['vertical_lines'])} 条竖线)"
  546. )
  547. col1, col2, col3, col4 = st.columns(4)
  548. with col1:
  549. st.metric("行数", len(structure['rows']))
  550. with col2:
  551. st.metric("横线数", len(structure['horizontal_lines']))
  552. with col3:
  553. st.metric("列数", len(structure['columns']))
  554. with col4:
  555. st.metric("竖线数", len(structure['vertical_lines']))
  556. except Exception as e:
  557. st.error(f"❌ 分析失败: {e}")
  558. import traceback
  559. st.code(traceback.format_exc())
  560. st.stop()
  561. # 显示结果(两种模式通用)
  562. if 'structure' in st.session_state and st.session_state.structure:
  563. structure = st.session_state.structure
  564. # 使用缓存机制绘制表格线
  565. img_with_lines = get_cached_table_lines_image(
  566. image,
  567. structure,
  568. line_width=line_width,
  569. show_numbers=show_line_numbers
  570. )
  571. # 根据显示模式显示图片
  572. if display_mode == "对比显示":
  573. col1, col2 = st.columns(2)
  574. with col1:
  575. st.subheader("原图")
  576. st.image(image, use_container_width=True)
  577. with col2:
  578. st.subheader("添加表格线")
  579. st.image(img_with_lines, use_container_width=True)
  580. elif display_mode == "仅显示划线图":
  581. display_width = int(img_with_lines.width * zoom_level)
  582. st.subheader(f"表格线图 (缩放: {zoom_level:.0%})")
  583. st.image(img_with_lines, width=display_width)
  584. else:
  585. display_width = int(image.width * zoom_level)
  586. st.subheader(f"原图 (缩放: {zoom_level:.0%})")
  587. st.image(image, width=display_width)
  588. # 显示详细信息
  589. with st.expander("📊 表格结构详情"):
  590. st.json({
  591. "行数": len(structure['rows']),
  592. "列数": len(structure['columns']),
  593. "横线数": len(structure.get('horizontal_lines', [])),
  594. "竖线数": len(structure.get('vertical_lines', [])),
  595. "横线坐标": structure.get('horizontal_lines', []),
  596. "竖线坐标": structure.get('vertical_lines', []),
  597. "标准行高": structure.get('row_height'),
  598. "列宽度": structure.get('col_widths'),
  599. "修改的横线": list(structure.get('modified_h_lines', set())),
  600. "修改的竖线": list(structure.get('modified_v_lines', set()))
  601. })
  602. # 🆕 手动调整 - 使用线坐标列表
  603. st.subheader("🛠️ 手动调整")
  604. adjust_type = st.radio(
  605. "调整类型",
  606. ["调整横线", "调整竖线", "添加横线", "删除横线", "添加竖线", "删除竖线"],
  607. horizontal=True
  608. )
  609. if adjust_type == "调整横线":
  610. horizontal_lines = structure.get('horizontal_lines', [])
  611. if len(horizontal_lines) > 0:
  612. line_index = st.selectbox(
  613. "选择横线",
  614. range(len(horizontal_lines)),
  615. format_func=lambda x: f"第 {x+1} 条横线 (Y: {horizontal_lines[x]}) {'🔴已修改' if x in structure.get('modified_h_lines', set()) else ''}"
  616. )
  617. new_y = st.number_input(
  618. "新的Y坐标",
  619. value=int(horizontal_lines[line_index]),
  620. step=1
  621. )
  622. if st.button("应用调整"):
  623. save_state_for_undo(structure)
  624. structure['horizontal_lines'][line_index] = new_y
  625. structure['modified_h_lines'].add(line_index)
  626. # 🔧 同步更新 rows
  627. if line_index < len(structure['rows']):
  628. structure['rows'][line_index]['y_start'] = new_y
  629. if line_index > 0:
  630. structure['rows'][line_index - 1]['y_end'] = new_y
  631. clear_table_image_cache()
  632. st.success("✅ 已调整")
  633. st.rerun()
  634. else:
  635. st.warning("⚠️ 没有检测到横线")
  636. elif adjust_type == "调整竖线":
  637. vertical_lines = structure.get('vertical_lines', [])
  638. if len(vertical_lines) > 0:
  639. line_index = st.selectbox(
  640. "选择竖线",
  641. range(len(vertical_lines)),
  642. format_func=lambda x: f"第 {x+1} 条竖线 (X: {vertical_lines[x]}) {'🔴已修改' if x in structure.get('modified_v_lines', set()) else ''}"
  643. )
  644. new_x = st.number_input(
  645. "新的X坐标",
  646. value=int(vertical_lines[line_index]),
  647. step=1
  648. )
  649. if st.button("应用调整"):
  650. save_state_for_undo(structure)
  651. structure['vertical_lines'][line_index] = new_x
  652. structure['modified_v_lines'].add(line_index)
  653. # 🔧 同步更新 columns
  654. if line_index < len(structure['columns']):
  655. structure['columns'][line_index]['x_start'] = new_x
  656. if line_index > 0:
  657. structure['columns'][line_index - 1]['x_end'] = new_x
  658. clear_table_image_cache()
  659. st.success("✅ 已调整")
  660. st.rerun()
  661. else:
  662. st.warning("⚠️ 没有检测到竖线")
  663. elif adjust_type == "删除横线":
  664. horizontal_lines = structure.get('horizontal_lines', [])
  665. if len(horizontal_lines) > 0:
  666. lines_to_delete = st.multiselect(
  667. "选择要删除的横线(可多选)",
  668. range(len(horizontal_lines)),
  669. format_func=lambda x: f"第 {x+1} 条横线 (Y: {horizontal_lines[x]}) {'🔴已修改' if x in structure.get('modified_h_lines', set()) else ''}"
  670. )
  671. if lines_to_delete and st.button("🗑️ 批量删除", type="primary"):
  672. save_state_for_undo(structure)
  673. # 🔧 删除线坐标
  674. for idx in sorted(lines_to_delete, reverse=True):
  675. del structure['horizontal_lines'][idx]
  676. # 🔧 重新计算 rows(删除线后重建行区间)
  677. new_rows = []
  678. for i in range(len(structure['horizontal_lines']) - 1):
  679. new_rows.append({
  680. 'y_start': structure['horizontal_lines'][i],
  681. 'y_end': structure['horizontal_lines'][i + 1],
  682. # 'bboxes': []
  683. })
  684. structure['rows'] = new_rows
  685. # 更新修改标记
  686. structure['modified_h_lines'] = set()
  687. clear_table_image_cache()
  688. st.success(f"✅ 已删除 {len(lines_to_delete)} 条横线")
  689. st.rerun()
  690. st.info(f"💡 当前有 {len(horizontal_lines)} 条横线,已选择 {len(lines_to_delete)} 条")
  691. else:
  692. st.warning("⚠️ 没有可删除的横线")
  693. elif adjust_type == "删除竖线":
  694. vertical_lines = structure.get('vertical_lines', [])
  695. if len(vertical_lines) > 0:
  696. lines_to_delete = st.multiselect(
  697. "选择要删除的竖线(可多选)",
  698. range(len(vertical_lines)),
  699. format_func=lambda x: f"第 {x+1} 条竖线 (X: {vertical_lines[x]}) {'🔴已修改' if x in structure.get('modified_v_lines', set()) else ''}"
  700. )
  701. if lines_to_delete and st.button("🗑️ 批量删除", type="primary"):
  702. save_state_for_undo(structure)
  703. # 🔧 删除线坐标
  704. for idx in sorted(lines_to_delete, reverse=True):
  705. del structure['vertical_lines'][idx]
  706. # 🔧 重新计算 columns
  707. new_columns = []
  708. for i in range(len(structure['vertical_lines']) - 1):
  709. new_columns.append({
  710. 'x_start': structure['vertical_lines'][i],
  711. 'x_end': structure['vertical_lines'][i + 1]
  712. })
  713. structure['columns'] = new_columns
  714. # 重新计算列宽
  715. structure['col_widths'] = [
  716. col['x_end'] - col['x_start']
  717. for col in new_columns
  718. ]
  719. # 更新修改标记
  720. structure['modified_v_lines'] = set()
  721. clear_table_image_cache()
  722. st.success(f"✅ 已删除 {len(lines_to_delete)} 条竖线")
  723. st.rerun()
  724. st.info(f"💡 当前有 {len(vertical_lines)} 条竖线,已选择 {len(lines_to_delete)} 条")
  725. else:
  726. st.warning("⚠️ 没有可删除的列")
  727. # 保存配置
  728. st.divider()
  729. save_col1, save_col2, save_col3 = st.columns(3)
  730. with save_col1:
  731. save_structure = st.checkbox("保存表格结构配置", value=True)
  732. with save_col2:
  733. save_image = st.checkbox("保存表格线图片", value=True)
  734. with save_col3:
  735. # 🆕 线条颜色选择
  736. line_color_option = st.selectbox(
  737. "保存时线条颜色",
  738. ["黑色", "蓝色", "红色"],
  739. index=0
  740. )
  741. if st.button("💾 保存", type="primary"):
  742. output_dir = Path("output/table_structures")
  743. output_dir.mkdir(parents=True, exist_ok=True)
  744. base_name = Path(st.session_state.loaded_image_name).stem
  745. saved_files = []
  746. if save_structure:
  747. structure_path = output_dir / f"{base_name}_structure.json"
  748. # 🔧 保存线坐标列表
  749. save_structure_data = {
  750. 'rows': structure['rows'],
  751. 'columns': structure['columns'],
  752. 'horizontal_lines': structure.get('horizontal_lines', []),
  753. 'vertical_lines': structure.get('vertical_lines', []),
  754. 'row_height': structure['row_height'],
  755. 'col_widths': structure['col_widths'],
  756. 'table_bbox': structure['table_bbox'],
  757. 'modified_h_lines': list(structure.get('modified_h_lines', set())),
  758. 'modified_v_lines': list(structure.get('modified_v_lines', set()))
  759. }
  760. with open(structure_path, 'w', encoding='utf-8') as f:
  761. json.dump(save_structure_data, f, indent=2, ensure_ascii=False)
  762. saved_files.append(("配置文件", structure_path))
  763. with open(structure_path, 'r') as f:
  764. st.download_button(
  765. "📥 下载配置文件",
  766. f.read(),
  767. file_name=f"{base_name}_structure.json",
  768. mime="application/json"
  769. )
  770. if save_image:
  771. # 🆕 根据选择的颜色绘制纯净表格线
  772. color_map = {
  773. "黑色": (0, 0, 0),
  774. "蓝色": (0, 0, 255),
  775. "红色": (255, 0, 0)
  776. }
  777. selected_color = color_map[line_color_option]
  778. # 🎯 使用纯净绘制函数
  779. clean_img = draw_clean_table_lines(
  780. image,
  781. structure,
  782. line_width=line_width,
  783. line_color=selected_color
  784. )
  785. output_image_path = output_dir / f"{base_name}_with_lines.png"
  786. clean_img.save(output_image_path)
  787. saved_files.append(("表格线图片", output_image_path))
  788. # 🆕 提供下载按钮
  789. import io
  790. buf = io.BytesIO()
  791. clean_img.save(buf, format='PNG')
  792. buf.seek(0)
  793. st.download_button(
  794. "📥 下载表格线图片",
  795. buf,
  796. file_name=f"{base_name}_with_lines.png",
  797. mime="image/png"
  798. )
  799. if saved_files:
  800. st.success(f"✅ 已保存 {len(saved_files)} 个文件:")
  801. for file_type, file_path in saved_files:
  802. st.info(f" • {file_type}: {file_path}")
  803. if __name__ == "__main__":
  804. create_table_line_editor()