Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmd/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"strconv"

"github.com/Achno/gowall/config"
"github.com/Achno/gowall/internal/image"
imageio "github.com/Achno/gowall/internal/image_io"
"github.com/Achno/gowall/internal/logger"
Expand All @@ -17,13 +18,27 @@ import (
var (
colorPair []string
theme string
backend string
nearest int
power float64
)

var convertCmd = &cobra.Command{
Use: "convert [INPUT] [OPTIONAL OUTPUT]",
Short: "Convert an img's color scheme",
Long: `Convert an img's color scheme or its format ie from webp to png etc`,
PreRunE: func(cmd *cobra.Command, args []string) error {

// Validate Shepard-specific flags
if backend != config.BackendShepard {
if cmd.Flags().Changed("nearest") {
return fmt.Errorf("--nearest is only valid with --backend shepard")
}
if cmd.Flags().Changed("power") {
return fmt.Errorf("--power is only valid with --backend shepard")
}
}

err := validateInput(shared, args)
if err != nil {
return err
Expand All @@ -36,6 +51,21 @@ var convertCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
var processor image.ImageProcessor

// Update backend if flag was provided
if cmd.Flags().Changed("backend") {
config.GowallConfig.ColorCorrectionBackend = backend
}

// Only update Shepard options if using Shepard backend
if config.GowallConfig.ColorCorrectionBackend == config.BackendShepard {
if cmd.Flags().Changed("nearest") {
config.GowallConfig.ShepardOptions.Nearest = nearest
}
if cmd.Flags().Changed("power") {
config.GowallConfig.ShepardOptions.Power = power
}
}

// Determine which processor to use
if len(theme) > 0 {
processor = &image.ThemeConverter{}
Expand Down Expand Up @@ -88,6 +118,10 @@ func init() {
convertCmd.Flags().StringVarP(&theme, "theme", "t", "", "Usage : --theme [ThemeName] or [PATH to Json file containing theme]")
convertCmd.Flags().StringVarP(&shared.Format, "format", "f", "", "Usage : --format [image format] png,webp,jpg,jpeg")
convertCmd.Flags().StringSliceVarP(&colorPair, "replace", "r", nil, "Usage: --replace #FromColor,#ToColor")
convertCmd.Flags().StringVarP(&backend, "backend", "b", config.BackendRBF, "Color correction backend: nn, rbf, shepard")
convertCmd.Flags().IntVarP(&nearest, "nearest", "n", config.ShepardOptionsDefault.Nearest, "Number of nearest colors for Shepard's method")
convertCmd.Flags().Float64VarP(&power, "power", "p", config.ShepardOptionsDefault.Power, "Power parameter for Shepard's method")
convertCmd.RegisterFlagCompletionFunc("theme", themeCompletion)

addGlobalFlags(convertCmd)
}
6 changes: 6 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ type Options struct {
ColorCorrectionBackend string `yaml:"ColorCorrectionBackend"`
OutputFolder string `yaml:"OutputFolder"`
Themes []themeWrapper `yaml:"themes"`
ShepardOptions ShepardOptions `yaml:"shepardOptions"`
}

type ShepardOptions struct {
Nearest int `yaml:"nearest"`
Power float64 `yaml:"power"`
}

var GowallConfig = defaultConfig()
Expand Down
28 changes: 20 additions & 8 deletions config/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,32 @@ const (
WallOfTheDayUrl = "https://www.reddit.com/r/wallpaper/top/"
HexCodeVisualUrl = "https://lawlesscreation.github.io/hex-color-visualiser/"
UpscalerBinaryName = "realesrgan-ncnn-vulkan"

BackendNN = "nn"
BackendRBF = "rbf"
BackendShepard = "shepard"
)

var (
EnableImagePreviewingDefault = true
InlineImagePreviewDefault = false
ImagePreviewBackend = ""
ThemesDefault = []themeWrapper{}
EnableImagePreviewingDefault = true
InlineImagePreviewDefault = false
ImagePreviewBackend = ""
ColorCorrectionBackendDefault = BackendRBF
ThemesDefault = []themeWrapper{}

ShepardOptionsDefault = ShepardOptions{
Nearest: 30,
Power: 4.0,
}
)

func defaultConfig() Options {
return Options{
EnableImagePreviewing: EnableImagePreviewingDefault,
Themes: ThemesDefault,
InlineImagePreview: InlineImagePreviewDefault,
ImagePreviewBackend: ImagePreviewBackend,
EnableImagePreviewing: EnableImagePreviewingDefault,
InlineImagePreview: InlineImagePreviewDefault,
ImagePreviewBackend: ImagePreviewBackend,
ColorCorrectionBackend: ColorCorrectionBackendDefault,
Themes: ThemesDefault,
ShepardOptions: ShepardOptionsDefault,
}
}
140 changes: 140 additions & 0 deletions internal/backends/colorthief/haldClut/shepard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package haldclut

import (
"image/color"
"math"
"sort"
)

type ShepardMapper struct {
options ShepardMapperOptions
}

type ShepardMapperOptions struct {
Nearest int
Power float64
}

func NewShepardMapper(opts ShepardMapperOptions) *ShepardMapper {
return &ShepardMapper{options: opts}
}

func (m *ShepardMapper) Map(original color.RGBA, palette []color.RGBA) color.RGBA {
return shepardInterpolation(original, palette, m.options)
}

// Core Shepard's Method implementation
func shepardInterpolation(originalRGBA color.RGBA, paletteRGBAs []color.RGBA, opts ShepardMapperOptions) color.RGBA {
if len(paletteRGBAs) == 0 {
return originalRGBA
}

// Find N closest colors based on original color
closest := findNClosestColors(originalRGBA, paletteRGBAs, opts.Nearest)
if len(closest) == 0 {
return originalRGBA
}

// If exact match or only one neighbor, return it
if len(closest) == 1 || closest[0].dist == 0 {
return closest[0].color
}

// Calculate inverse distance weights
weights := make([]float64, len(closest))
var totalWeight float64

for i, c := range closest {
if c.dist == 0 {
return c.color // Exact match found
}
weight := 1.0 / math.Pow(math.Sqrt(c.dist), opts.Power)
weights[i] = weight
totalWeight += weight
}

// Blend colors using inverse distance weights
blended := blendColors(extractColors(closest), weights)
return blended
}

func colorDistanceSquared(c1, c2 color.RGBA) float64 {
dr := float64(c1.R) - float64(c2.R)
dg := float64(c1.G) - float64(c2.G)
db := float64(c1.B) - float64(c2.B)
return dr*dr + dg*dg + db*db
}

func findNClosestColors(originalRGBA color.RGBA, paletteRGBAs []color.RGBA, n int) []struct {
dist float64
color color.RGBA
} {
// Early termination if exact match found
for _, pRGBA := range paletteRGBAs {
if originalRGBA == pRGBA {
return []struct {
dist float64
color color.RGBA
}{{dist: 0, color: pRGBA}}
}
}

distances := make([]struct {
dist float64
color color.RGBA
}, 0, len(paletteRGBAs))

for _, pRGBA := range paletteRGBAs {
distances = append(distances, struct {
dist float64
color color.RGBA
}{dist: colorDistanceSquared(originalRGBA, pRGBA), color: pRGBA})
}

sort.Slice(distances, func(i, j int) bool {
return distances[i].dist < distances[j].dist
})

if n > len(distances) {
n = len(distances)
}
return distances[:n]
}

func blendColors(colors []color.RGBA, weights []float64) color.RGBA {
if len(colors) == 0 || len(colors) != len(weights) {
return color.RGBA{}
}

var sumR, sumG, sumB float64
var totalWeight float64

for i := range colors {
sumR += float64(colors[i].R) * weights[i]
sumG += float64(colors[i].G) * weights[i]
sumB += float64(colors[i].B) * weights[i]
totalWeight += weights[i]
}

if totalWeight == 0 {
return colors[0]
}

return color.RGBA{
R: uint8(math.Round(sumR / totalWeight)),
G: uint8(math.Round(sumG / totalWeight)),
B: uint8(math.Round(sumB / totalWeight)),
A: 255,
}
}

func extractColors(sortedColors []struct {
dist float64
color color.RGBA
}) []color.RGBA {
colors := make([]color.RGBA, len(sortedColors))
for i, item := range sortedColors {
colors[i] = item.color
}
return colors
}
71 changes: 71 additions & 0 deletions internal/image/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"math"
"os"
"path/filepath"
"runtime"
"sync"

"github.com/Achno/gowall/config"
Expand Down Expand Up @@ -38,6 +39,15 @@ func (themeConv *ThemeConverter) Process(img image.Image, theme string) (image.I
return newimg, nil
}

// Shepard's method backend
if config.GowallConfig.ColorCorrectionBackend == config.BackendShepard {
newimg, err := ShepardMethod(img, selectedTheme)
if err != nil {
return nil, err
}
return newimg, nil
}

// hash colors to know if anything in the custom themes have changed
clrs, err := GetThemeColors(theme)
if err != nil {
Expand Down Expand Up @@ -107,6 +117,67 @@ func NearestNeighbour(img image.Image, theme Theme) (image.Image, error) {
return newImg, nil
}

func ShepardMethod(img image.Image, theme Theme) (image.Image, error) {
bounds := img.Bounds()
newImg := image.NewRGBA(bounds)

// Convert theme colors to RGBA
paletteRGBAs := make([]color.RGBA, len(theme.Colors))
for i, c := range theme.Colors {
if rgba, ok := c.(color.RGBA); ok {
paletteRGBAs[i] = rgba
} else {
return nil, fmt.Errorf("theme color at index %d is not color.RGBA", i)
}
}

// Create a ShepardMapper with the options from the config
opts := haldclut.ShepardMapperOptions{
Nearest: config.GowallConfig.ShepardOptions.Nearest,
Power: config.GowallConfig.ShepardOptions.Power,
}
mapper := haldclut.NewShepardMapper(opts)

// Concurrent processing using goroutines
numWorkers := runtime.NumCPU()
height := bounds.Max.Y - bounds.Min.Y
rowsPerWorker := height / numWorkers
if rowsPerWorker == 0 {
rowsPerWorker = 1
}

var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
startY := bounds.Min.Y + i*rowsPerWorker
endY := startY + rowsPerWorker
if i == numWorkers-1 {
endY = bounds.Max.Y
}

wg.Add(1)
go func(startY, endY int) {
defer wg.Done()
for y := startY; y < endY; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
originalColor := img.At(x, y)
originalRGBA := color.RGBAModel.Convert(originalColor).(color.RGBA)

if originalRGBA.A == 0 {
newImg.Set(x, y, color.Transparent)
continue
}

newColor := mapper.Map(originalRGBA, paletteRGBAs)
newImg.Set(x, y, newColor)
}
}
}(startY, endY)
}

wg.Wait()
return newImg, nil
}

func nearestColor(clr color.Color, theme Theme) color.Color {
r, g, b, _ := clr.RGBA()

Expand Down