DEV Community

Cover image for Comment redresser et binariser la photo d'un document avec OpenCV

Comment redresser et binariser la photo d'un document avec OpenCV

Une application de scanner fait trois choses à une photo prise au téléphone : elle repère la feuille, déforme ses quatre coins en rectangle, puis réduit le résultat à de l'encre et du papier. Aucune de ces trois étapes n'a rien d'exotique, mais chacune a un endroit où elle se trompe sans le dire. L'éditeur PDF de l'application de bureau osFoundry le fait avec quatre poignées de coin et un bouton Black and white. Cet article reconstruit le même enchaînement en Python avec OpenCV et le mesure, car il existe un moyen de le noter précisément : la photo, je l'ai générée moi-même, donc la page « imprimée » et les coins où elle a été posée sont connus.

La photo de test

make_scan.py dessine avec Pillow la première page d'un bail inventé (Times New Roman, 1275 × 1650, Letter à 150 ppp), puis la « photographie » virtuellement : une transformation de perspective sur un canevas de 1500 × 2000, une table en bois veiné, une ombre douce sous le papier, une luminosité qui baisse vers le bas à droite, un peu de bruit et un JPEG de qualité 82. La photo compte 253 niveaux de gris distincts, et les vrais coins sont (212,236) (1318,184) (1392,1652) (150,1748).

Tout ce qui suit s'exécute avec :

uvx --with opencv-python-headless --with numpy --with pillow --with pymupdf python -I measure.py
Enter fullscreen mode Exit fullscreen mode

OpenCV 5.0.0, NumPy 2.5.4, Python 3.12.15, sur un Apple M1. Les temps sont des mesures uniques : à lire comme des ordres de grandeur.

Trouver les quatre coins

La feuille est la grande tache claire. On floute la photo pour que le texte ne compte plus, on laisse Otsu choisir le seuil, on ferme les trous et on garde le plus grand contour :

blur = cv2.GaussianBlur(gray, (0, 0), 6)
t, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)   # t = 144
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((25, 25), np.uint8))
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
c = max(contours, key=cv2.contourArea)
hull = cv2.convexHull(c)
Enter fullscreen mode Exit fullscreen mode

Réduire l'enveloppe convexe à quatre points avec approxPolyDP est l'étape suivante habituelle, et c'est là que se trouve le premier piège. L'epsilon qui donne exactement quatre points dépend de la photo : on parcourt donc une plage d'epsilon et on garde le premier qui en renvoie quatre. Même ainsi, les coins ne sont qu'approximatifs. Par rapport aux vrais coins, les quatre points de approxPolyDP étaient décalés de 3.0, 5.7, 9.9 et 6.4 px (haut gauche, haut droit, bas droit, bas gauche). Sur 1500 px de large, cela paraît peu, mais après la déformation l'erreur est multipliée par le facteur d'échelle, et un texte de quelques pixels d'épaisseur y est sensible.

Un meilleur coin est l'intersection de deux bords bien ajustés. On prend les points du contour proches de chaque côté du quadrilatère approximatif, on y ajuste une droite avec une perte robuste pour que quelques points isolés ne la fassent pas basculer, puis on intersecte les droites voisines :

def refine(q, pts):
    lines = []
    for i in range(4):
        a, b = q[i], q[(i + 1) % 4]
        e = (b - a) / np.linalg.norm(b - a); n = np.array([-e[1], e[0]])
        along = (pts - a) @ e; dist = np.abs((pts - a) @ n); L = np.linalg.norm(b - a)
        sel = pts[(dist < 12) & (along > 0.08 * L) & (along < 0.92 * L)]   # skip the corners themselves
        vx, vy, x0, y0 = cv2.fitLine(sel, cv2.DIST_HUBER, 0, 0.01, 0.01).ravel()
        lines.append((np.array([x0, y0]), np.array([vx, vy])))
    out = []
    for i in range(4):
        (p1, d1), (p2, d2) = lines[i - 1], lines[i]
        t = np.linalg.solve(np.array([d1, -d2]).T, p2 - p1)[0]
        out.append(p1 + t * d1)
    return np.array(out, np.float32)
Enter fullscreen mode Exit fullscreen mode

L'erreur par rapport aux vrais coins tombe à 2.0, 2.2, 6.5 et 2.0 px. La recherche complète prend de 12 à 20 ms. Le seul coin qui reste à 6.5 px est celui du bas à droite, qui est aussi là où tombe l'ombre sous le papier sur cette photo.

Déformer vers un rectangle

getPerspectiveTransform prend les quatre coins et les quatre coins de la sortie, et warpPerspective fait le reste :

w = round((np.linalg.norm(q[1] - q[0]) + np.linalg.norm(q[2] - q[3])) / 2)
h = round((np.linalg.norm(q[3] - q[0]) + np.linalg.norm(q[2] - q[1])) / 2)
M = cv2.getPerspectiveTransform(q, np.float32([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]]))
flat = cv2.warpPerspective(gray, M, (w, h), flags=cv2.INTER_CUBIC)
Enter fullscreen mode Exit fullscreen mode

La déformation prend de 2 à 6 ms. Le deuxième piège est la taille de sortie. Faire la moyenne des longueurs des côtés opposés est la façon évidente de choisir w et h, mais la perspective fait paraître le côté proche plus long que le côté lointain, et la moyenne ne retrouve pas le vrai rapport. Ici, elle a donné 1174 × 1488, soit un rapport de 0.789, pour une page qui vaut en réalité 0.7727 (Letter). C'est un étirement de 2 %. Si vous connaissez le format du papier, utilisez-le ; sinon, acceptez une page légèrement étirée, comme le font les applications de scanner.

Déformer d'abord n'est pas facultatif. Otsu sur la photo non déformée, table comprise, marque 42.9 % des pixels comme de l'encre. Le bois est plus sombre que le papier et il est compté avec le texte.

Un effet secondaire apparaît dans l'histogramme. La photo a 253 niveaux de gris ; après la déformation bicubique, il y en a 255, parce que l'interpolation invente des valeurs entre les anciennes. C'est un bon rappel : le « nombre de niveaux de gris » ne veut rien dire avant la binarisation.

Binariser

Trois façons de transformer la page plate en niveaux de gris en deux valeurs :

def binarize(g, how):
    if how == 'global':      # one threshold for the whole page
        return cv2.threshold(g, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
    if how == 'adaptive':    # threshold relative to each pixel's neighbourhood
        return cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 41, 12)
    if how == 'flatfield':   # divide out the paper's own lighting, then one global threshold
        bg = cv2.medianBlur(cv2.dilate(g, np.ones((7, 7), np.uint8)), 51)
        n = np.clip(g.astype(np.float32) / np.maximum(bg, 1) * 255, 0, 255).astype(np.uint8)
        return cv2.threshold(n, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
Enter fullscreen mode Exit fullscreen mode

Pour les noter, j'ai ramené chaque résultat à la taille de la vraie page et comparé les pixels d'encre. La coïncidence exacte des pixels (IoU) est sévère pour du texte fin, puisqu'un trait décalé d'un pixel compte pour un échec ; le tableau donne donc aussi la précision et le rappel avec une tolérance de 3 px : la part de l'encre prédite qui a de l'encre réelle à moins de 3 px, et la part de l'encre réelle qui a de l'encre prédite à moins de 3 px. La vraie page contient 1.85 % d'encre.

Méthode Encre IoU Précision (3 px) Rappel (3 px) Temps
Globale (Otsu) 2.64% 0.602 0.944 1.000 1.0 ms
Adaptative gaussienne 3.15% 0.554 0.948 1.000 9.1 ms
Correction de fond + Otsu 2.81% 0.591 0.941 1.000 46.4 ms
Page enregistrée par l'app 2.36% 0.633 1.000 1.000 —

Sur cette photo, on ne les distingue pas. Après la déformation, la feuille est partout bien plus claire que l'encre, et un seul seuil suffit. Les trois gardent chaque trait (rappel 1.000) et les épaississent un peu (2.6 à 3.2 % d'encre contre 1.85 %).

La dernière ligne est la page enregistrée par l'éditeur PDF d'osFoundry pour la même photo, notée de la même façon. J'ai relu l'image de la page dans le PDF enregistré avec PyMuPDF : elle a exactement 2 niveaux de gris et 2.36 % d'encre. Je n'ai pas regardé comment l'éditeur procède ; cette ligne ne compare donc que des résultats. Elle montre qu'un outil à quatre poignées déplaçables arrive dans la même fourchette que l'enchaînement scripté.

Quand le seuil global casse

L'éclairage de la photo était doux, ce qui cache la raison d'être des deux autres méthodes. Pour la voir, j'ai multiplié la page plate par une rampe qui assombrit le bas à droite jusqu'à 40 % de luminosité, comme l'ombre d'une main sur un formulaire, et j'ai relancé les trois méthodes :

Méthode Encre Précision (3 px) Rappel (3 px)
Globale (Otsu) 49.85% 0.072 1.000
Adaptative gaussienne 3.04% 0.946 1.000
Correction de fond + Otsu 2.81% 0.939 1.000

La page avec une ombre ajoutée, puis le seuil global, puis la correction de fond

Otsu global noircit la moitié dans l'ombre : le rappel reste parfait pendant que la précision tombe à 0.07. Les résultats adaptatif et par correction de fond bougent à peine. L'adaptatif est le premier à essayer : il coûte environ 8 ms de plus que le global et n'ajoute aucune étape. La correction de fond donne presque le même résultat et elle est plus lente (46 ms ici), donc rien ne justifie de la préférer sur cette page.

Le code ci-dessus est tout l'enchaînement ; la notation compare seulement les pixels d'encre à la page dont la photo est issue. L'éditeur fait partie de l'application de bureau osFoundry gratuite, et la page local-first décrit comment osFoundry garde le travail sur votre propre machine.

Top comments (0)