Module calmagick
[hide private]
[frames] | no frames]

Source Code for Module calmagick

  1  #!/usr/bin/env python2.7 
  2  # -*- coding: utf-8 -*- 
  3   
  4  #    callirhoe - high quality calendar rendering 
  5  #    Copyright (C) 2012-2014 George M. Tzoumas 
  6   
  7  #    This program is free software: you can redistribute it and/or modify 
  8  #    it under the terms of the GNU General Public License as published by 
  9  #    the Free Software Foundation, either version 3 of the License, or 
 10  #    (at your option) any later version. 
 11  # 
 12  #    This program is distributed in the hope that it will be useful, 
 13  #    but WITHOUT ANY WARRANTY; without even the implied warranty of 
 14  #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the 
 15  #    GNU General Public License for more details. 
 16  # 
 17  #    You should have received a copy of the GNU General Public License 
 18  #    along with this program.  If not, see http://www.gnu.org/licenses/ 
 19   
 20  # ***************************************************************** 
 21  #                                                                 # 
 22  """  high quality photo calendar composition using ImageMagick  """ 
 23  #                                                                 # 
 24  # ***************************************************************** 
 25   
 26  import sys 
 27  import subprocess 
 28  import os.path 
 29  import os 
 30  import tempfile 
 31  import glob 
 32  import random 
 33  import optparse 
 34  import Queue 
 35  import threading 
 36   
 37  import lib 
 38  from lib.geom import rect_rel_scale 
 39   
 40  # MAYBE-TODO 
 41  # move to python 3? 
 42  # check ImageMagick availability/version 
 43  # convert input to ImageMagick native format for faster re-access 
 44  # report error on parse-float (like atoi()) 
 45  # abort --range only on KeyboardInterrupt? 
 46   
 47  _prog_im = os.getenv('CALLIRHOE_IM', 'convert') 
 48  """ImageMagick binary, either 'convert' or env var C{CALLIRHOE_IM}""" 
 49   
50 -def run_callirhoe(style, size, args, outfile):
51 """launch callirhoe to generate a calendar 52 53 @param style: calendar style to use (passes -s option to callirhoe) 54 @param size: tuple (I{width},I{height}) for output calendar size (in pixels) 55 @param args: (extra) argument list to pass to callirhoe 56 @param outfile: output calendar file 57 @rtype: subprocess.Popen 58 @return: Popen object 59 """ 60 return subprocess.Popen(['callirhoe', '-s', style, '--paper=-%d:-%d' % size] + args + [outfile])
61
62 -def _bound(x, lower, upper):
63 """return the closest number to M{x} that lies in [M{lower,upper}] 64 65 @rtype: type(x) 66 """ 67 if x < lower: return lower 68 if x > upper: return upper 69 return x
70
71 -class PNMImage(object):
72 """class to represent an PNM grayscale image given in P2 format 73 74 @ivar data: image data as 2-dimensional array (list of lists) 75 @ivar size: tuple M{(width,height)} of image dimensions 76 @ivar maxval: maximum grayscale value 77 @ivar _rsum_cache: used by L{_rsum()} to remember results 78 @ivar xsum: 2-dimensional array of running x-sums for each line, used for efficient 79 computation of block averages, resulting in M{O(H)} complexity, instead of M{O(W*H)}, 80 where M{W,H} the image dimensions 81 """
82 - def __init__(self, strlist):
83 self.data = []; 84 state = 0; 85 for i in range(len(strlist)): 86 # skip comments 87 if strlist[i].startswith('#'): continue 88 # skip empty lines 89 if len(strlist[i]) == 0: continue 90 # parse header 91 if state == 0: 92 if not strlist[i].startswith('P2'): 93 raise RuntimeError('invalid PNM image format: %s' % strlist[i]) 94 state += 1 95 # parse size 96 elif state == 1: 97 w,h = map(int,strlist[i].split()) 98 if w != h: 99 raise RuntimeError('non-square PNM image') 100 self.size = (w,h) 101 state += 1 102 # parse max value 103 elif state == 2: 104 self.maxval = int(strlist[i]) 105 state += 1 106 # bitmap 107 else: 108 data = ' '.join(filter(lambda s: not s.startswith('#'), strlist[i:])) 109 intlist = map(int,data.split()) 110 self.data = [intlist[x:x+w] for x in range(0, len(intlist), w)] 111 break 112 113 self._rsum_cache=(-1,-1,0) # y,x,s 114 # self.xsum = [map(lambda x: sum(self.data[y][0:x]), range(w+1)) for y in range(0,h)] 115 self.xsum = [map(lambda x: self._rsum(y,x), range(w+1)) for y in range(0,h)]
116
117 - def _rsum(self,y,x):
118 """running sum with cache 119 120 @rtype: int 121 """ 122 if self._rsum_cache[0] == y and self._rsum_cache[1] == x: 123 s = self._rsum_cache[2] + self.data[y][x-1] 124 else: 125 s = sum(self.data[y][0:x]) 126 self._rsum_cache = (y,x+1,s) 127 return s
128
129 - def block_avg(self, x, y, szx, szy):
130 """returns the average intensity of a block of size M{(szx,szy)} at pos (top-left) M{(x,y)} 131 132 @rtype: float 133 """ 134 return float(sum([(self.xsum[y][x+szx] - self.xsum[y][x]) for y in range(y,y+szy)]))/(szx*szy)
135
136 - def lowest_block_avg(self, szx, szy, at_least = 0):
137 """returns the M{(szx,szy)}-sized block with intensity as close to M{at_least} as possible 138 @rtype: (float,(float,float),(int,int),(int,int)) 139 @return: R=tuple M({avg, (szx_ratio,szy_ratio), (x,y), (szx,szy))}: R[0] is the 140 average intensity of the block found, R[1] is the block size ratio with respect to the whole image, 141 R[2] is the block position (top-left) and R[3] is the block size 142 """ 143 w,h = self.size 144 best = (self.maxval,(1,1),(0,0),(szx,szy)) # avg, (szx_ratio,szy_ratio), (x,y), (szx,szy) 145 for y in range(0,h-szy+1): 146 for x in range(0,w-szx+1): 147 cur = (self.block_avg(x,y,szx,szy), (float(szx)/w,float(szy)/h), (x,y), (szx,szy)) 148 if cur[0] < best[0]: 149 best = cur 150 if best[0] <= at_least: return best 151 return best
152
153 - def fit_rect(self, size_range = (0.333, 0.8), at_least = 7, relax = 0.2, rr = 1.0):
154 """find the maximal-area minimal-entropy rectangle within the image 155 156 @param size_range: tuple of smallest and largest rect/photo size ratio 157 158 size measured on the 'best-fit' dimension' if rectangle and photo ratios differ 159 @param at_least: early stop of minimization algorithm, when rect of this amount of entropy is found 160 @param relax: relax minimum entropy by a factor of (1+M{relax}), so that bigger sizes can be tried 161 162 This is because usuallly minimal entropy is achieved at a minimal-area box. 163 @param rr: ratio of ratios 164 165 Calendar rectangle ratio over Photo ratio. If M{r>1} then calendar rectangle, when scaled, fits 166 M{x} dimension first. Conversely, if M{r<1}, scaling touches the M{y} dimension first. When M{r=1}, 167 calendar rectangle can fit perfectly within the photo at 100% size. 168 169 @rtype: (float,(float,float),(int,int),(int,int),float) 170 """ 171 w,h = self.size 172 sz_lo = _bound(int(w*size_range[0]+0.5),1,w) 173 sz_hi = _bound(int(w*size_range[1]+0.5),1,w) 174 szv_range = range(sz_lo, sz_hi+1) 175 if rr == 1: 176 sz_range = zip(szv_range, szv_range) 177 elif rr > 1: 178 sz_range = zip(szv_range, map(lambda x: _bound(int(x/rr+0.5),1,w), szv_range)) 179 else: 180 sz_range = zip(map(lambda x: _bound(int(x*rr+0.5),1,w), szv_range), szv_range) 181 best = self.lowest_block_avg(*sz_range[0]) 182 # we do not use at_least because non-global minimum, when relaxed, may jump well above threshold 183 entropy_thres = max(at_least, best[0]*(1+relax)) 184 for sz in list(reversed(sz_range))[0:-1]: 185 # we do not use at_least because we want the best possible option, for bigger sizes 186 cur = self.lowest_block_avg(*sz) 187 if cur[0] <= entropy_thres: return cur + (best[0],) 188 return best + (best[0],) # avg, (szx_ratio,szy_ratio), (x,y), (szx,szy), best_avg
189 190
191 -def get_parser():
192 """get the argument parser object 193 194 @rtype: optparse.OptionParser 195 """ 196 parser = optparse.OptionParser(usage="usage: %prog IMAGE [options] [callirhoe-options] [--pre-magick ...] [--in-magick ...] [--post-magick ...]", 197 description="""High quality photo calendar composition with automatic minimal-entropy placement. 198 If IMAGE is a single file, then a calendar of the current month is overlayed. If IMAGE contains wildcards, 199 then every month is generated according to the --range option, advancing one month for every photo file. 200 Photos will be reused in a round-robin fashion if more calendar 201 months are requested.""", version="callirhoe.CalMagick " + lib._version + '\n' + lib._copyright) 202 parser.add_option("--outdir", default=".", 203 help="set directory for the output image(s); directory will be created if it does not already exist [%default]") 204 parser.add_option("--outfile", default=None, 205 help="set output filename when no --range is requested; by default will use the same name, unless it is going to " 206 "overwrite the input image, in which case suffix '_calmagick' will be added; this option will override --outdir and --format options") 207 parser.add_option("--prefix", type="choice", choices=['no','auto','yes'], default='auto', 208 help="set output filename prefix for multiple image output (with --range); 'no' means no prefix will be added, thus the output " 209 "filename order may not be the same, if the input photos are randomized (--shuffle or --sample), also some output files may be overwritten, " 210 "if input photos are reused in round-robin; " 211 "'auto' adds YEAR_MONTH_ prefix only when input photos are randomized or more months than photos are requested; 'yes' will always add prefix [%default]") 212 parser.add_option("--quantum", type="int", default=60, 213 help="choose quantization level for entropy computation [%default]") 214 parser.add_option("--placement", type="choice", choices="min max N S W E NW NE SW SE center random".split(), 215 default="min", help="choose placement algorithm among {min, max, " 216 "N, S, W, E, NW, NE, SW, SE, center, random} [%default]") 217 parser.add_option("--min-size", type="float", default=None, 218 help="for min/max/random placement: set minimum calendar/photo size ratio [0.333]; for " 219 "N,S,W,E,NW,NE,SW,SE placement: set margin/opposite-margin size ratio [0.05]; for " 220 "center placement it has no effect") 221 parser.add_option("--max-size", type="float", default=0.8, 222 help="set maximum calendar/photo size ratio [%default]") 223 parser.add_option("--ratio", default="0", 224 help="set calendar ratio either as a float or as X/Y where X,Y positive integers; if RATIO=0 then photo ratio R is used; note that " 225 "for min/max placement, calendar ratio CR will be equal to the closest number (a/b)*R, where " 226 "a,b integers, and MIN_SIZE <= x/QUANTUM <= MAX_SIZE, where x=b if RATIO < R otherwise x=a; in " 227 "any case 1/QUANTUM <= CR/R <= QUANTUM [%default]") 228 parser.add_option("--low-entropy", type="float", default=7, 229 help="set minimum entropy threshold (0-255) for early termination (0=global minimum) [%default]") 230 parser.add_option("--relax", type="float", default=0.2, 231 help="relax minimum entropy multiplying by 1+RELAX, to allow for bigger sizes [%default]") 232 parser.add_option("--negative", type="float", default=100, 233 help="average luminosity (0-255) threshold of the overlaid area, below which a negative " 234 "overlay is chosen [%default]") 235 parser.add_option("--test", type="choice", choices="none area quant quantimg print crop".split(), default='none', 236 help="test entropy minimization algorithm, without creating any calendar, TEST should be among " 237 "{none, area, quant, quantimg, print, crop}: none=test disabled; " 238 "area=show area in original image; quant=show area in quantizer; " 239 "quantimg=show both quantizer and image; print=print minimum entropy area in STDOUT as W H X Y, " 240 "without generating any files at all; crop=crop selected area [%default]") 241 parser.add_option("--alt", action="store_true", default=False, 242 help="use an alternate entropy computation algorithm; although for most cases it should be no better than the default one, " 243 "for some cases it might produce better results (yet to be verified)") 244 parser.add_option("-v", "--verbose", action="store_true", default=False, 245 help="print progress messages") 246 247 cal = optparse.OptionGroup(parser, "Calendar Options", "These options determine how callirhoe is invoked.") 248 cal.add_option("-s", "--style", default="transparent", 249 help="calendar default style [%default]") 250 cal.add_option("--range", default=None, 251 help="""set month range for calendar. Format is MONTH/YEAR or MONTH1-MONTH2/YEAR or 252 MONTH:SPAN/YEAR. If set, these arguments will be expanded (as positional arguments for callirhoe) 253 and a calendar will be created for 254 each month separately, for each input photo. Photo files will be globbed by the script 255 and used in a round-robin fashion if more months are requested. Globbing means that you should 256 normally enclose the file name in single quotes like '*.jpg' in order to avoid shell expansion. 257 If less months are requested, then the calendar 258 making process will terminate without having used all available photos. SPAN=0 will match the number of input 259 photos.""") 260 cal.add_option('-j', "--jobs", type="int", default=1, 261 help="set parallel job count (total number of threads) for the --range iteration; although python " 262 "threads are not true processes, they help running the external programs efficiently [%default]") 263 cal.add_option("--sample", type="int", default=None, 264 help="choose SAMPLE random images from the input and use in round-robin fashion (see --range option); if " 265 "SAMPLE=0 then the sample size is chosen to as big as possible, either equal to the month span defined with --range, or " 266 "equal to the total number of available photos") 267 cal.add_option("--shuffle", action="store_true", default=False, 268 help="shuffle input images and to use in round-robin fashion (see --range option); " 269 "the sample size is chosen to be equal to the month span defined with --range or equal to " 270 "the total number of available photos (whichever is smaller); this " 271 "is equivalent to specifying --sample=0") 272 cal.add_option("--vanilla", action="store_true", default=False, 273 help="suppress default options --no-footer --border=0") 274 parser.add_option_group(cal) 275 276 im = optparse.OptionGroup(parser, "ImageMagick Options", "These options determine how ImageMagick is used.") 277 im.add_option("--format", default="", 278 help="determines the file extension (without dot!) of the output image files; " 279 "use this option to generate files in a different format than the input, for example " 280 "to preserve quality by generating PNG from JPEG, thus not recompressing") 281 im.add_option("--brightness", type="int", default=10, 282 help="increase/decrease brightness by this (percent) value; " 283 "brightness is decreased on negative overlays [%default]") 284 im.add_option("--saturation", type="int", default=100, 285 help="set saturation of the overlaid area " 286 "to this value (percent) [%default]") 287 # im.add_option("--radius", type="float", default=2, 288 # help="radius for the entropy computation algorithm [%default]") 289 im.add_option("--pre-magick", action="store_true", default=False, 290 help="pass all subsequent arguments to ImageMagick, before entropy computation; should precede --in-magick and --post-magick") 291 im.add_option("--in-magick", action="store_true", default=False, 292 help="pass all subsequent arguments to ImageMagick, to be applied on the minimal-entropy area; should precede --post-magick") 293 im.add_option("--post-magick", action="store_true", default=False, 294 help="pass all subsequent arguments to ImageMagick, to be applied on the final output") 295 parser.add_option_group(im) 296 return parser
297
298 -def check_parsed_options(options):
299 """set (remaining) default values and check validity of various option combinations""" 300 if options.min_size is None: 301 options.min_size = min(0.333,options.max_size) if options.placement in ['min','max','random'] else min(0.05,options.max_size) 302 if options.min_size > options.max_size: 303 raise lib.Abort("calmagick: --min-size should not be greater than --max-size") 304 if options.sample is not None and not options.range: 305 raise lib.Abort("calmagick: --sample requested without --range") 306 if options.outfile is not None and options.range: 307 raise lib.Abort("calmagick: you cannot specify both --outfile and --range options") 308 if options.sample is not None and options.shuffle: 309 raise lib.Abort("calmagick: you cannot specify both --shuffle and --sample options") 310 if options.shuffle: 311 options.sample = 0 312 if options.sample is None: 313 if options.prefix == 'auto': options.prefix = 'no?' # dirty, isn't it? :) 314 else: 315 if options.prefix == 'auto': options.prefix = 'yes' 316 if options.jobs < 1: options.jobs = 1
317
318 -def parse_magick_args():
319 """extract arguments from command-line that will be passed to ImageMagick 320 321 ImageMagick-specific arguments should be defined between arguments C{--pre-magick}, 322 C{--in-magick}, C{--post-magick} is this order 323 324 @rtype: [[str,...],[str,...],[str,...]] 325 @return: 3-element list of lists containing the [pre,in,post]-options 326 """ 327 magickargs = [[],[],[]] 328 try: 329 m = sys.argv.index('--post-magick') 330 magickargs[2] = sys.argv[m+1:] 331 del sys.argv[m:] 332 except: 333 pass 334 try: 335 m = sys.argv.index('--in-magick') 336 magickargs[1] = sys.argv[m+1:] 337 del sys.argv[m:] 338 except: 339 pass 340 try: 341 m = sys.argv.index('--pre-magick') 342 magickargs[0] = sys.argv[m+1:] 343 del sys.argv[m:] 344 except: 345 pass 346 if ('--post-magick' in magickargs[2] or '--in-magick' in magickargs[2] or 347 '--pre-magick' in magickargs[2] or '--in-magick' in magickargs[1] or 348 '--pre-magick' in magickargs[1] or '--pre-magick' in magickargs[0]): 349 parser.print_help() 350 sys.exit(0) 351 return magickargs
352
353 -def mktemp(ext=''):
354 """get temporary file name with optional extension 355 356 @rtype: str 357 """ 358 f = tempfile.NamedTemporaryFile(suffix=ext, delete=False) 359 f.close() 360 return f.name
361
362 -def get_outfile(infile, outdir, base_prefix, format, hint=None):
363 """get output file name taking into account output directory, format and prefix, avoiding overwriting the input file 364 365 @rtype: str 366 """ 367 if hint: 368 outfile = hint 369 else: 370 head,tail = os.path.split(infile) 371 base,ext = os.path.splitext(tail) 372 if format: ext = '.' + format 373 outfile = os.path.join(outdir,base_prefix+base+ext) 374 if os.path.exists(outfile) and os.path.samefile(infile, outfile): 375 if hint: raise lib.Abort("calmagick: --outfile same as input, aborting") 376 outfile = os.path.join(outdir,base_prefix+base+'_calmagick'+ext) 377 return outfile
378
379 -def _IM_get_image_size(img, args):
380 """extract tuple(width,height) from image file using ImageMagick 381 382 @rtype: (int,int) 383 """ 384 info = subprocess.check_output([_prog_im, img] + args + ['-format', '%w %h', 'info:']).split() 385 return tuple(map(int, info))
386 387 _IM_lum_args = "-colorspace Lab -channel R -separate +channel -set colorspace Gray".split() 388 """IM colorspace conversion arguments to extract image luminance""" 389
390 -def _IM_get_image_luminance(img, args, geometry = None):
391 """get average image luminance as a float in [0,255], using ImageMagick 392 393 @rtype: float 394 """ 395 return 255.0*float(subprocess.check_output([_prog_im, img] + args + 396 (['-crop', '%dx%d+%d+%d' % geometry] if geometry else []) + 397 _IM_lum_args + ['-format', '%[fx:mean]', 'info:']))
398 399 _IM_entropy_head = "-scale 262144@>".split() 400 """IM args for entropy computation: pre-scaling""" 401 _IM_entropy_alg = ["-define convolve:scale=! -define morphology:compose=Lighten -morphology Convolve Sobel:>".split(), 402 "( +clone -blur 0x2 ) +swap -compose minus -composite".split()] 403 """IM main/alternate entropy computation operator""" 404 _IM_entropy_tail = "-colorspace Lab -channel R -separate +channel -set colorspace Gray -normalize -scale".split() 405 """IM entropy computation final colorspace""" 406 #_IM_entropy_tail = "-colorspace Lab -channel R -separate +channel -normalize -scale".split() 407
408 -def _IM_entropy_args(alt=False):
409 """IM entropy computation arguments, depending on default or alternate algorithm 410 411 @rtype: [str,...] 412 """ 413 return _IM_entropy_head + _IM_entropy_alg[alt] + _IM_entropy_tail
414
415 -def _entropy_placement(img, size, args, options, r):
416 """get rectangle of minimal/maximal entropy 417 418 @param img: image file 419 @param size: image size tuple(I{width,height}) 420 @param args: ImageMagick pre-processing argument list (see C{--pre-magick}) 421 @param options: (command-line) options object 422 @param r: rectangle ratio, 0=match input ratio 423 @rtype: (int,int,int,int) 424 @return: IM geometry tuple(I{width,height,x,y}) 425 """ 426 w,h = size 427 R = float(w)/h 428 if r == 0: r = R 429 if options.verbose: 430 print "Calculating image entropy..." 431 qresize = '%dx%d!' % ((options.quantum,)*2) 432 pnm_entropy = PNMImage(subprocess.check_output([_prog_im, img] + args + _IM_entropy_args(options.alt) + 433 [qresize, '-normalize'] + (['-negate'] if options.placement == 'max' else []) + "-compress None pnm:-".split()).splitlines()) 434 435 # find optimal fit 436 if options.verbose: print "Fitting... ", 437 best = pnm_entropy.fit_rect((options.min_size,options.max_size), options.low_entropy, options.relax, r/R) 438 if options.verbose: 439 print "ent=%0.2f frac=(%0.2f,%0.2f) pos=(%d,%d) bs=(%d,%d) min=%0.2f r=%0.2f" % ( 440 best[0], best[1][0], best[1][1], best[2][0], best[2][1], best[3][0], best[3][1], best[4], R*best[3][0]/best[3][1]) 441 442 # (W,H,X,Y) 443 w,h = size 444 geometry = tuple(map(int, (w*best[1][0], h*best[1][1], 445 float(w*best[2][0])/pnm_entropy.size[0], 446 float(h*best[2][1])/pnm_entropy.size[1]))) 447 return geometry
448
449 -def _manual_placement(size, options, r):
450 """get rectangle of ratio I{r} with user-defined placement (N,S,W,E,NW,NE,SW,SE,center,random) 451 452 @param size: image size tuple(I{width,height}) 453 @param options: (command-line) options object 454 @param r: rectangle ratio, 0=match input ratio 455 @rtype: (int,int,int,int) 456 @return: IM geometry tuple(I{width,height,x,y}) 457 """ 458 w,h = size 459 rect = (0, 0, w, h) 460 R = float(w)/h 461 if r == 0: r = R 462 if r == R: # float comparison should succeed here 463 fx, fy = 1.0, 1.0 464 elif r > R: 465 fx,fy = 1.0, R/r 466 else: 467 fx,fy = r/R, 1.0 468 if options.placement == 'random': 469 f = random.uniform(options.min_size, options.max_size) 470 rect2 = rect_rel_scale(rect, f*fx, f*fy, random.uniform(-1,1), random.uniform(-1,1)) 471 else: 472 ax = ay = 0 473 if 'W' in options.placement: ax = -1 + 2.0*options.min_size 474 if 'E' in options.placement: ax = 1 - 2.0*options.min_size 475 if 'N' in options.placement: ay = -1 + 2.0*options.min_size 476 if 'S' in options.placement: ay = 1 - 2.0*options.min_size 477 rect2 = rect_rel_scale(rect, options.max_size*fx, options.max_size*fy, ax, ay) 478 return tuple(map(int,[rect2[2], rect2[3], rect2[0], rect2[1]]))
479 480 _cache = dict() # {'filename': (geometry, is_dark)} 481 """cache input photo computed rectangle and luminance, key=filename, value=(geometry,is_dark)""" 482 _mutex = threading.Lock() 483 """mutex for cache access""" 484
485 -def get_cache(num_photos, num_months):
486 """returns a reference to the cache object, or None if caching is disabled 487 488 @rtype: dict 489 @note: caching is enabled only when more than 1/6 of photos is going to be re-used 490 """ 491 q,r = divmod(num_months, num_photos) 492 if q > 1: return _cache 493 if q < 1 or r == 0: return None 494 return _cache if (num_photos / r <= 6) else None;
495
496 -def compose_calendar(img, outimg, options, callirhoe_args, magick_args, stats=None, cache=None):
497 """performs calendar composition on a photo image 498 499 @param img: photo file 500 @param outimg: output file 501 @param options: (command-line) options object 502 @param callirhoe_args: extra argument list to pass to callirhoe 503 @param magick_args: [pre,in,post]-magick argument list 504 @param stats: if not C{None}: tuple(I{current,total}) counting input photos 505 @param cache: if cache enabled, points to the cache dictionary 506 """ 507 # get image info (dimensions) 508 geometry, dark = None, None 509 if cache is not None: 510 with _mutex: 511 if img in cache: 512 geometry, dark = cache[img] 513 if options.verbose and geometry: 514 if stats: print "[%d/%d]" % stats, 515 print "Reusing image info from cache...", geometry, "DARK" if dark else "LIGHT" 516 517 if geometry is None: 518 if options.verbose: 519 if stats: print "[%d/%d]" % stats, 520 print "Extracting image info..." 521 w,h = _IM_get_image_size(img, magick_args[0]) 522 qresize = '%dx%d!' % ((options.quantum,)*2) 523 if options.verbose: 524 print "%s %dx%d %dmp R=%0.2f" % (img, w, h, int(w*h/1000000.0+0.5), float(w)/h) 525 526 if '/' in options.ratio: 527 tmp = options.ratio.split('/') 528 calratio = float(lib.atoi(tmp[0],1))/lib.atoi(tmp[1],1) 529 else: 530 calratio = float(options.ratio) 531 if options.placement == 'min' or options.placement == 'max': 532 geometry = _entropy_placement(img, (w,h), magick_args[0], options, calratio) 533 else: 534 geometry = _manual_placement((w,h), options, calratio) 535 536 if options.test != 'none': 537 if options.test == 'area': 538 subprocess.call([_prog_im, img] + magick_args[0] + ['-region', '%dx%d+%d+%d' % geometry, 539 '-negate', outimg]) 540 elif options.test == 'quant': 541 subprocess.call([_prog_im, img] + magick_args[0] + _IM_entropy_args(options.alt) + 542 [qresize, '-normalize', '-scale', '%dx%d!' % (w,h), '-region', '%dx%d+%d+%d' % geometry, 543 '-negate', outimg]) 544 elif options.test == 'quantimg': 545 subprocess.call([_prog_im, img] + magick_args[0] + _IM_entropy_args(options.alt) + 546 [qresize, '-normalize', '-scale', '%dx%d!' % (w,h), 547 '-compose', 'multiply', img, '-composite', '-region', '%dx%d+%d+%d' % geometry, 548 '-negate', outimg]) 549 elif options.test == 'print': 550 print ' '.join(map(str,geometry)) 551 elif options.test == 'crop': 552 subprocess.call([_prog_im, img] + magick_args[0] + ['-crop', '%dx%d+%d+%d' % geometry, 553 outimg]) 554 return 555 556 # generate callirhoe calendar 557 if options.verbose: print "Generating calendar image (%s) ... [&]" % options.style 558 if not options.vanilla: callirhoe_args = callirhoe_args + ['--no-footer', '--border=0'] 559 calimg = mktemp('.png') 560 try: 561 pcal = run_callirhoe(options.style, geometry[0:2], callirhoe_args, calimg) 562 563 if dark is None: 564 # measure luminance 565 if options.verbose: print "Measuring luminance...", 566 if options.negative > 0 and options.negative < 255: 567 luma = _IM_get_image_luminance(img, magick_args[0], geometry) 568 if options.verbose: print "(%s)" % luma, 569 else: 570 luma = 255 - options.negative 571 dark = luma < options.negative 572 if options.verbose: print "DARK" if dark else "LIGHT" 573 if cache is not None: 574 with _mutex: 575 cache[img] = (geometry, dark) 576 577 pcal.wait() 578 if pcal.returncode != 0: raise RuntimeError("calmagick: calendar creation failed") 579 580 # perform final composition 581 if options.verbose: print "Composing overlay (%s)..." % outimg 582 overlay = ['(', '-negate', calimg, ')'] if dark else [calimg] 583 subprocess.call([_prog_im, img] + magick_args[0] + ['-region', '%dx%d+%d+%d' % geometry] + 584 ([] if options.brightness == 0 else ['-brightness-contrast', '%d' % (-options.brightness if dark else options.brightness)]) + 585 ([] if options.saturation == 100 else ['-modulate', '100,%d' % options.saturation]) + magick_args[1] + 586 ['-compose', 'over'] + overlay + ['-geometry', '+%d+%d' % geometry[2:], '-composite'] + 587 magick_args[2] + [outimg]) 588 finally: 589 os.remove(calimg)
590
591 -def parse_range(s,hint=None):
592 """returns list of (I{Month,Year}) tuples for a given range 593 594 @param s: range string in format I{Month1-Month2/Year} or I{Month:Span/Year} 595 @param hint: span value to be used, when M{Span=0} 596 @rtype: [(int,int),...] 597 @return: list of (I{Month,Year}) tuples for every month specified 598 """ 599 if '/' in s: 600 t = s.split('/') 601 month,span = lib.parse_month_range(t[0]) 602 if hint and span == 0: span = hint 603 year = lib.parse_year(t[1]) 604 margs = [] 605 for m in xrange(span): 606 margs += [(month,year)] 607 month += 1 608 if month > 12: month = 1; year += 1 609 return margs 610 else: 611 raise lib.Abort("calmagick: invalid range format '%s'" % options.range)
612
613 -def range_worker(q,ev,i):
614 """worker thread for a (I{Month,Year}) tuple 615 616 @param ev: Event used to consume remaining items in case of error 617 @param q: Queue object to consume items from 618 @param i: Thread number 619 """ 620 while True: 621 if ev.is_set(): 622 q.get() 623 q.task_done() 624 else: 625 item = q.get() 626 try: 627 compose_calendar(*item) 628 except Exception as e: 629 print >> sys.stderr, "Exception in Thread-%d: %s" % (i,e.args) 630 ev.set() 631 finally: 632 q.task_done()
633
634 -def main_program():
635 """this is the main program routine 636 637 Parses options, and calls C{compose_calendar()} the appropriate number of times, 638 possibly by multiple threads (if requested by user) 639 """ 640 parser = get_parser() 641 642 magick_args = parse_magick_args() 643 sys.argv,argv2 = lib.extract_parser_args(sys.argv,parser,2) 644 (options,args) = parser.parse_args() 645 check_parsed_options(options) 646 647 if len(args) < 1: 648 parser.print_help() 649 sys.exit(0) 650 651 if not os.path.isdir(options.outdir): 652 # this way we get an exception if outdir exists and is a normal file 653 os.mkdir(options.outdir) 654 655 if options.range: 656 flist = sorted(glob.glob(args[0])) 657 mrange = parse_range(options.range,hint=len(flist)) 658 if options.verbose: print "Composing %d photos..." % len(mrange) 659 if options.sample is not None: 660 flist = random.sample(flist, options.sample if options.sample else min(len(mrange),len(flist))) 661 nf = len(flist) 662 if nf > 0: 663 if len(mrange) > nf and options.prefix == 'no?': options.prefix = 'yes' 664 if options.jobs > 1: 665 q = Queue.Queue() 666 ev = threading.Event() 667 for i in range(options.jobs): 668 t = threading.Thread(target=range_worker,args=(q,ev,i)) 669 t.daemon = True 670 t.start() 671 672 cache = get_cache(nf, len(mrange)); 673 for i in range(len(mrange)): 674 img = flist[i % nf] 675 m,y = mrange[i] 676 prefix = '' if options.prefix.startswith('no') else '%04d-%02d_' % (y,m) 677 outimg = get_outfile(img,options.outdir,prefix,options.format) 678 args = (img, outimg, options, [str(m), str(y)] + argv2, magick_args, 679 (i+1,len(mrange)), cache) 680 if options.jobs > 1: q.put(args) 681 else: compose_calendar(*args) 682 683 if options.jobs > 1: q.join() 684 else: 685 img = args[0] 686 if not os.path.isfile(img): 687 raise lib.Abort("calmagick: input image '%s' does not exist" % img) 688 outimg = get_outfile(img,options.outdir,'',options.format,options.outfile) 689 compose_calendar(img, outimg, options, argv2, magick_args)
690 691 if __name__ == '__main__': 692 try: 693 main_program() 694 except lib.Abort as e: 695 sys.exit(e.args[0]) 696