From 904e53c281d6a6e73187def6d9be513a2f8f076f Mon Sep 17 00:00:00 2001 From: Ashish Kumar <143941235+ashish0kumar@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:38:47 +0530 Subject: [PATCH] add shepard method backend --- cmd/convert.go | 34 +++++ config/config.go | 6 + config/constants.go | 28 +++- .../backends/colorthief/haldClut/shepard.go | 140 ++++++++++++++++++ internal/image/convert.go | 71 +++++++++ 5 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 internal/backends/colorthief/haldClut/shepard.go diff --git a/cmd/convert.go b/cmd/convert.go index 47b58b3..c0c14cd 100644 --- a/cmd/convert.go +++ b/cmd/convert.go @@ -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" @@ -17,6 +18,9 @@ import ( var ( colorPair []string theme string + backend string + nearest int + power float64 ) var convertCmd = &cobra.Command{ @@ -24,6 +28,17 @@ var convertCmd = &cobra.Command{ 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 @@ -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{} @@ -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) } diff --git a/config/config.go b/config/config.go index 0f99739..f917a10 100644 --- a/config/config.go +++ b/config/config.go @@ -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() diff --git a/config/constants.go b/config/constants.go index 56d08f3..f092d8c 100644 --- a/config/constants.go +++ b/config/constants.go @@ -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, } } diff --git a/internal/backends/colorthief/haldClut/shepard.go b/internal/backends/colorthief/haldClut/shepard.go new file mode 100644 index 0000000..06181f3 --- /dev/null +++ b/internal/backends/colorthief/haldClut/shepard.go @@ -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 +} diff --git a/internal/image/convert.go b/internal/image/convert.go index 892d589..bf8389c 100644 --- a/internal/image/convert.go +++ b/internal/image/convert.go @@ -11,6 +11,7 @@ import ( "math" "os" "path/filepath" + "runtime" "sync" "github.com/Achno/gowall/config" @@ -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 { @@ -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()