DEV Community

Cover image for Cómo convertir la foto de una firma en un sello PDF transparente con Python

Cómo convertir la foto de una firma en un sello PDF transparente con Python

Una foto de firma tiene dos problemas antes de poder ir en un PDF. El papel no es blanco ni está iluminado de forma uniforme, así que pegar el JPEG tal cual deja un rectángulo gris sobre la línea de la firma. Y lo que pegues, el PDF lo guarda de dos formas muy distintas: como una anotación que cualquier lector puede arrastrar o borrar, o como parte de la página. La pestaña Scan y la opción Flatten comments del editor de PDF de la aplicación de escritorio de osFoundry hacen los dos trabajos. Abajo se reconstruye cada paso con Python, numpy, Pillow y PyMuPDF, con números medidos en este Mac. El código es mi propia versión de la idea, no el código fuente de la aplicación.

La foto de prueba está generada, no es una foto real de móvil: un JPEG de 1280 × 940 con una firma cursiva sobre papel, con una caída de brillo desde una esquina y grano de papel. Así el problema es reproducible, pero es un ejemplo, no una medición de móviles reales.

La foto de ejemplo de la firma usada para todos los números

Por qué un solo umbral no funciona

La forma obvia de quitar el papel es luminancia < T. En la foto de ejemplo, la luminancia del papel va de unos 202 (el 1 % más oscuro) a 235, y la tinta es mucho más oscura, así que algún T entre ambos parece bien:

Nivel de luz Papel, 1 % más oscuro T=160, píxeles de papel tomados como tinta T=190, píxeles de papel tomados como tinta Estimación local del papel
1.0 (original) 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

El nivel de luz es la foto multiplicada por 0.9, 0.8 y 0.7, como si se hubiera tomado en una habitación más oscura. «Píxeles de papel tomados como tinta» cuenta los píxeles fuera de un recuadro 40 px más ancho que la firma por cada lado, de un total de 917,666 píxeles en esa zona. T=190 está limpio con 1.0 y marcó 91,293 píxeles de papel en blanco con 0.9. Cualquier T fijo funciona con la luz para la que se ajustó y falla con la siguiente foto.

Estimar el papel y medir la oscuridad respecto a él

En lugar de comparar con una constante, se estima el brillo del papel en cada píxel y se mide cuánto más oscuro es el píxel. Un filtro de máximo sobre una copia pequeña de la imagen elimina la tinta (la tinta es oscura, así que el máximo la ignora) y un desenfoque suaviza el resultado:

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
Enter fullscreen mode Exit fullscreen mode

El píxel de salida conserva el color de la foto y usa alpha como transparencia, así que un bolígrafo azul sigue azul y un sello rojo sigue rojo. En la última columna de la tabla anterior, la misma función marcó 0 píxeles de papel en todos los niveles de luz, y la firma sale con 26,526 píxeles con alpha superior a 0.05 con 1.0 y entre 26,511 y 26,544 en los otros tres niveles.

El parámetro sens hace el papel del control Ink sensitivity de la aplicación (que empieza en 50), pero cómo se traduce a un umbral aquí es decisión mía. Con la misma foto:

sens Píxeles con alpha > 0 De ellos, fuera del recuadro de la firma
0 25,004 0
25 26,005 0
50 27,325 0
75 30,478 13
100 149,111 15,519

Con 100, la zona muerta es tan pequeña que el grano del papel empieza a contar como tinta, el mismo problema de motas que el control de la aplicación está para corregir. La extracción completa tardó de 61 a 65 ms para una foto de 1280 × 940 en este Mac, así que puede repetirse en cada movimiento del control.

Anotación o contenido de página

Escribir el resultado en un PNG con canal alfa y ponerlo en un PDF da dos resultados posibles, según la llamada de PyMuPDF que uses. Con el acuerdo de ejemplo y un recuadro de firma de 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
Enter fullscreen mode Exit fullscreen mode

Tras add_stamp_annot, la página tiene 1 anotación (tipo Stamp) y ninguna imagen. Borrar esa anotación con page.delete_annot se lleva la firma: el número de píxeles oscuros en el rectángulo de la firma pasa de 1,453 a 0. Tras doc.bake, la página tiene 0 anotaciones y 1 imagen, el bake tardó 2.0 ms, y borrar anotaciones después no quita nada (1,440 píxeles oscuros antes y después). El texto de la página sigue siendo seleccionable y extraíble, porque bake solo añade la imagen.

El archivo que guardó la aplicación tras Flatten comments se ve igual al leerlo con PyMuPDF: 0 anotaciones, 1 imagen con máscara alfa, de 1032 × 198 px, dibujada en un rectángulo de 150 × 28.7 pt, unos 495 dpi en la página. El original de 87,708 bytes pasó a 101,785 bytes. Mi propio archivo con el sello salió mucho más grande (922,521 bytes, 922,681 tras el bake), porque escribí un PNG a tamaño completo y dejé que PyMuPDF lo guardara tal cual, así que en un script conviene reducirlo antes al tamaño impreso.

Dónde termina esto

El resultado es una imagen en la página. No demuestra quién la colocó y nada en el archivo detecta una edición posterior. Para eso hace falta una firma digital real (una firma PKCS#7 sobre el archivo), que es otro mecanismo. Para los formularios que solo piden una firma en la línea, un PNG alfa integrado en la página es lo mismo que una firma impresa y escaneada, sin el papel.

Todo lo anterior se ejecuta con uvx --with pymupdf --with numpy --with pillow python -I script.py. La aplicación es gratuita en la página de descarga de osFoundry, y el editor trabaja con archivos en el equipo, en línea con su enfoque local-first.

Top comments (0)