#!/usr/bin/env python3 """ KOCR v4 — 金蝶 K3 凭证 OCR 填表工具 小唯 A06 出品 | 自动调参版 用法: python3 kocr.py # 批量处理所有新照片 python3 kocr.py --new # 只处理未识别过的照片 python3 kocr.py --status # 查看处理进度 python3 kocr.py IMG_001.jpg # 单张处理 python3 kocr.py --merge # 合并输出排序好的汇总 Excel python3 kocr.py --config my.yaml # 指定配置文件 特性: - 自动图像分析 + 自动调参(按照片亮度/阴影/模糊度选参数) - 大小写金额交叉验证(大写转数字比对) - PPStructure TableMaster 表格识别(失败回退 DB+SVTR) - 交错匹配(dr1, cr1, dr2, cr2...) - 多页凭证自动合并 - 外置配置文件(部署时只需调整 config.yaml) """ import os import sys import argparse import yaml import cv2 # 项目根目录 PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, PROJECT_DIR) from core.recognizer import run_ocr from core.parser import K3VoucherParser from core.postprocessor import validate_voucher, validate_merged_vouchers, generate_review_file from core.excel_writer import fill_template, merge_output, merge_voucher_data # ======== 路径常量 ======== DEFAULT_CONFIG = os.path.join(PROJECT_DIR, 'config.yaml') BATCH_INPUT_DIR = os.path.join(PROJECT_DIR, 'input') BATCH_INPUT_DONE = os.path.join(PROJECT_DIR, 'input/done') BATCH_OUTPUT_DIR = os.path.join(PROJECT_DIR, 'output') INDEX_FILE = os.path.join(PROJECT_DIR, '.kocr_index.json') # ======== 配置加载 ======== def load_config(config_path=None): """加载 YAML 配置,支持命令行覆盖""" path = config_path or DEFAULT_CONFIG if not os.path.exists(path): print(f"⚠️ 配置文件不存在: {path},使用默认配置") return _default_config() with open(path, 'r', encoding='utf-8') as f: config = yaml.safe_load(f) return config def _default_config(): """默认配置(当 config.yaml 不存在时使用)""" return { 'preprocessing': { 'max_side': 1600, 'denoise': True, 'deskew': True, 'remove_shadow': True, 'sharpen': True, }, 'ocr': { 'use_gpu': False, 'lang': 'ch', 'show_log': False, 'use_angle_cls': True, '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, 'rec_drop_score': 0.5, }, 'postprocessing': { 'amount_balance_tolerance': 0.01, }, 'paths': { 'input_dir': 'input', 'output_dir': 'output', 'done_dir': 'input/done', 'template': '~/.hermes/cache/documents/doc_704eac18442d_凭证模板.xls', }, 'photo_analysis': { 'brightness_thresholds': {'flash': 200, 'dark': 60}, 'blur_threshold': 100, 'shadow_threshold': 0.3, }, } # ======== 索引管理 ======== def load_index(): if os.path.exists(INDEX_FILE): try: with open(INDEX_FILE, 'r', encoding='utf-8') as f: return yaml.safe_load(f) or {} except: pass return {} def save_index(idx): os.makedirs(os.path.dirname(INDEX_FILE), exist_ok=True) with open(INDEX_FILE, 'w', encoding='utf-8') as f: yaml.dump(idx, f, allow_unicode=True, default_flow_style=False) def mark_processed(filename, voucher_info, output_path): idx = load_index() idx[filename] = { 'voucher_word': voucher_info.get('voucher_word', '记'), 'voucher_no': voucher_info.get('voucher_no'), 'date': voucher_info.get('date').strftime('%Y-%m-%d') if voucher_info.get('date') else '', 'entries': len(voucher_info.get('entries', [])), 'total_debit': round(sum(e.get('debit', 0) or 0 for e in voucher_info.get('entries', [])), 2), 'total_credit': round(sum(e.get('credit', 0) or 0 for e in voucher_info.get('entries', [])), 2), 'output': os.path.basename(output_path) if output_path else '', 'processed_at': __import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S'), } save_index(idx) def is_processed(filename): idx = load_index() return filename in idx # ======== 单张凭证处理 ======== def process_single(image_path, config, output_path=None, verbose=False, mark_done=True): """处理单张凭证照片 流程: 原图 → PaddleOCR → 解析 → 填Excel → 验证 Returns: (voucher_data, quality) """ filename = os.path.basename(image_path) if verbose: print(f"\n📷 {filename}") # 1. 读取图像 img = cv2.imread(image_path) if img is None: raise ValueError(f"无法读取图片: {image_path}") # 2. 原图直出 OCR(无预处理) ocr_config = config.get('ocr', {}) if verbose: print(f" 📋 OCR 识别中...") lines = run_ocr(img, ocr_config) if verbose: print(f" OCR: {len(lines)} 行文字") # 3. 解析 parser = K3VoucherParser(lines) voucher = parser.parse() if verbose: print(f" 公司: {voucher['company']}") print(f" 日期: {voucher['date'].strftime('%Y-%m-%d') if voucher['date'] else '未识别'}") print(f" 凭证: {voucher['voucher_word']}-{voucher['voucher_no']}") print(f" 分录: {len(voucher['entries'])} 行") for e in voucher['entries']: dr = f"借 {e['debit']:.2f}" if e['debit'] else "" cr = f"贷 {e['credit']:.2f}" if e['credit'] else "" print(f" {e['account_code']} {e['account_name']:20s} {dr:>12s} {cr:>12s}") # 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']) if cn_part: ok = amount_check(total_max, cn_part) if verbose: status = '✅' if ok else '❌' print(f" 大小写校验: {status} 小写={total_max:.2f} 大写={cn_part}") # 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') paths_config = config.get('paths', {}) template = os.path.expanduser(paths_config.get('template', '~/.hermes/cache/documents/doc_704eac18442d_凭证模板.xls')) fill_template(voucher, template, output_path, verbose=verbose) # 6. 标记已处理 if mark_done: mark_processed(filename, voucher, output_path) return voucher, None # ======== 状态报告 ======== def show_status(config): paths_config = config.get('paths', {}) input_dir = os.path.expanduser(paths_config.get('input_dir', 'input')) done_dir = os.path.expanduser(paths_config.get('done_dir', 'input/done')) idx = load_index() exts = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp') pending_files = sorted([f for f in os.listdir(input_dir) if f.lower().endswith(exts)]) if os.path.exists(input_dir) else [] done_files = sorted([f for f in os.listdir(done_dir) if f.lower().endswith(exts)]) if os.path.exists(done_dir) else [] processed = set(idx.keys()) print("📊 KOCR v4 处理状态") print(f" {'='*40}") print(f" 索引记录: {len(processed)} 张已处理") print(f" input/ 待处理: {len(pending_files)} 张") print(f" done/ 已归档: {len(done_files)} 张") print() if idx: print(" 最近处理的:") sorted_items = sorted( idx.items(), key=lambda x: (x[1].get('date', ''), x[1].get('voucher_no', 0)), reverse=True )[:10] for fname, info in sorted_items: v = f"{info.get('voucher_word', '记')}-{info.get('voucher_no', '?')}" d = info.get('date', '') e = info.get('entries', 0) t = info.get('processed_at', '') print(f" {fname:30s} → {v:>8s} {d} {e}笔分录 {t}") if pending_files: new_pending = [f for f in pending_files if f not in processed] if new_pending: print(f"\n 📋 未处理的新照片 ({len(new_pending)} 张):") for f in new_pending[:10]: print(f" {f}") if len(new_pending) > 10: print(f" ... 还有 {len(new_pending)-10} 张") print() # ======== 主入口 ======== def main(): parser = argparse.ArgumentParser(description='KOCR v4 — 金蝶 K3 凭证 OCR 填表工具') parser.add_argument('input', nargs='?', default=None, help='凭证照片路径(留空则批量处理 input 目录)') parser.add_argument('--output', '-o', default=None, help='输出 Excel 路径') parser.add_argument('--config', '-c', default=DEFAULT_CONFIG, help='配置文件路径') parser.add_argument('--verbose', '-v', action='store_true', help='显示详细结果') parser.add_argument('--status', '-s', action='store_true', help='显示处理进度') parser.add_argument('--new', '-n', action='store_true', help='只处理未识别过的新照片') parser.add_argument('--archive', '-a', action='store_true', help='归档已处理的照片到 done/') parser.add_argument('--review', '-r', action='store_true', help='生成数据校验报告') parser.add_argument('--merge', '-m', action='store_true', help='合并输出汇总 Excel') parser.add_argument('--force', '-f', action='store_true', help='强制重新处理所有图片') args = parser.parse_args() # 加载配置 config = load_config(args.config) # 路径 paths_config = config.get('paths', {}) input_dir = os.path.expanduser(paths_config.get('input_dir', BATCH_INPUT_DIR)) output_dir = os.path.expanduser(paths_config.get('output_dir', BATCH_OUTPUT_DIR)) done_dir = os.path.expanduser(paths_config.get('done_dir', BATCH_INPUT_DONE)) template_path = os.path.expanduser(paths_config.get('template', '~/.hermes/cache/documents/doc_704eac18442d_凭证模板.xls')) # 确保目录存在 for d in [input_dir, done_dir, output_dir]: os.makedirs(d, exist_ok=True) if not os.path.exists(template_path): print(f"❌ 找不到模板: {template_path}") sys.exit(1) # ---- 状态查看 ---- if args.status: show_status(config) return # ---- 归档 ---- if args.archive: idx = load_index() if not os.path.exists(input_dir): print("📭 input 目录不存在") return moved = 0 for fname in os.listdir(input_dir): if fname in idx: src = os.path.join(input_dir, fname) dst = os.path.join(done_dir, fname) os.rename(src, dst) moved += 1 print(f"📦 已归档 {moved} 张照片到 input/done/") return # ---- 确定文件列表 ---- input_path = args.input or input_dir if os.path.isdir(input_path): # 批量模式 exts = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp') files = sorted([f for f in os.listdir(input_path) if f.lower().endswith(exts)]) if not files: print(f"📭 {input_path} 中没有图片文件") sys.exit(0) # --new 模式过滤 idx = load_index() if args.new or not args.force: new_files = [f for f in files if f not in idx] skipped = len(files) - len(new_files) files = new_files if skipped > 0: print(f"⏭️ 跳过 {skipped} 张已处理过的照片") if not files: print("✅ 全部照片都已处理过了,无需重复识别") return print(f"📂 KOCR v4 — 批量处理: {len(files)} 张凭证") print(f" 输入: {input_path}") print(f" 输出: {output_dir}") print(f" {'='*40}") results = [] for i, fname in enumerate(files): fpath = os.path.join(input_path, fname) basename = os.path.splitext(fname)[0] out_path = os.path.join(output_dir, f'{basename}_K3.xls') print(f" [{i+1}/{len(files)}] {fname}") try: voucher, quality = process_single(fpath, config, out_path, verbose=args.verbose) 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" ✅ ['raw'] {vdate} 记-{vno} → {os.path.basename(out_path)}") except Exception as e: print(f" ❌ 失败: {e}") print() # ---- 合并凭证数据(多页合并)+ 统一校验 ---- merged_vouchers = merge_voucher_data(results) # ---- 科目名称归一化:同一代码取最高频名称 ---- # from collections import Counter code_name_counter = Counter() code_name_votes = {} for v in merged_vouchers: for e in v['entries']: c = e['account_code'] n = e['account_name'] if n and len(n) >= 2: code_name_counter[(c, n)] += 1 # 每个代码选最高频名称 code_best_name = {} for (c, n), cnt in code_name_counter.items(): if c not in code_best_name or cnt > code_best_name[c][1]: code_best_name[c] = (n, cnt) # 输出差异 for v in merged_vouchers: for e in v['entries']: c = e['account_code'] if c in code_best_name: best_n, _ = code_best_name[c] if e['account_name'] != best_n: e['account_name'] = best_n # 写回流到 results(merge_output 用 results 写文件) for fname, voucher, out_path, quality in results: if voucher.get('entries'): for e in voucher['entries']: c = e['account_code'] if c in code_best_name: e['account_name'] = code_best_name[c][0] validation_results = validate_merged_vouchers( merged_vouchers, config.get('postprocessing', {}).get('amount_balance_tolerance', 0.01) ) clean_count = sum(1 for _, is_clean, _, _ in validation_results if is_clean) multi_count = sum(1 for v in merged_vouchers if v.get('is_multi_page')) print(f" {'='*40}") print(f" 📋 合并后校验结果({len(merged_vouchers)} 笔凭证,含 {multi_count} 笔多页):") for voucher, is_clean, issues, summary in validation_results: date_str = voucher['date'].strftime('%m-%d') if voucher.get('date') else '??' vno = voucher.get('voucher_no', '?') multi = '📑' if voucher.get('is_multi_page') else ' ' entries = len(voucher['entries']) status = '✅' if is_clean else '⚠️' print(f" {status} {multi} {date_str} 记-{vno} ({entries}条分录)") if not is_clean: for iss in issues[:3]: print(f" {iss}") print(f" {'='*40}") print(f" ✅ 校验通过: {clean_count} 笔") print(f" ⚠️ 需人工复核: {len(validation_results) - clean_count} 笔") # ---- 合并输出 ---- if args.merge and len(results) > 1: merge_path = merge_output(results, template_path, output_dir) print(f"\n 📊 已生成: {merge_path}") # ---- 校验报告(基于合并后的数据) ---- if args.review and results: review_results = [] for voucher, is_clean, issues, summary in validation_results: fname = ' + '.join(voucher.get('pages', ['unknown'])) review_results.append((fname, voucher, '', None)) review_path = generate_review_file(review_results, output_dir) print(f" 📋 校验报告: {os.path.basename(review_path)}") # ---- 自动归档 ---- archived = 0 for fname, voucher, out_path, quality in results: src = os.path.join(input_path, fname) dst = os.path.join(done_dir, fname) if os.path.exists(src): os.rename(src, dst) archived += 1 if archived > 0: print(f" 📦 已归档 {archived} 张照片到 done/") print(f"🎉 完成! {len(results)}/{len(files)} 成功") else: # 单文件模式 if not os.path.exists(input_path): print(f"❌ 找不到图片: {input_path}") sys.exit(1) if args.output: out_path = args.output else: basename = os.path.splitext(os.path.basename(input_path))[0] out_path = os.path.join(output_dir, f'{basename}_K3.xls') voucher, quality = process_single(input_path, config, out_path, verbose=args.verbose) results = [(os.path.basename(input_path), voucher, out_path, quality)] if args.review: review_path = generate_review_file(results, output_dir) print(f" 📋 校验报告: {os.path.basename(review_path)}") if __name__ == '__main__': main()