#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Kimi 视觉兜底识别:用于 pdftotext/Mineru 对局部字段解析不稳定时,人工指定 PDF 图片页进行辅助抽取。 默认只输出 JSON 文件,不直接覆盖 PostgreSQL。 """ from __future__ import annotations import argparse import base64 import json import mimetypes import os import subprocess import sys import tempfile import urllib.error import urllib.request from datetime import datetime from pathlib import Path from typing import Any PROJECT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_IMAGE_ROOT = PROJECT_ROOT / "数据处理结果区" / "06_PDF图片对照" / "图片" DEFAULT_PDF_ROOT = PROJECT_ROOT / "已处理-患者首页PDF" / "2026_5_25_处理" DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "07_Kimi视觉识别" DEFAULT_API_BASE = "https://api.moonshot.cn/v1" DEFAULT_MODEL = os.environ.get("KIMI_MODEL", "kimi-k2.6") PROMPT = """请读取这张住院病案首页图片,仅返回 JSON。 优先核对这些区域:基本信息、地址联系人、入出院信息、诊断、手术及操作、质控签名、离院方式、费用。 如果是手术及操作区域,请按以下列拆分: 手术操作编码、手术操作日期、手术级别、手术操作名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师。 无法确认的字段填空字符串,不要编造。输出结构: { "page_summary": "", "suspected_missing_fields": [], "fields": {}, "discharge_diagnoses": [], "operations": [], "fees": {}, "notes": [] } """ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="使用 Kimi 视觉模型辅助识别患者首页图片") parser.add_argument("--image-root", type=Path, default=DEFAULT_IMAGE_ROOT, help="PDF 转图片根目录") parser.add_argument("--pdf-root", type=Path, default=DEFAULT_PDF_ROOT, help="没有图片目录时,从该 PDF 目录临时渲染页面") parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Kimi 识别结果输出目录") parser.add_argument("--case", help="只处理指定病例目录名,例如 ZY010001672803_ori") parser.add_argument("--page", help="只处理指定图片名,例如 page-001.png") parser.add_argument("--pdf-page", type=int, help="从 PDF 临时渲染时,只处理指定页码,从 1 开始") parser.add_argument("--dpi", type=int, default=160, help="PDF 临时渲染分辨率") parser.add_argument("--model", default=DEFAULT_MODEL, help="Kimi 模型名") parser.add_argument("--api-base", default=os.environ.get("MOONSHOT_API_BASE", DEFAULT_API_BASE), help="Moonshot API base URL") parser.add_argument("--api-key", default=os.environ.get("MOONSHOT_API_KEY") or os.environ.get("KIMI_API_KEY"), help="API Key,也可用 MOONSHOT_API_KEY") return parser.parse_args() def image_to_data_url(path: Path) -> str: mime_type, _ = mimetypes.guess_type(path.name) if mime_type not in {"image/png", "image/jpeg", "image/webp", "image/gif"}: mime_type = "image/png" encoded = base64.b64encode(path.read_bytes()).decode("utf-8") return f"data:{mime_type};base64,{encoded}" def call_kimi(image_path: Path, args: argparse.Namespace) -> dict[str, Any]: if not args.api_key: raise RuntimeError("未设置 MOONSHOT_API_KEY/KIMI_API_KEY,无法调用 Kimi API。") payload = { "model": args.model, "messages": [ {"role": "system", "content": "你是严谨的病案首页结构化抽取助手,只输出 JSON。"}, { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": image_to_data_url(image_path)}}, {"type": "text", "text": PROMPT}, ], }, ], } request = urllib.request.Request( f"{args.api_base.rstrip('/')}/chat/completions", data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {args.api_key}", }, method="POST", ) try: with urllib.request.urlopen(request, timeout=180) as response: data = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"Kimi API 返回错误 {exc.code}: {detail}") from exc content = data["choices"][0]["message"]["content"] parsed = parse_json_content(content) return { "image": str(image_path), "model": args.model, "recognized_at": datetime.now().isoformat(timespec="seconds"), "raw_response": content, "parsed": parsed, } def parse_json_content(content: str) -> Any: text = content.strip() if text.startswith("```"): text = text.strip("`") if text.startswith("json"): text = text[4:].strip() try: return json.loads(text) except json.JSONDecodeError: start = text.find("{") end = text.rfind("}") if start >= 0 and end > start: return json.loads(text[start : end + 1]) return {"notes": ["Kimi 返回内容不是合法 JSON"], "text": content} def iter_images(args: argparse.Namespace) -> list[Path]: root = args.image_root if args.case: case_dir = root / args.case if args.page: return [case_dir / args.page] return sorted(case_dir.glob("page-*.*")) images: list[Path] = [] for case_dir in sorted(path for path in root.iterdir() if path.is_dir()): images.extend(sorted(case_dir.glob("page-*.*"))) return images def find_case_pdf(args: argparse.Namespace) -> Path | None: if not args.case: return None stem = Path(args.case).stem candidates = [ args.pdf_root / f"{stem}.pdf", args.pdf_root / args.case, ] return next((path for path in candidates if path.exists() and path.is_file()), None) def render_pdf_pages(pdf_path: Path, output_root: Path, args: argparse.Namespace) -> list[Path]: case_dir = output_root / pdf_path.stem case_dir.mkdir(parents=True, exist_ok=True) if args.pdf_page: prefix = case_dir / f"page-{args.pdf_page:03d}" command = [ "pdftoppm", "-png", "-r", str(args.dpi), "-f", str(args.pdf_page), "-singlefile", str(pdf_path), str(prefix), ] else: command = ["pdftoppm", "-png", "-r", str(args.dpi), str(pdf_path), str(case_dir / "page")] subprocess.run(command, check=True) return sorted(case_dir.glob("page-*.png")) or sorted(case_dir.glob("page.png")) def main() -> int: args = parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) images = [path for path in iter_images(args) if path.exists() and path.is_file()] with tempfile.TemporaryDirectory(prefix="frontpage_kimi_") as temp_dir: if not images: pdf_path = find_case_pdf(args) if pdf_path: images = render_pdf_pages(pdf_path, Path(temp_dir), args) if not images: raise SystemExit("未找到待识别图片,也未找到可临时渲染的 PDF。") for image_path in images: print(f"识别:{image_path}") result = call_kimi(image_path, args) out_path = args.output_dir / f"{image_path.parent.name}_{image_path.stem}.json" out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") print(f"输出:{out_path}") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: # noqa: BLE001 print(f"错误:{exc}", file=sys.stderr) raise SystemExit(1)