kocr/core/parser.py

312 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""KOCR v4 — K3 记账凭证解析器(基于坐标)"""
import re
from datetime import datetime
class K3VoucherParser:
"""基于坐标解析 K3 标准记账凭证"""
def __init__(self, lines):
self.lines = sorted(lines, key=lambda l: (l[3], l[2]))
def parse(self):
result = {
'company': '',
'date': None,
'year': None,
'period': None,
'voucher_word': '',
'voucher_no': None,
'attachments': 0,
'entries': [],
'total_str': '',
'total_amount': 0,
'preparer': '',
'checker': '',
'cashier': '',
'handler': '',
'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()
if result['date']:
result['year'] = result['date'].year
result['entries'] = self._parse_entries()
return result
def _parse_entries(self):
"""基于坐标解析分录"""
if not self.lines:
return []
lines = sorted(self.lines, key=lambda l: (l[3], l[2]))
# === 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
y_end = 999999
for text, conf, cx, cy, box in lines:
if '合计' in text:
y_end = min(y_end, cy)
y_data_start = max(0, y_start - 150)
# === 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. 提取科目代码 ===
codes = []
seen_codes = set()
skip_keywords = ['科目', '摘要', '借方', '贷方', '出纳',
'制单', '审核', '经办', '过账', '附件',
'Manager', 'HONOR', 'Magic6', '合计',
'凭证', '']
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,
})
if not codes:
return self._parse_entries_fallback()
# === 4.5 摘要提取:从剩余 OCR 文字中找摘要 ===
code_y_min = min(c['y_pos'] for c in codes)
desc_candidates = []
search_lines = self.lines
for text, conf, cx, cy, box in search_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:
continue
if re.search(r'\d+\.\d{2}', text):
continue
if any(k in text for k in skip_keywords + ['合计', '亿', '', '', '', '']):
continue
if re.match(r'^[\d.\-,]+$', text.strip()):
continue
if len(text.strip()) < 3:
continue
if cx < 300:
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 cy < code_y_min - 50:
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})
desc_candidates.sort(key=lambda d: d['y'])
for i, desc in enumerate(desc_candidates):
if i < len(codes):
codes[i]['desc'] = desc['text']
# === 5. 提取金额 ===
amounts = []
skip_amount_keywords = ['Manager', 'HONOR', 'Magic6', 'RMB',
'', '', '凭证', '', '附件',
'制单', '审核', '出纳', '经办',
'科目', '摘要']
for text, conf, cx, cy, box in data_lines:
if any(k in text for k in skip_amount_keywords):
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})
# === 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]
else:
bound_x = img_w * 0.5
# === 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 '会计凭证',
'account_code': code['code'],
'account_name': code['name'] or '未知科目',
'debit': code['debit'],
'credit': code['credit'],
})
for e in 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