Files
didder/subcommands.go
2024-10-28 21:58:29 +01:00

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
}