fix: 模板只有1页导致list index out of range崩溃

- 重生成标准双页模板(t_Schema + Page1)
- merge_output 硬编码列索引改为防御性类型检查
This commit is contained in:
xiaoxue_admin 2026-06-22 17:40:17 +08:00
parent c49f9819e1
commit 1bcda1ab8c
6 changed files with 414 additions and 703 deletions

View File

@ -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

View File

@ -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))

View File

@ -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公司名日期凭证号摘要
- 科目区y1200-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、记-451/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

View File

@ -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: dictPaddleOCR 参数
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
View File

@ -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()

BIN
凭证模板.xls Normal file

Binary file not shown.