cache entropy data when more than 16% of photos is reused

fixed bug with auto prefix (was missing when photos were reused without sampling)

started providing epydoc



git-svn-id: https://callirhoe.googlecode.com/svn/trunk@154 81c8bb96-aa45-f2e2-0eef-c4fa4a15c6df
This commit is contained in:
geortz@gmail.com 2014-10-02 10:53:05 +00:00
parent ac735c637b
commit b8be327743
2 changed files with 105 additions and 45 deletions

View File

@ -19,7 +19,7 @@
# ***************************************************************** # *****************************************************************
# # # #
""" high quality photo calendar composition using Imagemagick """ """ high quality photo calendar composition using ImageMagick """
# # # #
# ***************************************************************** # *****************************************************************
@ -39,7 +39,6 @@ from lib.geom import rect_rel_scale
# TODO: # TODO:
# epydoc # epydoc
# cache stuff when --sample is used and more than 10% reuse
# move to python 3 # move to python 3
# MAYBE-TODO # MAYBE-TODO
@ -51,15 +50,33 @@ from lib.geom import rect_rel_scale
_version = "0.4.0" _version = "0.4.0"
_prog_im = os.getenv('CALLIRHOE_IM', 'convert') _prog_im = os.getenv('CALLIRHOE_IM', 'convert')
def run_callirhoe(style, w, h, args, outfile): def run_callirhoe(style, size, args, outfile):
return subprocess.Popen(['callirhoe', '-s', style, '--paper=-%d:-%d' % (w,h)] + args + [outfile]) """launch callirhoe to generate a calendar
def _bound(x, lower_bound, upper_bound): @param style: calendar style to use (pass -s option to callirhoe)
if x < lower_bound: return lower_bound @param size: tuple (I{width},I{height}) for output calendar size (in pixels)
if x > upper_bound: return upper_bound @param args: (extra) argument list to pass to callirhoe
@param outfile: output calendar file
@return: subprocess exit code
"""
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]}"""
if x < lower: return lower
if x > upper: return upper
return x return x
class PNMImage(object): 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 xsum: 2-dimensional array of running x-sums for each line, used for efficient
computation of block averages, resulting in M{O(sqrt(A))} complexity, instead of M{O(A)},
where M{A} the image area
"""
def __init__(self, strlist): def __init__(self, strlist):
self.data = []; self.data = [];
state = 0; state = 0;
@ -94,9 +111,15 @@ class PNMImage(object):
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: sum(self.data[y][0:x]), range(w+1)) for y in range(0,h)]
def block_avg(self, x, y, szx, szy): 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)}"""
return float(sum([(self.xsum[y][x+szx] - self.xsum[y][x]) for y in range(y,y+szy)]))/(szx*szy) 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): 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
@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 the whole image,
R[2] is the block position (top-left) and R[3] is the block size
"""
w,h = self.size w,h = self.size
best = (self.maxval,(1,1),(0,0),(szx,szy)) # avg, (szx_ratio,szy_ratio), (x,y), (szx,szy) 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 y in range(0,h-szy+1):
@ -108,6 +131,7 @@ class PNMImage(object):
return best return best
def fit_rect(self, size_range = (0.333, 0.8), at_least = 7, relax = 0.2, rr = 1.0): def fit_rect(self, size_range = (0.333, 0.8), at_least = 7, relax = 0.2, rr = 1.0):
"""find the lowest entropy rectangle within the image"""
w,h = self.size w,h = self.size
sz_lo = _bound(int(w*size_range[0]+0.5),1,w) sz_lo = _bound(int(w*size_range[0]+0.5),1,w)
sz_hi = _bound(int(w*size_range[1]+0.5),1,w) sz_hi = _bound(int(w*size_range[1]+0.5),1,w)
@ -143,8 +167,9 @@ months are requested.""", version="callirhoe.CalMagick " + _version)
"overwrite the input image, in which case suffix '_calmagick' will be added; this option will override --outdir and --format options") "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', 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 " 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); " "filename order may not be the same, if the input photos are randomized (--shuffle or --sample), also some output files may be overwritten, "
"'auto' adds YEAR_MONTH_ prefix only when input photos are randomized; 'yes' will always add prefix [%default]") "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, parser.add_option("--quantum", type="int", default=60,
help="choose quantization level for entropy computation [%default]") 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(), parser.add_option("--placement", type="choice", choices="min max N S W E NW NE SW SE center random".split(),
@ -230,6 +255,7 @@ photos.""")
return parser return parser
def check_parsed_options(options): def check_parsed_options(options):
"""set (remaining) default values and check validity of various option combinations"""
if options.min_size is None: if options.min_size is None:
options.min_size = 0.333 if options.placement in ['min','max','random'] else 0.05 options.min_size = 0.333 if options.placement in ['min','max','random'] else 0.05
if options.sample is not None and not options.range: if options.sample is not None and not options.range:
@ -241,12 +267,19 @@ def check_parsed_options(options):
if options.shuffle: if options.shuffle:
options.sample = 0 options.sample = 0
if options.sample is None: if options.sample is None:
if options.prefix == 'auto': options.prefix = 'no' if options.prefix == 'auto': options.prefix = 'no?' # dirty, isn't it? :)
else: else:
if options.prefix == 'auto': options.prefix = 'yes' if options.prefix == 'auto': options.prefix = 'yes'
if options.jobs < 1: options.jobs = 1 if options.jobs < 1: options.jobs = 1
def parse_magick_args(): 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
@return: 3-element list of lists containing the [pre,in,post]-options
"""
magickargs = [[],[],[]] magickargs = [[],[],[]]
try: try:
m = sys.argv.index('--post-magick') m = sys.argv.index('--post-magick')
@ -274,6 +307,7 @@ def parse_magick_args():
return magickargs return magickargs
def mktemp(ext=''): def mktemp(ext=''):
"""get temporary file name with optional extension"""
f = tempfile.NamedTemporaryFile(suffix=ext, delete=False) f = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
f.close() f.close()
return f.name return f.name
@ -357,25 +391,43 @@ def _manual_placement(size, options, r):
rect2 = rect_rel_scale(rect, options.max_size*fx, options.max_size*fy, ax, ay) 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]])) return tuple(map(int,[rect2[2], rect2[3], rect2[0], rect2[1]]))
def compose_calendar(img, outimg, options, callirhoe_args, magick_args, stats=None): _cache = dict() # {'filename': (geometry, is_dark)}
# get image info (dimensions) _mutex = threading.Lock()
if options.verbose: def get_cache(num_photos, num_months):
if stats: print "[%d/%d]" % stats, q,r = divmod(num_months, num_photos)
print "Extracting image info..." if q > 1: return _cache
w,h = _get_image_size(img, magick_args[0]) if q < 1 or r == 0: return None
qresize = '%dx%d!' % ((options.quantum,)*2) return _cache if (num_photos / r <= 6) else None;
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: def compose_calendar(img, outimg, options, callirhoe_args, magick_args, stats=None, cache = None):
tmp = options.ratio.split('/') # get image info (dimensions)
calratio = float(itoa(tmp[0],1))/itoa(tmp[1],1) geometry, dark = None, None
else: if cache is not None:
calratio = float(options.ratio) with _mutex:
if options.placement == 'min' or options.placement == 'max': if img in cache:
geometry = _entropy_placement(img, (w,h), magick_args[0], options, calratio) geometry, dark = cache[img]
else: if options.verbose and geometry:
geometry = _manual_placement((w,h), options, calratio) 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 = _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(itoa(tmp[0],1))/itoa(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 != 'none':
if options.test == 'area': if options.test == 'area':
@ -402,17 +454,22 @@ def compose_calendar(img, outimg, options, callirhoe_args, magick_args, stats=No
if not options.vanilla: callirhoe_args = callirhoe_args + ['--no-footer', '--border=0'] if not options.vanilla: callirhoe_args = callirhoe_args + ['--no-footer', '--border=0']
calimg = mktemp('.png') calimg = mktemp('.png')
try: try:
pcal = run_callirhoe(options.style, geometry[0], geometry[1], callirhoe_args, calimg) 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 = _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)
# measure luminance
if options.verbose: print "Measuring luminance...",
if options.negative > 0 and options.negative < 255:
luma = _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"
pcal.wait() pcal.wait()
if pcal.returncode != 0: raise RuntimeError("calmagick: calendar creation failed") if pcal.returncode != 0: raise RuntimeError("calmagick: calendar creation failed")
@ -482,6 +539,7 @@ def main_program():
flist = random.sample(flist, options.sample if options.sample else len(mrange)) flist = random.sample(flist, options.sample if options.sample else len(mrange))
nf = len(flist) nf = len(flist)
if nf > 0: if nf > 0:
if len(mrange) > nf and options.prefix == 'no?': options.prefix = 'yes'
if options.jobs > 1: if options.jobs > 1:
q = Queue.Queue() q = Queue.Queue()
ev = threading.Event() ev = threading.Event()
@ -490,15 +548,16 @@ def main_program():
t.daemon = True t.daemon = True
t.start() t.start()
cache = get_cache(nf, len(mrange));
for i in range(len(mrange)): for i in range(len(mrange)):
img = flist[i % nf] img = flist[i % nf]
m,y = mrange[i] m,y = mrange[i]
prefix = '' if options.prefix == 'no' else '%04d-%02d_' % (y,m) prefix = '' if options.prefix.startswith('no') else '%04d-%02d_' % (y,m)
outimg = get_outfile(img,options.outdir,prefix,options.format) outimg = get_outfile(img,options.outdir,prefix,options.format)
if options.jobs > 1: args = (img, outimg, options, [str(m), str(y)] + argv2, magick_args,
q.put((img, outimg, options, [str(m), str(y)] + argv2, magick_args, (i+1,len(mrange)))) (i+1,len(mrange)), cache)
else: if options.jobs > 1: q.put(args)
compose_calendar(img, outimg, options, [str(m), str(y)] + argv2, magick_args, (i+1,len(mrange))) else: compose_calendar(*args)
if options.jobs > 1: q.join() if options.jobs > 1: q.join()
else: else:

5
mkdoc
View File

@ -1,2 +1,3 @@
#!/bin/sh #!/bin/bash
epydoc-2.7 -v --html -o callirhoe_doc callirhoe.py lib layouts lang geom style [[ "$#" -gt 0 ]] && ITEMS="$@" || ITEMS="callirhoe.py calmagick.py lib layouts lang geom style"
epydoc-2.7 -v --html -o callirhoe_doc $ITEMS