glaunet-screening / export.py
rallou's picture
fix: simplify PDF report style
83341b4
Raw
History Blame Contribute Delete
8.57 kB
import io
import tempfile
from datetime import datetime
from fpdf import FPDF
from PIL import Image as PILImage
PAGE_W = 210
PAGE_H = 297
MARGIN = 15
USABLE_W = PAGE_W - 2 * MARGIN # 180 mm
N_COLS = 3
COL_GAP = 3
IMG_W = (USABLE_W - (N_COLS - 1) * COL_GAP) / N_COLS # ~58 mm
IMG_H = IMG_W
CAP_H = 5
INPUT_W = 80
FS_TITLE = 16
FS_HEAD = 11
FS_BODY = 9
FS_SMALL = 7
FS_MONO = 8
DARK_GRAY = (50, 50, 50)
MID_GRAY = (110, 110, 110)
LIGHT_GRAY = (200, 200, 200)
BLACK = (0, 0, 0)
RED_TEXT = (180, 30, 30)
GREEN_TEXT = (30, 120, 30)
TBL_HDR = (230, 238, 248)
TBL_ALT = (245, 249, 253)
TBL_BOR = (185, 200, 218)
LABELS = {
"YOLO conf": "YOLO Confidence",
"Seg failed": "Segmentation Failed",
"vCDR": "Vertical CDR",
"hCDR": "Horizontal CDR",
"Inferior rim": "Inferior Rim Ratio",
"Superior rim": "Superior Rim Ratio",
"SI compliant": "Inferior > Superior Rim Rule",
"NRR area": "NRR Area Ratio",
"p holistic": "EfficientNet Probability",
"p fused": "Fused Glaucoma Score",
"Threshold": "Decision Threshold",
}
def _safe(text: str) -> str:
text = (text
.replace('β€”', '-').replace('–', '-')
.replace('β€˜', "'").replace('’', "'")
.replace('β€œ', '"').replace('”', '"')
.replace('…', '...').replace('β€’', '*')
.replace('Β°', 'deg').replace('Β±', '+/-')
)
return text.encode('latin-1', errors='replace').decode('latin-1')
def _png(img: PILImage.Image) -> io.BytesIO:
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
def _fmt(v) -> str:
if v is True: return "Yes"
if v is False: return "No"
if isinstance(v, float): return f"{v:.4f}"
return str(v)
def _hline(pdf: FPDF):
y = pdf.get_y()
pdf.set_draw_color(*LIGHT_GRAY)
pdf.set_line_width(0.25)
pdf.line(MARGIN, y, PAGE_W - MARGIN, y)
pdf.set_draw_color(0, 0, 0)
pdf.set_line_width(0.2)
def _section(pdf: FPDF, title: str, gap_before: float = 7):
pdf.ln(gap_before)
pdf.set_font("Helvetica", style='B', size=FS_HEAD)
pdf.set_text_color(*DARK_GRAY)
pdf.cell(0, 6, title, new_x='LMARGIN', new_y='NEXT')
pdf.ln(2)
pdf.set_text_color(*BLACK)
def _image_grid(pdf: FPDF, items: list):
"""
3-column grid of (caption, PIL Image).
Uses set_xy + image(w, h) [no explicit y] per image.
Caption placed with pdf.text() β€” zero cursor impact.
row_y is a local variable, never derived from get_y() after image placement.
"""
row_y = None
for i, (caption, img) in enumerate(items):
col = i % N_COLS
if col == 0:
row_needed = IMG_H + CAP_H + 4
if pdf.get_y() + row_needed > PAGE_H - MARGIN:
pdf.add_page()
row_y = pdf.get_y()
x = MARGIN + col * (IMG_W + COL_GAP)
# Place image at (x, row_y) without specifying y explicitly
pdf.set_xy(x, row_y)
pdf.image(_png(img), w=IMG_W, h=IMG_H)
# fpdf2 advances cursor to (x + IMG_W, row_y + IMG_H) β€” we ignore it
# Caption: pdf.text() places text at absolute coords, does NOT move cursor
pdf.set_font("Helvetica", style='', size=FS_SMALL)
pdf.set_text_color(*MID_GRAY)
cap = _safe(caption)
tw = pdf.get_string_width(cap)
pdf.text(x + (IMG_W - tw) / 2, row_y + IMG_H + 4, cap)
pdf.set_text_color(*BLACK)
# After last column (or last item), advance cursor past the row
if col == N_COLS - 1 or i == len(items) - 1:
pdf.set_xy(MARGIN, row_y + IMG_H + CAP_H + 4)
def _table(pdf: FPDF, metrics: dict):
"""
2-column table. Uses new_y='TOP' for first cell (y stays),
new_y='NEXT' for last cell (y advances one row).
No TMARGIN β€” that resets y to page top and breaks layout.
"""
lw = USABLE_W * 0.68
vw = USABLE_W * 0.32
rh = 7
pdf.set_draw_color(*BLACK)
# Header row
pdf.set_font("Helvetica", style='B', size=FS_SMALL)
pdf.set_text_color(*BLACK)
pdf.cell(lw, rh, "BIOMARKER / SCORE", border=1,
new_x='RIGHT', new_y='TOP')
pdf.cell(vw, rh, "VALUE", border=1,
new_x='LMARGIN', new_y='NEXT')
# Data rows
for k, v in metrics.items():
pdf.set_font("Helvetica", style='', size=FS_BODY)
pdf.set_text_color(*BLACK)
pdf.cell(lw, rh, LABELS.get(k, k), border=1,
new_x='RIGHT', new_y='TOP')
pdf.set_font("Courier", style='', size=FS_MONO)
pdf.cell(vw, rh, _fmt(v), border=1,
new_x='LMARGIN', new_y='NEXT')
def generate_pdf_report(data: dict) -> str:
pdf = FPDF(orientation='P', unit='mm', format='A4')
pdf.set_margins(MARGIN, MARGIN, MARGIN)
pdf.set_auto_page_break(auto=True, margin=MARGIN)
pdf.add_page()
# ── TITLE ────────────────────────────────────────────────────
pdf.set_font("Helvetica", style='B', size=FS_TITLE)
pdf.set_text_color(*DARK_GRAY)
pdf.cell(0, 9, "GlauNET - Glaucoma Screening Report",
align='C', new_x='LMARGIN', new_y='NEXT')
pdf.set_font("Helvetica", style='', size=FS_SMALL)
pdf.set_text_color(*MID_GRAY)
pdf.cell(0, 5, f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
align='C', new_x='LMARGIN', new_y='NEXT')
pdf.set_font("Helvetica", style='I', size=FS_SMALL - 1)
pdf.cell(0, 4,
"Research prototype - not a substitute for diagnosis by a qualified ophthalmologist.",
align='C', new_x='LMARGIN', new_y='NEXT')
pdf.ln(3)
_hline(pdf)
pdf.set_text_color(*BLACK)
# ── DIAGNOSIS ────────────────────────────────────────────────
_section(pdf, "Automated Diagnosis", gap_before=5)
decision = data["decision"]
is_positive = "SUSPECTED" in decision
pdf.set_font("Helvetica", style='B', size=14)
pdf.set_text_color(*BLACK)
pdf.cell(0, 9, _safe(decision), align='C', new_x='LMARGIN', new_y='NEXT')
# ── INPUT FUNDUS IMAGE ───────────────────────────────────────
_section(pdf, "Input Fundus Image")
if pdf.get_y() + INPUT_W + 8 > PAGE_H - MARGIN:
pdf.add_page()
x_img = MARGIN + (USABLE_W - INPUT_W) / 2
img_top = pdf.get_y() # save y BEFORE image
pdf.set_xy(x_img, img_top)
pdf.image(_png(data["input_image"]), w=INPUT_W, h=INPUT_W)
# fpdf2 cursor is now at img_top + INPUT_W β€” we IGNORE it and use img_top
pdf.set_font("Helvetica", style='I', size=FS_SMALL)
pdf.set_text_color(*MID_GRAY)
cap = "Original fundus photograph"
pdf.text(MARGIN + (USABLE_W - pdf.get_string_width(cap)) / 2,
img_top + INPUT_W + 4, cap)
pdf.set_text_color(*BLACK)
# Advance cursor manually: img_top + image height + caption gap
pdf.set_xy(MARGIN, img_top + INPUT_W + 8)
# ── SEGMENTATION PIPELINE (first 5 images) ───────────────────
all_images = list(data["images"].items())
_section(pdf, "Segmentation Pipeline")
_image_grid(pdf, all_images[:5])
# ── GRADCAM ANALYSIS (last 4 images) ─────────────────────────
_section(pdf, "GradCAM Analysis")
_image_grid(pdf, all_images[5:])
# ── CLINICAL BIOMARKERS ──────────────────────────────────────
_section(pdf, "Clinical Biomarkers")
_table(pdf, data["metrics"])
pdf.ln(3)
# ── MEDGEMMA CLINICAL REPORT ─────────────────────────────────
_section(pdf, "MedGemma Clinical Report")
pdf.set_font("Courier", style='', size=FS_MONO)
pdf.set_text_color(*DARK_GRAY)
report = _safe(data.get("report", ""))
if report:
pdf.multi_cell(USABLE_W, 5, report)
else:
pdf.set_text_color(*MID_GRAY)
pdf.cell(0, 5, "(No report generated)", new_x='LMARGIN', new_y='NEXT')
pdf.set_text_color(*BLACK)
tmp = tempfile.NamedTemporaryFile(
suffix=".pdf", prefix="glaunet_report_", delete=False
)
tmp.close()
pdf.output(tmp.name)
return tmp.name