#!/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 json 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.preprocessor import analyze_image, VoucherPreprocessor 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 # ======== 科目代码标准映射表 ======== CODE_MAP_PATH = os.path.join(PROJECT_DIR, 'code_map.json') _code_map_cache = None def get_code_map(): global _code_map_cache if _code_map_cache is not None: return _code_map_cache if os.path.exists(CODE_MAP_PATH): with open(CODE_MAP_PATH, 'r', encoding='utf-8') as f: _code_map_cache = json.load(f) else: _code_map_cache = {} return _code_map_cache def update_code_map(new_entries: dict): """更新映射表(用于后续完善)""" current = get_code_map() current.update(new_entries) with open(CODE_MAP_PATH, 'w', encoding='utf-8') as f: json.dump(current, f, ensure_ascii=False, indent=2) global _code_map_cache _code_map_cache = current def get_reverse_code_map(): """名称 → 代码 反向映射(精确匹配)""" code_map = get_code_map() return {v: k for k, v in code_map.items()} def fuzzy_find_code(name_or_fragment: str): """根据名称部分字段模糊查找代码 匹配规则:名称包含该字段,或字段包含该名称 返回最长的匹配项(最具体) """ code_map = get_code_map() name_or_fragment = name_or_fragment.strip() if not name_or_fragment or len(name_or_fragment) < 2: return None best_code = None best_len = 0 for code, name in code_map.items(): if name_or_fragment in name or name in name_or_fragment: if len(name) > best_len: best_code = code best_len = len(name) return best_code # ======== 路径常量 ======== 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 # ======== PDF 页面提取 ======== def extract_pdf_pages(pdf_path, output_dir): """将 PDF 每页转换为 JPG 图片 直接输出到 output_dir,命名格式:_{pdf_name}_p{n}.jpg 返回 [(page_path, page_label), ...] """ import subprocess import re pdf_name = os.path.splitext(os.path.basename(pdf_path))[0] # pdftoppm: 每页生成 {prefix}-{页码}.jpg result = subprocess.run( ['pdftoppm', '-jpeg', '-r', '300', pdf_path, os.path.join(output_dir, pdf_name)], capture_output=True, text=True, timeout=120 ) if result.returncode != 0: raise RuntimeError(f"pdftoppm 失败: {result.stderr}") # 收集生成的图片 → 重命名为 _{pdf_name}_p{n}.jpg pages = sorted([ f for f in os.listdir(output_dir) if f.startswith(pdf_name) and f.lower().endswith('.jpg') ]) result_list = [] for p in pages: old_path = os.path.join(output_dir, p) # 从文件名提取页码,如 test-01.jpg → _test_p1.jpg m = re.search(r'-(\d+)\.jpg$', p) page_num = int(m.group(1)) if m else 0 new_name = f"_{pdf_name}_p{page_num}.jpg" new_path = os.path.join(output_dir, new_name) os.rename(old_path, new_path) # 统一图片尺寸:缩放+白边到 3072×4096(对齐照片坐标) # 解析器硬编码了 x/y 坐标阈值(适配 3072×4096 的照片) # PDF 图片尺寸和比例不同,必须统一到照片尺寸才不走样 try: import cv2 img = cv2.imread(new_path) if img is not None and (img.shape[1] != 3072 or img.shape[0] != 4096): h, w = img.shape[:2] # 计算缩放比例(保持宽高比,适配目标尺寸) scale = min(3072 / w, 4096 / h) new_w = int(w * scale) new_h = int(h * scale) # 缩放 resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_CUBIC) # 中心对齐,加白边补齐到 3072×4096 pad_top = (4096 - new_h) // 2 pad_bottom = 4096 - new_h - pad_top pad_left = (3072 - new_w) // 2 pad_right = 3072 - new_w - pad_left padded = cv2.copyMakeBorder(resized, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_CONSTANT, value=[255, 255, 255]) cv2.imwrite(new_path, padded) except Exception as e: pass # 缩放失败不影响主流程 page_label = f"第{page_num}页" if m else os.path.basename(pdf_path) result_list.append((new_path, page_label)) return result_list def cleanup_pdf_temp_files(input_path): """清理 PDF 提取的临时图片(以下划线开头的 JPG)""" for f in os.listdir(input_path): if f.startswith('_') and f.lower().endswith('.jpg'): try: os.remove(os.path.join(input_path, f)) except OSError: pass # ======== 单张凭证处理 ======== 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}") ocr_config = config.get('ocr', {}) # 2. 双管线 OCR:原图 + 预处理图,合并结果 if verbose: print(f" 📊 分析图片质量...") quality = analyze_image(img) if verbose: print(f" 📊 照片类型: {quality.photo_type} (亮度={quality.brightness:.0f}, 阴影比={quality.shadow_ratio:.2f})") # 管线1: 原图OCR(保留横杠标记/续行结构) if verbose: print(f" 📋 管线1: 原图OCR...") lines1 = run_ocr(img, ocr_config, photo_type=quality.photo_type) # 管线2: 预处理后OCR(去阴影/纠偏/锐化,提高文字识别率) preprocess_config = config.get('preprocessing', {}) preprocessor = VoucherPreprocessor.from_config(preprocess_config, quality.photo_type) img_processed = preprocessor.process(img) if verbose: print(f" 📋 管线2: 预处理图OCR...") lines2 = run_ocr(img_processed, ocr_config, photo_type=quality.photo_type) # 合并双管线结果:按 y 坐标邻近去重 all_lines = lines1 + lines2 # 按 y 排序后,合并相近位置的重复文本 all_lines.sort(key=lambda l: (l[3], l[2])) merged_lines = [] for line in all_lines: is_dup = False for existing in merged_lines: if abs(line[3] - existing[3]) < 20 and abs(line[2] - existing[2]) < 50: # 同位置,保留文本更长的 if len(line[0].strip()) > len(existing[0].strip()): existing = line is_dup = True break if not is_dup: merged_lines.append(line) if verbose: print(f" 📋 双管线合并: {len(lines1)}+{len(lines2)} → {len(merged_lines)} 行去重") # 3. 解析(使用双管线合并后的数据) parser = K3VoucherParser(merged_lines) voucher = parser.parse() # 3.5 双向对照表校正(代码→名称 + 名称→代码反查) CODE_MAP = get_code_map() REVERSE_MAP = get_reverse_code_map() corrections = 0 for e in voucher['entries']: code = e.get('account_code', '') name = e.get('account_name', '') # 正向:代码在映射表中 → 用标准名称 if code in CODE_MAP: std_name = CODE_MAP[code] if name != std_name: if verbose: print(f" 🔄 名称修正: {code} [{name}] → [{std_name}]") e['account_name'] = std_name corrections += 1 # 反向:名称精确匹配 → 用标准代码 if name in REVERSE_MAP: std_code = REVERSE_MAP[name] if code != std_code: if verbose: print(f" 🔄 代码修正: [{code}] → [{std_code}] (名称={name})") e['account_code'] = std_code corrections += 1 # 模糊反向:代码不在映射表但名称部分匹配 → 反查 if code not in CODE_MAP and name: fuzzy_code = fuzzy_find_code(name) if fuzzy_code: std_name = CODE_MAP[fuzzy_code] if verbose: print(f" 🔄 模糊反查: [{code},{name}] → [{fuzzy_code},{std_name}]") e['account_code'] = fuzzy_code e['account_name'] = std_name corrections += 1 if verbose and corrections > 0: print(f" ✅ 对照表双向修正: {corrections} 处") 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 [] pdf_files = sorted([f for f in os.listdir(input_dir) if f.lower().endswith('.pdf')]) 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)} 张图片") if pdf_files: print(f" input/ 待转换 PDF: {len(pdf_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): # 批量模式 img_exts = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp') files = sorted([f for f in os.listdir(input_path) if f.lower().endswith(img_exts)]) # PDF 文件检测与提取 pdf_files = sorted([f for f in os.listdir(input_path) if f.lower().endswith('.pdf')]) if pdf_files: print(f"📄 发现 {len(pdf_files)} 个 PDF 文件,正在提取页面...") for pdf_file in pdf_files: pdf_path = os.path.join(input_path, pdf_file) try: pages = extract_pdf_pages(pdf_path, input_path) # 将页面图片加入待处理列表 page_fnames = [os.path.basename(p) for p, _ in pages] files.extend(page_fnames) print(f" 📄 {pdf_file} → {len(pages)} 页") except Exception as e: print(f" ❌ {pdf_file} 提取失败: {e}") if not files: print(f"📭 {input_path} 中没有图片或 PDF 文件") sys.exit(0) # --new 模式过滤(PDF 临时页面以下划线开头,始终视为新文件) idx = load_index() if args.new or not args.force: new_files = [f for f in files if f not in idx or f.startswith('_')] 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) and not fname.startswith('_'): os.rename(src, dst) archived += 1 if archived > 0: print(f" 📦 已归档 {archived} 张照片到 done/") # ---- 清理 PDF 临时页面图片 ---- cleanup_pdf_temp_files(input_path) 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()