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

322 lines
14 KiB
Python

#!/usr/bin/env python3
# coding=utf-8
"""
Extension Inkscape : decoupe une image en couches empilees par nuances de gris.
L'image selectionnee est convertie en gris, floutee puis quantifiee en quelques
niveaux ; chaque niveau devient une forme fermee dont le contour suit la
frontiere de l'aplat, a la maniere des courbes de niveau d'une carte.
La logique vit dans `gray_iso_layers_core`, sans dependance a inkex.
"""
import base64
import hashlib
import io
import os
import sys
from urllib.parse import unquote, unquote_to_bytes, urlparse
from urllib.request import url2pathname
import inkex
from inkex.localization import localize
# L'extension vit dans son propre sous-dossier des extensions Inkscape, qui
# n'est pas forcement dans sys.path : on l'y ajoute pour trouver gray_iso_layers_core.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gray_iso_layers_core import ( # noqa: E402
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),
# textes anglais d'origine sinon.
localize()
class GrayIsoLayers(inkex.EffectExtension):
"""Decoupe chaque image selectionnee en planches a empiler, une par
niveau de gris, chacune dans son propre calque avec sa decoupe et le
repere de la planche suivante."""
def add_arguments(self, pars):
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
pars.add_argument("--tab", default="levels")
pars.add_argument("--levels", type=int, default=16)
pars.add_argument("--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)
pars.add_argument("--edges", type=inkex.Boolean, default=True)
pars.add_argument("--resolution", type=int, default=800)
pars.add_argument("--min_area", type=float, default=0.02)
pars.add_argument("--simplify", type=float, default=0.4)
pars.add_argument("--smooth", type=inkex.Boolean, default=True)
pars.add_argument("--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)
pars.add_argument("--unit", default="mm")
pars.add_argument("--stroke_color", default="4278190335")
pars.add_argument("--mark_color", default="255")
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
def effect(self):
images = list(self.svg.selection.get(inkex.Image))
if not images:
inkex.errormsg(_("Select at least one bitmap image (File > Import), "
"then run the extension again."))
return
if self.options.levels < 2:
inkex.errormsg(_("At least 2 gray levels are needed."))
return
# Un calque par planche, commun a toutes les images traitees.
self.board_layers = {}
for image in images:
self.process(image)
def board_layer(self, number, above):
"""Calque de la planche `number` (1 = la plus au fond), cree a la
demande au premier plan du document, juste au-dessus de `above`
(calque precedent ou image)."""
if number not in self.board_layers:
layer = inkex.Layer()
layer.label = _("Board no. {}").format(number)
root = self.svg
# Un calque Inkscape est un enfant direct de la racine : on remonte
# jusqu'a l'ancetre de premier plan pour s'inserer apres lui.
while above.getparent() is not None and above.getparent() is not root:
above = above.getparent()
root.insert(root.index(above) + 1, layer)
self.board_layers[number] = layer
return self.board_layers[number]
def process(self, image):
"""Traite une image : calcul dans gray_iso_layers_core, ecriture dans le SVG."""
opt = self.options
try:
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(
field, levels=opt.levels,
blur=opt.blur / 100.0 * max(rows, cols),
preserve_edges=opt.edges,
min_area=opt.min_area / 100.0,
tolerance=opt.simplify,
shapes=opt.shapes,
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 "
"contrasted image.").format(image.get_id()))
# Les couches sont calculees dans le repere propre de l'image, puis
# ecrites en coordonnees du document : les calques n'ont ainsi aucune
# transformation, meme pour une image tournee ou etiree.
left, top, width, height = self.image_box(image, cols, rows)
scale_x, scale_y = width / (cols - 1.0), height / (rows - 1.0)
box = (left, top, left + width, top + height)
matrix = image.composed_transform().matrix
above = image
for number, (_level, gray, rings, mark) in enumerate(boards, 1):
# Les planches sont numerotees du fond vers l'avant. Calque cree
# meme si le niveau est vide : autant de calques que de niveaux.
layer = self.board_layer(number, above)
above = layer
if not rings:
continue
node = inkex.PathElement()
node.label = _("Cut")
node.set("d", rings_to_d(scale_rings(rings, scale_x, scale_y, left, top),
smooth=opt.smooth, box=box, matrix=matrix))
node.style = self.layer_style(gray)
layer.add(node)
# Repere d'assemblage : le bord de la planche suivante, reporte
# sur celle-ci pour savoir ou la poser.
mark_style = self.mark_style() if opt.mark and mark else None
if mark_style is not None and (mark[0] or mark[1]):
node = inkex.PathElement()
node.label = _("Marking of board no. {}").format(number + 1)
node.set("d", lines_to_d(scale_lines(mark, scale_x, scale_y, left, top),
smooth=opt.smooth, box=box, matrix=matrix))
node.style = mark_style
layer.add(node)
if not opt.keep_original:
image.delete()
def layer_style(self, gray):
"""Style d'une couche : remplissage selon le mode, trait facultatif."""
opt = self.options
style = inkex.Style({"fill": "none", "fill-rule": "evenodd", "stroke": "none"})
if opt.fill_mode == "gray":
style["fill"] = gray_to_hex(gray)
elif opt.fill_mode == "color":
color, opacity = parse_color(opt.fill_color, ("#ffffff", 1.0))
style["fill"] = color
style["fill-opacity"] = str(opacity)
width = self.svg.unittouu("{}{}".format(opt.stroke_width, opt.unit))
if width > 0:
color, opacity = parse_color(opt.stroke_color, ("#ff0000", 1.0))
style["stroke"] = color
style["stroke-opacity"] = str(opacity)
style["stroke-width"] = str(round(width, 6))
style["stroke-linejoin"] = "round"
return style
def mark_style(self):
"""Style du repere d'assemblage : un trait sans remplissage, de la
meme epaisseur que la decoupe. `None` s'il n'y a pas de trait."""
opt = self.options
width = self.svg.unittouu("{}{}".format(opt.stroke_width, opt.unit))
if width <= 0:
return None
color, opacity = parse_color(opt.mark_color, ("#000000", 1.0))
return inkex.Style({
"fill": "none", "stroke": color, "stroke-opacity": str(opacity),
"stroke-width": str(round(width, 6)), "stroke-linejoin": "round",
"stroke-linecap": "round"})
@staticmethod
def image_box(image, cols, rows):
"""Rectangle reellement occupe par le bitmap, dans le repere de l'image.
Sauf preserveAspectRatio="none", le bitmap garde ses proportions et il
est centre dans le cadre x, y, width, height (cas « meet » par defaut).
"""
left, top, width, height = image.left, image.top, image.width, image.height
ratio = image.get("preserveAspectRatio", "xMidYMid meet")
if "none" not in ratio and "slice" not in ratio:
zoom = min(width / cols, height / rows)
fitted_width, fitted_height = cols * zoom, rows * zoom
left += (width - fitted_width) / 2.0
top += (height - fitted_height) / 2.0
width, height = fitted_width, fitted_height
return left, top, width, height
def 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)
else:
if href.startswith("file:"):
path = url2pathname(urlparse(href).path)
else:
path = unquote(href)
# Lien relatif : il part du dossier du document.
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()
@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)
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__":
GrayIsoLayers().run()