Le foyer produisait du papier plus vite qu’il ne le classait : factures, relevés, courriers administratifs, empilés dans un tiroir en attendant un hypothétique tri. Le vrai problème n’était pas le scan lui-même — n’importe quelle imprimante multifonction Wi-Fi sait numériser une page — c’était tout ce qu’il fallait faire après : nommer le fichier, deviner l’expéditeur, ranger au bon endroit, se souvenir où. Ce post documente la construction d’un pipeline qui fait disparaître cette étape manuelle, avec un morceau d’algorithmique pur au milieu qui s’est avéré plus intéressant que prévu.
Le problème du bac simplex
Le point de départ est une contrainte matérielle bête et méchante. L’imprimante multifonction du foyer expose son scanner en eSCL (le protocole standard qu’utilisent la plupart des imprimantes réseau modernes pour être pilotées sans driver propriétaire). Interroger ses capacités révèle vite la limite :
GET /eSCL/ScannerCapabilities
→ <scan:AdfSimplexInputCaps> présent
→ <scan:AdfDuplexInputCaps> absent
Le chargeur automatique (ADF) ne sait scanner qu’une seule face par passage. Pas de duplex matériel. Pour archiver un courrier recto-verso sans ressaisir quoi que ce soit au clavier, il ne reste que deux options : scanner à la vitre page par page (fastidieux dès la deuxième feuille), ou faire deux passes dans l’ADF — d’abord tous les rectos dans l’ordre normal, puis retourner la pile entière et repasser tous les versos — et reconstituer l’ordre logique en logiciel.
La deuxième option est la seule qui passe à l’échelle. Mais elle transforme un geste simple en un vrai casse-tête d’indices : après les deux passes, on a deux PDF distincts (front.pdf, back.pdf), et il faut retrouver quelle page physique correspond à quel feuillet, et dans quel sens. C’est ce casse-tête — pas le scan — qui est devenu le cœur technique de ce projet.
Architecture
Tout tourne en Docker Compose sur le NAS du homelab (architecture x86, donc hors du cluster Kubernetes en ARM64, et hors GitOps — le repo vit dans un dépôt git local dédié, pas dans le repo d’infra du cluster). Le flux :
scanservjs pilote la capture eSCL depuis une interface web (un favori sur le téléphone ou le PC suffit). Pour un scan recto simple, il dépose directement le PDF dans consume/, le dossier surveillé par Paperless. Pour un duplex, deux actions distinctes écrivent staging/front/front.pdf puis staging/back/back.pdf ; un petit service Python dédié observe ce dossier, attend que les deux fichiers soient présents et stables (plus de croissance de taille pendant quelques secondes — pour éviter d’apparier un vieux front.pdf oublié avec un back.pdf tout frais), assemble le PDF final dans l’ordre logique, et le dépose à son tour dans consume/. À partir de ce point, le flux duplex et le flux recto sont strictement identiques : Paperless ne sait même pas qu’un document est passé par deux scans.
Un détail d’infra qui a eu un vrai impact sur l’implémentation : le NAS héberge déjà une trentaine de conteneurs (média, Nextcloud, CI…), donc plusieurs ports usuels étaient déjà pris. Le port 8080, par exemple, est occupé par le client BitTorrent du NAS — scanservjs a dû prendre le 8081. Paperless a récupéré le 8000, resté libre.
| Service | Port | Rôle |
|---|---|---|
| scanservjs | 8081 | UI de déclenchement du scan |
| Paperless webserver | 8000 | archive + recherche + API |
| Postgres, Redis, Tika, Gotenberg | internes | pas exposés hors du réseau Docker |
Les données persistantes (archive de documents, base Postgres, dossiers consume/staging) vivent sur le volume RAID1 du NAS plutôt que sur son SSD système — une convention déjà en place pour les autres services hébergés là (voir la partie 1 sur le montage du NAS).
L’algorithme d’entrelacement, écrit test-first
C’est la partie qui méritait le plus de rigueur, et celle où une approche test-first a évité un bug silencieux et difficile à repérer a posteriori — parce qu’il ne casse rien visiblement, il inverse juste des pages, ce qu’on ne remarque qu’en relisant un document des mois plus tard.
Le service repose sur trois fonctions pures, écrites et testées avant d’écrire la moindre ligne de logique métier autour :
plan_order(n_front, n_back)— calcule la séquence entrelacée finale, en indiquant pour chaque position si on prend un recto ou un verso, et lequel.reindex_back(n_back)— gère le piège central (détaillé plus bas).is_blank(coverage, threshold)— décide si une page doit être écartée parce qu’elle est vide.
Le piège : la pile de versos est physiquement inversée
L’intuition naïve serait de dire : « la première page de back.pdf est le verso de la première feuille ». C’est faux, et c’est le genre d’erreur qu’on ne détecte qu’en relisant un document reconstitué et en s’apercevant que les pages sont mélangées.
Voici pourquoi. On scanne d’abord tous les rectos dans l’ordre (feuille 1, 2, 3…). Puis on retourne la pile entière pour repasser les versos dans l’ADF. Retourner une pile de feuilles inverse leur ordre physique : la feuille qui était en dessous se retrouve en dessus. Résultat, back.pdf contient les versos dans l’ordre inverse des rectos correspondants — le premier verso scanné est celui de la dernière feuille, pas de la première.
Il faut donc un remapping explicite entre « numéro de feuille » et « index brut dans le PDF verso ». C’est exactement le rôle de reindex_back :
recto.pdf — ordre de scan (= ordre des feuilles)① recto.pdf — scan order (= sheet order) verso.pdf — pile retournée avant le 2ᵉ passage② verso.pdf — stack flipped before the 2nd pass consume/ — ordre reconstitué par plan_order③ consume/ — order reconstructed by plan_order verso.pdf est scanné après avoir retourné toute la pile : son index brut 0 est en réalité
la dernière feuille. reindex_back remonte ce mapping avant l'entrelacement,
et la page verso vide — sous le seuil de couverture — est écartée du PDF final par is_blank. verso.pdf is scanned after the whole stack has been flipped : its raw index 0 is actually
the last sheet. reindex_back undoes this mapping before interleaving,
and the empty back page — below the coverage threshold — is dropped from the final PDF by is_blank. def reindex_back(n_back: int) -> list[int]:
"""La pile 'back' est scannée après retournement -> ordre inversé."""
return list(range(n_back - 1, -1, -1))
Avec 3 versos, reindex_back(3) renvoie [2, 1, 0] : le verso de la feuille 0 est à l’index brut 2 dans back.pdf, celui de la feuille 1 est à l’index 1, celui de la feuille 2 est à l’index 0. plan_order s’appuie dessus pour construire la séquence logique complète :
def plan_order(n_front: int, n_back: int) -> list[tuple[str, int]]:
"""Séquence entrelacée recto/verso.
("B", k) = verso de la feuille k (back ré-inversé). Fronts excédentaires en fin.
"""
back_read = reindex_back(n_back)
order: list[tuple[str, int]] = []
for i in range(n_front):
order.append(("F", i))
if i < n_back:
order.append(("B", back_read[i]))
return order
Le test qui capture le piège directement — le cas d’une liasse de 3 feuilles où la dernière n’a que du texte au recto (2 versos seulement) :
def test_reindex_back_reverses():
assert reindex_back(3) == [2, 1, 0]
assert reindex_back(0) == []
def test_plan_order_last_sheet_single_sided():
# 3 fronts, 2 backs (dernière feuille recto seul)
assert plan_order(3, 2) == [
("F", 0), ("B", 0),
("F", 1), ("B", 1),
("F", 2),
]
def test_plan_order_no_backs_is_simplex():
assert plan_order(2, 0) == [("F", 0), ("F", 1)]
Le deuxième test vérifie que le décompte impair (une feuille recto-seul en fin de liasse) ne casse pas l’entrelacement — le recto orphelin est simplement ajouté sans verso associé. Le troisième vérifie le cas dégénéré : zéro verso, c’est-à-dire le flux recto simple, qui doit produire exactement la même séquence qu’un scan simplex normal. Avoir ce cas comme test explicite garantit que le chemin « recto seul » et le chemin « duplex » partagent la même fonction sans branche spéciale fragile.
Écarter les pages blanches
Reste un problème pratique : une liasse au nombre de feuilles impair produit une dernière page verso blanche (rien n’a été imprimé au dos de la dernière feuille), qu’il ne faut pas archiver. La détection se fait par couverture de pixels : on rend la page en niveaux de gris à basse résolution et on compte la fraction de pixels qui ne sont pas quasi-blancs.
def is_blank(coverage: float, threshold: float = 0.004) -> bool:
"""coverage = fraction de pixels non-blancs. Sous le seuil -> page blanche."""
return coverage < threshold
Le seuil (0,4 % de couverture) a été choisi pour tolérer le bruit de fond normal d’un scan (poussière, légère variation de luminosité) sans laisser passer une page qui contient ne serait-ce qu’une ligne de texte ou un tampon. La fonction d’assemblage finale parcourt plan_order, résout chaque ("B", i) via reindex_back pour retrouver l’index brut réel, calcule la couverture de chaque page candidate et ne l’ajoute au PDF final que si elle n’est pas blanche. Le tout est validé par un test de bout en bout qui construit un vrai petit PDF de 3 feuilles recto-verso (6 pages numérotées) et vérifie que l’assemblage produit bien l’ordre 1, 2, 3, 4, 5, 6.
La bascule OCR → classification
Une fois l’archive alimentée et l’OCR en place (Tesseract, trois langues configurées — français, anglais, allemand, pour couvrir aussi bien le courrier local que les documents professionnels et les notices en allemand), chaque document devient recherchable en texte intégral en quelques minutes après le scan. C’est déjà la moitié de la valeur du projet : retrouver n’importe quel document en tapant un mot-clé, plutôt qu’en fouillant un tiroir.
Mais le vrai gain de temps au quotidien vient d’ailleurs : ne plus avoir à taper soi-même le titre, l’expéditeur ou le type de chaque document scanné. Un second service lit le texte OCR de chaque nouveau document dès qu’il est consommé et le fait analyser par un modèle de langage, qui renvoie titre, correspondant, type de document et tags pertinents — réécrits automatiquement dans les métadonnées Paperless.
Le choix « local d’abord »
Envoyer des factures, des relevés bancaires ou des documents de santé à une API cloud pour les faire classer était l’option la plus simple techniquement, et la première écartée. Le modèle de langage tourne à la place sur un PC séparé du NAS, joignable uniquement depuis le réseau local — jamais sur le NAS lui-même, dont le processeur basse consommation n’est pas dimensionné pour de l’inférence lourde. Un repli vers une API cloud a été envisagé dans la conception mais volontairement reporté : pour l’instant, si la machine qui héberge le modèle est éteinte, la classification automatique attend simplement qu’elle soit rallumée plutôt que de basculer ailleurs.
Le compromis est assumé : un modèle local, plus petit et parfois moins précis qu’une API cloud de pointe, contre la garantie que des documents personnels sensibles ne quittent jamais le réseau local. Le modèle utilisé est un modèle généraliste d’environ 12 milliards de paramètres, quantifié, servi via Ollama — largement suffisant pour extraire un titre, un émetteur et une poignée de tags d’un texte OCR déjà propre, une tâche d’extraction plus que de génération créative.
Itération de la classification : de « titre lisible » à « classement par expéditeur »
La classification automatique n’est pas arrivée d’un coup à son état actuel — elle s’est construite en deux temps distincts.
Premier temps. L’objectif initial était simplement de sortir d’un nom de fichier du type scan-20260615-143200.pdf pour obtenir un titre lisible, un correspondant identifié et quelques tags cohérents. Le service a d’abord tourné en mode manuel : chaque proposition du modèle passait par une revue avant d’être écrite dans Paperless, le temps de vérifier que la qualité était au rendez-vous et d’ajuster la consigne donnée au modèle (notamment pour forcer des réponses en français). Une fois la qualité jugée fiable sur un échantillon de documents réels, la classification est passée en automatique.
Deuxième temps, plus récent. Une fois l’archive bien alimentée, un défaut est apparu à l’usage : les documents d’un même émetteur — une administration, un assureur — se retrouvaient dispersés par année dans le classement, avec en plus des variantes de nom légèrement différentes d’un document à l’autre (« Assurance X » sur une lettre, « ASSURANCE X SA » sur une autre) qui empêchaient tout regroupement naturel. La correction a ajouté une couche de résolution d’expéditeur canonique : une table de règles reconnaît les formulations usuelles d’un même émetteur et les fait converger vers un seul nom, avec un repli vers le modèle de langage pour les émetteurs non couverts par la table, et une valeur par défaut générique pour les cas non identifiables. Le classement final dans le stockage en réseau accessible depuis les appareils du foyer suit désormais l’arborescence Personne / Expéditeur / Année, plutôt que Personne / Année seul — tout le courrier d’un même émetteur regroupé, avec l’année en sous-niveau plutôt qu’en clé de tri principale.
Ce qui reste hors-scope (volontairement)
Deux volets identifiés dès la conception initiale restent délibérément non implémentés. Le premier est la diffusion automatique : générer une note de synthèse et une notification à chaque document traité, pour ne plus avoir à ouvrir l’archive pour savoir ce qui vient d’être scanné. Le second est le durcissement : authentification sur les interfaces web du scanner et de la classification, et intégration explicite de l’archive documentaire à la stratégie de sauvegarde existante du NAS.
Ce ne sont pas des trous découverts après coup — ce sont des phases explicitement reportées dès le départ, une fois la valeur essentielle (scan → OCR → classement) livrée et fonctionnelle. La suite logique plutôt qu’une dette cachée.
Leçon transférable
Le réflexe le plus facile face à « je veux que mon archive documentaire se classe toute seule » est de foncer vers le plus gros modèle disponible et de tout lui déléguer. Ce projet a fini par prouver le contraire : la partie qui a demandé le plus de rigueur technique — l’entrelacement recto-verso — n’a rien à voir avec l’IA. C’est un problème d’algorithmique classique, avec un piège précis (l’inversion de la pile retournée) qui se prête parfaitement à des tests écrits avant le code. Le modèle de langage, lui, n’intervient qu’à la toute fin, sur un texte déjà OCR-isé et propre, pour une tâche d’extraction bornée — pas pour compenser une architecture bâclée en amont.
Le pipeline complet, capture eSCL et migration de la stack média du NAS comprises, s’appuie sur l’infrastructure posée dans la partie 2 du montage du NAS.