callirhoe/calmagick.py
2014-11-03 10:31:27 +00:00

696 lines
32 KiB
Python
Executable File

#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
# callirhoe - high quality calendar rendering
# Copyright (C) 2012-2014 George M. Tzoumas
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see http://www.gnu.org/licenses/
# *****************************************************************
# #
""" high quality photo calendar composition using ImageMagick """
# #
# *****************************************************************
import sys
import subprocess
import os.path
import os
import tempfile
import glob
import random
import optparse
import Queue
import threading
import lib
from lib.geom import rect_rel_scale
# MAYBE-TODO
# move to python 3?
# check ImageMagick availability/version
# convert input to ImageMagick native format for faster re-access
# report error on parse-float (like atoi())
# abort --range only on KeyboardInterrupt?
_prog_im = os.getenv('CALLIRHOE_IM', 'convert')
"""ImageMagick binary, either 'convert' or env var C{CALLIRHOE_IM}"""
def run_callirhoe(style, size, args, outfile):
"""launch callirhoe to generate a calendar
@param style: calendar style to use (passes -s option to callirhoe)
@param size: tuple (I{width},I{height}) for output calendar size (in pixels)
@param args: (extra) argument list to pass to callirhoe
@param outfile: output calendar file
@rtype: subprocess.Popen
@return: Popen object
"""
return subprocess.Popen(['callirhoe', '-s', style, '--paper=-%d:-%d' % size] + args + [outfile])
def _bound(x, lower, upper):
"""return the closest number to M{x} that lies in [M{lower,upper}]
@rtype: type(x)
"""
if x < lower: return lower
if x > upper: return upper
return x
class PNMImage(object):
"""class to represent an PNM grayscale image given in P2 format
@ivar data: image data as 2-dimensional array (list of lists)
@ivar size: tuple M{(width,height)} of image dimensions
@ivar maxval: maximum grayscale value
@ivar _rsum_cache: used by L{_rsum()} to remember results
@ivar xsum: 2-dimensional array of running x-sums for each line, used for efficient
computation of block averages, resulting in M{O(H)} complexity, instead of M{O(W*H)},
where M{W,H} the image dimensions
"""
def __init__(self, strlist):
self.data = [];
state = 0;
for i in range(len(strlist)):
# skip comments
if strlist[i].startswith('#'): continue
# skip empty lines
if len(strlist[i]) == 0: continue
# parse header
if state == 0:
if not strlist[i].startswith('P2'):
raise RuntimeError('invalid PNM image format: %s' % strlist[i])
state += 1
# parse size
elif state == 1:
w,h = map(int,strlist[i].split())
if w != h:
raise RuntimeError('non-square PNM image')
self.size = (w,h)
state += 1
# parse max value
elif state == 2:
self.maxval = int(strlist[i])
state += 1
# bitmap
else:
data = ' '.join(filter(lambda s: not s.startswith('#'), strlist[i:]))
intlist = map(int,data.split())
self.data = [intlist[x:x+w] for x in range(0, len(intlist), w)]
break
self._rsum_cache=(-1,-1,0) # y,x,s
# self.xsum = [map(lambda x: sum(self.data[y][0:x]), range(w+1)) for y in range(0,h)]
self.xsum = [map(lambda x: self._rsum(y,x), range(w+1)) for y in range(0,h)]
def _rsum(self,y,x):
"""running sum with cache
@rtype: int
"""
if self._rsum_cache[0] == y and self._rsum_cache[1] == x:
s = self._rsum_cache[2] + self.data[y][x-1]
else:
s = sum(self.data[y][0:x])
self._rsum_cache = (y,x+1,s)
return s
def block_avg(self, x, y, szx, szy):
"""returns the average intensity of a block of size M{(szx,szy)} at pos (top-left) M{(x,y)}
@rtype: float
"""
return float(sum([(self.xsum[y][x+szx] - self.xsum[y][x]) for y in range(y,y+szy)]))/(szx*szy)
def lowest_block_avg(self, szx, szy, at_least = 0):
"""returns the M{(szx,szy)}-sized block with intensity as close to M{at_least} as possible
@rtype: (float,(float,float),(int,int),(int,int))
@return: R=tuple M({avg, (szx_ratio,szy_ratio), (x,y), (szx,szy))}: R[0] is the
average intensity of the block found, R[1] is the block size ratio with respect to the whole image,
R[2] is the block position (top-left) and R[3] is the block size
"""
w,h = self.size
best = (self.maxval,(1,1),(0,0),(szx,szy)) # avg, (szx_ratio,szy_ratio), (x,y), (szx,szy)
for y in range(0,h-szy+1):
for x in range(0,w-szx+1):
cur = (self.block_avg(x,y,szx,szy), (float(szx)/w,float(szy)/h), (x,y), (szx,szy))
if cur[0] < best[0]:
best = cur
if best[0] <= at_least: return best
return best
def fit_rect(self, size_range = (0.333, 0.8), at_least = 7, relax = 0.2, rr = 1.0):
"""find the maximal-area minimal-entropy rectangle within the image
@param size_range: tuple of smallest and largest rect/photo size ratio
size measured on the 'best-fit' dimension' if rectangle and photo ratios differ
@param at_least: early stop of minimization algorithm, when rect of this amount of entropy is found
@param relax: relax minimum entropy by a factor of (1+M{relax}), so that bigger sizes can be tried
This is because usuallly minimal entropy is achieved at a minimal-area box.
@param rr: ratio of ratios
Calendar rectangle ratio over Photo ratio. If M{r>1} then calendar rectangle, when scaled, fits
M{x} dimension first. Conversely, if M{r<1}, scaling touches the M{y} dimension first. When M{r=1},
calendar rectangle can fit perfectly within the photo at 100% size.
@rtype: (float,(float,float),(int,int),(int,int),float)
"""
w,h = self.size
sz_lo = _bound(int(w*size_range[0]+0.5),1,w)
sz_hi = _bound(int(w*size_range[1]+0.5),1,w)
szv_range = range(sz_lo, sz_hi+1)
if rr == 1:
sz_range = zip(szv_range, szv_range)
elif rr > 1:
sz_range = zip(szv_range, map(lambda x: _bound(int(x/rr+0.5),1,w), szv_range))
else:
sz_range = zip(map(lambda x: _bound(int(x*rr+0.5),1,w), szv_range), szv_range)
best = self.lowest_block_avg(*sz_range[0])
# we do not use at_least because non-global minimum, when relaxed, may jump well above threshold
entropy_thres = max(at_least, best[0]*(1+relax))
for sz in list(reversed(sz_range))[0:-1]:
# we do not use at_least because we want the best possible option, for bigger sizes
cur = self.lowest_block_avg(*sz)
if cur[0] <= entropy_thres: return cur + (best[0],)
return best + (best[0],) # avg, (szx_ratio,szy_ratio), (x,y), (szx,szy), best_avg
def get_parser():
"""get the argument parser object
@rtype: optparse.OptionParser
"""
parser = optparse.OptionParser(usage="usage: %prog IMAGE [options] [callirhoe-options] [--pre-magick ...] [--in-magick ...] [--post-magick ...]",
description="""High quality photo calendar composition with automatic minimal-entropy placement.
If IMAGE is a single file, then a calendar of the current month is overlayed. If IMAGE contains wildcards,
then every month is generated according to the --range option, advancing one month for every photo file.
Photos will be reused in a round-robin fashion if more calendar
months are requested.""", version="callirhoe.CalMagick " + lib._version + '\n' + lib._copyright)
parser.add_option("--outdir", default=".",
help="set directory for the output image(s); directory will be created if it does not already exist [%default]")
parser.add_option("--outfile", default=None,
help="set output filename when no --range is requested; by default will use the same name, unless it is going to "
"overwrite the input image, in which case suffix '_calmagick' will be added; this option will override --outdir and --format options")
parser.add_option("--prefix", type="choice", choices=['no','auto','yes'], default='auto',
help="set output filename prefix for multiple image output (with --range); 'no' means no prefix will be added, thus the output "
"filename order may not be the same, if the input photos are randomized (--shuffle or --sample), also some output files may be overwritten, "
"if input photos are reused in round-robin; "
"'auto' adds YEAR_MONTH_ prefix only when input photos are randomized or more months than photos are requested; 'yes' will always add prefix [%default]")
parser.add_option("--quantum", type="int", default=60,
help="choose quantization level for entropy computation [%default]")
parser.add_option("--placement", type="choice", choices="min max N S W E NW NE SW SE center random".split(),
default="min", help="choose placement algorithm among {min, max, "
"N, S, W, E, NW, NE, SW, SE, center, random} [%default]")
parser.add_option("--min-size", type="float", default=None,
help="for min/max/random placement: set minimum calendar/photo size ratio [0.333]; for "
"N,S,W,E,NW,NE,SW,SE placement: set margin/opposite-margin size ratio [0.05]; for "
"center placement it has no effect")
parser.add_option("--max-size", type="float", default=0.8,
help="set maximum calendar/photo size ratio [%default]")
parser.add_option("--ratio", default="0",
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 "
"for min/max placement, calendar ratio CR will be equal to the closest number (a/b)*R, where "
"a,b integers, and MIN_SIZE <= x/QUANTUM <= MAX_SIZE, where x=b if RATIO < R otherwise x=a; in "
"any case 1/QUANTUM <= CR/R <= QUANTUM [%default]")
parser.add_option("--low-entropy", type="float", default=7,
help="set minimum entropy threshold (0-255) for early termination (0=global minimum) [%default]")
parser.add_option("--relax", type="float", default=0.2,
help="relax minimum entropy multiplying by 1+RELAX, to allow for bigger sizes [%default]")
parser.add_option("--negative", type="float", default=100,
help="average luminosity (0-255) threshold of the overlaid area, below which a negative "
"overlay is chosen [%default]")
parser.add_option("--test", type="choice", choices="none area quant quantimg print crop".split(), default='none',
help="test entropy minimization algorithm, without creating any calendar, TEST should be among "
"{none, area, quant, quantimg, print, crop}: none=test disabled; "
"area=show area in original image; quant=show area in quantizer; "
"quantimg=show both quantizer and image; print=print minimum entropy area in STDOUT as W H X Y, "
"without generating any files at all; crop=crop selected area [%default]")
parser.add_option("--alt", action="store_true", default=False,
help="use an alternate entropy computation algorithm; although for most cases it should be no better than the default one, "
"for some cases it might produce better results (yet to be verified)")
parser.add_option("-v", "--verbose", action="store_true", default=False,
help="print progress messages")
cal = optparse.OptionGroup(parser, "Calendar Options", "These options determine how callirhoe is invoked.")
cal.add_option("-s", "--style", default="transparent",
help="calendar default style [%default]")
cal.add_option("--range", default=None,
help="""set month range for calendar. Format is MONTH/YEAR or MONTH1-MONTH2/YEAR or
MONTH:SPAN/YEAR. If set, these arguments will be expanded (as positional arguments for callirhoe)
and a calendar will be created for
each month separately, for each input photo. Photo files will be globbed by the script
and used in a round-robin fashion if more months are requested. Globbing means that you should
normally enclose the file name in single quotes like '*.jpg' in order to avoid shell expansion.
If less months are requested, then the calendar
making process will terminate without having used all available photos. SPAN=0 will match the number of input
photos.""")
cal.add_option('-j', "--jobs", type="int", default=1,
help="set parallel job count (total number of threads) for the --range iteration; although python "
"threads are not true processes, they help running the external programs efficiently [%default]")
cal.add_option("--sample", type="int", default=None,
help="choose SAMPLE random images from the input and use in round-robin fashion (see --range option); if "
"SAMPLE=0 then the sample size is chosen to as big as possible, either equal to the month span defined with --range, or "
"equal to the total number of available photos")
cal.add_option("--shuffle", action="store_true", default=False,
help="shuffle input images and to use in round-robin fashion (see --range option); "
"the sample size is chosen to be equal to the month span defined with --range or equal to "
"the total number of available photos (whichever is smaller); this "
"is equivalent to specifying --sample=0")
cal.add_option("--vanilla", action="store_true", default=False,
help="suppress default options --no-footer --border=0")
parser.add_option_group(cal)
im = optparse.OptionGroup(parser, "ImageMagick Options", "These options determine how ImageMagick is used.")
im.add_option("--format", default="",
help="determines the file extension (without dot!) of the output image files; "
"use this option to generate files in a different format than the input, for example "
"to preserve quality by generating PNG from JPEG, thus not recompressing")
im.add_option("--brightness", type="int", default=10,
help="increase/decrease brightness by this (percent) value; "
"brightness is decreased on negative overlays [%default]")
im.add_option("--saturation", type="int", default=100,
help="set saturation of the overlaid area "
"to this value (percent) [%default]")
# im.add_option("--radius", type="float", default=2,
# help="radius for the entropy computation algorithm [%default]")
im.add_option("--pre-magick", action="store_true", default=False,
help="pass all subsequent arguments to ImageMagick, before entropy computation; should precede --in-magick and --post-magick")
im.add_option("--in-magick", action="store_true", default=False,
help="pass all subsequent arguments to ImageMagick, to be applied on the minimal-entropy area; should precede --post-magick")
im.add_option("--post-magick", action="store_true", default=False,
help="pass all subsequent arguments to ImageMagick, to be applied on the final output")
parser.add_option_group(im)
return parser
def check_parsed_options(options):
"""set (remaining) default values and check validity of various option combinations"""
if options.min_size is None:
options.min_size = min(0.333,options.max_size) if options.placement in ['min','max','random'] else min(0.05,options.max_size)
if options.min_size > options.max_size:
raise lib.Abort("calmagick: --min-size should not be greater than --max-size")
if options.sample is not None and not options.range:
raise lib.Abort("calmagick: --sample requested without --range")
if options.outfile is not None and options.range:
raise lib.Abort("calmagick: you cannot specify both --outfile and --range options")
if options.sample is not None and options.shuffle:
raise lib.Abort("calmagick: you cannot specify both --shuffle and --sample options")
if options.shuffle:
options.sample = 0
if options.sample is None:
if options.prefix == 'auto': options.prefix = 'no?' # dirty, isn't it? :)
else:
if options.prefix == 'auto': options.prefix = 'yes'
if options.jobs < 1: options.jobs = 1
def parse_magick_args():
"""extract arguments from command-line that will be passed to ImageMagick
ImageMagick-specific arguments should be defined between arguments C{--pre-magick},
C{--in-magick}, C{--post-magick} is this order
@rtype: [[str,...],[str,...],[str,...]]
@return: 3-element list of lists containing the [pre,in,post]-options
"""
magickargs = [[],[],[]]
try:
m = sys.argv.index('--post-magick')
magickargs[2] = sys.argv[m+1:]
del sys.argv[m:]
except:
pass
try:
m = sys.argv.index('--in-magick')
magickargs[1] = sys.argv[m+1:]
del sys.argv[m:]
except:
pass
try:
m = sys.argv.index('--pre-magick')
magickargs[0] = sys.argv[m+1:]
del sys.argv[m:]
except:
pass
if ('--post-magick' in magickargs[2] or '--in-magick' in magickargs[2] or
'--pre-magick' in magickargs[2] or '--in-magick' in magickargs[1] or
'--pre-magick' in magickargs[1] or '--pre-magick' in magickargs[0]):
parser.print_help()
sys.exit(0)
return magickargs
def mktemp(ext=''):
"""get temporary file name with optional extension
@rtype: str
"""
f = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
f.close()
return f.name
def get_outfile(infile, outdir, base_prefix, format, hint=None):
"""get output file name taking into account output directory, format and prefix, avoiding overwriting the input file
@rtype: str
"""
if hint:
outfile = hint
else:
head,tail = os.path.split(infile)
base,ext = os.path.splitext(tail)
if format: ext = '.' + format
outfile = os.path.join(outdir,base_prefix+base+ext)
if os.path.exists(outfile) and os.path.samefile(infile, outfile):
if hint: raise lib.Abort("calmagick: --outfile same as input, aborting")
outfile = os.path.join(outdir,base_prefix+base+'_calmagick'+ext)
return outfile
def _IM_get_image_size(img, args):
"""extract tuple(width,height) from image file using ImageMagick
@rtype: (int,int)
"""
info = subprocess.check_output([_prog_im, img] + args + ['-format', '%w %h', 'info:']).split()
return tuple(map(int, info))
_IM_lum_args = "-colorspace Lab -channel R -separate +channel -set colorspace Gray".split()
"""IM colorspace conversion arguments to extract image luminance"""
def _IM_get_image_luminance(img, args, geometry = None):
"""get average image luminance as a float in [0,255], using ImageMagick
@rtype: float
"""
return 255.0*float(subprocess.check_output([_prog_im, img] + args +
(['-crop', '%dx%d+%d+%d' % geometry] if geometry else []) +
_IM_lum_args + ['-format', '%[fx:mean]', 'info:']))
_IM_entropy_head = "-scale 262144@>".split()
"""IM args for entropy computation: pre-scaling"""
_IM_entropy_alg = ["-define convolve:scale=! -define morphology:compose=Lighten -morphology Convolve Sobel:>".split(),
"( +clone -blur 0x2 ) +swap -compose minus -composite".split()]
"""IM main/alternate entropy computation operator"""
_IM_entropy_tail = "-colorspace Lab -channel R -separate +channel -set colorspace Gray -normalize -scale".split()
"""IM entropy computation final colorspace"""
#_IM_entropy_tail = "-colorspace Lab -channel R -separate +channel -normalize -scale".split()
def _IM_entropy_args(alt=False):
"""IM entropy computation arguments, depending on default or alternate algorithm
@rtype: [str,...]
"""
return _IM_entropy_head + _IM_entropy_alg[alt] + _IM_entropy_tail
def _entropy_placement(img, size, args, options, r):
"""get rectangle of minimal/maximal entropy
@param img: image file
@param size: image size tuple(I{width,height})
@param args: ImageMagick pre-processing argument list (see C{--pre-magick})
@param options: (command-line) options object
@param r: rectangle ratio, 0=match input ratio
@rtype: (int,int,int,int)
@return: IM geometry tuple(I{width,height,x,y})
"""
w,h = size
R = float(w)/h
if r == 0: r = R
if options.verbose:
print "Calculating image entropy..."
qresize = '%dx%d!' % ((options.quantum,)*2)
pnm_entropy = PNMImage(subprocess.check_output([_prog_im, img] + args + _IM_entropy_args(options.alt) +
[qresize, '-normalize'] + (['-negate'] if options.placement == 'max' else []) + "-compress None pnm:-".split()).splitlines())
# find optimal fit
if options.verbose: print "Fitting... ",
best = pnm_entropy.fit_rect((options.min_size,options.max_size), options.low_entropy, options.relax, r/R)
if options.verbose:
print "ent=%0.2f frac=(%0.2f,%0.2f) pos=(%d,%d) bs=(%d,%d) min=%0.2f r=%0.2f" % (
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])
# (W,H,X,Y)
w,h = size
geometry = tuple(map(int, (w*best[1][0], h*best[1][1],
float(w*best[2][0])/pnm_entropy.size[0],
float(h*best[2][1])/pnm_entropy.size[1])))
return geometry
def _manual_placement(size, options, r):
"""get rectangle of ratio I{r} with user-defined placement (N,S,W,E,NW,NE,SW,SE,center,random)
@param size: image size tuple(I{width,height})
@param options: (command-line) options object
@param r: rectangle ratio, 0=match input ratio
@rtype: (int,int,int,int)
@return: IM geometry tuple(I{width,height,x,y})
"""
w,h = size
rect = (0, 0, w, h)
R = float(w)/h
if r == 0: r = R
if r == R: # float comparison should succeed here
fx, fy = 1.0, 1.0
elif r > R:
fx,fy = 1.0, R/r
else:
fx,fy = r/R, 1.0
if options.placement == 'random':
f = random.uniform(options.min_size, options.max_size)
rect2 = rect_rel_scale(rect, f*fx, f*fy, random.uniform(-1,1), random.uniform(-1,1))
else:
ax = ay = 0
if 'W' in options.placement: ax = -1 + 2.0*options.min_size
if 'E' in options.placement: ax = 1 - 2.0*options.min_size
if 'N' in options.placement: ay = -1 + 2.0*options.min_size
if 'S' in options.placement: ay = 1 - 2.0*options.min_size
rect2 = rect_rel_scale(rect, options.max_size*fx, options.max_size*fy, ax, ay)
return tuple(map(int,[rect2[2], rect2[3], rect2[0], rect2[1]]))
_cache = dict() # {'filename': (geometry, is_dark)}
"""cache input photo computed rectangle and luminance, key=filename, value=(geometry,is_dark)"""
_mutex = threading.Lock()
"""mutex for cache access"""
def get_cache(num_photos, num_months):
"""returns a reference to the cache object, or None if caching is disabled
@rtype: dict
@note: caching is enabled only when more than 1/6 of photos is going to be re-used
"""
q,r = divmod(num_months, num_photos)
if q > 1: return _cache
if q < 1 or r == 0: return None
return _cache if (num_photos / r <= 6) else None;
def compose_calendar(img, outimg, options, callirhoe_args, magick_args, stats=None, cache=None):
"""performs calendar composition on a photo image
@param img: photo file
@param outimg: output file
@param options: (command-line) options object
@param callirhoe_args: extra argument list to pass to callirhoe
@param magick_args: [pre,in,post]-magick argument list
@param stats: if not C{None}: tuple(I{current,total}) counting input photos
@param cache: if cache enabled, points to the cache dictionary
"""
# get image info (dimensions)
geometry, dark = None, None
if cache is not None:
with _mutex:
if img in cache:
geometry, dark = cache[img]
if options.verbose and geometry:
if stats: print "[%d/%d]" % stats,
print "Reusing image info from cache...", geometry, "DARK" if dark else "LIGHT"
if geometry is None:
if options.verbose:
if stats: print "[%d/%d]" % stats,
print "Extracting image info..."
w,h = _IM_get_image_size(img, magick_args[0])
qresize = '%dx%d!' % ((options.quantum,)*2)
if options.verbose:
print "%s %dx%d %dmp R=%0.2f" % (img, w, h, int(w*h/1000000.0+0.5), float(w)/h)
if '/' in options.ratio:
tmp = options.ratio.split('/')
calratio = float(lib.atoi(tmp[0],1))/lib.atoi(tmp[1],1)
else:
calratio = float(options.ratio)
if options.placement == 'min' or options.placement == 'max':
geometry = _entropy_placement(img, (w,h), magick_args[0], options, calratio)
else:
geometry = _manual_placement((w,h), options, calratio)
if options.test != 'none':
if options.test == 'area':
subprocess.call([_prog_im, img] + magick_args[0] + ['-region', '%dx%d+%d+%d' % geometry,
'-negate', outimg])
elif options.test == 'quant':
subprocess.call([_prog_im, img] + magick_args[0] + _IM_entropy_args(options.alt) +
[qresize, '-normalize', '-scale', '%dx%d!' % (w,h), '-region', '%dx%d+%d+%d' % geometry,
'-negate', outimg])
elif options.test == 'quantimg':
subprocess.call([_prog_im, img] + magick_args[0] + _IM_entropy_args(options.alt) +
[qresize, '-normalize', '-scale', '%dx%d!' % (w,h),
'-compose', 'multiply', img, '-composite', '-region', '%dx%d+%d+%d' % geometry,
'-negate', outimg])
elif options.test == 'print':
print ' '.join(map(str,geometry))
elif options.test == 'crop':
subprocess.call([_prog_im, img] + magick_args[0] + ['-crop', '%dx%d+%d+%d' % geometry,
outimg])
return
# generate callirhoe calendar
if options.verbose: print "Generating calendar image (%s) ... [&]" % options.style
if not options.vanilla: callirhoe_args = callirhoe_args + ['--no-footer', '--border=0']
calimg = mktemp('.png')
try:
pcal = run_callirhoe(options.style, geometry[0:2], callirhoe_args, calimg)
if dark is None:
# measure luminance
if options.verbose: print "Measuring luminance...",
if options.negative > 0 and options.negative < 255:
luma = _IM_get_image_luminance(img, magick_args[0], geometry)
if options.verbose: print "(%s)" % luma,
else:
luma = 255 - options.negative
dark = luma < options.negative
if options.verbose: print "DARK" if dark else "LIGHT"
if cache is not None:
with _mutex:
cache[img] = (geometry, dark)
pcal.wait()
if pcal.returncode != 0: raise RuntimeError("calmagick: calendar creation failed")
# perform final composition
if options.verbose: print "Composing overlay (%s)..." % outimg
overlay = ['(', '-negate', calimg, ')'] if dark else [calimg]
subprocess.call([_prog_im, img] + magick_args[0] + ['-region', '%dx%d+%d+%d' % geometry] +
([] if options.brightness == 0 else ['-brightness-contrast', '%d' % (-options.brightness if dark else options.brightness)]) +
([] if options.saturation == 100 else ['-modulate', '100,%d' % options.saturation]) + magick_args[1] +
['-compose', 'over'] + overlay + ['-geometry', '+%d+%d' % geometry[2:], '-composite'] +
magick_args[2] + [outimg])
finally:
os.remove(calimg)
def parse_range(s,hint=None):
"""returns list of (I{Month,Year}) tuples for a given range
@param s: range string in format I{Month1-Month2/Year} or I{Month:Span/Year}
@param hint: span value to be used, when M{Span=0}
@rtype: [(int,int),...]
@return: list of (I{Month,Year}) tuples for every month specified
"""
if '/' in s:
t = s.split('/')
month,span = lib.parse_month_range(t[0])
if hint and span == 0: span = hint
year = lib.parse_year(t[1])
margs = []
for m in xrange(span):
margs += [(month,year)]
month += 1
if month > 12: month = 1; year += 1
return margs
else:
raise lib.Abort("calmagick: invalid range format '%s'" % options.range)
def range_worker(q,ev,i):
"""worker thread for a (I{Month,Year}) tuple
@param ev: Event used to consume remaining items in case of error
@param q: Queue object to consume items from
@param i: Thread number
"""
while True:
if ev.is_set():
q.get()
q.task_done()
else:
item = q.get()
try:
compose_calendar(*item)
except Exception as e:
print >> sys.stderr, "Exception in Thread-%d: %s" % (i,e.args)
ev.set()
finally:
q.task_done()
def main_program():
"""this is the main program routine
Parses options, and calls C{compose_calendar()} the appropriate number of times,
possibly by multiple threads (if requested by user)
"""
parser = get_parser()
magick_args = parse_magick_args()
sys.argv,argv2 = lib.extract_parser_args(sys.argv,parser,2)
(options,args) = parser.parse_args()
check_parsed_options(options)
if len(args) < 1:
parser.print_help()
sys.exit(0)
if not os.path.isdir(options.outdir):
# this way we get an exception if outdir exists and is a normal file
os.mkdir(options.outdir)
if options.range:
flist = sorted(glob.glob(args[0]))
mrange = parse_range(options.range,hint=len(flist))
if options.verbose: print "Composing %d photos..." % len(mrange)
if options.sample is not None:
flist = random.sample(flist, options.sample if options.sample else min(len(mrange),len(flist)))
nf = len(flist)
if nf > 0:
if len(mrange) > nf and options.prefix == 'no?': options.prefix = 'yes'
if options.jobs > 1:
q = Queue.Queue()
ev = threading.Event()
for i in range(options.jobs):
t = threading.Thread(target=range_worker,args=(q,ev,i))
t.daemon = True
t.start()
cache = get_cache(nf, len(mrange));
for i in range(len(mrange)):
img = flist[i % nf]
m,y = mrange[i]
prefix = '' if options.prefix.startswith('no') else '%04d-%02d_' % (y,m)
outimg = get_outfile(img,options.outdir,prefix,options.format)
args = (img, outimg, options, [str(m), str(y)] + argv2, magick_args,
(i+1,len(mrange)), cache)
if options.jobs > 1: q.put(args)
else: compose_calendar(*args)
if options.jobs > 1: q.join()
else:
img = args[0]
if not os.path.isfile(img):
raise lib.Abort("calmagick: input image '%s' does not exist" % img)
outimg = get_outfile(img,options.outdir,'',options.format,options.outfile)
compose_calendar(img, outimg, options, argv2, magick_args)
if __name__ == '__main__':
try:
main_program()
except lib.Abort as e:
sys.exit(e.args[0])