Catalyst / admin/Synapse-Cortex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Cortex

public

Self Hosted ITSM Tool with RBAC/Tenanting and MFA

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Synapse-Cortex / Synapse-Cortexv2 / .venv / Scripts / priditherpng 7829 B · main
  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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
#!C:\Users\hanco\.openclaw\ClaudeLocal\Synapse-Cortexv2\.venv\Scripts\python.exe

# 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,
    input,
    bitdepth=1,
    linear=False,
    defaultgamma=1.0,
    targetgamma=None,
    cutoff=0.5,  # see :cutoff:default
):
    """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=input)

    _, _, pixels, info = r.asDirect()
    planes = info["planes"]
    # :todo: make an Exception
    assert planes == 1
    width = info["size"][0]
    sourcemaxval = 2 ** info["bitdepth"] - 1

    if linear:
        gamma = 1
    else:
        gamma = info.get("gamma") or defaultgamma

    # Calculate an effective gamma for input and output;
    # then build tables using those.

    # `gamma` (whether it was obtained from the input file or an
    # assumed value) is the encoding gamma.
    # We need the decoding gamma, which is the reciprocal.
    decode = 1.0 / gamma

    # `targetdecode` is the assumed gamma that is going to be used
    # to decoding the target PNG.
    # Note that even though we will _encode_ the target PNG we
    # still need the decoding gamma, because
    # the table we use maps from PNG pixel value to linear light level.
    if targetgamma is None:
        targetdecode = decode
    else:
        targetdecode = 1.0 / targetgamma

    incode = build_decode_table(sourcemaxval, decode)

    # For encoding, we still build a decode table, because we
    # use it inverted (searching with bisect).
    outcode = build_decode_table(maxval, targetdecode)

    # The table used for choosing output codes.  These values represent
    # the cutoff points between two adjacent output codes.
    # The cutoff parameter can be varied between 0 and 1 to
    # preferentially choose lighter (when cutoff > 0.5) or
    # darker (when cutoff < 0.5) values.
    # :cutoff:default: The default for this used to be 0.75, but
    # testing by drj on 2021-07-30 showed that this produces
    # banding when dithering left-to-right gradients;
    # test with:
    # priforgepng grl | priditherpng | kitty icat
    choosecode = list(zip(outcode[1:], outcode))
    p = cutoff
    choosecode = [x[0] * p + x[1] * (1.0 - p) for x in choosecode]

    rows = repeat_header(pixels)
    dithered_rows = run_dither(incode, choosecode, outcode, width, rows)
    dithered_rows = remove_header(dithered_rows)

    info["bitdepth"] = bitdepth
    info["gamma"] = 1.0 / targetdecode
    w = png.Writer(**info)
    w.write(out, dithered_rows)


def build_decode_table(maxval, gamma):
    """Build a lookup table for decoding;
    table converts from pixel values to linear space.
    """

    assert maxval == int(maxval)
    assert maxval > 0

    f = 1.0 / maxval
    table = [f * v for v in range(maxval + 1)]
    if gamma != 1.0:
        table = [v ** gamma for v in table]
    return table


def run_dither(incode, choosecode, outcode, width, rows):
    """
    Run an serpentine dither.
    Using the incode and choosecode tables.
    """

    # Errors diffused downwards (into next row)
    ed = [0.0] * width
    flipped = False
    for row in rows:
        # Convert to linear...
        row = [incode[v] for v in row]
        # Add errors...
        row = [e + v for e, v in zip(ed, row)]

        if flipped:
            row = row[::-1]
        targetrow = [0] * width

        for i, v in enumerate(row):
            # `it` will be the index of the chosen target colour;
            it = bisect_left(choosecode, v)
            targetrow[i] = it
            t = outcode[it]
            # err is the error that needs distributing.
            err = v - t

            # Sierra "Filter Lite" distributes          * 2
            # as per this diagram.                    1 1
            ef = err * 0.5
            # :todo: consider making rows one wider at each end and
            # removing "if"s
            if i + 1 < width:
                row[i + 1] += ef
            ef *= 0.5
            ed[i] = ef
            if i:
                ed[i - 1] += ef

        if flipped:
            ed = ed[::-1]
            targetrow = targetrow[::-1]
        yield targetrow
        flipped = not flipped


WARMUP_ROWS = 32


def repeat_header(rows):
    """Repeat the first row, to "warm up" the error register."""
    for row in rows:
        yield row
        for _ in range(WARMUP_ROWS):
            yield row
        break
    yield from rows


def remove_header(rows):
    """Remove the same number of rows that repeat_header added."""

    for _ in range(WARMUP_ROWS):
        next(rows)
    yield from rows


def main(argv=None):
    import sys

    # https://docs.python.org/3.5/library/argparse.html
    import argparse

    parser = argparse.ArgumentParser()

    if argv is None:
        argv = sys.argv

    progname, *args = argv

    parser.add_argument("--bitdepth", type=int, default=1, help="bitdepth of output")
    parser.add_argument(
        "--cutoff",
        type=float,
        default=0.5,
        help="cutoff to select adjacent output values",
    )
    parser.add_argument(
        "--defaultgamma",
        type=float,
        default=1.0,
        help="gamma value to use when no gamma in input",
    )
    parser.add_argument("--linear", action="store_true", help="force linear input")
    parser.add_argument(
        "--targetgamma",
        type=float,
        help="gamma to use in output (target), defaults to input gamma",
    )
    parser.add_argument(
        "input", nargs="?", default="-", type=png.cli_open, metavar="PNG"
    )

    ns = parser.parse_args(args)

    return dither(png.binary_stdout(), **vars(ns))


if __name__ == "__main__":
    main()