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
+60
View File
@@ -226,5 +226,65 @@
{
"original": "470629074 2026-04-21 Šebesta, Jaroslav [oznámení ZP správní řízení] [zahájení správního řízení, LRPéče indikace II/3 hypertenzní choroba II-III.st].pdf",
"corrected": "470629074 2026-04-21 Šebesta, Jaroslav [oznámení ZP správní řízení] [zahájení správního řízení, návrh lázně, indikace II3 hypertenzní choroba II-III.st].pdf"
},
{
"original": "5503040026 2026-02-17 Koubek, Jiří [LZ kardiologie] [ECHO: EF 65%, konc.hypertrofie, diastol.dysfunkce I.st, Bevimlar 20mg].pdf",
"corrected": "5503040026 2026-02-17 Koubek, Jiří [LZ kardiologie] [ECHO EF 65%, konc.hypertrofie, diastol.dysfunkce I.st, Bevimlar 20mg].pdf"
},
{
"original": "480529219 2026-04-17 Nytra, Vlastimil [Laboratoř] [osteomarkery, Ca, P, ALP, vit.D 67,1 snížen, PTH, Beta-CrossLaps].pdf",
"corrected": "480529219 2026-04-17 Nytra, Vlastimil [Laboratoř] [osteomarkery, Ca, P, ALP, vit.D 67.1 snížen, PTH, Beta-CrossLaps].pdf"
},
{
"original": "435520110 2026-04-20 Nechodomová, Marie [sonografie břicha] [hypersekr.žaludku, lipomatoza pankreatu, steatoza jat., cholecystolithiaza, splenomegalie].pdf",
"corrected": "435520110 2026-04-20 Nechodomová, Marie [sonografie břicha] [zesílení stěny žaludku - dovyšetřit, hypersekr.žaludku, lipomatoza pankreatu, steatoza jat., cholecystolithiaza, splenomegalie].pdf"
},
{
"original": "6903020080 2026-04-20 Novotný, Martin [Laboratoř] [cholesterol 5.54, LDL 3.25, TG 2.06, glukoza 6.1, HbA1c 38].pdf",
"corrected": "6903020080 2026-04-20 Novotný, Martin [Laboratoř] [smíšená hyperlipidémie, prediabetes, cholesterol 5.54, LDL 3.25, TG 2.06, glukoza 6.1, HbA1c 38].pdf"
},
{
"original": "480529219 2026-04-17 Nytra, Vlastimil [Laboratoř] [ELFO bílkovin, bílkovina 69.0, albumin 0.581, gama-globuliny 0.125].pdf",
"corrected": "480529219 2026-04-17 Nytra, Vlastimil [Laboratoř] [ELFO bílkovin OK, bílkovina 69.0, albumin 0.581, gama-globuliny 0.125].pdf"
},
{
"original": "5556046672 2026-04-07 Simionová, Lýdia [Laboratoř] [močový konkrement, whewellit 100%, 6x3mm, hnědý, bradavičnatý].pdf",
"corrected": "5556046672 2026-04-07 Simionová, Lýdia [Laboratoř] [močový konkrement analýza, whewellit 100%, 6x3mm, hnědý, bradavičnatý].pdf"
},
{
"original": "510802325 2026-04-20 Simion, Vladimír [LZ chirurgie] [chronický vřed kůže, TMT amputace IV.+V.prstu PDK, defekt LDK 5x3.5cm].pdf",
"corrected": "510802325 2026-04-20 Simion, Vladimír [LZ chirurgie] [chronický vřed kůže, TMT amputace IV.+V.prstu PDK, defekt LDK 5x3.5cm, DP 3xt].pdf"
},
{
"original": "436114002 2026-03-17 Petrovská, Eliška [LZ kardiologie] [fibrilace síní paroxysmální, sinus, st.p.kardioverzi, rivaroxaban].pdf",
"corrected": "436114002 2026-03-17 Petrovská, Eliška [LZ kardiologie] [fibrilace síní paroxysmální, sinus, st.p.kardioverzi, rivaroxaban, ad Holter EKG, bisoprolol vysadí].pdf"
},
{
"original": "436114002 2026-03-14 Petrovská, Eliška [LZ interna] [fibrilace síní paroxysmální, kardioverze, sinusový rytmus, rivaroxaban].pdf",
"corrected": "436114002 2026-03-14 Petrovská, Eliška [LZ interna urgent] [fibrilace síní paroxysmální, kardioverze, sinusový rytmus, rivaroxaban].pdf"
},
{
"original": "6008091738 2026-04-20 Nikitin, Petro [Laboratoř] [urea 9.47 zvýš, CKD-EPI G2, glukoza 6.6, osmolalita 296, MCV 81.5].pdf",
"corrected": "6008091738 2026-04-20 Nikitin, Petro [Laboratoř] [Z000 prediabetes, mikrocyty, urea 9.47 zvýš, CKD-EPI G2, glukoza 6.6, osmolalita 296, MCV 81.5].pdf"
},
{
"original": "440802018 2026-04-20 Havelka, Miroslav [Laboratoř] [CKD-EPI G2, NT-proBNP 6128 zvýš, CRP 6.6, MCV 81.8, MCHC 314].pdf",
"corrected": "440802018 2026-04-20 Havelka, Miroslav [Laboratoř] [srdeční selhání, mikrocyty, CKD-EPI G2, NT-proBNP 6128 zvýš, CRP 6.6, MCV 81.8, MCHC 314].pdf"
},
{
"original": "7857260422 2023-02-28 Jindrová, Kateřina [LZ ORL] [st.p. incizi inflam aterom P tváře - zhojeno, extirpace atheromu P tváře].pdf",
"corrected": "7857260422 2023-02-28 Jindrová, Kateřina [LZ ORL] [st.p. incizi inflam aterom P tváře - zhojeno, extirpace atheromu P tváře domluveno].pdf"
},
{
"original": "7857260422 2021-05-06 Jindrová, Kateřina [LZ angiologie] [CVI II. st. dle CEAP C4, ortostáza, flebitida/flebotrombóza bilat. neprokázána].pdf",
"corrected": "7857260422 2021-05-06 Jindrová, Kateřina [LZ angiologie] [CVI II. st. dle CEAP C4, ortostáza, flebitidaflebotrombóza bilat. neprokázána].pdf"
},
{
"original": "7857260422 2021-05-20 Jindrová, Kateřina [LZ neurologie] [VAS C-pá, porucha statodynamiky C úseku, tinitus auric. bilat., ad rehab].pdf",
"corrected": "7857260422 2021-05-20 Jindrová, Kateřina [LZ neurologie] [VAS Cp, porucha statodynamiky C úseku, tinitus auric. bilat., ad RHB].pdf"
},
{
"original": "7857260422 2024-02-12 Jindrová, Kateřina [EKG] [sinusový rytmus 70/min, semivertik poloha, osa 55st, fyziol záznam].pdf",
"corrected": "7857260422 2024-02-12 Jindrová, Kateřina [EKG] [sinusový rytmus 70min, semivertik poloha, osa 55st, fyziol záznam].pdf"
}
]
+160 -64
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
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 process_file(pdf_path: Path):
info = extract_patient_info(str(pdf_path))
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):
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()
+98
View File
@@ -0,0 +1,98 @@
"""
Standalone PDF/obrázek náhled — spouští se jako subprocess z extract_patient_info.py.
Argumenty: preview_viewer.py <soubor> [--delete-on-close]
"""
import sys
from pathlib import Path
import tkinter as tk
def main():
if len(sys.argv) < 2:
sys.exit(1)
pdf_path = Path(sys.argv[1])
delete_on_close = "--delete-on-close" in sys.argv
try:
from PIL import Image, ImageTk
import fitz
except ImportError:
sys.exit(2)
suffix = pdf_path.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
pil_img = Image.open(pdf_path)
doc = None
else:
doc = fitz.open(str(pdf_path))
pil_img = None
root = tk.Tk()
root.tk.call("encoding", "system", "utf-8")
sh = root.winfo_screenheight()
page_count = len(doc) if doc else 1
current = [0]
photo_ref = [None]
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_img.copy()
img.thumbnail((700, sh - 150), Image.LANCZOS)
return img
def on_close():
if doc:
try:
doc.close()
except Exception:
pass
if delete_on_close:
try:
pdf_path.unlink(missing_ok=True)
except Exception:
pass
root.destroy()
root.title(pdf_path.stem)
root.attributes("-topmost", True)
root.resizable(False, False)
root.protocol("WM_DELETE_WINDOW", on_close)
lbl_img = tk.Label(root)
lbl_img.pack()
frame_nav = tk.Frame(root)
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)
root.update_idletasks()
root.geometry("+0+0")
root.mainloop()
if __name__ == "__main__":
main()