commit 16589716fddb8f7582eced362ff05ba626254995 Author: Alex Date: Thu Oct 1 19:12:46 2026 +0200 Version initiale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aa9a2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Cache Python et outils +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ + +# Archives de distribution +*.zip + +# SVG intermediaires du schema (regeneres par docs/schema_parametres.py) +docs/*.svg + +# Page de presentation : hors depot (publiee sur le NAS par site/deploy.ps1 ; +# sa configuration contient hote, compte et chemin de cle SSH) +site/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ecc88f --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# Iso-couches par nuances de gris — extension Inkscape + + + + + + +
+Une image de relief flou, sa découpe en 16 niveaux de gris, et les mêmes 16 niveaux au trait seul + + +Extension Inkscape 1.x qui transforme une image bitmap en planches à découper +et à empiler, un calque par nuance de gris. L'image est lissée, quantifiée en quelques +niveaux (16 par défaut), puis chaque aplat est bordé d'un contour fermé et +souple, à la manière des courbes de niveau d'une carte. Le résultat se découpe +en strates (une feuille par niveau) ou se réduit à un dessin au trait. + +De gauche à droite : l'image de départ, ses 16 niveaux remplis chacun de son +gris (16 calques), et les mêmes 16 niveaux en remplissage blanc et trait noir. + +
+ +Le lissage respecte les contours : sur un portrait, la peau et les cheveux sont +aplanis mais les yeux, la bouche et le bord du visage restent nets, et les +16 niveaux suffisent à reconstruire le visage. + + +## Sommaire + +- [Fonctionnalités](#fonctionnalités) +- [Installation](#installation) +- [Utilisation](#utilisation) +- [Paramètres](#paramètres) +- [Structure du projet](#structure-du-projet) +- [Développement](#développement) + +## Fonctionnalités + +- Entrée : une ou plusieurs images bitmap sélectionnées (PNG, JPEG…), + incorporées ou liées, y compris dans un groupe sélectionné. Les couleurs sont + lues comme des gris, la transparence comme du blanc. +- Lissage qui respecte les contours (filtre guidé) : les textures sont + aplanies, les transitions marquées restent nettes, ce qui garde un visage + reconnaissable. Un flou gaussien simple reste disponible pour les reliefs + abstraits. +- Quantification à seuils réguliers, de 2 à 64 niveaux, après étirement de la + plage de gris de l'image. +- Contours tracés au sous-pixel sur l'image lissée (« marching squares »), puis + simplifiés et convertis en courbes de Bézier : pas d'escalier de pixels. +- Toutes les formes sont fermées : là où un niveau touche le bord de l'image, + son contour longe ce bord, en lignes droites. +- Un calque Inkscape par niveau, nommé « Planche n° 1 », « Planche n° 2 »… + du fond vers l'avant, créé au-dessus de l'image : autant de nouveaux calques + que de niveaux de gris. Plusieurs images sélectionnées partagent les mêmes + calques. +- Planches à empiler : la planche n° 1 couvre toute l'image et chaque planche + suivante, plus petite, se pose devant la précédente (la plus claire devant, + ou la plus sombre devant), pour construire un relief en strates. +- Repère d'assemblage : chaque planche porte, en plus de son trait de découpe + (rouge), le contour de la planche suivante (noir), au tracé exact de sa + découpe, pour savoir où la coller. +- Variante « Son gris seul » : chaque calque contient l'aplat de son gris, + bordé sur tous ses côtés ; les planches pavent l'image sans se recouvrir et + ne portent pas de repère. Les contours sont exactement les frontières de + l'image quantifiée. +- Formes écrites en coordonnées du document, sans transformation, même pour + une image tournée, étirée ou placée dans un calque transformé. +- Remplissage au choix (gris du niveau, couleur unique, aucun) et trait + facultatif. +- Interface traduite en français et en anglais, selon la langue d'Inkscape + (anglais pour toute autre langue). +- Script de déploiement Windows. + +## Installation + +Aucune dépendance à installer : l'extension utilise `inkex`, `numpy` et +`Pillow`, tous fournis avec Inkscape. + +### Windows + +```powershell +.\deploy.ps1 # copie dans %APPDATA%\inkscape\extensions\GrayIsoLayers +.\deploy.ps1 -Uninstall # retire l'extension +.\deploy.ps1 -Force # déploie même si Inkscape est ouvert +``` + +Inkscape doit être fermé pendant la copie, puis relancé. + +### Manuelle + +Copier dans un sous-dossier du répertoire des extensions utilisateur +(`%APPDATA%\inkscape\extensions` sous Windows, `~/.config/inkscape/extensions` +sous Linux, `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions` +sous macOS) : + +- `gray_iso_layers.inx`, `gray_iso_layers.py`, `gray_iso_layers_core.py` ; +- `images/parameters_en.png` ; +- le dossier `locale/`. + +## Utilisation + +1. Importer une image (**Fichier > Importer**) et la sélectionner. +2. Lancer **Extensions > AlexDesign > Iso-couches par nuances de gris…** + (*Gray Iso-Layers*). +3. Régler le nombre de niveaux et le lissage, puis **Appliquer**. + +Le résultat est une série de calques « Planche n° 1 », « Planche n° 2 »… +ajoutés juste au-dessus du calque de l'image, un par niveau de gris. La +planche n° 1 est au fond, la dernière devant. Par défaut, les planches +s'empilent : la planche n° 1, la plus sombre, couvre toute l'image et les +suivantes se posent devant elle. Chaque calque contient deux chemins : la +« Découpe » de la planche (trait rouge) et le « Repère de la planche n° … » +suivante (trait noir, sans remplissage). Avec le contenu « Son gris seul », +chaque planche est l'aplat de son gris et il n'y a pas de repère. Un niveau dont toutes +les formes sont plus petites que le plus petit îlot conservé garde son calque, +vide. + +### Conseils + +- **Découpe laser** : choisir « Aucun remplissage » ; il ne reste que les + traits de découpe (rouge) et de marquage (noir), à affecter chacun à son + réglage dans le logiciel de la machine. Découper un calque par feuille. +- **Dessin au trait, comme une carte topographique** : remplissage « Couleur + unique » blanche (ou « Aucun remplissage »), trait noir, repère décoché. +- **Contours trop chargés, petits îlots** : augmenter le lissage ou le plus + petit îlot conservé. +- **Contours anguleux** : augmenter le lissage ou la simplification. +- **Détails perdus, visage méconnaissable** : réduire le lissage (0,5 à 1 %), + laisser « Préserver les contours » coché, augmenter la résolution de calcul. +- **Portrait** : un fond uni donne le meilleur résultat ; un fond en dégradé + produit des anneaux, puisque chaque nuance du dégradé devient un aplat. +- **Image liée introuvable** : l'incorporer (clic droit > Incorporer l'image). +- Une image de bruit flou (nuages, relief) donne les motifs les plus proches + d'une carte de courbes de niveau. + +## Paramètres + +![Schéma des paramètres](docs/parametres.png) + +Onglet **Niveaux** + +| Paramètre | Défaut | Rôle | +| --- | --- | --- | +| Nombre de niveaux de gris | 16 | Nombre d'aplats, donc de calques produits (2 à 64). | +| Contenu de chaque planche | Empilées, la plus claire devant | « Empilées, la plus claire devant » : planche n° 1 = la plus sombre, toute l'image. « Empilées, la plus sombre devant » : planche n° 1 = la plus claire, toute l'image. « Son gris seul » : planches côte à côte, sans recouvrement, planche n° 1 = le gris le plus sombre. | +| Reporter sur chaque planche le contour de la suivante | oui | Repère d'assemblage tracé dans la couleur de marquage (planches empilées uniquement). | +| Lissage (% du grand côté de l'image) | 1 | Lissage appliqué avant la découpe : plus il est fort, plus les contours sont ronds et simples. | +| Préserver les contours lors du lissage | oui | Aplanit la peau et les textures mais garde nettes les transitions marquées (portraits, photos). Décoché : flou gaussien simple, mieux adapté aux reliefs abstraits. | +| Résolution de calcul (points sur le grand côté) | 800 | Taille à laquelle l'image est rééchantillonnée pour le calcul. | +| Plus petit îlot ou trou conservé (% de la surface de l'image) | 0,02 | Les formes plus petites sont écartées. | +| Simplification (points de calcul) | 0,4 | Écart maximal toléré lors de la suppression de nœuds ; 0 les conserve tous. | +| Lisser les contours avec des courbes de Bézier | oui | Sinon, les contours sont des polygones. | + +Onglet **Style** + +| Paramètre | Défaut | Rôle | +| --- | --- | --- | +| Remplissage | Gris de chaque niveau | Gris du niveau, couleur unique ou aucun remplissage. | +| Couleur de remplissage | blanc | Utilisée par le mode « Couleur unique ». | +| Épaisseur du trait (0 = sans trait) | 0,2 | Dans l'unité choisie ; commune à la découpe et au marquage. | +| Unité | mm | Unité de l'épaisseur du trait. | +| Couleur du trait de découpe | rouge | Contour de la planche. | +| Couleur du trait de marquage | noir | Repère de la planche suivante. | +| Conserver l'image d'origine | oui | Sinon l'image est supprimée. | + +## Structure du projet + +```text +gray_iso_layers.inx Boîte de dialogue (textes source en anglais) +gray_iso_layers.py Couche inkex : lecture de l'image, écriture du SVG +gray_iso_layers_core.py Noyau de calcul, sans inkex (numpy seul) +i18n.py Extraction / mise à jour / compilation des traductions +po/ Catalogues grayisolayers.pot, en.po, fr.po +locale/ Catalogues compilés (.mo), déployés +images/parameters_en.png Schéma de l'onglet « Help » +docs/schema_parametres.py Générateur du schéma (fr pour le README, en pour le dialogue) +docs/parametres.png Schéma du README +docs/exemple.png Illustrations du README (relief, portrait) +docs/exemple-portrait.png +doc/patern.jpg Motif qui a servi de modèle +doc/personne.jpg Portrait d'essai +test_gray_iso_layers.py Tests pytest +deploy.ps1 Déploiement / désinstallation Windows +``` + +API du noyau (`gray_iso_layers_core.py`). Un champ est un tableau numpy 2D de +gris (0 noir, 1 blanc) ; un anneau est un tableau `(n, 2)` de points, en +échantillons du champ. + +| Fonction | Rôle | +| --- | --- | +| `gaussian_blur(field, sigma)` | Flou gaussien séparable, bords prolongés. | +| `box_blur(field, radius)` | Moyenne sur une fenêtre carrée. | +| `edge_preserving_blur(field, radius, contrast)` | Lissage qui respecte les contours (filtre guidé). | +| `smooth(field, blur, preserve_edges)` | Lissage avant découpe, puis étirement sur 0..1. | +| `normalize(field)` | Étire le champ sur 0..1. | +| `thresholds(levels)` | Seuils réguliers séparant les niveaux. | +| `contour_rings(field, threshold, above=True)` | Contours fermés de la région au-dessus (ou au-dessous) du seuil. | +| `ring_area(ring)` | Aire d'un anneau. | +| `simplify_ring(ring, tolerance)` / `simplify_chain(chain, tolerance)` | Simplification de Douglas-Peucker d'un anneau fermé, d'une ligne ouverte. | +| `contour_lines(field, threshold, tolerance, min_area)` | Ligne de niveau d'un seuil : boucles fermées et lignes ouvertes aboutissant au bord. | +| `region_rings(field, lines, inside)` | Contour fermé d'une région bornée par des lignes de niveau, refermé le long du bord de l'image. | +| `iso_boards(field, levels, blur, min_area, tolerance, shapes, preserve_edges)` | Une planche par niveau : liste de `(niveau, gris, anneaux, repère)`, le repère étant la ligne de niveau qui borde la planche suivante. | +| `iso_layers(…)` | Comme `iso_boards`, sans les repères. | +| `image_ring(width, height)` | Rectangle de l'image. | +| `scale_rings(rings, scale_x, scale_y, offset_x, offset_y)` | Passage au repère de destination. | +| `lines_to_d(lines, smooth, box, precision, matrix)` / `scale_lines(…)` | Écriture et mise à l'échelle d'une ligne de niveau (boucles fermées et lignes ouvertes). | +| `rings_to_d(rings, smooth, box, precision, matrix)` | Données `d` d'un chemin SVG, bords de l'image gardés droits, transformation affine facultative. | +| `parse_color(value)` / `gray_to_hex(gray)` | Couleurs. | + +## Développement + +```powershell +pip install pytest numpy pillow +python -m pytest -q +``` + +Sans `inkex` : 41 tests passent, 13 sont ignorés. Avec `inkex` : + +```powershell +pip install lxml tinycss2 cssselect2 cssselect +$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions' +python -m pytest -q # 54 tests passent +``` + +Les tests couvrent le noyau (lissage qui respecte les contours, contours d'un cône et d'une rampe, fermeture le +long des bords, cas selle, simplification, aplats conformes à l'image +quantifiée, frontières communes aux aplats voisins, feuilles à empiler, îlots +écartés, écriture des chemins), l'extension de bout en bout (un calque par niveau, repères d'assemblage, image incorporée, liée ou +tournée, groupe sélectionné, styles, messages d'erreur) et la cohérence des traductions et du +`.inx`. + +### Traductions + +Les textes source sont en anglais, dans le `.inx` et dans les `_("...")` du +`.py`. Après toute modification : + +```powershell +python i18n.py # extrait, met à jour po/*.po, compile locale/ +``` + +Compléter les `msgstr` vides de `po/fr.po`, puis relancer la commande. Pour +ajouter une langue, l'ajouter à `LANGUAGES` dans `i18n.py`. + +### Schéma des paramètres + +```powershell +python docs/schema_parametres.py +``` + +Produit `docs/parametres.png` (français, README) et `images/parameters_en.png` +(anglais, onglet « Help »), tracés avec les fonctions du noyau. L'export PNG +passe par `inkscape.com`. diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..c27409b --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Déploie l'extension « Iso-couches par nuances de gris » dans Inkscape. + +.DESCRIPTION + Copie gray_iso_layers.inx, gray_iso_layers.py, gray_iso_layers_core.py, le schéma de l'onglet + « Help » (images\parameters_en.png) et les traductions + compilées (locale\\LC_MESSAGES\grayisolayers.mo) dans un + sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape + (Inkscape explore récursivement ce répertoire). Refuse de s'exécuter si + Inkscape est ouvert : les extensions ne sont chargées qu'au démarrage. + +.PARAMETER ExtensionsDir + Répertoire des extensions Inkscape. Par défaut : %APPDATA%\inkscape\extensions + +.PARAMETER FolderName + Nom du sous-dossier de l'extension. Par défaut : GrayIsoLayers + +.PARAMETER Uninstall + Supprime le sous-dossier de l'extension au lieu de le créer. + +.PARAMETER Force + Déploie même si Inkscape est en cours d'exécution. + +.EXAMPLE + .\deploy.ps1 + .\deploy.ps1 -Uninstall + .\deploy.ps1 -FolderName 'autre-nom' +#> + +[CmdletBinding()] +param( + [string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'), + [string] $FolderName = 'GrayIsoLayers', + [switch] $Uninstall, + [switch] $Force +) + +$ErrorActionPreference = 'Stop' + +$files = @('gray_iso_layers.inx', 'gray_iso_layers.py', 'gray_iso_layers_core.py', 'images\parameters_en.png') +$localeDir = 'locale' +$source = $PSScriptRoot +$destination = Join-Path $ExtensionsDir $FolderName + +# --- Inkscape doit être fermé : les extensions sont lues au démarrage --------- +$running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue +if ($running -and -not $Force) { + Write-Error "Inkscape est ouvert. Fermez-le puis relancez le script (ou utilisez -Force)." + exit 1 +} + +# --- Désinstallation --------------------------------------------------------- +if ($Uninstall) { + if (Test-Path -LiteralPath $destination) { + Remove-Item -LiteralPath $destination -Recurse -Force + Write-Host "Supprimé : $destination" -ForegroundColor Green + } + else { + Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow + } + exit 0 +} + +# --- Vérification des sources ------------------------------------------------ +$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) } +if (-not (Test-Path -LiteralPath (Join-Path $source $localeDir))) { + $missing = @($missing) + "$localeDir\ (lancer : python i18n.py)" +} +if ($missing) { + Write-Error "Fichier(s) introuvable(s) dans $source : $($missing -join ', ')" + exit 1 +} + +# --- Copie ------------------------------------------------------------------- +if (-not (Test-Path -LiteralPath $destination)) { + New-Item -ItemType Directory -Path $destination -Force | Out-Null + Write-Host "Dossier créé : $destination" +} + +foreach ($file in $files) { + $from = Join-Path $source $file + $to = Join-Path $destination $file + $action = if (Test-Path -LiteralPath $to) { 'remplacé' } else { 'copié ' } + New-Item -ItemType Directory -Path (Split-Path $to) -Force | Out-Null + Copy-Item -LiteralPath $from -Destination $to -Force + Write-Host " $action $file" +} + +# Traductions : le dossier est remplacé en entier (langue retirée => supprimée) +$localeTo = Join-Path $destination $localeDir +if (Test-Path -LiteralPath $localeTo) { + Remove-Item -LiteralPath $localeTo -Recurse -Force +} +Copy-Item -LiteralPath (Join-Path $source $localeDir) -Destination $localeTo -Recurse +$languages = Get-ChildItem -LiteralPath $localeTo -Directory | ForEach-Object Name +Write-Host " copié $localeDir\ ($($languages -join ', '))" + +# Le cache Python d'une version précédente peut masquer les fichiers copiés +$pycache = Join-Path $destination '__pycache__' +if (Test-Path -LiteralPath $pycache) { + Remove-Item -LiteralPath $pycache -Recurse -Force +} + +Write-Host "" +Write-Host "Extension déployée dans $destination" -ForegroundColor Green +Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Iso-couches par nuances de gris..." diff --git a/doc/patern.jpg b/doc/patern.jpg new file mode 100644 index 0000000..0d8ee54 Binary files /dev/null and b/doc/patern.jpg differ diff --git a/doc/personne.jpg b/doc/personne.jpg new file mode 100644 index 0000000..e3d3107 Binary files /dev/null and b/doc/personne.jpg differ diff --git a/docs/exemple.png b/docs/exemple.png new file mode 100644 index 0000000..93afe90 Binary files /dev/null and b/docs/exemple.png differ diff --git a/docs/parametres.png b/docs/parametres.png new file mode 100644 index 0000000..da6211b Binary files /dev/null and b/docs/parametres.png differ diff --git a/docs/schema_parametres.py b/docs/schema_parametres.py new file mode 100644 index 0000000..a1ad48e --- /dev/null +++ b/docs/schema_parametres.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Genere le schema explicatif des parametres de l'extension. + + python docs/schema_parametres.py # les deux versions + python docs/schema_parametres.py fr # docs/parametres.svg + .png (README) + python docs/schema_parametres.py en # docs/parameters_en.svg + # + images/parameters_en.png (onglet + # « Help » de la boite de dialogue) + +Les formes sont calculees par gray_iso_layers_core (memes fonctions que l'extension), +le schema reste donc fidele au resultat reel. L'export PNG passe par +inkscape.com, qui attend la fin de l'export (contrairement au raccourci +Chocolatey « inkscape »). +""" + +import math +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) + +import numpy as np # noqa: E402 + +import gray_iso_layers_core as core # noqa: E402 + +W, H = 1400, 1280 # 4 panneaux de 700 x 640 + +INK = "#343a40" # element principal +GHOST = "#dde1e5" # elements secondaires / voisins +DIM = "#d9480f" # cotes +DIM2 = "#1971c2" # cotes secondaires +EDGE = "#212529" # contour des formes, repere d'assemblage +CUT = "#e03131" # trait de decoupe +FONT = "font-family:Arial,Helvetica,sans-serif" + +INKSCAPE = [r"C:\Program Files\Inkscape\bin\inkscape.com", "/usr/bin/inkscape", + "/Applications/Inkscape.app/Contents/MacOS/inkscape"] + +# Version par langue : textes, fichiers produits, largeur du PNG et agrandissement +# des textes (la version anglaise est affichee reduite dans la boite de dialogue : +# 900 px de large, textes x 1.25 pour rester lisibles). +VERSIONS = { + "fr": { + "svg": os.path.join(HERE, "parametres.svg"), + "png": os.path.join(HERE, "parametres.png"), + "png_width": 1400, + "font_scale": 1.0, + "text": { + "title1": "Image de départ", + "note1": ["Image bitmap sélectionnée, lue en nuances de gris", + "(couleurs converties, transparence = blanc)."], + "title2": "Lissage", + "low": "Lissage 0,5 %", + "high": "Lissage 4 %", + "note2": ["Lissage appliqué avant la découpe, en % du grand côté :", + "plus il est fort, plus les contours sont ronds."], + "title3": "Nombre de niveaux de gris", + "levels": "5 niveaux", + "note3": ["Seuils régulièrement espacés du plus sombre au plus", + "clair. Chaque aplat est bordé d'un contour fermé."], + "title4": "Une planche par niveau, à empiler", + "level": "Planche n° {}", + "all": "Planches empilées", + "note4": ["Un calque par planche. Rouge : découpe de la planche.", + "Noir : repère de la planche suivante, à coller dessus."], + }, + }, + "en": { + "svg": os.path.join(HERE, "parameters_en.svg"), + "png": os.path.join(ROOT, "images", "parameters_en.png"), + "png_width": 900, + "font_scale": 1.25, + "text": { + "title1": "Source image", + "note1": ["Selected bitmap image, read as shades of gray", + "(colors converted, transparency = white)."], + "title2": "Smoothing", + "low": "Smoothing 0.5 %", + "high": "Smoothing 4 %", + "note2": ["Smoothing applied before cutting, in % of the longer", + "side: the higher, the rounder the outlines."], + "title3": "Number of gray levels", + "levels": "5 levels", + "note3": ["Evenly spaced thresholds from darkest to lightest.", + "Each flat area gets a closed outline."], + "title4": "One board per level, to stack", + "level": "Board no. {}", + "all": "Stacked boards", + "note4": ["One layer per board. Red: cut outline of the board.", + "Black: marking of the next board, glued on top."], + }, + }, +} + + +out = [] +T = {} +FS = 1.0 + + +def fmt(v): + return "{:.2f}".format(v).rstrip("0").rstrip(".") + + +def pts(polyline): + return " ".join("{},{}".format(fmt(x), fmt(y)) for x, y in polyline) + + +def polyline(points, color, width, extra=""): + out.append(''.format(pts(points), color, fmt(width), extra)) + + +def text(x, y, s, size=17, color=INK, anchor="start", weight="normal", halo=True): + style = "{};font-size:{}px;font-weight:{};fill:{}".format( + FONT, fmt(size * FS), weight, color) + if halo: + style += ";paint-order:stroke;stroke:#ffffff;stroke-width:5px;stroke-linejoin:round" + out.append('{}'.format( + fmt(x), fmt(y), anchor, style, s)) + + +def note(x, y, lines): + """Legende de bas de panneau, derniere ligne sur la ligne de base `y`.""" + step = 22 * FS + for k, line in enumerate(lines): + text(x, y - (len(lines) - 1 - k) * step, line, size=16, halo=False) + + +def arrow_head(tip, direction, color, size=9): + ux, uy = direction + nx, ny = -uy, ux + base = (tip[0] - size * ux, tip[1] - size * uy) + out.append(''.format(pts([ + tip, (base[0] + 0.45 * size * nx, base[1] + 0.45 * size * ny), + (base[0] - 0.45 * size * nx, base[1] - 0.45 * size * ny)]), color)) + + +def dimension(p, q, offset, label, color=DIM, label_pos=0.5, label_shift=18, size=17): + """Cote entre p et q, decalee de `offset` perpendiculairement.""" + dx, dy = q[0] - p[0], q[1] - p[1] + length = math.hypot(dx, dy) + ux, uy = dx / length, dy / length + nx, ny = -uy, ux + a = (p[0] + offset * nx, p[1] + offset * ny) + b = (q[0] + offset * nx, q[1] + offset * ny) + sign = 1 if offset >= 0 else -1 + for base, end in ((p, a), (q, b)): + polyline([(base[0] + sign * 4 * nx, base[1] + sign * 4 * ny), + (end[0] + sign * 6 * nx, end[1] + sign * 6 * ny)], color, 1, + 'stroke-dasharray="3,3"') + polyline([a, b], color, 1.6) + arrow_head(a, (-ux, -uy), color) + arrow_head(b, (ux, uy), color) + lx = a[0] + label_pos * (b[0] - a[0]) + sign * label_shift * FS * nx + ly = a[1] + label_pos * (b[1] - a[1]) + sign * label_shift * FS * ny + 6 * FS + text(lx, ly, label, size=size, color=color, anchor="middle", weight="bold") + + +def panel_title(x, y, number, title): + text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False) + + + +# -------------------------------------------------------------------------- +# Relief d'exemple et dessin des couches avec les fonctions du noyau +# -------------------------------------------------------------------------- + +COLS, ROWS = 161, 121 # echantillons du relief d'exemple (4:3) + + +def sample_field(): + """Relief synthetique : quelques bosses larges et un grain fin, pour que + l'effet du lissage se voie.""" + rng = np.random.default_rng(7) + y, x = np.mgrid[0:ROWS, 0:COLS] + field = np.zeros((ROWS, COLS)) + for _ in range(9): + cx, cy = rng.uniform(0, COLS), rng.uniform(0, ROWS) + radius = rng.uniform(18, 42) + field += rng.uniform(0.5, 1.0) * np.exp( + -((x - cx) ** 2 + (y - cy) ** 2) / (2 * radius ** 2)) + grain = core.gaussian_blur(rng.random((ROWS, COLS)), 1.6) + return core.normalize(field) + 1.5 * (grain - grain.mean()) + + +FIELD = sample_field() + + +def draw_layers(layers, x, y, width, height, stroke=EDGE, stroke_width=1.2, + fill=True, transform=None): + """Dessine des couches du noyau dans le rectangle (x, y, width, height).""" + scale_x, scale_y = width / (COLS - 1.0), height / (ROWS - 1.0) + box = (x, y, x + width, y + height) + for _level, gray, rings in layers: + if not rings: + continue + d = core.rings_to_d(core.scale_rings(rings, scale_x, scale_y, x, y), + box=box, precision=1) + extra = "" + if transform: + # Vue en perspective : le trait garde son epaisseur a l'ecran. + extra = ' transform="{}" vector-effect="non-scaling-stroke"'.format(transform) + out.append(''.format( + d, core.gray_to_hex(gray) if fill else "none", + stroke, fmt(stroke_width), extra)) + + +def layers_for(levels, blur_percent, shapes="band"): + return core.iso_layers(FIELD, levels=levels, blur=blur_percent / 100.0 * COLS, + min_area=0.0005, tolerance=0.3, shapes=shapes, + preserve_edges=True) + + +# -------------------------------------------------------------------------- +# Panneaux +# -------------------------------------------------------------------------- + +def panel_1(): + panel_title(30, 50, 1, T["title1"]) + # Beaucoup de niveaux sans trait, empiles pour ne laisser aucun joint : + # rendu continu, comme le bitmap. + draw_layers(layers_for(40, 0.0, "light"), 110, 110, 480, 360, stroke="none") + out.append(''.format(EDGE)) + note(30, 610, T["note1"]) + + +def panel_2(): + panel_title(730, 50, 2, T["title2"]) + for x, blur, label in ((730, 0.5, T["low"]), (1060, 4.0, T["high"])): + draw_layers(layers_for(5, blur), x, 150, 310, 232.5) + text(x + 155, 420, label, color=DIM, anchor="middle", weight="bold", + halo=False) + note(730, 610, T["note2"]) + + +def panel_3(): + panel_title(30, 690, 3, T["title3"]) + draw_layers(layers_for(5, 2.0), 30, 750, 440, 330) + # Echelle des 5 gris, du plus clair en haut au plus sombre en bas. + for k in range(5): + out.append(''.format( + 750 + 66 * k, core.gray_to_hex((4 - k) / 4.0), EDGE)) + dimension((540, 750), (540, 1080), -24, "", label_shift=0) + text(578, 921, T["levels"], color=DIM, weight="bold", + halo=False) + note(30, 1250, T["note3"]) + + +def panel_4(): + panel_title(730, 690, 4, T["title4"]) + # Une planche par vignette, comme dans son calque : sa decoupe en rouge + # et le repere de la planche suivante en noir ; puis la pile entiere. + boards = core.iso_boards(FIELD, levels=5, blur=2.0 / 100.0 * COLS, + min_area=0.0005, tolerance=0.3, shapes="light", + preserve_edges=True) + slots = [(730 + 220 * (k % 3), 745 + 215 * (k // 3)) for k in range(6)] + scale_x, scale_y = 200 / (COLS - 1.0), 150 / (ROWS - 1.0) + for k, (x, y) in enumerate(slots): + if k == 5: + draw_layers([board[:3] for board in boards], x, y, 200, 150, + stroke_width=1) + text(x + 100, y + 150 + 22 * FS, T["all"], size=15, anchor="middle", + color=DIM, weight="bold", halo=False) + continue + _level, _gray, rings, mark = boards[k] + box = (x, y, x + 200, y + 150) + cut = core.rings_to_d(core.scale_rings(rings, scale_x, scale_y, x, y), + box=box, precision=1) + out.append(''.format(cut, CUT)) + if mark: + line = core.lines_to_d(core.scale_lines(mark, scale_x, scale_y, x, y), + box=box, precision=1) + out.append(''.format(line, EDGE)) + text(x + 100, y + 150 + 22 * FS, T["level"].format(k + 1), size=15, + anchor="middle", halo=False) + note(730, 1250, T["note4"]) + + +def export_png(svg_path, png_path, width): + exe = next((path for path in INKSCAPE if os.path.exists(path)), None) + if exe is None: + print("Inkscape introuvable : exporter {} a la main".format(svg_path)) + return + os.makedirs(os.path.dirname(png_path), exist_ok=True) + subprocess.run([exe, svg_path, "--export-type=png", + "--export-width={}".format(width), + "--export-filename={}".format(png_path)], + check=True, stderr=subprocess.DEVNULL) + print(png_path) + + +def build(language): + global FS + version = VERSIONS[language] + out.clear() + T.clear() + T.update(version["text"]) + FS = version["font_scale"] + + out.append(''.format(W, H)) + for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)): + out.append(''.format(x1, y1, x2, y2)) + panel_1() + panel_2() + panel_3() + panel_4() + svg = ('\n' + '\n{2}\n\n').format(W, H, "\n".join(out)) + with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle: + handle.write(svg) + print(version["svg"]) + export_png(version["svg"], version["png"], version["png_width"]) + + +def main(argv): + languages = argv or sorted(VERSIONS) + for language in languages: + build(language) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/gray_iso_layers.inx b/gray_iso_layers.inx new file mode 100644 index 0000000..155067a --- /dev/null +++ b/gray_iso_layers.inx @@ -0,0 +1,97 @@ + + + + Gray Iso-Layers + fr.alexp.grayisolayers + + + + 16 + + + + + + true + 1.0 + true + 800 + 0.02 + 0.4 + true + + + + + + + + + 4294967295 + 0.2 + + + + + + + + + 4278190335 + 255 + true + + + + + images/parameters_en.png + + + + + + + + all + + + + + + + diff --git a/gray_iso_layers.py b/gray_iso_layers.py new file mode 100644 index 0000000..5240fc9 --- /dev/null +++ b/gray_iso_layers.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Extension Inkscape : decoupe une image en couches empilees par nuances de gris. + +L'image selectionnee est convertie en gris, floutee puis quantifiee en quelques +niveaux ; chaque niveau devient une forme fermee dont le contour suit la +frontiere de l'aplat, a la maniere des courbes de niveau d'une carte. + +La logique vit dans `gray_iso_layers_core`, sans dependance a inkex. +""" + +import base64 +import io +import os +import sys +from urllib.parse import unquote, unquote_to_bytes, urlparse +from urllib.request import url2pathname + +import inkex +from inkex.localization import localize + +# L'extension vit dans son propre sous-dossier des extensions Inkscape, qui +# n'est pas forcement dans sys.path : on l'y ajoute pour trouver gray_iso_layers_core. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from gray_iso_layers_core import ( # noqa: E402 + gray_to_hex, iso_boards, lines_to_d, parse_color, rings_to_d, scale_lines, + scale_rings) + +# Installe _() dans les builtins : catalogue « grayisolayers » de la langue +# de l'interface (Inkscape transmet domaine et dossier par l'environnement), +# textes anglais d'origine sinon. +localize() + + +class GrayIsoLayers(inkex.EffectExtension): + """Decoupe chaque image selectionnee en planches a empiler, une par + niveau de gris, chacune dans son propre calque avec sa decoupe et le + repere de la planche suivante.""" + + def add_arguments(self, pars): + # Un argument par du .inx, memes noms et memes valeurs par defaut. + pars.add_argument("--tab", default="levels") + pars.add_argument("--levels", type=int, default=16) + pars.add_argument("--shapes", default="light") + pars.add_argument("--mark", type=inkex.Boolean, default=True) + pars.add_argument("--blur", type=float, default=1.0) + pars.add_argument("--edges", type=inkex.Boolean, default=True) + pars.add_argument("--resolution", type=int, default=800) + pars.add_argument("--min_area", type=float, default=0.02) + pars.add_argument("--simplify", type=float, default=0.4) + pars.add_argument("--smooth", type=inkex.Boolean, default=True) + pars.add_argument("--fill_mode", default="gray") + pars.add_argument("--fill_color", default="4294967295") + pars.add_argument("--stroke_width", type=float, default=0.2) + pars.add_argument("--unit", default="mm") + pars.add_argument("--stroke_color", default="4278190335") + pars.add_argument("--mark_color", default="255") + pars.add_argument("--keep_original", type=inkex.Boolean, default=True) + + def effect(self): + images = list(self.svg.selection.get(inkex.Image)) + if not images: + inkex.errormsg(_("Select at least one bitmap image (File > Import), " + "then run the extension again.")) + return + if self.options.levels < 2: + inkex.errormsg(_("At least 2 gray levels are needed.")) + return + # Un calque par planche, commun a toutes les images traitees. + self.board_layers = {} + for image in images: + self.process(image) + + def board_layer(self, number, above): + """Calque de la planche `number` (1 = la plus au fond), cree a la + demande au premier plan du document, juste au-dessus de `above` + (calque precedent ou image).""" + if number not in self.board_layers: + layer = inkex.Layer() + layer.label = _("Board no. {}").format(number) + root = self.svg + # Un calque Inkscape est un enfant direct de la racine : on remonte + # jusqu'a l'ancetre de premier plan pour s'inserer apres lui. + while above.getparent() is not None and above.getparent() is not root: + above = above.getparent() + root.insert(root.index(above) + 1, layer) + self.board_layers[number] = layer + return self.board_layers[number] + + def process(self, image): + """Traite une image : calcul dans gray_iso_layers_core, ecriture dans le SVG.""" + opt = self.options + try: + field = self.load_field(image, opt.resolution) + except Exception as error: # fichier absent, format inconnu, lien casse... + inkex.errormsg(_("Cannot read the image '{}' ({}). Embed it in the " + "document (right-click > Embed Image) or check its " + "link.").format(image.get_id(), error)) + return + + rows, cols = field.shape + boards = iso_boards( + field, levels=opt.levels, + blur=opt.blur / 100.0 * max(rows, cols), + preserve_edges=opt.edges, + min_area=opt.min_area / 100.0, + tolerance=opt.simplify, + shapes=opt.shapes) + if sum(1 for board in boards if board[2]) < 2: + inkex.errormsg(_("The image '{}' is uniform: only the base level has " + "a shape. Reduce the smoothing or pick a more " + "contrasted image.").format(image.get_id())) + + # Les couches sont calculees dans le repere propre de l'image, puis + # ecrites en coordonnees du document : les calques n'ont ainsi aucune + # transformation, meme pour une image tournee ou etiree. + left, top, width, height = self.image_box(image, cols, rows) + scale_x, scale_y = width / (cols - 1.0), height / (rows - 1.0) + box = (left, top, left + width, top + height) + matrix = image.composed_transform().matrix + + above = image + for number, (_level, gray, rings, mark) in enumerate(boards, 1): + # Les planches sont numerotees du fond vers l'avant. Calque cree + # meme si le niveau est vide : autant de calques que de niveaux. + layer = self.board_layer(number, above) + above = layer + if not rings: + continue + node = inkex.PathElement() + node.label = _("Cut") + node.set("d", rings_to_d(scale_rings(rings, scale_x, scale_y, left, top), + smooth=opt.smooth, box=box, matrix=matrix)) + node.style = self.layer_style(gray) + layer.add(node) + + # Repere d'assemblage : le bord de la planche suivante, reporte + # sur celle-ci pour savoir ou la poser. + mark_style = self.mark_style() if opt.mark and mark else None + if mark_style is not None and (mark[0] or mark[1]): + node = inkex.PathElement() + node.label = _("Marking of board no. {}").format(number + 1) + node.set("d", lines_to_d(scale_lines(mark, scale_x, scale_y, left, top), + smooth=opt.smooth, box=box, matrix=matrix)) + node.style = mark_style + layer.add(node) + + if not opt.keep_original: + image.delete() + + def layer_style(self, gray): + """Style d'une couche : remplissage selon le mode, trait facultatif.""" + opt = self.options + style = inkex.Style({"fill": "none", "fill-rule": "evenodd", "stroke": "none"}) + if opt.fill_mode == "gray": + style["fill"] = gray_to_hex(gray) + elif opt.fill_mode == "color": + color, opacity = parse_color(opt.fill_color, ("#ffffff", 1.0)) + style["fill"] = color + style["fill-opacity"] = str(opacity) + + width = self.svg.unittouu("{}{}".format(opt.stroke_width, opt.unit)) + if width > 0: + color, opacity = parse_color(opt.stroke_color, ("#ff0000", 1.0)) + style["stroke"] = color + style["stroke-opacity"] = str(opacity) + style["stroke-width"] = str(round(width, 6)) + style["stroke-linejoin"] = "round" + return style + + def mark_style(self): + """Style du repere d'assemblage : un trait sans remplissage, de la + meme epaisseur que la decoupe. `None` s'il n'y a pas de trait.""" + opt = self.options + width = self.svg.unittouu("{}{}".format(opt.stroke_width, opt.unit)) + if width <= 0: + return None + color, opacity = parse_color(opt.mark_color, ("#000000", 1.0)) + return inkex.Style({ + "fill": "none", "stroke": color, "stroke-opacity": str(opacity), + "stroke-width": str(round(width, 6)), "stroke-linejoin": "round", + "stroke-linecap": "round"}) + + @staticmethod + def image_box(image, cols, rows): + """Rectangle reellement occupe par le bitmap, dans le repere de l'image. + + Sauf preserveAspectRatio="none", le bitmap garde ses proportions et il + est centre dans le cadre x, y, width, height (cas « meet » par defaut). + """ + left, top, width, height = image.left, image.top, image.width, image.height + ratio = image.get("preserveAspectRatio", "xMidYMid meet") + if "none" not in ratio and "slice" not in ratio: + zoom = min(width / cols, height / rows) + fitted_width, fitted_height = cols * zoom, rows * zoom + left += (width - fitted_width) / 2.0 + top += (height - fitted_height) / 2.0 + width, height = fitted_width, fitted_height + return left, top, width, height + + def load_field(self, image, resolution): + """Lit le bitmap et renvoie ses gris (0 noir, 1 blanc) en tableau 2D, + ramene a `resolution` points sur son grand cote.""" + import numpy + from PIL import Image + + href = image.get("xlink:href") or image.get("href") or "" + if href.startswith("data:"): + header, _sep, payload = href.partition(",") + data = base64.b64decode(payload) if ";base64" in header \ + else unquote_to_bytes(payload) + source = io.BytesIO(data) + else: + if href.startswith("file:"): + path = url2pathname(urlparse(href).path) + else: + path = unquote(href) + # Lien relatif : il part du dossier du document. + source = self.absolute_href(path) + + with Image.open(source) as picture: + # La transparence se lit comme du blanc (fond de page). + rgba = picture.convert("RGBA") + white = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) + gray = Image.alpha_composite(white, rgba).convert("L") + zoom = resolution / float(max(gray.size)) + size = (max(2, int(round(gray.width * zoom))), + max(2, int(round(gray.height * zoom)))) + gray = gray.resize(size, Image.LANCZOS) + return numpy.asarray(gray, dtype=float) / 255.0 + + +if __name__ == "__main__": + GrayIsoLayers().run() diff --git a/gray_iso_layers_core.py b/gray_iso_layers_core.py new file mode 100644 index 0000000..0775476 --- /dev/null +++ b/gray_iso_layers_core.py @@ -0,0 +1,731 @@ +# coding=utf-8 +""" +Noyau de calcul de l'extension « Gray Iso-Layers », sans dependance a inkex. + +Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema +des parametres). Seule dependance : numpy (fourni avec Inkscape). + +Conventions : +- un champ (`field`) est un tableau numpy 2D de gris, 0 = noir, 1 = blanc, + indexe [ligne, colonne] ; +- un anneau est un tableau numpy (n, 2) de points (x, y), ferme implicitement ; + un contour (`rings`) est une liste d'anneaux, les trous suivent la regle + pair-impair ; +- les anneaux sont exprimes en « echantillons » : x de 0 a largeur - 1, y de 0 + a hauteur - 1, les echantillons du bord etant poses sur le bord de l'image ; +- l'axe y est oriente vers le bas (repere SVG). +""" + +import numpy as np + +# Valeur de la bordure ajoutee autour du champ : assez grande pour que les +# contours se referment exactement sur les echantillons du bord. +_OUTSIDE = -1e12 + +# Segments de contour par cas du « marching squares ». Le cas est le nombre +# 8.HG + 4.HD + 2.BD + 1.BG (coins dans la region) ; chaque segment relie deux +# aretes de la cellule (T haut, R droite, B bas, L gauche). Les cas 5 et 10 +# (selles) sont traites a part. +_CASES = { + 1: ("LB",), 2: ("BR",), 3: ("LR",), 4: ("TR",), 6: ("TB",), 7: ("TL",), + 8: ("TL",), 9: ("TB",), 11: ("TR",), 12: ("LR",), 13: ("BR",), 14: ("LB",), +} +_SADDLES = { + # cas : (segments si le centre est dans la region, segments sinon) + 5: (("TL", "BR"), ("TR", "LB")), + 10: (("TR", "LB"), ("TL", "BR")), +} + + +def parse_color(value, default=("#b3b3b3", 1.0)): + """Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]). + + Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre + « color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode + nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change + entre les versions 1.x. + """ + text = str(value).strip() + try: + if text.startswith("#"): + digits = text[1:] + if len(digits) == 3: + digits = "".join(c * 2 for c in digits) + if len(digits) == 6: + digits += "ff" + if len(digits) != 8: + return default + number = int(digits, 16) + else: + number = int(text, 0) + except ValueError: + return default + number &= 0xFFFFFFFF + red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255 + alpha = (number & 255) / 255.0 + return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4) + + +def gray_to_hex(gray): + """Couleur CSS #rrggbb d'un gris entre 0 (noir) et 1 (blanc).""" + value = int(round(255 * min(1.0, max(0.0, float(gray))))) + return "#{0:02x}{0:02x}{0:02x}".format(value) + + +# -------------------------------------------------------------------------- +# Preparation du champ +# -------------------------------------------------------------------------- + +def gaussian_blur(field, sigma): + """Flou gaussien separable d'ecart-type `sigma` (en echantillons). + + Les bords sont prolonges par repetition, pour ne pas les assombrir. + """ + field = np.asarray(field, dtype=float) + if sigma <= 0: + return field.copy() + radius = max(1, int(np.ceil(3 * sigma))) + taps = np.arange(-radius, radius + 1) + kernel = np.exp(-0.5 * (taps / float(sigma)) ** 2) + kernel /= kernel.sum() + + result = field + for axis in (0, 1): + pad = [(0, 0), (0, 0)] + pad[axis] = (radius, radius) + padded = np.pad(result, pad, mode="edge") + size = result.shape[axis] + blurred = np.zeros_like(result) + for k, weight in enumerate(kernel): + window = [slice(None), slice(None)] + window[axis] = slice(k, k + size) + blurred += weight * padded[tuple(window)] + result = blurred + return result + + +def box_blur(field, radius): + """Moyenne sur une fenetre carree de cote 2 * radius + 1, bords repetes.""" + field = np.asarray(field, dtype=float) + radius = int(radius) + if radius < 1: + return field.copy() + size = 2 * radius + 1 + total = np.cumsum(np.cumsum(np.pad(field, radius, mode="edge"), axis=0), axis=1) + total = np.pad(total, ((1, 0), (1, 0))) + return (total[size:, size:] - total[:-size, size:] + - total[size:, :-size] + total[:-size, :-size]) / float(size * size) + + +def edge_preserving_blur(field, radius, contrast=0.1): + """Lissage qui respecte les contours (filtre guide par l'image elle-meme). + + Dans une fenetre de `radius` echantillons, les variations plus faibles que + `contrast` (texture de la peau, grain) sont aplanies, tandis que les + transitions plus marquees (bord d'un visage, yeux, bouche) restent nettes, + la ou un flou gaussien les etalerait. + """ + field = np.asarray(field, dtype=float) + radius = int(radius) + if radius < 1: + return field.copy() + mean = box_blur(field, radius) + variance = np.maximum(box_blur(field * field, radius) - mean * mean, 0.0) + # keep = 1 sur un contour (on garde l'image), 0 en zone calme (on garde + # la moyenne locale). + keep = variance / (variance + contrast ** 2) + return box_blur(keep, radius) * field + box_blur(mean * (1.0 - keep), radius) + + +def smooth(field, blur, preserve_edges=False): + """Lisse un champ avant sa decoupe en niveaux, puis l'etire sur 0..1. + + `blur` est la taille du lissage en echantillons. Sans `preserve_edges`, + c'est l'ecart-type d'un flou gaussien. Avec, c'est le rayon d'un lissage + qui respecte les contours, suivi d'un leger flou gaussien (un cinquieme) + qui arrondit le trace des lignes de niveau. + """ + field = normalize(field) + if blur > 0 and preserve_edges: + field = edge_preserving_blur(field, max(1, int(round(blur)))) + field = gaussian_blur(field, blur / 5.0) + elif blur > 0: + field = gaussian_blur(field, blur) + return normalize(field) + + +def normalize(field): + """Etire le champ sur toute la plage 0..1 (champ uniforme : tout a 0).""" + field = np.asarray(field, dtype=float) + low, high = float(field.min()), float(field.max()) + if high - low < 1e-9: + return np.zeros_like(field) + return (field - low) / (high - low) + + +def thresholds(levels): + """Seuils reguliers separant `levels` niveaux de gris sur 0..1.""" + return [k / float(levels) for k in range(1, levels)] + + +# -------------------------------------------------------------------------- +# Contours +# -------------------------------------------------------------------------- + +def contour_rings(field, threshold, above=True): + """Contour ferme de la region `field >= threshold` (ou `<=` si `above` + est faux), par « marching squares » avec interpolation lineaire. + + Le champ est borde d'une valeur hors region : la ou la region touche le + bord de l'image, le contour longe ce bord, il est donc toujours ferme. + """ + field = np.asarray(field, dtype=float) + height, width = field.shape + if height < 2 or width < 2: + return [] + signed = (field - threshold) if above else (threshold - field) + g = np.full((height + 2, width + 2), _OUTSIDE) + g[1:-1, 1:-1] = signed + rows, cols = g.shape + + inside = g >= 0 + tl, tr = inside[:-1, :-1], inside[:-1, 1:] + bl, br = inside[1:, :-1], inside[1:, 1:] + case = 8 * tl + 4 * tr + 2 * br + 1 * bl + center = (g[:-1, :-1] + g[:-1, 1:] + g[1:, :-1] + g[1:, 1:]) >= 0 + + # Identifiant entier de chaque arete de la grille : les deux cellules qui + # partagent une arete designent ainsi exactement le meme point de contour. + ii, jj = np.indices(case.shape) + count = rows * cols + edge = { + "T": ii * cols + jj, # arete horizontale (i, j)-(i, j+1) + "B": (ii + 1) * cols + jj, + "L": count + ii * cols + jj, # arete verticale (i, j)-(i+1, j) + "R": count + ii * cols + jj + 1, + } + + starts, ends = [], [] + + def add(mask, segments): + for first, second in segments: + starts.append(edge[first][mask]) + ends.append(edge[second][mask]) + + for number, segments in _CASES.items(): + add(case == number, segments) + for number, (if_inside, if_outside) in _SADDLES.items(): + add((case == number) & center, if_inside) + add((case == number) & ~center, if_outside) + + starts = np.concatenate(starts) + ends = np.concatenate(ends) + if starts.size == 0: + return [] + + # Chaque arete traversee appartient a exactement deux segments : on en + # deduit, pour chaque point, ses deux voisins le long du contour. + ids, inverse = np.unique(np.concatenate([starts, ends]), return_inverse=True) + first, second = inverse[:starts.size], inverse[starts.size:] + node = np.concatenate([first, second]) + other = np.concatenate([second, first]) + order = np.argsort(node, kind="stable") + neighbours = other[order].reshape(-1, 2).tolist() + + points = _edge_points(g, ids, cols, count) + # Retour au repere de l'image (sans la bordure), bord compris. + points -= 1.0 + np.clip(points[:, 0], 0, width - 1, out=points[:, 0]) + np.clip(points[:, 1], 0, height - 1, out=points[:, 1]) + + rings = [] + visited = [False] * len(neighbours) + for start in range(len(neighbours)): + if visited[start]: + continue + chain, previous, current = [], -1, start + while not visited[current]: + visited[current] = True + chain.append(current) + n0, n1 = neighbours[current] + previous, current = current, (n1 if n0 == previous else n0) + ring = _drop_duplicates(points[chain]) + if len(ring) >= 3: + rings.append(ring) + return rings + + +def _edge_points(g, ids, cols, count): + """Point de passage du contour sur chaque arete (repere du champ borde).""" + vertical = ids >= count + flat = np.where(vertical, ids - count, ids) + i, j = flat // cols, flat % cols + a = g[i, j] + b = np.where(vertical, g[np.minimum(i + 1, g.shape[0] - 1), j], + g[i, np.minimum(j + 1, cols - 1)]) + t = a / (a - b) + x = np.where(vertical, j, j + t) + y = np.where(vertical, i + t, i) + return np.column_stack([x, y]).astype(float) + + +def _drop_duplicates(ring, eps=1e-7): + """Retire les points consecutifs confondus (coins de l'image, valeurs + tombant pile sur le seuil).""" + if len(ring) < 2: + return ring + step = np.abs(ring - np.roll(ring, 1, axis=0)).max(axis=1) + return ring[step > eps] + + +def ring_area(ring): + """Aire d'un anneau (formule du lacet, valeur absolue).""" + ring = np.asarray(ring, dtype=float) + if len(ring) < 3: + return 0.0 + x, y = ring[:, 0], ring[:, 1] + return 0.5 * abs(float(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))) + + +def _keep_mask(points, tolerance, anchors): + """Douglas-Peucker : masque des points conserves entre les `anchors` + (indices croissants, toujours conserves).""" + keep = np.zeros(len(points), dtype=bool) + keep[list(anchors)] = True + stack = list(zip(anchors[:-1], anchors[1:])) + while stack: + first, last = stack.pop() + if last - first < 2: + continue + p, q = points[first], points[last] + inner = points[first + 1:last] + dx, dy = q - p + length = float(np.hypot(dx, dy)) + if length < 1e-12: + distance = np.hypot(inner[:, 0] - p[0], inner[:, 1] - p[1]) + else: + distance = np.abs(dx * (inner[:, 1] - p[1]) - dy * (inner[:, 0] - p[0])) / length + worst = int(np.argmax(distance)) + if distance[worst] > tolerance: + middle = first + 1 + worst + keep[middle] = True + stack.append((first, middle)) + stack.append((middle, last)) + return keep + + +def simplify_ring(ring, tolerance): + """Simplification de Douglas-Peucker d'un anneau ferme. + + Aucun point conserve ne s'ecarte de plus de `tolerance` du trace d'origine. + """ + ring = np.asarray(ring, dtype=float) + count = len(ring) + if tolerance <= 0 or count < 5: + return ring + # Deux ancres : le premier point et le plus eloigne de lui. + far = int(np.argmax(((ring - ring[0]) ** 2).sum(axis=1))) + if far == 0: + return ring + closed = np.vstack([ring, ring[:1]]) + return closed[:-1][_keep_mask(closed, tolerance, [0, far, count])[:-1]] + + +def simplify_chain(chain, tolerance): + """Simplification de Douglas-Peucker d'une ligne ouverte (extremites fixes).""" + chain = np.asarray(chain, dtype=float) + if tolerance <= 0 or len(chain) < 3: + return chain + return chain[_keep_mask(chain, tolerance, [0, len(chain) - 1])] + + +# -------------------------------------------------------------------------- +# Lignes de niveau et bord de l'image +# +# Le bord de l'image est repere par une abscisse curviligne `s`, qui part du +# coin haut-gauche et tourne dans le sens horaire : haut, droite, bas, gauche. +# -------------------------------------------------------------------------- + +def _sides(points, right, bottom): + """Pour chaque point, les cotes de l'image qu'il touche : colonnes haut, + droite, bas, gauche.""" + eps = 1e-6 * max(right, bottom, 1e-12) + x, y = points[:, 0], points[:, 1] + return np.column_stack([np.abs(y) < eps, np.abs(x - right) < eps, + np.abs(y - bottom) < eps, np.abs(x) < eps]) + + +def _border_position(point, right, bottom): + """Abscisse curviligne d'un point pose sur le bord de l'image.""" + top, on_right, on_bottom, _left = _sides(np.asarray([point]), right, bottom)[0] + x, y = float(point[0]), float(point[1]) + if top: + return x + if on_right: + return right + y + if on_bottom: + return right + bottom + (right - x) + return (2 * right + bottom + (bottom - y)) % (2 * (right + bottom)) + + +def _border_point(position, right, bottom): + """Point du bord de l'image d'abscisse curviligne `position`.""" + position %= 2 * (right + bottom) + if position <= right: + return position, 0.0 + if position <= right + bottom: + return right, position - right + if position <= 2 * right + bottom: + return right - (position - right - bottom), bottom + return 0.0, bottom - (position - 2 * right - bottom) + + +def _border_corners(start, end, right, bottom): + """Coins de l'image rencontres en longeant le bord de `start` a `end` + dans le sens des abscisses croissantes.""" + perimeter = 2 * (right + bottom) + span = (end - start) % perimeter + corners = [] + for position, corner in ((0.0, (0.0, 0.0)), (right, (right, 0.0)), + (right + bottom, (right, bottom)), + (2 * right + bottom, (0.0, bottom))): + distance = (position - start) % perimeter + if 0 < distance < span: + corners.append((distance, corner)) + return [corner for _distance, corner in sorted(corners)] + + +def _border_value(field, position): + """Valeur du champ au point du bord d'abscisse `position` (interpolee).""" + rows, cols = field.shape + x, y = _border_point(position, cols - 1.0, rows - 1.0) + j, i = min(int(x), cols - 2), min(int(y), rows - 2) + u, v = x - j, y - i + return ((1 - u) * (1 - v) * field[i, j] + u * (1 - v) * field[i, j + 1] + + (1 - u) * v * field[i + 1, j] + u * v * field[i + 1, j + 1]) + + +def contour_lines(field, threshold, tolerance=0.0, min_area=0.0): + """Ligne de niveau `field = threshold`, simplifiee une fois pour toutes. + + Renvoie `(closed, opened)` : les boucles fermees a l'interieur de l'image, + et les lignes ouvertes, dont les deux extremites sont sur le bord. Les + deux regions que la ligne separe la reutilisent telle quelle : leurs + frontieres communes coincident donc exactement. Les boucles et les lignes + qui delimitent une aire inferieure a `min_area` (en echantillons carres) + sont ecartees. + """ + field = np.asarray(field, dtype=float) + right, bottom = field.shape[1] - 1.0, field.shape[0] - 1.0 + closed, opened = [], [] + for ring in contour_rings(field, threshold): + sides = _sides(ring, right, bottom) + # Segment i -> i+1 pose sur le bord : ses deux bouts sur un meme cote. + border = (sides & np.roll(sides, -1, axis=0)).any(axis=1) + if border.all(): + continue + if not border.any(): + if ring_area(ring) >= min_area: + ring = simplify_ring(ring, tolerance) + if len(ring) >= 3: + closed.append(ring) + continue + count = len(ring) + start = next(i for i in range(count) if border[i - 1] and not border[i]) + chain = [ring[start]] + for step in range(count): + i = (start + step) % count + if not border[i]: + chain.append(ring[(i + 1) % count]) + continue + if len(chain) > 1: + chain = np.array(chain) + if _cut_area(chain, right, bottom) >= min_area: + chain = simplify_chain(chain, tolerance) + # Une ligne reduite a deux points d'un meme cote de + # l'image ne detache plus rien : elle longe le bord. + if len(chain) > 2 or not _sides(chain, right, bottom).all(axis=0).any(): + opened.append(chain) + chain = [ring[(i + 1) % count]] + return closed, opened + + +def _cut_area(chain, right, bottom): + """Aire de la plus petite des deux parts que la ligne detache de l'image.""" + corners = _border_corners(_border_position(chain[-1], right, bottom), + _border_position(chain[0], right, bottom), + right, bottom) + area = ring_area(np.vstack([chain] + [np.array([c]) for c in corners])) + return min(area, right * bottom - area) + + +def region_rings(field, lines, inside): + """Contour ferme d'une region bornee par des lignes de niveau. + + `lines` est la liste des `(closed, opened)` de `contour_lines` qui bordent + la region ; `inside(valeur)` dit si une valeur du champ est dans la region. + Les lignes ouvertes sont refermees en longeant le bord de l'image, du + cote ou il appartient a la region. + """ + field = np.asarray(field, dtype=float) + right, bottom = field.shape[1] - 1.0, field.shape[0] - 1.0 + perimeter = 2 * (right + bottom) + + def on_border(position): + return inside(_border_value(field, position)) + + rings = [ring for closed, _opened in lines for ring in closed] + chains = [chain for _closed, opened in lines for chain in opened] + if not chains: + # Aucune ligne n'atteint le bord : il est tout entier dans la region, + # ou tout entier dehors. + probes = [on_border(perimeter * (k + 0.5) / 32.0) for k in range(32)] + if sum(probes) > 16: + rings.append(image_ring(int(right) + 1, int(bottom) + 1)) + return rings + + # Extremites des lignes, dans l'ordre ou on les rencontre sur le bord : + # entre deux extremites voisines, le bord est alternativement dans la + # region et hors d'elle. + ends = sorted((_border_position(chain[-end], right, bottom), index, end) + for index, chain in enumerate(chains) for end in (0, 1)) + count = len(ends) + score = 0.0 + for i in range(count): + span = (ends[(i + 1) % count][0] - ends[i][0]) % perimeter + # Plusieurs sondes par intervalle : une seule pourrait tomber sur une + # petite forme ecartee par `min_area` et fausser le vote. + votes = sum(1 if on_border((ends[i][0] + span * (n + 0.5) / 9.0) % perimeter) + else -1 for n in range(9)) + score += span * votes * (1 if i % 2 == 0 else -1) + first = 0 if score >= 0 else 1 + + # link[i] = (extremite reliee a i par le bord, sens de parcours du bord) + link = {} + for i in range(first, first + count, 2): + start, end = i % count, (i + 1) % count + link[start] = (end, 1) + link[end] = (start, -1) + where = {(index, end): i for i, (_s, index, end) in enumerate(ends)} + + used = [False] * len(chains) + for origin in range(len(chains)): + if used[origin]: + continue + points, index, entry = [], origin, 0 + while not used[index]: + used[index] = True + chain = chains[index] + points.extend(chain if entry == 0 else chain[::-1]) + leave = where[(index, 1 - entry)] + reach, direction = link[leave] + if direction > 0: + corners = _border_corners(ends[leave][0], ends[reach][0], right, bottom) + else: + corners = _border_corners(ends[reach][0], ends[leave][0], right, bottom)[::-1] + points.extend(corners) + _s, index, entry = ends[reach] + ring = _drop_duplicates(np.array(points, dtype=float)) + if len(ring) >= 3: + rings.append(ring) + return rings + + +# -------------------------------------------------------------------------- +# Couches +# -------------------------------------------------------------------------- + +def iso_boards(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0, + shapes="light", preserve_edges=False): + """Decoupe un champ de gris en une planche par niveau. + + Le champ est lisse (`blur` en echantillons, `preserve_edges` : voir + `smooth`), etire sur 0..1 puis quantifie en `levels` niveaux a seuils + reguliers : le niveau k couvre les valeurs de k / levels a + (k + 1) / levels. Renvoie les planches du fond vers l'avant de la pile : + `(niveau, gris, rings, mark)` avec `niveau` de 0 (le plus sombre) a + levels - 1 et `gris` entre 0 et 1. + + `shapes` choisit la forme de chaque planche : + - "light" : le niveau et tous les plus clairs (planches a empiler, la plus + sombre au fond, couvrant toute l'image) ; + - "dark" : le niveau et tous les plus sombres (la plus claire au fond) ; + - "band" : l'aplat du niveau seul, borde sur tous ses cotes ; les planches + pavent l'image sans se recouvrir. + + `mark` est le repere d'assemblage : la ligne de niveau `(closed, opened)` + qui borde la planche suivante de la pile, a reporter sur celle-ci pour + savoir ou la poser. Elle a exactement le trace de la decoupe de la + planche suivante, sans les troncons qui longent le bord de l'image. Il + vaut `None` pour la derniere planche et pour les planches "band", qui ne + s'empilent pas. + + Dans tous les cas les contours sont les lignes de niveau des seuils, donc + les frontieres des aplats de l'image quantifiee. Les formes d'aire + inferieure a `min_area` (fraction de la surface de l'image) sont + ecartees, les autres simplifiees a `tolerance` echantillons pres. Il y a + toujours `levels` planches : une planche sans forme est renvoyee vide. + """ + field = np.asarray(field, dtype=float) + height, width = field.shape + levels = max(2, int(levels)) + field = smooth(field, blur, preserve_edges) + smallest = min_area * (width - 1) * (height - 1) + + cuts = thresholds(levels) + # lines[k] : ligne de niveau du seuil k / levels, entre les niveaux k - 1 et k. + lines = [None] + [contour_lines(field, cut, tolerance, smallest) for cut in cuts] + lines.append(None) + low = [-np.inf] + cuts + high = cuts + [np.inf] + + def band(k): + borders = [lines[n] for n in (k, k + 1) if lines[n] is not None] + return region_rings(field, borders, lambda v: low[k] <= v < high[k]), None + + def lighter(k): + # Bordee par lines[k] ; la planche suivante (k + 1) par lines[k + 1]. + borders = [lines[k]] if lines[k] is not None else [] + return region_rings(field, borders, lambda v: v >= low[k]), lines[k + 1] + + def darker(k): + # Bordee par lines[k + 1] ; la planche suivante (k - 1) par lines[k]. + borders = [lines[k + 1]] if lines[k + 1] is not None else [] + return region_rings(field, borders, lambda v: v < high[k]), lines[k] + + if shapes == "dark": + order, board = range(levels - 1, -1, -1), darker + else: + order, board = range(levels), (band if shapes == "band" else lighter) + return [(k, k / float(levels - 1)) + board(k) for k in order] + + +def iso_layers(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0, + shapes="band", preserve_edges=False): + """Comme `iso_boards`, sans les reperes d'assemblage : liste de + `(niveau, gris, rings)`.""" + return [board[:3] for board in iso_boards(field, levels, blur, min_area, + tolerance, shapes, preserve_edges)] + + +def image_ring(width, height): + """Anneau du rectangle de l'image, en echantillons.""" + return np.array([(0.0, 0.0), (width - 1.0, 0.0), + (width - 1.0, height - 1.0), (0.0, height - 1.0)]) + + +# -------------------------------------------------------------------------- +# Ecriture SVG +# -------------------------------------------------------------------------- + +def scale_rings(rings, scale_x, scale_y, offset_x=0.0, offset_y=0.0): + """Passe des echantillons au repere de destination.""" + factor = np.array([scale_x, scale_y], dtype=float) + offset = np.array([offset_x, offset_y], dtype=float) + return [np.asarray(ring, dtype=float) * factor + offset for ring in rings] + + +def scale_lines(lines, scale_x, scale_y, offset_x=0.0, offset_y=0.0): + """Comme `scale_rings`, pour une ligne de niveau `(closed, opened)`.""" + return tuple(scale_rings(part, scale_x, scale_y, offset_x, offset_y) + for part in lines) + + +def rings_to_d(rings, smooth=True, box=None, precision=3, matrix=None): + """Donnees `d` d'un chemin SVG : un sous-chemin ferme par anneau. + + Avec `smooth`, les points sont relies par une spline de Catmull-Rom + convertie en courbes de Bezier. `box` = (x0, y0, x1, y1) est le rectangle + de l'image : les troncons qui le longent restent des droites et les + courbes n'en sortent pas, pour que le bord des couches reste net. + + `matrix` = ((a, c, e), (b, d, f)) est une transformation affine appliquee + aux points ecrits (une courbe de Bezier la subit sans se deformer) ; `box` + reste exprime dans le repere des anneaux. + """ + fmt = _formatter(precision, matrix) + parts = [] + for ring in rings: + parts.extend(_subpath(ring, True, smooth, box, fmt)) + return " ".join(parts) + + +def lines_to_d(lines, smooth=True, box=None, precision=3, matrix=None): + """Donnees `d` d'une ligne de niveau `(closed, opened)` : un sous-chemin + ferme par boucle, un sous-chemin ouvert par ligne aboutissant au bord. + + Memes options que `rings_to_d`, et meme trace : une ligne ecrite ici se + superpose exactement au contour de la region qu'elle borde. + """ + closed, opened = lines + fmt = _formatter(precision, matrix) + parts = [] + for ring in closed: + parts.extend(_subpath(ring, True, smooth, box, fmt)) + for chain in opened: + parts.extend(_subpath(chain, False, smooth, box, fmt)) + return " ".join(parts) + + +def _formatter(precision, matrix): + """Ecriture d'un point "x,y", apres transformation affine eventuelle.""" + number = "{:.%df}" % precision + + def fmt(point): + x, y = point[0], point[1] + if matrix is not None: + (a, c, e), (b, d, f) = matrix + x, y = a * x + c * y + e, b * x + d * y + f + return number.format(x) + "," + number.format(y) + + return fmt + + +def _subpath(points, closed, smooth, box, fmt): + """Commandes SVG d'un anneau (`closed`) ou d'une ligne ouverte.""" + points = np.asarray(points, dtype=float) + count = len(points) + if count < (3 if closed else 2): + return [] + parts = ["M " + fmt(points[0])] + if not smooth: + parts.extend("L " + fmt(point) for point in points[1:]) + return parts + ["Z"] if closed else parts + + on_box, along = _on_box(points, box) + if not closed: + # Les extremites d'une ligne ouverte sont sur le bord de l'image : + # meme traitement que dans l'anneau qui la contient. + on_box = on_box.copy() + on_box[[0, -1]] = True + before = np.roll(points, 1, axis=0) + after = np.roll(points, -1, axis=0) + # Sur le bord, la tangente ne doit pas etre tiree par le troncon droit + # voisin : on la replie sur le point lui-meme. + tangent = np.where(on_box[:, None], 0.0, (after - before) / 6.0) + for k in range(count if closed else count - 1): + nxt = (k + 1) % count + if along[k]: + # Le dernier troncon droit est trace par la fermeture « Z ». + if nxt: + parts.append("L " + fmt(points[nxt])) + continue + chord = (points[nxt] - points[k]) / 6.0 + c1 = points[k] + (chord if on_box[k] else tangent[k]) + c2 = points[nxt] - (chord if on_box[nxt] else tangent[nxt]) + if box is not None: + c1 = np.clip(c1, box[:2], box[2:]) + c2 = np.clip(c2, box[:2], box[2:]) + parts.append("C {} {} {}".format(fmt(c1), fmt(c2), fmt(points[nxt]))) + return parts + ["Z"] if closed else parts + + +def _on_box(ring, box): + """Pour chaque point de l'anneau : est-il pose sur le rectangle `box`, et + le segment qui le relie au point suivant longe-t-il un cote de `box` ?""" + if box is None: + nowhere = np.zeros(len(ring), dtype=bool) + return nowhere, nowhere + x0, y0, x1, y1 = box + sides = _sides(ring - np.array([x0, y0]), x1 - x0, y1 - y0) + return sides.any(axis=1), (sides & np.roll(sides, -1, axis=0)).any(axis=1) diff --git a/i18n.py b/i18n.py new file mode 100644 index 0000000..a800d7f --- /dev/null +++ b/i18n.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Chaine de traduction de l'extension, sans dependance a gettext. + + python i18n.py # extract + update + compile + python i18n.py extract # po/.pot depuis le .inx et les .py + python i18n.py update # reporte le modele dans po/.po + python i18n.py compile # po/.po -> locale//LC_MESSAGES/*.mo + +Le domaine est lu dans l'attribut translationdomain du .inx ; les sources sont +le .inx et tous les .py du dossier (hors i18n.py et tests). + +Les textes source (.inx et appels _() des .py) sont en anglais : c'est ce +qu'Inkscape affiche pour une langue sans catalogue. Le catalogue anglais est +rempli automatiquement avec les textes source ; seul fr.po se traduit a la main. + +Les chaines retenues dans le .inx suivent les regles d'Inkscape (inx.its) : +, attributs gui-text et gui-description, contenu de