Version initiale

This commit is contained in:
Alex 2026-10-01 19:12:46 +02:00
commit 16589716fd
19 changed files with 3399 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -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/

253
README.md Normal file
View File

@ -0,0 +1,253 @@
# Iso-couches par nuances de gris — extension Inkscape
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/exemple.png" alt="Une image de relief flou, sa découpe en 16 niveaux de gris, et les mêmes 16 niveaux au trait seul" width="420">
</td>
<td valign="top">
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.
</td>
</tr>
</table>
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`.

107
deploy.ps1 Normal file
View File

@ -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\<langue>\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..."

BIN
doc/patern.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
doc/personne.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
docs/exemple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
docs/parametres.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

335
docs/schema_parametres.py Normal file
View File

@ -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('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
'stroke-linejoin="miter" {}/>'.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('<text x="{}" y="{}" text-anchor="{}" style="{}">{}</text>'.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('<polygon points="{}" fill="{}"/>'.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('<path d="{}" fill="{}" fill-rule="evenodd" stroke="{}" '
'stroke-width="{}" stroke-linejoin="round"{}/>'.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('<rect x="110" y="110" width="480" height="360" fill="none" '
'stroke="{}" stroke-width="1.2"/>'.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('<rect x="500" y="{}" width="40" height="66" fill="{}" '
'stroke="{}" stroke-width="1.2"/>'.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('<path d="{}" fill="#fff5f5" fill-rule="evenodd" stroke="{}" '
'stroke-width="1.6" stroke-linejoin="round"/>'.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('<path d="{}" fill="none" stroke="{}" stroke-width="1.3" '
'stroke-linejoin="round"/>'.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('<rect width="{}" height="{}" fill="#ffffff"/>'.format(W, H))
for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)):
out.append('<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#dee2e6" '
'stroke-width="1.5"/>'.format(x1, y1, x2, y2))
panel_1()
panel_2()
panel_3()
panel_4()
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" '
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\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:])

97
gray_iso_layers.inx Normal file
View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Textes source en anglais, traduits par le catalogue « grayisolayers »
(locale/<langue>/LC_MESSAGES/grayisolayers.mo). Après toute modification
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
-->
<inkscape-extension translationdomain="grayisolayers"
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Gray Iso-Layers</name>
<id>fr.alexp.grayisolayers</id>
<param name="tab" type="notebook">
<page name="levels" gui-text="Levels">
<param name="levels" type="int" min="2" max="64"
gui-text="Number of gray levels:">16</param>
<param name="shapes" type="optiongroup" appearance="combo"
gui-text="Content of each board:">
<option value="light">Stacked, lightest in front (board no. 1 = darkest, whole image)</option>
<option value="dark">Stacked, darkest in front (board no. 1 = lightest, whole image)</option>
<option value="band">Its own gray only (boards side by side, no overlap)</option>
</param>
<param name="mark" type="bool"
gui-text="Mark on each board the outline of the next one (stacked boards)"
gui-description="Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut.">true</param>
<param name="blur" type="float" precision="2" min="0" max="20"
gui-text="Smoothing (% of the image's longer side):"
gui-description="Smoothing applied before cutting the levels: the higher it is, the rounder and simpler the outlines.">1.0</param>
<param name="edges" type="bool"
gui-text="Preserve edges while smoothing (portraits, photos)"
gui-description="Flattens skin and textures but keeps sharp transitions (eyes, mouth, outline of a face). Uncheck for a plain blur, better suited to abstract reliefs.">true</param>
<param name="resolution" type="int" min="50" max="2000"
gui-text="Calculation resolution (points on the longer side):"
gui-description="The image is resampled to this size before the calculation. Higher is more accurate but slower.">800</param>
<param name="min_area" type="float" precision="3" min="0" max="10"
gui-text="Smallest island or hole kept (% of the image area):">0.02</param>
<param name="simplify" type="float" precision="2" min="0" max="5"
gui-text="Simplification (calculation points):"
gui-description="Largest deviation allowed when removing nodes. 0 keeps every node.">0.4</param>
<param name="smooth" type="bool"
gui-text="Smooth the outlines with Bézier curves">true</param>
</page>
<page name="style" gui-text="Style">
<param name="fill_mode" type="optiongroup" appearance="combo" gui-text="Fill:">
<option value="gray">Gray of each level</option>
<option value="color">Single color (below)</option>
<option value="none">No fill (outlines only)</option>
</param>
<param name="fill_color" type="color" appearance="colorbutton"
gui-text="Fill color:">4294967295</param>
<param name="stroke_width" type="float" precision="3" min="0" max="100"
gui-text="Stroke width (0 = no stroke):">0.2</param>
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
<!-- Les unités ne se traduisent pas -->
<option translatable="no" value="mm">mm</option>
<option translatable="no" value="cm">cm</option>
<option translatable="no" value="px">px</option>
<option translatable="no" value="pt">pt</option>
<option translatable="no" value="in">in</option>
</param>
<param name="stroke_color" type="color" appearance="colorbutton"
gui-text="Cut stroke color:">4278190335</param>
<param name="mark_color" type="color" appearance="colorbutton"
gui-text="Marking stroke color:">255</param>
<param name="keep_original" type="bool"
gui-text="Keep the original image">true</param>
</page>
<page name="help" gui-text="Help">
<!-- Schéma généré par docs/schema_parametres.py, en anglais :
Inkscape ne traduit pas le chemin d'une image. Affiché aux
3/4 du PNG (900 px) pour limiter la hauteur de l'onglet. -->
<image width="675" height="617">images/parameters_en.png</image>
<separator/>
<!-- Un paragraphe par ligne : Inkscape le replie à la largeur de
l'onglet ; des retours à la ligne forcés le laisseraient étroit. -->
<label xml:space="preserve">Select one or more bitmap images (imported photo, height map, blurred noise), then run the extension. Colors are read as grays and transparency as white.
The image is smoothed, then split into the requested number of gray levels with evenly spaced thresholds. Each level becomes one closed path that follows the edge of its flat area, like a contour line on a map.
Each gray level goes into its own layer, named Board no. 1, Board no. 2... from the back to the front, added above the image: as many new layers as gray levels. The boards are meant to be cut and stacked: board no. 1 covers the whole image and each following board is smaller and lies in front of the previous one, lightest (or darkest) in front. In each layer, the cut outline of the board is drawn in the cut color (red), and the outline of the next board is carried over in the marking color (black), to show where to glue it. With "Its own gray only", a board holds the flat area of its gray alone: the boards sit side by side without overlapping and carry no marking. Several selected images share the same layers.
For laser cutting, choose no fill: only the cut and marking strokes remain. For line art only, choose a white single-color fill, or no fill, with a black stroke and no marking. If the outlines are too busy, raise the smoothing or the smallest island; if they look angular, raise the smoothing or the simplification. If a face is no longer recognizable, lower the smoothing and keep "Preserve edges" checked.</label>
</page>
</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="AlexDesign"/>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">gray_iso_layers.py</command>
</script>
</inkscape-extension>

236
gray_iso_layers.py Normal file
View File

@ -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 <param> 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()

731
gray_iso_layers_core.py Normal file
View File

@ -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)

304
i18n.py Normal file
View File

@ -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/<domaine>.pot depuis le .inx et les .py
python i18n.py update # reporte le modele dans po/<langue>.po
python i18n.py compile # po/<langue>.po -> locale/<langue>/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) :
<name>, attributs gui-text et gui-description, contenu de <option>, <item> et
<label>, sauf translatable="no". Les espaces sont normalises, sauf sous
xml:space="preserve".
"""
import ast
import os
import re
import struct
import sys
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
LANGUAGES = ["en", "fr"]
SOURCE_LANGUAGE = "en"
PO_DIR = os.path.join(HERE, "po")
LOCALE_DIR = os.path.join(HERE, "locale")
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
INX_NS = "{http://www.inkscape.org/namespace/inkscape/extension}"
def _find_inx():
found = sorted(name for name in os.listdir(HERE) if name.endswith(".inx"))
if len(found) != 1:
sys.exit("i18n.py attend un seul fichier .inx dans {} (trouve : {})".format(
HERE, found))
return found[0]
INX = _find_inx()
_INX_ROOT = ET.parse(os.path.join(HERE, INX)).getroot()
DOMAIN = _INX_ROOT.get("translationdomain")
if not DOMAIN:
sys.exit("Attribut translationdomain absent de " + INX)
EXTENSION_NAME = (_INX_ROOT.findtext(INX_NS + "name") or DOMAIN).strip()
SOURCES = [INX] + sorted(
name for name in os.listdir(HERE)
if name.endswith(".py") and name != "i18n.py" and not name.startswith("test"))
# --------------------------------------------------------------------------
# Extraction
# --------------------------------------------------------------------------
def _local(tag):
return tag.rsplit("}", 1)[-1]
def _normalize(text, preserve):
return text if preserve else " ".join(text.split())
def extract_inx(path):
"""Chaines traduisibles d'un fichier .inx, dans l'ordre du document."""
messages = []
def walk(elem, preserve):
preserve = elem.get(XML_SPACE, "preserve" if preserve else "default") == "preserve"
if elem.get("translatable") != "no":
for attr in ("gui-text", "gui-description"):
if elem.get(attr):
messages.append(_normalize(elem.get(attr), preserve))
if _local(elem.tag) in ("name", "option", "item", "label") and elem.text:
messages.append(_normalize(elem.text, preserve))
for child in elem:
walk(child, preserve)
walk(ET.parse(path).getroot(), False)
return [m for m in messages if m]
def extract_py(path):
"""Arguments litteraux des appels _("...") d'un script Python."""
with open(path, encoding="utf-8") as handle:
tree = ast.parse(handle.read(), path)
calls = [node for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "_" and len(node.args) == 1
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)]
calls.sort(key=lambda node: (node.lineno, node.col_offset))
return [node.args[0].value for node in calls]
def extract():
"""Liste ordonnee et sans doublon de (msgid, [fichiers sources])."""
found = {}
for name in SOURCES:
path = os.path.join(HERE, name)
strings = extract_inx(path) if name.endswith(".inx") else extract_py(path)
for msgid in strings:
found.setdefault(msgid, [])
if name not in found[msgid]:
found[msgid].append(name)
return list(found.items())
# --------------------------------------------------------------------------
# Lecture / ecriture des fichiers .po
# --------------------------------------------------------------------------
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
def _unquote(line):
body = line.strip()[1:-1]
return re.sub(r'\\(.)', lambda m: _ESCAPES.get(m.group(1), m.group(1)), body)
def _quote(text):
text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
lines = text.split("\n")
parts = [line + "\\n" for line in lines[:-1]]
if lines[-1]:
parts.append(lines[-1])
if len(parts) <= 1:
return '"{}"'.format(parts[0] if parts else "")
return '""\n' + "\n".join('"{}"'.format(part) for part in parts)
def read_po(path):
"""Dictionnaire msgid -> (msgstr, fuzzy). L'en-tete a pour cle ""."""
entries = {}
if not os.path.exists(path):
return entries
msgid = msgstr = None
field = None
fuzzy = False
def flush():
if msgid is not None:
entries[msgid] = (msgstr or "", fuzzy)
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if line.startswith("#,") and "fuzzy" in line:
flush()
msgid = msgstr = field = None
fuzzy = True
elif line.startswith("msgid "):
if field == "msgstr":
flush()
fuzzy = False
msgid, msgstr, field = _unquote(line[6:]), None, "msgid"
elif line.startswith("msgstr "):
msgstr, field = _unquote(line[7:]), "msgstr"
elif line.startswith('"') and field == "msgid":
msgid += _unquote(line)
elif line.startswith('"') and field == "msgstr":
msgstr += _unquote(line)
elif not line and field == "msgstr":
flush()
msgid = msgstr = field = None
fuzzy = False
flush()
return entries
def _header(language):
fields = [
("Project-Id-Version", DOMAIN),
("Language", language or ""),
("MIME-Version", "1.0"),
("Content-Type", "text/plain; charset=UTF-8"),
("Content-Transfer-Encoding", "8bit"),
("Plural-Forms", {"fr": "nplurals=2; plural=(n > 1);",
"en": "nplurals=2; plural=(n != 1);"}.get(language, "")),
]
return "".join("{}: {}\n".format(key, value) for key, value in fields if value)
def write_po(path, language, messages, translations):
title = ("Modele de traduction" if language is None
else "Traduction ({})".format(language))
out = ["# {} de l'extension Inkscape « {} ».".format(title, EXTENSION_NAME),
"# Genere par i18n.py ; ne modifier que les msgstr.",
"msgid \"\"",
"msgstr " + _quote(_header(language)),
""]
for msgid, refs in messages:
msgstr, fuzzy = translations.get(msgid, ("", False))
out.append("#: " + " ".join(refs))
if fuzzy:
out.append("#, fuzzy")
out.append("msgid " + _quote(msgid))
out.append("msgstr " + _quote(msgstr))
out.append("")
with open(path, "w", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(out))
# --------------------------------------------------------------------------
# Compilation .mo (format GNU gettext)
# --------------------------------------------------------------------------
def write_mo(path, catalog):
"""Ecrit un catalogue {msgid: msgstr} au format .mo, cles triees."""
keys = sorted(catalog)
ids = [key.encode("utf-8") for key in keys]
strs = [catalog[key].encode("utf-8") for key in keys]
count = len(keys)
ids_start = 7 * 4 + 16 * count
strs_start = ids_start + sum(len(b) + 1 for b in ids)
table_ids, table_strs = [], []
offset = ids_start
for data in ids:
table_ids += [len(data), offset]
offset += len(data) + 1
offset = strs_start
for data in strs:
table_strs += [len(data), offset]
offset += len(data) + 1
output = struct.pack("<7I", 0x950412DE, 0, count, 7 * 4, 7 * 4 + 8 * count, 0, 0)
output += struct.pack("<{}I".format(2 * count), *table_ids)
output += struct.pack("<{}I".format(2 * count), *table_strs)
output += b"".join(data + b"\0" for data in ids)
output += b"".join(data + b"\0" for data in strs)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as handle:
handle.write(output)
# --------------------------------------------------------------------------
# Commandes
# --------------------------------------------------------------------------
def po_path(language):
return os.path.join(PO_DIR, "{}.po".format(language))
def mo_path(language):
return os.path.join(LOCALE_DIR, language, "LC_MESSAGES", DOMAIN + ".mo")
def cmd_extract():
os.makedirs(PO_DIR, exist_ok=True)
messages = extract()
write_po(os.path.join(PO_DIR, DOMAIN + ".pot"), None, messages, {})
print("{} chaines -> po/{}.pot".format(len(messages), DOMAIN))
def cmd_update():
messages = extract()
for language in LANGUAGES:
translations = read_po(po_path(language))
if language == SOURCE_LANGUAGE:
translations = {msgid: (msgid, False) for msgid, _refs in messages}
write_po(po_path(language), language, messages, translations)
missing = [m for m, _r in messages if not translations.get(m, ("", False))[0]]
print("po/{}.po : {} a traduire".format(language, len(missing)))
for msgid in missing:
print(" " + msgid.splitlines()[0])
def cmd_compile():
for language in LANGUAGES:
entries = read_po(po_path(language))
catalog = {msgid: msgstr for msgid, (msgstr, fuzzy) in entries.items()
if msgid and msgstr and not fuzzy}
catalog[""] = _header(language)
write_mo(mo_path(language), catalog)
print("{} -> {} ({} chaines)".format(
os.path.relpath(po_path(language), HERE),
os.path.relpath(mo_path(language), HERE), len(catalog) - 1))
def main(argv):
commands = {"extract": [cmd_extract], "update": [cmd_update],
"compile": [cmd_compile],
"all": [cmd_extract, cmd_update, cmd_compile]}
name = argv[0] if argv else "all"
if name not in commands:
print(__doc__)
return 1
for command in commands[name]:
command()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

BIN
images/parameters_en.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Binary file not shown.

180
po/en.po Normal file
View File

@ -0,0 +1,180 @@
# Traduction (en) de l'extension Inkscape « Gray Iso-Layers ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: grayisolayers\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: gray_iso_layers.inx
msgid "Gray Iso-Layers"
msgstr "Gray Iso-Layers"
#: gray_iso_layers.inx
msgid "Levels"
msgstr "Levels"
#: gray_iso_layers.inx
msgid "Number of gray levels:"
msgstr "Number of gray levels:"
#: gray_iso_layers.inx
msgid "Content of each board:"
msgstr "Content of each board:"
#: gray_iso_layers.inx
msgid "Stacked, lightest in front (board no. 1 = darkest, whole image)"
msgstr "Stacked, lightest in front (board no. 1 = darkest, whole image)"
#: gray_iso_layers.inx
msgid "Stacked, darkest in front (board no. 1 = lightest, whole image)"
msgstr "Stacked, darkest in front (board no. 1 = lightest, whole image)"
#: gray_iso_layers.inx
msgid "Its own gray only (boards side by side, no overlap)"
msgstr "Its own gray only (boards side by side, no overlap)"
#: gray_iso_layers.inx
msgid "Mark on each board the outline of the next one (stacked boards)"
msgstr "Mark on each board the outline of the next one (stacked boards)"
#: gray_iso_layers.inx
msgid "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut."
msgstr "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut."
#: gray_iso_layers.inx
msgid "Smoothing (% of the image's longer side):"
msgstr "Smoothing (% of the image's longer side):"
#: gray_iso_layers.inx
msgid "Smoothing applied before cutting the levels: the higher it is, the rounder and simpler the outlines."
msgstr "Smoothing applied before cutting the levels: the higher it is, the rounder and simpler the outlines."
#: gray_iso_layers.inx
msgid "Preserve edges while smoothing (portraits, photos)"
msgstr "Preserve edges while smoothing (portraits, photos)"
#: gray_iso_layers.inx
msgid "Flattens skin and textures but keeps sharp transitions (eyes, mouth, outline of a face). Uncheck for a plain blur, better suited to abstract reliefs."
msgstr "Flattens skin and textures but keeps sharp transitions (eyes, mouth, outline of a face). Uncheck for a plain blur, better suited to abstract reliefs."
#: gray_iso_layers.inx
msgid "Calculation resolution (points on the longer side):"
msgstr "Calculation resolution (points on the longer side):"
#: gray_iso_layers.inx
msgid "The image is resampled to this size before the calculation. Higher is more accurate but slower."
msgstr "The image is resampled to this size before the calculation. Higher is more accurate but slower."
#: gray_iso_layers.inx
msgid "Smallest island or hole kept (% of the image area):"
msgstr "Smallest island or hole kept (% of the image area):"
#: gray_iso_layers.inx
msgid "Simplification (calculation points):"
msgstr "Simplification (calculation points):"
#: gray_iso_layers.inx
msgid "Largest deviation allowed when removing nodes. 0 keeps every node."
msgstr "Largest deviation allowed when removing nodes. 0 keeps every node."
#: gray_iso_layers.inx
msgid "Smooth the outlines with Bézier curves"
msgstr "Smooth the outlines with Bézier curves"
#: gray_iso_layers.inx
msgid "Style"
msgstr "Style"
#: gray_iso_layers.inx
msgid "Fill:"
msgstr "Fill:"
#: gray_iso_layers.inx
msgid "Gray of each level"
msgstr "Gray of each level"
#: gray_iso_layers.inx
msgid "Single color (below)"
msgstr "Single color (below)"
#: gray_iso_layers.inx
msgid "No fill (outlines only)"
msgstr "No fill (outlines only)"
#: gray_iso_layers.inx
msgid "Fill color:"
msgstr "Fill color:"
#: gray_iso_layers.inx
msgid "Stroke width (0 = no stroke):"
msgstr "Stroke width (0 = no stroke):"
#: gray_iso_layers.inx
msgid "Unit:"
msgstr "Unit:"
#: gray_iso_layers.inx
msgid "Cut stroke color:"
msgstr "Cut stroke color:"
#: gray_iso_layers.inx
msgid "Marking stroke color:"
msgstr "Marking stroke color:"
#: gray_iso_layers.inx
msgid "Keep the original image"
msgstr "Keep the original image"
#: gray_iso_layers.inx
msgid "Help"
msgstr "Help"
#: gray_iso_layers.inx
msgid ""
"Select one or more bitmap images (imported photo, height map, blurred noise), then run the extension. Colors are read as grays and transparency as white.\n"
"\n"
"The image is smoothed, then split into the requested number of gray levels with evenly spaced thresholds. Each level becomes one closed path that follows the edge of its flat area, like a contour line on a map.\n"
"\n"
"Each gray level goes into its own layer, named Board no. 1, Board no. 2... from the back to the front, added above the image: as many new layers as gray levels. The boards are meant to be cut and stacked: board no. 1 covers the whole image and each following board is smaller and lies in front of the previous one, lightest (or darkest) in front. In each layer, the cut outline of the board is drawn in the cut color (red), and the outline of the next board is carried over in the marking color (black), to show where to glue it. With \"Its own gray only\", a board holds the flat area of its gray alone: the boards sit side by side without overlapping and carry no marking. Several selected images share the same layers.\n"
"\n"
"For laser cutting, choose no fill: only the cut and marking strokes remain. For line art only, choose a white single-color fill, or no fill, with a black stroke and no marking. If the outlines are too busy, raise the smoothing or the smallest island; if they look angular, raise the smoothing or the simplification. If a face is no longer recognizable, lower the smoothing and keep \"Preserve edges\" checked."
msgstr ""
"Select one or more bitmap images (imported photo, height map, blurred noise), then run the extension. Colors are read as grays and transparency as white.\n"
"\n"
"The image is smoothed, then split into the requested number of gray levels with evenly spaced thresholds. Each level becomes one closed path that follows the edge of its flat area, like a contour line on a map.\n"
"\n"
"Each gray level goes into its own layer, named Board no. 1, Board no. 2... from the back to the front, added above the image: as many new layers as gray levels. The boards are meant to be cut and stacked: board no. 1 covers the whole image and each following board is smaller and lies in front of the previous one, lightest (or darkest) in front. In each layer, the cut outline of the board is drawn in the cut color (red), and the outline of the next board is carried over in the marking color (black), to show where to glue it. With \"Its own gray only\", a board holds the flat area of its gray alone: the boards sit side by side without overlapping and carry no marking. Several selected images share the same layers.\n"
"\n"
"For laser cutting, choose no fill: only the cut and marking strokes remain. For line art only, choose a white single-color fill, or no fill, with a black stroke and no marking. If the outlines are too busy, raise the smoothing or the smallest island; if they look angular, raise the smoothing or the simplification. If a face is no longer recognizable, lower the smoothing and keep \"Preserve edges\" checked."
#: gray_iso_layers.py
msgid "Select at least one bitmap image (File > Import), then run the extension again."
msgstr "Select at least one bitmap image (File > Import), then run the extension again."
#: gray_iso_layers.py
msgid "At least 2 gray levels are needed."
msgstr "At least 2 gray levels are needed."
#: gray_iso_layers.py
msgid "Board no. {}"
msgstr "Board no. {}"
#: gray_iso_layers.py
msgid "Cannot read the image '{}' ({}). Embed it in the document (right-click > Embed Image) or check its link."
msgstr "Cannot read the image '{}' ({}). Embed it in the document (right-click > Embed Image) or check its link."
#: gray_iso_layers.py
msgid "The image '{}' is uniform: only the base level has a shape. Reduce the smoothing or pick a more contrasted image."
msgstr "The image '{}' is uniform: only the base level has a shape. Reduce the smoothing or pick a more contrasted image."
#: gray_iso_layers.py
msgid "Cut"
msgstr "Cut"
#: gray_iso_layers.py
msgid "Marking of board no. {}"
msgstr "Marking of board no. {}"

180
po/fr.po Normal file
View File

@ -0,0 +1,180 @@
# Traduction (fr) de l'extension Inkscape « Gray Iso-Layers ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: grayisolayers\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: gray_iso_layers.inx
msgid "Gray Iso-Layers"
msgstr "Iso-couches par nuances de gris"
#: gray_iso_layers.inx
msgid "Levels"
msgstr "Niveaux"
#: gray_iso_layers.inx
msgid "Number of gray levels:"
msgstr "Nombre de niveaux de gris :"
#: gray_iso_layers.inx
msgid "Content of each board:"
msgstr "Contenu de chaque planche :"
#: gray_iso_layers.inx
msgid "Stacked, lightest in front (board no. 1 = darkest, whole image)"
msgstr "Empilées, la plus claire devant (planche n° 1 = la plus sombre, toute l'image)"
#: gray_iso_layers.inx
msgid "Stacked, darkest in front (board no. 1 = lightest, whole image)"
msgstr "Empilées, la plus sombre devant (planche n° 1 = la plus claire, toute l'image)"
#: gray_iso_layers.inx
msgid "Its own gray only (boards side by side, no overlap)"
msgstr "Son gris seul (planches côte à côte, sans recouvrement)"
#: gray_iso_layers.inx
msgid "Mark on each board the outline of the next one (stacked boards)"
msgstr "Reporter sur chaque planche le contour de la suivante (planches empilées)"
#: gray_iso_layers.inx
msgid "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut."
msgstr "Repère d'assemblage : le contour de la planche à coller par-dessus est tracé dans la couleur de marquage, sans être découpé."
#: gray_iso_layers.inx
msgid "Smoothing (% of the image's longer side):"
msgstr "Lissage (% du grand côté de l'image) :"
#: gray_iso_layers.inx
msgid "Smoothing applied before cutting the levels: the higher it is, the rounder and simpler the outlines."
msgstr "Lissage appliqué avant de découper les niveaux : plus il est fort, plus les contours sont ronds et simples."
#: gray_iso_layers.inx
msgid "Preserve edges while smoothing (portraits, photos)"
msgstr "Préserver les contours lors du lissage (portraits, photos)"
#: gray_iso_layers.inx
msgid "Flattens skin and textures but keeps sharp transitions (eyes, mouth, outline of a face). Uncheck for a plain blur, better suited to abstract reliefs."
msgstr "Aplanit la peau et les textures mais garde nettes les transitions marquées (yeux, bouche, contour d'un visage). Décochez pour un flou simple, mieux adapté aux reliefs abstraits."
#: gray_iso_layers.inx
msgid "Calculation resolution (points on the longer side):"
msgstr "Résolution de calcul (points sur le grand côté) :"
#: gray_iso_layers.inx
msgid "The image is resampled to this size before the calculation. Higher is more accurate but slower."
msgstr "L'image est rééchantillonnée à cette taille avant le calcul. Une valeur élevée est plus précise mais plus lente."
#: gray_iso_layers.inx
msgid "Smallest island or hole kept (% of the image area):"
msgstr "Plus petit îlot ou trou conservé (% de la surface de l'image) :"
#: gray_iso_layers.inx
msgid "Simplification (calculation points):"
msgstr "Simplification (points de calcul) :"
#: gray_iso_layers.inx
msgid "Largest deviation allowed when removing nodes. 0 keeps every node."
msgstr "Écart maximal toléré lors de la suppression de nœuds. 0 conserve tous les nœuds."
#: gray_iso_layers.inx
msgid "Smooth the outlines with Bézier curves"
msgstr "Lisser les contours avec des courbes de Bézier"
#: gray_iso_layers.inx
msgid "Style"
msgstr "Style"
#: gray_iso_layers.inx
msgid "Fill:"
msgstr "Remplissage :"
#: gray_iso_layers.inx
msgid "Gray of each level"
msgstr "Gris de chaque niveau"
#: gray_iso_layers.inx
msgid "Single color (below)"
msgstr "Couleur unique (ci-dessous)"
#: gray_iso_layers.inx
msgid "No fill (outlines only)"
msgstr "Aucun remplissage (contours seuls)"
#: gray_iso_layers.inx
msgid "Fill color:"
msgstr "Couleur de remplissage :"
#: gray_iso_layers.inx
msgid "Stroke width (0 = no stroke):"
msgstr "Épaisseur du trait (0 = sans trait) :"
#: gray_iso_layers.inx
msgid "Unit:"
msgstr "Unité :"
#: gray_iso_layers.inx
msgid "Cut stroke color:"
msgstr "Couleur du trait de découpe :"
#: gray_iso_layers.inx
msgid "Marking stroke color:"
msgstr "Couleur du trait de marquage :"
#: gray_iso_layers.inx
msgid "Keep the original image"
msgstr "Conserver l'image d'origine"
#: gray_iso_layers.inx
msgid "Help"
msgstr "Aide"
#: gray_iso_layers.inx
msgid ""
"Select one or more bitmap images (imported photo, height map, blurred noise), then run the extension. Colors are read as grays and transparency as white.\n"
"\n"
"The image is smoothed, then split into the requested number of gray levels with evenly spaced thresholds. Each level becomes one closed path that follows the edge of its flat area, like a contour line on a map.\n"
"\n"
"Each gray level goes into its own layer, named Board no. 1, Board no. 2... from the back to the front, added above the image: as many new layers as gray levels. The boards are meant to be cut and stacked: board no. 1 covers the whole image and each following board is smaller and lies in front of the previous one, lightest (or darkest) in front. In each layer, the cut outline of the board is drawn in the cut color (red), and the outline of the next board is carried over in the marking color (black), to show where to glue it. With \"Its own gray only\", a board holds the flat area of its gray alone: the boards sit side by side without overlapping and carry no marking. Several selected images share the same layers.\n"
"\n"
"For laser cutting, choose no fill: only the cut and marking strokes remain. For line art only, choose a white single-color fill, or no fill, with a black stroke and no marking. If the outlines are too busy, raise the smoothing or the smallest island; if they look angular, raise the smoothing or the simplification. If a face is no longer recognizable, lower the smoothing and keep \"Preserve edges\" checked."
msgstr ""
"Sélectionnez une ou plusieurs images bitmap (photo importée, carte de relief, bruit flouté), puis lancez l'extension. Les couleurs sont lues comme des gris et la transparence comme du blanc.\n"
"\n"
"L'image est lissée, puis découpée en autant de niveaux de gris que demandé, avec des seuils régulièrement espacés. Chaque niveau devient un chemin fermé qui suit le bord de son aplat, comme une courbe de niveau sur une carte.\n"
"\n"
"Chaque niveau de gris va dans son propre calque, nommé Planche n° 1, Planche n° 2… du fond vers l'avant, ajouté au-dessus de l'image : il y a donc autant de nouveaux calques que de niveaux de gris. Les planches sont faites pour être découpées puis empilées : la planche n° 1 couvre toute l'image et chaque planche suivante, plus petite, se pose devant la précédente, la plus claire (ou la plus sombre) devant. Dans chaque calque, le contour de découpe de la planche est tracé dans la couleur de découpe (rouge), et le contour de la planche suivante est reporté dans la couleur de marquage (noir), pour montrer où la coller. Avec « Son gris seul », une planche contient uniquement l'aplat de son gris : les planches sont côte à côte sans se recouvrir et ne portent aucun marquage. Plusieurs images sélectionnées partagent les mêmes calques.\n"
"\n"
"Pour la découpe laser, choisissez aucun remplissage : il ne reste que les traits de découpe et de marquage. Pour un dessin au trait seul, choisissez un remplissage de couleur unique blanche, ou aucun remplissage, avec un trait noir et sans marquage. Si les contours sont trop chargés, augmentez le lissage ou le plus petit îlot ; s'ils paraissent anguleux, augmentez le lissage ou la simplification. Si un visage n'est plus reconnaissable, réduisez le lissage et laissez « Préserver les contours » coché."
#: gray_iso_layers.py
msgid "Select at least one bitmap image (File > Import), then run the extension again."
msgstr "Sélectionnez au moins une image bitmap (Fichier > Importer), puis relancez l'extension."
#: gray_iso_layers.py
msgid "At least 2 gray levels are needed."
msgstr "Il faut au moins 2 niveaux de gris."
#: gray_iso_layers.py
msgid "Board no. {}"
msgstr "Planche n° {}"
#: gray_iso_layers.py
msgid "Cannot read the image '{}' ({}). Embed it in the document (right-click > Embed Image) or check its link."
msgstr "Impossible de lire l'image « {} » ({}). Incorporez-la au document (clic droit > Incorporer l'image) ou vérifiez son lien."
#: gray_iso_layers.py
msgid "The image '{}' is uniform: only the base level has a shape. Reduce the smoothing or pick a more contrasted image."
msgstr "L'image « {} » est uniforme : seul le niveau de base contient une forme. Réduisez le lissage ou choisissez une image plus contrastée."
#: gray_iso_layers.py
msgid "Cut"
msgstr "Découpe"
#: gray_iso_layers.py
msgid "Marking of board no. {}"
msgstr "Repère de la planche n° {}"

171
po/grayisolayers.pot Normal file
View File

@ -0,0 +1,171 @@
# Modele de traduction de l'extension Inkscape « Gray Iso-Layers ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: grayisolayers\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: gray_iso_layers.inx
msgid "Gray Iso-Layers"
msgstr ""
#: gray_iso_layers.inx
msgid "Levels"
msgstr ""
#: gray_iso_layers.inx
msgid "Number of gray levels:"
msgstr ""
#: gray_iso_layers.inx
msgid "Content of each board:"
msgstr ""
#: gray_iso_layers.inx
msgid "Stacked, lightest in front (board no. 1 = darkest, whole image)"
msgstr ""
#: gray_iso_layers.inx
msgid "Stacked, darkest in front (board no. 1 = lightest, whole image)"
msgstr ""
#: gray_iso_layers.inx
msgid "Its own gray only (boards side by side, no overlap)"
msgstr ""
#: gray_iso_layers.inx
msgid "Mark on each board the outline of the next one (stacked boards)"
msgstr ""
#: gray_iso_layers.inx
msgid "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut."
msgstr ""
#: gray_iso_layers.inx
msgid "Smoothing (% of the image's longer side):"
msgstr ""
#: gray_iso_layers.inx
msgid "Smoothing applied before cutting the levels: the higher it is, the rounder and simpler the outlines."
msgstr ""
#: gray_iso_layers.inx
msgid "Preserve edges while smoothing (portraits, photos)"
msgstr ""
#: gray_iso_layers.inx
msgid "Flattens skin and textures but keeps sharp transitions (eyes, mouth, outline of a face). Uncheck for a plain blur, better suited to abstract reliefs."
msgstr ""
#: gray_iso_layers.inx
msgid "Calculation resolution (points on the longer side):"
msgstr ""
#: gray_iso_layers.inx
msgid "The image is resampled to this size before the calculation. Higher is more accurate but slower."
msgstr ""
#: gray_iso_layers.inx
msgid "Smallest island or hole kept (% of the image area):"
msgstr ""
#: gray_iso_layers.inx
msgid "Simplification (calculation points):"
msgstr ""
#: gray_iso_layers.inx
msgid "Largest deviation allowed when removing nodes. 0 keeps every node."
msgstr ""
#: gray_iso_layers.inx
msgid "Smooth the outlines with Bézier curves"
msgstr ""
#: gray_iso_layers.inx
msgid "Style"
msgstr ""
#: gray_iso_layers.inx
msgid "Fill:"
msgstr ""
#: gray_iso_layers.inx
msgid "Gray of each level"
msgstr ""
#: gray_iso_layers.inx
msgid "Single color (below)"
msgstr ""
#: gray_iso_layers.inx
msgid "No fill (outlines only)"
msgstr ""
#: gray_iso_layers.inx
msgid "Fill color:"
msgstr ""
#: gray_iso_layers.inx
msgid "Stroke width (0 = no stroke):"
msgstr ""
#: gray_iso_layers.inx
msgid "Unit:"
msgstr ""
#: gray_iso_layers.inx
msgid "Cut stroke color:"
msgstr ""
#: gray_iso_layers.inx
msgid "Marking stroke color:"
msgstr ""
#: gray_iso_layers.inx
msgid "Keep the original image"
msgstr ""
#: gray_iso_layers.inx
msgid "Help"
msgstr ""
#: gray_iso_layers.inx
msgid ""
"Select one or more bitmap images (imported photo, height map, blurred noise), then run the extension. Colors are read as grays and transparency as white.\n"
"\n"
"The image is smoothed, then split into the requested number of gray levels with evenly spaced thresholds. Each level becomes one closed path that follows the edge of its flat area, like a contour line on a map.\n"
"\n"
"Each gray level goes into its own layer, named Board no. 1, Board no. 2... from the back to the front, added above the image: as many new layers as gray levels. The boards are meant to be cut and stacked: board no. 1 covers the whole image and each following board is smaller and lies in front of the previous one, lightest (or darkest) in front. In each layer, the cut outline of the board is drawn in the cut color (red), and the outline of the next board is carried over in the marking color (black), to show where to glue it. With \"Its own gray only\", a board holds the flat area of its gray alone: the boards sit side by side without overlapping and carry no marking. Several selected images share the same layers.\n"
"\n"
"For laser cutting, choose no fill: only the cut and marking strokes remain. For line art only, choose a white single-color fill, or no fill, with a black stroke and no marking. If the outlines are too busy, raise the smoothing or the smallest island; if they look angular, raise the smoothing or the simplification. If a face is no longer recognizable, lower the smoothing and keep \"Preserve edges\" checked."
msgstr ""
#: gray_iso_layers.py
msgid "Select at least one bitmap image (File > Import), then run the extension again."
msgstr ""
#: gray_iso_layers.py
msgid "At least 2 gray levels are needed."
msgstr ""
#: gray_iso_layers.py
msgid "Board no. {}"
msgstr ""
#: gray_iso_layers.py
msgid "Cannot read the image '{}' ({}). Embed it in the document (right-click > Embed Image) or check its link."
msgstr ""
#: gray_iso_layers.py
msgid "The image '{}' is uniform: only the base level has a shape. Reduce the smoothing or pick a more contrasted image."
msgstr ""
#: gray_iso_layers.py
msgid "Cut"
msgstr ""
#: gray_iso_layers.py
msgid "Marking of board no. {}"
msgstr ""

790
test_gray_iso_layers.py Normal file
View File

@ -0,0 +1,790 @@
# coding=utf-8
"""Tests de l'extension « Gray Iso-Layers » (pytest)."""
import base64
import io
import math
import os
import re
import xml.etree.ElementTree as ET
import numpy as np
import pytest
from gray_iso_layers_core import (
box_blur, contour_lines, contour_rings, edge_preserving_blur, gaussian_blur,
gray_to_hex, image_ring, iso_boards, iso_layers, lines_to_d, normalize,
parse_color, ring_area, rings_to_d, scale_lines, scale_rings, simplify_chain,
simplify_ring, smooth, thresholds)
HERE = os.path.dirname(os.path.abspath(__file__))
def cone(size=101, radius=40.0):
"""Champ en cone : 1 au centre, 0 a `radius` echantillons et au-dela."""
y, x = np.mgrid[0:size, 0:size]
distance = np.hypot(x - (size - 1) / 2.0, y - (size - 1) / 2.0)
return np.clip(1.0 - distance / radius, 0.0, 1.0)
def ramp(width=60, height=40):
"""Rampe horizontale de 0 (gauche) a 1 (droite)."""
return np.tile(np.linspace(0.0, 1.0, width), (height, 1))
def evenodd_area(rings):
"""Aire d'un contour a trous imbriques simples : exterieur moins trous."""
areas = sorted((ring_area(ring) for ring in rings), reverse=True)
return sum(a if k % 2 == 0 else -a for k, a in enumerate(areas))
def contains(rings, x, y):
"""Le point est-il dans le contour, selon la regle pair-impair ?"""
crossings = 0
for ring in rings:
x0, y0 = ring[:, 0], ring[:, 1]
x1, y1 = np.roll(x0, -1), np.roll(y0, -1)
straddle = (y0 > y) != (y1 > y)
with np.errstate(divide="ignore", invalid="ignore"):
at = x0 + (y - y0) * (x1 - x0) / (y1 - y0)
crossings += int(np.count_nonzero(straddle & (at > x)))
return crossings % 2 == 1
def bilinear(field, x, y):
j, i = min(int(x), field.shape[1] - 2), min(int(y), field.shape[0] - 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 relief(seed=1, width=90, height=60, blur=4.0):
"""Relief aleatoire lisse, tel que l'extension le quantifie (deja floute
et etire : a passer a iso_layers avec blur=0)."""
rng = np.random.default_rng(seed)
return normalize(gaussian_blur(rng.random((height, width)), blur))
# --------------------------------------------------------------------------
# Noyau (sans inkex)
# --------------------------------------------------------------------------
def test_parse_color_inkscape_integer():
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
def test_parse_color_hex_and_invalid():
assert parse_color("#123456") == ("#123456", 1.0)
assert parse_color("#abc") == ("#aabbcc", 1.0)
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
def test_gray_to_hex():
assert gray_to_hex(0) == "#000000"
assert gray_to_hex(1) == "#ffffff"
assert gray_to_hex(0.5) == "#808080"
assert gray_to_hex(2) == "#ffffff"
def test_thresholds_are_regular():
assert thresholds(4) == [0.25, 0.5, 0.75]
assert thresholds(2) == [0.5]
def test_normalize_stretches_and_handles_uniform():
field = normalize(np.array([[0.2, 0.4], [0.3, 0.6]]))
assert field.min() == 0.0 and field.max() == 1.0
assert not normalize(np.full((3, 3), 0.7)).any()
def test_gaussian_blur_keeps_mean_and_uniform_field():
uniform = np.full((20, 30), 0.4)
assert np.allclose(gaussian_blur(uniform, 3.0), 0.4)
spot = np.zeros((41, 41))
spot[20, 20] = 1.0
blurred = gaussian_blur(spot, 2.0)
assert blurred.max() < 0.1
assert math.isclose(blurred.sum(), 1.0, rel_tol=1e-6)
assert np.array_equal(gaussian_blur(spot, 0), spot)
def test_box_blur_is_a_window_mean():
assert np.allclose(box_blur(np.full((9, 12), 0.3), 2), 0.3)
spot = np.zeros((9, 9))
spot[4, 4] = 1.0
blurred = box_blur(spot, 1)
assert np.allclose(blurred[3:6, 3:6], 1 / 9.0) and blurred[2, 4] == 0
assert np.array_equal(box_blur(spot, 0), spot)
def noisy_step(seed=3):
"""Deux aplats (0.25 et 0.75) separes par un bord vertical net, avec du grain."""
rng = np.random.default_rng(seed)
field = np.where(np.arange(80) < 40, 0.25, 0.75) * np.ones((60, 1))
return field + rng.normal(0, 0.03, field.shape)
def test_edge_preserving_blur_flattens_grain_but_keeps_edges():
field = noisy_step()
kept = edge_preserving_blur(field, 6)
plain = gaussian_blur(field, 6)
# Le grain des aplats est aplani...
assert kept[:, 5:30].std() < 0.01 and kept[:, 50:75].std() < 0.01
# ... mais le bord reste net, la ou le flou gaussien l'etale.
assert kept[:, 38].mean() < 0.3 and kept[:, 41].mean() > 0.7
assert plain[:, 38].mean() > 0.4 and plain[:, 41].mean() < 0.6
assert np.allclose(edge_preserving_blur(np.full((20, 20), 0.6), 4), 0.6)
assert np.array_equal(edge_preserving_blur(field, 0), field)
def test_smooth_stretches_and_selects_the_filter():
field = noisy_step()
for preserve in (False, True):
result = smooth(field, 5.0, preserve)
assert result.min() == 0.0 and result.max() == 1.0
assert np.array_equal(smooth(field, 0.0, True), normalize(field))
# Largeur de la transition entre les deux aplats, en echantillons.
def width(result):
row = result.mean(axis=0)
return int(np.count_nonzero((row > 0.1) & (row < 0.9)))
assert width(smooth(field, 5.0, True)) < width(smooth(field, 5.0, False)) / 2.0
def test_iso_layers_preserve_edges_keeps_a_thin_feature():
# Un trait sombre fin sur fond clair (une bouche, une branche de
# lunettes) : le flou gaussien l'etale, le lissage qui respecte les
# contours lui garde sa largeur.
field = np.full((80, 80), 0.8)
field[38:42, 15:65] = 0.1
field += np.random.default_rng(2).normal(0, 0.02, field.shape)
def thickness(result):
"""Epaisseur du trait a mi-hauteur, en echantillons."""
return int(np.count_nonzero(result[:, 35:45].mean(axis=1) < 0.5))
assert thickness(smooth(field, 6.0, False)) >= 10
assert thickness(smooth(field, 6.0, True)) <= 6
# Le niveau le plus sombre reste une bande fine autour du trait.
dark = iso_layers(field, levels=2, blur=6.0, preserve_edges=True)[0][2]
assert len(dark) == 1
assert dark[0][:, 1].max() - dark[0][:, 1].min() <= 7
def test_contour_of_cone_is_a_circle():
# Seuil 0.5 sur un cone de rayon 40 : cercle de rayon 20.
rings = contour_rings(cone(), 0.5)
assert len(rings) == 1
assert math.isclose(ring_area(rings[0]), math.pi * 20 ** 2, rel_tol=0.01)
radii = np.hypot(rings[0][:, 0] - 50, rings[0][:, 1] - 50)
assert np.allclose(radii, 20, atol=0.3)
def test_contour_below_threshold_has_a_hole():
# Region « sous le seuil » : le rectangle entier perce du cercle.
rings = contour_rings(cone(), 0.5, above=False)
assert len(rings) == 2
assert math.isclose(evenodd_area(rings), 100 * 100 - math.pi * 20 ** 2, rel_tol=0.01)
def test_contour_closes_along_the_image_border():
# Rampe : la region >= 0.5 est la moitie droite, fermee par les bords.
rings = contour_rings(ramp(), 0.5)
assert len(rings) == 1
ring = rings[0]
assert math.isclose(ring_area(ring), 29.5 * 39, rel_tol=1e-6)
assert math.isclose(ring[:, 0].min(), 29.5, abs_tol=1e-6)
assert ring[:, 0].max() == 59 and ring[:, 1].min() == 0 and ring[:, 1].max() == 39
def test_contour_saddle_and_empty_cases():
checker = np.array([[1.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 1.0]])
for ring in contour_rings(checker, 0.5):
assert len(ring) >= 3
assert contour_rings(np.zeros((5, 5)), 0.5) == []
assert contour_rings(np.zeros((1, 5)), 0.5) == []
full = contour_rings(np.ones((5, 7)), 0.5)
assert len(full) == 1 and math.isclose(ring_area(full[0]), 6 * 4)
def test_simplify_ring_removes_aligned_points_only():
square = []
for k in range(10):
square.append((k, 0))
for k in range(10):
square.append((10, k))
for k in range(10):
square.append((10 - k, 10))
for k in range(10):
square.append((0, 10 - k))
simplified = simplify_ring(np.array(square, dtype=float), 0.1)
assert len(simplified) == 4
assert math.isclose(ring_area(simplified), 100.0)
circle = contour_rings(cone(), 0.5)[0]
light = simplify_ring(circle, 0.3)
assert len(light) < len(circle)
assert math.isclose(ring_area(light), ring_area(circle), rel_tol=0.02)
assert len(simplify_ring(circle, 0)) == len(circle)
def test_simplify_chain_keeps_both_ends():
chain = np.array([(0, 0), (1, 0.01), (2, 0), (3, 2), (4, 4.01), (5, 6)], dtype=float)
assert simplify_chain(chain, 0.1).tolist() == [[0, 0], [2, 0], [5, 6]]
assert len(simplify_chain(chain, 0)) == 6
def test_contour_lines_split_closed_and_open():
closed, opened = contour_lines(cone(), 0.5)
assert len(closed) == 1 and opened == []
# Rampe : une ligne ouverte, du bord haut au bord bas.
closed, opened = contour_lines(ramp(), 0.5, tolerance=0.1)
assert closed == [] and len(opened) == 1
assert sorted(opened[0].tolist()) == [[29.5, 0.0], [29.5, 39.0]]
# Une ligne que la simplification aplatit sur le bord est ecartee.
bump = np.zeros((40, 60))
bump[0, 20:30] = 0.6
assert len(contour_lines(bump, 0.5)[1]) == 1
assert contour_lines(bump, 0.5, tolerance=2.0) == ([], [])
# Aire trop petite : boucle et ligne ecartees.
assert contour_lines(cone(), 0.5, min_area=2000) == ([], [])
assert contour_lines(ramp(), 0.9, min_area=500) == ([], [])
def test_iso_layers_bands_of_cone_are_rings():
layers = iso_layers(cone(), levels=4)
assert [level for level, _gray, _rings in layers] == [0, 1, 2, 3]
assert [gray for _level, gray, _rings in layers] == [0.0, 1 / 3.0, 2 / 3.0, 1.0]
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
# Seuils 1/4, 1/2, 3/4 : cercles de rayons 30, 20 et 10. Chaque aplat est
# la couronne entre deux cercles, bordee des deux cotes.
disc = [math.pi * r ** 2 for r in (30, 20, 10)]
expected = [100 * 100 - disc[0], disc[0] - disc[1], disc[1] - disc[2], disc[2]]
for area, wanted in zip(areas, expected):
assert math.isclose(area, wanted, rel_tol=0.02)
assert [len(rings) for _level, _gray, rings in layers] == [2, 2, 2, 1]
assert math.isclose(sum(areas), 100 * 100, rel_tol=1e-9)
@pytest.mark.parametrize("tolerance", [0.0, 0.4])
def test_iso_layers_bands_are_the_quantized_image(tolerance):
"""Chaque point de l'image est dans l'aplat de son niveau quantifie, et
dans aucun autre : les contours sont bien les frontieres des niveaux."""
levels = 8
field = relief()
layers = iso_layers(field, levels=levels, tolerance=tolerance)
assert len(layers) == levels
rng = np.random.default_rng(5)
checked = 0
for _ in range(600):
x, y = rng.uniform(0, 89), rng.uniform(0, 59)
value = bilinear(field, x, y) * levels
if abs(value - round(value)) < 0.15:
continue # trop pres d'un seuil : interpolation et simplification
inside = [level for level, _gray, rings in layers if contains(rings, x, y)]
assert inside == [min(int(value), levels - 1)], (x, y, value)
checked += 1
assert checked > 300
def test_iso_layers_bands_never_overlap_when_small_shapes_are_dropped():
"""Avec `min_area`, des formes disparaissent, mais aucun point ne se
retrouve dans deux aplats et les grands aplats restent a leur place."""
levels = 5
rng = np.random.default_rng(7)
# Relief a grain fin : beaucoup de petites formes, y compris sur le bord.
field = normalize(gaussian_blur(rng.random((121, 161)), 2.0))
for seed in range(4):
field = normalize(field + 0.6 * relief(seed, 161, 121, 12.0))
layers = iso_layers(field, levels=levels, tolerance=0.3, min_area=0.002)
probes = np.random.default_rng(seed)
wrong = 0
for _ in range(400):
x, y = probes.uniform(0, 160), probes.uniform(0, 120)
inside = [level for level, _gray, rings in layers if contains(rings, x, y)]
assert len(inside) <= 1, (x, y, inside)
value = bilinear(field, x, y) * levels
wrong += inside != [min(int(value), levels - 1)]
# Seuls les points des petites formes ecartees changent de niveau.
assert wrong < 60, wrong
def test_iso_layers_dropped_speck_on_the_border_does_not_flip_a_band():
# Coin sombre en haut a gauche, et un eclat sombre pose sur le bord pile a
# l'oppose (coin bas-droit) : une fois l'eclat ecarte, l'aplat sombre doit
# rester le coin, pas son complementaire.
field = np.ones((60, 60))
field[:20, :20] = 0.0
field[59, 59] = 0.0
dark, light = iso_layers(field, levels=2, min_area=0.001)
assert len(dark[2]) == 1 and len(light[2]) == 1
assert math.isclose(ring_area(dark[2][0]), 19.5 ** 2, rel_tol=0.01)
assert math.isclose(ring_area(light[2][0]), 59 ** 2 - 19.5 ** 2, rel_tol=0.01)
def test_iso_layers_neighbouring_bands_share_their_border():
layers = iso_layers(relief(), levels=6, tolerance=0.4)
def interior(rings):
points = np.vstack(rings)
edge = ((points[:, 0] < 1e-6) | (points[:, 0] > 89 - 1e-6)
| (points[:, 1] < 1e-6) | (points[:, 1] > 59 - 1e-6))
return {tuple(point) for point in points[~edge].round(9).tolist()}
sets = [interior(rings) for _level, _gray, rings in layers]
for k, points in enumerate(sets):
neighbours = set()
for n in (k - 1, k + 1):
if 0 <= n < len(sets):
neighbours |= sets[n]
# Tout point de contour interieur appartient aussi a un aplat voisin.
assert points <= neighbours
assert sum(1 for points in sets if points) >= 4
def test_iso_layers_light_sheets_are_nested_discs():
layers = iso_layers(cone(), levels=4, shapes="light")
assert [level for level, _gray, _rings in layers] == [0, 1, 2, 3]
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
assert math.isclose(areas[0], 100 * 100)
for area, radius in zip(areas[1:], (30, 20, 10)):
assert math.isclose(area, math.pi * radius ** 2, rel_tol=0.02)
def test_iso_layers_dark_sheets():
layers = iso_layers(cone(), levels=4, shapes="dark")
assert [level for level, _gray, _rings in layers] == [3, 2, 1, 0]
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
assert math.isclose(areas[0], 100 * 100)
for area, radius in zip(areas[1:], (10, 20, 30)):
assert math.isclose(area, 100 * 100 - math.pi * radius ** 2, rel_tol=0.02)
def test_iso_layers_close_along_the_image_border():
# Rampe en 3 niveaux : trois bandes verticales, des rectangles.
layers = iso_layers(ramp(), levels=3, tolerance=0.1)
for (_level, _gray, rings), (left, right) in zip(
layers, ((0, 59 / 3.0), (59 / 3.0, 118 / 3.0), (118 / 3.0, 59))):
assert len(rings) == 1 and len(rings[0]) == 4
assert math.isclose(rings[0][:, 0].min(), left, abs_tol=1e-6)
assert math.isclose(rings[0][:, 0].max(), right, abs_tol=1e-6)
assert math.isclose(ring_area(rings[0]), (right - left) * 39, rel_tol=1e-6)
def test_iso_layers_uniform_image_gives_base_only():
layers = iso_layers(np.full((20, 30), 0.5), levels=8)
assert len(layers) == 8
assert np.array_equal(layers[0][2][0], image_ring(30, 20))
assert all(rings == [] for _level, _gray, rings in layers[1:])
def test_iso_layers_min_area_removes_specks():
field = np.zeros((80, 80))
field[10:50, 10:50] = 1.0
field[70, 70] = 1.0
assert len(iso_layers(field, levels=2)[1][2]) == 2
assert len(iso_layers(field, levels=2, min_area=0.01)[1][2]) == 1
@pytest.mark.parametrize("shapes", ["band", "light", "dark"])
def test_iso_layers_always_one_layer_per_level(shapes):
rng = np.random.default_rng(1)
field = rng.random((60, 90))
for levels in (2, 5, 16):
layers = iso_layers(field, levels=levels, blur=4.0, tolerance=0.4,
min_area=0.002, shapes=shapes)
assert len(layers) == levels
assert sorted(level for level, _gray, _rings in layers) == list(range(levels))
for _level, _gray, rings in layers:
for ring in rings:
assert len(ring) >= 3
assert ring[:, 0].min() >= 0 and ring[:, 0].max() <= 89
assert ring[:, 1].min() >= 0 and ring[:, 1].max() <= 59
def numbers(d):
"""Ensemble des points "x,y" d'une donnee de chemin."""
return set(re.findall(r"-?[\d.]+,-?[\d.]+", d))
def test_iso_boards_carry_the_outline_of_the_next_board():
# Empilees, claire devant : chaque planche porte le bord de la suivante.
boards = iso_boards(cone(), levels=4, shapes="light")
assert [board[0] for board in boards] == [0, 1, 2, 3]
for board, following in zip(boards, boards[1:]):
closed, opened = board[3]
assert opened == [] and len(closed) == 1
assert np.array_equal(closed[0], following[2][0])
assert boards[-1][3] is None
# Sombre devant : la pile part de la planche la plus claire.
boards = iso_boards(cone(), levels=4, shapes="dark")
assert [board[0] for board in boards] == [3, 2, 1, 0]
for board, following in zip(boards, boards[1:]):
closed, _opened = board[3]
# Le bord de la planche suivante est le trou qu'elle laisse au centre.
assert any(np.array_equal(closed[0], ring) for ring in following[2])
assert boards[-1][3] is None
# Aplats cote a cote : rien a empiler, donc aucun repere.
assert all(board[3] is None for board in iso_boards(cone(), levels=4, shapes="band"))
# iso_layers : les memes planches, sans les reperes.
layers = iso_layers(cone(), levels=4, shapes="light")
assert [len(layer) for layer in layers] == [3, 3, 3, 3]
assert all(np.array_equal(layer[2][0], board[2][0])
for layer, board in zip(layers, iso_boards(cone(), levels=4)))
def test_iso_boards_marks_skip_the_image_border():
# Rampe : la planche suivante touche trois bords de l'image, mais seul son
# bord interieur (une ligne ouverte) est reporte.
boards = iso_boards(ramp(), levels=3, tolerance=0.1, shapes="light")
closed, opened = boards[0][3]
assert closed == [] and len(opened) == 1
assert sorted(opened[0].tolist()) == [[59 / 3.0, 0.0], [59 / 3.0, 39.0]]
box = (0, 0, 59, 39)
mark = lines_to_d(boards[0][3], box=box)
assert mark.startswith("M ") and "Z" not in mark and mark.count("M ") == 1
# Meme trace que la decoupe de la planche suivante.
assert numbers(mark) <= numbers(rings_to_d(boards[1][2], box=box))
def test_lines_to_d_matches_the_cut_of_the_next_board():
field = relief()
boards = iso_boards(field, levels=6, tolerance=0.4, shapes="light")
box = (0, 0, 89, 59)
seen = 0
for board, following in zip(boards, boards[1:]):
mark = lines_to_d(board[3], box=box)
cut = rings_to_d(following[2], box=box)
assert numbers(mark) <= numbers(cut)
seen += bool(mark)
assert seen >= 4
assert lines_to_d(([], [])) == ""
polygon = lines_to_d(([image_ring(11, 6)], [np.array([(0, 0), (4, 3)], dtype=float)]),
smooth=False, precision=0)
assert polygon == "M 0,0 L 10,0 L 10,5 L 0,5 Z M 0,0 L 4,3"
def test_scale_lines():
closed, opened = scale_lines(([image_ring(3, 3)], [np.array([(1.0, 2.0)])]),
2.0, 3.0, 10.0, 20.0)
assert closed[0].tolist()[2] == [14, 26] and opened[0].tolist() == [[12, 26]]
def test_scale_rings():
scaled = scale_rings([image_ring(11, 6)], 2.0, 3.0, 100.0, 200.0)
assert scaled[0].tolist() == [[100, 200], [120, 200], [120, 215], [100, 215]]
def test_rings_to_d_polygon():
d = rings_to_d([image_ring(11, 6)], smooth=False, precision=0)
assert d == "M 0,0 L 10,0 L 10,5 L 0,5 Z"
assert rings_to_d([np.zeros((2, 2))]) == ""
# Transformation affine appliquee a l'ecriture : ici x' = 2x + 100, y' = y.
d = rings_to_d([image_ring(11, 6)], smooth=False, precision=0,
matrix=((2, 0, 100), (0, 1, 0)))
assert d == "M 100,0 L 120,0 L 120,5 L 100,5 Z"
def test_rings_to_d_keeps_the_image_border_straight():
box = (0, 0, 59, 39)
half = simplify_ring(contour_rings(ramp(), 0.5)[0], 0.1)
assert rings_to_d([half], smooth=True, box=box, precision=1) == "M 29.5,0.0 L 59.0,0.0 L 59.0,39.0 L 29.5,39.0 C 29.5,32.5 29.5,6.5 29.5,0.0 Z"
# Demi-disque colle au bord gauche : un cote droit, le reste en courbes
# qui ne sortent pas de l'image.
y, x = np.mgrid[0:40, 0:60]
bump = np.clip(1.0 - np.hypot(x, y - 19.5) / 30.0, 0.0, 1.0)
d = rings_to_d(contour_rings(bump, 0.5), smooth=True, box=box)
assert "L " in d and d.count("C ") > 10 and "-" not in d
circle = contour_rings(cone(), 0.5)[0]
d = rings_to_d([circle], smooth=True, box=(0, 0, 100, 100))
assert "L " not in d and d.count("C ") == len(circle)
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex et Pillow)
# --------------------------------------------------------------------------
SVG = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="200mm" height="200mm" viewBox="0 0 200 200">
<g id="layer1" inkscape:groupmode="layer" inkscape:label="Photo"
transform="translate(10,20)">
<image id="img1" x="5" y="5" width="100" height="100"
transform="{transform}" preserveAspectRatio="none" xlink:href="{href}"/>
<image id="img2" x="120" y="5" width="50" height="50"
preserveAspectRatio="none" xlink:href="{href}"/>
<rect id="rect1" x="0" y="0" width="10" height="10"/>
</g>
<g id="layer2" inkscape:groupmode="layer" inkscape:label="Dessus"/>
</svg>
"""
def make_document(tmp_path, linked=False, transform=""):
"""Document de test : une image en cone, incorporee ou liee."""
image_module = pytest.importorskip("PIL.Image")
picture = image_module.fromarray((cone(64, 28.0) * 255).astype("uint8"), "L")
if linked:
picture.save(str(tmp_path / "cone.png"))
href = "cone.png"
else:
buffer = io.BytesIO()
picture.save(buffer, "PNG")
href = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
path = tmp_path / "document.svg"
path.write_text(SVG.format(href=href, transform=transform), encoding="utf-8")
return str(path)
def run_extension(tmp_path, *args, linked=False, document=None, transform=""):
pytest.importorskip("inkex")
from gray_iso_layers import GrayIsoLayers
document = document or make_document(tmp_path, linked, transform)
out = tmp_path / "out.svg"
GrayIsoLayers().run([*args, "--output={}".format(out), document])
# inkex n'ecrit rien quand le document n'a pas change.
return out.read_text(encoding="utf-8") if out.exists() else ""
def paths(svg):
return re.findall(r"<(?:svg:)?path\b[^>]*>", svg)
def layers_of(svg):
"""Calques de premier niveau du document : (nom, nombre de chemins)."""
root = ET.fromstring(svg)
ink = "{http://www.inkscape.org/namespaces/inkscape}"
return [(child.get(ink + "label"),
sum(1 for elem in child.iter() if elem.tag.endswith("}path")))
for child in root if child.get(ink + "groupmode") == "layer"]
def test_end_to_end_side_by_side_boards(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--shapes=band")
assert 'id="img1"' in svg
# Un calque par niveau, au premier plan, juste au-dessus du calque de
# l'image et dans l'ordre des gris.
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 1), ("Board no. 2", 1),
("Board no. 3", 1), ("Board no. 4", 1), ("Dessus", 0)]
found = paths(svg)
assert "fill:#000000" in found[0] and "fill:#ffffff" in found[3]
# Aplats : le plus sombre est le rectangle de l'image perce d'un trou,
# les suivants des couronnes, le plus clair un disque.
assert [path.count("M ") for path in found] == [2, 2, 2, 1]
assert "M 15.000,25.000 L 115.000,25.000 L 115.000,125.000 L 15.000,125.000 Z" \
in found[0]
assert all("L " not in path for path in found[1:])
assert svg.count("transform=") == 1 # celle du calque d'origine
def test_end_to_end_default(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0")
# Planches a empiler : chaque calque porte sa decoupe et, sauf le
# dernier, le repere de la planche suivante.
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 2), ("Board no. 2", 2),
("Board no. 3", 2), ("Board no. 4", 1), ("Dessus", 0)]
found = paths(svg)
cuts = [path for path in found if "stroke:#ff0000" in path]
marks = [path for path in found if "stroke:#000000" in path]
assert len(cuts) == 4 and len(marks) == 3
# Decoupes : le rectangle de l'image en coordonnees du document (calque
# translate de 10, 20), puis des disques de plus en plus petits.
assert ' d="M 15.000,25.000 L 115.000,25.000 L 115.000,125.000 L 15.000,125.000 Z"' \
in cuts[0]
assert "fill:#000000" in cuts[0] and "fill:#ffffff" in cuts[3]
assert [path.count("M ") for path in cuts] == [1, 1, 1, 1]
# Reperes : un trait noir sans remplissage, au trace de la decoupe suivante.
for mark, following in zip(marks, cuts[1:]):
assert "fill:none" in mark
assert numbers(mark) and numbers(mark) <= numbers(following)
assert "Marking of board no. 2" in marks[0] and "Cut" in cuts[0]
assert svg.count("transform=") == 1 # celle du calque d'origine
def test_end_to_end_marking_options(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--mark=false")
assert [count for name, count in layers_of(svg) if name.startswith("Board")] \
== [1, 1, 1, 1]
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--stroke_color={}".format(0x0000FFFF),
"--mark_color={}".format(0x00AA00FF))
found = paths(svg)
assert sum("stroke:#0000ff" in path for path in found) == 4
assert sum("stroke:#00aa00" in path for path in found) == 3
# Sombre devant : les reperes suivent la pile dans l'autre sens.
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--shapes=dark")
assert [count for name, count in layers_of(svg) if name.startswith("Board")] \
== [2, 2, 2, 1]
# Sans trait, pas de repere non plus.
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--stroke_width=0")
assert len(paths(svg)) == 4
def test_end_to_end_boards_are_numbered_from_the_back(tmp_path):
# La planche n° 1 est toujours celle du fond : la plus sombre, sauf en
# « sombre devant » ou c'est la plus claire, qui couvre toute l'image.
for shapes, back, front in (("band", "#000000", "#ffffff"),
("light", "#000000", "#ffffff"),
("dark", "#ffffff", "#000000")):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--mark=false", "--shapes=" + shapes)
root = ET.fromstring(svg)
ink = "{http://www.inkscape.org/namespaces/inkscape}"
fills = {}
for layer in root:
label = layer.get(ink + "label") or ""
for elem in layer.iter():
if label.startswith("Board") and elem.tag.endswith("}path"):
fills[label] = re.search(r"fill:(#\w+)", elem.get("style")).group(1)
assert fills["Board no. 1"] == back, shapes
assert fills["Board no. 4"] == front, shapes
def test_end_to_end_one_layer_per_level_even_if_empty(tmp_path):
# Seuil d'aire enorme : seules les grandes formes restent, mais il y a
# toujours autant de calques que de niveaux.
svg = run_extension(tmp_path, "--id=img1", "--levels=6", "--blur=0",
"--min_area=10", "--mark=false")
created = [item for item in layers_of(svg) if item[0].startswith("Board")]
assert [name for name, _count in created] == [
"Board no. {}".format(k) for k in range(1, 7)]
assert created[0][1] == 1 and created[-1][1] == 0
def test_end_to_end_rotated_image(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=2", "--blur=0",
"--shapes=light", transform="rotate(90)")
# rotate(90) : (x, y) -> (-y, x), puis translation du calque (10, 20).
assert ' d="M 5.000,25.000 L 5.000,125.000 L -95.000,125.000 L -95.000,25.000 Z"' \
in paths(svg)[0]
def test_end_to_end_linked_image_in_selected_group(tmp_path, monkeypatch):
# Lien relatif : resolu depuis le dossier du document.
monkeypatch.setenv("DOCUMENT_PATH", str(tmp_path / "document.svg"))
svg = run_extension(tmp_path, "--id=layer1", "--levels=3", linked=True)
# Deux images dans le groupe : elles partagent les trois calques
# (une decoupe et un repere par image, sauf sur la derniere planche).
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 4), ("Board no. 2", 4),
("Board no. 3", 2), ("Dessus", 0)]
def test_end_to_end_removes_original(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--keep_original=false")
assert 'id="img1"' not in svg
def test_end_to_end_styles(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=3", "--fill_mode=color",
"--mark=false",
"--fill_color={}".format(0x336699FF),
"--stroke_color={}".format(0xCC0000FF),
"--stroke_width=1", "--unit=px")
assert all("fill:#336699" in path and "stroke:#cc0000" in path
for path in paths(svg))
svg = run_extension(tmp_path, "--id=img1", "--levels=3", "--fill_mode=none",
"--stroke_width=0")
assert all("fill:none" in path and "stroke:none" in path for path in paths(svg))
def test_end_to_end_plain_blur(tmp_path):
# Flou simple a la place du lissage qui respecte les contours : le bord
# du cone s'etale, ses disques n'ont plus le meme trace.
kept = run_extension(tmp_path, "--id=img1", "--levels=3", "--blur=8",
"--mark=false")
plain = run_extension(tmp_path, "--id=img1", "--levels=3", "--blur=8",
"--mark=false", "--edges=false")
assert len(paths(kept)) == len(paths(plain)) == 3
assert paths(kept) != paths(plain)
def test_end_to_end_without_image(tmp_path, capsys):
svg = run_extension(tmp_path, "--id=rect1")
assert "Board no. 1" not in svg
assert "Select at least one bitmap image" in capsys.readouterr().err
def test_end_to_end_broken_link(tmp_path, capsys):
pytest.importorskip("PIL.Image")
document = tmp_path / "broken.svg"
document.write_text(SVG.format(href="absente.png", transform=""),
encoding="utf-8")
svg = run_extension(tmp_path, "--id=img1", document=str(document))
assert "Board no. 1" not in svg
assert "Cannot read the image" in capsys.readouterr().err
# --------------------------------------------------------------------------
# Traductions
# --------------------------------------------------------------------------
def test_translations_up_to_date_and_complete():
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
import gettext
import i18n
msgids = [msgid for msgid, _refs in i18n.extract()]
assert "Gray Iso-Layers" in msgids
assert "mm" not in msgids # unites marquees translatable="no"
for language in i18n.LANGUAGES:
entries = i18n.read_po(i18n.po_path(language))
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
assert not missing, "{}.po incomplet : {}".format(language, missing)
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
for msgid in msgids:
assert catalog.gettext(msgid) == entries[msgid][0], \
"{} : .mo a recompiler (python i18n.py)".format(language)
def test_po_roundtrip(tmp_path):
import i18n
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
path = str(tmp_path / "xx.po")
i18n.write_po(path, "fr", messages,
{m: ("<" + m + ">", False) for m, _r in messages})
entries = i18n.read_po(path)
for msgid, _refs in messages:
assert entries[msgid] == ("<" + msgid + ">", False)
def test_inx_matches_arguments():
"""Chaque <param> du .inx a son add_argument, avec la meme valeur par defaut."""
root = ET.parse(os.path.join(HERE, "gray_iso_layers.inx")).getroot()
params = {elem.get("name"): (elem.text or "").strip() for elem in root.iter()
if elem.tag.rsplit("}", 1)[-1] == "param"}
with open(os.path.join(HERE, "gray_iso_layers.py"), encoding="utf-8") as handle:
source = handle.read()
arguments = set(re.findall(r'add_argument\("--(\w+)"', source))
assert set(params) == arguments
pytest.importorskip("inkex")
from gray_iso_layers import GrayIsoLayers
options = GrayIsoLayers().arg_parser.parse_args([])
for name, text in params.items():
default = getattr(options, name)
if name in ("tab", "shapes", "fill_mode", "unit"):
continue # listes : la valeur par defaut est la premiere option
if isinstance(default, bool):
assert text == str(default).lower(), name
elif isinstance(default, (int, float)):
assert float(text) == float(default), name
else:
assert text == default, name
def test_inx_images_exist():
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
root = ET.parse(os.path.join(HERE, "gray_iso_layers.inx")).getroot()
for elem in root.iter():
if elem.tag.rsplit("}", 1)[-1] == "image":
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text