└── wallpaperfm.py /wallpaperfm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # Wallpaperfm.py is a python script that generates desktop wallpapers from your last.fm music profile. 3 | # by Koant, http://www.last.fm/user/Koant 4 | # ./wallpaper.py will display the instructions 5 | # 6 | # Requirements: 7 | # . Python Imaging Library (probably already installed, available through synaptic for Ubuntu users) 8 | # . a last.fm account and an active internet connection 9 | # 10 | # v. 02 Aug 2010 11 | # Update on 02 Aug 2010: added filelist.reverse() # changed on 02Aug2010 on l.285 12 | 13 | __author__ = 'Koant (http://www.last.fm/user/Koant)' 14 | __version__ = '$02 Aug 2010$' 15 | __date__ = '$Date: 2008/07/17 $' 16 | __copyright__ = 'Copyright (c) 2008 Koant' 17 | __license__ = 'GPL' 18 | 19 | 20 | from urllib import urlopen 21 | from xml.dom import minidom 22 | import os 23 | import os.path 24 | import sys 25 | from getopt import getopt 26 | import random 27 | import Image 28 | import ImageDraw 29 | import ImageFilter 30 | 31 | def usage(): 32 | print "Quick examples" 33 | print "--------------" 34 | print "./wallpaperfm.py -m tile -u your_lastfm_username will generate an image with all your favorite albums tiled up in a random order." 35 | print "./wallpaperfm.py -m glass -u your_lastfm_username will generate an image with a small random collection of albums, with a glassy effect." 36 | print "./wallpaperfm.py -m collage -u your_lastfm_username will generate a random collage of your favorite albums." 37 | 38 | print "\nGlobal switches:" 39 | print "-u, --Username: your last.fm username." 40 | print "-f, --Filename: the filename where the image will be saved. Username by default." 41 | print "-t, --Past: [overall] how far back should the profile go. One of 3month,6month,12month or overall." 42 | print "-O, --FinalOpacity: [80] darkness of the final image. from 0 to 100" 43 | print "-i, --ImageSize: [1280x1024] size of the final image. Format: numberxnumber" 44 | print "-c, --CanvasSize: size of the canvas. = image size by default." 45 | print "-e, --Cache: [wpcache] path to the cache." 46 | print "-x, --ExcludedList: ['http://cdn.last.fm/depth/catalogue/noimage/cover_med.gif'] excluded urls, comma separated." 47 | print "-l, --Local: use a local copy of the charts. Ideal for using it offline or being kind to the last.fm servers." 48 | 49 | print "\nSpecific switches for the 'tile' mode (-m tile):" 50 | print "-a, --AlbumSize: [130] size of the albums, in pixel." 51 | print "-s, --Interspace: [5] space between in tile, in pixel." 52 | 53 | print "\nSpecific switches for the 'glass' mode (-m glass):" 54 | print "-n, --AlbumNumber: [7] number of albums to show." 55 | print "-d, --EndPoint: [75] controls when the shadow ends, in percentage of the album size." 56 | print "-r, --Offset: [40] starting value of opacity for the shadow." 57 | 58 | print "\nSpecific switches for the 'collage' mode (-m collage):" 59 | print "-a, --AlbumSize: [250] size of the albums, in pixel." 60 | print "-o, --AlbumOpacity: [90] maximum opacity of each album, from 0 to 100." 61 | print "-n, --AlbumNumber: [50] number of albums to show." 62 | print "-g, --GradientSize: [15] portion of the album in the gradient, from 0 to 100" 63 | print "-p, --Passes: [4] number of iterations of the algorithms." 64 | sys.exit() 65 | 66 | def getSize(s): 67 | """ Turns '300x400' to (300,400) """ 68 | return tuple([int(item) for item in s.rsplit('x')]) 69 | 70 | def getParameters(): 71 | """ Get Parameters from the command line or display usage in case of problem """ 72 | # Common Default Parameters 73 | Filename='' 74 | mode='tile' 75 | 76 | Profile=dict() 77 | Profile['Username']='Koant' 78 | Profile['Past']='overall' 79 | Profile['cache']='wpcache' 80 | Profile['ExcludedList']=['http://cdn.last.fm/depth/catalogue/noimage/cover_med.gif','http://cdn.last.fm/flatness/catalogue/noimage/2/default_album_medium.png','http://userserve-ak.last.fm/serve/174s/32868291.png'] 81 | Profile['Limit']=50 82 | Profile['Local']='no' 83 | 84 | Common=dict(); 85 | Common['ImageSize']=(1280,1024) 86 | Common['CanvasSize']='' 87 | Common['FinalOpacity']=80 88 | 89 | ## Specific Default Parameters 90 | # Collage 91 | Collage=dict(); 92 | Collage['Passes']=4 93 | Collage['AlbumOpacity']=90 94 | Collage['GradientSize']=15 95 | Collage['AlbumSize']=250 96 | 97 | # Tile 98 | Tile=dict() 99 | Tile['AlbumSize']=130 100 | Tile['Interspace']=5 101 | 102 | # Glass 103 | Glass=dict() 104 | Glass['AlbumNumber']=7 105 | Glass['Offset']=40 106 | Glass['EndPoint']=75 107 | 108 | try: 109 | optlist, args=getopt(sys.argv[1:], 'hu:t:n:c:f:a:o:g:O:i:m:p:s:e:d:r:x:l',["help", "Mode=", "Username=", "Past=", "Filename=","CanvasSize=", "ImageSize=", "FinalOpacity=", "AlbumSize=","AlbumOpacity=","GradientSize=", "Passes=", "AlbumNumber=", "Interspace=","Cache=","Offset=","EndPoint=","ExcludedList=","Local"]) 110 | except Exception, err: 111 | print "#"*20 112 | print str(err) 113 | print "#"*20 114 | usage() 115 | if len(optlist)==0: 116 | usage() 117 | for option, value in optlist: 118 | if option in ('-h','--help'): 119 | usage() 120 | elif option in ('-m','--Mode'): # m: mode, one of Tile,Glass or Collage 121 | mode=value.lower() 122 | 123 | elif option in('-e','--Cache'): # e: cache 124 | Profile['cache']=value 125 | 126 | elif option in('-l','--Local'): # l: use a local copy of the charts 127 | Profile['Local']='yes' 128 | 129 | elif option in ('-u','--Username'): # u: username (Common) 130 | Profile['Username']=value 131 | 132 | elif option in ('-t','--Past'): # t: how far back (Common), either 3month,6month or 12month 133 | Profile['Past']=value 134 | 135 | elif option in ('-x','--ExcludedList'): # x: excluded url 136 | Profile['ExcludedList'].extend(value.rsplit(',')) 137 | 138 | elif option in ('-f', '--Filename'): # f: image filename (Common) 139 | Filename=value 140 | 141 | elif option in ('-c','--CanvasSize'): # c: canvas size (Common) 142 | Common['CanvasSize']=getSize(value) 143 | 144 | elif option in ('-i','--ImageSize'): # i: image size (Common) 145 | Common['ImageSize']=getSize(value) 146 | 147 | elif option in ('-O', '--FinalOpacity'): # O: opacity of final image (Common) 148 | Common['FinalOpacity']=int(value) 149 | 150 | elif option in ('-a','--AlbumSize'): # a: album size (Collage,Tile) 151 | Collage['AlbumSize']=int(value) 152 | Tile['AlbumSize']=int(value) 153 | 154 | elif option in ('-o','--AlbumOpacity'): # o: album opacity (Collage) 155 | Collage['AlbumOpacity']=int(value) 156 | 157 | elif option in ('-g','--GradientSize'): # g: gradient size (Collage) 158 | Collage['GradientSize']=int(value) 159 | 160 | elif option in ('-p','--Passes'): # p: number of passes (Collage) 161 | Collage['Passes']=int(value) 162 | 163 | elif option in ('-n','--AlbumNumber'): # n: number of albums (Glass, Collage) 164 | Glass['AlbumNumber']=int(value) 165 | Collage['AlbumNumber']=int(value) 166 | 167 | elif option in ('-s','--Interspace'): # s: interspace (Tile) 168 | Tile['Interspace']=int(value) 169 | 170 | elif option in ('-d','--EndPoint'): # d: EndPoint (Glass) 171 | Glass['EndPoint']=int(value) 172 | 173 | elif option in ('-r','--Offset'): # r: Offset (Glass) 174 | Glass['Offset']=int(value) 175 | 176 | 177 | else: 178 | print "I'm not using ", option 179 | 180 | if Filename=='': # by default, Filename=Username 181 | Filename=Profile['Username'] 182 | if Common['CanvasSize']=='': # by default, CanvasSize=ImageName 183 | Common['CanvasSize']=Common['ImageSize'] 184 | 185 | # Add the common parameters 186 | for k,v in Common.iteritems(): 187 | Collage[k]=v 188 | Tile[k]=v 189 | Glass[k]=v 190 | 191 | return {'Filename':Filename, 'Mode':mode, 'Profile':Profile, 'Tile':Tile, 'Glass':Glass, 'Collage':Collage} 192 | 193 | ############################## 194 | ## Parse and download the files 195 | ############################## 196 | def makeFilename(url): 197 | """ Turns the url into a filename by replacing possibly annoying characters by _ """ 198 | url=url[7:] # remove 'http://' 199 | for c in ['/', ':', '?', '#', '&','%']: 200 | url=url.replace(c,'_') 201 | return url 202 | 203 | def download(url,filename): 204 | """ download the binary file at url """ 205 | instream=urlopen(url) 206 | outfile=open(filename,'wb') 207 | for chunk in instream: 208 | outfile.write(chunk) 209 | instream.close() 210 | outfile.close() 211 | 212 | def IsImageFile(imfile): 213 | """ Make sure the file is an image, and not a 404. """ 214 | flag=True 215 | try: 216 | i=Image.open(imfile) 217 | except Exception,err: 218 | flag=False 219 | return flag 220 | 221 | def getAlbumCovers(Username='Koant',Past='overall',cache='wp_cache',ExcludedList=['http://cdn.last.fm/depth/catalogue/noimage/cover_med.gif','http://cdn.last.fm/flatness/catalogue/noimage/2/default_album_medium.png'],Limit=50,Local='no'): 222 | """ download album covers if necessary """ 223 | ## Preparing the file list. 224 | if Past in ('3month','6month','12month'): 225 | tpe='&type='+Past 226 | else: 227 | tpe='' 228 | 229 | url='http://ws.audioscrobbler.com/1.0/user/'+Username+'/topalbums.xml?limit='+str(Limit)+tpe 230 | 231 | # make cache if doesn't exist 232 | if not os.path.exists(cache): 233 | print "cache directory ("+cache+") doesn't exist. I'm creating it." 234 | os.mkdir(cache) 235 | 236 | # Make a local copy of the charts 237 | if Local=='no': 238 | try: 239 | print "Downloading from ",url 240 | download(url,cache+os.sep+'charts_'+Username+'.xml') 241 | except Exception,err: 242 | print "#"*20 243 | print "I couldn't download the profile or make a local copy of it." 244 | print "#"*20 245 | else: 246 | print "Reading from local copy: ",cache+os.sep+'charts_'+Username+'.xml' 247 | 248 | # Parse image filenames 249 | print "Parsing..." 250 | try: 251 | data=open(cache+os.sep+'charts_'+Username+'.xml','rb') 252 | xmldoc=minidom.parse(data) 253 | data.close() 254 | except Exception,err: 255 | print '#'*20 256 | print "Error while parsing your profile. Your username might be misspelt or your charts empty." 257 | print '#'*20 258 | sys.exit() 259 | 260 | filelist=[imfile.firstChild.data for imfile in xmldoc.getElementsByTagName('large')] 261 | 262 | 263 | 264 | # Exclude covers from the ExcludedList 265 | filelist=[item for item in filelist if not item in ExcludedList] 266 | 267 | # Stop if charts are empty 268 | if len(filelist)==0: 269 | print '#'*20 270 | print "Your charts are empty. I can't proceed." 271 | print '#'*20 272 | sys.exit() 273 | 274 | # download covers only if not available in the cache 275 | for imfile in filelist[:]: 276 | url=imfile 277 | imfile=makeFilename(imfile) 278 | if not os.path.exists(cache+os.sep+imfile): 279 | print " Downloading ",url 280 | download(url,cache+os.sep+imfile) 281 | 282 | filelist=[cache+os.sep+makeFilename(imfile) for imfile in filelist] 283 | 284 | filelist=[imfile for imfile in filelist if IsImageFile(imfile)] # Checks the file is indeed an image 285 | filelist.reverse() # changed on 02Aug2010 286 | return filelist 287 | 288 | ############################## 289 | ## Tile 290 | ############################## 291 | def Tile(Profile,ImageSize=(1280,1024),CanvasSize=(1280,1024),AlbumSize=130,FinalOpacity=30,Interspace=5): 292 | """ produce a tiling of albums covers """ 293 | 294 | imagex,imagey=ImageSize 295 | canvasx,canvasy=CanvasSize 296 | 297 | offsetx=(imagex-canvasx)/2 298 | offsety=(imagey-canvasy)/2 299 | 300 | #number of albums on rows and columns 301 | nx=(canvasx-Interspace)/(AlbumSize+Interspace) 302 | ny=(canvasy-Interspace)/(AlbumSize+Interspace) 303 | 304 | # number of images to download 305 | Profile['Limit']=ny*nx+len(Profile['ExcludedList'])+5 # some extra in case of 404 , even though there shouldn't be any really. 306 | 307 | # download images 308 | filelist=getAlbumCovers(**Profile) 309 | 310 | background=Image.new('RGB',(imagex,imagey),0) # background 311 | 312 | filelist2=list() 313 | posy=-AlbumSize+(canvasy-ny*(AlbumSize+Interspace)-Interspace)/2 314 | for j in range(0,ny): 315 | posx,posy=(-AlbumSize+(canvasx-nx*(AlbumSize+Interspace)-Interspace)/2,posy+Interspace+AlbumSize) # location of album in the canvas 316 | for i in range(0,nx): 317 | posx=posx+Interspace+AlbumSize 318 | if len(filelist2)==0: # better than random.choice() (minimises risk of doubles and goes through the whole list) 319 | filelist2=list(filelist) 320 | random.shuffle(filelist2) 321 | imfile=filelist2.pop() 322 | try: 323 | im=Image.open(imfile).convert('RGB') 324 | except Exception,err: 325 | print "#"*20 326 | print err 327 | print "I couln't read that file: "+imfile 328 | print "You might want to exclude its corresponding URL with -x because it probably doesn't point to an image." 329 | print "#"*20 330 | sys.exit() 331 | im=im.resize((AlbumSize,AlbumSize),2) 332 | background.paste(im,(posx+offsetx,posy+offsety)) 333 | 334 | # darken the result 335 | background=background.point(lambda i: FinalOpacity*i/100) 336 | return background 337 | 338 | ############################## 339 | ## Glassy wallpaper 340 | ############################## 341 | def makeGlassMask(ImageSize,Offset=50,EndPoint=75): 342 | """ Make mask for the glassy wallpaper """ 343 | mask=Image.new('L',ImageSize,0) 344 | di=ImageDraw.Draw(mask) 345 | sizex,sizey=ImageSize 346 | 347 | stop=min((EndPoint*sizey)/100,sizey) 348 | E=EndPoint*sizey/100 349 | O=255*Offset/100 350 | for i in range(0,stop): 351 | color=(255*Offset/100*-100*i)/(EndPoint*sizey)+255*Offset/100 #linear gradient 352 | #color=((i-E)*(i-E)*O)/(E*E) # quadratic gradient 353 | #color=(O*(E*E-i*i))/(E*E) 354 | di.line((0,i,sizex,i),color) 355 | return mask 356 | 357 | def Glass(Profile, ImageSize=(1280,1024),CanvasSize=(1280,1024),AlbumNumber=7,FinalOpacity=100,Offset=50,EndPoint=75): 358 | """ Make a glassy wallpaper from album covers """ 359 | 360 | if AlbumNumber>Profile['Limit']: 361 | Profile['Limit']=AlbumNumber+len(Profile['ExcludedList'])+5 362 | 363 | filelist=getAlbumCovers(**Profile) 364 | imagex,imagey=ImageSize 365 | 366 | canvasx,canvasy=CanvasSize 367 | 368 | offsetx=(imagex-canvasx)/2 369 | offsety=(imagey-canvasy)/2 370 | 371 | background=Image.new('RGB',(imagex,imagey),0) # background 372 | 373 | albumsize=canvasx/AlbumNumber 374 | mask=makeGlassMask((albumsize,albumsize),Offset,EndPoint) 375 | 376 | posx=(canvasx-AlbumNumber*albumsize)/2-albumsize 377 | 378 | for i in range(0,AlbumNumber): 379 | imfile=filelist.pop() # assumes there are enough albums in the filelist 380 | tmpfile=Image.open(imfile).convert('RGB') 381 | tmpfile=tmpfile.resize((albumsize,albumsize),2) # make it square, prettier 382 | posx,posy=(posx+albumsize,canvasy/2-albumsize) 383 | background.paste(tmpfile,(posx+offsetx,posy+offsety)) # paste the album cover 384 | tmpfile=tmpfile.transpose(1) #turn it upside down 385 | background.paste(tmpfile,(posx+offsetx,canvasy/2+offsety),mask) # apply mask and paste 386 | # darken the result 387 | background=background.point(lambda i: FinalOpacity*i/100) 388 | return background 389 | 390 | ############################ 391 | ## Collage 392 | ############################ 393 | def erfc(x): 394 | """ approximate erfc with a few splines """ 395 | if x<-2: 396 | return 2; 397 | elif (-2<=x) and (x<-1): 398 | c=[ 0.9040, -1.5927, -0.7846, -0.1305]; 399 | elif (-1<=x) and (x<0): 400 | c=[1.0000, -1.1284, -0.1438, 0.1419]; 401 | elif (0<=x) and (x<1): 402 | c=[1.0000, -1.1284 , 0.1438, 0.1419]; 403 | elif (1<=x) and (x<2): 404 | c=[1.0960, -1.5927, 0.7846 , -0.1305]; 405 | else: 406 | return 0; 407 | return c[0]+c[1]*x+c[2]*x*x+c[3]*x*x*x; 408 | 409 | def makeCollageMask(size,transparency,gradientsize): 410 | mask=Image.new('L',size,0) 411 | sizex,sizey=size 412 | l=(gradientsize*sizex)/100 413 | c=(255*transparency)/100.0 414 | c=c/4.0 # 4=normalizing constant from convolution 415 | s2=1/(l*1.4142) 416 | for i in range(sizex): 417 | for j in range(sizey): 418 | v=c*(erfc(s2*(l-i))-erfc(s2*(sizex-l-i)))*(erfc(s2*(l-j))-erfc(s2*(sizex-l-j))) 419 | mask.putpixel((i,j),int(v)) 420 | 421 | return mask 422 | 423 | def Collage(Profile,ImageSize=(1280,1024),CanvasSize=(1280,1024),AlbumNumber=50,AlbumSize=300,GradientSize=20,AlbumOpacity=70,Passes=4,FinalOpacity=70): 424 | """ make a collage of album covers """ 425 | 426 | Profile['Limit']=min(200,max(AlbumNumber,Profile['Limit'])) 427 | 428 | filelist=getAlbumCovers(**Profile) 429 | 430 | imagex,imagey=ImageSize 431 | canvasx,canvasy=CanvasSize 432 | 433 | background=Image.new('RGB',(imagex,imagey),0) # background 434 | mask=makeCollageMask((AlbumSize,AlbumSize),AlbumOpacity,GradientSize) 435 | print "Computing the collage..." 436 | for p in range(0,Passes): 437 | print "Pass ",p+1," of ",Passes 438 | for imfile in filelist: 439 | tmpfile=Image.open(imfile).convert('RGB') 440 | tmpfile=tmpfile.resize((AlbumSize,AlbumSize),1) 441 | posx=random.randint(0,canvasx-AlbumSize) 442 | posy=random.randint(0,canvasy-AlbumSize) 443 | background.paste(tmpfile,(posx+(imagex-canvasx)/2,posy+(imagey-canvasy)/2),mask) 444 | 445 | # darken the result 446 | background=background.point(lambda i: FinalOpacity*i/100) 447 | return background 448 | 449 | ######################## 450 | ## main 451 | ######################## 452 | def main(): 453 | print "" 454 | print " Wallpaperfm.py is a python script that generates desktop wallpapers from your last.fm musical profile." 455 | print " by Koant, http://www.last.fm/user/Koant" 456 | print "" 457 | param=getParameters() 458 | 459 | print "Mode: "+param['Mode'] 460 | print " Image will be saved as "+param['Filename']+".jpg" 461 | if param['Mode']=='tile': 462 | for k,v in param['Tile'].iteritems(): 463 | print " "+k+": "+str(v) 464 | image=Tile(param['Profile'],**param['Tile']) 465 | elif param['Mode']=='glass': 466 | for k,v in param['Glass'].iteritems(): 467 | print " "+k+": "+str(v) 468 | image=Glass(param['Profile'],**param['Glass']) 469 | elif param['Mode']=='collage': 470 | for k,v in param['Collage'].iteritems(): 471 | print " "+k+": "+str(v) 472 | image=Collage(param['Profile'],**param['Collage']) 473 | else: 474 | print " I don't know this mode: ", param['Mode'] 475 | sys.exit() 476 | 477 | image.save(param['Filename']+'.jpg') 478 | print "Image saved as "+param['Filename']+'.jpg' 479 | 480 | if __name__=="__main__": 481 | main() 482 | --------------------------------------------------------------------------------