""" MOSAICS WITH A GENETIC ALGORITHM IN PYTHON+TKINTER By: Aldo Gonzalez Saint Norbert College Senior Capstone Project - Spring 2021 Sources: -Pillow -Slicer -L subclass of list https://code.activestate.com/recipes/579103-python-addset-attributes-to-list/ -W3schools -tutorialspoint -Codemy (Tkinter tutorial videos) -htmlview https://pypi.org/project/tkhtmlview/ -Dr. McVey """ import copy import random import time import os from tkinter import * import PIL from PIL import ImageTk,Image,ImageOps #need to include all modules import image_slicer #import io #import zipfile from tkhtmlview import HTMLLabel root = Tk() #window root.title('Mosaics') #Frame.geometry("1000x850") #window size? Frame=LabelFrame(root) Frame.pack() global ps1, ps2 #packetsize vars in fitness -> determine #s that go into calculating packetsize ps1=1 #if 5 (this is kinda the half pt, vs ps2 is the full length -> assuming same w+l, so can only do 4x4,10x10,etc.) ps2=2 #+10 -> 10x10 packets=100=500. good for basics w/ 25. probs need less w/ 49t (1500px) #back w/ 500/3750=13%. could stick around that #400t=300px= 4x4=26%, or 2x2=.7% #note: keep even bc =/= half pixels class L(list): """ A subclass of list that can accept additional attributes. Should be able to be used just like a regular list. The problem: a = [1, 2, 4, 8] a.x = "Hey!" # AttributeError: 'list' object has no attribute 'x' The solution: a = L(1, 2, 4, 8) a.x = "Hey!" print a # [1, 2, 4, 8] print a.x # "Hey!" print len(a) # 4 You can also do these: a = L( 1, 2, 4, 8 , x="Hey!" ) # [1, 2, 4, 8] a = L( 1, 2, 4, 8 )( x="Hey!" ) # [1, 2, 4, 8] a = L( [1, 2, 4, 8] , x="Hey!" ) # [1, 2, 4, 8] a = L( {1, 2, 4, 8} , x="Hey!" ) # [1, 2, 4, 8] a = L( [2 ** b for b in range(4)] , x="Hey!" ) # [1, 2, 4, 8] a = L( (2 ** b for b in range(4)) , x="Hey!" ) # [1, 2, 4, 8] a = L( 2 ** b for b in range(4) )( x="Hey!" ) # [1, 2, 4, 8] a = L( 2 ) # [2] """ def __new__(self, *args, **kwargs): return super(L, self).__new__(self, args, kwargs) def __init__(self, *args, **kwargs): if len(args) == 1 and hasattr(args[0], '__iter__'): list.__init__(self, args[0]) else: list.__init__(self, args) self.__dict__.update(kwargs) def __call__(self, **kwargs): self.__dict__.update(kwargs) return self """ IMGINIT -input parameters -> globals -multi or single mode set (via a and adv vars) -slicer slices into tiles to make final image tile list -other pixel/sizing/related vars set here -frame destroyed and replaced """ def imgInit(fN, a): #INPUT GLOBALS global tileNum #used for many funct loops global mainCy #frequency global eP global mPopP, mTileP global GenSize, ConstGenSize global curr curr=1 tileNum=int(tileNumE.get()) #rows, cols set pretty proportionally. 20,25,32,49. 100, 156, 210, 400 #cycles=1000 #T.TD: only for now. later will end when user wants, when not getting much better, etc. GenSize=int(GenSizeE.get()) #Pops eP=float(ePE.get()) #elites: 20% of top move on mPopP=float(mPopPE.get()) #.5=50% of pops affected (out of the ones that make it past selection, bc this happens before crossover) (can include repeated ones due to randomness) mTileP=float(mTilePE.get()) #.1=10% of tiles affected (can include repeated ones due to randomness) mainCy=int(mainCyE.get()) ConstGenSize=copy.deepcopy(GenSize) #avoid data share -> deep copy from GenSize global adv if a: #if the option was for single (basic) or multi (adv) adv=True else: adv=False global tiles #fN=takes filename, so, will take disk version (not the resized version from main) #can always use paint and resize it in disk, or at least a copy -> now, when "join," won't display massive one #or, since join returns an img, can just resize that one tiles=image_slicer.slice(fN, tileNum, save=False) #tiles=image_slicer.slice(fN, 20, 5, 4, False) #see documentation tiles=image_slicer.save_tiles(tiles, prefix="tile", directory=os.getcwd(),format="png") #no prefix for now=imgs get replaced. + current dir. not sure how to do new folder. #Note: column=row, row=column. careful w/ using those properties tileNum=len(tiles) #the real # used is rounded up to be proportionate, so good to change now global tilePixelX, tilePixelY #used in fitness funct global tilePixelTotal #used in fitness funct global popRows, popColumns #used in crossover bc going row by row, changing all columns in certain rows tilePixelX=(tiles[1].coords)[0] #gives x distance from 0 to 1 (or, that's at least how I've used it) popRows=(tiles[tileNum-1].position)[1] """ goal: determine # of y pixels in a tile (bc that varies when # of tiles change) for x=super easy, bc tile[1] is always the next one x-wise for y=not as simple. as I'm going thru tiles, need to detect when y coords change the first time -> that's the answer """ x=0 while True: if (tiles[x].coords)[1] != 0: #once it changes tilePixelY=(tiles[x].coords)[1] popColumns=(tiles[x-1].position)[0] #guy before=in last column break x+=1 tilePixelTotal=tilePixelX*tilePixelY print(tilePixelTotal*tileNum) global Frame Frame.destroy() #=/= make var disappear Frame=LabelFrame(root) #comparable to initial one that was in main scope Frame.pack() randomT() #set up first gen and s2 displays """ INITIAL GUI/TK SET-UP """ ParOptionsL = HTMLLabel(Frame, html="

Please Enter Desired Parameters

") ParOptionsL.grid(row = 0, column = 0, columnspan=6) ParOptionsL.fit_height() #num of tiles tileNumL = Label(Frame, text="Number of Tiles") #per Population") tileNumL.grid(row = 1, column = 0) tileNumE = Entry(Frame) #width=8) tileNumE.grid(row=2, column=0) tileNumE.insert(0, "25") #update frequency mainCyL = Label(Frame, text="Update Frequency") # (in Generations)") mainCyL.grid(row = 1, column = 1) mainCyE = Entry(Frame) #width=8) mainCyE.grid(row=2, column=1) mainCyE.insert(0, "150") #num of pops per gen GenSizeL = Label(Frame, text="Size of Generation") # (in Populations)") GenSizeL.grid(row = 1, column = 2) GenSizeE = Entry(Frame) #width=8) GenSizeE.grid(row=2, column=2) GenSizeE.insert(0, "4") #elite percentage (pops per gen) ePL = Label(Frame, text="Elite Decimal") ePL.grid(row = 1, column = 3) ePE = Entry(Frame) #width=8) ePE.grid(row=2, column=3) ePE.insert(0, ".3") #mutation: pops affected mPopPL = Label(Frame, text="Mutation: Populations Affected") mPopPL.grid(row = 1, column = 4) mPopPE = Entry(Frame) #width=8) mPopPE.grid(row=2, column=4) mPopPE.insert(0, ".3") #mutation: tiles affected per pop mTilePL = Label(Frame, text="Mutation: Tiles Affected") mTilePL.grid(row = 1, column = 5) mTilePE = Entry(Frame) #width=8) mTilePE.grid(row=2, column=5) mTilePE.insert(0, ".05") OptionRunL=HTMLLabel(Frame, html="

Please Select an Option to Run

") OptionRunL.grid(row=3, column=0, columnspan=6) OptionRunL.fit_height() MonaLisaFN="MonaLisa.png" #fileName MonaLisa=Image.open(MonaLisaFN) #img object #MonaLisa=ImageOps.fit(MonaLisa,(300,200)) #600,400 #150,100 #MonaLisa=ImageOps.scale(MonaLisa, 0.5) MonaLisaTK=ImageTk.PhotoImage(MonaLisa) #TK img object MonaLisaLabel=Label(Frame, image=MonaLisaTK) MonaLisaLabel.grid(row=4,column=0, columnspan=2) #.pack() MonaLisaButton=Button(Frame, text="Single Image", command=lambda: imgInit(MonaLisaFN, False), bg="black", fg="white") MonaLisaButton.grid(row=5,column=0, columnspan=2) #FinalDog FinalDogFN="adv/final.png" #"Garfield1.png" FinalDog=Image.open(FinalDogFN) #print(FinalDog) #FinalDog=ImageOps.fit(FinalDog,(250,300)) #FinalDog=ImageOps.scale(FinalDog, 0.5) FinalDogTK=ImageTk.PhotoImage(FinalDog) #for TK FinalDogLabel=Label(Frame, image=FinalDogTK) FinalDogLabel.grid(row=4, column=2, columnspan=2) #.pack() #pack what's in label now #Q: if reusing vars doesn't hurt, how to remove items? FinalDogSingleButton=Button(Frame, text="Single Image", command=lambda: imgInit(FinalDogFN, False), bg="black", fg="white") FinalDogSingleButton.grid(row=5, column=2) FinalDogMultiButton=Button(Frame, text="Multi-Image", command=lambda: imgInit(FinalDogFN, True), bg="black", fg="white") FinalDogMultiButton.grid(row=5, column=3) #Sandler SandlerFN="sandler.png" Sandler=Image.open(SandlerFN) #Sandler=ImageOps.fit(Sandler,(300,200)) #Sandler=ImageOps.scale(Sandler, 0.5) SandlerTK=ImageTk.PhotoImage(Sandler) #for TK SandlerLabel=Label(Frame, image=SandlerTK) SandlerLabel.grid(row=4, column=4, columnspan=2) #.pack() #pack what's in label now #Q: if reusing vars doesn't hurt, how to remove items? SandlerButton=Button(Frame, text="Single Image", command=lambda: imgInit(SandlerFN, False), bg="black", fg="white") SandlerButton.grid(row=5, column=4, columnspan=2) """ -stores all disk tiles into imgList (for easily picking random ones to put in first gen) ADV- =/= use imgs from disk from slicer (those are from the final), so the file names are different here then builds first generation -fills list of random imgs -replaces tile imgs w/ those -joins into one img """ def randomT(): """ only need to run once """ #1. list w/ all tile names + images from disk ImgFNList=[] global imgList #will use in mutation. basic=has "final" img tiles (bc same as initial), adv=has initial pics (diff from final) imgList=[] #! image objects of all tiles -> for basic, will be final img tiles -> will compare random tiles to these if(adv): for x in range(40,51): #60,66 #40,51 curr="adv/"+str(x)+".png" currImg=Image.open(curr) #less costly than replacing in list #imgList.append(currImg) imgList.append(ImageOps.fit(currImg,(tilePixelX, tilePixelY))) #now =/= need to update in paint every time diff tile # or diff pics ImgFNList.append(curr) #no use for now. only care for img objects for pixels else: #need rows and cols bc of slicer disk naming process for x in range(1,popRows+1): #1,5=01-04 here for ROW if x<10: currX="tile_0"+str(x)+"_" else: currX="tile_"+str(x)+"_" for y in range(1,popColumns+1): #1,6=01-05 on inner loop for COL if y<10: curr=currX+"0"+str(y)+".png" else: curr=currX+str(y)+".png" currImg=Image.open(curr) #less costly than replacing in list imgList.append(currImg) #imgList.append(ImageOps.fit(currImg,(55,50))) ImgFNList.append(curr) #no use for now. only care for img objects for pixels global tilesList #might be using in diff functs tilesList=copy.deepcopy(tiles) #not shallow tilesList=list(tilesList) #tuple->list of tuples.data type can change, sure? tilesList=L(tilesList) #now can store fitness global Gen Gen=[] #current generation. possibly =/= need diff final list """ SETS UP INITIAL GENERATION! run multiple times will run through all pops append each pop (w/ fitness) to Gen """ for pop in range(0,ConstGenSize): #2. loop to generate random # + make a list of random images (used to also display tiles) #col=0 #cnt=0 RimgList=[] #! random img objects -> 1 population img. later: list w/in list for allowing multiple population imgs? or dict? or? RFNList=[] #TKList=[] #random TK img objects #TKlabelList=[] #random TK img labels #txtlabelList=[] size=len(imgList) for x in range(0,tileNum): #if 20 -> 0-19 iterations num=random.randint(0, size-1) #now good for both basic+adv bc allows for a shorter set of imgs than tiles (you just run the random on those more times than the #of imgs) #tileNum-1) #random # between 0-19 -> yes bc list RimgList.append(imgList[num]) RFNList.append(ImgFNList[num]) #3. replace all the tile imgs w/ random ones for x in range(0,tileNum): tilesList[x].image = RimgList[x] tilesList[x].filename = RFNList[x] Gen.append(copy.deepcopy(tilesList)) #brings fitness along #tilesList just has current pop -> temp var, bc L screen2() """ SCREEN2 sets up second screen all the labels, buttons, and initial process images """ def screen2(): global joinTKList, joinTKlabelList #bc will be calling main() from a button after this joinTKList=[] #population TK img objects=generation joinTKlabelList=[] #population TK img labels=generation startL = Label(Frame, text = 'Start', font=10) startL.grid(row = 0, column = 0) currentL = Label(Frame, text = 'Current', font=10) currentL.grid(row = 0, column = 1, columnspan=2) endL = Label(Frame, text = 'End', font=10) endL.grid(row = 0, column = 3) #INITIAL joinImg=image_slicer.join(Gen[0]) joinTKList.append(ImageTk.PhotoImage(joinImg)) joinTKlabelList.append(Label(Frame, image=joinTKList[0])) joinTKlabelList[0].grid(row=1, column=0) #CURRENT joinImg=image_slicer.join(Gen[0]) joinTKList.append(ImageTk.PhotoImage(joinImg)) joinTKlabelList.append(Label(Frame, image=joinTKList[1])) joinTKlabelList[1].grid(row=1, column=1, columnspan=2) #FINAL joinImg=image_slicer.join(tiles) #print(joinImg.size) joinTKList.append(ImageTk.PhotoImage(joinImg)) joinTKlabelList.append(Label(Frame, image=joinTKList[2])) joinTKlabelList[2].grid(row=1, column=3) global GoButton GoButton = Button(Frame, text="GO. Keep going!", command=main, bg="black", fg="white") #KEEP ER MOVIN GoButton.grid(row = 2, column = 1) DoneButton = Button(Frame, text="DONE. Looks Good!", command=done, bg="black", fg="white") DoneButton.grid(row = 2, column = 2) """ DoneButton = Button(Frame, text="click", command=click) DoneButton.grid(row = 2, column = 1) """ def done(): exit() """ def click(): for x in range(0,cycles): GoButton.invoke() time.sleep(1) """ """ MAIN #Cycle Each funct will run # of pop times each have their own loops for this here, only concerned w/ how many generations/cycles we want to run this process then updates display! -> MAIN runs as many times as you click the button "GO" """ def main(): global Gen #bc of shuffle, sort global joinTKList, joinTKlabelList #for new label space global curr for cycle in range(0,mainCy): #print(Gen) #should have 4 Ls (lists w/ fitnesses) fitness2() print("GENERATION #",curr,": ",sep="") for x in range(0,GenSize): print(Gen[x].fitness) selection() mutation() crossover() random.shuffle(Gen) curr+=1 Gen.sort(key=sortFitness, reverse=True) #UPDATE DISPLAY (w/ best one as current) joinImg=image_slicer.join(Gen[GenSize-1]) #best joinTKList.append(ImageTk.PhotoImage(joinImg)) temp=len(joinTKList)-1 #new size of list. eff bc using twice joinTKlabelList.append(Label(Frame, image=joinTKList[temp])) #all to keep clean vars joinTKlabelList[temp].grid(row=1, column=1, columnspan=2) #replace. may destroy before, too. random.shuffle(Gen) """ CROSSOVER -pick random pop in Gen -deep copy append into Gen (new spot) -pick another random pop in Gen -every other row, switch each tile w/ 2nd pop =now a mix of both parents, each getting every other row repeat till amt recovered update GenSize (so, set as global... again) """ def crossover(): global GenSize #stop if reached original amt? (need original var) #could be good bc what if elitism is set low? need more -> keeps going while GenSize != ConstGenSize: p1=random.randint(0,GenSize-1) p2=random.randint(0,GenSize-1) tileCnt=0; Gen.append(copy.deepcopy(Gen[p1])) change=False #first row can stay. next row, I want it to change, grabbing tiles from other parent GenSize=len(Gen) #=/= sure how many by end. but if stop pt works, can just set back to original var (TBD) for r in range(0,popRows): #rows if(change): #changing. pulling from other parent #$: store Gen[p2] in a var to not continue to access list for c in range(0,popColumns): #cols Gen[GenSize-1][tileCnt].image=Gen[p2][tileCnt].image #swapping row, tile by tile tileCnt+=1 else: #no tile swapping here #do need current tileCnt for when I change. this=more efficient than looping tileCnt+=popColumns change= not change #toggle """ MUTATION need: -mPopP (currently half) -Gen -Gen length -random loop -pick a random pop -change some random tiles to some random tiles (from original, global tiles?) """ def mutation(): size=len(imgList) #efficiency mPopAmt=round(GenSize*mPopP) mTileAmt=round(mTileP*tileNum) for x in range(0, mPopAmt): #$: can return right in [] w/o variables. currently like this for readability pop=random.randint(0, GenSize-1) #0-3. which pop? for y in range(0, mTileAmt): tile=random.randint(0, tileNum-1) tileO=random.randint(0, size-1) Gen[pop][tile].image = imgList[tileO] #tiles[tileO].image #basic/adv=imgList has all initial imgs. diff for each, but can use same var! #print("GENERATION #1 AFTER MUTATION: ") """ print(GenSize) for x in range(0,3): print(Gen[x].fitness) """ #used to sort messy Ls (that includes fitness property) by fitness! def sortFitness(e): return e.fitness #yes!!! """ ELITES: sort, get top %, throw into generation BRACKET SELECTION: go 2x2, picking best, throwing into generation slight variance if even or odd size of gen Note: bracket selects from ALL. thus, possible it picks out some elites again, which is fine. GenSize: Updated here! """ def selection(): E=copy.deepcopy(Gen) S=copy.deepcopy(Gen) Gen.clear() #now =/= need Gen, bc all items in copy lists E.sort(key=sortFitness, reverse=True) size=len(E) Esize=round(size*eP) #ELITES for x in range(size-Esize,size): Gen.append(copy.deepcopy(E[x])) #BRACKET SELECTION if(size % 2 == 0): #even. can do 2x2 whole way. for x in range(0,size,2): #size depends on # of pops if S[x].fitness < S[x+1].fitness: #throw best in there Gen.append(copy.deepcopy(S[x])) else: Gen.append(copy.deepcopy(S[x+1])) else: #odd. 2x2 until last guy, which is just thrown in, probs for x in range(0,size-1,2): #size depends on # of pops if S[x].fitness < S[x+1].fitness: #throw best in there Gen.append(copy.deepcopy(S[x])) else: Gen.append(copy.deepcopy(S[x+1])) Gen.append(copy.deepcopy(S[size-1])) #last guy global GenSize #for this nested funct to affect the var for main... maybe if it wasn't nested, it'd work GenSize=len(Gen) #changed. now diff from size, Esize """ yxy PACKET PIXEL BY PIXEL 5 yxy packets. top, left, mid, right, bottom much faster than all pixels, but still catching much more detail difference than avg color of tile the smaller the # -> the smaller the difference -> the closest in match =/=pre-compute so, need tiles of initial/original (global tiles) + tiles of manipulated/current (tilesList) + pixel arrays for all tiles of each (each loop pass=1 tile of each) """ def fitness2(): for pop in range(0,GenSize): #cnt=0 #sum=0 imgSum=0 for t in range(0,tileNum): #CURRENT TILE pixO=tiles[t].image.load() #original pixC=Gen[pop][t].image.load() #current """ #each tile=5 packets of 10x10 -> 500. each 1 a nested loop after finding start pt """ for packet in range(0,5): if packet==0: #TOP startX=round((tilePixelX/2)-ps1) endX=startX+ps2 startY=0 endY=startY+ps2 elif packet==1: #MID startX=round((tilePixelX/2)-ps1) endX=startX+ps2 startY=round((tilePixelY/2)-ps1) endY=startY+ps2 elif packet==2: #BOTTOM startX=round((tilePixelX/2)-ps1) endX=startX+ps2 startY=tilePixelY-ps2 endY=startY+ps2 elif packet==3: #LEFT startX=0 endX=ps2 startY=round((tilePixelY/2)-ps1) endY=startY+ps2 else: #RIGHT startX=tilePixelX-ps2 endX=startX+ps2 startY=round((tilePixelY/2)-ps1) endY=startY+ps2 for x in range(startX, endX): for y in range(startY, endY): colorO=pixO[x,y] #current RGB tuple from original colorC=pixC[x,y] #current RGB tuple from changed/current #cnt+=1 pixDiff=0 for color in range(3): #color=R, G, B. goes to each to compare separately pixDiff+=abs(colorO[color]-colorC[color]) #add differences up imgSum+=pixDiff avg=imgSum/tileNum #sum can work, too, but avg will be a more manageable number (smaller) Gen[pop].fitness=avg """ AVERAGE TILE COLOR *not currently usable. assumes certain small additions* requires pre-comp (could be a funct, but called before cycle) + need global tiles to be L as well? bc each tile needs an tileAvgColor assume that for now add up all Rs, Gs, and Bs? compare to all Rs, Gs, and Bs from final? or wait, how to do avg tile color??? is that it? can also divide each by # of pix will probs be very diff if I get the precomp to help w/ current ones too """ def fitnessAvg(): for pop in range(0,GenSize): for x in range(0,tileNum): #CURRENT TILE AvgO=tiles[x].tileAvgColor #original #TD: assuming this exists pixC=Gen[pop][x].image.load() #current sum=[0,0,0] #RGB sum for each tile. will compare w/ AvgO at end of this loop #each tile=50x75, bc 250x300 -> 3750. TD: can put var later for r in range(0,tilePixelX): for c in range(0,tilePixelY): #colorO=pixO[r,c] #current RGB tuple from original colorC=pixC[r,c] #current RGB tuple from changed/current for color in range(3): #color=R, G, B. goes to each to compare separately sum[color]+=colorC[color] # sum[0]/=tilePixelTotal #avg=sum/# of pixels sum[1]/=tilePixelTotal sum[2]/=tilePixelTotal #now compare, do smth (wait, do I compare? or just send #?), and reset #print(equalCnt) #how many pixels correct in this tile? should be 60x50=3,000 #prints 20x4=80 times, so not awful #but can also have an if asking if it's the right # -> same -> many less prints avg=sum/tileNum #sum can work, too, but avg will be a more manageable number (smaller) Gen[pop].fitness=avg #print("fitness score of population img #",pop+1,sep="") #sep by , . sep= indicates diff par #print(Gen[pop].fitness) #T #return avg """ PIXEL BY PIXEL =/=pre-compute so, need tiles of initial/original (global tiles) + tiles of manipulated/current (tilesList) + pixel arrays for all tiles of each (each loop pass=1 tile of each) calcs sum via all correct pixels of all tiles -> avg for whole img per tile -> returned """ def fitness(): for pop in range(0,GenSize): sum=0 for x in range(0,tileNum): #CURRENT TILE pixO=tiles[x].image.load() #original pixC=Gen[pop][x].image.load() #current equalCnt=0 #each tile=50x75, bc 250x300 -> 3750. for r in range(0,tilePixelX): for c in range(0,tilePixelY): colorO=pixO[r,c] #current RGB tuple from original colorC=pixC[r,c] #current RGB tuple from changed/current equal=True #assume RGBs are equal for color in range(3): #color=R, G, B. goes to each to compare separately if colorO[color]!=colorC[color]: #e- are the R's equal? equal=False break #if R differs=no match=why bother to keep going if equal: equalCnt+=1 sum+=equalCnt #add correct pixels #print(equalCnt) #how many pixels correct in this tile? should be 60x50=3,000 #prints 20x4=80 times, so not awful #but can also have an if asking if it's the right # -> same -> many less prints avg=sum/tileNum #sum can work, too, but avg will be a more manageable number (smaller) Gen[pop].fitness=avg #print("fitness score of population img #",pop+1,sep="") #sep by ,. sep= indicates diff par #print(Gen[pop].fitness) #T #return avg root.mainloop() #root fine? bc window. or does this only run @start to run window?