1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
41
42
43
44
45
46
47 _prog_im = os.getenv('CALLIRHOE_IM', 'convert')
48 """ImageMagick binary, either 'convert' or env var C{CALLIRHOE_IM}"""
49
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
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
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 """
83 self.data = [];
84 state = 0;
85 for i in range(len(strlist)):
86
87 if strlist[i].startswith('#'): continue
88
89 if len(strlist[i]) == 0: continue
90
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
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
103 elif state == 2:
104 self.maxval = int(strlist[i])
105 state += 1
106
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)
114
115 self.xsum = [map(lambda x: self._rsum(y,x), range(w+1)) for y in range(0,h)]
116
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
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
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))
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
183 entropy_thres = max(at_least, best[0]*(1+relax))
184 for sz in list(reversed(sz_range))[0:-1]:
185
186 cur = self.lowest_block_avg(*sz)
187 if cur[0] <= entropy_thres: return cur + (best[0],)
188 return best + (best[0],)
189
190
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
288
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
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?'
314 else:
315 if options.prefix == 'auto': options.prefix = 'yes'
316 if options.jobs < 1: options.jobs = 1
317
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
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
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
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
407
414
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
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
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
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:
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()
481 """cache input photo computed rectangle and luminance, key=filename, value=(geometry,is_dark)"""
482 _mutex = threading.Lock()
483 """mutex for cache access"""
484
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
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
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
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
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
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
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
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
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