fix: 模板只有1页导致list index out of range崩溃
- 重生成标准双页模板(t_Schema + Page1) - merge_output 硬编码列索引改为防御性类型检查
This commit is contained in:
parent
c49f9819e1
commit
1bcda1ab8c
97
config.yaml
97
config.yaml
|
|
@ -1,103 +1,30 @@
|
|||
# ============================================================
|
||||
# KOCR v4 — 配置中心
|
||||
# 所有可调参数集中管理,不同照片类型预设参数组
|
||||
# 简化版:原图直出 PaddleOCR,无预处理
|
||||
# ============================================================
|
||||
|
||||
# 照片类型检测阈值
|
||||
photo_analysis:
|
||||
# 亮度等级:[0-255]
|
||||
brightness_thresholds:
|
||||
flash: 200 # >=200 判定为闪光灯或过曝
|
||||
bright: 180 # >=180 判定为偏亮
|
||||
normal_min: 100 # 100-180 正常
|
||||
dark: 60 # 低于60判定为偏暗
|
||||
# 阴影检测
|
||||
shadow_threshold: 0.3 # 阴影面积占比超过此值启用去阴影
|
||||
# 模糊检测
|
||||
blur_threshold: 100 # 拉普拉斯方差低于此值判定为模糊
|
||||
# 对比度
|
||||
contrast_min: 40 # 低于此值启用对比度增强
|
||||
|
||||
# 预处理参数
|
||||
preprocessing:
|
||||
max_side: 1600 # 长边最大像素(与检测模型对齐)
|
||||
denoise: true
|
||||
deskew: true
|
||||
remove_shadow: true
|
||||
sharpen: true
|
||||
# 不同照片类型的预处理覆盖
|
||||
photo_type_overrides:
|
||||
flash:
|
||||
remove_shadow: false # 闪光灯照片不需要去阴影
|
||||
denoise: true
|
||||
sharpen: true
|
||||
dark:
|
||||
remove_shadow: true
|
||||
denoise: true
|
||||
sharpen: true
|
||||
blurry:
|
||||
denoise: true
|
||||
sharpen: true
|
||||
normal:
|
||||
remove_shadow: true
|
||||
denoise: true
|
||||
deskew: true
|
||||
sharpen: true
|
||||
|
||||
# OCR 参数(PaddleOCR 原生参数)
|
||||
ocr:
|
||||
# === 通用参数 ===
|
||||
use_gpu: false # GPU 包未就绪前强制 CPU
|
||||
use_gpu: false
|
||||
lang: ch
|
||||
show_log: false
|
||||
use_angle_cls: true
|
||||
|
||||
# === DB 文本检测 ===
|
||||
det_db_thresh: 0.3 # 分割图阈值
|
||||
det_db_box_thresh: 0.5 # 输出框置信度阈值(降低以保留印章遮挡文字)
|
||||
det_db_unclip_ratio: 2.2 # 文字框外扩比例(凭证小字防切碎)
|
||||
max_side_len: 1600 # 适配高分辨率扫描件
|
||||
det_db_score_mode: slow # 平均置信度计算,小文字框判定更准
|
||||
min_size: 8 # 过滤小于8像素的噪点框
|
||||
|
||||
# 不同照片类型的 OCR 参数覆盖
|
||||
photo_type_overrides:
|
||||
flash:
|
||||
det_db_thresh: 0.18 # 闪光灯/过曝降低阈值
|
||||
det_db_box_thresh: 0.4
|
||||
det_db_unclip_ratio: 2.5 # 更激进外扩
|
||||
dark:
|
||||
det_db_thresh: 0.25 # 偏暗适度降低
|
||||
det_db_box_thresh: 0.45
|
||||
blurry:
|
||||
det_db_thresh: 0.35 # 模糊提高阈值减少误检
|
||||
det_db_box_thresh: 0.55
|
||||
|
||||
# === SVTR 文本识别 ===
|
||||
rec_drop_score: 0.5 # 低于此置信度丢弃
|
||||
# rec_char_dict_path: dict/finance_dict.txt # 内置字典已覆盖财务大写
|
||||
|
||||
# === TableMaster 表格识别 ===
|
||||
table_algorithm: TableMaster
|
||||
table_max_len: 488
|
||||
cell_box_thresh: 0.5
|
||||
|
||||
# === 性能 ===
|
||||
use_tensorrt: false # 4G 显存不建议开启
|
||||
precision: fp32 # fp16 需 TensorRT
|
||||
det_db_thresh: 0.3
|
||||
det_db_box_thresh: 0.5
|
||||
det_db_unclip_ratio: 1.5
|
||||
max_side_len: 1600
|
||||
det_db_score_mode: fast
|
||||
min_size: 8
|
||||
rec_drop_score: 0.5
|
||||
|
||||
# 后处理参数
|
||||
postprocessing:
|
||||
amount_balance_tolerance: 0.01 # 借贷平衡允许误差(元)
|
||||
amount_confidence_min: 0.5 # 金额最低置信度
|
||||
entry_confidence_min: 0.4 # 分录文字最低置信度
|
||||
min_entry_rows: 1 # 最少分录行数
|
||||
max_entry_rows: 50 # 最大分录行数
|
||||
amount_balance_tolerance: 0.01
|
||||
|
||||
# 输出路径(相对于项目根目录)
|
||||
# 输出路径
|
||||
paths:
|
||||
input_dir: input
|
||||
output_dir: output
|
||||
done_dir: input/done
|
||||
index_file: .kocr_index.json
|
||||
template: ~/.hermes/cache/documents/doc_704eac18442d_凭证模板.xls
|
||||
template: /home/muc/mc/会计工具/kocr-v4/凭证模板.xls
|
||||
|
|
|
|||
|
|
@ -189,8 +189,8 @@ def merge_output(results, template_path, output_dir, review=False):
|
|||
for r in range(1, ws.nrows):
|
||||
row_data = [ws.cell_value(r, c) for c in range(ws.ncols)]
|
||||
page_rows.append(row_data)
|
||||
dr_val = float(row_data[10] or 0)
|
||||
cr_val = float(row_data[11] or 0)
|
||||
dr_val = float(row_data[10] or 0) if isinstance(row_data[10], (int, float)) else 0.0
|
||||
cr_val = float(row_data[11] or 0) if isinstance(row_data[11], (int, float)) else 0.0
|
||||
total_dr += dr_val
|
||||
total_cr += cr_val
|
||||
pages_entries.append((fname, page_rows))
|
||||
|
|
|
|||
705
core/parser.py
705
core/parser.py
|
|
@ -1,14 +1,23 @@
|
|||
#!/usr/bin/env python3
|
||||
"""KOCR v4 — K3 记账凭证解析器(基于坐标)"""
|
||||
"""KOCR v5 — 金蝶 K3 凭证解析器(基于 y 坐标区间的垂直布局解析)"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class K3VoucherParser:
|
||||
"""基于坐标解析 K3 标准记账凭证"""
|
||||
"""基于 y 坐标区间的 K3 凭证解析器
|
||||
|
||||
核心思路:不依赖行级匹配,而是按 y 坐标区间划分功能区域:
|
||||
- 头部区(y<1200):公司名、日期、凭证号、摘要
|
||||
- 科目区(y≈1200-2400):科目代码和名称
|
||||
- 借方金额区(借方标题 ~ 贷方标题之间)
|
||||
- 贷方金额区(贷方标题 ~ 图片底部)
|
||||
- 底部区:合计、制单等固定信息
|
||||
"""
|
||||
|
||||
def __init__(self, lines):
|
||||
"""lines: [(text, conf, cx, cy, box), ...]"""
|
||||
self.lines = sorted(lines, key=lambda l: (l[3], l[2]))
|
||||
|
||||
def parse(self):
|
||||
|
|
@ -30,417 +39,353 @@ class K3VoucherParser:
|
|||
'poster': '',
|
||||
}
|
||||
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
if cy < 480 and '物业' in text:
|
||||
result['company'] = text
|
||||
|
||||
elif '业务日期' in text or '日期' in text:
|
||||
m = re.search(r'(\d{4}[-\u5e74]\d{1,2}[-\u6708]\d{1,2})', text)
|
||||
if m:
|
||||
date_str = m.group(1).replace('年', '-').replace('月', '-').replace('日', '')
|
||||
try:
|
||||
result['date'] = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
except:
|
||||
pass
|
||||
|
||||
elif '凭证' in text:
|
||||
# 兼容各种格式:记-45、记 - 45、记-45(1/3)、45(1/3)
|
||||
m = re.search(r'[记凭证]\s*[-—]\s*(\d+)', text)
|
||||
if m:
|
||||
result['voucher_no'] = int(m.group(1))
|
||||
if not result['voucher_no']:
|
||||
m2 = re.search(r'(?:记\s*)?[-—]?\s*(\d+)\s*[((]\d+[/]\d+[))]', text)
|
||||
if m2:
|
||||
result['voucher_no'] = int(m2.group(1))
|
||||
# 兜底:直接找"凭证号"后面的数字
|
||||
if not result['voucher_no']:
|
||||
m3 = re.search(r'凭证号\s*[::]\s*记?\s*[-—]?\s*(\d+)', text)
|
||||
if m3:
|
||||
result['voucher_no'] = int(m3.group(1))
|
||||
|
||||
elif '附件数' in text:
|
||||
m = re.search(r'附件数[::](\d+)', text)
|
||||
if m:
|
||||
result['attachments'] = int(m.group(1))
|
||||
|
||||
elif '第' in text and '期' in text:
|
||||
m = re.search(r'第(\d+)期', text)
|
||||
if m:
|
||||
result['period'] = int(m.group(1))
|
||||
|
||||
elif '合计' in text or '壹' in text or '贰' in text:
|
||||
result['total_str'] = text
|
||||
m = re.search(r'([\d,]+\.[\d]{2})', text)
|
||||
if m:
|
||||
result['total_amount'] = float(m.group(1).replace(',', ''))
|
||||
|
||||
elif '制单' in text:
|
||||
m = re.search(r'制单[::](.*)', text)
|
||||
if m:
|
||||
result['preparer'] = m.group(1).strip()
|
||||
|
||||
elif '审核' in text:
|
||||
m = re.search(r'审核[::](.*)', text)
|
||||
if m:
|
||||
result['checker'] = m.group(1).strip()
|
||||
|
||||
elif '出纳' in text:
|
||||
m = re.search(r'出纳[::](.*)', text)
|
||||
if m:
|
||||
result['cashier'] = m.group(1).strip()
|
||||
|
||||
elif '经办' in text:
|
||||
m = re.search(r'经办[::](.*)', text)
|
||||
if m:
|
||||
result['handler'] = m.group(1).strip()
|
||||
|
||||
elif '过账' in text:
|
||||
m = re.search(r'过账[::](.*)', text)
|
||||
if m:
|
||||
result['poster'] = m.group(1).strip()
|
||||
# 1. 找到关键 y 坐标边界
|
||||
bounds = self._find_bounds()
|
||||
|
||||
if result['date']:
|
||||
result['year'] = result['date'].year
|
||||
# 2. 提取头部信息(日期、凭证号等)
|
||||
self._extract_header(result, bounds)
|
||||
|
||||
# 3. 提取科目代码和名称
|
||||
codes = self._extract_codes(bounds)
|
||||
|
||||
# 4. 提取金额(按借方/贷方分类)
|
||||
debit_amts, credit_amts = self._extract_amounts(bounds)
|
||||
|
||||
# 5. 提取摘要
|
||||
summary = self._extract_summary(bounds)
|
||||
|
||||
# 6. 汇总金额并去重
|
||||
debit_amts = self._deduplicate_amounts(debit_amts)
|
||||
credit_amts = self._deduplicate_amounts(credit_amts)
|
||||
|
||||
# 7. 按行数匹配金额到科目
|
||||
self._match_entries(result, codes, debit_amts, credit_amts, summary, bounds)
|
||||
|
||||
result['entries'] = self._parse_entries()
|
||||
return result
|
||||
|
||||
def _parse_entries(self):
|
||||
"""基于坐标解析分录"""
|
||||
if not self.lines:
|
||||
return []
|
||||
def _find_bounds(self):
|
||||
"""找到凭证各区域的 y 坐标边界"""
|
||||
bounds = {
|
||||
'y_summary_header': None, # "摘要" 标题
|
||||
'y_subject_header': None, # "科目" 标题
|
||||
'y_debit_header': None, # "借方" 标题
|
||||
'y_credit_header': None, # "贷方" 标题
|
||||
'y_date': None, # 日期行
|
||||
'y_total': None, # 合计行
|
||||
}
|
||||
|
||||
lines = sorted(self.lines, key=lambda l: (l[3], l[2]))
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
t = text.strip()
|
||||
if t == '摘要':
|
||||
bounds['y_summary_header'] = cy
|
||||
elif t == '科目':
|
||||
bounds['y_subject_header'] = cy
|
||||
elif t == '借方':
|
||||
bounds['y_debit_header'] = cy
|
||||
elif t == '贷方':
|
||||
bounds['y_credit_header'] = cy
|
||||
elif '合计' in t and ('壹' in t or '贰' in t or '参' in t or
|
||||
'合计' in t and any(c in t for c in '零壹贰叁肆伍陆柒捌玖拾佰仟万亿')):
|
||||
if bounds['y_total'] is None:
|
||||
bounds['y_total'] = (cy, text)
|
||||
elif cy > bounds['y_total'][0]:
|
||||
bounds['y_total'] = (cy, text)
|
||||
elif '日期' in t:
|
||||
bounds['y_date'] = cy
|
||||
|
||||
# === 1. 确定数据区域 ===
|
||||
y_headers = []
|
||||
for text, conf, cx, cy, box in lines:
|
||||
if text.strip() in ('借方', '贷方', '科目', '摘要'):
|
||||
y_headers.append(cy)
|
||||
y_start = min(y_headers) if y_headers else 0
|
||||
# 数据区上界:列头上方150px
|
||||
y_data_start = max(0, y_start - 150)
|
||||
y_end = 999999
|
||||
|
||||
# === 2. 获取图像宽度 ===
|
||||
all_xs = []
|
||||
for _, _, _, _, box in lines:
|
||||
if box:
|
||||
for p in box:
|
||||
all_xs.append(p[0])
|
||||
img_w = max(all_xs) if all_xs else 3000
|
||||
|
||||
# === 3. 筛选数据区域 ===
|
||||
data_lines = [(t, c, x, y, b) for t, c, x, y, b in lines
|
||||
if y_data_start <= y <= y_end and c >= 0.4]
|
||||
|
||||
# === 4. 提取科目代码 ===
|
||||
return bounds
|
||||
|
||||
def _extract_header(self, result, bounds):
|
||||
"""提取凭证头部信息:日期、凭证号、公司"""
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
t = text.strip()
|
||||
|
||||
if '物业' in t and '公司' in t and not result['company']:
|
||||
result['company'] = t
|
||||
elif '日期' in t:
|
||||
m = re.search(r'(\d{4}[-年]\d{1,2}[-月]\d{1,2})', t)
|
||||
if m:
|
||||
ds = m.group(1).replace('年', '-').replace('月', '-').replace('日', '')
|
||||
try:
|
||||
result['date'] = datetime.strptime(ds, '%Y-%m-%d')
|
||||
result['year'] = result['date'].year
|
||||
result['period'] = result['date'].month
|
||||
except:
|
||||
pass
|
||||
elif '凭证' in t:
|
||||
m = re.search(r'[记凭证号][^\d]*(\d+)', t)
|
||||
if m:
|
||||
result['voucher_no'] = int(m.group(1))
|
||||
elif '附件' in t:
|
||||
m = re.search(r'附件数[::]?(\d+)', t)
|
||||
if m:
|
||||
result['attachments'] = int(m.group(1))
|
||||
elif '第' in t and '期' in t:
|
||||
m = re.search(r'第(\d+)期', t)
|
||||
if m:
|
||||
result['period'] = int(m.group(1))
|
||||
elif '合计' in t and any(c in t for c in '零壹贰叁肆伍陆柒捌玖拾佰仟万亿'):
|
||||
result['total_str'] = t
|
||||
m = re.search(r'([\d,]+\.\d{2})', t.replace(',', ''))
|
||||
if m:
|
||||
result['total_amount'] = float(m.group(1).replace(',', ''))
|
||||
elif t.startswith('制单') or t.startswith('制单人'):
|
||||
result['preparer'] = t.split(':')[-1].split(':')[-1].strip()
|
||||
elif t.startswith('审核'):
|
||||
result['checker'] = t.split(':')[-1].split(':')[-1].strip()
|
||||
elif t.startswith('出纳'):
|
||||
result['cashier'] = t.split(':')[-1].split(':')[-1].strip()
|
||||
elif t.startswith('经办'):
|
||||
result['handler'] = t.split(':')[-1].split(':')[-1].strip()
|
||||
elif t.startswith('过账'):
|
||||
result['poster'] = t.split(':')[-1].split(':')[-1].strip()
|
||||
|
||||
def _extract_codes(self, bounds):
|
||||
"""提取科目代码和名称"""
|
||||
codes = []
|
||||
seen_codes = set()
|
||||
skip_keywords = ['科目', '摘要', '借方', '贷方', '出纳',
|
||||
'制单', '审核', '经办', '过账', '附件',
|
||||
'Manager', 'HONOR', 'Magic6', '合计',
|
||||
'凭证', '号']
|
||||
seen = set()
|
||||
|
||||
for text, conf, cx, cy, box in data_lines:
|
||||
if any(k in text for k in skip_keywords):
|
||||
continue
|
||||
text_clean = text.strip().replace(':', '').replace(' ', '')
|
||||
if text_clean in {'借', '贷', '科', '摘要', 'RMB', ''}:
|
||||
continue
|
||||
m = re.match(r'(\d[\d.]*)([--]?\s*.*)', text.strip())
|
||||
if m and m.group(1).count('.') >= 1:
|
||||
code_part = m.group(1).rstrip('.')
|
||||
rest = m.group(2).lstrip('--—= ').strip()
|
||||
if re.match(r'^\d{3,}\.', code_part):
|
||||
first_dot = code_part.find('.')
|
||||
code_num = int(code_part[:first_dot]) if first_dot > 0 else int(code_part)
|
||||
if code_num >= 1000 and code_part not in seen_codes:
|
||||
seen_codes.add(code_part)
|
||||
codes.append({
|
||||
'code': code_part,
|
||||
'name': rest,
|
||||
'desc': '',
|
||||
'debit': 0.0,
|
||||
'credit': 0.0,
|
||||
'y_pos': cy,
|
||||
'x_pos': cx,
|
||||
})
|
||||
# 安全获取边界值
|
||||
y_subj = bounds.get('y_subject_header')
|
||||
y_debit = bounds.get('y_debit_header')
|
||||
y_summary = bounds.get('y_summary_header')
|
||||
|
||||
if not codes:
|
||||
return self._parse_entries_fallback()
|
||||
# 默认边界
|
||||
y_subj_val = y_subj if y_subj is not None else 2000
|
||||
y_debit_val = y_debit if y_debit is not None else 99999
|
||||
y_lower = y_summary if y_summary is not None else 800
|
||||
|
||||
# === 4.3 科目名称补位:如果代码没有名称,从附近行找 ===
|
||||
account_priority_kw = ['银行', '货币', '账款', '费用', '收入', '成本',
|
||||
'应收', '应付', '预付', '预收', '其他', '库存',
|
||||
'管理', '财务', '手续费', '押金', '资本']
|
||||
for code in codes:
|
||||
if code['name']:
|
||||
continue
|
||||
name_candidates = []
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
if conf < 0.5:
|
||||
continue
|
||||
if not re.search(r'[\u4e00-\u9fff]', text):
|
||||
continue
|
||||
if any(k in text for k in skip_keywords + ['合计', '亿', '千', '百', '拾', '万']):
|
||||
continue
|
||||
if len(text.strip()) < 3:
|
||||
continue
|
||||
if cx < 400 or cx > 1300:
|
||||
continue
|
||||
if re.search(r'(公司|有限|集团|企业)', text):
|
||||
continue
|
||||
if text.strip() in ('未到账', '已到账', '未达', '已达'):
|
||||
continue
|
||||
if re.search(r'\d{3,}\.\d', text):
|
||||
continue
|
||||
# 排除纯银行账号/支行信息行
|
||||
if re.match(r'^[--]?\w{2,6}(支行|分行|银行)', text.strip()):
|
||||
continue
|
||||
if re.search(r'\d{15,}', text):
|
||||
continue
|
||||
dist_y = cy - code['y_pos']
|
||||
if 0 < dist_y < 200:
|
||||
# 优先看是否含会计关键词
|
||||
has_account_kw = any(kw in text for kw in account_priority_kw)
|
||||
name_candidates.append((text, dist_y, has_account_kw, cx, cy))
|
||||
if name_candidates:
|
||||
# 优先选含会计关键词的,再按y距离
|
||||
name_candidates.sort(key=lambda x: (not x[2], x[1]))
|
||||
code['name'] = name_candidates[0][0]
|
||||
code['y_pos'] = max(code['y_pos'], name_candidates[0][4])
|
||||
|
||||
# === 4.5 摘要提取:从剩余 OCR 文字中找摘要 ===
|
||||
code_y_min = min(c['y_pos'] for c in codes)
|
||||
|
||||
# 找到表格头的y坐标("科目"或"摘要"列头)
|
||||
table_header_y = min(
|
||||
(cy for text, conf, cx, cy, box in self.lines
|
||||
if text.strip() in ('科目', '摘要', '借方', '贷方')),
|
||||
default=0
|
||||
)
|
||||
# 摘要搜索上界:向上1000px的宽范围(摘要有时在列头上方)
|
||||
desc_upper_bound = max(0, code_y_min - 1000)
|
||||
|
||||
desc_candidates = []
|
||||
search_lines = self.lines
|
||||
for text, conf, cx, cy, box in search_lines:
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
if conf < 0.5:
|
||||
continue
|
||||
is_code = False
|
||||
for c in codes:
|
||||
if abs(c['y_pos'] - cy) < 20:
|
||||
is_code = True
|
||||
break
|
||||
if is_code:
|
||||
|
||||
# 科目代码区域:摘要标题上方 ~ 借方标题之间
|
||||
# 实际中代码可能在"科目"标题上方(y<y_subj),所以要放宽下界
|
||||
if cy < y_lower - 100 or cy > y_debit_val:
|
||||
continue
|
||||
if re.search(r'\d+\.\d{2}', text):
|
||||
|
||||
# x坐标:科目代码通常在表格中间区域
|
||||
if cx < 1500 or cx > 2600:
|
||||
continue
|
||||
if any(k in text for k in skip_keywords + ['合计', '亿', '千', '百', '拾', '万']):
|
||||
|
||||
t = text.strip()
|
||||
# 排除表头文字
|
||||
if t in ('摘要', '科目', '借方', '贷方', '合计') or len(t) < 4:
|
||||
continue
|
||||
if re.match(r'^[\d.\-,]+$', text.strip()):
|
||||
|
||||
# 匹配科目代码模式:4位数字开头,后面跟 .数字
|
||||
m = re.match(r'(\d{3,4}\.\d[\d.]*)', t)
|
||||
if not m:
|
||||
continue
|
||||
if len(text.strip()) < 3:
|
||||
code_part = m.group(1).rstrip('.')
|
||||
|
||||
# 验证是合法的科目代码(1000~9999)
|
||||
first_num = int(code_part.split('.')[0])
|
||||
if first_num < 1000 or first_num > 9999:
|
||||
continue
|
||||
if cx < 300:
|
||||
if code_part in seen:
|
||||
continue
|
||||
if text.strip().startswith('·') or '工商银行' in text:
|
||||
continue
|
||||
if '支行' in text or re.search(r'\d{15,}', text):
|
||||
continue
|
||||
if text.strip() in ('未到账', '已到账', '未达', '已达'):
|
||||
continue
|
||||
# 过滤垃圾摘要:纯字母数字/太短/无中文
|
||||
if not re.search(r'[\u4e00-\u9fff]', text): # 没有中文字,排除
|
||||
continue
|
||||
if len(text.strip()) < 4: # 太短
|
||||
continue
|
||||
if conf < 0.6: # 提高置信度要求
|
||||
continue
|
||||
# 过滤非摘要的日期/期号文本
|
||||
if re.match(r'\d{3}年第\d+期', text.strip()):
|
||||
continue
|
||||
if re.match(r'^[::]?\d{1,3}$', text.strip()):
|
||||
continue
|
||||
if text.strip().startswith('日期') or text.strip().startswith('业务日期'):
|
||||
continue
|
||||
if re.match(r'[\d,]+\s*\.\s*\d{2}', text.strip()):
|
||||
continue
|
||||
# 摘要列通常在凭证中部(x=400~950),排除右侧的公司名和左侧的金额
|
||||
if cx > 950 or cx < 400:
|
||||
continue
|
||||
# 过滤包含"公司"、"物业"、"有限"等公司相关词
|
||||
if re.search(r'(公司|有限|物业|集团|企业)', text):
|
||||
continue
|
||||
# 过滤科目名称文本
|
||||
account_keywords = ['银行', '货币', '账款', '费用', '收入', '成本',
|
||||
'应收', '应付', '预付', '预收', '其他', '库存',
|
||||
'管理', '财务', '手续费', '押金', '资本',
|
||||
'建设', '工商', '手续', '公积', '工资', '薪酬',
|
||||
'税金', '社保', '统筹', '折旧']
|
||||
# 以 -/- 开头的会计科目
|
||||
if re.match(r'^[--][^\d]', text.strip()) and any(
|
||||
kw in text for kw in account_keywords
|
||||
):
|
||||
continue
|
||||
# 纯科目名称(无业务关键词的短会计术语)
|
||||
biz_keywords = ['到账', '刷卡', '转账', '退回', '收入', '支出',
|
||||
'费用', '电费', '水费', '物业', '装修', '押金',
|
||||
'退', '报', '扫码', '业主', '假日', '客户',
|
||||
'房租', '租金', '工资', '货款', '报销']
|
||||
if len(text.strip()) < 25 and not any(kw in text for kw in biz_keywords):
|
||||
# 全是会计关键词 → 排除
|
||||
cn_chars = re.findall(r'[\u4e00-\u9fff]', text)
|
||||
if cn_chars:
|
||||
match_count = sum(1 for c in cn_chars
|
||||
for kw in account_keywords
|
||||
if c in kw)
|
||||
if match_count >= len(cn_chars) * 0.5: # 超过一半中文字是会计关键词
|
||||
continue
|
||||
# 过滤与科目名称重复的候选
|
||||
if len(text.strip()) < 25:
|
||||
is_near_name = False
|
||||
for c in codes:
|
||||
if c['name'] and c['name'] in text:
|
||||
if abs(c['y_pos'] - cy) < 100:
|
||||
is_near_name = True
|
||||
break
|
||||
# 也检查文本是否以代码开头(被当成名称的片段)
|
||||
if text.strip().startswith('-') or text.strip().startswith('-'):
|
||||
short_name = text.strip().lstrip('--')
|
||||
if c['name'] and short_name in c['name']:
|
||||
if abs(c['y_pos'] - cy) < 100:
|
||||
is_near_name = True
|
||||
break
|
||||
if is_near_name:
|
||||
continue
|
||||
if cy < desc_upper_bound:
|
||||
continue
|
||||
# 去重:跳过 y 距离 < 30 的重复文本
|
||||
if any(abs(d['y'] - cy) < 30 for d in desc_candidates):
|
||||
continue
|
||||
desc_candidates.append({'text': text, 'y': cy, 'x': cx})
|
||||
seen.add(code_part)
|
||||
|
||||
# 提取名称
|
||||
name = t[m.end():].lstrip('--—= ')
|
||||
# 清除尾随乱码(尾随的数字和特殊符号)
|
||||
name = re.sub(r'[\d.\-—=]+$', '', name).strip()
|
||||
# 清除开头的破折号
|
||||
name = name.lstrip('-—-=')
|
||||
|
||||
codes.append({
|
||||
'code': code_part,
|
||||
'name': name,
|
||||
'desc': '',
|
||||
'debit': 0.0,
|
||||
'credit': 0.0,
|
||||
'y_pos': cy,
|
||||
'x_pos': cx,
|
||||
})
|
||||
|
||||
desc_candidates.sort(key=lambda d: d['y'])
|
||||
# 按y坐标最近邻匹配:对每个科目代码,找最近的摘要候选
|
||||
# 按 y 排序
|
||||
codes.sort(key=lambda c: c['y_pos'])
|
||||
used_indices = set()
|
||||
for code in codes:
|
||||
best_dist = float('inf')
|
||||
best_idx = -1
|
||||
for i, desc in enumerate(desc_candidates):
|
||||
if i in used_indices:
|
||||
continue
|
||||
dist = abs(desc['y'] - code['y_pos'])
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_idx = i
|
||||
if best_idx >= 0 and best_dist < 200: # 200px以内视为有效匹配
|
||||
code['desc'] = desc_candidates[best_idx]['text']
|
||||
used_indices.add(best_idx)
|
||||
elif best_idx >= 0:
|
||||
# 超过200px但只有这一个候选且是第一行,可能是共享摘要
|
||||
pass
|
||||
return codes
|
||||
|
||||
def _extract_amounts(self, bounds):
|
||||
"""提取金额,按借方/贷方分类"""
|
||||
y_debit = bounds.get('y_debit_header')
|
||||
y_credit = bounds.get('y_credit_header')
|
||||
|
||||
# 如果没有任何匹配到摘要,但候选列表有内容,用第一个候选作为共享摘要
|
||||
none_found = all(not c.get('desc') for c in codes)
|
||||
if none_found and desc_candidates:
|
||||
for code in codes:
|
||||
code['desc'] = desc_candidates[0]['text']
|
||||
# 安全默认值
|
||||
y_debit_val = y_debit if y_debit is not None else 3000
|
||||
y_credit_val = y_credit if y_credit is not None else 3500
|
||||
|
||||
# === 5. 提取金额 ===
|
||||
amounts = []
|
||||
skip_amount_keywords = ['Manager', 'HONOR', 'Magic6', 'RMB',
|
||||
'日', '期', '凭证', '号', '附件',
|
||||
'制单', '审核', '出纳', '经办',
|
||||
'科目', '摘要']
|
||||
skip_texts = {'摘要', '科目', '借方', '贷方', '合计', '经办', '审核', '出纳', '制单'}
|
||||
|
||||
for text, conf, cx, cy, box in data_lines:
|
||||
if any(k in text for k in skip_amount_keywords):
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
if conf < 0.5:
|
||||
continue
|
||||
m = re.search(r'([\d,]+\.[\d]{2})', text.replace(',', ''))
|
||||
if m:
|
||||
val = float(m.group(1).replace(',', ''))
|
||||
# 排除年份/月份误识别
|
||||
if 1900 < val < 2100:
|
||||
continue
|
||||
if re.match(r'^\d{3,}\.', text.strip()):
|
||||
continue
|
||||
if val < 0.5:
|
||||
continue
|
||||
amounts.append({'val': val, 'x': cx, 'y': cy})
|
||||
if text.strip() in skip_texts:
|
||||
continue
|
||||
if any(k in text for k in ['Manager', 'HONOR', 'Magic6', 'RMB']):
|
||||
continue
|
||||
|
||||
m = re.search(r'([\d,]+\.\d{2})', text.replace(',', ''))
|
||||
if not m:
|
||||
continue
|
||||
|
||||
val = float(m.group(1).replace(',', ''))
|
||||
# 排除年份
|
||||
if 1900 < val < 2100:
|
||||
continue
|
||||
if val < 0.5:
|
||||
continue
|
||||
|
||||
# 分类借贷:只取借方标题以下的文本作为金额
|
||||
if cy < y_debit_val:
|
||||
continue # 在借方标题上方,不是金额
|
||||
elif cy <= y_credit_val:
|
||||
zone = 'debit'
|
||||
else:
|
||||
zone = 'credit'
|
||||
|
||||
amounts.append({'val': val, 'x': cx, 'y': cy, 'zone': zone})
|
||||
|
||||
# === 6. 找到借贷分界线 ===
|
||||
amount_xs = [a['x'] for a in amounts]
|
||||
if amount_xs:
|
||||
amount_xs.sort()
|
||||
max_gap = 0
|
||||
split_idx = len(amount_xs) // 2
|
||||
for i in range(1, len(amount_xs)):
|
||||
gap = amount_xs[i] - amount_xs[i-1]
|
||||
if gap > max_gap:
|
||||
max_gap = gap
|
||||
split_idx = i
|
||||
bound_x = (amount_xs[split_idx-1] + amount_xs[split_idx]) / 2 if split_idx > 0 else amount_xs[0]
|
||||
debit_amts = [a for a in amounts if a['zone'] == 'debit']
|
||||
credit_amts = [a for a in amounts if a['zone'] == 'credit']
|
||||
|
||||
return debit_amts, credit_amts
|
||||
|
||||
def _deduplicate_amounts(self, amts):
|
||||
"""对金额去重:相同值的只保留一个(OCR常把同一金额读两次)"""
|
||||
if not amts:
|
||||
return []
|
||||
|
||||
# 按 y 排序后,相同值的只保留第一个
|
||||
amts.sort(key=lambda a: a['y'])
|
||||
seen_vals = set()
|
||||
deduped = []
|
||||
for a in amts:
|
||||
rounded = round(a['val'], 2)
|
||||
if rounded in seen_vals:
|
||||
continue # 相同值的金额只保留一个
|
||||
seen_vals.add(rounded)
|
||||
deduped.append(a)
|
||||
|
||||
# 进一步:如果某个金额等于其他金额之和,去除(可能是合计行)
|
||||
if len(deduped) >= 3:
|
||||
total = sum(a['val'] for a in deduped)
|
||||
for a in deduped[:]:
|
||||
other_sum = total - a['val']
|
||||
if other_sum > 0.01 and abs(a['val'] - other_sum) < 0.01:
|
||||
deduped = [x for x in deduped if x is not a]
|
||||
break
|
||||
|
||||
return deduped
|
||||
|
||||
return deduped
|
||||
|
||||
def _extract_summary(self, bounds):
|
||||
"""提取凭证摘要(通常在表格顶部区域)"""
|
||||
y_subj = bounds.get('y_subject_header')
|
||||
y_subj_val = y_subj if y_subj is not None else 2000
|
||||
|
||||
candidates = []
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
if conf < 0.6:
|
||||
continue
|
||||
if not re.search(r'[\u4e00-\u9fff]', text):
|
||||
continue
|
||||
if cy < 0 or cy > y_subj_val - 100:
|
||||
continue
|
||||
if len(text.strip()) < 6:
|
||||
continue
|
||||
if any(k in text for k in ['经办', '合计', '审核', '日期', '业务', '附件']):
|
||||
continue
|
||||
|
||||
candidates.append((text, cy))
|
||||
|
||||
# 按 y 排序,取最靠上且有意义的候选
|
||||
candidates.sort(key=lambda x: x[1])
|
||||
# 过滤掉日期/期号类文本
|
||||
real_summaries = [t for t, y in candidates
|
||||
if not re.match(r'\d{3}年第\d+期', t)
|
||||
and not re.match(r'[::]\d+', t)
|
||||
and '日期' not in t
|
||||
and len(t) >= 6]
|
||||
if real_summaries:
|
||||
return real_summaries[0]
|
||||
return ''
|
||||
|
||||
def _match_entries(self, result, codes, debit_amts, credit_amts, summary, bounds):
|
||||
"""将金额按匹配到科目,构建分录"""
|
||||
if not codes:
|
||||
result['entries'] = []
|
||||
return
|
||||
|
||||
n_codes = len(codes)
|
||||
n_debit = len(debit_amts)
|
||||
n_credit = len(credit_amts)
|
||||
|
||||
# 策略 1:等额借贷
|
||||
if n_codes == n_debit == n_credit:
|
||||
for i, code in enumerate(codes):
|
||||
code['debit'] = debit_amts[i]['val']
|
||||
code['credit'] = credit_amts[i]['val']
|
||||
|
||||
# 策略 2:单行分录
|
||||
elif n_codes == 1 and (n_debit > 0 or n_credit > 0):
|
||||
codes[0]['debit'] = sum(a['val'] for a in debit_amts)
|
||||
codes[0]['credit'] = sum(a['val'] for a in credit_amts)
|
||||
|
||||
# 策略 3:借贷均足量,按顺序匹配
|
||||
elif n_debit >= n_codes and n_credit >= n_codes:
|
||||
for i, code in enumerate(codes):
|
||||
if i < n_debit:
|
||||
code['debit'] = debit_amts[i]['val']
|
||||
if i < n_credit:
|
||||
code['credit'] = credit_amts[i]['val']
|
||||
|
||||
# 策略 4:借方多,贷方平均分配
|
||||
elif n_debit >= n_codes:
|
||||
for i, code in enumerate(codes):
|
||||
code['debit'] = debit_amts[i]['val']
|
||||
if n_credit > 0:
|
||||
per_code = sum(a['val'] for a in credit_amts) / n_codes
|
||||
for code in codes:
|
||||
code['credit'] = round(per_code, 2)
|
||||
|
||||
# 策略 5:贷方多,借方平均分配
|
||||
elif n_credit >= n_codes:
|
||||
for i, code in enumerate(codes):
|
||||
code['credit'] = credit_amts[i]['val']
|
||||
if n_debit > 0:
|
||||
per_code = sum(a['val'] for a in debit_amts) / n_codes
|
||||
for code in codes:
|
||||
code['debit'] = round(per_code, 2)
|
||||
|
||||
# 策略 6:都不够
|
||||
else:
|
||||
bound_x = img_w * 0.5
|
||||
for i, code in enumerate(codes):
|
||||
if i < n_debit:
|
||||
code['debit'] = debit_amts[i]['val']
|
||||
if i < n_credit:
|
||||
code['credit'] = credit_amts[i]['val']
|
||||
|
||||
# === 7. 交错匹配金额到科目 ===
|
||||
debit_amts = sorted([a for a in amounts if a['x'] < bound_x], key=lambda a: a['y'])
|
||||
credit_amts = sorted([a for a in amounts if a['x'] >= bound_x], key=lambda a: a['y'])
|
||||
|
||||
interleaved = []
|
||||
max_len = max(len(debit_amts), len(credit_amts))
|
||||
for i in range(max_len):
|
||||
if i < len(debit_amts):
|
||||
interleaved.append(('dr', debit_amts[i]))
|
||||
if i < len(credit_amts):
|
||||
interleaved.append(('cr', credit_amts[i]))
|
||||
|
||||
codes.sort(key=lambda c: c['y_pos'])
|
||||
|
||||
for i, code in enumerate(codes):
|
||||
if i < len(interleaved):
|
||||
side, amt = interleaved[i]
|
||||
if side == 'dr':
|
||||
code['debit'] = amt['val']
|
||||
else:
|
||||
code['credit'] = amt['val']
|
||||
|
||||
# === 8. 构建结果 ===
|
||||
entries = []
|
||||
# 设置摘要
|
||||
for code in codes:
|
||||
entries.append({
|
||||
'id': len(entries) + 1,
|
||||
'description': code['desc'] or '会计凭证',
|
||||
code['desc'] = summary or '会计凭证'
|
||||
|
||||
# 构建 entries
|
||||
result['entries'] = []
|
||||
for i, code in enumerate(codes):
|
||||
result['entries'].append({
|
||||
'id': i + 1,
|
||||
'description': code['desc'],
|
||||
'account_code': code['code'],
|
||||
'account_name': code['name'] or '未知科目',
|
||||
'debit': code['debit'],
|
||||
'credit': code['credit'],
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
# 清洗科目名称
|
||||
for e in result['entries']:
|
||||
e['account_name'] = e['account_name'].replace('--', '-').strip()
|
||||
|
||||
return entries
|
||||
|
||||
def _parse_entries_fallback(self):
|
||||
"""兜底:基础科目代码检测"""
|
||||
entries = []
|
||||
for text, conf, cx, cy, box in self.lines:
|
||||
if conf < 0.5:
|
||||
continue
|
||||
m = re.match(r'(\d{3,}\.[\d.]+)', text.strip())
|
||||
if m:
|
||||
code = m.group(1).rstrip('.')
|
||||
name = text[m.end():].lstrip('--—= ')
|
||||
entries.append({
|
||||
'id': len(entries) + 1,
|
||||
'description': '',
|
||||
'account_code': code,
|
||||
'account_name': name or '未知科目',
|
||||
'debit': 0.0,
|
||||
'credit': 0.0,
|
||||
})
|
||||
return entries
|
||||
|
|
|
|||
|
|
@ -1,205 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""KOCR v4 — OCR 识别模块(TableMaster + DB+SVTR 双管线)"""
|
||||
"""KOCR v5 — OCR 识别模块(简化版:原图直出,无预处理)"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import cv2
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
|
||||
def _build_ocr_params(config: dict, photo_type: str = 'normal'):
|
||||
"""根据配置和照片类型构建 PaddleOCR 参数
|
||||
|
||||
Args:
|
||||
config: config.yaml 中 ocr 章节
|
||||
photo_type: 照片类型(用于覆盖参数)
|
||||
|
||||
Returns:
|
||||
params dict
|
||||
"""
|
||||
params = {
|
||||
'use_angle_cls': config.get('use_angle_cls', True),
|
||||
'lang': config.get('lang', 'ch'),
|
||||
'use_gpu': config.get('use_gpu', False),
|
||||
'show_log': config.get('show_log', False),
|
||||
'det_db_thresh': config.get('det_db_thresh', 0.3),
|
||||
'det_db_box_thresh': config.get('det_db_box_thresh', 0.5),
|
||||
'det_db_unclip_ratio': config.get('det_db_unclip_ratio', 2.2),
|
||||
'det_limit_side_len': config.get('max_side_len', 1600),
|
||||
'det_db_score_mode': config.get('det_db_score_mode', 'slow'),
|
||||
'min_size': config.get('min_size', 8),
|
||||
'rec_drop_score': config.get('rec_drop_score', 0.5),
|
||||
}
|
||||
|
||||
# 自定义字典路径(相对于项目根目录)
|
||||
rec_dict = config.get('rec_char_dict_path')
|
||||
if rec_dict:
|
||||
params['rec_char_dict_path'] = os.path.abspath(rec_dict)
|
||||
|
||||
# 照片类型参数覆盖
|
||||
overrides = config.get('photo_type_overrides', {}).get(photo_type, {})
|
||||
params.update(overrides)
|
||||
|
||||
return params
|
||||
_ocr = None
|
||||
|
||||
|
||||
def _check_gpu_available() -> bool:
|
||||
"""检查 GPU 是否对 PaddleOCR 可用(不仅看 nvidia-smi)"""
|
||||
try:
|
||||
# 先看 nvidia-smi
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
['nvidia-smi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
def get_ocr_instance(config: dict):
|
||||
"""懒加载 PaddleOCR 实例"""
|
||||
global _ocr
|
||||
if _ocr is None:
|
||||
_ocr = PaddleOCR(
|
||||
use_angle_cls=config.get('use_angle_cls', True),
|
||||
lang=config.get('lang', 'ch'),
|
||||
use_gpu=config.get('use_gpu', False),
|
||||
show_log=config.get('show_log', False),
|
||||
det_db_thresh=config.get('det_db_thresh', 0.3),
|
||||
det_db_box_thresh=config.get('det_db_box_thresh', 0.5),
|
||||
det_db_unclip_ratio=config.get('det_db_unclip_ratio', 2.2),
|
||||
det_limit_side_len=config.get('max_side_len', 1600),
|
||||
det_db_score_mode=config.get('det_db_score_mode', 'slow'),
|
||||
min_size=config.get('min_size', 8),
|
||||
rec_drop_score=config.get('rec_drop_score', 0.5),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
# 再看 PaddlePaddle 能否真正加载 GPU
|
||||
import paddle
|
||||
return paddle.is_compiled_with_cuda()
|
||||
except Exception:
|
||||
return False
|
||||
return _ocr
|
||||
|
||||
|
||||
def run_ocr_ppstructure(img, config: dict, verbose=False):
|
||||
"""尝试 PPStructure TableMaster 表格识别
|
||||
def run_ocr(image, ocr_config: dict) -> list:
|
||||
"""对图像执行 OCR,返回 [(text, confidence, cx, cy, box), ...]
|
||||
|
||||
Args:
|
||||
img: 预处理后的图像
|
||||
config: OCR 配置
|
||||
verbose: 是否输出详细信息
|
||||
输入:
|
||||
image: numpy array (cv2.imread) 或 图像文件路径
|
||||
ocr_config: dict(PaddleOCR 参数)
|
||||
|
||||
Returns:
|
||||
lines 列表或 None(失败)
|
||||
返回:
|
||||
排序后的 lines: [(text, conf, cx, cy, box), ...]
|
||||
按 (y, x) 排序
|
||||
"""
|
||||
table_algorithm = config.get('table_algorithm', 'TableMaster')
|
||||
use_gpu = _check_gpu_available() and config.get('use_gpu', False)
|
||||
ocr = get_ocr_instance(ocr_config)
|
||||
|
||||
if verbose:
|
||||
print(f" 📋 PPStructure 识别 ({table_algorithm})...", file=sys.stderr)
|
||||
# 支持 numpy array 和 文件路径
|
||||
result = ocr(image)
|
||||
|
||||
try:
|
||||
from paddleocr import PPStructure
|
||||
|
||||
pp_args = {
|
||||
'show_log': False,
|
||||
'lang': 'ch',
|
||||
'layout': True,
|
||||
'ocr': True,
|
||||
'table_algorithm': table_algorithm,
|
||||
'use_gpu': use_gpu,
|
||||
}
|
||||
|
||||
engine = PPStructure(**pp_args)
|
||||
table_result = engine(img)
|
||||
|
||||
for res in table_result:
|
||||
if res['type'] == 'table':
|
||||
html = res['res']['html']
|
||||
if '<td>' in html and html.count('<tr>') >= 2:
|
||||
lines = parse_table_html_to_lines(html)
|
||||
if lines and len(lines) >= 4:
|
||||
if verbose:
|
||||
print(f" ✅ TableMaster 识别成功", file=sys.stderr)
|
||||
return lines
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f" ⚠️ TableMaster 不可用: {e}", file=sys.stderr)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_ocr_det_rec(img, config: dict, photo_type: str = 'normal', verbose=False):
|
||||
"""DB 检测 + SVTR 文本识别(回退方案)
|
||||
|
||||
Args:
|
||||
img: 预处理后的图像
|
||||
config: OCR 配置
|
||||
photo_type: 照片类型
|
||||
verbose: 是否输出详细信息
|
||||
|
||||
Returns:
|
||||
lines 列表
|
||||
"""
|
||||
params = _build_ocr_params(config, photo_type)
|
||||
use_gpu = _check_gpu_available() and config.get('use_gpu', False)
|
||||
params['use_gpu'] = use_gpu
|
||||
|
||||
if verbose:
|
||||
print(f" 🔍 DB+SVTR 识别 (GPU={use_gpu})", file=sys.stderr)
|
||||
|
||||
from .postprocessor import char_correct
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
ocr = PaddleOCR(**params)
|
||||
result = ocr.ocr(img, cls=True)
|
||||
|
||||
# 防御:PaddleOCR 可能返回 None 或 [None]
|
||||
if not result or result[0] is None:
|
||||
if verbose:
|
||||
print(f" ⚠️ PaddleOCR 未检测到文字", file=sys.stderr)
|
||||
# PaddleOCR v2.8 返回格式多变,兼容处理
|
||||
lines_raw = []
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
lines = []
|
||||
for line_group in result:
|
||||
for line in line_group:
|
||||
box = line[0]
|
||||
text = char_correct(line[1][0])
|
||||
conf = line[1][1]
|
||||
xs = [p[0] for p in box]
|
||||
ys = [p[1] for p in box]
|
||||
cx = sum(xs) / 4
|
||||
cy = sum(ys) / 4
|
||||
lines.append((text, conf, cx, cy, box))
|
||||
# tuple of 3: (boxes, texts, elapsed)
|
||||
if isinstance(result, tuple) and len(result) >= 2:
|
||||
boxes = result[0]
|
||||
texts = result[1]
|
||||
if boxes and texts:
|
||||
for box, (text, conf) in zip(boxes, texts):
|
||||
if conf < 0.4:
|
||||
continue
|
||||
cx = float((box[0][0] + box[2][0]) / 2)
|
||||
cy = float((box[0][1] + box[2][1]) / 2)
|
||||
lines_raw.append((text, conf, cx, cy, box))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def parse_table_html_to_lines(html):
|
||||
"""解析 PPStructure 表格 HTML 为结构化行数据"""
|
||||
from bs4 import BeautifulSoup
|
||||
lines = []
|
||||
try:
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
tables = soup.find_all('table')
|
||||
if not tables:
|
||||
return []
|
||||
rows = tables[0].find_all('tr')
|
||||
for ri, row in enumerate(rows):
|
||||
cells = row.find_all(['td', 'th'])
|
||||
row_texts = []
|
||||
for ci, cell in enumerate(cells):
|
||||
text = cell.get_text(strip=True)
|
||||
if text:
|
||||
x = float(ci * 300 + 100)
|
||||
y = float(ri * 200 + 100)
|
||||
lines.append((text, 0.95, x, y, None))
|
||||
row_texts.append(text)
|
||||
if not row_texts:
|
||||
continue
|
||||
except Exception:
|
||||
return []
|
||||
return lines
|
||||
|
||||
|
||||
def run_ocr(img, config: dict, photo_type: str = 'normal', verbose=False):
|
||||
"""完整 OCR 识别入口:先 TableMaster,失败回退 DB+SVTR
|
||||
# 旧版格式: list of [[box], (text, conf), ...]
|
||||
elif isinstance(result, list):
|
||||
for item in result:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
box = item[0]
|
||||
if isinstance(box, (list, tuple)) and len(box) >= 1:
|
||||
text, conf = item[1]
|
||||
if conf < 0.4:
|
||||
continue
|
||||
cx = float((box[0][0] + box[2][0]) / 2)
|
||||
cy = float((box[0][1] + box[2][1]) / 2)
|
||||
lines_raw.append((text, conf, cx, cy, box))
|
||||
|
||||
Args:
|
||||
img: 预处理后的图像
|
||||
config: OCR 配置
|
||||
photo_type: 照片类型
|
||||
verbose: 是否输出详细信息
|
||||
|
||||
Returns:
|
||||
识别结果行列表
|
||||
"""
|
||||
# 1. 尝试 TableMaster 表格识别
|
||||
lines = run_ocr_ppstructure(img, config, verbose=verbose)
|
||||
if lines:
|
||||
return lines
|
||||
|
||||
# 2. 回退 DB+SVTR
|
||||
lines = run_ocr_det_rec(img, config, photo_type, verbose=verbose)
|
||||
return lines
|
||||
# 按 y 排序(从上到下),同 y 按 x 排序(从左到右)
|
||||
lines_raw.sort(key=lambda l: (l[3], l[2]))
|
||||
return lines_raw
|
||||
|
|
|
|||
62
kocr.py
62
kocr.py
|
|
@ -30,7 +30,6 @@ import cv2
|
|||
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
from core.preprocessor import VoucherPreprocessor, analyze_image
|
||||
from core.recognizer import run_ocr
|
||||
from core.parser import K3VoucherParser
|
||||
from core.postprocessor import validate_voucher, validate_merged_vouchers, generate_review_file
|
||||
|
|
@ -139,68 +138,35 @@ def is_processed(filename):
|
|||
def process_single(image_path, config, output_path=None, verbose=False, mark_done=True):
|
||||
"""处理单张凭证照片
|
||||
|
||||
流程: 图像分析 → 自动调参 → 预处理 → OCR(TableMaster/DB+SVTR) → 解析 → 填Excel → 验证
|
||||
流程: 原图 → PaddleOCR → 解析 → 填Excel → 验证
|
||||
|
||||
Returns:
|
||||
(voucher_data, quality_info)
|
||||
(voucher_data, quality)
|
||||
"""
|
||||
filename = os.path.basename(image_path)
|
||||
|
||||
if verbose:
|
||||
print(f"\n📷 {filename}")
|
||||
|
||||
# 1. 读取图像 + 图像质量分析
|
||||
# 1. 读取图像
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
raise ValueError(f"无法读取图片: {image_path}")
|
||||
|
||||
quality = analyze_image(img)
|
||||
if verbose:
|
||||
print(f" 📊 图像分析: 类型={quality.photo_type}, "
|
||||
f"亮度={quality.brightness}, 模糊={quality.blur_score}")
|
||||
|
||||
# 2. 双管线:先对原图OCR提取头部(凭证号、日期对小字更敏感)
|
||||
# 2. 原图直出 OCR(无预处理)
|
||||
ocr_config = config.get('ocr', {})
|
||||
|
||||
# 2a. 原图OCR → 提取头部信息(预处理会破坏小字)
|
||||
if verbose:
|
||||
print(f" 📋 原图OCR提取头部...")
|
||||
lines_raw = run_ocr(img, ocr_config, quality.photo_type, verbose=verbose)
|
||||
parser_raw = K3VoucherParser(lines_raw)
|
||||
header_info = parser_raw.parse()
|
||||
if verbose and header_info['voucher_no']:
|
||||
print(f" ✅ 原图识别到凭证号: 记-{header_info['voucher_no']}")
|
||||
|
||||
# 2b. 自动调参 + 预处理 → 提取表格内容
|
||||
preproc_config = config.get('preprocessing', {})
|
||||
preprocessor = VoucherPreprocessor.from_config(preproc_config, quality.photo_type)
|
||||
processed = preprocessor.process(img)
|
||||
print(f" 📋 OCR 识别中...")
|
||||
lines = run_ocr(img, ocr_config)
|
||||
|
||||
if verbose:
|
||||
print(f" 预处理: 缩放={preprocessor.max_side}, "
|
||||
f"去阴影={preprocessor.remove_shadow}, "
|
||||
f"锐化={preprocessor.sharpen}")
|
||||
print(f" OCR: {len(lines)} 行文字")
|
||||
|
||||
lines = run_ocr(processed, ocr_config, quality.photo_type, verbose=verbose)
|
||||
|
||||
if verbose:
|
||||
print(f" OCR: {len(lines)} 行文字(预处理后)")
|
||||
|
||||
# 3. 解析(合并原图头部 + 预处理表格)
|
||||
# 3. 解析
|
||||
parser = K3VoucherParser(lines)
|
||||
voucher = parser.parse()
|
||||
|
||||
# 如果原图识别到了凭证号但预处理后没识别到,补回来
|
||||
if voucher['voucher_no'] is None and header_info['voucher_no'] is not None:
|
||||
voucher['voucher_no'] = header_info['voucher_no']
|
||||
if verbose:
|
||||
print(f" ✅ 使用原图凭证号: 记-{header_info['voucher_no']}")
|
||||
# 同理补日期
|
||||
if voucher['date'] is None and header_info['date'] is not None:
|
||||
voucher['date'] = header_info['date']
|
||||
if verbose:
|
||||
print(f" ✅ 使用原图日期: {header_info['date'].strftime('%Y-%m-%d')}")
|
||||
|
||||
if verbose:
|
||||
print(f" 公司: {voucher['company']}")
|
||||
print(f" 日期: {voucher['date'].strftime('%Y-%m-%d') if voucher['date'] else '未识别'}")
|
||||
|
|
@ -211,21 +177,21 @@ def process_single(image_path, config, output_path=None, verbose=False, mark_don
|
|||
cr = f"贷 {e['credit']:.2f}" if e['credit'] else ""
|
||||
print(f" {e['account_code']} {e['account_name']:20s} {dr:>12s} {cr:>12s}")
|
||||
|
||||
# 5. 大小写金额交叉验证(自动检测 total_str)
|
||||
# 4. 大小写金额交叉验证
|
||||
if voucher['total_str'] and voucher['total_amount'] > 0:
|
||||
from core.postprocessor import amount_check
|
||||
total_dr = sum(e.get('debit', 0) or 0 for e in voucher['entries'])
|
||||
total_cr = sum(e.get('credit', 0) or 0 for e in voucher['entries'])
|
||||
total_max = max(total_dr, total_cr)
|
||||
if total_max > 0:
|
||||
cn_part = __import__('re').sub(r'[\d,.\s¥¥]', '', voucher['total_str'])
|
||||
cn_part = __import__('re').sub(r'[\\d,.\\s¥¥]', '', voucher['total_str'])
|
||||
if cn_part:
|
||||
ok = amount_check(total_max, cn_part)
|
||||
if verbose:
|
||||
status = '✅' if ok else '❌'
|
||||
print(f" 大小写校验: {status} 小写={total_max:.2f} 大写={cn_part}")
|
||||
|
||||
# 6. 填 Excel
|
||||
# 5. 填 Excel
|
||||
if output_path is None:
|
||||
basename = os.path.splitext(filename)[0]
|
||||
output_path = os.path.join(BATCH_OUTPUT_DIR, f'{basename}_K3.xls')
|
||||
|
|
@ -235,11 +201,11 @@ def process_single(image_path, config, output_path=None, verbose=False, mark_don
|
|||
|
||||
fill_template(voucher, template, output_path, verbose=verbose)
|
||||
|
||||
# 7. 标记已处理(借贷校验在合并阶段统一做)
|
||||
# 6. 标记已处理
|
||||
if mark_done:
|
||||
mark_processed(filename, voucher, output_path)
|
||||
|
||||
return voucher, quality
|
||||
return voucher, None
|
||||
|
||||
|
||||
# ======== 状态报告 ========
|
||||
|
|
@ -385,7 +351,7 @@ def main():
|
|||
vno = voucher.get('voucher_no', '?')
|
||||
vdate = voucher['date'].strftime('%Y-%m-%d') if voucher.get('date') else '?'
|
||||
results.append((fname, voucher, out_path, quality))
|
||||
print(f" ✅ [{quality.photo_type}] {vdate} 记-{vno} → {os.path.basename(out_path)}")
|
||||
print(f" ✅ ['raw'] {vdate} 记-{vno} → {os.path.basename(out_path)}")
|
||||
except Exception as e:
|
||||
print(f" ❌ 失败: {e}")
|
||||
print()
|
||||
|
|
|
|||
Loading…
Reference in New Issue