callirhoe/calmagick.py

410 lines
20 KiB
Python
Raw Normal View History

#!/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
from callirhoe import extract_parser_args, parse_month_range, parse_year
from lib.geom import rect_rel_scale
# TODO:
# cache stuff when --sample is used, move --sample to filedef, like '*.jpg:0'
# fork processes (independent stuff...)
def run_callirhoe(style, w, h, args, outfile):
if subprocess.call(['callirhoe', '-s', style, '--paper=-%d:-%d' % (w,h)] + args + [outfile]):
sys.exit("calmagick: calendar creation failed")
class PNMImage(object):
def __init__(self, strlist):
self.data = [];
state = 0;
for i in range(len(strlist)):
# skip comments
if strlist[i].startswith('#'): 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.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, sz):
return float(sum([(self.xsum[y][x+sz] - self.xsum[y][x]) for y in range(y,y+sz)]))/(sz*sz)
def lowest_block_avg(self, sz, at_least = 0):
best = (self.maxval,1,0,0,sz) # avg, sz_ratio, x, y, sz
w,h = self.size
for y in range(0,h-sz+1):
for x in range(0,w-sz+1):
cur = (self.block_avg(x,y,sz), float(sz)/w, x, y, sz)
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):
w,h = self.size
sz_range = (int(w*size_range[0]+0.5), int(w*size_range[1]+0.5))
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 range(sz_range[1],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, sz_ratio, x, y, sz, best_avg
_version = "0.1.0"
def get_parser():
"""get the argument parser object"""
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="calmagick " + _version)
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); "
"'auto' adds YEAR_MONTH_ prefix only when input photos are randomized; '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("--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 print crop".split(), default='none',
help="test entropy minimization algorithm, without creating any calendar, TEST should be among "
"{none, area, quant, print, crop}: none=test disabled; "
"area=show area in original image; quant=show area in quantizer; 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("-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("--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 be equal to the month span defined with --range")
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; 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):
if options.min_size is None:
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:
sys.exit("calmagick: --sample requested without --range")
if options.outfile is not None and options.range:
sys.exit("calmagick: you cannot specify both --outfile and --range options")
if options.sample is not None and options.shuffle:
sys.exit("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'
else:
if options.prefix == 'auto': options.prefix = 'yes'
def parse_magick_args():
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=''):
f = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
f.close()
return f.name
def get_outfile(infile, outdir, base_prefix, format, hint=None):
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: sys.exit("calmagick: --outfile same as input, aborting")
outfile = os.path.join(outdir,base_prefix+base+'_calmagick'+ext)
return outfile
def _get_image_size(img, args):
info = subprocess.check_output(['convert', img] + args + ['-format', '%w %h', 'info:']).split()
return tuple(map(int, info))
def _get_image_luminance(img, args, geometry = None):
return 255.0*float(subprocess.check_output(['convert', img] + args +
(['-crop', '%dx%d+%d+%d' % geometry] if geometry else []) +
['-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']))
def _entropy_placement(img, size, args, options):
if options.verbose:
print "Calculating image entropy..."
qresize = '%dx%d!' % ((options.quantum,)*2)
pnm_entropy = PNMImage(subprocess.check_output(['convert', img] + args +
"-scale 512> -define convolve:scale=! -define morphology:compose=Lighten -morphology Convolve Sobel:> -colorspace Gray -normalize -unsharp 0x5 -scale".split() +
[qresize] + (['-negate'] if options.placement == 'max' else []) + ['-compress', 'None', 'pnm:-']).splitlines())
#-scale 512> ( -clone -blur 0x2 ) -compose minus -composite -colorspace Gray -normalize -unsharp 0x5 -scale 60x60! -normalize
#-scale 512> -define convolve:scale=! -define morphology:compose=Lighten -morphology Convolve Sobel:> -colorspace Gray -normalize -unsharp 0x5 -scale 60x60!
# find optimal fit
if options.verbose: print "Fitting... ",
best = pnm_entropy.fit_rect((options.min_size,options.max_size), options.low_entropy, options.relax)
if options.verbose: print best
# (W,H,X,Y)
w,h = size
geometry = tuple(map(int, (w*best[1], h*best[1],
float(w*best[2])/pnm_entropy.size[0],
float(h*best[3])/pnm_entropy.size[1])))
return geometry
def _manual_placement(size, options):
r = (0, 0, size[0], size[1])
if options.placement == 'random':
f = random.uniform(options.min_size, options.max_size)
r2 = rect_rel_scale(r, f, f, 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
r2 = rect_rel_scale(r, options.max_size, options.max_size, ax, ay)
return tuple(map(int,[r2[2], r2[3], r2[0], r2[1]]))
def compose_calendar(img, outimg, options, callirhoe_args, magick_args):
# get image info (dimensions)
if options.verbose: 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" % (img, w, h, int(w*h/1000000.0+0.5))
if options.placement == 'min' or options.placement == 'max':
geometry = _entropy_placement(img, (w,h), magick_args[0], options)
else:
geometry = _manual_placement((w,h), options)
if options.test != 'none':
if options.test == 'area':
subprocess.call(['convert', img] + magick_args[0] + ['-region', '%dx%d+%d+%d' % geometry,
'-negate', outimg])
elif options.test == 'quant':
subprocess.call(['convert', img] + magick_args[0] +
"-scale 512> -define convolve:scale=! -define morphology:compose=Lighten -morphology Convolve Sobel:> -colorspace Gray -normalize -unsharp 0x5 -scale".split() +
[qresize, '-scale', '%dx%d!' % (w,h), '-region', '%dx%d+%d+%d' % geometry,
'-negate', outimg])
elif options.test == 'print':
print ' '.join(map(str,geometry))
elif options.test == 'crop':
subprocess.call(['convert', img] + magick_args[0] + ['-crop', '%dx%d+%d+%d' % geometry,
outimg])
return
# 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"
# 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:
run_callirhoe(options.style, geometry[0], geometry[1], callirhoe_args, calimg);
# perform final composition
if options.verbose: print "Composing overlay (%s)..." % outimg
overlay = ['(', '-negate', calimg, ')'] if dark else [calimg]
subprocess.call(['convert', 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):
if '/' in s:
t = s.split('/')
month,span = parse_month_range(t[0])
if hint and span == 0: span = hint
year = 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:
sys.exit("calmagick: invalid range format '%s'." % options.range)
if __name__ == '__main__':
parser = get_parser()
magick_args = parse_magick_args()
sys.argv,argv2 = 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 = 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 len(mrange))
nf = len(flist)
if nf > 0:
for i in range(len(mrange)):
img = flist[i % nf]
m,y = mrange[i]
prefix = '' if options.prefix == 'no' else '%04d-%02d_' % (y,m)
outimg = get_outfile(img,options.outdir,prefix,options.format)
compose_calendar(img, outimg, options, [str(m), str(y)] + argv2, magick_args)
else:
img = args[0]
if not os.path.isfile(img):
sys.exit("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)