inkscape.isoCoucheGreyScale/gray_iso_layers_depth.py
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

134 lines
5.1 KiB
Python

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