"""

This is a program to dither an image using Minimized Average Error dithering, as opposed to the
Floyd-Steinberg dithering method, which most programs (including PIL/Pillow) use to dither quantized
images

References:
    en.wikipedia.org/wiki/Floyd-Steinberg_dithering     good pseudocode
    en.wikipedia.org/wiki/Error_diffusion               MAE dithering distribution matrix
    https://www.geeksforgeeks.org/python-channel-drop-using-pillow  Name's on the tin


Tested on Windows and Linux
"""

# INF360 - Programming in Python
# Levi Smith
# Final Project

import sys # to exit in case of bad fileName


try:
    from PIL import Image # PIL is an image editing library with many pixel-based functions
except:
    print("Pillow is not installed, try: pip install Pillow")
    sys.exit()


import os # for console clear function


try:
    import numpy as np # to easily assign color channels as a single array
except:
    print("numpy is not installed, try: pip install numpy")
    sys.exit()


import logging

logging.basicConfig(filename="ditherlog.txt", level=logging.DEBUG, format=' %(asctime)s - %(levelname)s - %(message)s')

def findNearestColor(RGB):
    return round((qStep - 1) * RGB / 255) * (255 / (qStep - 1)) # scales up rgb to be divisible by 255

def singleChannelDither(im):
    for y in range(im.size[1]):
        for x in range(im.size[0]):
            oldPixel = im.getpixel((x, y)) # plucks pixel from original image before it gets changed
            newPixel = round(findNearestColor(oldPixel)) # gets value for quantized pixel
            im.putpixel((x, y), newPixel) # places quantized pixel in new image
            quantError = oldPixel - newPixel # calculates difference to distribute fractions of later

            for i in currentDitherMatrix: #loops through length of dither matrix, applying x and y offset and dither proportion
                if (x + i[0] > 0 and x + i[0] < im.size[0] and y + i[1] > 0 and y + i[1] < im.size[1]): #checks if pixels are in bounds of image
                    im.putpixel((x + i[0], y + i[1]), round(im.getpixel((x + i[0], y + i[1])) + (quantError * i[2]))) # line that puts pixels in their place with distribution fraction
        if y % round(im.size[1] / 4) == 0:
            print(f"{round((y / im.size[1]) * 100)}%")

# structure of "Key" : [[x-offset, y-offset, distributionfraction],...]
ditherStyleDict = { "MAE" : [[1, 0, 7.0/48.0], [2, 0, 5.0/48.0], [-2, 1, 3.0/48.0], [-1, 1, 5.0/48.0], [0, 1, 7.0/48.0], [1, 1, 5.0/48.0], [2, 1, 3.0/48.0], [-2, 2, 1.0/48.0], [-1, 2, 3.0/48.0], [0, 2, 5.0/48.0], [1, 2, 3.0/48.0], [2, 2, 1.0/48.0]],
                    "FS" : [[1, 0, 7.0/16.0], [-1, 1, 3.0/16.0], [0, 1, 5.0/16.0], [1, 1, 1.0/16.0]]}

currentDitherMatrix = ditherStyleDict["FS"] # default settings for a fast dither
currentChannelType = "Grayscale"
imageName = ""
qStep = 2

def mainMenu():
    print("Main menu:")
    print(" 1. Generate Image")
    print(" 2. Settings")
    print(" 3. Exit")
    print("Choose which option you would like")
    choice = input(">> ")
    os.system('clear')
    if choice == "1":
        generateImage()
    elif choice == "2":
        settingsMenu()
    elif choice == "3":
        print("Goodbye")
        sys.exit()
    else:
        print("Invalid input, please try again")
        logging.warning('Invalid option input: ' + choice)
        mainMenu()

def generateImage():
    global imageName 
    print("Input file name, including extension")
    currentFile = input(">> ")
    os.system('clear')
    imageName = currentFile
    try:
        currentFile = Image.open("./" + currentFile)
        if currentChannelType == "Grayscale":
            grayscaleDither(currentFile)
        elif currentChannelType == "RGB":
            rgbDither(currentFile)

        print("Thank you for using my dithering program, goodbye")
    except:
        print("Couldn't open file, is it in the working directory?")
        mainMenu()
    sys.exit()

def grayscaleDither(currentFile):
    currentFile = currentFile.convert("L")
    print("Progress:")
    singleChannelDither(currentFile)
    currentFile.save("d" + imageName)

def rgbDither(currentFile):
    redChannel = Image.fromarray(np.array(currentFile, np.uint8)[::, ::, 0])#numpy can pull a list from 
    greenChannel = Image.fromarray(np.array(currentFile, np.uint8)[::, ::, 1])#a multi-dimensional list
    blueChannel = Image.fromarray(np.array(currentFile, np.uint8)[::, ::, 2])#these lines convert to img
    print("Channel 1 Progress:")
    singleChannelDither(redChannel)
    print("Channel 2 Progress:")
    singleChannelDither(greenChannel)
    print("Channel 3 Progress:")
    singleChannelDither(blueChannel)

    currentFile = Image.merge("RGB", (redChannel, greenChannel, blueChannel)) #merge from Pillow into one image

    currentFile.save("d" + imageName)






#Menus

def settingsMenu():
    print("Settings:")
    print(" 1. Number of output colors")
    print(" 2. Dithering Style")
    print(" 3. Color Channels in Output")
    print(" 4. Go Back")
    choice = input(">> ")
    os.system('clear')
    if choice == "1":
        outputColorsMenu()
    elif choice == "2":
        ditherChoiceMenu()
    elif choice == "3":
        grayscaleRGBMenu()
    elif choice == "4":
        mainMenu()
    else:
        logging.warning('Invalid option input: ' + choice)
        print("Invalid input")
        settingsMenu()

def outputColorsMenu():
    global qStep
    print("Enter the number of colors to be in the final output, or type 'back' to go back to settings")
    temp = input(">> ")
    os.system('clear')
    try:
        qStep = int(temp)
    except:
        if temp.tolower() == 'back':
            settingsMenu()
        else:
            logging.warning('Invalid qStep input: ' + str(temp))
            print("Invalid input")
            outputColorsMenu()
    settingsMenu()

def ditherChoiceMenu():
    global currentDitherMatrix
    print("Which dithering style would you like to use?")
    print(" 1. Floyd-Steinberg")
    print(" 2. Minimized average error")
    print(" 3. Go Back")
    choice = input(">> ")
    os.system('clear')
    if choice == "1":
        currentDitherMatrix = ditherStyleDict["FS"]
    elif choice == "2":
        currentDitherMatrix = ditherStyleDict["MAE"]
    elif choice == "3":
        settingsMenu()
    else:
        print("Invalid input")
        logging.warning('Invalid option input: ' + choice)
        ditherChoiceMenu()
    settingsMenu()

def grayscaleRGBMenu():
    global currentChannelType
    print("Choose how you would like the output image to look")
    print(" 1. Grayscale")
    print(" 2. RGB")
    print(" 3. Go back")
    choice = input(">> ")
    os.system('clear')
    if choice == "1":
        currentChannelType = "Grayscale"
    elif choice == "2":
        currentChannelType = "RGB"
    elif choice == "3":
        settingsMenu()
    else:
        print("Invalid input")
        logging.warning('Invalid option input: ' + choice)
        grayscaleRGBMenu()
    settingsMenu()


#end of menus



logging.debug('Start of program')

os.system('clear')

print('''Welcome to my dithering program!
        Tested file types: .png, .jpeg
        Tested on Linux and Windows
        ''')

mainMenu()

logging.debug('End of Program')
