#!/usr/bin/env python3 # MIT License # # Copyright (c) 2025 Eugenio Parodi # # 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],'../../libs/pyTermTk')) 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) # Math from: # https://stackoverflow.com/questions/14177744/how-does-perspective-transformation-work-in-pil #res = np.dot(np.linalg.inv(A.T * A) * A.T, B) res = np.linalg.solve(A,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 if zfar != znear: 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="Perspectivator", 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) at.setWidget(widget=ttk.TTkLogViewer(),position=at.BOTTOM, size=4) 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()