Version initiale
This commit is contained in:
commit
16589716fd
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
253
README.md
Normal 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
|
||||
|
||||

|
||||
|
||||
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
107
deploy.ps1
Normal 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
BIN
doc/patern.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
BIN
doc/personne.jpg
Normal file
BIN
doc/personne.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/exemple.png
Normal file
BIN
docs/exemple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/parametres.png
Normal file
BIN
docs/parametres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
335
docs/schema_parametres.py
Normal file
335
docs/schema_parametres.py
Normal 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
97
gray_iso_layers.inx
Normal 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
236
gray_iso_layers.py
Normal 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
731
gray_iso_layers_core.py
Normal 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
304
i18n.py
Normal 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
BIN
images/parameters_en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
locale/en/LC_MESSAGES/grayisolayers.mo
Normal file
BIN
locale/en/LC_MESSAGES/grayisolayers.mo
Normal file
Binary file not shown.
BIN
locale/fr/LC_MESSAGES/grayisolayers.mo
Normal file
BIN
locale/fr/LC_MESSAGES/grayisolayers.mo
Normal file
Binary file not shown.
180
po/en.po
Normal file
180
po/en.po
Normal 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
180
po/fr.po
Normal 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
171
po/grayisolayers.pot
Normal 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
790
test_gray_iso_layers.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user