summaryrefslogtreecommitdiffstats
path: root/build/pypng/pipdither
blob: c14c76c3c4be84d94520333ffa1d23d6d5f775e4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
#!/usr/bin/env python
# $URL: http://pypng.googlecode.com/svn/trunk/code/pipdither $
# $Rev: 150 $

# pipdither
# Error Diffusing image dithering.
# Now with serpentine scanning.

# See http://www.efg2.com/Lab/Library/ImageProcessing/DHALF.TXT

# http://www.python.org/doc/2.4.4/lib/module-bisect.html
from bisect import bisect_left

import png

def dither(out, inp,
  bitdepth=1, linear=False, defaultgamma=1.0, targetgamma=None,
  cutoff=0.75):
    """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
    else:
        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
    else:
        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):
    # http://www.python.org/doc/2.4.4/lib/module-getopt.html
    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')
    else:
        f = sys.stdin

    return dither(sys.stdout, f, **k)


if __name__ == '__main__':
    main()