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))
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.
inv()
and the one-dimensional inverse has a simple analytic solution. Did you see it? \$\endgroup\$upsale=False
? \$\endgroup\$