60 ScansProcessing: PDF náhled, fuzzy RČ, Medicus podle počítače

- Nová knihovna Knihovny/najdi_medicus.py — připojení k Medicus podle hostname
  (LEKAR=localhost/M:, SESTRA+LENOVO=192.168.1.10/M:, ostatní=localhost/C:)
- Fuzzy matching RČ rozšířen o vkládání nuly (oprava sekvencí 00→0)
- PDF náhled jako samostatný subprocess (preview_viewer.py, pymupdf)
  otevře se před OCR, zůstane do přejmenování, pracuje s temp kopií
- Prompt: poznámka čerpá přednostně ze sekce Závěr: zprávy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
michaela.buzalkova
2026-04-22 09:08:34 +02:00
parent 3aca4f5772
commit a1cde8b471
3 changed files with 328 additions and 74 deletions
+170 -74
View File
@@ -13,6 +13,7 @@ import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
@@ -121,18 +122,23 @@ def _lookup_by_rc(cur, rc_digits: str) -> dict | None:
def _rc_candidates(rc: str) -> list[str]:
"""
Generuje kandidáty RČ pro fuzzy matching (edit distance 1):
- vynechání každé cifry (oprava extra znaku z OCR)
Generuje kandidáty RČ pro fuzzy matching:
- vynechání každé cifry (OCR přečetlo znak navíc)
- vložení nuly na každou pozici (OCR přehlédlo nulu v sekvenci 00)
- záměna podobně vypadajících číslic na každé pozici
Vrátí unikátní seznam kandidátů bez původního RČ.
"""
similar = {"0": "8", "8": "0", "1": "7", "7": "1", "5": "6", "6": "5", "3": "8"}
candidates = set()
# Vynechání jedné cifry (nejčastější OCR chyba — přebývající nula)
# Vynechání jedné cifry (OCR přečetlo znak navíc)
for i in range(len(rc)):
candidates.add(rc[:i] + rc[i+1:])
# Vložení nuly na každou pozici (nejčastější chyba: sekvence 00 přečtena jako 0)
for i in range(len(rc) + 1):
candidates.add(rc[:i] + "0" + rc[i:])
# Záměna podobné cifry na každé pozici
for i, ch in enumerate(rc):
if ch in similar:
@@ -235,7 +241,10 @@ def extract_patient_info(pdf_path: str) -> dict:
"\"PZ {oddělení}\" = propouštěcí zpráva z hospitalizace (např. \"PZ interna\", \"PZ neurologie\"). "
"Jiné typy: \"Laboratoř\", \"CT břicha\", \"MRI páteře\", \"kolonoskopie\", "
"\"operační protokol oční\", \"poukaz FT\", \"diagnostická mamografie\" atd.\n"
"- \"poznamka\": krátká klinická poznámka česky, max 80 znaků\n"
"- \"poznamka\": krátká klinická poznámka česky, max 80 znaků. "
"DŮLEŽITÉ: pokud zpráva obsahuje sekci \"Závěr:\" nebo \"Závěr vyšetření:\", "
"použij VÝHRADNĚ obsah této sekce — je nejdůležitější. "
"Teprve pokud závěr chybí, shrň obsah z celé zprávy.\n"
"- \"nazev_souboru\": název souboru ve formátu "
"\"{rodne_cislo} {datum_zpravy} {Příjmení}, {Jméno} [{typ_dokumentu}] [{poznamka}].pdf\" "
"(jméno bez titulu, RČ bez lomítka)\n"
@@ -281,6 +290,95 @@ def sanitize_filename(name: str) -> str:
return re.sub(r'[<>:"/\\|?*]', '', name)
def _open_preview(root, pdf_path: Path):
"""Otevře náhledové okno PDF/obrázku jako Toplevel. Pracuje s temp kopií — žádné zamykání originálu."""
import tkinter as tk
import tempfile
import shutil as _shutil
try:
from PIL import Image, ImageTk
import fitz
except ImportError:
return
# Temp kopie — prohlížeč nikdy nesahá na originál
tmp = Path(tempfile.mktemp(suffix=pdf_path.suffix))
_shutil.copy2(pdf_path, tmp)
suffix = pdf_path.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
pil_pages = [Image.open(tmp)]
doc = None
else:
try:
doc = fitz.open(str(tmp))
except Exception:
tmp.unlink(missing_ok=True)
return
pil_pages = []
def render(n) -> Image.Image:
if doc is not None:
page = doc[n]
zoom = min(700 / page.rect.width, (sh - 150) / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
else:
img = pil_pages[0].copy()
img.thumbnail((700, sh - 150), Image.LANCZOS)
return img
def on_close():
try:
if doc:
doc.close()
except Exception:
pass
tmp.unlink(missing_ok=True)
win.destroy()
page_count = len(doc) if doc else 1
sh = root.winfo_screenheight()
current = [0]
photo_ref = [None]
win = tk.Toplevel(root)
win.title(pdf_path.name)
win.attributes("-topmost", True)
win.resizable(False, False)
win.protocol("WM_DELETE_WINDOW", on_close)
lbl_img = tk.Label(win)
lbl_img.pack()
frame_nav = tk.Frame(win)
frame_nav.pack(pady=4)
lbl_page = tk.Label(frame_nav, font=("Segoe UI", 9))
lbl_page.pack(side="left", padx=10)
def show(n):
current[0] = n
img = render(n)
photo_ref[0] = ImageTk.PhotoImage(img)
lbl_img.config(image=photo_ref[0])
lbl_page.config(text=f"Strana {n + 1} / {page_count}")
btn_prev.config(state="normal" if n > 0 else "disabled")
btn_next.config(state="normal" if n < page_count - 1 else "disabled")
btn_prev = tk.Button(frame_nav, text="◄ Předchozí",
command=lambda: show(current[0] - 1))
btn_prev.pack(side="left")
btn_next = tk.Button(frame_nav, text="Další ►",
command=lambda: show(current[0] + 1))
btn_next.pack(side="left")
show(0)
win.update_idletasks()
win.geometry(f"+0+0")
def _rename_dialog(nazev: str, info_lines: list[str]) -> str | None:
"""
Tkinter dialog pro schválení / opravu názvu souboru.
@@ -291,15 +389,18 @@ def _rename_dialog(nazev: str, info_lines: list[str]) -> str | None:
result = {"value": None}
root = tk.Tk()
root.withdraw()
root.tk.call("encoding", "system", "utf-8")
root.title("Schválení názvu souboru")
root.resizable(True, False)
root.attributes("-topmost", True)
dlg = tk.Toplevel(root)
dlg.title("Schválení názvu souboru")
dlg.resizable(True, False)
dlg.attributes("-topmost", True)
pad = {"padx": 12, "pady": 6}
# Informační sekce
frame_info = tk.Frame(root, bg="#f0f0f0", bd=1, relief="sunken")
frame_info = tk.Frame(dlg, bg="#f0f0f0", bd=1, relief="sunken")
frame_info.pack(fill="x", **pad)
for line in info_lines:
color = "#b00000" if line.startswith("") else "#004080" if line.startswith("") else "#333"
@@ -307,18 +408,18 @@ def _rename_dialog(nazev: str, info_lines: list[str]) -> str | None:
fg=color, font=("Segoe UI", 10)).pack(fill="x", padx=8, pady=1)
# Pole pro název (bez .pdf)
tk.Label(root, text="Název souboru (bez .pdf):", anchor="w",
tk.Label(dlg, text="Název souboru (bez .pdf):", anchor="w",
font=("Segoe UI", 9, "bold")).pack(fill="x", padx=12, pady=(10, 2))
nazev_bez = nazev[:-4] if nazev and nazev.endswith(".pdf") else (nazev or "")
var = tk.StringVar(value=nazev_bez)
entry = tk.Entry(root, textvariable=var, font=("Segoe UI", 10), width=90)
entry = tk.Entry(dlg, textvariable=var, font=("Segoe UI", 10), width=90)
entry.pack(fill="x", padx=12, pady=(0, 10))
entry.icursor(tk.END)
entry.focus_set()
# Tlačítka
frame_btn = tk.Frame(root)
frame_btn = tk.Frame(dlg)
frame_btn.pack(pady=(0, 12))
def schvalit(event=None):
@@ -336,14 +437,18 @@ def _rename_dialog(nazev: str, info_lines: list[str]) -> str | None:
bg="#7a2a2a", fg="white", font=("Segoe UI", 10),
padx=16, pady=6).pack(side="left", padx=8)
root.bind("<Return>", schvalit)
root.bind("<Escape>", preskocit)
dlg.bind("<Return>", schvalit)
dlg.bind("<Escape>", preskocit)
# Vystředit okno na obrazovce
root.update_idletasks()
w, h = root.winfo_width(), root.winfo_height()
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
root.geometry(f"+{(sw - w) // 2}+{(sh - h) // 2}")
# Umísti dialog vpravo od náhledu (nebo vystředit pokud náhled není)
dlg.update_idletasks()
sw = dlg.winfo_screenwidth()
sh = dlg.winfo_screenheight()
w = dlg.winfo_width()
h = dlg.winfo_height()
x = min(720, sw - w - 20)
y = (sh - h) // 2
dlg.geometry(f"+{x}+{y}")
root.mainloop()
return result["value"]
@@ -408,43 +513,8 @@ def interactive_rename(pdf_path: Path, info: dict, verif: dict) -> bool:
print(f" Navržený název: {nazev}")
print(" Otevírám dialog...")
# Otevři PDF v prohlížeči (konkrétní proces, abychom zavřeli jen toto okno)
viewer_proc = None
try:
import subprocess
import winreg
import shlex
def _get_pdf_exe() -> list[str] | None:
try:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r".pdf") as k:
prog_id = winreg.QueryValue(k, "")
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, rf"{prog_id}\shell\open\command") as k:
cmd = winreg.QueryValue(k, "")
parts = shlex.split(cmd.replace("\\", "/"))
exe = parts[0].replace("/", "\\")
return [exe, str(pdf_path)]
except Exception:
return None
cmd = _get_pdf_exe()
if cmd:
viewer_proc = subprocess.Popen(cmd)
else:
subprocess.Popen(["start", "", str(pdf_path)], shell=True)
except Exception as e:
print(f" [Prohlížeč] Nepodařilo se otevřít: {e}")
odpoved = _rename_dialog(nazev or "", info_lines)
# Zavři jen tento konkrétní proces prohlížeče a počkej na uvolnění zámku
if viewer_proc is not None:
try:
viewer_proc.terminate()
viewer_proc.wait(timeout=10)
except Exception:
pass
if odpoved is None:
print(" Přeskočeno.")
return False
@@ -465,19 +535,11 @@ def interactive_rename(pdf_path: Path, info: dict, verif: dict) -> bool:
print(f" VAROVÁNÍ: '{final_name}' již existuje v Processed, přeskakuji.")
return False
for attempt in range(5):
try:
if pdf_path.suffix.lower() in (".jpg", ".jpeg", ".png"):
from jpg_to_pdf import image_to_pdf
image_to_pdf(pdf_path, dest, rotate_ccw=info.get("rotace") or 0)
else:
shutil.copy2(pdf_path, dest)
break
except PermissionError:
if attempt < 4:
time.sleep(0.5)
else:
raise
if pdf_path.suffix.lower() in (".jpg", ".jpeg", ".png"):
from jpg_to_pdf import image_to_pdf
image_to_pdf(pdf_path, dest, rotate_ccw=info.get("rotace") or 0)
else:
shutil.copy2(pdf_path, dest)
pdf_path.unlink()
print(f" ✓ Uloženo: Processed/{final_name}")
@@ -486,15 +548,49 @@ def interactive_rename(pdf_path: Path, info: dict, verif: dict) -> bool:
# ─── Hlavní logika ────────────────────────────────────────────────────────────
def _start_preview_process(pdf_path: Path):
"""
Otevře náhled PDF jako samostatný subprocess (žádné tkinter threading problémy).
Pracuje s temp kopií — originál zůstane volný.
Vrátí funkci close() pro ukončení procesu.
"""
import tempfile
import shutil as _shutil
tmp = Path(tempfile.mktemp(suffix=pdf_path.suffix))
_shutil.copy2(pdf_path, tmp)
viewer = Path(__file__).parent / "preview_viewer.py"
proc = subprocess.Popen(
[sys.executable, str(viewer), str(tmp), "--delete-on-close"],
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
def close():
try:
proc.terminate()
proc.wait(timeout=3)
except Exception:
pass
try:
tmp.unlink(missing_ok=True)
except Exception:
pass
return close
def process_file(pdf_path: Path):
info = extract_patient_info(str(pdf_path))
rc_from_scan = re.sub(r"\D", "", info.get("rodne_cislo") or "")
print(f" Ověřuji v Medicus (RČ: {rc_from_scan})...")
verif = verify_patient(rc_from_scan)
print_verification(verif, rc_from_scan)
interactive_rename(pdf_path, info, verif)
close_preview = _start_preview_process(pdf_path)
try:
info = extract_patient_info(str(pdf_path))
rc_from_scan = re.sub(r"\D", "", info.get("rodne_cislo") or "")
print(f" Ověřuji v Medicus (RČ: {rc_from_scan})...")
verif = verify_patient(rc_from_scan)
print_verification(verif, rc_from_scan)
interactive_rename(pdf_path, info, verif)
finally:
close_preview()
def process_folder(folder: Path):
pdf_files = sorted(f for f in folder.iterdir()