mirror of
https://github.com/makew0rld/didder.git
synced 2025-09-03 17:42:33 +02:00
550 lines
14 KiB
Go
550 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/gif"
|
|
"image/png"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"github.com/makeworld-the-better-one/dither/v2"
|
|
"github.com/urfave/cli/v2"
|
|
"golang.org/x/image/colornames"
|
|
)
|
|
|
|
// parsePercentArg takes a string like "0.5" or "50%" and will return a float
|
|
// like 50 or 0.5, depending on the second argument. An empty string returns 0.
|
|
//
|
|
// If `maxOne` is true, then "50%" will return 0.5. Otherwise it will return 50.
|
|
func parsePercentArg(arg string, maxOne bool) (float64, error) {
|
|
if arg == "" {
|
|
return 0, nil
|
|
}
|
|
if strings.HasSuffix(arg, "%") {
|
|
arg = arg[:len(arg)-1]
|
|
f64, err := strconv.ParseFloat(arg, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if maxOne {
|
|
f64 /= 100.0
|
|
}
|
|
return f64, nil
|
|
}
|
|
f64, err := strconv.ParseFloat(arg, 64)
|
|
if !maxOne {
|
|
f64 *= 100.0
|
|
}
|
|
return f64, err
|
|
}
|
|
|
|
// globalFlag returns the value of flag at the top level of the command.
|
|
// For example, with the command:
|
|
// dither --threads 1 edm -s Simple2D
|
|
// "threads" is a global flag, and "s" is a flag local to the edm subcommand.
|
|
func globalFlag(flag string, c *cli.Context) interface{} {
|
|
ancestor := c.Lineage()[len(c.Lineage())-1]
|
|
if len(ancestor.Args().Slice()) == 0 {
|
|
// When the global context calls this func, the last in the lineage
|
|
// has no args for some reason. So return the second-last instead.
|
|
return c.Lineage()[len(c.Lineage())-2].Value(flag)
|
|
}
|
|
return ancestor.Value(flag)
|
|
}
|
|
|
|
// globalIsSet returns a bool indicating whether the provided global flag
|
|
// was actually set.
|
|
func globalIsSet(flag string, c *cli.Context) bool {
|
|
ancestor := c.Lineage()[len(c.Lineage())-1]
|
|
if len(ancestor.Args().Slice()) == 0 {
|
|
// See globalFlag for why this if statement exists
|
|
return c.Lineage()[len(c.Lineage())-2].IsSet(flag)
|
|
}
|
|
return ancestor.IsSet(flag)
|
|
}
|
|
|
|
// parseArgs takes arguments and splits them using the provided split characters.
|
|
func parseArgs(args []string, splitRunes string) []string {
|
|
finalArgs := make([]string, 0)
|
|
for _, arg := range args {
|
|
finalArgs = append(finalArgs, strings.FieldsFunc(arg, func(c rune) bool {
|
|
for _, c2 := range splitRunes {
|
|
if c == c2 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})...)
|
|
}
|
|
return finalArgs
|
|
}
|
|
|
|
func hexToColor(hex string) (color.NRGBA, error) {
|
|
// Modified from https://github.com/lucasb-eyer/go-colorful/blob/v1.2.0/colors.go#L333
|
|
|
|
hex = strings.TrimPrefix(hex, "#")
|
|
|
|
format := "%02x%02x%02x"
|
|
var r, g, b uint8
|
|
n, err := fmt.Sscanf(strings.ToLower(hex), format, &r, &g, &b)
|
|
if err != nil {
|
|
return color.NRGBA{}, err
|
|
}
|
|
if n != 3 {
|
|
return color.NRGBA{}, fmt.Errorf("%s is not a hex color", hex)
|
|
}
|
|
return color.NRGBA{r, g, b, 255}, nil
|
|
}
|
|
|
|
func rgbToColor(s string) (color.NRGBA, error) {
|
|
format := "%d,%d,%d"
|
|
var r, g, b uint8
|
|
n, err := fmt.Sscanf(s, format, &r, &g, &b)
|
|
if err != nil {
|
|
return color.NRGBA{}, err
|
|
}
|
|
if n != 3 {
|
|
return color.NRGBA{}, fmt.Errorf("%s is not an RGB tuple", s)
|
|
}
|
|
return color.NRGBA{r, g, b, 255}, nil
|
|
}
|
|
|
|
func rgbaToColor(s string) (color.NRGBA, error) {
|
|
format := "%d,%d,%d,%d"
|
|
var r, g, b, a uint8
|
|
n, err := fmt.Sscanf(s, format, &r, &g, &b, &a)
|
|
if err != nil {
|
|
return color.NRGBA{}, err
|
|
}
|
|
if n != 4 {
|
|
return color.NRGBA{}, fmt.Errorf("%s is not an RGBA tuple", s)
|
|
}
|
|
// Parse as non-premult, as that's more user-friendly
|
|
return color.NRGBA{r, g, b, a}, nil
|
|
}
|
|
|
|
// parseColors takes args and turns them into a color slice. All returned
|
|
// colors are guaranteed to only be color.NRGBA.
|
|
func parseColors(flag string, c *cli.Context) ([]color.Color, error) {
|
|
args := parseArgs([]string{globalFlag(flag, c).(string)}, " ")
|
|
colors := make([]color.Color, len(args))
|
|
|
|
for i, arg := range args {
|
|
// Try to parse as RGB numbers, then hex, then grayscale, then SVG colors, then fail
|
|
// Optionally try for RGBA if it's recolor, see #1
|
|
|
|
if strings.Count(arg, ",") == 2 {
|
|
rgbColor, err := rgbToColor(arg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %s is not a valid RGB tuple. Example: 25,200,150", flag, arg)
|
|
}
|
|
colors[i] = rgbColor
|
|
continue
|
|
}
|
|
|
|
if flag == "recolor" && strings.Count(arg, ",") == 3 {
|
|
rgbaColor, err := rgbaToColor(arg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %s is not a valid RGBA tuple. Example: 25,200,150,100", flag, arg)
|
|
}
|
|
colors[i] = rgbaColor
|
|
continue
|
|
}
|
|
|
|
hexColor, err := hexToColor(arg)
|
|
if err == nil {
|
|
colors[i] = hexColor
|
|
continue
|
|
}
|
|
|
|
n, err := strconv.Atoi(arg)
|
|
if err == nil {
|
|
if n > 255 || n < 0 {
|
|
return nil, fmt.Errorf("%s: single numbers like %d must be in the range 0-255", flag, n)
|
|
}
|
|
colors[i] = color.NRGBA{uint8(n), uint8(n), uint8(n), 255}
|
|
continue
|
|
}
|
|
|
|
htmlColor, ok := colornames.Map[strings.ToLower(arg)]
|
|
if ok {
|
|
colors[i] = color.NRGBAModel.Convert(htmlColor).(color.NRGBA)
|
|
continue
|
|
}
|
|
|
|
return nil, fmt.Errorf("%s: %s not recognized as an RGB tuple, hex code, number 0-255, or SVG color name", flag, arg)
|
|
}
|
|
|
|
return colors, nil
|
|
}
|
|
|
|
// getInputImage takes an input image arg and returns an image that has
|
|
// modifications applied.
|
|
func getInputImage(arg string, c *cli.Context) (image.Image, error) {
|
|
var img image.Image
|
|
var err error
|
|
|
|
if arg == "-" {
|
|
img, err = imaging.Decode(os.Stdin, autoOrientation)
|
|
} else {
|
|
img, err = imaging.Open(arg, autoOrientation)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if width != 0 || height != 0 {
|
|
// Box sampling is quick and fast, and better then others at downscaling
|
|
// Downscaling will be a much more common use case for pre-dither scaling
|
|
// then upscaling
|
|
// https://pkg.go.dev/github.com/disintegration/imaging#ResampleFilter
|
|
// https://en.wikipedia.org/wiki/Image_scaling#Box_sampling
|
|
img = imaging.Resize(img, width, height, imaging.Box)
|
|
}
|
|
|
|
if grayscale {
|
|
img = imaging.Grayscale(img)
|
|
}
|
|
if saturation != 0 {
|
|
img = imaging.AdjustSaturation(img, saturation)
|
|
}
|
|
if contrast != 0 {
|
|
img = imaging.AdjustContrast(img, contrast)
|
|
}
|
|
if brightness != 0 {
|
|
img = imaging.AdjustBrightness(img, brightness)
|
|
}
|
|
|
|
return img, nil
|
|
}
|
|
|
|
// From dither library
|
|
|
|
func copyImage(dst draw.Image, src image.Image) {
|
|
draw.Draw(dst, src.Bounds(), src, src.Bounds().Min, draw.Src)
|
|
}
|
|
func copyOfImage(img image.Image) *image.RGBA {
|
|
dst := image.NewRGBA(img.Bounds())
|
|
copyImage(dst, img)
|
|
return dst
|
|
}
|
|
|
|
///////
|
|
|
|
// recolor will recolor the image pixels if necessary. It should be called
|
|
// before writing any image. It should only be given a dithered image.
|
|
// It will copy an image if it cannot draw on it.
|
|
//
|
|
// If the input image is *image.Paletted, the output will always be of that type too.
|
|
func recolor(src image.Image) image.Image {
|
|
if len(recolorPalette) == 0 {
|
|
return src
|
|
}
|
|
|
|
// Modified and returned value
|
|
var img draw.Image
|
|
|
|
// getRecolor takes an image color and returns the recolor one
|
|
getRecolor := func(a color.Color) color.Color {
|
|
// palette and recolorPalette are both NRGBA, so use that here too
|
|
c := color.NRGBAModel.Convert(a).(color.NRGBA)
|
|
|
|
for i := range palette {
|
|
pc := palette[i].(color.NRGBA)
|
|
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
|
|
// Colors match. Alpha is ignored because palette colors aren't
|
|
// allowed alpha, so theirs will always be 255. While the image
|
|
// might have a different alpha at that point
|
|
return recolorPalette[i]
|
|
}
|
|
}
|
|
// This should never happen
|
|
return recolorPalette[0]
|
|
}
|
|
|
|
// Fast path for paletted images
|
|
if p, ok := src.(*image.Paletted); ok {
|
|
// For each color in the image palette, replace it with the equivalent
|
|
// recolor palette color
|
|
for i, c := range p.Palette {
|
|
p.Palette[i] = getRecolor(c)
|
|
}
|
|
return p
|
|
}
|
|
|
|
var ok bool
|
|
if img, ok = src.(draw.Image); !ok {
|
|
// Can't be changed
|
|
// Instead make a copy and recolor and return that
|
|
img = copyOfImage(src)
|
|
}
|
|
|
|
// Swap each image pixel
|
|
|
|
b := img.Bounds()
|
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
|
for x := b.Min.X; x < b.Max.X; x++ {
|
|
// Image pixel -> convert to RGBA -> find recolor palette color using map
|
|
// -> set color
|
|
img.Set(x, y, getRecolor(img.At(x, y)))
|
|
}
|
|
}
|
|
return img
|
|
}
|
|
|
|
// postProcImage post-processes the image, applying recolor and upscaling.
|
|
//
|
|
// If the input image is *image.Paletted, the output will always be of that type too.
|
|
func postProcImage(img image.Image) image.Image {
|
|
img = recolor(img)
|
|
|
|
if upscale == 1 {
|
|
return img
|
|
}
|
|
|
|
var palette color.Palette
|
|
if p, ok := img.(*image.Paletted); ok {
|
|
palette = p.Palette
|
|
}
|
|
|
|
img = imaging.Resize(
|
|
img,
|
|
img.Bounds().Dx()*upscale,
|
|
0,
|
|
imaging.NearestNeighbor,
|
|
)
|
|
|
|
if len(palette) == 0 {
|
|
return img
|
|
}
|
|
|
|
pi := image.NewPaletted(img.Bounds(), palette)
|
|
copyImage(pi, img)
|
|
return pi
|
|
}
|
|
|
|
// processImages dithers all the input images and writes them.
|
|
// It handles all image I/O.
|
|
func processImages(d *dither.Ditherer, c *cli.Context) error {
|
|
outPath := globalFlag("out", c).(string)
|
|
|
|
// Setup for if it's an animated GIF output
|
|
// Overall adapted from:
|
|
// https://github.com/makeworld-the-better-one/dither/blob/v2.0.0/examples/gif_animation.go
|
|
|
|
isAnimGIF := len(inputImages) > 1 && outFormat == "gif" && !outIsDir
|
|
|
|
var frames []*image.Paletted
|
|
if isAnimGIF {
|
|
frames = make([]*image.Paletted, len(inputImages))
|
|
}
|
|
|
|
var delays []int
|
|
var animGIF gif.GIF
|
|
if isAnimGIF {
|
|
if !globalIsSet("fps", c) {
|
|
return errors.New("output will be animated GIF, but --fps flag is not set")
|
|
}
|
|
|
|
delays = make([]int, len(inputImages))
|
|
for i := range delays {
|
|
// Round to the nearest possible frame rate supported by the GIF format
|
|
// See for details: https://superuser.com/a/1449370
|
|
// A rolling average is not done because it's harder to code and looks
|
|
// bad: https://superuser.com/q/1459724
|
|
//
|
|
// Lowest allowed delay is 1, or 100 FPS.
|
|
delays[i] = int(math.Max(math.Round(100.0/globalFlag("fps", c).(float64)), 1))
|
|
}
|
|
|
|
loopCount := int(globalFlag("loop", c).(uint))
|
|
if loopCount == 1 {
|
|
// Looping once is set using -1 in the image/gif library
|
|
loopCount = -1
|
|
} else if loopCount != 0 {
|
|
// The CLI flag is equal to the number of times looped
|
|
// But for gif.GIF.LoopCount, "the animation is looped LoopCount+1 times."
|
|
loopCount -= 1
|
|
}
|
|
animGIF = gif.GIF{
|
|
Image: frames,
|
|
Delay: delays,
|
|
LoopCount: loopCount,
|
|
}
|
|
}
|
|
|
|
// Go through images and dither (and write if not an animated GIF)
|
|
|
|
for i, inputPath := range inputImages {
|
|
|
|
img, err := getInputImage(inputPath, c)
|
|
if err != nil {
|
|
return fmt.Errorf("error loading '%s': %w", inputPath, err)
|
|
}
|
|
|
|
if isAnimGIF {
|
|
if i == 0 {
|
|
// Use the config of the first image for the animated GIF
|
|
var config image.Config
|
|
frames[0], config = d.DitherPalettedConfig(img)
|
|
frames[0] = postProcImage(frames[0]).(*image.Paletted)
|
|
|
|
if len(recolorPalette) == 0 {
|
|
animGIF.Config = config
|
|
} else {
|
|
// Same config as the Ditherer would give, but with the recolor palette
|
|
animGIF.Config = image.Config{
|
|
ColorModel: color.Palette(recolorPalette),
|
|
Width: frames[0].Bounds().Dx(),
|
|
Height: frames[0].Bounds().Dy(),
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
// Later frames
|
|
if upscale == 1 && !img.Bounds().Eq(frames[0].Bounds()) {
|
|
// Upscale check is needed because otherwise frames[0] will be upscaled and not match
|
|
return fmt.Errorf(
|
|
"image '%s' isn't the same size as '%s', all sizes must match to create an animated GIF",
|
|
inputPath, inputImages[0],
|
|
)
|
|
}
|
|
frames[i] = d.DitherPaletted(img)
|
|
frames[i] = postProcImage(frames[i]).(*image.Paletted)
|
|
|
|
// Do bounds check now, if it didn't happen before because of upscaling
|
|
if upscale != 1 && !frames[i].Bounds().Eq(frames[0].Bounds()) {
|
|
return fmt.Errorf(
|
|
"image '%s' isn't the same size as '%s', all sizes must match to create an animated GIF",
|
|
inputPath, inputImages[0],
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Not an animated GIF
|
|
// Write out the image now
|
|
// (partially copied below, outside the loop)
|
|
|
|
var file io.WriteCloser
|
|
var path string
|
|
|
|
if outPath == "-" {
|
|
file = os.Stdout
|
|
path = "stdout"
|
|
} else {
|
|
if outIsDir {
|
|
// Inside output directory
|
|
// Same name as input file but potentially different extension
|
|
path = filepath.Join(
|
|
outPath,
|
|
strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))+"."+outFormat,
|
|
)
|
|
} else {
|
|
// Output file path
|
|
path = outPath
|
|
}
|
|
|
|
file, err = os.OpenFile(path, outFileFlags, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s': %w", path, err)
|
|
}
|
|
}
|
|
|
|
if outFormat == "png" {
|
|
img = postProcImage(d.Dither(img))
|
|
err = (&png.Encoder{CompressionLevel: compLevel}).Encode(file, img)
|
|
if err != nil {
|
|
defer file.Close() // Keep (possibly stdout) open to write error messages then close
|
|
return fmt.Errorf("error writing PNG to '%s': %w", path, err)
|
|
}
|
|
file.Close()
|
|
} else {
|
|
// Output static GIF
|
|
// Adapted from:
|
|
// https://github.com/makeworld-the-better-one/dither/blob/v2.0.0/examples/gif_image.go
|
|
|
|
if !postProcNeeded {
|
|
// No post
|
|
// GIF encoder calls the ditherer
|
|
err = gif.Encode(
|
|
file, img,
|
|
&gif.Options{
|
|
NumColors: len(palette),
|
|
Quantizer: d,
|
|
Drawer: d,
|
|
},
|
|
)
|
|
} else {
|
|
// Dither and post-process first, and use recolor palette if needed
|
|
// The gif package will not change the image if it's *image.Paletted
|
|
// So even though Drawer is not set to the ditherer it'll be fine,
|
|
// and the default FloydSteinberg Drawer won't be used
|
|
|
|
img = postProcImage(d.DitherPaletted(img))
|
|
|
|
var quantizer draw.Quantizer
|
|
if len(recolorPalette) == 0 {
|
|
quantizer = d
|
|
} else {
|
|
quantizer = &fakeQuantizer{recolorPalette}
|
|
}
|
|
err = gif.Encode(
|
|
file, img,
|
|
&gif.Options{
|
|
NumColors: len(recolorPalette),
|
|
Quantizer: quantizer,
|
|
},
|
|
)
|
|
}
|
|
if err != nil {
|
|
defer file.Close()
|
|
return fmt.Errorf("error writing GIF to '%s': %w", path, err)
|
|
}
|
|
file.Close()
|
|
}
|
|
}
|
|
|
|
// Either all images have been written and everything is done, or the animated GIF
|
|
// needs to be saved.
|
|
|
|
if !isAnimGIF {
|
|
return nil
|
|
}
|
|
|
|
// Partially copied from above
|
|
|
|
var file io.WriteCloser
|
|
var path string
|
|
var err error
|
|
|
|
if outPath == "-" {
|
|
file = os.Stdout
|
|
path = "stdout"
|
|
} else {
|
|
// Output file path
|
|
path = outPath
|
|
file, err = os.OpenFile(path, outFileFlags, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s': %w", path, err)
|
|
}
|
|
}
|
|
|
|
err = gif.EncodeAll(file, &animGIF)
|
|
if err != nil {
|
|
defer file.Close()
|
|
return fmt.Errorf("error writing GIF to '%s': %w", path, err)
|
|
}
|
|
file.Close()
|
|
return nil
|
|
}
|