|
- #!C:\odoobuild\WinPy64\python-3.12.3.amd64\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()
|