# Python function that skews an image while preserving its aspect ratio using affine transformation

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 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))
else:
matrix[1] = (tana, 1/cosa, max(0, -w0*tana))
else:
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)]
else:
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),
method=Image.AFFINE,
data=matrix[:2, :].flatten(),
resample=Image.BILINEAR
)

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.show()
img1.save('D:/test/img1.jpg', format='JPEG', quality=80, optimize=True)
img2 = rhombize(img1, angle=45)
img2.show()
img2.save('D:/test/img2.jpg', format='JPEG', quality=80, optimize=True)
img3 = rhombize(img1, angle=45, dimension='vertical')
img3.show()
img3.save('D:/test/img3.jpg', format='JPEG', quality=80, optimize=True)
img4 = rhombize(img1, angle=45, upsale=False)
img4.show()
img4.save('D:/test/img4.jpg', format='JPEG', quality=80, optimize=True)
img5 = rhombize(img1, 45, dimension='vertical', upsale=False)
img5.show()
img5.save('D:/test/img5.jpg', format='JPEG', quality=80, optimize=True)
img6 = rhombize(img1, angle=-30, upsale=False)
img6.show()
img6.save('D:/test/img6.jpg', format='JPEG', quality=80, optimize=True)

if __name__ == '__main__':
test()


## Edit

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

• 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? May 23 at 13:28
• Am I to believe in upsale=False? May 24 at 15:10