Spaces:
Sleeping
Sleeping
Fabio Antonini Claude Sonnet 4.6 commited on
Commit ·
21e9333
1
Parent(s): a579ad1
Add PDF report generation to Forensic Pipeline
Browse filesAfter the pipeline completes, a "Scarica Report PDF" button appears.
_generate_pipeline_pdf() uses fpdf2 to build a professional multi-page
forensic report with all 7 step outputs (text + embedded images) and
the LLM narrative conclusion, plus a disclaimer footer.
Unicode normalization via _to_latin1() handles em-dash and other
characters outside latin-1 range required by fpdf2 core fonts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app/grapholab_demo.py +193 -0
- requirements.txt +1 -0
app/grapholab_demo.py
CHANGED
|
@@ -1306,6 +1306,177 @@ def run_pipeline(
|
|
| 1306 |
yield (_, _, _, _, _, _, _, _, _, _, _, _, _, llm_report)
|
| 1307 |
|
| 1308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1309 |
with gr.Blocks() as pipeline_tab:
|
| 1310 |
gr.Markdown(
|
| 1311 |
"## Pipeline Forense Integrata\n\n"
|
|
@@ -1373,6 +1544,10 @@ with gr.Blocks() as pipeline_tab:
|
|
| 1373 |
with gr.Accordion("Step 7 — Valutazione LLM (Ollama)", open=True):
|
| 1374 |
out_llm = gr.Markdown(label="Referto sintetico LLM")
|
| 1375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1376 |
pipe_btn.click(
|
| 1377 |
fn=run_pipeline,
|
| 1378 |
inputs=[pipe_doc, pipe_ref],
|
|
@@ -1387,6 +1562,24 @@ with gr.Blocks() as pipeline_tab:
|
|
| 1387 |
pipe_results,
|
| 1388 |
out_llm,
|
| 1389 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1390 |
)
|
| 1391 |
|
| 1392 |
|
|
|
|
| 1306 |
yield (_, _, _, _, _, _, _, _, _, _, _, _, _, llm_report)
|
| 1307 |
|
| 1308 |
|
| 1309 |
+
def _generate_pipeline_pdf(
|
| 1310 |
+
s1_img, s1_txt,
|
| 1311 |
+
s2_txt,
|
| 1312 |
+
s3_md,
|
| 1313 |
+
s4_md, s4_img,
|
| 1314 |
+
s5_md, s5_img,
|
| 1315 |
+
s6_txt, s6_img,
|
| 1316 |
+
llm_text,
|
| 1317 |
+
) -> str:
|
| 1318 |
+
"""Generate a PDF forensic report from pipeline outputs. Returns the file path."""
|
| 1319 |
+
import tempfile, re, unicodedata
|
| 1320 |
+
from fpdf import FPDF
|
| 1321 |
+
from PIL import Image as _PILImage
|
| 1322 |
+
import io as _io
|
| 1323 |
+
import datetime
|
| 1324 |
+
|
| 1325 |
+
def _to_latin1(text: str) -> str:
|
| 1326 |
+
"""Normalize Unicode to latin-1 for fpdf2 core fonts."""
|
| 1327 |
+
if not text:
|
| 1328 |
+
return ""
|
| 1329 |
+
replacements = {
|
| 1330 |
+
"\u2014": "-", "\u2013": "-",
|
| 1331 |
+
"\u2018": "'", "\u2019": "'",
|
| 1332 |
+
"\u201c": '"', "\u201d": '"',
|
| 1333 |
+
"\u2026": "...",
|
| 1334 |
+
"\u2022": "*",
|
| 1335 |
+
}
|
| 1336 |
+
for src, dst in replacements.items():
|
| 1337 |
+
text = text.replace(src, dst)
|
| 1338 |
+
text = unicodedata.normalize("NFKD", text)
|
| 1339 |
+
return text.encode("latin-1", errors="replace").decode("latin-1")
|
| 1340 |
+
|
| 1341 |
+
def _md_to_plain(text: str) -> str:
|
| 1342 |
+
"""Strip markdown syntax to plain text for PDF rendering."""
|
| 1343 |
+
if not text:
|
| 1344 |
+
return ""
|
| 1345 |
+
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
| 1346 |
+
text = re.sub(r"\*{1,2}(.+?)\*{1,2}", r"\1", text)
|
| 1347 |
+
text = re.sub(r"`{1,3}[^`]*`{1,3}", "", text)
|
| 1348 |
+
text = re.sub(r"^\|.*\|$", "", text, flags=re.MULTILINE)
|
| 1349 |
+
text = re.sub(r"^[-|]+$", "", text, flags=re.MULTILINE)
|
| 1350 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 1351 |
+
return _to_latin1(text.strip())
|
| 1352 |
+
|
| 1353 |
+
def _numpy_to_jpeg_bytes(arr) -> bytes | None:
|
| 1354 |
+
"""Convert numpy array to JPEG bytes for embedding in PDF."""
|
| 1355 |
+
if arr is None:
|
| 1356 |
+
return None
|
| 1357 |
+
try:
|
| 1358 |
+
img = _PILImage.fromarray(arr.astype("uint8"))
|
| 1359 |
+
buf = _io.BytesIO()
|
| 1360 |
+
img.save(buf, format="JPEG", quality=85)
|
| 1361 |
+
return buf.getvalue()
|
| 1362 |
+
except Exception:
|
| 1363 |
+
return None
|
| 1364 |
+
|
| 1365 |
+
class ForensicPDF(FPDF):
|
| 1366 |
+
def header(self):
|
| 1367 |
+
self.set_font("Helvetica", "B", 10)
|
| 1368 |
+
self.set_text_color(80, 80, 80)
|
| 1369 |
+
self.cell(0, 8, "GraphoLab - Referto Forense Integrato", align="C")
|
| 1370 |
+
self.ln(2)
|
| 1371 |
+
self.set_draw_color(180, 180, 180)
|
| 1372 |
+
self.line(10, self.get_y(), 200, self.get_y())
|
| 1373 |
+
self.ln(4)
|
| 1374 |
+
|
| 1375 |
+
def footer(self):
|
| 1376 |
+
self.set_y(-15)
|
| 1377 |
+
self.set_font("Helvetica", "I", 8)
|
| 1378 |
+
self.set_text_color(130, 130, 130)
|
| 1379 |
+
self.cell(0, 10, f"Pagina {self.page_no()} - Generato da GraphoLab", align="C")
|
| 1380 |
+
|
| 1381 |
+
pdf = ForensicPDF()
|
| 1382 |
+
pdf.set_auto_page_break(auto=True, margin=18)
|
| 1383 |
+
pdf.add_page()
|
| 1384 |
+
|
| 1385 |
+
# ── Titolo ────────────────────────────────────────────────────────────────
|
| 1386 |
+
pdf.set_font("Helvetica", "B", 18)
|
| 1387 |
+
pdf.set_text_color(30, 30, 30)
|
| 1388 |
+
pdf.cell(0, 12, "Referto Forense Integrato", align="C")
|
| 1389 |
+
pdf.ln(4)
|
| 1390 |
+
pdf.set_font("Helvetica", "", 10)
|
| 1391 |
+
pdf.set_text_color(100, 100, 100)
|
| 1392 |
+
now = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
|
| 1393 |
+
pdf.cell(0, 8, f"Data generazione: {now}", align="C")
|
| 1394 |
+
pdf.ln(10)
|
| 1395 |
+
|
| 1396 |
+
def _section_title(title: str):
|
| 1397 |
+
pdf.set_font("Helvetica", "B", 12)
|
| 1398 |
+
pdf.set_text_color(255, 255, 255)
|
| 1399 |
+
pdf.set_fill_color(50, 80, 120)
|
| 1400 |
+
pdf.cell(0, 8, _to_latin1(f" {title}"), fill=True)
|
| 1401 |
+
pdf.ln(4)
|
| 1402 |
+
pdf.set_text_color(30, 30, 30)
|
| 1403 |
+
|
| 1404 |
+
def _body_text(text: str):
|
| 1405 |
+
if not text:
|
| 1406 |
+
return
|
| 1407 |
+
pdf.set_font("Helvetica", "", 10)
|
| 1408 |
+
pdf.set_text_color(40, 40, 40)
|
| 1409 |
+
pdf.multi_cell(0, 5, _md_to_plain(text))
|
| 1410 |
+
pdf.ln(3)
|
| 1411 |
+
|
| 1412 |
+
def _embed_image(arr, max_w: int = 170):
|
| 1413 |
+
data = _numpy_to_jpeg_bytes(arr)
|
| 1414 |
+
if data is None:
|
| 1415 |
+
return
|
| 1416 |
+
buf = _io.BytesIO(data)
|
| 1417 |
+
img = _PILImage.open(buf)
|
| 1418 |
+
w, h = img.size
|
| 1419 |
+
ratio = min(max_w / w, 100 / h)
|
| 1420 |
+
disp_w, disp_h = w * ratio, h * ratio
|
| 1421 |
+
buf.seek(0)
|
| 1422 |
+
x = (210 - disp_w) / 2
|
| 1423 |
+
pdf.image(buf, x=x, w=disp_w, h=disp_h)
|
| 1424 |
+
pdf.ln(4)
|
| 1425 |
+
|
| 1426 |
+
# ── Step 1 ────────────────────────────────────────────────────────────────
|
| 1427 |
+
_section_title("Step 1 — Rilevamento Firma (YOLOv8)")
|
| 1428 |
+
_body_text(s1_txt)
|
| 1429 |
+
_embed_image(s1_img)
|
| 1430 |
+
|
| 1431 |
+
# ── Step 2 ────────────────────────────────────────────────────────────────
|
| 1432 |
+
_section_title("Step 2 — Trascrizione HTR (EasyOCR)")
|
| 1433 |
+
_body_text(s2_txt)
|
| 1434 |
+
|
| 1435 |
+
# ── Step 3 ────────────────────────────────────────────────────────────────
|
| 1436 |
+
_section_title("Step 3 — Riconoscimento Entita' (NER)")
|
| 1437 |
+
_body_text(s3_md)
|
| 1438 |
+
|
| 1439 |
+
# ── Step 4 ────────────────────────────────────────────────────────────────
|
| 1440 |
+
_section_title("Step 4 — Identificazione Scrittore")
|
| 1441 |
+
_body_text(s4_md)
|
| 1442 |
+
_embed_image(s4_img)
|
| 1443 |
+
|
| 1444 |
+
# ── Step 5 ────────────────────────────────────────────────────────────────
|
| 1445 |
+
_section_title("Step 5 — Analisi Grafologica")
|
| 1446 |
+
_body_text(s5_md)
|
| 1447 |
+
_embed_image(s5_img)
|
| 1448 |
+
|
| 1449 |
+
# ── Step 6 ────────────────────────────────────────────────────────────────
|
| 1450 |
+
_section_title("Step 6 — Verifica Firma (SigNet)")
|
| 1451 |
+
_body_text(s6_txt)
|
| 1452 |
+
_embed_image(s6_img)
|
| 1453 |
+
|
| 1454 |
+
# ── Step 7: LLM ───────────────────────────────────────────────────────────
|
| 1455 |
+
_section_title("Step 7 — Valutazione LLM (Ollama)")
|
| 1456 |
+
_body_text(llm_text)
|
| 1457 |
+
|
| 1458 |
+
# ── Disclaimer ────────────────────────────────────────────────────────────
|
| 1459 |
+
pdf.ln(6)
|
| 1460 |
+
pdf.set_draw_color(180, 180, 180)
|
| 1461 |
+
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
|
| 1462 |
+
pdf.ln(4)
|
| 1463 |
+
pdf.set_font("Helvetica", "I", 8)
|
| 1464 |
+
pdf.set_text_color(120, 120, 120)
|
| 1465 |
+
pdf.multi_cell(
|
| 1466 |
+
0, 4,
|
| 1467 |
+
"Referto generato automaticamente da GraphoLab. "
|
| 1468 |
+
"Tutti i risultati hanno carattere indicativo e devono essere valutati "
|
| 1469 |
+
"da un perito calligrafo qualificato.",
|
| 1470 |
+
)
|
| 1471 |
+
|
| 1472 |
+
# ── Salvataggio ───────────────────────────────────────────────────────────
|
| 1473 |
+
tmp = tempfile.NamedTemporaryFile(
|
| 1474 |
+
suffix=".pdf", prefix="grapholab_referto_", delete=False
|
| 1475 |
+
)
|
| 1476 |
+
pdf.output(tmp.name)
|
| 1477 |
+
return tmp.name
|
| 1478 |
+
|
| 1479 |
+
|
| 1480 |
with gr.Blocks() as pipeline_tab:
|
| 1481 |
gr.Markdown(
|
| 1482 |
"## Pipeline Forense Integrata\n\n"
|
|
|
|
| 1544 |
with gr.Accordion("Step 7 — Valutazione LLM (Ollama)", open=True):
|
| 1545 |
out_llm = gr.Markdown(label="Referto sintetico LLM")
|
| 1546 |
|
| 1547 |
+
with gr.Row(visible=False) as pdf_row:
|
| 1548 |
+
pdf_btn = gr.Button("📄 Scarica Report PDF", variant="secondary")
|
| 1549 |
+
pdf_out = gr.File(label="Report PDF", file_types=[".pdf"])
|
| 1550 |
+
|
| 1551 |
pipe_btn.click(
|
| 1552 |
fn=run_pipeline,
|
| 1553 |
inputs=[pipe_doc, pipe_ref],
|
|
|
|
| 1562 |
pipe_results,
|
| 1563 |
out_llm,
|
| 1564 |
],
|
| 1565 |
+
).then(
|
| 1566 |
+
fn=lambda: gr.update(visible=True),
|
| 1567 |
+
inputs=None,
|
| 1568 |
+
outputs=pdf_row,
|
| 1569 |
+
)
|
| 1570 |
+
|
| 1571 |
+
pdf_btn.click(
|
| 1572 |
+
fn=_generate_pipeline_pdf,
|
| 1573 |
+
inputs=[
|
| 1574 |
+
out_s1_img, out_s1_txt,
|
| 1575 |
+
out_s2_txt,
|
| 1576 |
+
out_s3_md,
|
| 1577 |
+
out_s4_md, out_s4_img,
|
| 1578 |
+
out_s5_md, out_s5_img,
|
| 1579 |
+
out_s6_txt, out_s6_img,
|
| 1580 |
+
out_llm,
|
| 1581 |
+
],
|
| 1582 |
+
outputs=pdf_out,
|
| 1583 |
)
|
| 1584 |
|
| 1585 |
|
requirements.txt
CHANGED
|
@@ -15,6 +15,7 @@ ultralytics>=8.0.0
|
|
| 15 |
|
| 16 |
# audioop backport for Python 3.13 (pydub dependency of gradio 4.x)
|
| 17 |
audioop-lts; python_version >= "3.13"
|
|
|
|
| 18 |
|
| 19 |
# Data / ML utilities
|
| 20 |
scikit-learn>=1.3.0
|
|
|
|
| 15 |
|
| 16 |
# audioop backport for Python 3.13 (pydub dependency of gradio 4.x)
|
| 17 |
audioop-lts; python_version >= "3.13"
|
| 18 |
+
fpdf2>=2.7 # PDF report generation
|
| 19 |
|
| 20 |
# Data / ML utilities
|
| 21 |
scikit-learn>=1.3.0
|