Vectorisation déterministe des schémas manuscrits
Principe
Ce script ne régénère jamais le contenu d’un schéma — il trace géométriquement les pixels d’encre réels du scan en chemins vectoriels (via potrace). Zéro hallucination possible, contrairement à un pipeline de diffusion / img2img. Chaque chiffre, lettre hébraïque ou trait du SVG final correspond exactement à ce qui est tracé sur le papier.
Deux modes :
line— encre noire uniforme (ex. planches imprimées TSI 1970). Seuillage simple (correction d’éclairage + Otsu) +potraceen une passe.color— manuscrit à la main, plusieurs couleurs d’encre (classeurs CLG : rouge, vert, violet…). Séparation par teinte (HSV), une couchepotracepar couleur, fusion en un seul SVG multicouleur.
Validé sur
- TSI-1970 p.191, Fig. 28 (chandelier à 7 branches, imprimé, mode
line) — fidélité totale, aucune perte. - PMD p12 (Magen David, manuscrit couleur à main levée, mode
color) — fidélité totale sur rouge/vert/violet ; limite connue : l’orange se confond avec le rouge sur cette photo (teintes trop proches), quelques micro-parasites résiduels en périphérie (bruit du quadrillage, seuil de saturation bas nécessaire pour capter les petits chiffres).
Dépendances
Système : potrace, librsvg2-bin (rsvg-convert), imagemagick (convert)
Python : opencv-python, pillow, numpy
Usage
# Mode ligne (dessin noir uniforme, ex. planche imprimée)
python3 vectorize_manuscript.py <image_in> <out_dir> --mode line \
--crop 0.02,0.235,0.855,0.685
# Mode couleur (manuscrit à main levée, plusieurs encres)
python3 vectorize_manuscript.py <image_in> <out_dir> --mode color \
--crop 0.18,0.06,0.98,0.68 \
--colors "rouge:0-15,175-180:45" "vert:40-90:45" "violet:140-175:18"--colors : nom:hue_lo-hue_hi[,hue_lo2-hue_hi2]:sat_min (teintes sur l’échelle OpenCV 0-180). À ajuster à l’œil par essai/erreur selon les stylos utilisés et l’éclairage de la photo — pas de détection automatique des couleurs pour l’instant (piste d’amélioration : clustering K-means sur la teinte pour proposer des --colors par défaut).
Sortie : <out_dir>/vector.svg + preview.png + report.json
Code source
#!/usr/bin/env python3
"""
vectorize_manuscript.py — Vectorisation deterministe (potrace) des schemas
manuscrits de CLG pour le projet Guematrie du Tanakh.
PRINCIPE : ceci NE regenere jamais le contenu. Chaque trait, chiffre ou
lettre hebraique du SVG final est un tracage geometrique exact des pixels
d'encre du scan original. Zero hallucination possible (contrairement a un
pipeline de diffusion / img2img).
Deux modes :
- "line" : dessin a l'encre noire uniforme (ex. planches imprimees TSI).
Seuillage simple + potrace en une passe.
- "color" : manuscrit a la main, plusieurs couleurs d'encre (ex. classeurs
CLG : rouge, vert, violet...). Separation par teinte (HSV),
une couche potrace par couleur, fusion en un seul SVG.
Dependances systeme : potrace, librsvg2-bin (rsvg-convert), imagemagick (convert)
Dependances Python : opencv-python, pillow, numpy
USAGE :
python3 vectorize_manuscript.py <image_in> <out_dir> \
--mode color \
--crop 0.02,0.24,0.98,0.69 \
--colors rouge:0-15,165-180:45 vert:40-90:45 violet:140-165:18
python3 vectorize_manuscript.py <image_in> <out_dir> --mode line
Sortie : <out_dir>/vector.svg + <out_dir>/preview.png + <out_dir>/report.json
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
import cv2
import numpy as np
def crop_fractional(img, crop_str):
"""crop_str: 'left,top,right,bottom' en fractions 0-1 de la largeur/hauteur"""
h, w = img.shape[:2]
l, t, r, b = [float(x) for x in crop_str.split(",")]
return img[int(t * h):int(b * h), int(l * w):int(r * w)]
def flatten_illumination_gray(gray, sigma=25):
"""Corrige l'ombre/gradient d'eclairage sur une image en niveaux de gris
(division par une version tres floutee) puis Otsu pour binariser."""
bg = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma)
norm = cv2.divide(gray, bg, scale=255)
_, th = cv2.threshold(norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
kernel = np.ones((2, 2), np.uint8)
return cv2.morphologyEx(th, cv2.MORPH_OPEN, kernel, iterations=1)
def flatten_illumination_lab(bgr, sigma=41):
"""Corrige l'eclairage en preservant la chrominance (espace LAB) —
utile en amont d'une separation par couleur."""
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB).astype(np.float32)
L, A, B = cv2.split(lab)
bgL = cv2.GaussianBlur(L, (0, 0), sigmaX=sigma)
L_flat = np.clip(L - bgL + 200, 0, 255)
lab_fixed = cv2.merge([L_flat, A, B]).astype(np.uint8)
return cv2.cvtColor(lab_fixed, cv2.COLOR_LAB2BGR)
def build_color_mask(hsv, hue_ranges, sat_min):
"""hue_ranges: liste de (lo,hi) sur l'echelle OpenCV 0-180 (gere le
wrap-around si lo>hi, ex red = [(0,15),(165,180)])."""
h, s, v = cv2.split(hsv)
mask = np.zeros(h.shape, dtype=bool)
for lo, hi in hue_ranges:
mask |= (h >= lo) & (h <= hi)
mask &= s > sat_min
return mask
def mask_to_pbm(mask, out_png, out_pbm):
out = np.where(mask, 0, 255).astype(np.uint8)
inv = 255 - out
kernel = np.ones((3, 3), np.uint8)
inv = cv2.morphologyEx(inv, cv2.MORPH_CLOSE, kernel, iterations=2)
out = 255 - inv
cv2.imwrite(str(out_png), out)
subprocess.run(["convert", str(out_png), "-depth", "1", str(out_pbm)], check=True)
def potrace_svg(pbm_path, svg_path, turdsize=8, opttolerance=0.3):
subprocess.run(
["potrace", str(pbm_path), "-s", "-o", str(svg_path),
"--turdsize", str(turdsize), "--opttolerance", str(opttolerance)],
check=True,
)
def extract_potrace_group(svg_path):
import re
content = Path(svg_path).read_text()
m = re.search(
r'(<g transform="[^"]*"\s*\nfill="[^"]*" stroke="none">\n)(.*?)(\n</g>)',
content, re.S,
)
if not m:
return None, None
return m.group(1), m.group(2)
def merge_svg_layers(layers, width_pt, height_pt, viewbox_w, viewbox_h, out_svg):
"""layers: liste de (transform_tag, inner_path, hex_color)"""
header = (
'<?xml version="1.0" standalone="no"?>\n'
'<svg version="1.0" xmlns="http://www.w3.org/2000/svg"\n'
f' width="{width_pt}pt" height="{height_pt}pt" '
f'viewBox="0 0 {viewbox_w} {viewbox_h}"\n'
' preserveAspectRatio="xMidYMid meet">\n'
'<metadata>Vectorisation deterministe (potrace) — projet Guematrie</metadata>\n'
)
body = ""
for transform_tag, inner, color in layers:
if transform_tag is None:
continue
tt = transform_tag.replace('fill="#000000"', f'fill="{color}"')
body += tt + inner + "\n</g>"
Path(out_svg).write_text(header + body + "\n</svg>\n")
DEFAULT_PALETTE = {
"rouge": "#c0392b", "vert": "#2e6b3e", "violet": "#8e3a6b",
"orange": "#d4772a", "bleu": "#2e4f8e", "noir": "#1a1a1a",
}
def parse_color_hints(specs):
""" 'rouge:0-15,165-180:45' -> ('rouge', [(0,15),(165,180)], 45) """
out = []
for spec in specs:
name, ranges_str, sat = spec.split(":")
ranges = []
for part in ranges_str.split(","):
lo, hi = part.split("-")
ranges.append((int(lo), int(hi)))
out.append((name, ranges, int(sat)))
return out
def run_line_mode(img, out_dir):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (3, 3), 0)
th = flatten_illumination_gray(blur)
png = out_dir / "mask.png"
pbm = out_dir / "mask.pbm"
cv2.imwrite(str(png), th)
subprocess.run(["convert", str(png), "-depth", "1", str(pbm)], check=True)
svg = out_dir / "vector.svg"
potrace_svg(pbm, svg, turdsize=5, opttolerance=0.3)
return {"mode": "line", "svg": str(svg)}
def run_color_mode(img, out_dir, color_hints):
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
h, w = img.shape[:2]
layers = []
for name, ranges, sat_min in color_hints:
mask = build_color_mask(hsv, ranges, sat_min)
if mask.sum() < 200:
continue
png = out_dir / f"mask_{name}.png"
pbm = out_dir / f"mask_{name}.pbm"
svg = out_dir / f"layer_{name}.svg"
mask_to_pbm(mask, png, pbm)
potrace_svg(pbm, svg, turdsize=10, opttolerance=0.3)
tag, inner = extract_potrace_group(svg)
color_hex = DEFAULT_PALETTE.get(name, "#333333")
layers.append((tag, inner, color_hex))
merged = out_dir / "vector.svg"
merge_svg_layers(layers, w, h, w, h, merged)
return {"mode": "color", "svg": str(merged), "layers": [l[2] for l in layers]}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("image_in")
ap.add_argument("out_dir")
ap.add_argument("--mode", choices=["line", "color"], default="line")
ap.add_argument("--crop", default=None, help="left,top,right,bottom en fractions 0-1")
ap.add_argument("--colors", nargs="*", default=[],
help="nom:hue_lo-hue_hi[,hue_lo2-hue_hi2]:sat_min")
args = ap.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
img = cv2.imread(args.image_in)
if img is None:
print(json.dumps({"error": f"cannot read {args.image_in}"}))
sys.exit(1)
if args.crop:
img = crop_fractional(img, args.crop)
if args.mode == "line":
result = run_line_mode(img, out_dir)
else:
hints = parse_color_hints(args.colors)
img_lab = flatten_illumination_lab(img)
# hue extraction on the *original* crop tends to be more reliable
# than post-LAB-correction (correction can crush saturation) —
# kept as `img`, not `img_lab`, based on empirical testing.
result = run_color_mode(img, out_dir, hints)
# render preview
preview = out_dir / "preview.png"
subprocess.run(["rsvg-convert", "-w", "1000", result["svg"], "-o", str(preview)], check=True)
result["preview"] = str(preview)
(out_dir / "report.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()Déployé comme outil MCP (2026-09-09, ticket 4bed3b3a)
Le script ci-dessus est désormais empaqueté dans le serveur MCP :
vectorize_manuscript_page(page_name, mode='line'|'color', crop?, colors?, filename?).
- Trouve la photo via la ligne
Photo : \fichier`de la page (même mécanisme quephoto_mark`). - Dépose le SVG + un aperçu PNG sur
manuscrits.nombresbibliques.com/vecteurs/<Classeur>/<photo>.svg(volumemanuscrits-photos/vecteurs, servi en lecture seule par le même nginx que les photos). - Renvoie
svg_url,preview_urlet la lignemarkdownà coller dans la page. colorsomis en modecolor⇒ détection automatique des encres (K-means sur la teinte des pixels saturés, projetée sur le cercle unité pour gérer le wrap-around du rouge, puis fusion des plages qui se touchent). Limite connue : deux encres de teintes proches sont fusionnées en une seule couche — sur PMD p12 l’auto rend correctement le rouge et le vert mais peint le violet en rouge. Toujours regarder l’aperçu ; le réglage manuel reste le repli exact.- Le protocole de transcription (
photo_transcription_guide) intègre l’étape et la vérification d’aperçu. Reste à ajouter côté Desktop la ligne correspondante dansprompt_transcription_manuscrits_v2.md.
Revalidé en production sur les deux cas de référence : TSI-1970 p.191 Fig. 28 (mode line) et
PMD p12 (mode color, réglage manuel).