Преглед изворни кода

feat: 增强内容渲染和搜索功能,优化高亮显示逻辑

zhch158_admin пре 14 часа
родитељ
комит
c3f6a15418
1 измењених фајлова са 260 додато и 78 уклоњено
  1. 260 78
      ocr_validator_layout.py

+ 260 - 78
ocr_validator_layout.py

@@ -178,15 +178,28 @@ class OCRLayoutManager:
             st.error(f"❌ 图像加载失败: {e}")
             return None
 
-    def render_content_by_mode(self, content: str, render_mode: str, font_size: int, container_height: int, layout_type: str):
-        """根据渲染模式显示内容 - 增强版本"""
+    def render_content_by_mode(self, content: str, render_mode: str, font_size: int, 
+                          container_height: int, layout_type: str, 
+                          highlight_config: Optional[Dict] = None):
+        """
+        根据渲染模式显示内容 - 增强版本
+        
+        Args:
+            content: 要渲染的内容
+            render_mode: 渲染模式
+            font_size: 字体大小
+            container_height: 容器高度
+            layout_type: 布局类型
+            highlight_config: 高亮配置 {'has_bbox': bool, 'match_type': str}
+        """
         if content is None or render_mode is None:
             return
             
         if render_mode == "HTML渲染":
-            # 增强的HTML渲染样式,支持横向滚动
+            # 🎯 构建样式 - 包含基础样式和高亮样式
             content_style = f"""
             <style>
+            /* ========== 基础容器样式 ========== */
             .{layout_type}-content-display {{
                 height: {container_height}px;
                 overflow-x: auto;
@@ -201,12 +214,12 @@ class OCRLayoutManager:
                 max-width: 100%;
             }}
             
+            /* ========== 表格样式 ========== */
             .{layout_type}-content-display table {{
-                width: 100%;  /* 修改:从100%改为auto,让表格自适应内容 */
+                width: 100%;
                 border-collapse: collapse;
                 margin: 10px 0;
-                white-space: nowrap;  /* 修改:允许文字换行 */
-                /* table-layout: auto; *?  /* 新增:自动表格布局 */
+                white-space: nowrap;
             }}
             
             .{layout_type}-content-display th,
@@ -214,11 +227,10 @@ class OCRLayoutManager:
                 border: 1px solid #ddd;
                 padding: 8px;
                 text-align: left;
-                /* 移除:min-width固定限制 */
-                max-width: 300px;     /* 新增:设置最大宽度避免过宽 */
-                word-wrap: break-word; /* 新增:长单词自动换行 */
-                word-break: break-all; /* 新增:允许在任意字符间换行 */
-                vertical-align: top;   /* 新增:顶部对齐 */
+                max-width: 300px;
+                word-wrap: break-word;
+                word-break: break-all;
+                vertical-align: top;
             }}
             
             .{layout_type}-content-display th {{
@@ -226,30 +238,31 @@ class OCRLayoutManager:
                 position: sticky;
                 top: 0;
                 z-index: 1;
-                font-weight: bold;    /* 新增:表头加粗 */
+                font-weight: bold;
             }}
             
-            /* 新增:针对数字列的特殊处理 */
+            /* 数字列右对齐 */
             .{layout_type}-content-display td.number {{
                 text-align: right;
                 white-space: nowrap;
                 font-family: 'Monaco', 'Menlo', monospace;
             }}
             
-            /* 新增:针对短文本列的处理 */
+            /* 短文本列不换行 */
             .{layout_type}-content-display td.short-text {{
                 white-space: nowrap;
                 min-width: 80px;
             }}
             
+            /* ========== 图片样式 ========== */
             .{layout_type}-content-display img {{
                 max-width: 100%;
                 height: auto;
                 border-radius: 4px;
                 margin: 10px 0;
             }}
-        
-            /* 新增:响应式表格 */
+            
+            /* ========== 响应式设计 ========== */
             @media (max-width: 768px) {{
                 .{layout_type}-content-display table {{
                     font-size: {max(font_size-2, 8)}px;
@@ -260,23 +273,54 @@ class OCRLayoutManager:
                     max-width: 150px;
                 }}
             }}
-
-            .highlight-text {{
-                background-color: #ffeb3b !important;
+            
+            /* ========== 高亮文本样式 ========== */
+            .{layout_type}-content-display .highlight-text {{
                 padding: 2px 4px;
                 border-radius: 3px;
                 cursor: pointer;
-                color: #333333 !important;
+                font-weight: 500;
+                transition: all 0.2s ease;
+            }}
+            
+            .{layout_type}-content-display .highlight-text:hover {{
+                opacity: 0.8;
+                transform: scale(1.02);
             }}
             
-            .selected-highlight {{
+            /* 🎯 精确匹配且有框 - 绿色 */
+            .{layout_type}-content-display .highlight-text.selected-highlight {{
                 background-color: #4caf50 !important;
                 color: white !important;
+                border: 1px solid #2e7d32 !important;
+            }}
+            
+            /* 🎯 OCR匹配 - 蓝色 */
+            .{layout_type}-content-display .highlight-text.ocr-match {{
+                background-color: #2196f3 !important;
+                color: white !important;
+                border: 1px solid #1565c0 !important;
+            }}
+            
+            /* 🎯 无边界框 - 橙色虚线 */
+            .{layout_type}-content-display .highlight-text.no-bbox {{
+                background-color: #ff9800 !important;
+                color: white !important;
+                border: 1px dashed #f57c00 !important;
+            }}
+            
+            /* 🎯 默认高亮 - 黄色 */
+            .{layout_type}-content-display .highlight-text.default {{
+                background-color: #ffeb3b !important;
+                color: #333333 !important;
+                border: 1px solid #fbc02d !important;
             }}
             </style>
             """
+            
             st.markdown(content_style, unsafe_allow_html=True)
-            st.markdown(f'<div class="{layout_type}-content-display">{content}</div>', unsafe_allow_html=True)
+            st.markdown(f'<div class="{layout_type}-content-display">{content}</div>', 
+                       unsafe_allow_html=True)
             
         elif render_mode == "Markdown渲染":
             converted_content = convert_html_table_to_markdown(content)
@@ -296,26 +340,25 @@ class OCRLayoutManager:
                 key=f"{layout_type}_text_area"
             )
 
+
     def create_compact_layout(self, config: Dict):
-        """创建紧凑的对比布局"""
-        # 主要内容区域
+        """创建紧凑的对比布局 - 增强搜索功能"""
         layout = config['styles']['layout']
         font_size = config['styles'].get('font_size', 10)
         container_height = layout.get('default_height', 600)
         zoom_level = layout.get('default_zoom', 1.0)
         layout_type = "compact"
 
-        left_col, right_col = st.columns([layout['content_width'], layout['sidebar_width']], vertical_alignment='top', border=True)
+        left_col, right_col = st.columns([layout['content_width'], layout['sidebar_width']], 
+                                         vertical_alignment='top', border=True)
 
         with left_col:
-            # 快速定位文本选择器 - 增强版(搜索+下拉)
             if self.validator.text_bbox_mapping:
                 # 搜索输入框
                 search_col, select_col = st.columns([1, 2])
                 
-                # 初始化session state
                 if "compact_search_query" not in st.session_state:
-                    st.session_state.compact_search_query = None
+                    st.session_state.compact_search_query = ""
                 
                 with search_col:
                     search_query = st.text_input(
@@ -325,38 +368,89 @@ class OCRLayoutManager:
                         key=f"{layout_type}_search_input",
                         label_visibility="collapsed"
                     )
-                    # 更新session state
                     st.session_state.compact_search_query = search_query
                 
-                # 构建选项列表
+                # 🎯 增强搜索逻辑:构建选项列表
                 text_options = ["请选择文本..."]
                 text_display = ["请选择文本..."]
+                match_info = [None]  # 记录匹配信息
                 
                 for text, info_list in self.validator.text_bbox_mapping.items():
-                    # 如果有搜索条件,进行过滤
+                    # 🔑 关键改进:同时搜索 text 和 matched_text
                     if search_query and search_query.strip():
-                        if search_query.lower() not in text.lower():
-                            continue  # 跳过不匹配的项
+                        query_lower = search_query.lower()
+                        
+                        # 1. 检查原始文本
+                        text_match = query_lower in text.lower()
+                        
+                        # 2. 检查 matched_text(OCR识别文本)
+                        matched_text_match = False
+                        matched_text = None
+                        if info_list and isinstance(info_list[0], dict):
+                            matched_text = info_list[0].get('matched_text', '')
+                            matched_text_match = query_lower in matched_text.lower() if matched_text else False
+                        
+                        # 如果都不匹配,跳过
+                        if not text_match and not matched_text_match:
+                            continue
+                        
+                        # 记录匹配类型
+                        if text_match:
+                            match_type = "exact"
+                            match_source = text
+                        else:
+                            match_type = "ocr"
+                            match_source = matched_text
+                    else:
+                        match_type = None
+                        match_source = text
                     
                     text_options.append(text)
                     
-                    # 检查是否是表格单元格
+                    # 🎯 构建显示文本(带匹配提示)
                     if info_list and isinstance(info_list[0], dict):
                         first_info = info_list[0]
+                        
+                        # 检查是否有 bbox
+                        has_bbox = 'bbox' in first_info and first_info['bbox']
+                        
+                        # 表格单元格显示
                         if 'row' in first_info and 'col' in first_info:
                             display_text = f"[R{first_info['row']},C{first_info['col']}] {text}"
-                            if len(display_text) > 47:
-                                display_text = display_text[:44] + "..."
                         else:
-                            display_text = text[:47] + "..." if len(text) > 50 else text
-                    else:
-                        display_text = text[:47] + "..." if len(text) > 50 else text
+                            display_text = text
+                        
+                        # 🎯 添加匹配提示
+                        if match_type == "ocr":
+                            display_text = f"🔍 {display_text} (OCR: {match_source[:20]}...)"
+                        elif not has_bbox:
+                            display_text = f"⚠️ {display_text} (无框)"
                         
+                        # 截断过长文本
+                        if len(display_text) > 60:
+                            display_text = display_text[:57] + "..."
+                    else:
+                        display_text = text[:57] + "..." if len(text) > 60 else text
+                    
                     text_display.append(display_text)
+                    match_info.append({
+                        'type': match_type,
+                        'source': match_source,
+                        'has_bbox': has_bbox if info_list else False
+                    })
                 
-                # 显示匹配数量
+                # 🎯 显示搜索统计
                 if search_query and search_query.strip():
-                    st.caption(f"找到 {len(text_options)-1} 个匹配项")
+                    ocr_matches = sum(1 for m in match_info[1:] if m and m['type'] == 'ocr')
+                    no_bbox_count = sum(1 for m in match_info[1:] if m and not m['has_bbox'])
+                    
+                    stat_parts = [f"找到 {len(text_options)-1} 个匹配项"]
+                    if ocr_matches > 0:
+                        stat_parts.append(f"🔍 {ocr_matches} 个OCR匹配")
+                    if no_bbox_count > 0:
+                        stat_parts.append(f"⚠️ {no_bbox_count} 个无框")
+                    
+                    st.caption(" | ".join(stat_parts))
                 
                 # 确定默认选中的索引
                 default_index = 0
@@ -373,28 +467,78 @@ class OCRLayoutManager:
                         key=f"{layout_type}_quick_text_selector"
                     )
                 
+                # 🎯 显示匹配详情
                 if selected_index > 0:
                     st.session_state.selected_text = text_options[selected_index]
-        
-            # 处理并显示OCR内容 - 只高亮选中的文本
+                    
+                    # 获取匹配信息
+                    selected_match_info = match_info[selected_index]
+                    if selected_match_info:
+                        if selected_match_info['type'] == 'ocr':
+                            st.info(f"🔍 **OCR识别文本匹配**: `{selected_match_info['source']}`")
+                        elif not selected_match_info['has_bbox']:
+                            st.warning(f"⚠️ **未找到边界框**: 文本在MD中存在,但没有对应的坐标信息")
+            
+            # 🎯 增强高亮显示逻辑
             if self.validator.md_content:
                 highlighted_content = self.validator.md_content
                 
-                # 只高亮选中的文本
                 if st.session_state.selected_text:
                     selected_text = st.session_state.selected_text
+                    
+                    # 获取匹配信息
+                    info_list = self.validator.text_bbox_mapping.get(selected_text, [])
+                    has_bbox = False
+                    matched_text = None
+                    match_type = None
+                    
+                    if info_list and isinstance(info_list[0], dict):
+                        has_bbox = 'bbox' in info_list[0] and info_list[0]['bbox']
+                        matched_text = info_list[0].get('matched_text', '')
+                        
+                        # 🔑 判断匹配类型
+                        if matched_text and matched_text != selected_text:
+                            match_type = "ocr"
+                        elif has_bbox:
+                            match_type = "exact"
+                        else:
+                            match_type = "no_bbox"
+                    
+                    # 🎯 应用高亮
                     if len(selected_text) > 2:
-                        highlighted_content = highlighted_content.replace(
-                            selected_text,
-                            f'<span class="highlight-text selected-highlight" title="{selected_text}">{selected_text}</span>'
-                        )
+                        # 1. 高亮原始文本
+                        if selected_text in highlighted_content:
+                            if match_type == "exact":
+                                highlight_class = "highlight-text selected-highlight"
+                            elif match_type == "no_bbox":
+                                highlight_class = "highlight-text no-bbox"
+                            else:
+                                highlight_class = "highlight-text default"
+                            
+                            highlighted_content = highlighted_content.replace(
+                                selected_text,
+                                f'<span class="{highlight_class}" title="{selected_text}">{selected_text}</span>'
+                            )
+                        
+                        # 2. 如果有 matched_text 且不同,也高亮
+                        if matched_text and matched_text != selected_text and matched_text in highlighted_content:
+                            highlighted_content = highlighted_content.replace(
+                                matched_text,
+                                f'<span class="highlight-text ocr-match" title="OCR: {matched_text}">{matched_text}</span>'
+                            )
                 
-                self.render_content_by_mode(highlighted_content, "HTML渲染", font_size, container_height, layout_type)
-        
+                # 🎯 调用渲染方法(样式已内置)
+                self.render_content_by_mode(
+                    highlighted_content, 
+                    "HTML渲染", 
+                    font_size, 
+                    container_height, 
+                    layout_type
+                )
+    
         with right_col:
-            # 修复的对齐图片显示
             self.create_aligned_image_display(zoom_level, "compact")
-    
+
     def create_aligned_image_display(self, zoom_level: float = 1.0, layout_type: str = "aligned"):
         """创建响应式图片显示"""
     
@@ -453,6 +597,7 @@ class OCRLayoutManager:
         if image:
             try:
                 resized_image, all_boxes, selected_boxes = self.zoom_image(image, self.zoom_level)
+                
                 # 创建交互式图片
                 fig = self.create_resized_interactive_plot(resized_image, selected_boxes, self.zoom_level, all_boxes)
 
@@ -518,44 +663,81 @@ class OCRLayoutManager:
 
         return resized_image, all_boxes, selected_boxes
 
-    def _add_bboxes_to_plot(self, fig: go.Figure, bboxes: List[List[int]], image_height: int, 
-                           line_color: str = "blue", line_width: int = 1, 
-                           fill_color: str = "rgba(0, 100, 200, 0.2)"):
+    def _add_bboxes_to_plot_batch(self, fig: go.Figure, bboxes: List[List[int]], 
+                                image_height: int, 
+                                line_color: str = "blue", 
+                                line_width: int = 1, 
+                                fill_color: str = "rgba(0, 100, 200, 0.2)"):
         """
-        在plotly图表上添加边界框
-        
-        Args:
-            fig: plotly图表对象
-            bboxes: 边界框列表,每个bbox格式为[x1, y1, x2, y2]
-            image_height: 图片高度,用于Y轴坐标转换
-            line_color: 边框线颜色
-            line_width: 边框线宽度
-            fill_color: 填充颜色(RGBA格式)
+        批量添加边界框(性能优化版)
         """
         if not bboxes or len(bboxes) == 0:
             return
-            
+        
+        # 🎯 关键优化:构建 shapes 列表,一次性添加
+        shapes = []
         for bbox in bboxes:
             if len(bbox) < 4:
                 continue
-                
+            
             x1, y1, x2, y2 = bbox[:4]
             
-            # 转换为Plotly坐标系(翻转Y轴)
-            # JSON格式: 原点在左上角, y向下增加
-            # Plotly格式: 原点在左下角, y向上增加
+            # 转换坐标
             plot_x1 = x1
             plot_x2 = x2
-            plot_y1 = image_height - y2  # JSON的y2(底部) -> Plotly的底部
-            plot_y2 = image_height - y1  # JSON的y1(顶部) -> Plotly的顶部
+            plot_y1 = image_height - y2
+            plot_y2 = image_height - y1
             
-            fig.add_shape(
+            shapes.append(dict(
                 type="rect",
                 x0=plot_x1, y0=plot_y1,
                 x1=plot_x2, y1=plot_y2,
                 line=dict(color=line_color, width=line_width),
                 fillcolor=fill_color,
-            )
+            ))
+        
+        # 🎯 一次性更新所有形状
+        fig.update_layout(shapes=fig.layout.shapes + tuple(shapes))
+
+    def _add_bboxes_as_scatter(self, fig: go.Figure, bboxes: List[List[int]], 
+                          image_height: int,
+                          line_color: str = "blue", 
+                          line_width: int = 1,
+                          name: str = "boxes"):
+        """
+        使用 Scatter 绘制边界框(极致性能优化)
+        """
+        if not bboxes or len(bboxes) == 0:
+            return
+        
+        # 🎯 收集所有矩形的边框线坐标
+        x_coords = []
+        y_coords = []
+        
+        for bbox in bboxes:
+            if len(bbox) < 4:
+                continue
+            
+            x1, y1, x2, y2 = bbox[:4]
+            
+            # 转换坐标
+            plot_y1 = image_height - y2
+            plot_y2 = image_height - y1
+            
+            # 绘制矩形:5个点(闭合)
+            x_coords.extend([x1, x2, x2, x1, x1, None])  # None用于断开线段
+            y_coords.extend([plot_y1, plot_y1, plot_y2, plot_y2, plot_y1, None])
+        
+        # 🎯 一次性添加所有边框
+        fig.add_trace(go.Scatter(
+            x=x_coords,
+            y=y_coords,
+            mode='lines',
+            line=dict(color=line_color, width=line_width),
+            name=name,
+            showlegend=False,
+            hoverinfo='skip'
+        ))
 
     def create_resized_interactive_plot(self, image: Image.Image, selected_boxes: List[List[int]], 
                                        zoom_level: float, all_boxes: List[List[int]]) -> go.Figure:
@@ -579,23 +761,23 @@ class OCRLayoutManager:
         
         # 显示所有bbox(淡蓝色)
         if all_boxes:
-            self._add_bboxes_to_plot(
+            self._add_bboxes_as_scatter(
                 fig=fig,
                 bboxes=all_boxes,
                 image_height=image.height,
-                line_color="blue",
+                line_color="rgba(0, 100, 200, 0.8)",
                 line_width=1,
-                fill_color="rgba(0, 100, 200, 0.2)"
+                name="all_boxes"
             )
 
         # 高亮显示选中的bbox(红色)
         if selected_boxes:
-            self._add_bboxes_to_plot(
+            self._add_bboxes_to_plot_batch(
                 fig=fig,
                 bboxes=selected_boxes,
                 image_height=image.height,
                 line_color="red",
-                line_width=3,
+                line_width=1,
                 fill_color="rgba(255, 0, 0, 0.3)"
             )