#!/usr/pkg/bin/python3.11

# priweavepng
# Weave selected channels from input PNG files into
# a multi-channel output PNG.

import collections
import re

from array import array

import png

"""
priweavepng file1.png [file2.png ...]

The `priweavepng` tool combines channels from the input images and
weaves a selection of those channels into an output image.

Conceptually an intermediate image is formed consisting of
all channels of all input images in the order given on the command line
and in the order of each channel in its image.
Then from 1 to 4 channels are selected and
an image is output with those channels.
The limit on the number of selected channels is
imposed by the PNG image format.

The `-c n` option selects channel `n`.
Further channels can be selected either by repeating the `-c` option,
or using a comma separated list.
For example `-c 3,2,1` will select channels 3, 2, and 1 in that order;
if the input is an RGB PNG, this will swop the Red and Blue channels.
The order is significant, the order in which the options are given is
the order of the output channels.
It is permissible, and sometimes useful
(for example, grey to colour expansion, see below),
to repeat the same channel.

If no `-c` option is used the default is
to select all of the input channels, up to the first 4.

`priweavepng` does not care about the meaning of the channels
and treats them as a matrix of values.

The numer of output channels determines the colour mode of the PNG file:
L (1-channel, Grey), LA (2-channel, Grey+Alpha),
RGB (3-channel, Red+Green+Blue), RGBA (4-channel, Red+Green+Blue+Alpha).

The `priweavepng` tool can be used for a variety of
channel building, swopping, and extraction effects:

Combine 3 grayscale images into RGB colour:
    priweavepng grey1.png grey2.png grey3.png

Swop Red and Blue channels in colour image:
    priweavepng -c 3 -c 2 -c 1 rgb.png

Extract Green channel as a greyscale image:
    priweavepng -c 2 rgb.png

Convert a greyscale image to a colour image (all grey):
    priweavepng -c 1 -c 1 -c 1 grey.png

Add alpha mask from a separate (greyscale) image:
    priweavepng rgb.png grey.png

Extract alpha mask into a separate (greyscale) image:
    priweavepng -c 4 rgba.png

Steal alpha mask from second file and add to first.
Note that the intermediate image in this example has 7 channels:
    priweavepng -c 1 -c 2 -c 3 -c 7 rgb.png rgba.png

Take Green channel from 3 successive colour images to make a new RGB image:
    priweavepng -c 2 -c 5 -c 8 rgb1.png rgb2.png rgb3.png

"""

Image = collections.namedtuple("Image", "rows info")

# For each channel in the intermediate raster,
# model:
# - image: the input image (0-based);
# - i: the channel index within that image (0-based);
# - bitdepth: the bitdepth of this channel.
Channel = collections.namedtuple("Channel", "image i bitdepth")


class Error(Exception):
    pass


def weave(out, args):
    """Stack the input PNG files and extract channels
    into a single output PNG.
    """

    paths = args.input

    if len(paths) < 1:
        raise Error("Required input is missing.")

    # List of Image instances
    images = []
    # Channel map. Maps from channel number (starting from 1)
    # to an (image_index, channel_index) pair.
    channel_map = dict()
    channel = 1

    for image_index, path in enumerate(paths):
        inp = png.cli_open(path)
        rows, info = png.Reader(file=inp).asDirect()[2:]
        rows = list(rows)
        image = Image(rows, info)
        images.append(image)
        # A later version of PyPNG may intelligently support
        # PNG files with heterogenous bitdepths.
        # For now, assumes bitdepth of all channels in image
        # is the same.
        channel_bitdepth = (image.info["bitdepth"],) * image.info["planes"]
        for i in range(image.info["planes"]):
            channel_map[channel + i] = Channel(image_index, i, channel_bitdepth[i])
        channel += image.info["planes"]

    assert channel - 1 == sum(image.info["planes"] for image in images)

    # If no channels, select up to first 4 as default.
    if not args.channel:
        args.channel = range(1, channel)[:4]

    out_channels = len(args.channel)
    if not (0 < out_channels <= 4):
        raise Error("Too many channels selected (must be 1 to 4)")
    alpha = out_channels in (2, 4)
    greyscale = out_channels in (1, 2)

    bitdepth = tuple(image.info["bitdepth"] for image in images)
    arraytype = "BH"[max(bitdepth) > 8]

    size = [image.info["size"] for image in images]
    # Currently, fail unless all images same size.
    if len(set(size)) > 1:
        raise NotImplementedError("Cannot cope when sizes differ - sorry!")
    size = size[0]

    # Values per row, of output image
    vpr = out_channels * size[0]

    def weave_row_iter():
        """
        Yield each woven row in turn.
        """
        # The zip call creates an iterator that yields
        # a tuple with each element containing the next row
        # for each of the input images.
        for row_tuple in zip(*(image.rows for image in images)):
            # output row
            row = array(arraytype, [0] * vpr)
            # for each output channel select correct input channel
            for out_channel_i, selection in enumerate(args.channel):
                channel = channel_map[selection]
                # incoming row (make it an array)
                irow = array(arraytype, row_tuple[channel.image])
                n = images[channel.image].info["planes"]
                row[out_channel_i::out_channels] = irow[channel.i :: n]
            yield row

    w = png.Writer(
        size[0],
        size[1],
        greyscale=greyscale,
        alpha=alpha,
        bitdepth=bitdepth,
        interlace=args.interlace,
    )
    w.write(out, weave_row_iter())


def comma_list(s):
    """
    Type and return a list of integers.
    """

    return [int(c) for c in re.findall(r"\d+", s)]


def main(argv=None):
    import argparse
    import itertools
    import sys

    if argv is None:
        argv = sys.argv
    argv = argv[1:]

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-c",
        "--channel",
        action="append",
        type=comma_list,
        help="list of channels to extract",
    )
    parser.add_argument("--interlace", action="store_true", help="write interlaced PNG")
    parser.add_argument("input", nargs="+")
    args = parser.parse_args(argv)

    if args.channel:
        args.channel = list(itertools.chain(*args.channel))

    return weave(png.binary_stdout(), args)


if __name__ == "__main__":
    main()
