Skip to content

Suggestion: a dialog to set resolution. #18

Description

@MarioMey

Yesterday I needed to apply perspective to a high resolution image and the result was not good:

Image

I asked (free) ChatGPT to change the code but it was not correct. A friend of mine did it with Claude and it made a good job: resulting image was clearly better...

Image

... but it said that the result was to be more or less the same dimension of the source image... and it wasn't (only a quarter size).

Image

I understood that the extension uses the "visual resolution" to get the final dimensions... and it is not easy for me to understand the code and where is that "number".

Suggestion: a little dialog before applying the effect with some options. Maybe:

  • DPI (like "Resolution for Create Bitmap Copy" in Preferences) or...
  • Multiply of source image (default, 0.25x, 0.5x, 1x, 2x, 4x)

Here is the code wrote by Claude, maybe it gives you a suggested modification (it doesn't have any option):

image_perspective.py (yes, it changed the name 🤦) #!/usr/bin/env python3

"""
Copyright (C) 2021-2023 Samir OUCHENE, samirmath01@gmail.com
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
02110-1301, USA.
"""

import os
import urllib.request
import urllib.parse

import io
import inkex
from PIL import Image as PIL_Image
import base64
import numpy

try:
from base64 import decodebytes
except ImportError:
from base64 import decodestring as decodebytes

class ImagePerspective(inkex.Effect):
def init(self):
inkex.Effect.init(self)

def extract_image(self, node):
    """Extract the node as if it were an image."""
    xlink = node.get("xlink:href")
    if not xlink.startswith("data:"):
        # Parse the path and get the absolute path of the image
        _path = urllib.parse.urlparse(xlink).path
        path = self.absolute_href(
            _path or "", cwd=os.path.dirname(self.options.input_file)
        )
        starts_with_file = xlink.startswith("file:")
        if starts_with_file or os.path.isfile(path):

            # FIXME: redundancy
            if starts_with_file:
                path = urllib.parse.urlparse(xlink).path
                # On windows it is important to use urllib.request.url2pathname
                # see: https://stackoverflow.com/a/43925228/5920411
                path = urllib.request.url2pathname(path)
            if os.path.isfile(path):
                # open the file and encode it
                # I know, this is a horrible workaround (to b64 encode the image), but
                # for the sake of it, that provides an easy workaround for linked images,
                # and let's ignore it for now. (#FIXME)
                with open(path, "rb") as linked_img_file:
                    data = base64.b64encode(linked_img_file.read())
                    # return decodebytes(data.encode("utf-8"))
                    return decodebytes(data)

            else:
                inkex.errormsg(f"Invalid path: {path}")

        return  # Not embedded image data

    try:
        data = xlink[5:]
        (_mimetype, data) = data.split(";", maxsplit=1)
        (base, data) = data.split(",", maxsplit=1)
    except ValueError:
        inkex.errormsg("Invalid image format found.")
        return

    if base != "base64":
        inkex.errormsg("Can't decode encoding: {}.".format(base))
        return

    return decodebytes(data.encode("utf-8"))

def find_coeffs(self, source_coords, target_coords):
    matrix = []
    for s, t in zip(source_coords, target_coords):
        matrix.append([t[0], t[1], 1, 0, 0, 0, -s[0] * t[0], -s[0] * t[1]])
        matrix.append([0, 0, 0, t[0], t[1], 1, -s[1] * t[0], -s[1] * t[1]])
    A = numpy.array(matrix, dtype=float)
    B = numpy.array(source_coords).reshape(8)
    res = numpy.linalg.inv(A.T @ A) @ A.T @ B
    return numpy.array(res).reshape(8)

def effect(self):
    the_image_node, envelope_node = self.svg.selection
    if str(envelope_node) == "image" and str(the_image_node) == "path":
        envelope_node, the_image_node = self.svg.selection  # switch
    if str(the_image_node) != "image" and str(envelope_node) != "path":
        inkex.errormsg(
            "Your selection must contain an image and a path with at least 4 points."
        )
        return
    img_width, img_height = the_image_node.width, the_image_node.height

    try:
        unit_to_vp = self.svg.unit_to_viewport
    except AttributeError:
        unit_to_vp = self.svg.uutounit

    try:
        vp_to_unit = self.svg.viewport_to_unit
    except AttributeError:
        vp_to_unit = self.svg.unittouu

    img_width = unit_to_vp(img_width)
    img_height = unit_to_vp(img_height)

    nodes_pts = list(envelope_node.path.control_points)
    node1 = (unit_to_vp(nodes_pts[0][0]), unit_to_vp(nodes_pts[0][1]))
    node2 = (unit_to_vp(nodes_pts[1][0]), unit_to_vp(nodes_pts[1][1]))
    node3 = (unit_to_vp(nodes_pts[2][0]), unit_to_vp(nodes_pts[2][1]))
    node4 = (unit_to_vp(nodes_pts[3][0]), unit_to_vp(nodes_pts[3][1]))

    nodes = [node1, node2, node3, node4]

    xMax = max([node[0] for node in nodes])
    xMin = min([node[0] for node in nodes])
    yMax = max([node[1] for node in nodes])
    yMin = min([node[1] for node in nodes])
    # add some assertions (FIXME)

    img_data = self.extract_image(the_image_node)
    orig_image = PIL_Image.open(io.BytesIO(img_data))
    pil_img_size = orig_image.size
    scale = pil_img_size[0] / img_width

    # --- Calidad de salida -------------------------------------------------
    # Tamaño físico del resultado en el lienzo (unidades de viewport).
    W, H = xMax - xMin, yMax - yMin

    # Factor de supersampling: píxeles de la fuente por unidad de viewport.
    # Renderizar el raster a esta densidad preserva la resolución original
    # en vez de colapsarla al tamaño en pantalla del cuadrilátero.
    ss = scale
    # Tope de seguridad: que una imagen enorme metida en un quad chico
    # no dispare el uso de memoria.
    MAX_SIDE = 8000
    ss = min(ss, MAX_SIDE / max(W, H, 1))
    ss = max(ss, 1.0)  # nunca por debajo del tamaño del bounding box

    final_w, final_h = int(round(W * ss)), int(round(H * ss))
    # -----------------------------------------------------------------------

    coeffs = self.find_coeffs(
        [
            (0, 0),
            (img_width * scale, 0),
            (img_width * scale, img_height * scale),
            (0, img_height * scale),
        ],
        [
            ((node1[0] - xMin) * ss, (node1[1] - yMin) * ss),
            ((node2[0] - xMin) * ss, (node2[1] - yMin) * ss),
            ((node3[0] - xMin) * ss, (node3[1] - yMin) * ss),
            ((node4[0] - xMin) * ss, (node4[1] - yMin) * ss),
        ],
    )

    # Check if the image has transparency
    hasTransparency = orig_image.mode in ("RGBA", "LA") or (
        orig_image.mode == "P" and "transparency" in orig_image.info
    )

    transp_img = orig_image

    # If the original image is not transparent, create a new image with alpha channel
    if not hasTransparency:
        transp_img = PIL_Image.new("RGBA", orig_image.size)
        transp_img.format = "PNG"
        transp_img.paste(orig_image)

    # It was announced in pillow v9.1.0 that PIL.Image.PERSPECTIVE and PIL.Image.BICUBIC were
    # deprecated and would be removed in v10.0
    # see:  https://pillow.readthedocs.io/en/stable/releasenotes/9.1.0.html#deprecations
    #
    # However, that decision was reversed in v9.4.0 and those constants are kept.
    # https://pillow.readthedocs.io/en/stable/releasenotes/9.4.0.html#restored-image-constants
    image = transp_img.transform(
        (final_w, final_h),
        PIL_Image.PERSPECTIVE,
        coeffs,
        PIL_Image.BICUBIC,
    )

    obj = inkex.Image()
    obj.set("x", vp_to_unit(xMin))
    obj.set("y", vp_to_unit(yMin))
    # El tamaño *físico* del <image> es el del bounding box (W, H), NO el del
    # raster supersampleado (final_w, final_h). Así el raster de mayor
    # resolución se muestra al mismo tamaño en el lienzo => más nitidez.
    obj.set("width", vp_to_unit(W))
    obj.set("height", vp_to_unit(H))
    # embed the transformed image
    persp_img_data = io.BytesIO()
    image.save(persp_img_data, transp_img.format)
    mime = PIL_Image.MIME[transp_img.format]
    b64 = base64.b64encode(persp_img_data.getvalue()).decode("utf-8")
    uri = f"data:{mime};base64,{b64}"
    obj.set("xlink:href", uri)
    self.svg.add(obj)

imagePerspective = ImagePerspective()
imagePerspective.run()

image_perspective.inx Image Perspective org.inkscape.image_perspective all <script> image_perspective.py </script>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions