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) + potrace en une passe.
  • color — manuscrit à la main, plusieurs couleurs d’encre (classeurs CLG : rouge, vert, violet…). Séparation par teinte (HSV), une couche potrace par 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 (volume manuscrits-photos/vecteurs, servi en lecture seule par le même nginx que les photos).
  • Renvoie svg_url, preview_url et la ligne markdown à coller dans la page.
  • colors omis en mode color ⇒ 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 dans prompt_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).