Compare commits

..

3 Commits

Author SHA1 Message Date
Alex
d9486a6c96 Unités en mm 2026-10-10 10:36:56 +02:00
Alex
34984e96f7 Utilisation de depth map 2026-10-04 23:13:07 +02:00
Alex
3ed0fa0987 Carte de profondeur pour des planches qui suivent les volumes
La profondeur de la scène est estimée à partir de la photo (MiDaS small
ou Depth Anything V2 Small) et mélangée aux gris : les planches suivent
les volumes du sujet plutôt que ses lumières et ses ombres.

- Onglet « Profondeur » : part de la carte de profondeur, modèle, et
  détails fins de la photo gravés dans la profondeur (yeux, bouche).
- Répartition des niveaux : rapproche les planches d'aires égales quand
  les valeurs sont tassées sur quelques gris.
- Images 16 bits lues sur toute leur plage (elles étaient écrêtées à 255).
- Le Python d'Inkscape (MinGW, sans pip) ne peut pas charger onnxruntime :
  le calcul tourne dans un environnement à part, installé par
  deploy.ps1 -Depth, lancé en sous-processus, résultat gardé en cache.
- Depth Anything sur la carte graphique (DirectML) avec repli sur le
  processeur ; MiDaS small toujours sur le processeur, DirectML lui
  faisant rendre une carte fausse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AumyrbcuQobB3GbPrFjG9i
2026-10-04 14:19:26 +02:00
16 changed files with 1878 additions and 53 deletions

142
README.md
View File

@ -44,7 +44,18 @@ aplanis mais les yeux, la bouche et le bord du visage restent nets, et les
reconnaissable. Un flou gaussien simple reste disponible pour les reliefs reconnaissable. Un flou gaussien simple reste disponible pour les reliefs
abstraits. abstraits.
- Quantification à seuils réguliers, de 2 à 64 niveaux, après étirement de la - Quantification à seuils réguliers, de 2 à 64 niveaux, après étirement de la
plage de gris de l'image. plage de gris de l'image. Une répartition réglable rapproche les niveaux
d'aires égales, pour les images dont presque toutes les valeurs tiennent
dans quelques gris (cartes de profondeur).
- Carte de profondeur (facultative) : la profondeur de la scène est estimée
à partir de la photo par un réseau de neurones (MiDaS small ou Depth
Anything V2 Small), enrichie des contrastes fins de la photo (yeux, bouche)
et mélangée aux gris dans la proportion voulue. Les
planches suivent alors les volumes du sujet plutôt que ses lumières et ses
ombres. Calcul sur la carte graphique quand c'est possible, résultat gardé
en cache.
- Images 16 bits (cartes de profondeur produites ailleurs) lues sur toute
leur plage.
- Contours tracés au sous-pixel sur l'image lissée (« marching squares »), puis - 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. 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, - Toutes les formes sont fermées : là où un niveau touche le bord de l'image,
@ -56,9 +67,17 @@ aplanis mais les yeux, la bouche et le bord du visage restent nets, et les
- Planches à empiler : la planche n° 1 couvre toute l'image et chaque planche - 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, 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. ou la plus sombre devant), pour construire un relief en strates.
- Planches superposées à la place de l'image (aperçu du relief) ou
juxtaposées en grille à côté d'elle (chaque planche visible et découpable
seule, avec son repère).
- Repère d'assemblage : chaque planche porte, en plus de son trait de découpe - 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 (rouge), le contour de la planche suivante (noir), au tracé exact de sa
découpe, pour savoir où la coller. découpe, pour savoir où la coller.
- Numéros à graver, dans une troisième couleur (bleu) : chaque planche porte
son numéro, encadré, et chaque zone marquée porte le numéro de la planche
qui vient s'y coller. Tous se logent sous la planche suivante : aucun ne se
voit une fois la pile collée, et la planche du dessus n'en porte aucun.
Les chiffres sont tracés en segments droits, sans police de caractères.
- Variante « Son gris seul » : chaque calque contient l'aplat de son gris, - 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 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 ne portent pas de repère. Les contours sont exactement les frontières de
@ -73,8 +92,9 @@ aplanis mais les yeux, la bouche et le bord du visage restent nets, et les
## Installation ## Installation
Aucune dépendance à installer : l'extension utilise `inkex`, `numpy` et Aucune dépendance à installer pour l'usage de base : l'extension utilise
`Pillow`, tous fournis avec Inkscape. `inkex`, `numpy` et `Pillow`, tous fournis avec Inkscape. Seule la carte de
profondeur demande une installation à part (`-Depth`, ci-dessous).
### Windows ### Windows
@ -82,10 +102,41 @@ Aucune dépendance à installer : l'extension utilise `inkex`, `numpy` et
.\deploy.ps1 # copie dans %APPDATA%\inkscape\extensions\GrayIsoLayers .\deploy.ps1 # copie dans %APPDATA%\inkscape\extensions\GrayIsoLayers
.\deploy.ps1 -Uninstall # retire l'extension .\deploy.ps1 -Uninstall # retire l'extension
.\deploy.ps1 -Force # déploie même si Inkscape est ouvert .\deploy.ps1 -Force # déploie même si Inkscape est ouvert
.\deploy.ps1 -Depth # déploie, et installe de quoi calculer la profondeur
``` ```
Inkscape doit être fermé pendant la copie, puis relancé. Inkscape doit être fermé pendant la copie, puis relancé.
### Carte de profondeur
Le Python fourni avec Inkscape ne peut pas charger `onnxruntime` (c'est une
construction MinGW, sans `pip`). `.\deploy.ps1 -Depth` installe donc, dans
`%LOCALAPPDATA%\GrayIsoLayers` :
- `venv\` : un environnement Python avec `onnxruntime-directml`, `numpy` et
`pillow`, créé à partir du Python 3 de la machine (`python` du `PATH`, sinon
le lanceur `py` ; `-Python <chemin>` pour en imposer un autre) ;
- `models\` : les deux modèles, téléchargés une fois (64 Mo et 94 Mo) ;
- `cache\` : les 30 dernières cartes calculées.
L'extension lance le calcul dans cet environnement. Sans lui, elle le signale
et travaille sur les gris seuls. `.\deploy.ps1 -Uninstall` supprime aussi ce
dossier. La variable d'environnement `GRAY_ISO_LAYERS_PYTHON` désigne un autre
Python que celui de `venv\`.
Temps mesurés sur le portrait d'essai, lancement du Python compris (Core i7-3770
et RTX 4080 SUPER ; à titre indicatif) :
| Modèle | Calcul | Premier passage | Passages suivants |
| --- | --- | --- | --- |
| MiDaS small (256 points) | processeur | 1 s environ | cache, sans délai |
| Depth Anything V2 Small (518 points) | carte graphique (DirectML) | 2 s environ | cache, sans délai |
| Depth Anything V2 Small | processeur | 3 s environ | cache, sans délai |
MiDaS small est toujours calculé sur le processeur : sous DirectML il rend une
carte fausse, et son calcul est plus court que l'initialisation de la carte
graphique.
### Manuelle ### Manuelle
Copier dans un sous-dossier du répertoire des extensions utilisateur Copier dans un sous-dossier du répertoire des extensions utilisateur
@ -93,8 +144,9 @@ Copier dans un sous-dossier du répertoire des extensions utilisateur
sous Linux, `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions` sous Linux, `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions`
sous macOS) : sous macOS) :
- `gray_iso_layers.inx`, `gray_iso_layers.py`, `gray_iso_layers_core.py` ; - `gray_iso_layers.inx`, `gray_iso_layers.py`, `gray_iso_layers_core.py`,
- `images/parameters_en.png` ; `gray_iso_layers_depth.py`, `depth_estimate.py` ;
- `images/parameters_en.png`, `images/depth_en.png` ;
- le dossier `locale/`. - le dossier `locale/`.
## Utilisation ## Utilisation
@ -118,8 +170,11 @@ vide.
### Conseils ### Conseils
- **Découpe laser** : choisir « Aucun remplissage » ; il ne reste que les - **Découpe laser** : choisir « Aucun remplissage » ; il ne reste que les
traits de découpe (rouge) et de marquage (noir), à affecter chacun à son traits de découpe (rouge), de marquage (noir) et des numéros (bleu), à
affecter chacun à son
réglage dans le logiciel de la machine. Découper un calque par feuille. réglage dans le logiciel de la machine. Découper un calque par feuille.
Avec la position « Juxtaposées », les planches sont rangées côte à côte :
on voit chacune en entier sans masquer les autres calques.
- **Dessin au trait, comme une carte topographique** : remplissage « Couleur - **Dessin au trait, comme une carte topographique** : remplissage « Couleur
unique » blanche (ou « Aucun remplissage »), trait noir, repère décoché. unique » blanche (ou « Aucun remplissage »), trait noir, repère décoché.
- **Contours trop chargés, petits îlots** : augmenter le lissage ou le plus - **Contours trop chargés, petits îlots** : augmenter le lissage ou le plus
@ -127,6 +182,19 @@ vide.
- **Contours anguleux** : augmenter le lissage ou la simplification. - **Contours anguleux** : augmenter le lissage ou la simplification.
- **Détails perdus, visage méconnaissable** : réduire le lissage (0,5 à 1 %), - **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. laisser « Préserver les contours » coché, augmenter la résolution de calcul.
- **Relief fidèle aux volumes** : onglet « Profondeur », part de la carte de
profondeur à 100 % et modèle « Fin », qui détoure nettement mieux les cheveux
et le visage que le modèle « Rapide ». Les planches suivent les volumes (le
nez devant, le fond derrière) et le réglage « Détails fins » y grave les
yeux, la bouche et les sourcils, que la profondeur seule ne contient pas.
Visage trop lisse : monter les détails fins (jusqu'à 150 %). Trop de petites
formes dans les cheveux ou la barbe : les baisser, ou augmenter le lissage.
- **Planches vides, sujet tassé sur quelques planches** (fréquent avec une
carte de profondeur) : augmenter la répartition des niveaux. À 100 %, toutes
les planches ont la même surface, fond compris ; une valeur intermédiaire
garde plus de planches pour le sujet.
- **Carte de profondeur produite ailleurs** : l'importer et la sélectionner
comme n'importe quelle image (8 ou 16 bits), part de profondeur à 0.
- **Portrait** : un fond uni donne le meilleur résultat ; un fond en dégradé - **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. produit des anneaux, puisque chaque nuance du dégradé devient un aplat.
- **Image liée introuvable** : l'incorporer (clic droit > Incorporer l'image). - **Image liée introuvable** : l'incorporer (clic droit > Incorporer l'image).
@ -142,8 +210,11 @@ Onglet **Niveaux**
| Paramètre | Défaut | Rôle | | Paramètre | Défaut | Rôle |
| --- | --- | --- | | --- | --- | --- |
| Nombre de niveaux de gris | 16 | Nombre d'aplats, donc de calques produits (2 à 64). | | Nombre de niveaux de gris | 16 | Nombre d'aplats, donc de calques produits (2 à 64). |
| Répartition des niveaux (% d'aires égales) | 0 | 0 : seuils régulièrement espacés. 100 : chaque planche couvre la même surface. |
| 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. | | 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. |
| Position des planches | Superposées, à la place de l'image | « Superposées » : toutes les planches à la place de l'image, comme une fois collées. « Juxtaposées » : chaque planche dans sa case d'une grille à droite de l'image, avec son repère, prête à être découpée seule. |
| Reporter sur chaque planche le contour de la suivante | oui | Repère d'assemblage tracé dans la couleur de marquage (planches empilées uniquement). | | Reporter sur chaque planche le contour de la suivante | oui | Repère d'assemblage tracé dans la couleur de marquage (planches empilées uniquement). |
| Écrire les numéros des planches | oui | Numéros tracés en segments dans la couleur des numéros, à graver. Encadré : le numéro de la planche. Sans cadre : le numéro de la planche à coller dans cette zone marquée. Sur des planches empilées, les deux se logent sous la planche suivante : aucun numéro ne se voit une fois les planches collées (une pièce trop peu recouverte reste sans numéro), et la planche du dessus n'en porte aucun. Avec « Son gris seul », rien ne s'empile : chaque pièce garde son numéro visible. |
| 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. | | 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. | | 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. | | Résolution de calcul (points sur le grand côté) | 800 | Taille à laquelle l'image est rééchantillonnée pour le calcul. |
@ -151,6 +222,25 @@ Onglet **Niveaux**
| Simplification (points de calcul) | 0,4 | Écart maximal toléré lors de la suppression de nœuds ; 0 les conserve tous. | | 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. | | Lisser les contours avec des courbes de Bézier | oui | Sinon, les contours sont des polygones. |
Onglet **Profondeur**
Chaque réglage porte un numéro dans la boîte de dialogue ; le schéma montre,
sous le même numéro, ce qu'il produit pour plusieurs valeurs (planches
découpées en 8 niveaux, la plus sombre au fond, la plus claire devant).
![Schéma de l'onglet Profondeur](docs/profondeur.png)
| N° | Paramètre | Défaut | Rôle |
| --- | --- | --- | --- |
| 1 | Part de la carte de profondeur (%) | 0 | 0 : gris de l'image seuls. 100 : profondeur estimée seule. Entre les deux, mélange. Demande `deploy.ps1 -Depth`. |
| 2 | Modèle de profondeur | Rapide (MiDaS small) | « Rapide » : MiDaS small, carte lisse. « Fin » : Depth Anything V2 Small, contours plus précis. |
| 3 | Détails fins de la photo (%) | 100 | Grave dans la profondeur les contrastes fins de la photo (yeux, bouche, sourcils). Un fond uni n'apporte rien et reste au fond. 0 : profondeur lisse seule. |
Pour régler un portrait : mettre **1** à 100 % et choisir **2** = Fin, puis
doser **3** jusqu'à ce que le visage soit lisible sans petites formes parasites
(100 % en général). Baisser **1** ne sert que pour retrouver une part des
lumières et des ombres de la photo.
Onglet **Style** Onglet **Style**
| Paramètre | Défaut | Rôle | | Paramètre | Défaut | Rôle |
@ -161,6 +251,7 @@ Onglet **Style**
| Unité | mm | Unité de l'épaisseur du trait. | | Unité | mm | Unité de l'épaisseur du trait. |
| Couleur du trait de découpe | rouge | Contour de la planche. | | Couleur du trait de découpe | rouge | Contour de la planche. |
| Couleur du trait de marquage | noir | Repère de la planche suivante. | | Couleur du trait de marquage | noir | Repère de la planche suivante. |
| Couleur du trait des numéros | bleu | Numéros des planches, à graver à part de la découpe et du repère. |
| Conserver l'image d'origine | oui | Sinon l'image est supprimée. | | Conserver l'image d'origine | oui | Sinon l'image est supprimée. |
## Structure du projet ## Structure du projet
@ -169,12 +260,17 @@ Onglet **Style**
gray_iso_layers.inx Boîte de dialogue (textes source en anglais) 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.py Couche inkex : lecture de l'image, écriture du SVG
gray_iso_layers_core.py Noyau de calcul, sans inkex (numpy seul) gray_iso_layers_core.py Noyau de calcul, sans inkex (numpy seul)
gray_iso_layers_depth.py Carte de profondeur : lancement du calcul, cache
depth_estimate.py Calcul de la profondeur (onnxruntime), hors Inkscape
i18n.py Extraction / mise à jour / compilation des traductions i18n.py Extraction / mise à jour / compilation des traductions
po/ Catalogues grayisolayers.pot, en.po, fr.po po/ Catalogues grayisolayers.pot, en.po, fr.po
locale/ Catalogues compilés (.mo), déployés locale/ Catalogues compilés (.mo), déployés
images/parameters_en.png Schéma de l'onglet « Help » 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/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/parametres.png Schéma du README
docs/schema_profondeur.py Générateur du schéma de la carte de profondeur
docs/profondeur.png Schéma de la carte de profondeur (README)
images/depth_en.png Le même, en anglais, pour l'onglet « Depth »
docs/exemple.png Illustrations du README (relief, portrait) docs/exemple.png Illustrations du README (relief, portrait)
docs/exemple-portrait.png docs/exemple-portrait.png
doc/patern.jpg Motif qui a servi de modèle doc/patern.jpg Motif qui a servi de modèle
@ -194,14 +290,21 @@ gris (0 noir, 1 blanc) ; un anneau est un tableau `(n, 2)` de points, en
| `edge_preserving_blur(field, radius, contrast)` | Lissage qui respecte les contours (filtre guidé). | | `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. | | `smooth(field, blur, preserve_edges)` | Lissage avant découpe, puis étirement sur 0..1. |
| `normalize(field)` | Étire le champ sur 0..1. | | `normalize(field)` | Étire le champ sur 0..1. |
| `spread_levels(field, amount)` | Répartit les valeurs pour que les niveaux tendent vers des aires égales. |
| `guided_filter(guide, source, radius, eps)` | Lisse `source` en lui donnant les contours de `guide` (carte de profondeur recalée sur la photo). |
| `add_detail(depth, gray, amount, sigma)` | Grave dans la profondeur les contrastes fins des gris (yeux, bouche). |
| `blend_fields(gray, depth, mix)` | Mélange des gris et de la profondeur. |
| `thresholds(levels)` | Seuils réguliers séparant les niveaux. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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_boards(field, levels, blur, min_area, tolerance, shapes, preserve_edges, equalize)` | 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. | | `iso_layers(…)` | Comme `iso_boards`, sans les repères. |
| `board_labels(rings, following, number, width, height, largest, smallest, hidden=False)` | Emplacement et taille des numéros d'une planche : le sien par pièce, celui de la suivante par zone marquée. Avec `hidden`, le sien uniquement sous la planche suivante. |
| `number_lines(number, x, y, height, framed)` / `number_size(…)` | Tracé d'un numéro en segments, cadre facultatif ; son encombrement. |
| `region_mask(rings, width, height, step)` / `mask_depth(mask)` / `mask_components(mask)` | Région en grille de booléens, distance au bord, parties d'un seul tenant. |
| `image_ring(width, height)` | Rectangle de l'image. | | `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. | | `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). | | `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). |
@ -215,18 +318,26 @@ pip install pytest numpy pillow
python -m pytest -q python -m pytest -q
``` ```
Sans `inkex` : 41 tests passent, 13 sont ignorés. Avec `inkex` : Sans `inkex` : 54 tests passent, 19 sont ignorés. Avec `inkex` :
```powershell ```powershell
pip install lxml tinycss2 cssselect2 cssselect pip install lxml tinycss2 cssselect2 cssselect
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions' $env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
python -m pytest -q # 54 tests passent python -m pytest -q # 72 tests passent, 1 est ignoré
``` ```
Les tests couvrent le noyau (lissage qui respecte les contours, contours d'un cône et d'une rampe, fermeture le Le test ignoré est le calcul réel d'une carte de profondeur, qui demande
`onnxruntime` et le modèle. Il se lance dans l'environnement de `-Depth` :
```powershell
& "$env:LOCALAPPDATA\GrayIsoLayers\venv\Scripts\python.exe" -m pip install pytest
& "$env:LOCALAPPDATA\GrayIsoLayers\venv\Scripts\python.exe" -m pytest -q -k depth
```
Les tests couvrent le noyau (lissage qui respecte les contours, répartition des niveaux, filtre guidé, détails fins gravés dans la profondeur, contours d'un cône et d'une rampe, fermeture le
long des bords, cas selle, simplification, aplats conformes à l'image long des bords, cas selle, simplification, aplats conformes à l'image
quantifiée, frontières communes aux aplats voisins, feuilles à empiler, îlots 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 écartés, écriture des chemins), la carte de profondeur avec un calcul simulé (cache, environnement transmis, causes d'échec), l'extension de bout en bout (image 16 bits, mélange de la profondeur et repli sur les gris, planches juxtaposées, numéros des planches, 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 tournée, groupe sélectionné, styles, messages d'erreur) et la cohérence des traductions et du
`.inx`. `.inx`.
@ -251,3 +362,12 @@ python docs/schema_parametres.py
Produit `docs/parametres.png` (français, README) et `images/parameters_en.png` 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 (anglais, onglet « Help »), tracés avec les fonctions du noyau. L'export PNG
passe par `inkscape.com`. passe par `inkscape.com`.
```powershell
python docs/schema_profondeur.py
```
Produit `docs/profondeur.png` (français, README) et `images/depth_en.png`
(anglais, onglet « Depth ») à partir du portrait d'essai `doc/personne.jpg`,
par les mêmes étapes que l'extension. La profondeur est réellement calculée :
il faut l'environnement de `deploy.ps1 -Depth`.

View File

@ -4,7 +4,7 @@
.DESCRIPTION .DESCRIPTION
Copie gray_iso_layers.inx, gray_iso_layers.py, gray_iso_layers_core.py, le schéma de l'onglet 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 « Help » (images\parameters_en.png), celui de l'onglet « Depth » (images\depth_en.png) et les traductions
compilées (locale\<langue>\LC_MESSAGES\grayisolayers.mo) dans un compilées (locale\<langue>\LC_MESSAGES\grayisolayers.mo) dans un
sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape 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 explore récursivement ce répertoire). Refuse de s'exécuter si
@ -16,14 +16,28 @@
.PARAMETER FolderName .PARAMETER FolderName
Nom du sous-dossier de l'extension. Par défaut : GrayIsoLayers Nom du sous-dossier de l'extension. Par défaut : GrayIsoLayers
.PARAMETER Depth
Installe aussi de quoi calculer les cartes de profondeur, dans
%LOCALAPPDATA%\GrayIsoLayers : un environnement Python (onnxruntime-directml,
numpy, pillow) et les deux modèles (MiDaS small, Depth Anything V2 Small,
environ 165 Mo à télécharger). Le Python d'Inkscape ne peut pas charger
onnxruntime : il faut un Python 3 installé sur la machine.
.PARAMETER Python
Python qui crée l'environnement de -Depth. Par défaut : « python » du PATH,
sinon le lanceur « py ». À indiquer si le Python trouvé est trop récent
pour onnxruntime.
.PARAMETER Uninstall .PARAMETER Uninstall
Supprime le sous-dossier de l'extension au lieu de le créer. Supprime le sous-dossier de l'extension au lieu de le créer, ainsi que
%LOCALAPPDATA%\GrayIsoLayers (environnement, modèles, cache).
.PARAMETER Force .PARAMETER Force
Déploie même si Inkscape est en cours d'exécution. Déploie même si Inkscape est en cours d'exécution.
.EXAMPLE .EXAMPLE
.\deploy.ps1 .\deploy.ps1
.\deploy.ps1 -Depth
.\deploy.ps1 -Uninstall .\deploy.ps1 -Uninstall
.\deploy.ps1 -FolderName 'autre-nom' .\deploy.ps1 -FolderName 'autre-nom'
#> #>
@ -32,17 +46,31 @@
param( param(
[string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'), [string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'),
[string] $FolderName = 'GrayIsoLayers', [string] $FolderName = 'GrayIsoLayers',
[switch] $Depth,
[string] $Python,
[switch] $Uninstall, [switch] $Uninstall,
[switch] $Force [switch] $Force
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$files = @('gray_iso_layers.inx', 'gray_iso_layers.py', 'gray_iso_layers_core.py', 'images\parameters_en.png') $files = @('gray_iso_layers.inx', 'gray_iso_layers.py', 'gray_iso_layers_core.py',
'gray_iso_layers_depth.py', 'depth_estimate.py', 'images\parameters_en.png',
'images\depth_en.png')
$localeDir = 'locale' $localeDir = 'locale'
$source = $PSScriptRoot $source = $PSScriptRoot
$destination = Join-Path $ExtensionsDir $FolderName $destination = Join-Path $ExtensionsDir $FolderName
# Cartes de profondeur : environnement Python, modèles et cache, hors du
# dossier des extensions (Inkscape le parcourt en entier au démarrage).
# Mêmes noms que dans gray_iso_layers_depth.py.
$dataDir = Join-Path $env:LOCALAPPDATA 'GrayIsoLayers'
$packages = @('onnxruntime-directml', 'numpy', 'pillow')
$models = [ordered]@{
'midas_v21_small_256.onnx' = 'https://github.com/isl-org/MiDaS/releases/download/v2_1/model-small.onnx'
'depth_anything_v2_small.onnx' = 'https://huggingface.co/onnx-community/depth-anything-v2-small/resolve/main/onnx/model.onnx'
}
# --- Inkscape doit être fermé : les extensions sont lues au démarrage --------- # --- Inkscape doit être fermé : les extensions sont lues au démarrage ---------
$running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue $running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue
if ($running -and -not $Force) { if ($running -and -not $Force) {
@ -59,6 +87,10 @@ if ($Uninstall) {
else { else {
Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow
} }
if (Test-Path -LiteralPath $dataDir) {
Remove-Item -LiteralPath $dataDir -Recurse -Force
Write-Host "Supprimé : $dataDir" -ForegroundColor Green
}
exit 0 exit 0
} }
@ -102,6 +134,69 @@ if (Test-Path -LiteralPath $pycache) {
Remove-Item -LiteralPath $pycache -Recurse -Force Remove-Item -LiteralPath $pycache -Recurse -Force
} }
# --- Cartes de profondeur ----------------------------------------------------
if ($Depth) {
Write-Host ""
Write-Host "Cartes de profondeur : $dataDir"
$venvPython = Join-Path $dataDir 'venv\Scripts\python.exe'
if (-not (Test-Path -LiteralPath $venvPython)) {
# « python » avant le lanceur « py » : ce dernier prend la version la
# plus récente, parfois une préversion pour laquelle onnxruntime
# n'est pas encore publié.
$command = @()
if ($Python) {
$command = @($Python)
}
elseif (Get-Command python -ErrorAction SilentlyContinue) {
$command = @((Get-Command python).Source)
}
elseif (Get-Command py -ErrorAction SilentlyContinue) {
$command = @((Get-Command py).Source, '-3')
}
if ($command.Count -eq 0) {
Write-Error "Aucun Python 3 trouvé (python ou py) : installez-le depuis python.org, ou indiquez-le avec -Python."
exit 1
}
$arguments = @($command | Select-Object -Skip 1) + @('-m', 'venv', (Join-Path $dataDir 'venv'))
& $command[0] $arguments
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $venvPython)) {
Write-Error "Création de l'environnement Python impossible avec $($command -join ' ')."
exit 1
}
Write-Host " créé venv\"
}
& $venvPython -m pip install --quiet --disable-pip-version-check --upgrade @packages
if ($LASTEXITCODE -ne 0) {
Write-Error "Installation de $($packages -join ', ') impossible."
exit 1
}
Write-Host " installé $($packages -join ', ')"
$modelsDir = Join-Path $dataDir 'models'
New-Item -ItemType Directory -Path $modelsDir -Force | Out-Null
foreach ($name in $models.Keys) {
$to = Join-Path $modelsDir $name
if (Test-Path -LiteralPath $to) {
Write-Host " présent models\$name"
continue
}
# Téléchargement dans un fichier provisoire : un modèle tronqué par une
# coupure ne passe pas pour un modèle installé.
$partial = "$to.part"
$previous = $ProgressPreference
$ProgressPreference = 'SilentlyContinue' # la barre ralentit beaucoup PowerShell 5.1
try {
Invoke-WebRequest -Uri $models[$name] -OutFile $partial -UseBasicParsing
}
finally {
$ProgressPreference = $previous
}
Move-Item -LiteralPath $partial -Destination $to -Force
$size = [math]::Round((Get-Item -LiteralPath $to).Length / 1MB)
Write-Host " téléchargé models\$name ($size Mo)"
}
}
Write-Host "" Write-Host ""
Write-Host "Extension déployée dans $destination" -ForegroundColor Green Write-Host "Extension déployée dans $destination" -ForegroundColor Green
Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Iso-couches par nuances de gris..." Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Iso-couches par nuances de gris..."

122
depth_estimate.py Normal file
View File

@ -0,0 +1,122 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Calcul d'une carte de profondeur, pour l'extension « Gray Iso-Layers ».
python depth_estimate.py image.png sortie.npy modele.onnx --model midas_small
Ce script ne tourne pas dans le Python d'Inkscape (une construction MinGW,
sans pip, ou onnxruntime ne s'installe pas) mais dans un environnement a
part, cree par `deploy.ps1 -Depth` ; l'extension le lance en sous-processus
(voir `gray_iso_layers_depth`). Dependances : onnxruntime (ou
onnxruntime-directml pour le calcul sur carte graphique), numpy, Pillow.
La sortie est un tableau numpy 2D float32 de profondeur inverse : plus la
valeur est haute, plus le point est proche.
"""
import argparse
import sys
import numpy as np
# Cote du carre presente a chaque modele.
MODELS = {"midas_small": 256, "depth_anything_v2_small": 518}
# Modeles calcules sur le processeur meme si une carte graphique est la :
# sous DirectML, MiDaS small rend une carte fausse, et son calcul est de
# toute facon plus court que l'initialisation de la carte graphique.
CPU_ONLY = {"midas_small"}
# Normalisation ImageNet, commune aux deux modeles.
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
_GPU = "DmlExecutionProvider"
_CPU = "CPUExecutionProvider"
def preprocess(picture, size):
"""Image PIL -> tenseur (1, 3, size, size) float32 attendu par le modele."""
from PIL import Image
rgb = picture.convert("RGB").resize((size, size), Image.BICUBIC)
pixels = (np.asarray(rgb, dtype=np.float32) / 255.0 - _MEAN) / _STD
return np.ascontiguousarray(pixels.transpose(2, 0, 1)[None], dtype=np.float32)
def choose_providers(available, cpu=False):
"""Fournisseurs de calcul a essayer, dans l'ordre : la carte graphique
(DirectML) si elle est disponible et non ecartee par `cpu`, puis le
processeur, toujours present."""
if cpu or _GPU not in available:
return [_CPU]
return [_GPU, _CPU]
def _run(weights, tensor, provider):
import onnxruntime
options = onnxruntime.SessionOptions()
options.log_severity_level = 3
if provider == _GPU:
# Exigences de DirectML.
options.enable_mem_pattern = False
options.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
# Carte designee explicitement : sans cela, l'initialisation echoue
# sur une machine a deux cartes graphiques.
provider = (_GPU, {"device_id": 0})
session = onnxruntime.InferenceSession(weights, options, providers=[provider])
name = session.get_inputs()[0].name
# onnxruntime se replie de lui-meme sur le processeur si la carte
# graphique ne s'initialise pas : on rend le fournisseur reellement pris.
return session.run(None, {name: tensor})[0], session.get_providers()[0]
def estimate(picture, weights, model, cpu=False):
"""Profondeur inverse de l'image : `(tableau 2D float32, fournisseur)`.
Le calcul se fait sur la carte graphique si le modele s'y prete (voir
`CPU_ONLY`) ; s'il y echoue (pilote, memoire), il est refait sur le
processeur.
"""
import onnxruntime
tensor = preprocess(picture, MODELS[model])
providers = choose_providers(onnxruntime.get_available_providers(),
cpu or model in CPU_ONLY)
for provider in providers:
try:
depth, provider = _run(weights, tensor, provider)
depth = np.squeeze(depth)
except Exception:
if provider == providers[-1]:
raise
continue
if depth.ndim != 2:
raise ValueError("sortie du modele inattendue : {}".format(depth.shape))
return depth.astype(np.float32), provider
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
parser.add_argument("image")
parser.add_argument("output")
parser.add_argument("weights")
parser.add_argument("--model", choices=sorted(MODELS), default="midas_small")
parser.add_argument("--cpu", action="store_true",
help="ne pas utiliser la carte graphique")
args = parser.parse_args(argv)
from PIL import Image
with Image.open(args.image) as picture:
depth, provider = estimate(picture, args.weights, args.model, args.cpu)
with open(args.output, "wb") as handle:
np.save(handle, depth, allow_pickle=False)
print(provider)
return 0
if __name__ == "__main__":
sys.exit(main())

BIN
docs/profondeur.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

241
docs/schema_profondeur.py Normal file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Genere le schema explicatif de l'onglet « Profondeur ».
python docs/schema_profondeur.py # les deux versions
python docs/schema_profondeur.py fr # docs/profondeur.svg + .png (README)
python docs/schema_profondeur.py en # docs/depth_en.svg
# + images/depth_en.png (onglet
# « Depth » de la boite de dialogue)
Une ligne par parametre de l'onglet, reperee par le numero que le parametre
porte dans la boite de dialogue, avec le resultat pour plusieurs valeurs.
Le portrait d'essai (doc/personne.jpg) passe par les memes fonctions que
l'extension : profondeur estimee par `gray_iso_layers_depth` (il faut donc
l'environnement installe par `deploy.ps1 -Depth`), puis filtre guide, details
fins et decoupe en planches du noyau. Le schema reste fidele au resultat reel.
"""
import base64
import hashlib
import io
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
sys.path.insert(0, HERE)
import numpy as np # noqa: E402
from PIL import Image # noqa: E402
import gray_iso_layers_core as core # noqa: E402
import schema_parametres as base # noqa: E402
from gray_iso_layers_depth import DepthUnavailable, depth_field # noqa: E402
PHOTO = os.path.join(ROOT, "doc", "personne.jpg")
RESOLUTION = 500 # points de calcul sur le grand cote
LEVELS = 8 # planches des vignettes
W, ROW = 1400, 430 # 3 lignes de 4 vignettes
H = 3 * ROW
COLUMN = (W - 30) / 4.0
TILE_W, TILE_H = 225.0, 286.0 # vignette, aux proportions du portrait
VERSIONS = {
"fr": {
"svg": os.path.join(HERE, "profondeur.svg"),
"png": os.path.join(HERE, "profondeur.png"),
"png_width": 1400,
"font_scale": 1.0,
"text": {
"title1": "Part de la carte de profondeur",
"note1": "Dose le mélange entre les gris de la photo (0 %) et sa "
"profondeur estimée (100 %). Planche claire = devant, "
"planche sombre = au fond.",
"tiles1": ["La photo", "0 % : gris seuls", "50 %", "100 % : volumes"],
"title2": "Modèle de profondeur",
"note2": "Rapide : calcul court, carte lisse. Fin : cheveux et visage "
"mieux détourés. (Exemples avec les réglages 1 et 3 à 100 %.)",
"tiles2": ["Rapide : profondeur", "Rapide : planches",
"Fin : profondeur", "Fin : planches"],
"title3": "Détails fins de la photo",
"note3": "Grave dans la profondeur les yeux, la bouche, les sourcils. "
"Trop fort : petites formes parasites. (Exemples avec le "
"réglage 1 à 100 %, modèle Fin.)",
"tiles3": ["0 % : visage lisse", "50 %", "100 %", "200 %"],
},
},
"en": {
"svg": os.path.join(HERE, "depth_en.svg"),
"png": os.path.join(ROOT, "images", "depth_en.png"),
"png_width": 900,
"font_scale": 1.25,
"text": {
"title1": "Depth map share",
"note1": "Mixes the grays of the photo (0 %) with its estimated depth "
"(100 %). Light board = front, dark board = back.",
"tiles1": ["The photo", "0 %: grays alone", "50 %", "100 %: volumes"],
"title2": "Depth model",
"note2": "Fast: short run, smooth map. Fine: sharper hair and face. "
"(Examples with settings 1 and 3 at 100 %.)",
"tiles2": ["Fast: depth", "Fast: boards", "Fine: depth", "Fine: boards"],
"title3": "Fine details of the photo",
"note3": "Carves eyes, mouth and eyebrows into the depth. Too high: "
"stray small shapes. (Examples with setting 1 at 100 %, "
"Fine model.)",
"tiles3": ["0 %: smooth face", "50 %", "100 %", "200 %"],
},
},
}
# --------------------------------------------------------------------------
# Champs : memes etapes que GrayIsoLayers.add_depth
# --------------------------------------------------------------------------
class Portrait:
"""Le portrait d'essai : ses gris et sa profondeur selon chaque modele."""
def __init__(self):
with open(PHOTO, "rb") as handle:
data = handle.read()
picture = Image.open(io.BytesIO(data)).convert("RGB")
zoom = RESOLUTION / float(max(picture.size))
size = (int(round(picture.width * zoom)), int(round(picture.height * zoom)))
self.gray = core.normalize(np.asarray(
picture.convert("L").resize(size, Image.LANCZOS), dtype=float))
rows, cols = self.gray.shape
self.sigma = max(rows, cols) / 40.0
small = picture.copy()
small.thumbnail((768, 768), Image.LANCZOS)
digest = hashlib.sha1(data).hexdigest()
self.depth = {}
for model in ("midas_small", "depth_anything_v2_small"):
depth = depth_field(small, digest, model)
depth = Image.fromarray(np.asarray(depth, dtype=np.float32))
depth = core.normalize(np.asarray(depth.resize(size, Image.BICUBIC),
dtype=float))
self.depth[model] = core.guided_filter(
self.gray, depth, max(2, int(round(max(rows, cols) / 100.0))))
def field(self, mix, detail, model="depth_anything_v2_small"):
"""Champ decoupe par l'extension pour ces reglages (en %)."""
depth = core.add_detail(self.depth[model], self.gray, detail / 100.0,
self.sigma)
return core.blend_fields(self.gray, depth, mix / 100.0)
# --------------------------------------------------------------------------
# Dessin
# --------------------------------------------------------------------------
def tile_box(row, column):
return 30 + column * COLUMN, row * ROW + 98
def frame(x, y):
base.out.append('<rect x="{}" y="{}" width="{}" height="{}" fill="none" '
'stroke="{}" stroke-width="1.2"/>'.format(
base.fmt(x), base.fmt(y), TILE_W, TILE_H, base.EDGE))
def draw_field(field, x, y):
"""Vignette d'un champ, en image de gris."""
buffer = io.BytesIO()
Image.fromarray((np.clip(field, 0, 1) * 255).round().astype(np.uint8)).save(
buffer, "PNG")
base.out.append(
'<image x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="none" '
'xlink:href="data:image/png;base64,{}"/>'.format(
base.fmt(x), base.fmt(y), TILE_W, TILE_H,
base64.b64encode(buffer.getvalue()).decode()))
frame(x, y)
def draw_boards(field, x, y):
"""Vignette des planches decoupees dans un champ, chacune de son gris."""
rows, cols = field.shape
layers = core.iso_layers(field, levels=LEVELS, blur=0.01 * max(rows, cols),
min_area=0.0004, tolerance=0.4, shapes="light",
preserve_edges=True)
scale_x, scale_y = TILE_W / (cols - 1.0), TILE_H / (rows - 1.0)
box = (x, y, x + TILE_W, y + TILE_H)
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)
base.out.append('<path d="{}" fill="{}" fill-rule="evenodd" stroke="{}" '
'stroke-width="0.45" stroke-linejoin="round"/>'.format(
d, core.gray_to_hex(gray), base.EDGE))
frame(x, y)
def row(number, tiles):
"""Ligne du parametre `number` : pastille, titre, note, puis les
vignettes `tiles`, liste de `(dessin, champ)`."""
top = (number - 1) * ROW
# Pastille au numero du parametre, comme dans la boite de dialogue.
base.out.append('<circle cx="47" cy="{}" r="17" fill="{}"/>'.format(
top + 43, base.DIM))
base.text(47, top + 43 + 7.5 * base.FS, str(number), size=21, color="#ffffff",
anchor="middle", weight="bold", halo=False)
base.text(76, top + 51, base.T["title{}".format(number)], size=22,
weight="bold", halo=False)
base.text(30, top + 84, base.T["note{}".format(number)], size=15, halo=False)
captions = base.T["tiles{}".format(number)]
for column, ((draw, field), caption) in enumerate(zip(tiles, captions)):
x, y = tile_box(number - 1, column)
draw(field, x, y)
base.text(x + TILE_W / 2.0, y + TILE_H + 22 * base.FS, caption, size=16,
color=base.DIM, anchor="middle", weight="bold", halo=False)
def build(language, portrait):
version = VERSIONS[language]
base.out.clear()
base.T.clear()
base.T.update(version["text"])
base.FS = version["font_scale"]
base.out.append('<rect width="{}" height="{}" fill="#ffffff"/>'.format(W, H))
for y in (ROW, 2 * ROW):
base.out.append('<line x1="20" y1="{0}" x2="{1}" y2="{0}" stroke="#dee2e6" '
'stroke-width="1.5"/>'.format(y, W - 20))
fine, fast = "depth_anything_v2_small", "midas_small"
row(1, [(draw_field, portrait.gray)]
+ [(draw_boards, portrait.field(mix, 100)) for mix in (0, 50, 100)])
row(2, [(draw_field, portrait.depth[fast]),
(draw_boards, portrait.field(100, 100, fast)),
(draw_field, portrait.depth[fine]),
(draw_boards, portrait.field(100, 100, fine))])
row(3, [(draw_boards, portrait.field(100, detail))
for detail in (0, 50, 100, 200)])
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink" width="{0}" height="{1}" '
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\n').format(W, H, "\n".join(base.out))
with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle:
handle.write(svg)
print(version["svg"])
base.export_png(version["svg"], version["png"], version["png_width"])
def main(argv):
try:
portrait = Portrait()
except DepthUnavailable as error:
sys.exit("Carte de profondeur indisponible ({}) : lancer "
"deploy.ps1 -Depth.".format(error))
for language in argv or sorted(VERSIONS):
build(language, portrait)
if __name__ == "__main__":
main(sys.argv[1:])

View File

@ -13,15 +13,26 @@
<page name="levels" gui-text="Levels"> <page name="levels" gui-text="Levels">
<param name="levels" type="int" min="2" max="64" <param name="levels" type="int" min="2" max="64"
gui-text="Number of gray levels:">16</param> gui-text="Number of gray levels:">16</param>
<param name="equalize" type="float" precision="0" min="0" max="100"
gui-text="Level distribution (% of equal areas):"
gui-description="0 keeps evenly spaced thresholds. 100 spreads the levels so that every board covers the same area: use it when most of the image sits in a few grays, as in a depth map.">0</param>
<param name="shapes" type="optiongroup" appearance="combo" <param name="shapes" type="optiongroup" appearance="combo"
gui-text="Content of each board:"> gui-text="Content of each board:">
<option value="light">Stacked, lightest in front (board no. 1 = darkest, whole image)</option> <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="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> <option value="band">Its own gray only (flat areas tiling the image, no overlap)</option>
</param>
<param name="layout" type="optiongroup" appearance="combo"
gui-text="Position of the boards:">
<option value="stack">On top of each other, at the place of the image</option>
<option value="side">Side by side, in a grid to the right of the image</option>
</param> </param>
<param name="mark" type="bool" <param name="mark" type="bool"
gui-text="Mark on each board the outline of the next one (stacked boards)" 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> 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="numbers" type="bool"
gui-text="Write the board numbers"
gui-description="Drawn in the number color, to engrave. Framed number: the number of the board itself. Plain number: the board to glue in that marked area. On stacked boards both sit under the next board, so no number shows once the boards are glued, and the top board carries none.">true</param>
<param name="blur" type="float" precision="2" min="0" max="20" <param name="blur" type="float" precision="2" min="0" max="20"
gui-text="Smoothing (% of the image's longer side):" 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> gui-description="Smoothing applied before cutting the levels: the higher it is, the rounder and simpler the outlines.">1.0</param>
@ -40,6 +51,27 @@
gui-text="Smooth the outlines with Bézier curves">true</param> gui-text="Smooth the outlines with Bézier curves">true</param>
</page> </page>
<page name="depth" gui-text="Depth">
<param name="depth_mix" type="float" precision="0" min="0" max="100"
gui-text="1. Depth map share (%):"
gui-description="0 uses the grays of the image alone, 100 its estimated depth alone. In between, the depth gives the volumes and the grays keep details such as the eyes and the mouth. Needs the depth models, installed by deploy.ps1 -Depth.">0</param>
<param name="depth_model" type="optiongroup" appearance="combo"
gui-text="2. Depth model:">
<option value="midas_small">Fast (MiDaS small)</option>
<option value="depth_anything_v2_small">Fine (Depth Anything V2 Small)</option>
</param>
<param name="depth_detail" type="float" precision="0" min="0" max="200"
gui-text="3. Fine details of the photo (%):"
gui-description="Carves the fine contrasts of the photo (eyes, mouth, eyebrows, strands of hair) into the depth, which has none of them. A plain background brings nothing and stays at the back. 0 keeps the smooth depth alone.">100</param>
<label>Each setting carries a number: the examples below show, under the same number, what it does for several values.</label>
<separator/>
<!-- Schéma généré par docs/schema_profondeur.py, en anglais, affiché
aux 3/4 du PNG (900 px), comme celui de l'onglet « Help ». Une
ligne par paramètre, au numéro que porte son libellé. -->
<image width="675" height="622">images/depth_en.png</image>
<label>The estimate runs on the graphics card when possible and is kept in a cache: only the first run on an image takes a few seconds.</label>
</page>
<page name="style" gui-text="Style"> <page name="style" gui-text="Style">
<param name="fill_mode" type="optiongroup" appearance="combo" gui-text="Fill:"> <param name="fill_mode" type="optiongroup" appearance="combo" gui-text="Fill:">
<option value="gray">Gray of each level</option> <option value="gray">Gray of each level</option>
@ -62,6 +94,8 @@
gui-text="Cut stroke color:">4278190335</param> gui-text="Cut stroke color:">4278190335</param>
<param name="mark_color" type="color" appearance="colorbutton" <param name="mark_color" type="color" appearance="colorbutton"
gui-text="Marking stroke color:">255</param> gui-text="Marking stroke color:">255</param>
<param name="number_color" type="color" appearance="colorbutton"
gui-text="Number stroke color:">65535</param>
<param name="keep_original" type="bool" <param name="keep_original" type="bool"
gui-text="Keep the original image">true</param> gui-text="Keep the original image">true</param>
</page> </page>
@ -78,7 +112,9 @@
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. 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. 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 the board numbers, drawn in the number color (blue), each board carries its own number in a frame and each marked area carries, without a frame, the number of the board to glue there; both sit under the next board, so no number shows on the glued stack, and the top board carries none. With "Its own gray only", a board holds the flat area of its gray alone: the flat areas tile the image without overlapping and carry no marking. Several selected images share the same layers. With the boards side by side, each board is moved into its own cell of a grid to the right of the image, with its marking, so that every board can be seen and cut on its own.
For a relief that follows the real volumes of a portrait rather than its light and shadows, raise the depth map share (Depth tab): at 100, the boards follow the volumes, and the fine details setting carves the eyes and the mouth into them. A depth map made elsewhere (8 or 16 bits) can also be imported and selected like any image, with a share of 0. In both cases, if most boards come out empty or the subject only gets a few of them, raise the level distribution.
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> 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> </page>

View File

@ -11,7 +11,9 @@ La logique vit dans `gray_iso_layers_core`, sans dependance a inkex.
""" """
import base64 import base64
import hashlib
import io import io
import math
import os import os
import sys import sys
from urllib.parse import unquote, unquote_to_bytes, urlparse from urllib.parse import unquote, unquote_to_bytes, urlparse
@ -25,8 +27,17 @@ from inkex.localization import localize
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gray_iso_layers_core import ( # noqa: E402 from gray_iso_layers_core import ( # noqa: E402
gray_to_hex, iso_boards, lines_to_d, parse_color, rings_to_d, scale_lines, add_detail, blend_fields, board_labels, gray_to_hex, guided_filter, iso_boards,
lines_to_d, normalize, number_lines, parse_color, rings_to_d, scale_lines,
scale_rings) scale_rings)
from gray_iso_layers_depth import DepthUnavailable, depth_field # noqa: E402
# Modes Pillow a plus de 8 bits par canal (carte de profondeur en 16 bits).
_DEEP_MODES = ("I", "F")
# Grand cote de l'image envoyee au calcul de profondeur : les modeles
# travaillent a 518 points au plus.
_DEPTH_SIZE = 768
# Installe _() dans les builtins : catalogue « grayisolayers » de la langue # Installe _() dans les builtins : catalogue « grayisolayers » de la langue
# de l'interface (Inkscape transmet domaine et dossier par l'environnement), # de l'interface (Inkscape transmet domaine et dossier par l'environnement),
@ -43,20 +54,27 @@ class GrayIsoLayers(inkex.EffectExtension):
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut. # Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
pars.add_argument("--tab", default="levels") pars.add_argument("--tab", default="levels")
pars.add_argument("--levels", type=int, default=16) pars.add_argument("--levels", type=int, default=16)
pars.add_argument("--equalize", type=float, default=0.0)
pars.add_argument("--shapes", default="light") pars.add_argument("--shapes", default="light")
pars.add_argument("--layout", default="stack")
pars.add_argument("--mark", type=inkex.Boolean, default=True) pars.add_argument("--mark", type=inkex.Boolean, default=True)
pars.add_argument("--numbers", type=inkex.Boolean, default=True)
pars.add_argument("--blur", type=float, default=1.0) pars.add_argument("--blur", type=float, default=1.0)
pars.add_argument("--edges", type=inkex.Boolean, default=True) pars.add_argument("--edges", type=inkex.Boolean, default=True)
pars.add_argument("--resolution", type=int, default=800) pars.add_argument("--resolution", type=int, default=800)
pars.add_argument("--min_area", type=float, default=0.02) pars.add_argument("--min_area", type=float, default=0.02)
pars.add_argument("--simplify", type=float, default=0.4) pars.add_argument("--simplify", type=float, default=0.4)
pars.add_argument("--smooth", type=inkex.Boolean, default=True) pars.add_argument("--smooth", type=inkex.Boolean, default=True)
pars.add_argument("--depth_mix", type=float, default=0.0)
pars.add_argument("--depth_model", default="midas_small")
pars.add_argument("--depth_detail", type=float, default=100.0)
pars.add_argument("--fill_mode", default="gray") pars.add_argument("--fill_mode", default="gray")
pars.add_argument("--fill_color", default="4294967295") pars.add_argument("--fill_color", default="4294967295")
pars.add_argument("--stroke_width", type=float, default=0.2) pars.add_argument("--stroke_width", type=float, default=0.2)
pars.add_argument("--unit", default="mm") pars.add_argument("--unit", default="mm")
pars.add_argument("--stroke_color", default="4278190335") pars.add_argument("--stroke_color", default="4278190335")
pars.add_argument("--mark_color", default="255") pars.add_argument("--mark_color", default="255")
pars.add_argument("--number_color", default="65535")
pars.add_argument("--keep_original", type=inkex.Boolean, default=True) pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
def effect(self): def effect(self):
@ -93,12 +111,15 @@ class GrayIsoLayers(inkex.EffectExtension):
"""Traite une image : calcul dans gray_iso_layers_core, ecriture dans le SVG.""" """Traite une image : calcul dans gray_iso_layers_core, ecriture dans le SVG."""
opt = self.options opt = self.options
try: try:
field = self.load_field(image, opt.resolution) picture, digest = self.load_picture(image)
field = self.gray_field(picture, opt.resolution)
except Exception as error: # fichier absent, format inconnu, lien casse... except Exception as error: # fichier absent, format inconnu, lien casse...
inkex.errormsg(_("Cannot read the image '{}' ({}). Embed it in the " inkex.errormsg(_("Cannot read the image '{}' ({}). Embed it in the "
"document (right-click > Embed Image) or check its " "document (right-click > Embed Image) or check its "
"link.").format(image.get_id(), error)) "link.").format(image.get_id(), error))
return return
if opt.depth_mix > 0:
field = self.add_depth(image, picture, digest, field)
rows, cols = field.shape rows, cols = field.shape
boards = iso_boards( boards = iso_boards(
@ -107,7 +128,8 @@ class GrayIsoLayers(inkex.EffectExtension):
preserve_edges=opt.edges, preserve_edges=opt.edges,
min_area=opt.min_area / 100.0, min_area=opt.min_area / 100.0,
tolerance=opt.simplify, tolerance=opt.simplify,
shapes=opt.shapes) shapes=opt.shapes,
equalize=opt.equalize / 100.0)
if sum(1 for board in boards if board[2]) < 2: if sum(1 for board in boards if board[2]) < 2:
inkex.errormsg(_("The image '{}' is uniform: only the base level has " inkex.errormsg(_("The image '{}' is uniform: only the base level has "
"a shape. Reduce the smoothing or pick a more " "a shape. Reduce the smoothing or pick a more "
@ -119,10 +141,14 @@ class GrayIsoLayers(inkex.EffectExtension):
left, top, width, height = self.image_box(image, cols, rows) left, top, width, height = self.image_box(image, cols, rows)
scale_x, scale_y = width / (cols - 1.0), height / (rows - 1.0) scale_x, scale_y = width / (cols - 1.0), height / (rows - 1.0)
box = (left, top, left + width, top + height) box = (left, top, left + width, top + height)
matrix = image.composed_transform().matrix base = image.composed_transform().matrix
shifts = self.board_shifts(base, box, len(boards))
above = image above = image
for number, (_level, gray, rings, mark) in enumerate(boards, 1): for number, (_level, gray, rings, mark) in enumerate(boards, 1):
(a, c, e), (b, d, f) = base
shift_x, shift_y = shifts[number - 1]
matrix = ((a, c, e + shift_x), (b, d, f + shift_y))
# Les planches sont numerotees du fond vers l'avant. Calque cree # Les planches sont numerotees du fond vers l'avant. Calque cree
# meme si le niveau est vide : autant de calques que de niveaux. # meme si le niveau est vide : autant de calques que de niveaux.
layer = self.board_layer(number, above) layer = self.board_layer(number, above)
@ -147,9 +173,57 @@ class GrayIsoLayers(inkex.EffectExtension):
node.style = mark_style node.style = mark_style
layer.add(node) layer.add(node)
# Numeros a graver : celui de la planche, encadre, sur chacune de
# ses pieces, et celui de la planche suivante la ou elle se pose.
# Dans une pile ils restent sous la planche suivante, donc caches
# une fois les planches collees, et la planche du dessus, que rien
# ne recouvre, n'en porte aucun.
stacked = opt.shapes != "band"
following = boards[number][2] if stacked and number < len(boards) else None
number_style = self.number_style() if opt.numbers else None
if number_style is not None and (following or not stacked):
own, after = board_labels(
rings, following, number, cols, rows,
largest=0.03 * max(rows, cols), smallest=0.008 * max(rows, cols),
hidden=stacked)
parts = [number_lines(number, x, y, size, framed=True)
for x, y, size in own]
parts += [number_lines(number + 1, x, y, size) for x, y, size in after]
d = " ".join(
lines_to_d(scale_lines(part, scale_x, scale_y, left, top),
smooth=False, matrix=matrix) for part in parts)
if d:
node = inkex.PathElement()
node.label = _("Numbers")
node.set("d", d)
node.style = number_style
layer.add(node)
if not opt.keep_original: if not opt.keep_original:
image.delete() image.delete()
def board_shifts(self, matrix, box, count):
"""Decalage (x, y) de chaque planche, en coordonnees du document.
Planches superposees : aucun, chacune reste a la place de l'image.
Planches juxtaposees : elles sont rangees en grille a droite de
l'image, de gauche a droite puis de haut en bas, chacune dans une
case de la taille de l'image, pour etre vues et decoupees une a une.
"""
if self.options.layout != "side":
return [(0.0, 0.0)] * count
(a, c, e), (b, d, f) = matrix
x0, y0, x1, y1 = box
# Encombrement de l'image dans le document, meme tournee ou etiree.
corners = [(a * x + c * y + e, b * x + d * y + f)
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))]
width = max(x for x, _y in corners) - min(x for x, _y in corners)
height = max(y for _x, y in corners) - min(y for _x, y in corners)
gap = 0.05 * max(width, height)
columns = int(math.ceil(math.sqrt(count)))
return [((width + gap) * (1 + k % columns), (height + gap) * (k // columns))
for k in range(count)]
def layer_style(self, gray): def layer_style(self, gray):
"""Style d'une couche : remplissage selon le mode, trait facultatif.""" """Style d'une couche : remplissage selon le mode, trait facultatif."""
opt = self.options opt = self.options
@ -183,6 +257,16 @@ class GrayIsoLayers(inkex.EffectExtension):
"stroke-width": str(round(width, 6)), "stroke-linejoin": "round", "stroke-width": str(round(width, 6)), "stroke-linejoin": "round",
"stroke-linecap": "round"}) "stroke-linecap": "round"})
def number_style(self):
"""Style des numeros : comme le repere d'assemblage, dans une couleur
a eux pour les graver a part. `None` s'il n'y a pas de trait."""
style = self.mark_style()
if style is not None:
color, opacity = parse_color(self.options.number_color, ("#0000ff", 1.0))
style["stroke"] = color
style["stroke-opacity"] = str(opacity)
return style
@staticmethod @staticmethod
def image_box(image, cols, rows): def image_box(image, cols, rows):
"""Rectangle reellement occupe par le bitmap, dans le repere de l'image. """Rectangle reellement occupe par le bitmap, dans le repere de l'image.
@ -200,36 +284,104 @@ class GrayIsoLayers(inkex.EffectExtension):
width, height = fitted_width, fitted_height width, height = fitted_width, fitted_height
return left, top, width, height return left, top, width, height
def load_field(self, image, resolution): def add_depth(self, image, picture, digest, field):
"""Lit le bitmap et renvoie ses gris (0 noir, 1 blanc) en tableau 2D, """Melange aux gris `field` la profondeur estimee de l'image, dans la
ramene a `resolution` points sur son grand cote.""" proportion `depth_mix`. Si elle ne peut pas etre calculee, le dit et
rend les gris seuls."""
import numpy import numpy
from PIL import Image from PIL import Image
opt = self.options
try:
depth = depth_field(self.color_picture(picture), digest, opt.depth_model)
except DepthUnavailable as error:
inkex.errormsg(_("No depth map for the image '{}' ({}). Install the "
"depth models with 'deploy.ps1 -Depth'. The grays "
"of the image are used alone.").format(
image.get_id(), error))
return field
rows, cols = field.shape
depth = Image.fromarray(numpy.asarray(depth, dtype=numpy.float32))
depth = normalize(numpy.asarray(depth.resize((cols, rows), Image.BICUBIC),
dtype=float))
# La carte sort du modele en basse resolution : ses bords flous sont
# recales sur ceux de la photo.
depth = guided_filter(field, depth, max(2, int(round(max(rows, cols) / 100.0))))
# Les yeux et la bouche, absents de la profondeur, viennent des gris.
depth = add_detail(depth, field, opt.depth_detail / 100.0,
max(rows, cols) / 40.0)
return blend_fields(field, depth, opt.depth_mix / 100.0)
def load_picture(self, image):
"""Lit le bitmap : `(image PIL, empreinte de ses octets)`."""
from PIL import Image
href = image.get("xlink:href") or image.get("href") or "" href = image.get("xlink:href") or image.get("href") or ""
if href.startswith("data:"): if href.startswith("data:"):
header, _sep, payload = href.partition(",") header, _sep, payload = href.partition(",")
data = base64.b64decode(payload) if ";base64" in header \ data = base64.b64decode(payload) if ";base64" in header \
else unquote_to_bytes(payload) else unquote_to_bytes(payload)
source = io.BytesIO(data)
else: else:
if href.startswith("file:"): if href.startswith("file:"):
path = url2pathname(urlparse(href).path) path = url2pathname(urlparse(href).path)
else: else:
path = unquote(href) path = unquote(href)
# Lien relatif : il part du dossier du document. # Lien relatif : il part du dossier du document.
source = self.absolute_href(path) with open(self.absolute_href(path), "rb") as handle:
data = handle.read()
picture = Image.open(io.BytesIO(data))
picture.load()
return picture, hashlib.sha1(data).hexdigest()
with Image.open(source) as picture: @staticmethod
# La transparence se lit comme du blanc (fond de page). def gray_picture(picture):
rgba = picture.convert("RGBA") """Gris de l'image, en image PIL : mode « L » (0 a 255), ou « F »
white = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) (0 a 1) pour une image a plus de 8 bits par canal."""
gray = Image.alpha_composite(white, rgba).convert("L") import numpy
from PIL import Image
if picture.mode.startswith(_DEEP_MODES):
# Carte de profondeur en 16 bits : Pillow l'ecreterait a 255 en la
# convertissant, on l'etire nous-memes sur 0..1.
values = normalize(numpy.asarray(picture, dtype=float))
return Image.fromarray(values.astype(numpy.float32))
# La transparence se lit comme du blanc (fond de page).
rgba = picture.convert("RGBA")
white = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
return Image.alpha_composite(white, rgba).convert("L")
@classmethod
def gray_field(cls, picture, resolution):
"""Gris de l'image (0 noir, 1 blanc) en tableau 2D, ramene a
`resolution` points sur son grand cote."""
import numpy
from PIL import Image
gray = cls.gray_picture(picture)
zoom = resolution / float(max(gray.size)) zoom = resolution / float(max(gray.size))
size = (max(2, int(round(gray.width * zoom))), size = (max(2, int(round(gray.width * zoom))),
max(2, int(round(gray.height * zoom)))) max(2, int(round(gray.height * zoom))))
gray = gray.resize(size, Image.LANCZOS) gray = gray.resize(size, Image.LANCZOS)
return numpy.asarray(gray, dtype=float) / 255.0 scale = 1.0 if gray.mode == "F" else 255.0
return numpy.asarray(gray, dtype=float) / scale
@classmethod
def color_picture(cls, picture):
"""Image RGB envoyee au calcul de profondeur, transparence en blanc,
reduite a `_DEPTH_SIZE` points sur son grand cote."""
import numpy
from PIL import Image
if picture.mode.startswith(_DEEP_MODES):
gray = numpy.asarray(cls.gray_picture(picture), dtype=float)
color = Image.fromarray((gray * 255.0).round().astype(numpy.uint8))
color = color.convert("RGB")
else:
rgba = picture.convert("RGBA")
white = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
color = Image.alpha_composite(white, rgba).convert("RGB")
color.thumbnail((_DEPTH_SIZE, _DEPTH_SIZE), Image.LANCZOS)
return color
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -163,6 +163,70 @@ def normalize(field):
return (field - low) / (high - low) return (field - low) / (high - low)
def spread_levels(field, amount=1.0, bins=1024):
"""Repartit les valeurs du champ pour que les niveaux aient la meme aire.
Le champ est remappe par sa fonction de repartition (egalisation
d'histogramme) : des seuils reguliers decoupent alors des surfaces egales,
meme si presque toutes les valeurs sont tassees sur une plage etroite
(carte de profondeur : fond lointain etendu, sujet resserre). `amount`
dose l'effet, de 0 (champ etire sur 0..1, sans plus) a 1 (aires egales).
"""
field = normalize(field)
amount = min(1.0, max(0.0, float(amount)))
if amount <= 0:
return field
counts, edges = np.histogram(field, bins=bins, range=(0.0, 1.0))
share = np.concatenate([[0.0], np.cumsum(counts)]) / float(field.size)
return (1.0 - amount) * field + amount * np.interp(field, edges, share)
def guided_filter(guide, source, radius, eps=1e-3):
"""Lisse `source` en lui donnant les contours de `guide` (filtre guide).
Dans chaque fenetre de `radius` echantillons, `source` est approche par
une fonction affine de `guide` : un bord flou de `source` (carte de
profondeur calculee en basse resolution) se recale sur le bord net
correspondant de `guide` (la photo). `eps` est la variance de `guide` en
dessous de laquelle une zone est tenue pour calme, donc simplement lissee.
"""
guide = np.asarray(guide, dtype=float)
source = np.asarray(source, dtype=float)
radius = int(radius)
if radius < 1:
return source.copy()
mean_guide, mean_source = box_blur(guide, radius), box_blur(source, radius)
covariance = box_blur(guide * source, radius) - mean_guide * mean_source
variance = np.maximum(box_blur(guide * guide, radius) - mean_guide ** 2, 0.0)
slope = covariance / (variance + eps)
offset = mean_source - slope * mean_guide
return box_blur(slope, radius) * guide + box_blur(offset, radius)
def add_detail(depth, gray, amount, sigma):
"""Grave dans la profondeur les contrastes fins des gris.
Une carte de profondeur donne les volumes mais gomme les yeux, la bouche,
les sourcils. On lui ajoute les gris prives de leurs grandes variations
(ecart a leur moyenne locale, sur `sigma` echantillons) : un trait sombre
se creuse, un reflet ressort, alors qu'un fond uni, clair ou sombre,
n'apporte rien et reste a sa profondeur. `amount` dose l'effet (0 : la
profondeur seule). Le resultat reste sur 0..1.
"""
depth = normalize(depth)
if amount <= 0:
return depth
gray = normalize(gray)
return np.clip(depth + amount * (gray - gaussian_blur(gray, sigma)), 0.0, 1.0)
def blend_fields(gray, depth, mix):
"""Melange les gris et la profondeur, chacun etire sur 0..1 : `mix` = 0
garde les gris seuls, 1 la profondeur seule."""
mix = min(1.0, max(0.0, float(mix)))
return (1.0 - mix) * normalize(gray) + mix * normalize(depth)
def thresholds(levels): def thresholds(levels):
"""Seuils reguliers separant `levels` niveaux de gris sur 0..1.""" """Seuils reguliers separant `levels` niveaux de gris sur 0..1."""
return [k / float(levels) for k in range(1, levels)] return [k / float(levels) for k in range(1, levels)]
@ -536,13 +600,15 @@ def region_rings(field, lines, inside):
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def iso_boards(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0, def iso_boards(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0,
shapes="light", preserve_edges=False): shapes="light", preserve_edges=False, equalize=0.0):
"""Decoupe un champ de gris en une planche par niveau. """Decoupe un champ de gris en une planche par niveau.
Le champ est lisse (`blur` en echantillons, `preserve_edges` : voir Le champ est lisse (`blur` en echantillons, `preserve_edges` : voir
`smooth`), etire sur 0..1 puis quantifie en `levels` niveaux a seuils `smooth`), etire sur 0..1 puis quantifie en `levels` niveaux a seuils
reguliers : le niveau k couvre les valeurs de k / levels a 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 : (k + 1) / levels. Avec `equalize` (0 a 1, voir `spread_levels`), les
valeurs sont d'abord reparties pour que les niveaux tendent vers des
aires egales. Renvoie les planches du fond vers l'avant de la pile :
`(niveau, gris, rings, mark)` avec `niveau` de 0 (le plus sombre) a `(niveau, gris, rings, mark)` avec `niveau` de 0 (le plus sombre) a
levels - 1 et `gris` entre 0 et 1. levels - 1 et `gris` entre 0 et 1.
@ -570,6 +636,8 @@ def iso_boards(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0,
height, width = field.shape height, width = field.shape
levels = max(2, int(levels)) levels = max(2, int(levels))
field = smooth(field, blur, preserve_edges) field = smooth(field, blur, preserve_edges)
if equalize > 0:
field = spread_levels(field, equalize)
smallest = min_area * (width - 1) * (height - 1) smallest = min_area * (width - 1) * (height - 1)
cuts = thresholds(levels) cuts = thresholds(levels)
@ -601,11 +669,205 @@ def iso_boards(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0,
def iso_layers(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0, def iso_layers(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0,
shapes="band", preserve_edges=False): shapes="band", preserve_edges=False, equalize=0.0):
"""Comme `iso_boards`, sans les reperes d'assemblage : liste de """Comme `iso_boards`, sans les reperes d'assemblage : liste de
`(niveau, gris, rings)`.""" `(niveau, gris, rings)`."""
return [board[:3] for board in iso_boards(field, levels, blur, min_area, return [board[:3] for board in iso_boards(field, levels, blur, min_area,
tolerance, shapes, preserve_edges)] tolerance, shapes, preserve_edges,
equalize)]
# --------------------------------------------------------------------------
# Numeros des planches
#
# Chaque piece d'une planche porte son numero, encadre, et chaque zone ou se
# pose la planche suivante porte le numero de celle-ci. Dans une pile, les
# deux se logent sous la planche suivante : plus aucun numero ne se voit une
# fois les planches collees. Les numeros sont traces en segments droits, sans
# police : ils se gravent tels quels.
# --------------------------------------------------------------------------
# Segments d'un chiffre dans une case de 0.5 x 1 : a haut, b droite haut,
# c droite bas, d bas, e gauche bas, f gauche haut, g milieu.
_SEGMENTS = {
"a": ((0.0, 0.0), (0.5, 0.0)), "b": ((0.5, 0.0), (0.5, 0.5)),
"c": ((0.5, 0.5), (0.5, 1.0)), "d": ((0.0, 1.0), (0.5, 1.0)),
"e": ((0.0, 0.5), (0.0, 1.0)), "f": ((0.0, 0.0), (0.0, 0.5)),
"g": ((0.0, 0.5), (0.5, 0.5)),
}
_DIGITS = {"0": "abcdef", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc",
"5": "afgcd", "6": "afgedc", "7": "abc", "8": "abcdefg", "9": "abcdfg"}
_DIGIT_STEP = 0.8 # pas d'un chiffre au suivant, en hauteurs de chiffre
_FRAME_MARGIN = 0.35 # marge du cadre autour du numero, idem
def number_size(number, height, framed=False):
"""Largeur et hauteur du numero trace par `number_lines`."""
count = len(str(int(number)))
width = height * (0.5 + _DIGIT_STEP * (count - 1))
margin = 2 * _FRAME_MARGIN * height if framed else 0.0
return width + margin, height + margin
def number_lines(number, x, y, height, framed=False):
"""Trace du numero centre en (x, y), chiffres hauts de `height`.
Renvoie une ligne `(closed, opened)` comme `contour_lines` : les segments
des chiffres en lignes ouvertes, et le cadre (`framed`) en boucle fermee.
"""
text = str(int(number))
width, _height = number_size(number, height)
left, top = x - width / 2.0, y - height / 2.0
opened = []
for k, digit in enumerate(text):
origin = np.array([left + k * _DIGIT_STEP * height, top])
for name in _DIGITS[digit]:
opened.append(origin + height * np.array(_SEGMENTS[name]))
closed = []
if framed:
w, h = number_size(number, height, True)
closed.append(np.array([(x - w / 2, y - h / 2), (x + w / 2, y - h / 2),
(x + w / 2, y + h / 2), (x - w / 2, y + h / 2)]))
return closed, opened
def region_mask(rings, width, height, step):
"""Grille de booleens : la region bornee par `rings` (regle pair-impair),
echantillonnee tous les `step` echantillons. Renvoie `(mask, xs, ys)`,
les abscisses et ordonnees des colonnes et des lignes de la grille."""
xs = np.arange(step / 2.0, width - 1.0, step)
ys = np.arange(step / 2.0, height - 1.0, step)
mask = np.zeros((len(ys), len(xs)), dtype=bool)
rings = [np.asarray(ring, dtype=float) for ring in rings if len(ring) >= 3]
if not rings:
return mask, xs, ys
start = np.vstack(rings)
end = np.vstack([np.roll(ring, -1, axis=0) for ring in rings])
x0, y0, x1, y1 = start[:, 0], start[:, 1], end[:, 0], end[:, 1]
for i, y in enumerate(ys):
crossing = (y0 <= y) != (y1 <= y)
if not crossing.any():
continue
a, b = y0[crossing], y1[crossing]
at = x0[crossing] + (y - a) * (x1[crossing] - x0[crossing]) / (b - a)
mask[i] = np.searchsorted(np.sort(at), xs) % 2 == 1
return mask, xs, ys
def mask_depth(mask):
"""Pour chaque case du masque, sa distance au bord de la region, en
cases (1 sur le bord, 0 dehors) : nombre d'erosions qu'elle supporte."""
depth = np.zeros(mask.shape, dtype=int)
current = np.pad(np.asarray(mask, dtype=bool), 1)
while current.any():
depth += current[1:-1, 1:-1]
current[1:-1, 1:-1] = (current[1:-1, 1:-1] & current[:-2, 1:-1]
& current[2:, 1:-1] & current[1:-1, :-2]
& current[1:-1, 2:])
return depth
def mask_components(mask):
"""Parties d'un seul tenant du masque (voisinage par les cotes) :
tableau d'etiquettes, 0 hors du masque, puis 1, 2..., et leur nombre."""
mask = np.asarray(mask, dtype=bool)
if not mask.any():
return np.zeros(mask.shape, dtype=int), 0
# Chaque case part avec un numero a elle, puis le plus grand numero se
# propage le long des lignes et des colonnes, dans les deux sens, jusqu'a
# ce que plus rien ne bouge : toute une partie finit avec le meme numero.
big = mask.size + 1
labels = np.where(mask, np.arange(1, big, dtype=np.int64).reshape(mask.shape), 0)
while True:
before = labels
for axis in (1, 0):
for flip in (False, True):
view = np.flip(labels, axis) if flip else labels
inside = np.flip(mask, axis) if flip else mask
# Numero de troncon : le maximum cumule ne passe pas d'un
# troncon de cases au suivant.
run = np.cumsum(~inside, axis=axis) * big
view = np.where(inside, np.maximum.accumulate(view + run, axis=axis) - run, 0)
labels = np.flip(view, axis) if flip else view
if np.array_equal(labels, before):
break
_values, compact = np.unique(labels, return_inverse=True)
compact = compact.reshape(mask.shape)
if mask.all(): # pas de case a 0 : les etiquettes partent de 1
compact = compact + 1
return compact, int(compact.max())
def _number_height(number, radius, largest, framed):
"""Plus grande hauteur de chiffre, au plus `largest`, dont le numero
tient dans un disque de rayon `radius`."""
width, height = number_size(number, 1.0, framed)
return min(largest, 2.0 * radius / float(np.hypot(width, height)))
def board_labels(rings, following, number, width, height, largest, smallest,
step=None, hidden=False):
"""Emplacement des numeros a graver sur la planche `number`.
`rings` est le contour de la planche, `following` celui de la planche
suivante de la pile (ou `None`). Renvoie `(own, next)`, deux listes de
`(x, y, hauteur des chiffres)` en echantillons :
- `next` : le numero de la planche suivante, au creux de chaque zone ou
elle se pose ;
- `own` : le numero de la planche, un par piece d'un seul tenant, a
encadrer. Il se place de preference sous la planche suivante, a l'ecart
du numero de celle-ci, pour etre cache une fois la pile collee ; sinon
sur la partie qui reste visible. Avec `hidden`, une piece que la planche
suivante ne recouvre pas assez reste sans numero plutot que de le
montrer : rien ne se lit sur la pile collee.
Les chiffres mesurent au plus `largest` ; une piece ou une zone trop
etroite pour des chiffres de `smallest` ne recoit pas de numero.
"""
if step is None:
step = max(1.0, max(width, height) / 200.0)
region, xs, ys = region_mask(rings, width, height, step)
taken = np.zeros(region.shape, dtype=bool)
grid_y, grid_x = np.indices(region.shape)
def spots(mask, shown, framed):
"""Un numero par partie de `mask`, au plus profond de celle-ci."""
found = []
depth = mask_depth(mask)
labels, count = mask_components(mask)
for label in range(1, count + 1):
reach = np.where(labels == label, depth, 0)
i, j = np.unravel_index(int(np.argmax(reach)), reach.shape)
size = _number_height(shown, (reach[i, j] - 0.5) * step, largest, framed)
if size < smallest:
continue
found.append((float(xs[j]), float(ys[i]), size, label))
box = number_size(shown, size, framed)
radius = 0.6 * float(np.hypot(*box)) / step + 1.0
taken[(grid_x - j) ** 2 + (grid_y - i) ** 2 <= radius ** 2] = True
return found, labels
covered = np.zeros(region.shape, dtype=bool)
after = []
if following:
covered = region_mask(following, width, height, step)[0]
after = [spot[:3] for spot in spots(covered, number + 1, False)[0]]
pieces, _count = mask_components(region)
own, done = [], set()
# D'abord sous la planche suivante (numero cache), puis a decouvert.
masks = [region & covered & ~taken]
if not hidden:
masks.append(region & ~covered & ~taken)
for mask in masks:
found, _labels = spots(mask, number, True)
for x, y, size, _label in sorted(found, key=lambda spot: -spot[2]):
j = int(np.argmin(np.abs(xs - x)))
i = int(np.argmin(np.abs(ys - y)))
piece = pieces[i, j]
if piece and piece not in done:
done.add(piece)
own.append((x, y, size))
return own, after
def image_ring(width, height): def image_ring(width, height):

133
gray_iso_layers_depth.py Normal file
View File

@ -0,0 +1,133 @@
# coding=utf-8
"""
Carte de profondeur d'une image, pour l'extension « Gray Iso-Layers ».
Le Python d'Inkscape ne peut pas charger onnxruntime : le calcul est confie
au script `depth_estimate.py`, lance avec le Python d'un environnement a part
(cree par `deploy.ps1 -Depth`). Le resultat est garde sur disque, car
Inkscape relance l'extension a chaque « Appliquer » : seul le premier calcul
d'une image coute l'inference.
Dossier de donnees (hors du dossier des extensions, qu'Inkscape parcourt en
entier au demarrage) : `venv/`, `models/` et `cache/`.
Sans dependance a inkex. Dependances : numpy et Pillow (fournis avec Inkscape).
"""
import os
import subprocess
import sys
import tempfile
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
# Modeles proposes : nom (parametre depth_model du .inx) -> fichier de poids.
MODELS = {
"midas_small": "midas_v21_small_256.onnx",
"depth_anything_v2_small": "depth_anything_v2_small.onnx",
}
CACHE_LIMIT = 30 # cartes gardees sur disque
TIMEOUT = 120 # secondes laissees au calcul
class DepthUnavailable(Exception):
"""La carte de profondeur ne peut pas etre calculee (environnement ou
modele absent, echec du calcul). Le message en donne la cause."""
def data_dir():
"""Dossier de l'environnement Python, des modeles et du cache."""
if os.name == "nt":
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
else:
base = os.path.join(os.path.expanduser("~"), ".local", "share")
return os.path.join(base, "GrayIsoLayers")
def find_python():
"""Python qui execute `depth_estimate.py` : celui de la variable
GRAY_ISO_LAYERS_PYTHON, sinon celui de l'environnement du dossier de
donnees."""
chosen = os.environ.get("GRAY_ISO_LAYERS_PYTHON")
if chosen:
return chosen
if os.name == "nt":
return os.path.join(data_dir(), "venv", "Scripts", "python.exe")
return os.path.join(data_dir(), "venv", "bin", "python")
def depth_field(picture, digest, model="midas_small"):
"""Profondeur inverse de `picture` (image PIL RGB) : tableau 2D, plus la
valeur est haute, plus le point est proche. Sa taille est celle de la
sortie du modele, pas celle de l'image.
`digest` identifie l'image (empreinte de ses octets) : avec le nom du
modele, c'est la cle du cache. Leve `DepthUnavailable` si le calcul est
impossible.
"""
if model not in MODELS:
raise DepthUnavailable("unknown model '{}'".format(model))
cache = os.path.join(data_dir(), "cache")
target = os.path.join(cache, "{}-{}.npy".format(digest, model))
if os.path.isfile(target):
try:
depth = np.load(target, allow_pickle=False)
os.utime(target) # carte resservie : la derniere a etre purgee
return depth
except (OSError, ValueError):
pass # fichier abime : on recalcule
python = find_python()
script = os.path.join(HERE, "depth_estimate.py")
weights = os.path.join(data_dir(), "models", MODELS[model])
for path in (python, script, weights):
if not os.path.isfile(path):
raise DepthUnavailable("missing file: {}".format(path))
# Inkscape pointe PYTHONPATH et PYTHONHOME vers son propre Python : ils
# feraient charger ses modules par celui de l'environnement.
env = {key: value for key, value in os.environ.items()
if key.upper() not in ("PYTHONPATH", "PYTHONHOME")}
with tempfile.TemporaryDirectory() as folder:
source = os.path.join(folder, "image.png")
output = os.path.join(folder, "depth.npy")
picture.save(source)
try:
done = subprocess.run(
[python, script, source, output, weights, "--model", model],
env=env, capture_output=True, encoding="utf-8", errors="replace",
timeout=TIMEOUT,
# Pas de fenetre de console sous Windows.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except (OSError, subprocess.TimeoutExpired) as error:
raise DepthUnavailable(str(error))
if done.returncode != 0 or not os.path.isfile(output):
lines = (done.stderr or "").strip().splitlines()
raise DepthUnavailable(lines[-1] if lines else
"exit code {}".format(done.returncode))
try:
depth = np.load(output, allow_pickle=False)
except (OSError, ValueError) as error:
raise DepthUnavailable(str(error))
try:
os.makedirs(cache, exist_ok=True)
np.save(target, depth, allow_pickle=False)
_prune(cache)
except OSError as error:
# Le cache n'est qu'un confort : la carte est calculee, on la rend.
print("gray_iso_layers_depth: {}".format(error), file=sys.stderr)
return depth
def _prune(cache, limit=None):
"""Ne garde que les `limit` cartes les plus recemment servies."""
limit = CACHE_LIMIT if limit is None else limit
files = [os.path.join(cache, name) for name in os.listdir(cache)
if name.endswith(".npy")]
files.sort(key=os.path.getmtime, reverse=True)
for path in files[limit:]:
os.remove(path)

BIN
images/depth_en.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@ -21,6 +21,14 @@ msgstr "Levels"
msgid "Number of gray levels:" msgid "Number of gray levels:"
msgstr "Number of gray levels:" msgstr "Number of gray levels:"
#: gray_iso_layers.inx
msgid "Level distribution (% of equal areas):"
msgstr "Level distribution (% of equal areas):"
#: gray_iso_layers.inx
msgid "0 keeps evenly spaced thresholds. 100 spreads the levels so that every board covers the same area: use it when most of the image sits in a few grays, as in a depth map."
msgstr "0 keeps evenly spaced thresholds. 100 spreads the levels so that every board covers the same area: use it when most of the image sits in a few grays, as in a depth map."
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Content of each board:" msgid "Content of each board:"
msgstr "Content of each board:" msgstr "Content of each board:"
@ -34,8 +42,20 @@ msgid "Stacked, darkest in front (board no. 1 = lightest, whole image)"
msgstr "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 #: gray_iso_layers.inx
msgid "Its own gray only (boards side by side, no overlap)" msgid "Its own gray only (flat areas tiling the image, no overlap)"
msgstr "Its own gray only (boards side by side, no overlap)" msgstr "Its own gray only (flat areas tiling the image, no overlap)"
#: gray_iso_layers.inx
msgid "Position of the boards:"
msgstr "Position of the boards:"
#: gray_iso_layers.inx
msgid "On top of each other, at the place of the image"
msgstr "On top of each other, at the place of the image"
#: gray_iso_layers.inx
msgid "Side by side, in a grid to the right of the image"
msgstr "Side by side, in a grid to the right of the image"
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Mark on each board the outline of the next one (stacked boards)" msgid "Mark on each board the outline of the next one (stacked boards)"
@ -45,6 +65,14 @@ msgstr "Mark on each board the outline of the next one (stacked boards)"
msgid "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut." 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." 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 "Write the board numbers"
msgstr "Write the board numbers"
#: gray_iso_layers.inx
msgid "Drawn in the number color, to engrave. Framed number: the number of the board itself. Plain number: the board to glue in that marked area. On stacked boards both sit under the next board, so no number shows once the boards are glued, and the top board carries none."
msgstr "Drawn in the number color, to engrave. Framed number: the number of the board itself. Plain number: the board to glue in that marked area. On stacked boards both sit under the next board, so no number shows once the boards are glued, and the top board carries none."
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Smoothing (% of the image's longer side):" msgid "Smoothing (% of the image's longer side):"
msgstr "Smoothing (% of the image's longer side):" msgstr "Smoothing (% of the image's longer side):"
@ -85,6 +113,46 @@ msgstr "Largest deviation allowed when removing nodes. 0 keeps every node."
msgid "Smooth the outlines with Bézier curves" msgid "Smooth the outlines with Bézier curves"
msgstr "Smooth the outlines with Bézier curves" msgstr "Smooth the outlines with Bézier curves"
#: gray_iso_layers.inx
msgid "Depth"
msgstr "Depth"
#: gray_iso_layers.inx
msgid "1. Depth map share (%):"
msgstr "1. Depth map share (%):"
#: gray_iso_layers.inx
msgid "0 uses the grays of the image alone, 100 its estimated depth alone. In between, the depth gives the volumes and the grays keep details such as the eyes and the mouth. Needs the depth models, installed by deploy.ps1 -Depth."
msgstr "0 uses the grays of the image alone, 100 its estimated depth alone. In between, the depth gives the volumes and the grays keep details such as the eyes and the mouth. Needs the depth models, installed by deploy.ps1 -Depth."
#: gray_iso_layers.inx
msgid "2. Depth model:"
msgstr "2. Depth model:"
#: gray_iso_layers.inx
msgid "Fast (MiDaS small)"
msgstr "Fast (MiDaS small)"
#: gray_iso_layers.inx
msgid "Fine (Depth Anything V2 Small)"
msgstr "Fine (Depth Anything V2 Small)"
#: gray_iso_layers.inx
msgid "3. Fine details of the photo (%):"
msgstr "3. Fine details of the photo (%):"
#: gray_iso_layers.inx
msgid "Carves the fine contrasts of the photo (eyes, mouth, eyebrows, strands of hair) into the depth, which has none of them. A plain background brings nothing and stays at the back. 0 keeps the smooth depth alone."
msgstr "Carves the fine contrasts of the photo (eyes, mouth, eyebrows, strands of hair) into the depth, which has none of them. A plain background brings nothing and stays at the back. 0 keeps the smooth depth alone."
#: gray_iso_layers.inx
msgid "Each setting carries a number: the examples below show, under the same number, what it does for several values."
msgstr "Each setting carries a number: the examples below show, under the same number, what it does for several values."
#: gray_iso_layers.inx
msgid "The estimate runs on the graphics card when possible and is kept in a cache: only the first run on an image takes a few seconds."
msgstr "The estimate runs on the graphics card when possible and is kept in a cache: only the first run on an image takes a few seconds."
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@ -125,6 +193,10 @@ msgstr "Cut stroke color:"
msgid "Marking stroke color:" msgid "Marking stroke color:"
msgstr "Marking stroke color:" msgstr "Marking stroke color:"
#: gray_iso_layers.inx
msgid "Number stroke color:"
msgstr "Number stroke color:"
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Keep the original image" msgid "Keep the original image"
msgstr "Keep the original image" msgstr "Keep the original image"
@ -139,7 +211,9 @@ msgid ""
"\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" "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" "\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" "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 the board numbers, drawn in the number color (blue), each board carries its own number in a frame and each marked area carries, without a frame, the number of the board to glue there; both sit under the next board, so no number shows on the glued stack, and the top board carries none. With \"Its own gray only\", a board holds the flat area of its gray alone: the flat areas tile the image without overlapping and carry no marking. Several selected images share the same layers. With the boards side by side, each board is moved into its own cell of a grid to the right of the image, with its marking, so that every board can be seen and cut on its own.\n"
"\n"
"For a relief that follows the real volumes of a portrait rather than its light and shadows, raise the depth map share (Depth tab): at 100, the boards follow the volumes, and the fine details setting carves the eyes and the mouth into them. A depth map made elsewhere (8 or 16 bits) can also be imported and selected like any image, with a share of 0. In both cases, if most boards come out empty or the subject only gets a few of them, raise the level distribution.\n"
"\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." "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 "" msgstr ""
@ -147,7 +221,9 @@ msgstr ""
"\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" "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" "\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" "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 the board numbers, drawn in the number color (blue), each board carries its own number in a frame and each marked area carries, without a frame, the number of the board to glue there; both sit under the next board, so no number shows on the glued stack, and the top board carries none. With \"Its own gray only\", a board holds the flat area of its gray alone: the flat areas tile the image without overlapping and carry no marking. Several selected images share the same layers. With the boards side by side, each board is moved into its own cell of a grid to the right of the image, with its marking, so that every board can be seen and cut on its own.\n"
"\n"
"For a relief that follows the real volumes of a portrait rather than its light and shadows, raise the depth map share (Depth tab): at 100, the boards follow the volumes, and the fine details setting carves the eyes and the mouth into them. A depth map made elsewhere (8 or 16 bits) can also be imported and selected like any image, with a share of 0. In both cases, if most boards come out empty or the subject only gets a few of them, raise the level distribution.\n"
"\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." "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."
@ -178,3 +254,11 @@ msgstr "Cut"
#: gray_iso_layers.py #: gray_iso_layers.py
msgid "Marking of board no. {}" msgid "Marking of board no. {}"
msgstr "Marking of board no. {}" msgstr "Marking of board no. {}"
#: gray_iso_layers.py
msgid "Numbers"
msgstr "Numbers"
#: gray_iso_layers.py
msgid "No depth map for the image '{}' ({}). Install the depth models with 'deploy.ps1 -Depth'. The grays of the image are used alone."
msgstr "No depth map for the image '{}' ({}). Install the depth models with 'deploy.ps1 -Depth'. The grays of the image are used alone."

View File

@ -21,6 +21,14 @@ msgstr "Niveaux"
msgid "Number of gray levels:" msgid "Number of gray levels:"
msgstr "Nombre de niveaux de gris :" msgstr "Nombre de niveaux de gris :"
#: gray_iso_layers.inx
msgid "Level distribution (% of equal areas):"
msgstr "Répartition des niveaux (% d'aires égales) :"
#: gray_iso_layers.inx
msgid "0 keeps evenly spaced thresholds. 100 spreads the levels so that every board covers the same area: use it when most of the image sits in a few grays, as in a depth map."
msgstr "0 garde des seuils régulièrement espacés. 100 répartit les niveaux pour que chaque planche couvre la même surface : à utiliser quand presque toute l'image tient dans quelques gris, comme dans une carte de profondeur."
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Content of each board:" msgid "Content of each board:"
msgstr "Contenu de chaque planche :" msgstr "Contenu de chaque planche :"
@ -34,8 +42,20 @@ 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)" msgstr "Empilées, la plus sombre devant (planche n° 1 = la plus claire, toute l'image)"
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Its own gray only (boards side by side, no overlap)" msgid "Its own gray only (flat areas tiling the image, no overlap)"
msgstr "Son gris seul (planches côte à côte, sans recouvrement)" msgstr "Son gris seul (aplats qui pavent l'image, sans recouvrement)"
#: gray_iso_layers.inx
msgid "Position of the boards:"
msgstr "Position des planches :"
#: gray_iso_layers.inx
msgid "On top of each other, at the place of the image"
msgstr "Superposées, à la place de l'image"
#: gray_iso_layers.inx
msgid "Side by side, in a grid to the right of the image"
msgstr "Juxtaposées, en grille à droite de l'image"
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Mark on each board the outline of the next one (stacked boards)" msgid "Mark on each board the outline of the next one (stacked boards)"
@ -45,6 +65,14 @@ msgstr "Reporter sur chaque planche le contour de la suivante (planches empilée
msgid "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut." 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é." 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 "Write the board numbers"
msgstr "Écrire les numéros des planches"
#: gray_iso_layers.inx
msgid "Drawn in the number color, to engrave. Framed number: the number of the board itself. Plain number: the board to glue in that marked area. On stacked boards both sit under the next board, so no number shows once the boards are glued, and the top board carries none."
msgstr "Tracés dans la couleur des numéros, à graver. Numéro encadré : celui de la planche elle-même. Numéro seul : la planche à coller dans cette zone marquée. Sur des planches empilées, les deux se logent sous la planche suivante : aucun numéro ne se voit une fois les planches collées, et la planche du dessus n'en porte aucun."
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Smoothing (% of the image's longer side):" msgid "Smoothing (% of the image's longer side):"
msgstr "Lissage (% du grand côté de l'image) :" msgstr "Lissage (% du grand côté de l'image) :"
@ -85,6 +113,46 @@ msgstr "Écart maximal toléré lors de la suppression de nœuds. 0 conserve tou
msgid "Smooth the outlines with Bézier curves" msgid "Smooth the outlines with Bézier curves"
msgstr "Lisser les contours avec des courbes de Bézier" msgstr "Lisser les contours avec des courbes de Bézier"
#: gray_iso_layers.inx
msgid "Depth"
msgstr "Profondeur"
#: gray_iso_layers.inx
msgid "1. Depth map share (%):"
msgstr "1. Part de la carte de profondeur (%) :"
#: gray_iso_layers.inx
msgid "0 uses the grays of the image alone, 100 its estimated depth alone. In between, the depth gives the volumes and the grays keep details such as the eyes and the mouth. Needs the depth models, installed by deploy.ps1 -Depth."
msgstr "0 utilise les gris de l'image seuls, 100 sa profondeur estimée seule. Entre les deux, la profondeur donne les volumes et les gris gardent les détails comme les yeux et la bouche. Nécessite les modèles de profondeur, installés par deploy.ps1 -Depth."
#: gray_iso_layers.inx
msgid "2. Depth model:"
msgstr "2. Modèle de profondeur :"
#: gray_iso_layers.inx
msgid "Fast (MiDaS small)"
msgstr "Rapide (MiDaS small)"
#: gray_iso_layers.inx
msgid "Fine (Depth Anything V2 Small)"
msgstr "Fin (Depth Anything V2 Small)"
#: gray_iso_layers.inx
msgid "3. Fine details of the photo (%):"
msgstr "3. Détails fins de la photo (%) :"
#: gray_iso_layers.inx
msgid "Carves the fine contrasts of the photo (eyes, mouth, eyebrows, strands of hair) into the depth, which has none of them. A plain background brings nothing and stays at the back. 0 keeps the smooth depth alone."
msgstr "Grave dans la profondeur les contrastes fins de la photo (yeux, bouche, sourcils, mèches de cheveux), qu'elle ne contient pas. Un fond uni n'apporte rien et reste au fond. 0 garde la profondeur lisse seule."
#: gray_iso_layers.inx
msgid "Each setting carries a number: the examples below show, under the same number, what it does for several values."
msgstr "Chaque réglage porte un numéro : les exemples ci-dessous montrent, sous le même numéro, ce qu'il produit pour plusieurs valeurs."
#: gray_iso_layers.inx
msgid "The estimate runs on the graphics card when possible and is kept in a cache: only the first run on an image takes a few seconds."
msgstr "L'estimation se fait sur la carte graphique quand c'est possible et elle est gardée en cache : seul le premier calcul d'une image prend quelques secondes."
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@ -125,6 +193,10 @@ msgstr "Couleur du trait de découpe :"
msgid "Marking stroke color:" msgid "Marking stroke color:"
msgstr "Couleur du trait de marquage :" msgstr "Couleur du trait de marquage :"
#: gray_iso_layers.inx
msgid "Number stroke color:"
msgstr "Couleur du trait des numéros :"
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Keep the original image" msgid "Keep the original image"
msgstr "Conserver l'image d'origine" msgstr "Conserver l'image d'origine"
@ -139,7 +211,9 @@ msgid ""
"\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" "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" "\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" "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 the board numbers, drawn in the number color (blue), each board carries its own number in a frame and each marked area carries, without a frame, the number of the board to glue there; both sit under the next board, so no number shows on the glued stack, and the top board carries none. With \"Its own gray only\", a board holds the flat area of its gray alone: the flat areas tile the image without overlapping and carry no marking. Several selected images share the same layers. With the boards side by side, each board is moved into its own cell of a grid to the right of the image, with its marking, so that every board can be seen and cut on its own.\n"
"\n"
"For a relief that follows the real volumes of a portrait rather than its light and shadows, raise the depth map share (Depth tab): at 100, the boards follow the volumes, and the fine details setting carves the eyes and the mouth into them. A depth map made elsewhere (8 or 16 bits) can also be imported and selected like any image, with a share of 0. In both cases, if most boards come out empty or the subject only gets a few of them, raise the level distribution.\n"
"\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." "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 "" msgstr ""
@ -147,7 +221,9 @@ msgstr ""
"\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" "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" "\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" "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 les numéros des planches, tracés dans la couleur des numéros (bleu), chaque planche porte son propre numéro dans un cadre et chaque zone marquée porte, sans cadre, le numéro de la planche à y coller ; les deux se logent sous la planche suivante : aucun numéro ne se voit sur la pile collée, et la planche du dessus n'en porte aucun. Avec « Son gris seul », une planche contient uniquement l'aplat de son gris : les aplats pavent l'image sans se recouvrir et ne portent aucun marquage. Plusieurs images sélectionnées partagent les mêmes calques. Avec des planches juxtaposées, chaque planche est déplacée dans sa propre case d'une grille à droite de l'image, avec son marquage, pour que chacune puisse être vue et découpée seule.\n"
"\n"
"Pour un relief qui suit les vrais volumes d'un portrait plutôt que ses lumières et ses ombres, augmentez la part de la carte de profondeur (onglet Profondeur) : à 100, les planches suivent les volumes, et le réglage des détails fins y grave les yeux et la bouche. Une carte de profondeur produite ailleurs (8 ou 16 bits) peut aussi être importée et sélectionnée comme n'importe quelle image, avec une part de 0. Dans les deux cas, si la plupart des planches sortent vides ou si le sujet n'en reçoit que quelques-unes, augmentez la répartition des niveaux.\n"
"\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é." "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é."
@ -178,3 +254,11 @@ msgstr "Découpe"
#: gray_iso_layers.py #: gray_iso_layers.py
msgid "Marking of board no. {}" msgid "Marking of board no. {}"
msgstr "Repère de la planche n° {}" msgstr "Repère de la planche n° {}"
#: gray_iso_layers.py
msgid "Numbers"
msgstr "Numéros"
#: gray_iso_layers.py
msgid "No depth map for the image '{}' ({}). Install the depth models with 'deploy.ps1 -Depth'. The grays of the image are used alone."
msgstr "Pas de carte de profondeur pour l'image « {} » ({}). Installez les modèles de profondeur avec « deploy.ps1 -Depth ». Les gris de l'image sont utilisés seuls."

View File

@ -19,6 +19,14 @@ msgstr ""
msgid "Number of gray levels:" msgid "Number of gray levels:"
msgstr "" msgstr ""
#: gray_iso_layers.inx
msgid "Level distribution (% of equal areas):"
msgstr ""
#: gray_iso_layers.inx
msgid "0 keeps evenly spaced thresholds. 100 spreads the levels so that every board covers the same area: use it when most of the image sits in a few grays, as in a depth map."
msgstr ""
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Content of each board:" msgid "Content of each board:"
msgstr "" msgstr ""
@ -32,7 +40,19 @@ msgid "Stacked, darkest in front (board no. 1 = lightest, whole image)"
msgstr "" msgstr ""
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Its own gray only (boards side by side, no overlap)" msgid "Its own gray only (flat areas tiling the image, no overlap)"
msgstr ""
#: gray_iso_layers.inx
msgid "Position of the boards:"
msgstr ""
#: gray_iso_layers.inx
msgid "On top of each other, at the place of the image"
msgstr ""
#: gray_iso_layers.inx
msgid "Side by side, in a grid to the right of the image"
msgstr "" msgstr ""
#: gray_iso_layers.inx #: gray_iso_layers.inx
@ -43,6 +63,14 @@ msgstr ""
msgid "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut." msgid "Assembly guide: the outline of the board to glue on top is drawn in the marking color, without being cut."
msgstr "" msgstr ""
#: gray_iso_layers.inx
msgid "Write the board numbers"
msgstr ""
#: gray_iso_layers.inx
msgid "Drawn in the number color, to engrave. Framed number: the number of the board itself. Plain number: the board to glue in that marked area. On stacked boards both sit under the next board, so no number shows once the boards are glued, and the top board carries none."
msgstr ""
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Smoothing (% of the image's longer side):" msgid "Smoothing (% of the image's longer side):"
msgstr "" msgstr ""
@ -83,6 +111,46 @@ msgstr ""
msgid "Smooth the outlines with Bézier curves" msgid "Smooth the outlines with Bézier curves"
msgstr "" msgstr ""
#: gray_iso_layers.inx
msgid "Depth"
msgstr ""
#: gray_iso_layers.inx
msgid "1. Depth map share (%):"
msgstr ""
#: gray_iso_layers.inx
msgid "0 uses the grays of the image alone, 100 its estimated depth alone. In between, the depth gives the volumes and the grays keep details such as the eyes and the mouth. Needs the depth models, installed by deploy.ps1 -Depth."
msgstr ""
#: gray_iso_layers.inx
msgid "2. Depth model:"
msgstr ""
#: gray_iso_layers.inx
msgid "Fast (MiDaS small)"
msgstr ""
#: gray_iso_layers.inx
msgid "Fine (Depth Anything V2 Small)"
msgstr ""
#: gray_iso_layers.inx
msgid "3. Fine details of the photo (%):"
msgstr ""
#: gray_iso_layers.inx
msgid "Carves the fine contrasts of the photo (eyes, mouth, eyebrows, strands of hair) into the depth, which has none of them. A plain background brings nothing and stays at the back. 0 keeps the smooth depth alone."
msgstr ""
#: gray_iso_layers.inx
msgid "Each setting carries a number: the examples below show, under the same number, what it does for several values."
msgstr ""
#: gray_iso_layers.inx
msgid "The estimate runs on the graphics card when possible and is kept in a cache: only the first run on an image takes a few seconds."
msgstr ""
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Style" msgid "Style"
msgstr "" msgstr ""
@ -123,6 +191,10 @@ msgstr ""
msgid "Marking stroke color:" msgid "Marking stroke color:"
msgstr "" msgstr ""
#: gray_iso_layers.inx
msgid "Number stroke color:"
msgstr ""
#: gray_iso_layers.inx #: gray_iso_layers.inx
msgid "Keep the original image" msgid "Keep the original image"
msgstr "" msgstr ""
@ -137,7 +209,9 @@ msgid ""
"\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" "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" "\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" "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 the board numbers, drawn in the number color (blue), each board carries its own number in a frame and each marked area carries, without a frame, the number of the board to glue there; both sit under the next board, so no number shows on the glued stack, and the top board carries none. With \"Its own gray only\", a board holds the flat area of its gray alone: the flat areas tile the image without overlapping and carry no marking. Several selected images share the same layers. With the boards side by side, each board is moved into its own cell of a grid to the right of the image, with its marking, so that every board can be seen and cut on its own.\n"
"\n"
"For a relief that follows the real volumes of a portrait rather than its light and shadows, raise the depth map share (Depth tab): at 100, the boards follow the volumes, and the fine details setting carves the eyes and the mouth into them. A depth map made elsewhere (8 or 16 bits) can also be imported and selected like any image, with a share of 0. In both cases, if most boards come out empty or the subject only gets a few of them, raise the level distribution.\n"
"\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." "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 "" msgstr ""
@ -169,3 +243,11 @@ msgstr ""
#: gray_iso_layers.py #: gray_iso_layers.py
msgid "Marking of board no. {}" msgid "Marking of board no. {}"
msgstr "" msgstr ""
#: gray_iso_layers.py
msgid "Numbers"
msgstr ""
#: gray_iso_layers.py
msgid "No depth map for the image '{}' ({}). Install the depth models with 'deploy.ps1 -Depth'. The grays of the image are used alone."
msgstr ""

View File

@ -11,11 +11,14 @@ import xml.etree.ElementTree as ET
import numpy as np import numpy as np
import pytest import pytest
import depth_estimate
import gray_iso_layers_depth
from gray_iso_layers_core import ( from gray_iso_layers_core import (
box_blur, contour_lines, contour_rings, edge_preserving_blur, gaussian_blur, add_detail, blend_fields, board_labels, box_blur, contour_lines, contour_rings, edge_preserving_blur,
gray_to_hex, image_ring, iso_boards, iso_layers, lines_to_d, normalize, gaussian_blur, gray_to_hex, guided_filter, image_ring, iso_boards, iso_layers,
parse_color, ring_area, rings_to_d, scale_lines, scale_rings, simplify_chain, lines_to_d, mask_components, mask_depth, normalize, number_lines, number_size,
simplify_ring, smooth, thresholds) parse_color, region_mask, ring_area, rings_to_d, scale_lines,
scale_rings, simplify_chain, simplify_ring, smooth, spread_levels, thresholds)
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@ -171,6 +174,80 @@ def test_iso_layers_preserve_edges_keeps_a_thin_feature():
assert dark[0][:, 1].max() - dark[0][:, 1].min() <= 7 assert dark[0][:, 1].max() - dark[0][:, 1].min() <= 7
def test_spread_levels_gives_equal_areas():
# Rampe : les valeurs sont deja reparties, rien ne bouge.
assert np.allclose(spread_levels(ramp(200, 20)), ramp(200, 20), atol=0.01)
# Champ tasse vers le noir (carte de profondeur : fond etendu, sujet
# resserre) : a seuils reguliers, le premier niveau prend presque tout.
packed = ramp(200, 20) ** 4
assert np.array_equal(spread_levels(packed, 0), packed)
assert (packed < 0.25).mean() > 0.7
spread = spread_levels(packed)
assert spread.min() >= 0.0 and spread.max() == 1.0
for cut in (0.25, 0.5, 0.75):
assert math.isclose((spread < cut).mean(), cut, abs_tol=0.01)
# Dosage : a mi-chemin entre le champ et sa repartition.
assert np.allclose(spread_levels(packed, 0.5), (packed + spread) / 2.0)
assert not spread_levels(np.full((5, 5), 0.3)).any()
def test_iso_layers_equalize_fills_every_level():
packed = ramp(200, 20) ** 4
width = [evenodd_area(rings) / 19.0
for _level, _gray, rings in iso_layers(packed, levels=4)]
assert width[0] > 140 and width[3] < 15
width = [evenodd_area(rings) / 19.0
for _level, _gray, rings in iso_layers(packed, levels=4, equalize=1.0)]
assert np.allclose(width, 199 / 4.0, atol=2.0)
def test_guided_filter_snaps_a_blurred_edge_to_the_guide():
step = np.where(np.arange(80) < 40, 0.25, 0.75) * np.ones((60, 1))
blurred = gaussian_blur(step, 2.0)
def width(result):
"""Largeur de la transition entre les deux aplats, en echantillons."""
row = result.mean(axis=0)
return int(np.count_nonzero((row > 0.3) & (row < 0.7)))
# Le bord se recale si la fenetre est nettement plus large que le flou.
assert width(blurred) >= 6
assert width(guided_filter(step, blurred, 12)) <= 2
# Source uniforme : rien a recaler. Guide uniforme : simple lissage.
assert np.allclose(guided_filter(step, np.full(step.shape, 0.4), 8), 0.4)
assert np.allclose(guided_filter(np.zeros(step.shape), blurred, 3),
box_blur(box_blur(blurred, 3), 3))
assert np.array_equal(guided_filter(step, blurred, 0), blurred)
def test_add_detail_carves_fine_contrasts_only():
depth = ramp(80, 80)
# Fond uni, clair ou sombre : rien a graver, la profondeur ne bouge pas.
for level in (0.1, 0.9):
assert np.allclose(add_detail(depth, np.full((80, 80), level), 1.0, 4.0), depth)
# Trait sombre fin sur fond clair (une bouche) : il se creuse, et loin de
# lui la profondeur est intacte.
gray = np.full((80, 80), 0.8)
gray[38:42, 20:60] = 0.1
carved = add_detail(depth, gray, 0.5, 4.0)
assert (carved[39:41, 30:50] < depth[39:41, 30:50] - 0.2).all()
assert np.allclose(carved[:15], depth[:15], atol=1e-3)
assert carved.min() >= 0.0 and carved.max() <= 1.0
# Dosage : sans detail, la profondeur seule, etiree sur 0..1.
assert np.allclose(add_detail(depth * 3.0, gray, 0, 4.0), depth)
lighter = add_detail(depth, gray, 0.25, 4.0)
assert (lighter[39:41, 30:50] > carved[39:41, 30:50]).all()
def test_blend_fields():
gray, depth = ramp(), ramp().T[:40, :40].repeat(2, axis=1)[:, :60] * 3.0
assert np.array_equal(blend_fields(gray, depth, 0), normalize(gray))
assert np.array_equal(blend_fields(gray, depth, 1), normalize(depth))
assert np.array_equal(blend_fields(gray, depth, 7), normalize(depth))
assert np.allclose(blend_fields(gray, depth, 0.25),
0.75 * normalize(gray) + 0.25 * normalize(depth))
def test_contour_of_cone_is_a_circle(): def test_contour_of_cone_is_a_circle():
# Seuil 0.5 sur un cone de rayon 40 : cercle de rayon 20. # Seuil 0.5 sur un cone de rayon 40 : cercle de rayon 20.
rings = contour_rings(cone(), 0.5) rings = contour_rings(cone(), 0.5)
@ -501,6 +578,208 @@ def test_rings_to_d_keeps_the_image_border_straight():
assert "L " not in d and d.count("C ") == len(circle) assert "L " not in d and d.count("C ") == len(circle)
def test_number_lines_draw_digits_as_segments():
closed, opened = number_lines(8, 50, 20, 10)
assert closed == [] and len(opened) == 7
points = np.vstack(opened)
# Chiffre de 5 x 10, centre en (50, 20).
assert points.min(axis=0).tolist() == [47.5, 15]
assert points.max(axis=0).tolist() == [52.5, 25]
assert [len(number_lines(n, 0, 0, 1)[1]) for n in (1, 7, 4, 0)] == [2, 3, 4, 6]
# Deux chiffres, encadres : un cadre ferme autour du tout.
closed, opened = number_lines(12, 0, 0, 10, framed=True)
assert len(opened) == 2 + 5 and len(closed) == 1
width, height = number_size(12, 10, True)
assert (width, height) == (13 + 7, 10 + 7)
assert np.allclose(closed[0].max(axis=0), (width / 2, height / 2))
assert np.abs(np.vstack(opened)).max(axis=0).tolist() == [6.5, 5]
def test_region_mask_depth_and_components():
square = np.array([(10, 10), (30, 10), (30, 30), (10, 30)], dtype=float)
hole = np.array([(18, 18), (22, 18), (22, 22), (18, 22)], dtype=float)
far = np.array([(40, 5), (48, 5), (48, 9), (40, 9)], dtype=float)
mask, xs, ys = region_mask([square, hole, far], 61, 41, 1.0)
assert mask.shape == (len(ys), len(xs)) == (40, 60)
assert mask.sum() == 20 * 20 - 4 * 4 + 8 * 4
assert mask[15, 15] and not mask[19, 19] and mask[6, 43] and not mask[0, 0]
labels, count = mask_components(mask)
assert count == 2 and labels[15, 15] != labels[6, 43] and labels[0, 0] == 0
depth = mask_depth(mask)
assert depth[0, 0] == 0 and depth[10, 10] == 1 and depth[6, 43] == 2
# Troisieme case depuis le bord gauche du carre.
assert depth[15, 12] == 3
assert not region_mask([], 61, 41, 1.0)[0].any()
def test_board_labels_number_each_piece_and_each_marked_area():
boards = iso_boards(cone(), levels=4, shapes="light")
# Planche n° 1 (toute l'image) : le numero 2 au creux du disque ou se pose
# la planche n° 2, et son propre numero cache sous elle, a l'ecart.
own, after = board_labels(boards[0][2], boards[1][2], 1, 101, 101, 6.0, 2.0)
assert len(own) == 1 and len(after) == 1
(x, y, size), (nx, ny, nsize) = own[0], after[0]
assert math.hypot(nx - 50, ny - 50) < 2 and nsize == 6.0
assert contains(boards[1][2], x, y) and size >= 2.0
assert math.hypot(x - nx, y - ny) > 6.0
# Numeros caches seulement : une planche trop peu recouverte pour loger
# son numero sous la suivante reste sans numero, plutot que de le montrer.
small = [np.array([(48, 48), (52, 48), (52, 52), (48, 52)], dtype=float)]
own, after = board_labels(boards[0][2], small, 1, 101, 101, 6.0, 2.0)
assert len(own) == 1 and not contains(small, own[0][0], own[0][1])
assert board_labels(boards[0][2], small, 1, 101, 101, 6.0, 2.0,
hidden=True)[0] == []
own, _after = board_labels(boards[0][2], boards[1][2], 1, 101, 101, 6.0, 2.0,
hidden=True)
assert len(own) == 1 and contains(boards[1][2], own[0][0], own[0][1])
# Planche sans suivante (aplats "band") : son numero reste visible.
own, after = board_labels(boards[3][2], None, 4, 101, 101, 6.0, 2.0)
assert after == [] and len(own) == 1
assert contains(boards[3][2], own[0][0], own[0][1])
# Piece trop etroite pour un numero : pas de numero.
assert board_labels(boards[3][2], None, 4, 101, 101, 30.0, 25.0) == ([], [])
# Deux pieces separees : un numero chacune.
left = np.array([(5, 5), (40, 5), (40, 40), (5, 40)], dtype=float)
right = left + (50, 50)
own, _after = board_labels([left, right], None, 3, 101, 101, 6.0, 2.0)
assert sorted(round(x) // 50 for x, _y, _size in own) == [0, 1]
# --------------------------------------------------------------------------
# Carte de profondeur (sans inkex ni onnxruntime : le calcul est simule)
# --------------------------------------------------------------------------
@pytest.fixture
def depth_home(tmp_path, monkeypatch):
"""Dossier de donnees factice, avec son Python et ses modeles, et un
calcul simule qui note ses appels."""
pytest.importorskip("PIL.Image")
home = tmp_path / "GrayIsoLayers"
(home / "models").mkdir(parents=True)
for name in gray_iso_layers_depth.MODELS.values():
(home / "models" / name).write_bytes(b"onnx")
monkeypatch.setattr(gray_iso_layers_depth, "data_dir", lambda: str(home))
monkeypatch.delenv("GRAY_ISO_LAYERS_PYTHON", raising=False)
python = gray_iso_layers_depth.find_python()
os.makedirs(os.path.dirname(python))
with open(python, "wb"):
pass
calls = []
def run(command, env=None, **_options):
calls.append((command, env))
np.save(command[3], np.full((4, 6), float(len(calls)), dtype=np.float32))
return type("Done", (), {"returncode": 0, "stderr": ""})()
monkeypatch.setattr(gray_iso_layers_depth.subprocess, "run", run)
return home, calls
def photo():
from PIL import Image
return Image.new("RGB", (12, 8), (200, 120, 40))
def test_depth_field_runs_the_helper_once_then_reads_the_cache(depth_home, monkeypatch):
home, calls = depth_home
monkeypatch.setenv("PYTHONPATH", "inkscape")
monkeypatch.setenv("PYTHONHOME", "inkscape")
depth = gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
assert depth.shape == (4, 6) and (depth == 1).all()
command, env = calls[0]
assert command[0] == gray_iso_layers_depth.find_python()
assert command[1].endswith("depth_estimate.py") and os.path.isfile(command[1])
assert command[4].endswith("midas_v21_small_256.onnx")
assert command[5:] == ["--model", "midas_small"]
# Les variables du Python d'Inkscape ne passent pas a l'autre Python.
assert "PYTHONPATH" not in env and "PYTHONHOME" not in env and "PATH" in env
# Meme image, meme modele : le cache repond. Autre modele ou autre
# image : nouveau calcul.
assert (gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small") == 1).all()
assert len(calls) == 1
gray_iso_layers_depth.depth_field(photo(), "abc", "depth_anything_v2_small")
gray_iso_layers_depth.depth_field(photo(), "def", "midas_small")
assert len(calls) == 3
assert len(os.listdir(str(home / "cache"))) == 3
def test_depth_field_prunes_the_cache(depth_home, monkeypatch):
home, _calls = depth_home
monkeypatch.setattr(gray_iso_layers_depth, "CACHE_LIMIT", 2)
for k, digest in enumerate(("a", "b", "c")):
gray_iso_layers_depth.depth_field(photo(), digest, "midas_small")
os.utime(str(home / "cache" / (digest + "-midas_small.npy")), (k, k))
gray_iso_layers_depth._prune(str(home / "cache"))
assert sorted(os.listdir(str(home / "cache"))) == [
"b-midas_small.npy", "c-midas_small.npy"]
def test_depth_field_reports_what_is_missing(depth_home, monkeypatch):
home, calls = depth_home
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="unknown model"):
gray_iso_layers_depth.depth_field(photo(), "abc", "autre")
# Calcul en echec : la derniere ligne de son erreur est rapportee.
monkeypatch.setattr(
gray_iso_layers_depth.subprocess, "run",
lambda *_a, **_k: type("Done", (), {"returncode": 1,
"stderr": "Traceback\nboum\n"})())
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="^boum$"):
gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
def too_long(*_args, **_options):
raise gray_iso_layers_depth.subprocess.TimeoutExpired("python", 120)
monkeypatch.setattr(gray_iso_layers_depth.subprocess, "run", too_long)
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="timed out"):
gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
# Modele absent, puis environnement Python absent.
os.remove(str(home / "models" / "midas_v21_small_256.onnx"))
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="midas_v21"):
gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
os.remove(gray_iso_layers_depth.find_python())
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="python"):
gray_iso_layers_depth.depth_field(photo(), "abc", "depth_anything_v2_small")
assert calls == [] and not (home / "cache").exists()
def test_find_python_honours_the_environment_variable(monkeypatch):
monkeypatch.setenv("GRAY_ISO_LAYERS_PYTHON", "ailleurs/python")
assert gray_iso_layers_depth.find_python() == "ailleurs/python"
monkeypatch.delenv("GRAY_ISO_LAYERS_PYTHON")
assert gray_iso_layers_depth.find_python().startswith(
gray_iso_layers_depth.data_dir())
def test_depth_estimate_preprocess_and_providers():
pytest.importorskip("PIL.Image")
assert sorted(depth_estimate.MODELS) == sorted(gray_iso_layers_depth.MODELS)
for size in depth_estimate.MODELS.values():
tensor = depth_estimate.preprocess(photo(), size)
assert tensor.shape == (1, 3, size, size) and tensor.dtype == np.float32
# Normalisation ImageNet du rouge : (200 / 255 - 0.485) / 0.229.
assert math.isclose(tensor[0, 0, 0, 0], (200 / 255.0 - 0.485) / 0.229, rel_tol=1e-4)
gpu, cpu = "DmlExecutionProvider", "CPUExecutionProvider"
assert depth_estimate.choose_providers([gpu, cpu]) == [gpu, cpu]
assert depth_estimate.choose_providers([gpu, cpu], cpu=True) == [cpu]
assert depth_estimate.choose_providers([cpu]) == [cpu]
def test_depth_estimate_real_model(tmp_path):
"""Calcul reel, quand onnxruntime et le modele sont installes."""
pytest.importorskip("onnxruntime")
pytest.importorskip("PIL.Image")
weights = os.path.join(gray_iso_layers_depth.data_dir(), "models",
gray_iso_layers_depth.MODELS["midas_small"])
if not os.path.isfile(weights):
pytest.skip("modele absent : deploy.ps1 -Depth")
source, output = str(tmp_path / "photo.png"), str(tmp_path / "depth.npy")
photo().save(source)
assert depth_estimate.main([source, output, weights, "--cpu"]) == 0
depth = np.load(output)
assert depth.shape == (256, 256) and np.isfinite(depth).all()
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Bout en bout (necessite inkex et Pillow) # Bout en bout (necessite inkex et Pillow)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -522,10 +801,13 @@ SVG = """<?xml version="1.0" encoding="UTF-8"?>
""" """
def make_document(tmp_path, linked=False, transform=""): def make_document(tmp_path, linked=False, transform="", bits=8):
"""Document de test : une image en cone, incorporee ou liee.""" """Document de test : une image en cone, incorporee ou liee."""
image_module = pytest.importorskip("PIL.Image") image_module = pytest.importorskip("PIL.Image")
picture = image_module.fromarray((cone(64, 28.0) * 255).astype("uint8"), "L") if bits == 16:
picture = image_module.fromarray((cone(64, 28.0) * 65535).astype("uint16"))
else:
picture = image_module.fromarray((cone(64, 28.0) * 255).astype("uint8"))
if linked: if linked:
picture.save(str(tmp_path / "cone.png")) picture.save(str(tmp_path / "cone.png"))
href = "cone.png" href = "cone.png"
@ -543,7 +825,9 @@ def run_extension(tmp_path, *args, linked=False, document=None, transform=""):
from gray_iso_layers import GrayIsoLayers from gray_iso_layers import GrayIsoLayers
document = document or make_document(tmp_path, linked, transform) document = document or make_document(tmp_path, linked, transform)
out = tmp_path / "out.svg" out = tmp_path / "out.svg"
GrayIsoLayers().run([*args, "--output={}".format(out), document]) # Sans les numeros, sauf demande : les tests comptent les chemins.
GrayIsoLayers().run(["--numbers=false", *args, "--output={}".format(out),
document])
# inkex n'ecrit rien quand le document n'a pas change. # inkex n'ecrit rien quand le document n'a pas change.
return out.read_text(encoding="utf-8") if out.exists() else "" return out.read_text(encoding="utf-8") if out.exists() else ""
@ -604,6 +888,67 @@ def test_end_to_end_default(tmp_path):
assert svg.count("transform=") == 1 # celle du calque d'origine assert svg.count("transform=") == 1 # celle du calque d'origine
def test_end_to_end_boards_side_by_side(tmp_path):
# Image de 100 x 100 en (15, 25) : cases de 105 (ecart de 5 %), 4 planches
# sur 2 colonnes, la premiere case juste a droite de l'image.
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--layout=side")
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 ' d="M 120.000,25.000 L 220.000,25.000 L 220.000,125.000 L 120.000,125.000 Z"' in cuts[0]
def cell(path):
"""Case de la grille (colonne, ligne) ou tombent tous les points."""
points = re.findall(r"(-?[\d.]+),(-?[\d.]+)", path)
cells = {(int((float(x) - 15) // 105), int((float(y) - 25) // 105))
for x, y in points}
assert len(cells) == 1, cells
return cells.pop()
assert [cell(path) for path in cuts] == [(1, 0), (2, 0), (1, 1), (2, 1)]
# Le repere suit sa planche : c'est le trace de la suivante, ramene
# dans la case de celle-ci.
assert [cell(path) for path in marks] == [(1, 0), (2, 0), (1, 1)]
stacked = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0")
assert paths(stacked)[0] != found[0]
assert svg.count("transform=") == 1 # celle du calque d'origine
# Image tournee : la grille part de son encombrement dans le document.
svg = run_extension(tmp_path, "--id=img1", "--levels=2", "--blur=0",
"--layout=side", "--mark=false", transform="rotate(90)")
assert ' d="M 110.000,25.000 L 110.000,125.000 L 10.000,125.000 L 10.000,25.000 Z"' in paths(svg)[0]
def test_end_to_end_numbers(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--numbers=true")
# Un chemin de numeros par planche, en plus de la decoupe et du repere,
# sauf sur la planche du dessus : rien ne viendrait cacher son numero.
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 3), ("Board no. 2", 3),
("Board no. 3", 3), ("Board no. 4", 1), ("Dessus", 0)]
numbered = [path for path in paths(svg) if 'label="Numbers"' in path]
assert len(numbered) == 3
# Une troisieme couleur, ni celle de la decoupe ni celle du repere.
for path in numbered:
assert "fill:none" in path and "stroke:#0000ff" in path and "C " not in path
# Planche n° 1 : son numero encadre (un cadre ferme, 2 segments) et le
# numero 2 (5 segments), tous deux sous la planche n° 2.
assert numbered[0].count("Z") == 1 and numbered[0].count("M ") == 1 + 2 + 5
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--numbers=true", "--number_color={}".format(0x00AA00FF))
assert sum("stroke:#00aa00" in path for path in paths(svg)) == 3
# Aplats seuls : rien ne s'empile, chaque piece garde son numero visible.
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--numbers=true", "--shapes=band")
assert sum('label="Numbers"' in path for path in paths(svg)) == 4
# Sans trait, rien a graver.
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--numbers=true", "--stroke_width=0")
assert 'label="Numbers"' not in svg
def test_end_to_end_marking_options(tmp_path): def test_end_to_end_marking_options(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0", svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--mark=false") "--mark=false")
@ -704,6 +1049,75 @@ def test_end_to_end_plain_blur(tmp_path):
assert paths(kept) != paths(plain) assert paths(kept) != paths(plain)
def test_end_to_end_16_bit_image(tmp_path):
# Carte de profondeur en 16 bits : memes planches que la meme image en
# 8 bits, la ou une conversion directe l'ecreterait en image blanche.
args = ("--id=img1", "--levels=4", "--blur=0", "--shapes=band")
deep = run_extension(tmp_path, *args, document=make_document(tmp_path, bits=16))
plain = run_extension(tmp_path, *args)
assert [path.count("M ") for path in paths(deep)] == [2, 2, 2, 1]
def extent(path):
"""Etendue en x des points du chemin."""
xs = [float(x) for x, _y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", path)]
return min(xs), max(xs)
for a, b in zip(paths(deep), paths(plain)):
assert np.allclose(extent(a), extent(b), atol=0.5)
def test_end_to_end_equalize(tmp_path):
# Cone : les disques a seuils reguliers ont des rayons reguliers ; a
# aires egales, le niveau le plus clair s'etend.
def top_width(*extra):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--shapes=band", *extra)
xs = [float(x) for x, _y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", paths(svg)[3])]
return max(xs) - min(xs)
assert top_width("--equalize=100") > 1.5 * top_width()
def test_end_to_end_depth(tmp_path, monkeypatch, capsys):
pytest.importorskip("inkex")
import gray_iso_layers
calls = []
def fake(picture, digest, model):
calls.append((picture.mode, picture.size, len(digest), model))
return ramp(32, 32) # profondeur : proche a droite
monkeypatch.setattr(gray_iso_layers, "depth_field", fake)
args = ("--id=img1", "--levels=4", "--blur=0", "--shapes=band")
cone_only = run_extension(tmp_path, *args)
assert calls == [] # part de profondeur nulle : aucun calcul
assert all("L " not in path for path in paths(cone_only)[1:])
svg = run_extension(tmp_path, *args, "--depth_mix=100", "--depth_detail=0",
"--depth_model=depth_anything_v2_small")
assert calls == [("RGB", (64, 64), 40, "depth_anything_v2_small")]
# Profondeur seule : quatre bandes verticales, bordees par l'image.
assert [path.count("M ") for path in paths(svg)] == [1, 1, 1, 1]
assert all("L " in path for path in paths(svg))
# Melange : ni le cone seul, ni la rampe seule.
mixed = run_extension(tmp_path, *args, "--depth_mix=50", "--depth_detail=0")
assert paths(mixed) != paths(svg) and paths(mixed) != paths(cone_only)
# Details fins : les contrastes du cone se gravent dans la rampe.
carved = run_extension(tmp_path, *args, "--depth_mix=100")
assert paths(carved) != paths(svg) and paths(carved) != paths(cone_only)
assert capsys.readouterr().err == ""
def missing(*_args):
raise gray_iso_layers.DepthUnavailable("missing file: venv")
# Profondeur indisponible : c'est dit, et les gris servent seuls.
monkeypatch.setattr(gray_iso_layers, "depth_field", missing)
svg = run_extension(tmp_path, *args, "--depth_mix=100")
assert paths(svg) == paths(cone_only)
error = capsys.readouterr().err
assert "missing file: venv" in error and "deploy.ps1 -Depth" in error
def test_end_to_end_without_image(tmp_path, capsys): def test_end_to_end_without_image(tmp_path, capsys):
svg = run_extension(tmp_path, "--id=rect1") svg = run_extension(tmp_path, "--id=rect1")
assert "Board no. 1" not in svg assert "Board no. 1" not in svg
@ -772,7 +1186,7 @@ def test_inx_matches_arguments():
options = GrayIsoLayers().arg_parser.parse_args([]) options = GrayIsoLayers().arg_parser.parse_args([])
for name, text in params.items(): for name, text in params.items():
default = getattr(options, name) default = getattr(options, name)
if name in ("tab", "shapes", "fill_mode", "unit"): if name in ("tab", "shapes", "layout", "depth_model", "fill_mode", "unit"):
continue # listes : la valeur par defaut est la premiere option continue # listes : la valeur par defaut est la premiere option
if isinstance(default, bool): if isinstance(default, bool):
assert text == str(default).lower(), name assert text == str(default).lower(), name