kocr/core/parser.py

424 lines
18 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
# 数据区上界列头上方500px兼容科目代码在列头上方的布局
y_data_start = max(0, y_start - 500)
y_end = 999999
# 找数据区下方的"合计"行(排除顶部大写合计:带中文数字的)
import re as _re
heji_positions = [cy for text, conf, cx, cy, box in lines
if '合计' in text
and cy > y_data_start
and not _re.search(r'[壹贰叁肆伍陆柒捌玖拾佰仟万亿]', text)]
if heji_positions:
y_end = max(heji_positions) # 取最靠下的合计行
# === 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.3 科目名称补位:如果代码没有名称,从附近行找 ===
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 re.match(r'^[\d.\-,]+$', text.strip()):
continue
if len(text.strip()) < 3:
continue
if cx < 400 or cx > 1300: # 名称列通常在x=400-1300
continue
if re.search(r'(公司|有限|集团|企业)', text): # 排除公司名
continue
if text.strip() in ('未到账', '已到账', '未达', '已达'):
continue
# 排除包含其他科目代码的行(如 "6603.02-财务费用"
if re.search(r'\d{3,}\.\d', text):
continue
dist_y = cy - code['y_pos']
if 0 < dist_y < 200: # 代码下方200px以内
name_candidates.append((text, dist_y, cx, cy))
if name_candidates:
# 取y距离最近的
name_candidates.sort(key=lambda x: (x[1], -x[2]))
code['name'] = name_candidates[0][0]
code['y_pos'] = max(code['y_pos'], name_candidates[0][3]) # 更新y为名称位置
# === 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:
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 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
# 过滤科目名称文本:以 -/ 开头的会计科目
if re.match(r'^[-][^\d]', text.strip()) and any(
kw in text for kw in ['银行', '货币', '账款', '费用', '收入', '成本',
'应收', '应付', '预付', '预收', '其他', '库存',
'管理', '财务', '手续费', '押金', '资本']
):
continue
# 过滤纯科目名称(在代码附近找到的科目名不应作为摘要)
if len(text.strip()) < 20: # 短文本更容易是科目名
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 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})
desc_candidates.sort(key=lambda d: d['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
# 如果没有任何匹配到摘要,但候选列表有内容,用第一个候选作为共享摘要
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']
# === 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