La foto di una firma ha due problemi prima di poter finire in un PDF. La carta non è bianca e non è illuminata in modo uniforme, quindi incollare il JPEG così com'è lascia un rettangolo grigio sulla riga della firma. E qualunque cosa si incolli, il PDF la memorizza in due modi molto diversi: come annotazione che qualsiasi lettore può trascinare via o cancellare, oppure come parte della pagina. La scheda Scan e il comando Flatten comments dell'editor PDF dell'app desktop di osFoundry fanno entrambi i lavori. Qui sotto ogni passaggio è ricostruito con Python, numpy, Pillow e PyMuPDF, con numeri misurati su questo Mac. Il codice è una mia versione dell'idea, non il codice sorgente dell'app.
La foto di prova è generata, non è una vera foto da telefono: un JPEG da 1280 × 940 con una firma in corsivo su carta, con una caduta di luminosità da un angolo e grana della carta. Così il problema è riproducibile, ma è un esempio, non una misura di telefoni reali.
Perché una sola soglia non basta
Il modo ovvio per togliere la carta è luminanza < T. Nella foto di esempio la luminanza della carta va da circa 202 (l'1 % più scuro) a 235, e l'inchiostro è molto più scuro, quindi un T qualsiasi nel mezzo sembra andare bene:
| Livello di luce | Carta, 1 % più scuro | T=160, pixel di carta scambiati per inchiostro | T=190, pixel di carta scambiati per inchiostro | Stima locale della carta |
|---|---|---|---|---|
| 1.0 (originale) | 201.9 | 0 | 0 | 0 |
| 0.9 | 181.7 | 0 | 91,293 | 0 |
| 0.8 | 161.6 | 2,663 | 917,309 | 0 |
| 0.7 | 141.4 | 669,328 | 917,666 | 0 |
Il livello di luce è la foto moltiplicata per 0.9, 0.8 e 0.7, come se fosse stata scattata in una stanza più buia. «Pixel di carta scambiati per inchiostro» conta i pixel fuori da un riquadro più largo di 40 px per lato rispetto alla firma, su 917,666 pixel in quell'area. T=190 è pulito a 1.0 e a 0.9 ha segnalato 91,293 pixel di carta bianca. Qualsiasi T fisso funziona con la luce per cui è stato regolato e fallisce con la foto successiva.
Stimare la carta e misurare lo scuro rispetto a essa
Invece di confrontare con una costante, si stima la luminosità della carta in ogni pixel e si misura quanto il pixel è più scuro. Un filtro massimo su una copia piccola dell'immagine toglie l'inchiostro (l'inchiostro è scuro, quindi il massimo lo ignora) e una sfocatura leviga il risultato:
def paper_est(L, small=160):
h, w = L.shape
im = Image.fromarray(L.astype(np.uint8)).resize((small, int(small * h / w)))
im = im.filter(ImageFilter.MaxFilter(15)).filter(ImageFilter.GaussianBlur(6))
return np.asarray(im.resize((w, h), Image.BILINEAR), np.float32)
def extract(img, sens=50):
a = np.asarray(img.convert("RGB"), np.float32)
L = a @ np.array([.299, .587, .114])
P = paper_est(L)
d = np.clip((P - L) / np.maximum(P, 1), 0, 1) # how much darker than the local paper
t0 = 0.03 + (100 - sens) / 100 * 0.12 # dead zone for grain
alpha = np.clip((d - t0) / 0.35, 0, 1)
return alpha, a
Il pixel di uscita mantiene il colore della foto e usa alpha come trasparenza, quindi una penna blu resta blu e un sigillo rosso resta rosso. Nell'ultima colonna della tabella qui sopra la stessa funzione ha segnalato 0 pixel di carta a ogni livello di luce, e la firma stessa risulta di 26,526 pixel con alpha superiore a 0.05 a 1.0 e tra 26,511 e 26,544 agli altri tre livelli.
Il parametro sens fa la parte del cursore Ink sensitivity dell'app (che parte da 50), ma come si traduce in una soglia qui è una mia scelta. Sulla stessa foto:
| sens | Pixel con alpha > 0 | Di cui fuori dal riquadro della firma |
|---|---|---|
| 0 | 25,004 | 0 |
| 25 | 26,005 | 0 |
| 50 | 27,325 | 0 |
| 75 | 30,478 | 13 |
| 100 | 149,111 | 15,519 |
A 100 la zona morta è così piccola che la grana della carta comincia a contare come inchiostro, lo stesso problema di macchioline che il cursore dell'app serve a correggere. L'intera estrazione ha richiesto da 61 a 65 ms per una foto da 1280 × 940 su questo Mac, quindi si può rifare a ogni spostamento del cursore.
Annotazione o contenuto della pagina
Scrivere il risultato in un PNG con canale alfa e metterlo su un PDF dà due esiti possibili, a seconda della chiamata PyMuPDF usata. Con l'accordo di esempio e un riquadro di firma di 150 × 28.7 pt:
rect = pymupdf.Rect(196.6, 575.5, 346.6, 604.2)
pg.add_stamp_annot(rect, stamp="ink-en.png") # an annotation
doc.bake(annots=True, widgets=False) # draw annotations into the page
Dopo add_stamp_annot la pagina ha 1 annotazione (tipo Stamp) e nessuna immagine. Cancellare quell'annotazione con page.delete_annot porta via la firma: il numero di pixel scuri nel rettangolo della firma passa da 1,453 a 0. Dopo doc.bake la pagina ha 0 annotazioni e 1 immagine, il bake ha richiesto 2.0 ms, e cancellare le annotazioni dopo non toglie nulla (1,440 pixel scuri prima e dopo). Il testo della pagina resta selezionabile ed estraibile, perché bake aggiunge solo l'immagine.
Il file salvato dall'app dopo Flatten comments, letto con PyMuPDF, è identico: 0 annotazioni, 1 immagine con maschera alfa, di 1032 × 198 px, disegnata in un rettangolo di 150 × 28.7 pt, circa 495 dpi sulla pagina. L'originale di 87,708 byte è passato a 101,785 byte. Il mio file con timbro è venuto molto più grande (922,521 byte, 922,681 dopo il bake), perché ho scritto un PNG a dimensione piena e ho lasciato che PyMuPDF lo salvasse com'è: in uno script conviene ridurlo prima alla dimensione di stampa.
Dove finisce
Il risultato è un'immagine sulla pagina. Non dimostra chi l'ha messa e nulla nel file rileva una modifica successiva. Per questo serve una vera firma digitale (una firma PKCS#7 sul file), che è un meccanismo diverso. Per i moduli che chiedono solo una firma sulla riga, un PNG alfa incorporato nella pagina equivale a una firma stampata e scansionata, senza la carta.
Tutto quanto sopra si esegue con uvx --with pymupdf --with numpy --with pillow python -I script.py. L'app è gratuita dalla pagina di download di osFoundry, e l'editor lavora su file presenti sul computer, in linea con il suo approccio local-first.

Top comments (0)