inkscape.isoCoucheGreyScale/gray_iso_layers.py
2026-10-01 19:12:46 +02:00

237 lines
10 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 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
gray_to_hex, iso_boards, lines_to_d, parse_color, rings_to_d, scale_lines,
scale_rings)
# 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("--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("--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:
field = self.load_field(image, 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
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)
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 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."""
import numpy
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 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")
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
if __name__ == "__main__":
GrayIsoLayers().run()