You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

181 lines
6.7 KiB

#!/usr/bin/env python
# $URL: $
# $Rev: 150 $
# pipdither
# Error Diffusing image dithering.
# Now with serpentine scanning.
# See
from bisect import bisect_left
import png
def dither(out, inp,
bitdepth=1, linear=False, defaultgamma=1.0, targetgamma=None,
"""Dither the input PNG `inp` into an image with a smaller bit depth
and write the result image onto `out`. `bitdepth` specifies the bit
depth of the new image.
Normally the source image gamma is honoured (the image is
converted into a linear light space before being dithered), but
if the `linear` argument is true then the image is treated as
being linear already: no gamma conversion is done (this is
quicker, and if you don't care much about accuracy, it won't
matter much).
Images with no gamma indication (no ``gAMA`` chunk) are normally
treated as linear (gamma = 1.0), but often it can be better
to assume a different gamma value: For example continuous tone
photographs intended for presentation on the web often carry
an implicit assumption of being encoded with a gamma of about
0.45 (because that's what you get if you just "blat the pixels"
onto a PC framebuffer), so ``defaultgamma=0.45`` might be a
good idea. `defaultgamma` does not override a gamma value
specified in the file itself: It is only used when the file
does not specify a gamma.
If you (pointlessly) specify both `linear` and `defaultgamma`,
`linear` wins.
The gamma of the output image is, by default, the same as the input
image. The `targetgamma` argument can be used to specify a
different gamma for the output image. This effectively recodes the
image to a different gamma, dithering as we go. The gamma specified
is the exponent used to encode the output file (and appears in the
output PNG's ``gAMA`` chunk); it is usually less than 1.
# Encoding is what happened when the PNG was made (and also what
# happens when we output the PNG). Decoding is what we do to the
# source PNG in order to process it.
# The dithering algorithm is not completely general; it
# can only do bit depth reduction, not arbitrary palette changes.
import operator
maxval = 2**bitdepth - 1
r = png.Reader(file=inp)
# If image gamma is 1 or gamma is not present and we are assuming a
# value of 1, then it is faster to pass a maxval parameter to
# asFloat (the multiplications get combined). With gamma, we have
# to have the pixel values from 0.0 to 1.0 (as long as we are doing
# gamma correction here).
# Slightly annoyingly, we don't know the image gamma until we've
# called asFloat().
_,_,pixels,info = r.asDirect()
planes = info['planes']
assert planes == 1
width = info['size'][0]
sourcemaxval = 2**info['bitdepth'] - 1
if linear:
gamma = 1
gamma = info.get('gamma') or defaultgamma
# Convert gamma from encoding gamma to the required power for
# decoding.
decode = 1.0/gamma
# Build a lookup table for decoding; convert from pixel values to linear
# space:
sourcef = 1.0/sourcemaxval
incode = map(sourcef.__mul__, range(sourcemaxval+1))
if decode != 1.0:
incode = map(decode.__rpow__, incode)
# Could be different, later on. targetdecode is the assumed gamma
# that is going to be used to decoding the target PNG. It is the
# reciprocal of the exponent that we use to encode the target PNG.
# This is the value that we need to build our table that we use for
# converting from linear to target colour space.
if targetgamma is None:
targetdecode = decode
targetdecode = 1.0/targetgamma
# The table we use for encoding (creating the target PNG), still
# maps from pixel value to linear space, but we use it inverted, by
# searching through it with bisect.
targetf = 1.0/maxval
outcode = map(targetf.__mul__, range(maxval+1))
if targetdecode != 1.0:
outcode = map(targetdecode.__rpow__, outcode)
# The table used for choosing output codes. These values represent
# the cutoff points between two adjacent output codes.
choosecode = zip(outcode[1:], outcode)
p = cutoff
choosecode = map(lambda x: x[0]*p+x[1]*(1.0-p), choosecode)
def iterdither():
# Errors diffused downwards (into next row)
ed = [0.0]*width
flipped = False
for row in pixels:
row = map(incode.__getitem__, row)
row = map(operator.add, ed, row)
if flipped:
row = row[::-1]
targetrow = [0] * width
for i,v in enumerate(row):
# Clamp. Necessary because previously added errors may take
# v out of range.
v = max(0.0, min(v, 1.0))
# `it` will be the index of the chosen target colour;
it = bisect_left(choosecode, v)
t = outcode[it]
targetrow[i] = it
# err is the error that needs distributing.
err = v - t
# Sierra "Filter Lite" distributes * 2
# as per this diagram. 1 1
ef = err/2.0
# :todo: consider making rows one wider at each end and
# removing "if"s
if i+1 < width:
row[i+1] += ef
ef /= 2.0
ed[i] = ef
if i:
ed[i-1] += ef
if flipped:
ed = ed[::-1]
targetrow = targetrow[::-1]
yield targetrow
flipped = not flipped
info['bitdepth'] = bitdepth
info['gamma'] = 1.0/targetdecode
w = png.Writer(**info)
w.write(out, iterdither())
def main(argv=None):
from getopt import getopt
import sys
if argv is None:
argv = sys.argv
opt,argv = getopt(argv[1:], 'b:c:g:lo:')
k = {}
for o,v in opt:
if o == '-b':
k['bitdepth'] = int(v)
if o == '-c':
k['cutoff'] = float(v)
if o == '-g':
k['defaultgamma'] = float(v)
if o == '-l':
k['linear'] = True
if o == '-o':
k['targetgamma'] = float(v)
if o == '-?':
print >>sys.stderr, "pipdither [-b bits] [-c cutoff] [-g assumed-gamma] [-l] [in.png]"
if len(argv) > 0:
f = open(argv[0], 'rb')
f = sys.stdin
return dither(sys.stdout, f, **k)
if __name__ == '__main__':