A signature photo has two problems before it can go on a PDF. The paper is not white, and it is not evenly lit, so pasting the JPEG as it is gives a grey rectangle over the signature line. And whatever you paste, the PDF stores it in one of two very different ways: as an annotation that any reader can drag away, or as part of the page. The Scan tab and Flatten comments in the PDF Editor of the osFoundry desktop app do both jobs. Below is each step rebuilt with Python, numpy, Pillow and PyMuPDF, with numbers measured on this Mac. The code is my own version of the idea, not the app's source.
The test photo is generated, not a real phone picture: a 1280 × 940 JPEG of a script signature on paper with a brightness falloff from one corner and paper grain. That makes the problem reproducible, and it is an example, not a measurement of real phones.
Why one threshold doesn't work
The obvious way to remove paper is luminance < T. On the example photo the paper's luminance runs from about 202 (darkest 1%) to 235, and the ink is much darker, so some T between them looks fine:
| Light level | Paper, darkest 1% | T=160, paper px flagged as ink | T=190, paper px flagged as ink | Local paper estimate |
|---|---|---|---|---|
| 1.0 (as shot) | 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 |
The light level is the photo multiplied by 0.9, 0.8 and 0.7, as if taken in a dimmer room. "Paper px flagged" counts pixels outside a box 40 px wider than the signature on every side, out of 917,666 pixels there. T=190 is clean at 1.0 and has flagged 91,293 pixels of blank paper at 0.9. Any fixed T works for the lighting it was tuned on and fails with the next photo.
Estimate the paper, then measure darkness against it
Instead of comparing with a constant, estimate the paper's brightness at each pixel and measure how much darker than that the pixel is. A grey-scale maximum filter on a small copy of the image takes the ink out (ink is dark, so the maximum ignores it), and a blur smooths the result:
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 The output pixel keeps the photo's own colour and takes alpha as its transparency, so a blue pen stays blue and a red seal stays red. In the last column of the table above the same function flagged 0 paper pixels at every light level, and the signature itself comes out at 26,526 pixels with alpha above 0.05 at 1.0 and between 26,511 and 26,544 at the other three levels.
The sens parameter plays the part of the Ink sensitivity slider in the app (which starts at 50), but how it maps to a threshold here is my own choice. On the same photo:
| sens | Pixels with alpha > 0 | Of those, outside the signature box |
|---|---|---|
| 0 | 25,004 | 0 |
| 25 | 26,005 | 0 |
| 50 | 27,325 | 0 |
| 75 | 30,478 | 13 |
| 100 | 149,111 | 15,519 |
At 100 the dead zone is so small that paper grain starts to count as ink, which is the same speck problem the slider in the app is there to fix. The whole extraction took 61 to 65 ms for a 1280 × 940 photo on this Mac, so it can run again on every slider move.
Stamp or page content
Writing the result to a PNG with an alpha channel and putting it on a PDF gives two possible outcomes, depending on which PyMuPDF call you use. Using the example agreement with a 150 × 28.7 pt signature box:
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 After add_stamp_annot the page has 1 annotation (type Stamp) and no images. Deleting that annotation with page.delete_annot takes the signature with it: the number of dark pixels in the signature's rectangle goes from 1,453 to 0. After doc.bake, the page has 0 annotations and 1 image, the bake took 2.0 ms, and deleting annotations afterwards removes nothing (1,440 dark pixels before and after). The page text stays selectable and extractable, because baking only adds the image.
The file the app saved after Flatten comments looks the same when read back with PyMuPDF: 0 annotations, 1 image, with an alpha mask, 1032 × 198 px, drawn into a 150 × 28.7 pt rectangle, about 495 dpi on the page. The 87,708-byte original grew to 101,785 bytes. My own stamp file came out far bigger (922,521 bytes, 922,681 after baking), because I wrote a full-size PNG and let PyMuPDF store it as it is, so downscaling to the printed size first is worth doing in a script.
Where this stops
The result is an image on the page. It doesn't prove who placed it, and nothing in the file detects a later edit. For that you need a real digital signature (a PKCS#7 signature over the file), which is a different mechanism. For the forms that only ask for a signature on the line, an alpha PNG baked into the page is the same thing as a printed and scanned signature, without the paper.
Everything above runs with uvx --with pymupdf --with numpy --with pillow python -I script.py. The app is free from the osFoundry download page, and the editor works on files on the computer, in line with its local-first approach.

