From 1bcda1ab8cc2c5a5572159d706aea5fc5c18094e Mon Sep 17 00:00:00 2001 From: xiaoxue_admin <9283713@qq.com> Date: Mon, 22 Jun 2026 17:40:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=A8=A1=E6=9D=BF=E5=8F=AA=E6=9C=891?= =?UTF-8?q?=E9=A1=B5=E5=AF=BC=E8=87=B4list=20index=20out=20of=20range?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=20-=20=E9=87=8D=E7=94=9F=E6=88=90=E6=A0=87?= =?UTF-8?q?=E5=87=86=E5=8F=8C=E9=A1=B5=E6=A8=A1=E6=9D=BF=EF=BC=88t=5FSchem?= =?UTF-8?q?a=20+=20Page1=EF=BC=89=20-=20merge=5Foutput=20=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=88=97=E7=B4=A2=E5=BC=95=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E6=80=A7=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.yaml | 97 +----- core/excel_writer.py | 4 +- core/parser.py | 705 ++++++++++++++++++++----------------------- core/recognizer.py | 249 ++++----------- kocr.py | 62 +--- 凭证模板.xls | Bin 0 -> 9728 bytes 6 files changed, 414 insertions(+), 703 deletions(-) create mode 100644 凭证模板.xls diff --git a/config.yaml b/config.yaml index cb58533..e8cbcac 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/core/excel_writer.py b/core/excel_writer.py index 26ee654..429b002 100644 --- a/core/excel_writer.py +++ b/core/excel_writer.py @@ -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)) diff --git a/core/parser.py b/core/parser.py index 0689e82..96df0dd 100644 --- a/core/parser.py +++ b/core/parser.py @@ -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):公司名、日期、凭证号、摘要 + - 科目区(y≈1200-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、记-45(1/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_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 diff --git a/core/recognizer.py b/core/recognizer.py index 287e23c..7f1ff08 100644 --- a/core/recognizer.py +++ b/core/recognizer.py @@ -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: dict(PaddleOCR 参数) - 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 '' in html and html.count('') >= 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 diff --git a/kocr.py b/kocr.py index fff41fd..d12cbb8 100644 --- a/kocr.py +++ b/kocr.py @@ -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() diff --git a/凭证模板.xls b/凭证模板.xls new file mode 100644 index 0000000000000000000000000000000000000000..4afb65cbb224c25cd22569340a5b05c4abfaae21 GIT binary patch literal 9728 zcmeI2Yit}>701u4y${>Fw#Tm$1F;Eh+9rW!?KW|N#7XR|yDiz+>!htHvYFUJY`WRC zHtQ&+Qs{#Sp{i;t=mP{)mLjBnX+cPBqoRmP2=M_alzu>}gp^S62~mMmC_*jf|35Q( zXLd?eB1A=`9cyRif9|>WckelO?mXmQU$if+|9J4SM7F)sBLCcIlSIgF;oPq4ZbA9R z24(#Slfcy6uA&IErI58mTwc!nD#=_j-7SKP0-feu`OEG ziM=H+YMs~Bl9hh>C44r%_86x-O5?J>y6lwxKMOXursbRV%iQ2yvYZbX)Hok-@XvtaWmT&8do08i6?81_J z!Z|A$d9a7A#AVt!?q=VS*JI}(XVfIQBWo)z8p++^uC?QkTXsu}1?PmDf|zhtYRi=} z?RPq*RhPbP5Czv=oOMp=`9!6ZFE6^MGqBHA7mhf`N-pceZ=&R!JeeRb<(LhQ0m2!{3QA@oE;e@qIme&SL(Ql)oRr(FRWpV*4*|YNe53aEIH-lZqcc^ zFi(}faWW1wnxy=LJ9HeAIyY6Vtgd8R#7Y!@ekRL#?wLk>(+GkhI$|CVR0|bUP84r) zaqaFzAZzpantK9k$jDAuviObQH=2g?!P6@xr|d91sB`z?1K)((F1p>!aP41MsH~Q2 z`AHPMy7uNN*l*#mCf%B|T*^--#2Qb|w7-#nFGwCtIG=v+tJy#3p9?R$v@4!@-V}Q)4{{*57*{?IOm@Z9e?Wy#E%Yl-Q%CU zJ&Qt)^NoUa+CR-4`STi1HDfqM`$N;`iQzbK*i}3_i^KXoV@o)UE&XB!hkwlcy04vi>b~=C*E}ebPyVgIiHh_boqu#rfrEwhqTff3=jiOA!-$M{_oVKIds0SM z>NNI?(xvCNuGig?l6Ri{%Wn=Hn;xH6%l)u$L!9M0US6^x53mL*cd|gP(LKGkX^ri= ze~>=%Q9U2jeII5Q!nt+2-#^Jcq&{EQ(yic(>h{|B^`Y|{aDMiWGcRpE`^NZlzw`|bF;Y>_tf-qOE^WQVoG`tEnQogc3sE!?X>l;7#zyVHg5aj6)eu>`H^gBJ$b zCGQGjR(~?{l!fOsj^9%;D7NaU7AQQ-?ys{(|rlznDY&+Tq z+H6=oe~!||c+tfet`+cYZN~!J)*fHLz1#xZ_WzRFGW!{AnYN&1X2}*@Y}#_nv>elx zacK)%j=iU4jyc*gZ9&VJrBGO%w5KiFGSvkv%Prb^V_zA!Xp6K3En>Puan>=0t=eLs z7>O=zpJiSo;=U(iPftd#6>&$Kh&yu=CDIlU<4WW#A(8XfM(^v2L_Wb^(6TTwj!b)= zB*qPq`HvE53y93Fjd|-a5=smdiM>9NBO%`#V?v4aBr#!#I>#dOo3?!Lv zF=dSZC!rYsjNxaLf35Kf^SPcx`LqRmuE@4r=hx;c8dl_QHD%%acN%`D@~<~uVLsQo zD4(`~-=+K;{Mx+F+Y%`9Z!~3LewX2QDSw;s3iG)RM)|Y_eBRk?+3wfwR(_z!XMdUh z?uPtxW%lNOxAJ!wuP~o0Zhb%Pf2;8d^SP2m`LqT60p)+juRWmrK#~7h zQx+cofZ-1)|8vHx&fgpBkr((NC;PbbH{N~z*H8O=E)~6ZUae32n<7s`9FHw~u;)y` z6$rK0C|)z;MlommjAG9A8^t+uz$nh!38R>|`dY_XW8)>a&TE4@XcX6?oKYQ6TZ~FW zO&OJey3MFgsJu~KP`4Y^4Rwc6T=hP0R1ef4qk5qVM)g6}R|vs1V#{GuHUKqk)HP7` zxh#0<@>W)zrSN(0ELCH3*Ni|j0?i0CBhZXMGXl*BG$YWAz*Qarz8%Hz