mirror of
https://github.com/makew0rld/didder.git
synced 2025-09-03 17:42:33 +02:00
501 lines
12 KiB
Go
501 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image/color"
|
|
"image/png"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"github.com/makeworld-the-better-one/dither/v2"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
const (
|
|
unsupportedFormat string = "'%s' is an unsupported format, only 'png' or 'gif' are accepted"
|
|
)
|
|
|
|
var (
|
|
// palette stores the palette colors. It's set after pre-processing.
|
|
// Guaranteed to only hold color.NRGBA.
|
|
palette []color.Color
|
|
|
|
// recolorPalette stores the recolor palette colors. It's set after pre-processing.
|
|
// Guaranteed to only hold color.NRGBA.
|
|
recolorPalette []color.Color
|
|
|
|
grayscale bool
|
|
|
|
// Range -100,100
|
|
|
|
saturation float64
|
|
brightness float64
|
|
contrast float64
|
|
|
|
autoOrientation imaging.DecodeOption
|
|
|
|
inputImages []string
|
|
outFormat string // "png" or "gif"
|
|
outIsDir bool
|
|
|
|
compLevel png.CompressionLevel
|
|
|
|
outFileFlags int // For os.OpenFile
|
|
|
|
width int
|
|
height int
|
|
// upscale will always be 1 or above
|
|
upscale int
|
|
|
|
ditherer *dither.Ditherer
|
|
|
|
// range [-1, 1]
|
|
strength float32
|
|
|
|
// Is post-processing needed?
|
|
postProcNeeded bool
|
|
)
|
|
|
|
// preProcess is automatically called by the app before anything else.
|
|
// It's run in the global context.
|
|
func preProcess(c *cli.Context) error {
|
|
runtime.GOMAXPROCS(int(c.Uint("threads")))
|
|
|
|
var err error
|
|
palette, err = parseColors("palette", c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(palette) < 2 {
|
|
return errors.New("the palette must have at least two colors")
|
|
}
|
|
|
|
if c.String("recolor") != "" {
|
|
recolorPalette, err = parseColors("recolor", c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(recolorPalette) != len(palette) {
|
|
return errors.New("recolor palette must have the same number of colors as the initial palette")
|
|
}
|
|
}
|
|
|
|
// Check if palette is grayscale and make image grayscale
|
|
// Or if the user forces it
|
|
|
|
grayscale = true
|
|
if !c.Bool("grayscale") {
|
|
// Grayscale isn't specified by the user
|
|
// So check to see if palette is grayscale
|
|
for _, c := range palette {
|
|
r, g, b, _ := c.RGBA()
|
|
if r != g || g != b {
|
|
grayscale = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
saturation, err = parsePercentArg(c.String("saturation"), false)
|
|
if err != nil {
|
|
return fmt.Errorf("saturation: %w", err)
|
|
}
|
|
if saturation <= -100 {
|
|
grayscale = true
|
|
saturation = 0
|
|
}
|
|
brightness, err = parsePercentArg(c.String("brightness"), false)
|
|
if err != nil {
|
|
return fmt.Errorf("brightness: %w", err)
|
|
}
|
|
contrast, err = parsePercentArg(c.String("contrast"), false)
|
|
if err != nil {
|
|
return fmt.Errorf("contrast: %w", err)
|
|
}
|
|
|
|
autoOrientation = imaging.AutoOrientation(!c.Bool("no-exif-rotation"))
|
|
|
|
inputImages = make([]string, 0)
|
|
for _, path := range c.StringSlice("in") {
|
|
if strings.Contains(path, "*") {
|
|
// Parse as glob
|
|
paths, err := filepath.Glob(path)
|
|
if err != nil {
|
|
return fmt.Errorf("bad glob pattern '%s': %w", path, err)
|
|
}
|
|
inputImages = append(inputImages, paths...)
|
|
} else {
|
|
inputImages = append(inputImages, path)
|
|
}
|
|
}
|
|
|
|
formatVal := c.String("format")
|
|
if formatVal != "png" && formatVal != "gif" {
|
|
return fmt.Errorf(unsupportedFormat, formatVal)
|
|
}
|
|
|
|
// Figure out output format
|
|
|
|
outVal := c.String("out")
|
|
|
|
if outVal == "-" {
|
|
// Outputting to stdout, so just use whatever the flag is
|
|
outFormat = formatVal
|
|
} else {
|
|
// Outputting to dir or file
|
|
|
|
outFI, err := os.Stat(outVal)
|
|
|
|
if err == nil && outFI.IsDir() {
|
|
// Exists and is a directory
|
|
// Just use what the flag is
|
|
outFormat = formatVal
|
|
outIsDir = true
|
|
|
|
} else {
|
|
// Outputting to file, that already exists
|
|
// Or something that doesn't exist - assumed to be a file
|
|
|
|
if !c.IsSet("format") {
|
|
// Format wasn't set, so ignore default value of "png"
|
|
// Try to figure out format from output filename
|
|
ext := strings.TrimPrefix(filepath.Ext(outVal), ".")
|
|
if ext == "png" || ext == "gif" {
|
|
// Acceptable extension
|
|
outFormat = ext
|
|
} else if ext == "" {
|
|
// No extension, use default format
|
|
outFormat = "png"
|
|
} else {
|
|
// Unsupported extension and no format flag override
|
|
return fmt.Errorf(unsupportedFormat, ext)
|
|
}
|
|
} else {
|
|
// Format flag was set, so ignore what the file looks like
|
|
outFormat = formatVal
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// Multiple input images are only valid if the output is GIF,
|
|
// or if the output points to a directory.
|
|
if len(inputImages) > 1 && (outFormat != "gif" && !outIsDir) {
|
|
return fmt.Errorf("multiple input images are only allowed if the output format is GIF, or an existing directory")
|
|
}
|
|
|
|
if outFormat == "gif" && len(palette) > 256 {
|
|
return errors.New("the GIF format only supports 256 colors or less in the palette")
|
|
}
|
|
|
|
// Set PNG compression type
|
|
|
|
switch c.String("compression") {
|
|
case "default":
|
|
compLevel = png.DefaultCompression
|
|
case "no":
|
|
compLevel = png.NoCompression
|
|
case "speed":
|
|
compLevel = png.BestSpeed
|
|
case "size":
|
|
compLevel = png.BestCompression
|
|
default:
|
|
return fmt.Errorf("invalid compression type '%s'", c.String("compression"))
|
|
}
|
|
|
|
if c.Bool("no-overwrite") {
|
|
outFileFlags = os.O_WRONLY | os.O_CREATE | os.O_EXCL
|
|
} else {
|
|
outFileFlags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
|
|
}
|
|
|
|
// Set here for convenience
|
|
width = int(c.Uint("width"))
|
|
height = int(c.Uint("height"))
|
|
upscale = int(c.Uint("upscale"))
|
|
if upscale == 0 {
|
|
// Invalid
|
|
upscale = 1
|
|
}
|
|
|
|
ditherer = dither.NewDitherer(palette)
|
|
|
|
tmp, err := parsePercentArg(c.String("strength"), true)
|
|
if err != nil {
|
|
return fmt.Errorf("strength: %w", err)
|
|
}
|
|
strength = float32(tmp)
|
|
if strength == 0 {
|
|
// Ignore
|
|
strength = 1
|
|
}
|
|
|
|
if len(recolorPalette) != 0 || upscale > 1 {
|
|
postProcNeeded = true
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func random(c *cli.Context) error {
|
|
args := parseArgs(c.Args().Slice(), " ,")
|
|
|
|
// Manually parse out --seed, -s flag
|
|
// The manual parsing is done to allow for numbers that start with a negative
|
|
// which would otherwise be interpreted as flags
|
|
|
|
seedIsSet := false
|
|
var seed int64
|
|
|
|
if len(args) >= 1 {
|
|
if args[0] == "--seed" || args[0] == "-s" {
|
|
if len(args) >= 2 {
|
|
// Parse and set seed value
|
|
|
|
var err error
|
|
seed, err = strconv.ParseInt(args[1], 10, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("couldn't parse seed value: %w", err)
|
|
}
|
|
seedIsSet = true
|
|
args = args[2:]
|
|
} else {
|
|
// Seed flag but no value after it
|
|
return errors.New("no value after seed flag")
|
|
}
|
|
} else if args[0] == "--help" || args[0] == "-h" {
|
|
// Display the help
|
|
return cli.ShowCommandHelp(c, "random")
|
|
}
|
|
}
|
|
|
|
if len(args) != 2 && len(args) != 6 {
|
|
return errors.New("random needs 2 or 6 arguments")
|
|
}
|
|
|
|
floatArgs := make([]float32, len(args))
|
|
for i, arg := range args {
|
|
f64, err := parsePercentArg(arg, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
floatArgs[i] = float32(f64)
|
|
}
|
|
|
|
if seedIsSet {
|
|
rand.Seed(seed)
|
|
} else {
|
|
// Seed with something that won't repeat next use
|
|
rand.Seed(time.Now().UnixNano())
|
|
}
|
|
|
|
if len(floatArgs) == 2 {
|
|
if grayscale {
|
|
ditherer.Mapper = dither.RandomNoiseGrayscale(floatArgs[0], floatArgs[1])
|
|
} else {
|
|
// Use the two arguments for all channels
|
|
ditherer.Mapper = dither.RandomNoiseRGB(floatArgs[0], floatArgs[1], floatArgs[0], floatArgs[1], floatArgs[0], floatArgs[1])
|
|
}
|
|
} else {
|
|
ditherer.Mapper = dither.RandomNoiseRGB(floatArgs[0], floatArgs[1], floatArgs[2], floatArgs[3], floatArgs[4], floatArgs[5])
|
|
}
|
|
if seedIsSet {
|
|
// Make deterministic
|
|
ditherer.SingleThreaded = true
|
|
}
|
|
|
|
err := processImages(ditherer, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func bayer(c *cli.Context) error {
|
|
args := parseArgs(c.Args().Slice(), " ,x")
|
|
|
|
if len(args) != 2 {
|
|
return errors.New("bayer needs 2 arguments exactly. Example: 4x4")
|
|
}
|
|
|
|
uintArgs := make([]uint, 2)
|
|
for i, arg := range args {
|
|
u64, err := strconv.ParseUint(arg, 10, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uintArgs[i] = uint(u64)
|
|
}
|
|
|
|
// Validate args to prevent dither.Bayer from panicking
|
|
|
|
x, y := uintArgs[0], uintArgs[1]
|
|
if x == 0 || y == 0 {
|
|
return errors.New("neither dimension can be 0")
|
|
}
|
|
if x == 1 && y == 1 {
|
|
return errors.New("a 1x1 matrix will not dither the image")
|
|
}
|
|
if ((x&(x-1)) != 0 || (y&(y-1)) != 0) && // Power of two?
|
|
!((x == 3 && y == 3) || (x == 5 && y == 3) || (x == 3 && y == 5)) { // Exceptions
|
|
// Not a power of two, and not an exception
|
|
return errors.New("both dimensions must be powers of two")
|
|
}
|
|
|
|
ditherer.Mapper = dither.Bayer(x, y, strength)
|
|
|
|
err := processImages(ditherer, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var odmName = map[string]dither.OrderedDitherMatrix{
|
|
"clustereddot4x4": dither.ClusteredDot4x4,
|
|
"clustereddotdiagonal8x8": dither.ClusteredDotDiagonal8x8,
|
|
"vertical5x3": dither.Vertical5x3,
|
|
"horizontal3x5": dither.Horizontal3x5,
|
|
"clustereddotdiagonal6x6": dither.ClusteredDotDiagonal6x6,
|
|
"clustereddotdiagonal8x8_2": dither.ClusteredDotDiagonal8x8_2,
|
|
"clustereddotdiagonal16x16": dither.ClusteredDotDiagonal16x16,
|
|
"clustereddot6x6": dither.ClusteredDot6x6,
|
|
"clustereddotspiral5x5": dither.ClusteredDotSpiral5x5,
|
|
"clustereddothorizontalline": dither.ClusteredDotHorizontalLine,
|
|
"clustereddotverticalline": dither.ClusteredDotVerticalLine,
|
|
"clustereddot8x8": dither.ClusteredDot8x8,
|
|
"clustereddot6x6_2": dither.ClusteredDot6x6_2,
|
|
"clustereddot6x6_3": dither.ClusteredDot6x6_3,
|
|
"clustereddotdiagonal8x8_3": dither.ClusteredDotDiagonal8x8_3,
|
|
}
|
|
|
|
func odm(c *cli.Context) error {
|
|
args := c.Args().Slice()
|
|
|
|
if len(args) != 1 {
|
|
return errors.New("odm only accepts one argument")
|
|
}
|
|
|
|
var matrix dither.OrderedDitherMatrix
|
|
|
|
matrix, ok := odmName[strings.ReplaceAll(strings.ToLower(args[0]), "-", "_")]
|
|
if !ok {
|
|
// Either inline JSON, path to file, or an error
|
|
err := json.Unmarshal([]byte(args[0]), &matrix)
|
|
if err != nil {
|
|
bytes, err := os.ReadFile(args[0])
|
|
if err != nil {
|
|
return errors.New("couldn't process argument as matrix name, inline JSON, or path to accessible JSON file")
|
|
}
|
|
err = json.Unmarshal(bytes, &matrix)
|
|
if err != nil {
|
|
return errors.New("couldn't process argument as matrix name, inline JSON, or path to accessible JSON file")
|
|
}
|
|
}
|
|
|
|
// Validate matrix
|
|
|
|
if matrix.Max == 0 {
|
|
return errors.New("the max value of the matrix cannot be 0")
|
|
}
|
|
if len(matrix.Matrix) == 0 {
|
|
return errors.New("matrix is empty")
|
|
}
|
|
// Is it rectangular?
|
|
width := len(matrix.Matrix[0])
|
|
if width == 0 {
|
|
return errors.New("matrix has empty row")
|
|
}
|
|
for _, row := range matrix.Matrix {
|
|
if len(row) != width {
|
|
return errors.New("matrix is not rectangular, all rows must be the same length")
|
|
}
|
|
}
|
|
}
|
|
|
|
ditherer.Mapper = dither.PixelMapperFromMatrix(matrix, strength)
|
|
|
|
err := processImages(ditherer, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var edmName = map[string]dither.ErrorDiffusionMatrix{
|
|
"simple2d": dither.Simple2D,
|
|
"floydsteinberg": dither.FloydSteinberg,
|
|
"falsefloydsteinberg": dither.FalseFloydSteinberg,
|
|
"jarvisjudiceninke": dither.JarvisJudiceNinke,
|
|
"atkinson": dither.Atkinson,
|
|
"stucki": dither.Stucki,
|
|
"burkes": dither.Burkes,
|
|
"sierra": dither.Sierra,
|
|
"sierra3": dither.Sierra3,
|
|
"tworowsierra": dither.TwoRowSierra,
|
|
"sierralite": dither.SierraLite,
|
|
"sierra2_4a": dither.Sierra2_4A,
|
|
"stevenpigeon": dither.StevenPigeon,
|
|
}
|
|
|
|
func edm(c *cli.Context) error {
|
|
args := c.Args().Slice()
|
|
|
|
if len(args) != 1 {
|
|
return errors.New("edm only accepts one argument")
|
|
}
|
|
|
|
var matrix dither.ErrorDiffusionMatrix
|
|
|
|
matrix, ok := edmName[strings.ReplaceAll(strings.ToLower(args[0]), "-", "_")]
|
|
if !ok {
|
|
// Either inline JSON, path to file, or an error
|
|
err := json.Unmarshal([]byte(args[0]), &matrix)
|
|
if err != nil {
|
|
bytes, err := os.ReadFile(args[0])
|
|
if err != nil {
|
|
return errors.New("couldn't process argument as matrix name, inline JSON, or path to accessible JSON file")
|
|
}
|
|
err = json.Unmarshal(bytes, &matrix)
|
|
if err != nil {
|
|
return errors.New("couldn't process argument as matrix name, inline JSON, or path to accessible JSON file")
|
|
}
|
|
}
|
|
|
|
// Validate matrix
|
|
|
|
if len(matrix) == 0 {
|
|
return errors.New("matrix is empty")
|
|
}
|
|
// Is it rectangular?
|
|
width := len(matrix[0])
|
|
if width == 0 {
|
|
return errors.New("matrix has empty row")
|
|
}
|
|
for _, row := range matrix {
|
|
if len(row) != width {
|
|
return errors.New("matrix is not rectangular, all rows must be the same length")
|
|
}
|
|
}
|
|
}
|
|
|
|
ditherer.Matrix = dither.ErrorDiffusionStrength(matrix, strength)
|
|
if c.Bool("serpentine") {
|
|
ditherer.Serpentine = true
|
|
}
|
|
|
|
err := processImages(ditherer, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|