inkscape.isoCoucheGreyScale/gray_iso_layers.py
2026-10-10 10:36:56 +02:00

389 lines
18 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 math
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, board_labels, gray_to_hex, guided_filter, iso_boards,
lines_to_d, normalize, number_lines, 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("--layout", default="stack")
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("--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("--number_color", default="65535")
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)
base = image.composed_transform().matrix
shifts = self.board_shifts(base, box, len(boards))
above = image
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
# 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)
# 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:
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):
"""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"})
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
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()