You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

992 lines
38 KiB

#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2025 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import sys,os
import math
import argparse
import json
from dataclasses import dataclass
from typing import Optional,Tuple,List,Dict,Any
import numpy as np,array
from PIL import Image, ImageDraw, ImageFilter, ImageChops, ImageOps
sys.path.append(os.path.join(sys.path[0],'../..'))
import TermTk as ttk
@dataclass
class RenderData:
# fogNear: float
# fogFar: float
# bgColor: Tuple[int,int,int,int]
resolution: Tuple[int,int]
# outFile: str
# offY: int
# show: bool = False
# mirror: Tuple[int,int]
class _ThreadingData:
__slots__ = ('timer')
timer: ttk.TTkTimer
def __init__(self):
self.timer = ttk.TTkTimer()
def find_coeffs(pa, pb):
matrix = []
for p1, p2 in zip(pa, pb):
matrix.append([p1[0], p1[1], 1, 0, 0, 0, -p2[0]*p1[0], -p2[0]*p1[1]])
matrix.append([0, 0, 0, p1[0], p1[1], 1, -p2[1]*p1[0], -p2[1]*p1[1]])
A = np.matrix(matrix, dtype=float)
B = np.array(pb).reshape(8)
res = np.dot(np.linalg.inv(A.T * A) * A.T, B)
return np.array(res).reshape(8)
def project_point(camera_position, look_at, point_3d):
"""
Project a 3D point into a normalized projection matrix.
Args:
camera_position: The 3D position of the camera (x, y, z).
look_at: The 3D position the camera is looking at (x, y, z).
point_3d: The 3D point to project (x, y, z).
Returns:
The 2D coordinates of the projected point in normalized space.
"""
# Step 1: Calculate the forward, right, and up vectors
def normalize(v):
return v / np.linalg.norm(v)
forward = normalize(np.array(look_at) - np.array(camera_position))
right = normalize(np.cross(forward, [0, 1, 0]))
if np.all(np.isnan(right)):
right = [0,1,0]
up = np.cross(right, forward)
# Step 2: Create the view matrix
view_matrix = np.array([
[ right[0] , right[1] , right[2] , -np.dot( right , camera_position)],
[ up[0] , up[1] , up[2] , -np.dot( up , camera_position)],
[-forward[0] , -forward[1] , -forward[2] , np.dot(forward , camera_position)],
[ 0 , 0 , 0 , 1]
])
# Step 3: Create the projection matrix
near = 1.0 # Near plane normalized to 1
width = 1.0 # Width of the near plane
height = 1.0 # Height of the near plane
aspect_ratio = width / height
projection_matrix = np.array([
[1 / aspect_ratio, 0, 0, 0],
[ 0, 1, 0, 0],
[ 0, 0, -1, -2 * near],
[ 0, 0, -1, 0]
])
# Step 4: Transform the 3D point into clip space
point_3d_homogeneous = np.array([point_3d[0], point_3d[1], point_3d[2], 1])
view_space_point = view_matrix @ point_3d_homogeneous
clip_space_point = projection_matrix @ view_space_point
# Step 5: Perform perspective divide to get normalized device coordinates (NDC)
if clip_space_point[3] == 0:
raise ValueError("Invalid projection: w component is zero.")
ndc_x = clip_space_point[0] / clip_space_point[3]
ndc_y = clip_space_point[1] / clip_space_point[3]
return ndc_x, ndc_y
class _Toolbox():
__slots__ = ('widget', 'updated')
updated:ttk.pyTTkSignal
def __init__(self):
self.updated = ttk.pyTTkSignal()
self.widget = ttk.TTkUiLoader.loadFile(os.path.join(os.path.dirname(os.path.abspath(__file__)),"t.toolbox.tui.json"))
@ttk.pyTTkSlot(str)
def _presetChanged(preset):
w,h = preset.split('x')
self.widget.getWidgetByName("SB_HRes").setValue(int(w))
self.widget.getWidgetByName("SB_VRes").setValue(int(h))
pres = self.widget.getWidgetByName("CB_ResPresets")
pres.addItems([
'320x200', '320x240', '400x300', '512x384',
'640x400', '640x480', '800x600', '1024x768',
'1152x864', '1280x720', '1280x800', '1280x960',
'1280x1024', '1360x768', '1366x768', '1400x1050',
'1440x900', '1600x900', '1600x1200', '1680x1050',
'1920x1080', '1920x1200', '2048x1152', '2048x1536',
'2560x1080', '2560x1440', '2560x1600', '2880x1800',
'3200x1800', '3440x1440', '3840x2160', '4096x2160',
'5120x2880', '7680x4320'])
pres.setCurrentIndex(6)
pres.currentTextChanged.connect(_presetChanged)
aa = self.widget.getWidgetByName("CB_AA")
aa.addItems(['0X','2X','3X','4X'])
aa.setCurrentIndex(0)
self.widget.getWidgetByName("SB_HRes").valueChanged.connect(self._triggerUpdated)
self.widget.getWidgetByName("SB_VRes").valueChanged.connect(self._triggerUpdated)
self.widget.getWidgetByName("CB_Bg").stateChanged.connect(self._triggerUpdated)
self.widget.getWidgetByName("BG_Color").colorSelected.connect(self._triggerUpdated)
self.widget.getWidgetByName("SB_Fog_Near").valueChanged.connect(self._triggerUpdated)
self.widget.getWidgetByName("SB_Fog_Far").valueChanged.connect(self._triggerUpdated)
self.widget.getWidgetByName("SB_OffY").valueChanged.connect(self._triggerUpdated)
self.widget.getWidgetByName("SB_FadingFrom").valueChanged.connect(self._triggerUpdated)
self.widget.getWidgetByName("SB_FadingTo").valueChanged.connect(self._triggerUpdated)
@ttk.pyTTkSlot()
def _triggerUpdated(self):
self.updated.emit()
def mirror(self) -> Tuple[float,float]:
return (
self.widget.getWidgetByName("SB_FadingFrom").value() ,
self.widget.getWidgetByName("SB_FadingTo").value() )
def bg(self) -> Tuple[int,int,int,int]:
if self.widget.getWidgetByName("CB_Bg").isChecked():
btnColor = self.widget.getWidgetByName("BG_Color").color()
return (*btnColor.fgToRGB(),255)
else:
return (0,0,0,0)
def fog(self) -> tuple[float,float]:
return (
self.widget.getWidgetByName("SB_Fog_Near").value() ,
self.widget.getWidgetByName("SB_Fog_Far").value() )
def resolution(self) -> Tuple[int,int]:
return(
self.widget.getWidgetByName("SB_HRes").value(),
self.widget.getWidgetByName("SB_VRes").value())
def offset_y(self) -> float:
return self.widget.getWidgetByName("SB_OffY").value()
def serialize(self) -> Dict:
return {
'bg':self.bg(),
'fog':self.fog(),
'resolution':self.resolution(),
'offset_y': self.offset_y(),
'mirror': self.mirror(),
}
@staticmethod
def deserialize(data:Dict) -> '_Toolbox':
ret = _Toolbox()
ret.widget.getWidgetByName("SB_HRes").setValue(data['resolution'][0])
ret.widget.getWidgetByName("SB_VRes").setValue(data['resolution'][1])
if data['bg']==[0,0,0,0]:
ret.widget.getWidgetByName("CB_Bg").setChecked(False)
else:
ret.widget.getWidgetByName("CB_Bg").setChecked(True)
r = data['bg'][0]
g = data['bg'][1]
b = data['bg'][2]
color = ttk.TTkColor.fg(f"#{r<<16|g<<8|b:06x}")
ret.widget.getWidgetByName("BG_Color").setColor(color)
ret.widget.getWidgetByName("SB_Fog_Near").setValue(data['fog'][0])
ret.widget.getWidgetByName("SB_Fog_Far").setValue(data['fog'][1])
ret.widget.getWidgetByName("SB_OffY").setValue(data['offset_y'])
ret.widget.getWidgetByName("SB_OffY").setValue(data['offset_y'])
ret.widget.getWidgetByName("SB_FadingFrom").setValue(data['mirror'][0])
ret.widget.getWidgetByName("SB_FadingTo").setValue(data['mirror'][1])
return ret
class _Movable():
__slots__ = ('_x','_y','_z','data', 'selected', 'name','widget', 'updated')
_x:int
_y:int
_z:int
data:Dict[str,Any]
selected:ttk.pyTTkSignal
updated:ttk.pyTTkSignal
name:ttk.TTkLabel
widget:ttk.TTkWidget
def __init__(self, x:int=0,y:int=0,z:int=0, name:str=""):
self.selected = ttk.pyTTkSignal(_Movable)
self.updated = ttk.pyTTkSignal()
self.data = {}
self._x = x
self._y = y
self._z = z
self.name = ttk.TTkLabel(text=ttk.TTkString(name),maxHeight=1)
self.widget = ttk.TTkWidget()
def serialize(self) -> Dict:
return {
'x':self._x,
'y':self._y,
'z':self._z,
'name': self.name.text().toAscii()
}
@staticmethod
def deserialize(data:Dict) -> '_Movable':
return _Movable(**data)
def x(self) -> int:
return self._x
def setX(self, value:int):
self._x = value
self._updateBox()
self.updated.emit()
def y(self) -> int:
return self._y
def setY(self, value:int):
self._y = value
self._updateBox()
self.updated.emit()
def z(self) -> int:
return self._z
def setZ(self, value:int):
self._z = value
self._updateBox()
self.updated.emit()
def getBox(self) -> Tuple[int,int,int,int]:
return (self.x-1,self._y,4,1)
def _updateBox(self):
pass
class _Image(_Movable):
__slots__ = ('_size','_tilt','fileName', 'box', 'image')
_size:int
_tilt:float
fileName:str
image:Image
box:Tuple[int,int,int,int]
def __init__(self,size:int,tilt:float,fileName:str, **kwargs):
self._size = size
self._tilt = tilt
self.fileName = fileName
super().__init__(**kwargs)
self._loadStuff()
def _loadStuff(self):
self.widget = ttk.TTkUiLoader.loadFile(os.path.join(os.path.dirname(os.path.abspath(__file__)),"t.image.tui.json"))
if not os.path.isfile(self.fileName):
raise ValueError(f"{self.fileName} is not a file")
try:
self.image = Image.open(self.fileName).convert("RGBA")
except FileNotFoundError:
raise ValueError(f"Failed to open {self.fileName}")
self._updateBox()
self.widget.getWidgetByName("SB_X").valueChanged.connect(self.setX)
self.widget.getWidgetByName("SB_Y").valueChanged.connect(self.setY)
self.widget.getWidgetByName("SB_Z").valueChanged.connect(self.setZ)
self.widget.getWidgetByName("SB_Tilt").valueChanged.connect(self.setTilt)
self.widget.getWidgetByName("SB_Size").valueChanged.connect(self.setSize)
self.updated.connect(self._updated)
self._updated()
def serialize(self) -> Dict:
ret = super().serialize()
return ret | {
'size': self._size,
'tilt': self._tilt,
'fileName': self.fileName
}
@staticmethod
def deserialize(data:Dict) -> '_Image':
return _Image(**data)
@ttk.pyTTkSlot()
def _updated(self):
self.widget.getWidgetByName("SB_X").setValue(self._x)
self.widget.getWidgetByName("SB_Y").setValue(self._y)
self.widget.getWidgetByName("SB_Z").setValue(self._z)
self.widget.getWidgetByName("SB_Tilt").setValue(self._tilt)
self.widget.getWidgetByName("SB_Size").setValue(self._size)
def size(self) -> int:
return self._size
def setSize(self, value:int):
self._size = value
self._updateBox()
self.updated.emit()
def tilt(self) -> float:
return self._tilt
def setTilt(self, value:float):
self._tilt = value
self._updateBox()
self.updated.emit()
def _updateBox(self):
size = float(self._size)
w = 1 + 2*size*abs(math.cos(self._tilt))
h = 1 + size*abs(math.sin(self._tilt))
self.box = (
int(self._x-w/2),
int(self._y-h/2),
int(w), int(h),
)
def getBox(self) -> Tuple[int,int,int,int]:
return self.box
class _Camera(_Movable):
__slots__= ('_tilt')
_tilt:float
def __init__(self, tilt:float=0, **kwargs):
self._tilt = tilt
super().__init__(**kwargs)
self._loadStuff()
def _loadStuff(self):
self.widget = ttk.TTkUiLoader.loadFile(os.path.join(os.path.dirname(os.path.abspath(__file__)),"t.camera.tui.json"))
self.widget.getWidgetByName("SB_X").valueChanged.connect(self.setX)
self.widget.getWidgetByName("SB_Y").valueChanged.connect(self.setY)
self.widget.getWidgetByName("SB_Z").valueChanged.connect(self.setZ)
self.widget.getWidgetByName("SB_Tilt").valueChanged.connect(self.setTilt)
self.updated.connect(self._updated)
self._updated()
def serialize(self) -> Dict:
ret = super().serialize()
return ret | {
'tilt': self._tilt,
}
@staticmethod
def deserialize(data:Dict) -> '_Camera':
return _Camera(**data)
@ttk.pyTTkSlot()
def _updated(self):
self.widget.getWidgetByName("SB_X").setValue(self._x)
self.widget.getWidgetByName("SB_Y").setValue(self._y)
self.widget.getWidgetByName("SB_Z").setValue(self._z)
self.widget.getWidgetByName("SB_Tilt").setValue(self._tilt)
def tilt(self) -> float:
return self._tilt
def setTilt(self, value:float):
self._tilt = value
self.updated.emit()
class _State():
__slots__ = (
'camera','images', 'toolbox',
'_currentMovable','highlightedMovable',
'currentMovableUpdated','updated')
camera: _Camera
images: List[_Image]
_currentMovable: Optional[_Movable]
highlightedMovable: Optional[_Movable]
currentMovableUpdated: ttk.pyTTkSignal
updated: ttk.pyTTkSignal
def __init__(self, camera: _Camera, images: List[_Image], toolbox: Optional[_Toolbox]=None):
if not toolbox:
self.toolbox = _Toolbox()
else:
self.toolbox = toolbox
self.currentMovableUpdated = ttk.pyTTkSignal(_Movable)
self.updated = ttk.pyTTkSignal()
self.camera = camera
self.images = images
self._currentMovable = None
self.highlightedMovable = None
self.camera.updated.connect(self.updated.emit)
self.toolbox.updated.connect(self.updated.emit)
for img in images:
img.updated.connect(self.updated.emit)
@property
def currentMovable(self) -> Optional[_Movable]:
return self._currentMovable
@currentMovable.setter
def currentMovable(self, value: Optional[_Movable]):
if self._currentMovable != value:
self._currentMovable = value
if value:
self.currentMovableUpdated.emit(value)
@ttk.pyTTkSlot(str)
def save(self, fileName):
data = {
'camera': self.camera.serialize(),
'images': [img.serialize() for img in self.images],
'toolbox': self.toolbox.serialize()
}
with open(fileName, "w") as f:
json.dump(data, f, indent=4)
@staticmethod
def load(fileName) -> '_State':
with open(fileName, "r") as f:
data = json.load(f)
toolbox = _Toolbox.deserialize(data['toolbox'])
images = []
for imgData in data['images']:
images.append(_Image.deserialize(imgData))
state = _State(
toolbox=toolbox,
camera=_Camera.deserialize(data['camera']),
images=images)
return state
class Perspectivator(ttk.TTkWidget):
__slots__ = ('_state')
_state:_State
def __init__(self, state:_State, **kwargs):
self._state = state
super().__init__(**kwargs)
self.setFocusPolicy(ttk.TTkK.ClickFocus)
state.updated.connect(self.update)
def mousePressEvent(self, evt):
self._state.highlightedMovable = None
self._state.currentMovable = None
cx,cy = self._state.camera.x(),self._state.camera.y()
if cx-1 <= evt.x <= cx+2 and cy-1 <= evt.y <= cy+1:
self._state.currentMovable = self._state.camera
self._state.camera.data |= {
'mx':self._state.camera.x()-evt.x,
'my':self._state.camera.y()-evt.y}
else:
for image in self._state.images:
ix,iy,iw,ih = image.getBox()
if ix <= evt.x < ix+iw and iy <= evt.y < iy+ih:
image.data |= {
'mx':image.x()-evt.x,
'my':image.y()-evt.y}
self._state.currentMovable = image
index = self._state.images.index(image)
self._state.images.pop(index)
self._state.images.append(image)
break
if self._state.currentMovable:
self._state.currentMovable.selected.emit(self._state.currentMovable)
self.update()
return True
def mouseMoveEvent(self, evt:ttk.TTkMouseEvent):
self._state.highlightedMovable = None
cx,cy = self._state.camera.x(),self._state.camera.y()
if cx-1 <= evt.x <= cx+2 and cy-1 <= evt.y <= cy+1:
self._state.highlightedMovable = self._state.camera
else:
for image in self._state.images:
ix,iy,iw,ih = image.getBox()
if ix <= evt.x < ix+iw and iy <= evt.y < iy+ih:
self._state.highlightedMovable = image
break
self.update()
return True
def mouseReleaseEvent(self, evt:ttk.TTkMouseEvent):
self._state.highlightedMovable = None
self._state.currentMovable = None
self.update()
return True
def mouseDragEvent(self, evt:ttk.TTkMouseEvent):
if not (movable:=self._state.currentMovable):
pass
elif evt.key == ttk.TTkK.RightButton and isinstance(movable,_Image):
x = evt.x-movable.x()
y = evt.y-movable.y()
movable.setTilt(math.atan2(x,y*2))
self.update()
elif evt.key == ttk.TTkK.LeftButton:
mx,my = movable.data['mx'],movable.data['my']
movable.setX(evt.x+mx)
movable.setY(evt.y+my)
self.update()
return True
def wheelEvent(self, evt:ttk.TTkMouseEvent) -> bool:
if not ((image:=self._state.highlightedMovable) and isinstance(image,_Image)):
pass
elif evt.evt == ttk.TTkK.WHEEL_Up:
image.setSize(min(image.size()+1,50))
self.update()
elif evt.evt == ttk.TTkK.WHEEL_Down:
image.setSize(max(image.size()-1,5))
self.update()
return True
def getImage(self, data:RenderData) -> Image:
return self.getImagePil(data)
def getImagePil(self, data:RenderData) -> Image:
screen_width, screen_height = data.resolution
w,h = screen_width,screen_height
fog = self._state.toolbox.fog()
fogNear=fog[0]
fogFar=fog[1]
bgColor=self._state.toolbox.bg()
offY=self._state.toolbox.offset_y()*h/600
mirror = self._state.toolbox.mirror()
ww,wh = self.size()
cam_x = self._state.camera.x()
cam_y = self._state.camera.y()
cam_z = self._state.camera.z()
cam_tilt = self._state.camera.tilt()
observer = (cam_x , cam_z , cam_y ) # Observer's position
# observer = (ww/2 , -data.cameraY , wh-3 ) # Observer's position
dz = 10*math.cos(math.pi * cam_tilt/360)
dy = 10*math.sin(math.pi * cam_tilt/360)
look_at = (cam_x , cam_z-dy, cam_y+dz) # Observer is looking along the positive Z-axis
prw,prh = screen_width/2, screen_height/2
# step1, sort Images based on the distance
images = sorted(self._state.images,key=lambda img:img.getBox()[1])
znear,zfar = 0xFFFFFFFF,-0xFFFFFFFF
for img in images:
ix,iy,iw,ih = img.getBox()
iz = img.z()
ih-=1
znear=min(znear,iy,iy+ih)
zfar=max(zfar,iy,iy+ih)
isz = img.size()*2
if math.pi/2 <= img.tilt() < math.pi or -math.pi <= img.tilt() < 0:
zleft = iy
zright = iy+ih
ip1x,ip1y = project_point(observer,look_at,(ix+iw , iz+isz , iy+ih ))
ip2x,ip2y = project_point(observer,look_at,(ix , iz+isz , iy ))
ip3x,ip3y = project_point(observer,look_at,(ix+iw , iz , iy+ih ))
ip4x,ip4y = project_point(observer,look_at,(ix , iz , iy ))
ip5x,ip5y = project_point(observer,look_at,(ix+iw , -iz-isz , iy+ih ))
ip6x,ip6y = project_point(observer,look_at,(ix , -iz-isz , iy ))
ip7x,ip7y = project_point(observer,look_at,(ix+iw , -iz , iy+ih ))
ip8x,ip8y = project_point(observer,look_at,(ix , -iz , iy ))
else:
zleft = iy+ih
zright = iy
ip1x,ip1y = project_point(observer,look_at,(ix+iw , iz+isz , iy ))
ip2x,ip2y = project_point(observer,look_at,(ix , iz+isz , iy+ih ))
ip3x,ip3y = project_point(observer,look_at,(ix+iw , iz , iy ))
ip4x,ip4y = project_point(observer,look_at,(ix , iz , iy+ih ))
ip5x,ip5y = project_point(observer,look_at,(ix+iw , -iz-isz , iy ))
ip6x,ip6y = project_point(observer,look_at,(ix , -iz-isz , iy+ih ))
ip7x,ip7y = project_point(observer,look_at,(ix+iw , -iz , iy ))
ip8x,ip8y = project_point(observer,look_at,(ix , -iz , iy+ih ))
img.data |= {
'zleft':zleft,
'zright':zright,
'top' : {
'p1':(int((ip1x+1)*prw) , int(offY+(ip1y+1)*prh)),
'p2':(int((ip2x+1)*prw) , int(offY+(ip2y+1)*prh)),
'p3':(int((ip3x+1)*prw) , int(offY+(ip3y+1)*prh)),
'p4':(int((ip4x+1)*prw) , int(offY+(ip4y+1)*prh)),
},
'bottom' : {
'p1':(int((ip5x+1)*prw) , int(offY+(ip5y+1)*prh)),
'p2':(int((ip6x+1)*prw) , int(offY+(ip6y+1)*prh)),
'p3':(int((ip7x+1)*prw) , int(offY+(ip7y+1)*prh)),
'p4':(int((ip8x+1)*prw) , int(offY+(ip8y+1)*prh)),
}
}
# step2, get all the layers and masks for alla the images
for img in images:
image = img.image
imageTop = image.copy()
imageBottom = image.copy()
imageTopAlpha = imageTop.split()[-1]
imageBottomAlpha = imageBottom.split()[-1]
imw, imh = image.size
# Create a gradient mask for the mirrored image
gradient = Image.new("L", (imw, imh), 0)
draw = ImageDraw.Draw(gradient)
# Fading Math:
# ----|--f--y___h-----t---
# av = 1-(y-f)/(t-f)
# bv = 1-(y+h-f)/(t-f)
_f,_t = mirror
if _f == _t:
_f-=0.01
_y = img.z()
_h = img.size()*imh/imw
_av = 1-(-_y-_f)/(_t-_f)
_bv = 1-(-_y-_h-_f)/(_t-_f)
for i in range(imh):
_p = (i/imh)
alpha = int(min(1,max(0,((_p)*_av+(1-_p)*_bv)))*255) # alpha goes from 0 to 204
draw.rectangle((0, i, imw, i), fill=alpha)
# Apply the mirror mask to the image
imageBottomAlphaGradient = ImageChops.multiply(imageBottomAlpha, gradient)
# Create a gradient mask for the fog
gradient = Image.new("L", (imw, imh), 0)
draw = ImageDraw.Draw(gradient)
for i in range(imw):
an = 255-fogNear
af = 255-fogFar
zl = img.data['zleft']
zr = img.data['zright']
zi = (i/imw)*(zr-zl)+zl
znorm = (zi-znear)/(zfar-znear)
alpha = znorm*(an-af)+af
draw.rectangle((i, 0, i, imh), fill=int(alpha))
# resultAlpha.show()
imageTop.putalpha(ImageChops.multiply(imageTopAlpha, gradient))
imageBottom.putalpha(ImageChops.multiply(imageBottomAlphaGradient, gradient))
# Define the source and destination points
src_points = [(imw, 0), (0, 0), (imw, imh), (0, imh)]
dst_top = [
img.data['top']['p1'],
img.data['top']['p2'],
img.data['top']['p3'],
img.data['top']['p4'],
]
dst_bottom = [
img.data['bottom']['p1'],
img.data['bottom']['p2'],
img.data['bottom']['p3'],
img.data['bottom']['p4'],
]
def _transform(_img:Image, _dst:List) -> Image:
return _img.transform(
(w, h), Image.Transform.PERSPECTIVE,
find_coeffs(_dst, src_points),
Image.Resampling.BICUBIC)
blurRadius = (math.sqrt(w*h)*4/math.sqrt(800*600))
img.data['imageTop'] = _transform(imageTop, dst_top)
img.data['imageBottom'] = _transform(imageBottom, dst_bottom).filter(ImageFilter.BoxBlur(blurRadius))
img.data['imageTopAlpha'] = _transform(imageTopAlpha, dst_top)
img.data['imageBottomAlpha'] = _transform(imageBottomAlpha, dst_bottom).filter(ImageFilter.BoxBlur(blurRadius))
# def _customBlur(_img:Image, _alpha:Image) -> Image:
# thresholds = [(0,70), (70,150), (150,255)]
# blur_radius = [7, 4, 0]
# _out = Image.new("RGBA", _img.size, (0, 0, 0, 0))
# # Create a new image to store the blurred result
# for (_f,_t),_r in zip(thresholds,blur_radius):
# # Create a mask for the current threshold
# _mask = _alpha.point(lambda p: p if _f < p <= _t else 0)
# # Apply Gaussian blur to the image using the mask
# _blurred = _img.filter(ImageFilter.BoxBlur(radius=_r))
# _blurred.putalpha(_mask)
# # Composite the blurred image with the original image using the mask
# _out = Image.alpha_composite(_out,_blurred)
# #_alpha.show()
# #_mask.show()
# #_blurred.show()
# #_out.show()
# pass
# return _out
# img.data['imageBottom'] = _customBlur(
# img.data['imageBottom'], img.data['imageBottom'].split()[-1])
# return image
# Apply blur to the alpha channel
# alpha = image.split()[-1]
# alpha = alpha.filter(ImageFilter.GaussianBlur(radius=5))
# image.putalpha(alpha)
# image = image.filter(ImageFilter.GaussianBlur(radius=3))
# Paste the processed image onto the output image
# Create a new image with a transparent background
outImage = Image.new("RGBA", (w, h), bgColor)
# Apply the masks and Draw all the images
for img in images:
imageTop = img.data['imageTop']
imageBottom = img.data['imageBottom']
imageTopAlpha = imageTop.split()[-1]
imageBottomAlpha = imageBottom.split()[-1]
for maskImg in reversed(images):
if img==maskImg:
break
maskTop = ImageOps.invert(maskImg.data['imageTopAlpha'])
maskBottom = ImageOps.invert(maskImg.data['imageBottomAlpha'])
imageTopAlpha = ImageChops.multiply(imageTopAlpha, maskTop)
imageBottomAlpha = ImageChops.multiply(imageBottomAlpha, maskTop)
imageBottomAlpha = ImageChops.multiply(imageBottomAlpha, maskBottom)
imageTop.putalpha(imageTopAlpha)
imageBottom.putalpha(imageBottomAlpha)
# imageBottom.show()
# outImage.paste(imageBottom,box=None,mask=imageBottom)
# outImage.paste(imageTop,box=None,mask=imageTop)
outImage = Image.alpha_composite(outImage,imageBottom)
outImage = Image.alpha_composite(outImage,imageTop)
# imageBottom.show()
return outImage
def paintEvent(self, canvas):
w,h = self.size()
cx,cy=self._state.camera.x(),self._state.camera.y()
if self._state.highlightedMovable == self._state.camera:
canvas.drawTTkString(pos=(cx-1,cy),text=ttk.TTkString("<😘>"))
elif self._state.currentMovable == self._state.camera:
canvas.drawTTkString(pos=(cx,cy),text=ttk.TTkString("😍"))
else:
canvas.drawTTkString(pos=(cx,cy),text=ttk.TTkString("😎"))
# Draw Fov
for y in range(cy):
canvas.drawChar(char='/', pos=(cx+cy-y+1,y))
canvas.drawChar(char='\\',pos=(cx-cy+y,y))
# Draw Images
for image in self._state.images:
ix,iy,iw,ih = image.getBox()
canvas.drawText(pos=(ix,iy-1),text=f"{image.tilt():.2f}", color=ttk.TTkColor.YELLOW)
canvas.drawText(pos=(ix+5,iy-1),text=f"{image.fileName}", color=ttk.TTkColor.CYAN)
if image == self._state.highlightedMovable:
canvas.fill(pos=(ix,iy),size=(iw,ih),char='+',color=ttk.TTkColor.GREEN)
elif image == self._state.currentMovable:
canvas.fill(pos=(ix,iy),size=(iw,ih),char='+',color=ttk.TTkColor.YELLOW)
else:
canvas.fill(pos=(ix,iy),size=(iw,ih),char='+')
if ih > iw > 1:
for dy in range(ih):
dx = iw*dy//ih
if (
math.pi/2 < image.tilt() < math.pi or
-math.pi/2 < image.tilt() < 0 ):
canvas.drawChar(char='X',pos=(ix+dx,iy+dy))
else:
canvas.drawChar(char='X',pos=(ix+iw-dx,iy+dy))
elif iw >= ih > 1:
for dx in range(iw):
dy = ih*dx//iw
if (
math.pi/2 < image.tilt() < math.pi or
-math.pi/2 < image.tilt() < 0 ):
canvas.drawChar(char='X',pos=(ix+dx,iy+dy))
else:
canvas.drawChar(char='X',pos=(ix+iw-dx,iy+dy))
class _Preview(ttk.TTkWidget):
__slots__ = ('_canvasImage')
def __init__(self, **kwargs):
self._canvasImage = ttk.TTkCanvas(width=20,height=3)
self._canvasImage.drawText(pos=(0,0),text="Preview...")
super().__init__(**kwargs)
def updateCanvas(self, img:Image):
w,h = img.size
pixels = img.load()
self._canvasImage.resize(w,h//2)
self._canvasImage.updateSize()
for x in range(w):
for y in range(h//2):
bg = 100 if (x//4+y//2)%2 else 150
p1 = pixels[x,y*2]
p2 = pixels[x,y*2+1]
def _c2hex(p) -> str:
a = p[3]/255
r = int(bg*(1-a)+a*p[0])
g = int(bg*(1-a)+a*p[1])
b = int(bg*(1-a)+a*p[2])
return f"#{r<<16|g<<8|b:06x}"
c = ttk.TTkColor.fgbg(_c2hex(p2),_c2hex(p1))
self._canvasImage.drawChar(pos=(x,y), char='', color=c)
self.update()
def paintEvent(self, canvas):
w,h = self.size()
canvas.paintCanvas(self._canvasImage, (0,0,w,h), (0,0,w,h), (0,0,w,h))
class ControlPanel(ttk.TTkSplitter):
__slots__ = (
'previewPressed','renderPressed','_toolbox', '_previewImage', '_state', '_movableLayout','_threadData')
def __init__(self, state:_State, **kwargs):
self._threadData:_ThreadingData = _ThreadingData()
self.previewPressed = ttk.pyTTkSignal(RenderData)
self.renderPressed = ttk.pyTTkSignal(RenderData)
self._movableLayout = ttk.TTkGridLayout()
self._movableLayout.addItem(ttk.TTkLayout(),2,0)
self._state = state
super().__init__(**kwargs|{"orientation":ttk.TTkK.VERTICAL})
self._previewImage = _Preview()
self._state.toolbox.widget.getWidgetByName("Btn_Render").clicked.connect(self._renderClicked)
self._state.toolbox.widget.getWidgetByName("Btn_Preview").clicked.connect(self._previewChanged)
self._state.toolbox.widget.getWidgetByName("Btn_SaveCfg").clicked.connect(self._saveCfg)
self._state.updated.connect(self._previewChanged)
self.addWidget(self._previewImage,size=5)
self.addWidget(self._state.toolbox.widget)
self.addItem(self._movableLayout)
state.currentMovableUpdated.connect(self._movableChanged)
self._threadData.timer.timeout.connect(self._previewThread)
@ttk.pyTTkSlot(_Movable)
def _movableChanged(self, movable:_Movable):
if isinstance(movable,_Movable):
self._movableLayout.addWidget(movable.name,0,0)
self._movableLayout.addWidget(movable.widget,1,0)
else:
raise ValueError(f"Unknown movable {movable}")
def drawPreview(self, img:Image):
self._previewImage.updateCanvas(img)
@ttk.pyTTkSlot()
def _saveCfg(self):
filePath = os.path.join(os.path.abspath('.'),'perspectivator.cfg.json')
filePicker = ttk.TTkFileDialogPicker(
pos = (3,3), size=(80,30),
acceptMode=ttk.TTkK.AcceptMode.AcceptSave,
caption="Save As...",
fileMode=ttk.TTkK.FileMode.AnyFile,
path=filePath,
filter="TTk Tui Files (*.cfg.json);;Json Files (*.json);;All Files (*)")
filePicker.pathPicked.connect(self._state.save)
ttk.TTkHelper.overlay(None, filePicker, 5, 5, True)
@ttk.pyTTkSlot()
def _renderClicked(self):
w,h = self._state.toolbox.resolution()
# fog = self._state.toolbox.fog()
mult = {
'0X':1,'2X':2,'3X':3,'4X':4}.get(
self._state.toolbox.widget.getWidgetByName("CB_AA").currentText(),1)
waa = w*mult
haa = h*mult
data = RenderData(
# outFile=self._state.toolbox.widget.getWidgetByName("LE_OutFile").text().toAscii(),
# fogNear=fog[0],
# fogFar=fog[1],
# bgColor=self._state.toolbox.bg(),
resolution=(waa,haa),
# offY=self._state.toolbox.offset_y(),
# show = self._state.toolbox.widget.getWidgetByName("CB_Show").isChecked(),
# mirror = self._state.toolbox.mirror()
)
self.renderPressed.emit(data)
@ttk.pyTTkSlot()
def _previewChanged(self):
if not self._state.toolbox.widget.getWidgetByName("Btn_Preview").isChecked():
return
self._threadData.timer.start()
def _previewThread(self):
w,h = self._previewImage.size()
# fog = self._state.toolbox.fog()
data = RenderData(
# outFile=self._state.toolbox.widget.getWidgetByName("LE_OutFile").text().toAscii(),
# fogNear=fog[0],
# fogFar=fog[1],
# bgColor=self._state.toolbox.bg(),
resolution=(w,h*2),
# offY=self._state.toolbox.offset_y(),
# show = self._state.toolbox.widget.getWidgetByName("CB_Show").isChecked(),
# mirror = self._state.toolbox.mirror()
)
self.previewPressed.emit(data)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('filename', type=str, nargs='+',
help='the images to compose or the json config file')
args = parser.parse_args()
ttk.TTkTheme.loadTheme(ttk.TTkTheme.NERD)
root = ttk.TTk(
layout=ttk.TTkGridLayout(),
title="TTkode",
mouseTrack=True)
if len(args.filename) == 1 and args.filename[0].endswith('.json'):
_state = _State.load(args.filename[0])
else:
_images = []
_camera = _Camera(x=25,y=25,z=5, name="Camera")
for fileName in args.filename:
d=len(_images)*2
image = _Image(x=25+d,y=15+d, z=0, size=5, tilt=0,fileName=fileName, name=fileName)
_camera.setX(_camera.x()+1)
_camera.setY(_camera.y()+1)
_images.append(image)
_state = _State(
camera=_camera,
images=_images)
perspectivator = Perspectivator(state=_state)
controlPanel = ControlPanel(state=_state)
at = ttk.TTkAppTemplate()
at.setWidget(widget=perspectivator,position=at.MAIN)
at.setWidget(widget=controlPanel,position=at.RIGHT, size=30)
root.layout().addWidget(at)
def _render(data:RenderData):
outImage = perspectivator.getImage(data)
# outImage.save(filename='outImage.png')
bbox = outImage.getbbox()
if bbox:
outImage = outImage.crop(bbox)
outw,outh = _state.toolbox.resolution()
cropw,croph = outImage.size
outImage = outImage.resize((outw,outh*croph//cropw), Image.LANCZOS)
outImage.save(_state.toolbox.widget.getWidgetByName("LE_OutFile").text().toAscii())
if _state.toolbox.widget.getWidgetByName("CB_Show").isChecked():
outImage.show()
def _preview(data):
img = perspectivator.getImage(data)
controlPanel.drawPreview(img)
controlPanel.renderPressed.connect(_render)
controlPanel.previewPressed.connect(_preview)
root.mainloop()
if __name__ == '__main__':
main()