See this post for more details.

I re-implemented my idea using affine transformations, and finally understood how transformation matrices are formed... Turns out it is stupidly simple, but nothing I can find online explains it directly...

I updated the function so that it can also not upscale the image, not-upscaling means if the image's resolution is 1920*1080, the shifted width and height will always be equal to 1920 and 1080 respectively no matter what.

I rewrote the code and made it as efficient as I can possibly make it, and it meets all my expectations, but I do still think there might be other improvements I can't think of.

The code:

import numpy as np
from numpy.linalg import inv
from PIL import Image
from typing import Literal, List, Tuple

def sin(d: float): return np.sin(np.radians(d))
def cos(d: float): return np.cos(np.radians(d))
def tan(d: float): return np.tan(np.radians(d))

def skew(pos: Tuple[float, float], a: float, reverse: bool=False):
    x, y = pos
    if reverse:
        x, y = y, x
    x1 = x - y * sin(a)
    y1 = y * cos(a)
    if reverse:
        x1, y1 = y1, x1
    return (x1, y1)

def extremes(coordinates: List[Tuple[float, float]]):
    x, y = zip(*coordinates)
    min_x, max_x = min(x), max(x)
    min_y, max_y = min(y), max(y)
    return {'min_x': min_x, 'max_x': max_x, 'min_y': min_y, 'max_y': max_y}

def rhombize(
    img: Image,
    angle: float,
    dimension: Literal['horizontal', 'vertical'] = 'horizontal',
    upscale: bool=True
    w0, h0 = img.size
    cosa = cos(angle)
    sina = sin(angle)
    tana = tan(angle)
    assert dimension in ('horizontal', 'vertical')
    matrix = np.eye(3, 3)
    if upscale:
        if dimension == 'horizontal':
            matrix[0] = (1/cosa,   tana, max(0, -h0*tana))
            matrix[1] = (tana, 1/cosa, max(0, -w0*tana))
        corners = [(0, 0), (w0-1, 0), (0, h0-1), (w0-1, h0-1)]
        reverse = False
        if dimension == 'horizontal':
            matrix[:2, :2] = [(1, -sina), (0, cosa)]
            matrix[:2, :2] = [(cosa, 0), (-sina, 1)]
            reverse = True
        corners = [skew(pos, angle, reverse) for pos in corners]
        min_x, max_x, min_y, max_y = extremes(corners).values()
        matrix[0, 2] = -min_x
        matrix[1, 2] = -min_y

    size_transform = np.abs(matrix[:2, :2])
    w1, h1 = (size_transform @ img.size).astype(int)

    matrix = inv(matrix)

    rhombised = img.transform(
        size=(w1, h1),
        data=matrix[:2, :].flatten(),

    return rhombised

def test() -> None:
    arr = np.zeros((1024, 1024, 3), dtype=np.uint8)
    arr[:, :, 0] = np.linspace(0, 255, 1024, dtype=np.uint8)[np.newaxis, :]  # red ramp, horz
    arr[:, :, 2] = np.linspace(0, 255, 1024, dtype=np.uint8)[:, np.newaxis]  # blue ramp, vert

    img1 = Image.fromarray(arr)
    img1.save('D:/test/img1.jpg', format='JPEG', quality=80, optimize=True)
    img2 = rhombize(img1, angle=45)
    img2.save('D:/test/img2.jpg', format='JPEG', quality=80, optimize=True)
    img3 = rhombize(img1, angle=45, dimension='vertical')
    img3.save('D:/test/img3.jpg', format='JPEG', quality=80, optimize=True)
    img4 = rhombize(img1, angle=45, upsale=False)
    img4.save('D:/test/img4.jpg', format='JPEG', quality=80, optimize=True)
    img5 = rhombize(img1, 45, dimension='vertical', upsale=False)
    img5.save('D:/test/img5.jpg', format='JPEG', quality=80, optimize=True)
    img6 = rhombize(img1, angle=-30, upsale=False)
    img6.save('D:/test/img6.jpg', format='JPEG', quality=80, optimize=True)

if __name__ == '__main__':

enter image description here

enter image description here

enter image description here

enter image description here

enter image description here

enter image description here


Fixed a typo in the code, I only found out about just now.

  • \$\begingroup\$ An edit to my answer shows that you don't need to call inv() and the one-dimensional inverse has a simple analytic solution. Did you see it? \$\endgroup\$
    – Reinderien
    May 23 at 13:28
  • \$\begingroup\$ Am I to believe in upsale=False? \$\endgroup\$
    – greybeard
    May 24 at 15:10


Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.