File size: 7,554 Bytes
e16aadc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e601def
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e16aadc
 
 
 
dd3a79b
 
 
e16aadc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd3a79b
e16aadc
dd3a79b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e16aadc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""场景分类器:多信号加权评分判断图片属于文本场景还是复杂场景

综合以下4个信号:
1. 文本面积占比(权重0.15)——OCR检测到的文本区域面积
2. 背景白度占比(权重0.50)——浅色像素比例
3. 颜色方差倒数(权重0.25)——颜色变化越小越可能是文档
4. 文本行密度(权重0.10)——单位面积内文本行数
"""

import cv2
import numpy as np

from module.config import (
    TEXT_AREA_WEIGHT, WHITENESS_WEIGHT, LOW_VARIANCE_WEIGHT,
    LINE_DENSITY_WEIGHT, DOC_SCORE_THRESHOLD,
)


def _cv_imread(path, flags=cv2.IMREAD_COLOR):
    """支持中文路径的cv2.imread"""
    return cv2.imdecode(np.fromfile(path, dtype=np.uint8), flags)


def _compute_whiteness(img, brightness_threshold=200):
    """计算图片背景白度占比

    白底文档的浅色像素(亮度>200)占比通常>60%

    Args:
        img: BGR格式的numpy数组
        brightness_threshold: 亮度阈值,高于此值视为'白'
    Returns:
        whiteness: 白色像素占比,范围[0,1]
    """
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    white_pixels = np.sum(gray >= brightness_threshold)
    total_pixels = gray.shape[0] * gray.shape[1]
    return white_pixels / total_pixels if total_pixels > 0 else 0.0


def _compute_color_variance_norm(img):
    """计算图片颜色方差的归一化值

    文档图片(白底黑字)颜色方差低,场景照片颜色方差高
    归一化到[0,1],值越大表示颜色越丰富(越不像文档)

    Args:
        img: BGR格式的numpy数组
    Returns:
        variance_norm: 归一化颜色方差,范围[0,1]
    """
    variances = [np.var(img[:, :, c]) for c in range(3)]
    avg_var = np.mean(variances)
    # sigmoid归一化:方差2000约为0.5(经验值)
    # 文档方差通常<500,场景照片方差通常>3000
    variance_norm = 1.0 / (1.0 + np.exp(-(avg_var - 2000) / 800))
    return float(variance_norm)


def _compute_line_density(text_boxes, img_shape):
    """计算文本行密度

    即使文字分散,如果行数多,行密度仍然高
    营业执照虽然文字分散,但行数多(10+行),密度不会太低

    Args:
        text_boxes: OCR检测到的文本框列表
        img_shape: (h, w) 图片尺寸
    Returns:
        line_density: 归一化行密度,范围[0,1]
    """
    h, w = img_shape[:2]
    if not text_boxes or h == 0 or w == 0:
        return 0.0

    # 行数 / 图片面积(以1000*1000为参考)
    area_factor = (h * w) / (1000 * 1000)
    if area_factor == 0:
        return 0.0

    raw_density = len(text_boxes) / area_factor
    # sigmoid归一化:10行/百万像素约为0.5
    density_norm = 1.0 / (1.0 + np.exp(-(raw_density - 10) / 5))
    return float(density_norm)


def _ocr_image(image_path, ocr_engine):
    """仅执行 OCR 提取文本,不做场景分类

    Returns:
        full_text: 提取的文本字符串
    """
    try:
        result = ocr_engine.ocr(image_path, cls=True)
        if result and result[0]:
            return ' '.join(line[1][0] for line in result[0])
    except Exception as e:
        print(f"[OCR] 提取失败: {e}")
    return ''


def classify_scene(image_path, ocr_engine, doc_score_threshold=DOC_SCORE_THRESHOLD,
                   precomputed_ocr_result=None):
    """多信号加权评分判断图片属于文本场景还是复杂场景

    快速预判:先用廉价的视觉信号(白度+方差)做预判,只有中间模糊区才调用 OCR。
    典型情况下可省掉 OCR,从 2.6s 降到 0.1s。

    Args:
        image_path: 图片路径
        ocr_engine: PaddleOCR引擎实例
        doc_score_threshold: 文档场景判定阈值,默认0.55
        precomputed_ocr_result: 预计算的OCR结果,避免重复OCR

    Returns:
        scene_type: 'text'(文本场景) 或 'complex'(复杂场景)
        doc_score: 文档评分
        detail: 各信号的详细分数字典
        ocr_result: OCR原始结果(可复用给下游)
    """
    img = _cv_imread(image_path)
    if img is None:
        return 'complex', 0.0, {}, None

    h, w = img.shape[:2]
    total_area = h * w

    # === 第1步:廉价的视觉信号(<5ms) ===
    whiteness = _compute_whiteness(img)
    variance_norm = _compute_color_variance_norm(img)
    low_variance = 1.0 - variance_norm

    # === 第2步:快速预判(省 OCR)===
    # 白度>0.7 且 颜色方差低 → 文档(白底文档的典型特征)
    # 白度<0.2 且 颜色方差高 → 场景照片(彩色实景的典型特征)
    # 仅当落在模糊区才调用完整 OCR
    needs_ocr = True
    fast_scene = None
    fast_doc_score = None

    # 白底文档:白度极高 + 方差极低
    if whiteness >= 0.70 and low_variance >= 0.7:
        # 视觉信号已足够判断为 text
        fast_scene = 'text'
        # 估算 doc_score:whiteness 权重0.5 + low_variance 权重0.25 = 0.75
        # text_area 和 line_density 假设中等值(0.3, 0.3)
        fast_doc_score = (
            TEXT_AREA_WEIGHT * 0.3
            + WHITENESS_WEIGHT * whiteness
            + LOW_VARIANCE_WEIGHT * low_variance
            + LINE_DENSITY_WEIGHT * 0.3
        )
        needs_ocr = False
    # 彩色实景:白度极低 + 方差极高
    elif whiteness <= 0.20 and low_variance <= 0.3:
        fast_scene = 'complex'
        fast_doc_score = (
            TEXT_AREA_WEIGHT * 0.0
            + WHITENESS_WEIGHT * whiteness
            + LOW_VARIANCE_WEIGHT * low_variance
            + LINE_DENSITY_WEIGHT * 0.0
        )
        needs_ocr = False

    if not needs_ocr:
        detail = {
            'text_area_ratio': 0.0 if fast_scene == 'complex' else 0.3,
            'whiteness': round(whiteness, 4),
            'low_variance': round(low_variance, 4),
            'line_density': 0.0 if fast_scene == 'complex' else 0.3,
            'text_box_count': 0,
            'full_text': '',
            'fast_classified': True,
        }
        return fast_scene, round(fast_doc_score, 4), detail, None

    # === 第3步:OCR(仅在模糊区执行,~1.5-2s) ===
    if precomputed_ocr_result is not None:
        result = precomputed_ocr_result
    else:
        result = ocr_engine.ocr(image_path, cls=True)

    text_area = 0
    text_boxes = []
    full_text = ''
    if result and result[0]:
        for line in result[0]:
            box = line[0]
            text_boxes.append(box)
            n = len(box)
            area = 0
            for i in range(n):
                j = (i + 1) % n
                area += box[i][0] * box[j][1]
                area -= box[j][0] * box[i][1]
            text_area += abs(area) / 2
        full_text = ' '.join(line[1][0] for line in result[0])

    text_area_ratio = text_area / total_area if total_area > 0 else 0
    line_density = _compute_line_density(text_boxes, img.shape)

    doc_score = (
        TEXT_AREA_WEIGHT * text_area_ratio
        + WHITENESS_WEIGHT * whiteness
        + LOW_VARIANCE_WEIGHT * low_variance
        + LINE_DENSITY_WEIGHT * line_density
    )

    scene_type = 'text' if doc_score >= doc_score_threshold else 'complex'

    detail = {
        'text_area_ratio': round(text_area_ratio, 4),
        'whiteness': round(whiteness, 4),
        'low_variance': round(low_variance, 4),
        'line_density': round(line_density, 4),
        'text_box_count': len(text_boxes),
        'full_text': full_text,
    }

    return scene_type, round(doc_score, 4), detail, result