From 3ed0fa0987dace771be209a9a381e41d064104a1 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 4 Oct 2026 14:19:26 +0200 Subject: [PATCH] Carte de profondeur pour des planches qui suivent les volumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AumyrbcuQobB3GbPrFjG9i --- README.md | 98 +++++++- deploy.ps1 | 98 +++++++- depth_estimate.py | 122 ++++++++++ gray_iso_layers.inx | 21 ++ gray_iso_layers.py | 115 ++++++++-- gray_iso_layers_core.py | 77 ++++++- gray_iso_layers_depth.py | 133 +++++++++++ locale/en/LC_MESSAGES/grayisolayers.mo | Bin 7192 -> 10734 bytes locale/fr/LC_MESSAGES/grayisolayers.mo | Bin 7869 -> 11757 bytes po/en.po | 56 +++++ po/fr.po | 56 +++++ po/grayisolayers.pot | 54 +++++ test_gray_iso_layers.py | 297 ++++++++++++++++++++++++- 13 files changed, 1090 insertions(+), 37 deletions(-) create mode 100644 depth_estimate.py create mode 100644 gray_iso_layers_depth.py diff --git a/README.md b/README.md index 3ecc88f..0857bde 100644 --- a/README.md +++ b/README.md @@ -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 abstraits. - 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 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, @@ -73,8 +84,9 @@ aplanis mais les yeux, la bouche et le bord du visage restent nets, et les ## Installation -Aucune dépendance à installer : l'extension utilise `inkex`, `numpy` et -`Pillow`, tous fournis avec Inkscape. +Aucune dépendance à installer pour l'usage de base : l'extension utilise +`inkex`, `numpy` et `Pillow`, tous fournis avec Inkscape. Seule la carte de +profondeur demande une installation à part (`-Depth`, ci-dessous). ### Windows @@ -82,10 +94,41 @@ Aucune dépendance à installer : l'extension utilise `inkex`, `numpy` et .\deploy.ps1 # copie dans %APPDATA%\inkscape\extensions\GrayIsoLayers .\deploy.ps1 -Uninstall # retire l'extension .\deploy.ps1 -Force # déploie même si Inkscape est ouvert +.\deploy.ps1 -Depth # déploie, et installe de quoi calculer la profondeur ``` 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 ` 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 Copier dans un sous-dossier du répertoire des extensions utilisateur @@ -93,7 +136,8 @@ Copier dans un sous-dossier du répertoire des extensions utilisateur sous Linux, `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions` sous macOS) : -- `gray_iso_layers.inx`, `gray_iso_layers.py`, `gray_iso_layers_core.py` ; +- `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` ; - le dossier `locale/`. @@ -127,6 +171,19 @@ vide. - **Contours anguleux** : augmenter le lissage ou la simplification. - **Détails perdus, visage méconnaissable** : réduire le lissage (0,5 à 1 %), laisser « Préserver les contours » coché, augmenter la résolution de calcul. +- **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é produit des anneaux, puisque chaque nuance du dégradé devient un aplat. - **Image liée introuvable** : l'incorporer (clic droit > Incorporer l'image). @@ -142,6 +199,7 @@ Onglet **Niveaux** | Paramètre | Défaut | Rôle | | --- | --- | --- | | 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. | | Reporter sur chaque planche le contour de la suivante | oui | Repère d'assemblage tracé dans la couleur de marquage (planches empilées uniquement). | | Lissage (% du grand côté de l'image) | 1 | Lissage appliqué avant la découpe : plus il est fort, plus les contours sont ronds et simples. | @@ -151,6 +209,14 @@ Onglet **Niveaux** | Simplification (points de calcul) | 0,4 | Écart maximal toléré lors de la suppression de nœuds ; 0 les conserve tous. | | Lisser les contours avec des courbes de Bézier | oui | Sinon, les contours sont des polygones. | +Onglet **Profondeur** + +| Paramètre | Défaut | Rôle | +| --- | --- | --- | +| 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`. | +| Modèle de profondeur | Rapide (MiDaS small) | « Rapide » : MiDaS small, carte lisse. « Fin » : Depth Anything V2 Small, contours plus précis. | +| 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. | + Onglet **Style** | Paramètre | Défaut | Rôle | @@ -169,6 +235,8 @@ Onglet **Style** gray_iso_layers.inx Boîte de dialogue (textes source en anglais) gray_iso_layers.py Couche inkex : lecture de l'image, écriture du SVG gray_iso_layers_core.py Noyau de calcul, sans inkex (numpy seul) +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 po/ Catalogues grayisolayers.pot, en.po, fr.po locale/ Catalogues compilés (.mo), déployés @@ -194,13 +262,17 @@ 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é). | | `smooth(field, blur, preserve_edges)` | Lissage avant découpe, puis étirement 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. | | `contour_rings(field, threshold, above=True)` | Contours fermés de la région au-dessus (ou au-dessous) du seuil. | | `ring_area(ring)` | Aire d'un anneau. | | `simplify_ring(ring, tolerance)` / `simplify_chain(chain, tolerance)` | Simplification de Douglas-Peucker d'un anneau fermé, d'une ligne ouverte. | | `contour_lines(field, threshold, tolerance, min_area)` | Ligne de niveau d'un seuil : boucles fermées et lignes ouvertes aboutissant au bord. | | `region_rings(field, lines, inside)` | Contour fermé d'une région bornée par des lignes de niveau, refermé le long du bord de l'image. | -| `iso_boards(field, levels, blur, min_area, tolerance, shapes, preserve_edges)` | Une planche par niveau : liste de `(niveau, gris, anneaux, repère)`, le repère étant la ligne de niveau qui borde la planche suivante. | +| `iso_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. | | `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. | @@ -215,18 +287,26 @@ pip install pytest numpy pillow python -m pytest -q ``` -Sans `inkex` : 41 tests passent, 13 sont ignorés. Avec `inkex` : +Sans `inkex` : 51 tests passent, 17 sont ignorés. Avec `inkex` : ```powershell pip install lxml tinycss2 cssselect2 cssselect $env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions' -python -m pytest -q # 54 tests passent +python -m pytest -q # 67 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 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, un calque par niveau, repères d'assemblage, image incorporée, liée ou tournée, groupe sélectionné, styles, messages d'erreur) et la cohérence des traductions et du `.inx`. diff --git a/deploy.ps1 b/deploy.ps1 index c27409b..f388dd3 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -16,14 +16,28 @@ .PARAMETER FolderName 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 - 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 Déploie même si Inkscape est en cours d'exécution. .EXAMPLE .\deploy.ps1 + .\deploy.ps1 -Depth .\deploy.ps1 -Uninstall .\deploy.ps1 -FolderName 'autre-nom' #> @@ -32,17 +46,30 @@ param( [string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'), [string] $FolderName = 'GrayIsoLayers', + [switch] $Depth, + [string] $Python, [switch] $Uninstall, [switch] $Force ) $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') $localeDir = 'locale' $source = $PSScriptRoot $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 --------- $running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue if ($running -and -not $Force) { @@ -59,6 +86,10 @@ if ($Uninstall) { else { 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 } @@ -102,6 +133,69 @@ if (Test-Path -LiteralPath $pycache) { 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 "Extension déployée dans $destination" -ForegroundColor Green Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Iso-couches par nuances de gris..." diff --git a/depth_estimate.py b/depth_estimate.py new file mode 100644 index 0000000..848c79d --- /dev/null +++ b/depth_estimate.py @@ -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()) diff --git a/gray_iso_layers.inx b/gray_iso_layers.inx index 155067a..91f392c 100644 --- a/gray_iso_layers.inx +++ b/gray_iso_layers.inx @@ -13,6 +13,9 @@ 16 + 0 @@ -40,6 +43,22 @@ gui-text="Smooth the outlines with Bézier curves">true + + 0 + + + + + 100 + + + + @@ -80,6 +99,8 @@ The image is smoothed, then split into the requested number of gray levels with Each gray level goes into its own layer, named Board no. 1, Board no. 2... from the back to the front, added above the image: as many new layers as gray levels. The boards are meant to be cut and stacked: board no. 1 covers the whole image and each following board is smaller and lies in front of the previous one, lightest (or darkest) in front. In each layer, the cut outline of the board is drawn in the cut color (red), and the outline of the next board is carried over in the marking color (black), to show where to glue it. With "Its own gray only", a board holds the flat area of its gray alone: the boards sit side by side without overlapping and carry no marking. Several selected images share the same layers. +For 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. diff --git a/gray_iso_layers.py b/gray_iso_layers.py index 5240fc9..3517613 100644 --- a/gray_iso_layers.py +++ b/gray_iso_layers.py @@ -11,6 +11,7 @@ La logique vit dans `gray_iso_layers_core`, sans dependance a inkex. """ import base64 +import hashlib import io import os import sys @@ -25,8 +26,16 @@ from inkex.localization import localize sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from gray_iso_layers_core import ( # noqa: E402 - gray_to_hex, iso_boards, lines_to_d, parse_color, rings_to_d, scale_lines, - scale_rings) + add_detail, blend_fields, gray_to_hex, guided_filter, iso_boards, lines_to_d, normalize, + parse_color, rings_to_d, scale_lines, 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 # de l'interface (Inkscape transmet domaine et dossier par l'environnement), @@ -43,6 +52,7 @@ class GrayIsoLayers(inkex.EffectExtension): # Un argument par du .inx, memes noms et memes valeurs par defaut. pars.add_argument("--tab", default="levels") pars.add_argument("--levels", type=int, default=16) + pars.add_argument("--equalize", type=float, default=0.0) pars.add_argument("--shapes", default="light") pars.add_argument("--mark", type=inkex.Boolean, default=True) pars.add_argument("--blur", type=float, default=1.0) @@ -51,6 +61,9 @@ class GrayIsoLayers(inkex.EffectExtension): pars.add_argument("--min_area", type=float, default=0.02) pars.add_argument("--simplify", type=float, default=0.4) pars.add_argument("--smooth", type=inkex.Boolean, default=True) + pars.add_argument("--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_color", default="4294967295") pars.add_argument("--stroke_width", type=float, default=0.2) @@ -93,12 +106,15 @@ class GrayIsoLayers(inkex.EffectExtension): """Traite une image : calcul dans gray_iso_layers_core, ecriture dans le SVG.""" opt = self.options 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... inkex.errormsg(_("Cannot read the image '{}' ({}). Embed it in the " "document (right-click > Embed Image) or check its " "link.").format(image.get_id(), error)) return + if opt.depth_mix > 0: + field = self.add_depth(image, picture, digest, field) rows, cols = field.shape boards = iso_boards( @@ -107,7 +123,8 @@ class GrayIsoLayers(inkex.EffectExtension): preserve_edges=opt.edges, min_area=opt.min_area / 100.0, 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: inkex.errormsg(_("The image '{}' is uniform: only the base level has " "a shape. Reduce the smoothing or pick a more " @@ -200,36 +217,104 @@ class GrayIsoLayers(inkex.EffectExtension): width, height = fitted_width, fitted_height return left, top, width, height - def load_field(self, image, resolution): - """Lit le bitmap et renvoie ses gris (0 noir, 1 blanc) en tableau 2D, - ramene a `resolution` points sur son grand cote.""" + def add_depth(self, image, picture, digest, field): + """Melange aux gris `field` la profondeur estimee de l'image, dans la + proportion `depth_mix`. Si elle ne peut pas etre calculee, le dit et + rend les gris seuls.""" import numpy 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 "" if href.startswith("data:"): header, _sep, payload = href.partition(",") data = base64.b64decode(payload) if ";base64" in header \ else unquote_to_bytes(payload) - source = io.BytesIO(data) else: if href.startswith("file:"): path = url2pathname(urlparse(href).path) else: path = unquote(href) # Lien relatif : il part du dossier du document. - source = self.absolute_href(path) + with 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: - # La transparence se lit comme du blanc (fond de page). - rgba = picture.convert("RGBA") - white = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) - gray = Image.alpha_composite(white, rgba).convert("L") + @staticmethod + def gray_picture(picture): + """Gris de l'image, en image PIL : mode « L » (0 a 255), ou « F » + (0 a 1) pour une image a plus de 8 bits par canal.""" + 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)) size = (max(2, int(round(gray.width * zoom))), max(2, int(round(gray.height * zoom)))) gray = gray.resize(size, Image.LANCZOS) - return numpy.asarray(gray, dtype=float) / 255.0 + 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__": diff --git a/gray_iso_layers_core.py b/gray_iso_layers_core.py index 0775476..69f9bcf 100644 --- a/gray_iso_layers_core.py +++ b/gray_iso_layers_core.py @@ -163,6 +163,70 @@ def normalize(field): 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): """Seuils reguliers separant `levels` niveaux de gris sur 0..1.""" 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, - shapes="light", preserve_edges=False): + shapes="light", preserve_edges=False, equalize=0.0): """Decoupe un champ de gris en une planche par niveau. Le champ est lisse (`blur` en echantillons, `preserve_edges` : voir `smooth`), etire sur 0..1 puis quantifie en `levels` niveaux a seuils reguliers : le niveau k couvre les valeurs de k / levels a - (k + 1) / levels. Renvoie les planches du fond vers l'avant de la pile : + (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 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 levels = max(2, int(levels)) field = smooth(field, blur, preserve_edges) + if equalize > 0: + field = spread_levels(field, equalize) smallest = min_area * (width - 1) * (height - 1) cuts = thresholds(levels) @@ -601,11 +669,12 @@ 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, - shapes="band", preserve_edges=False): + shapes="band", preserve_edges=False, equalize=0.0): """Comme `iso_boards`, sans les reperes d'assemblage : liste de `(niveau, gris, rings)`.""" return [board[:3] for board in iso_boards(field, levels, blur, min_area, - tolerance, shapes, preserve_edges)] + tolerance, shapes, preserve_edges, + equalize)] def image_ring(width, height): diff --git a/gray_iso_layers_depth.py b/gray_iso_layers_depth.py new file mode 100644 index 0000000..bca4f57 --- /dev/null +++ b/gray_iso_layers_depth.py @@ -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) diff --git a/locale/en/LC_MESSAGES/grayisolayers.mo b/locale/en/LC_MESSAGES/grayisolayers.mo index 57dfbbfdeee62c3d2a559ffaaf17104686d561c4..660affdd273a3d7e2469b1e2ea9953b390e38231 100644 GIT binary patch literal 10734 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 Ee`UPah5!Hn delta 777 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 tLC9uYB%h@oB~KZV9iM^OFhPSADX=03 delta 765 zcmYMyPe>F|9KiA4gMVu>}>09;#w>#n6PF>_=gT6udT@-{w1^-++MBiU7G|bHB{oc%bzj?nm2Lr2jb3d!> zcf!%g$To4&0T)-H5OGGZ?O}vFE9Kq)p#2?5h``oVJ5ne*e7V%&?YQg|k zisWR8Mim{OkW;>J(}}<1RrD~b6T?`FX}pI|QJ*hj4XzdA4b%@gz-F`=^Z--1MPDB4 ziHBH4HTh+hh9-QEar}xm@C=7BQhuIrw&*&B>9;G+R}{l>;zk_8`C|WXG{m~hmr%g# znDUG8NCvQl{PK*(41UBAI@IkVw&FGHz* 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(): # Seuil 0.5 sur un cone de rayon 40 : cercle de rayon 20. rings = contour_rings(cone(), 0.5) @@ -501,6 +577,141 @@ def test_rings_to_d_keeps_the_image_border_straight(): assert "L " not in d and d.count("C ") == len(circle) +# -------------------------------------------------------------------------- +# 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) # -------------------------------------------------------------------------- @@ -522,10 +733,13 @@ SVG = """ """ -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.""" 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: picture.save(str(tmp_path / "cone.png")) href = "cone.png" @@ -704,6 +918,75 @@ def test_end_to_end_plain_blur(tmp_path): 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): svg = run_extension(tmp_path, "--id=rect1") assert "Board no. 1" not in svg @@ -772,7 +1055,7 @@ def test_inx_matches_arguments(): options = GrayIsoLayers().arg_parser.parse_args([]) for name, text in params.items(): default = getattr(options, name) - if name in ("tab", "shapes", "fill_mode", "unit"): + if name in ("tab", "shapes", "depth_model", "fill_mode", "unit"): continue # listes : la valeur par defaut est la premiere option if isinstance(default, bool): assert text == str(default).lower(), name