237 lines
10 KiB
Python
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()
|