Skip to content

Commit fcad471

Browse files
author
James Campbell
committed
Add pkg/swatchify library files
1 parent 2f54e42 commit fcad471

File tree

2 files changed

+252
-0
lines changed

2 files changed

+252
-0
lines changed

pkg/swatchify/swatchify.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Package swatchify extracts dominant colors from images using k-means clustering.
2+
//
3+
// Example usage:
4+
//
5+
// colors, err := swatchify.ExtractFromFile("image.jpg", nil)
6+
// if err != nil {
7+
// log.Fatal(err)
8+
// }
9+
// for _, c := range colors {
10+
// fmt.Printf("%s (%.1f%%)\n", c.Hex, c.Percentage)
11+
// }
12+
package swatchify
13+
14+
import (
15+
"image"
16+
17+
"github.com/james-see/swatchify/internal/cluster"
18+
"github.com/james-see/swatchify/internal/imageio"
19+
"github.com/james-see/swatchify/internal/palette"
20+
"github.com/james-see/swatchify/internal/utils"
21+
)
22+
23+
// Color represents a dominant color extracted from an image.
24+
type Color struct {
25+
Hex string `json:"hex"`
26+
Percentage float64 `json:"percentage"`
27+
R, G, B uint8 `json:"-"`
28+
}
29+
30+
// Options configures color extraction behavior.
31+
type Options struct {
32+
// NumColors is the number of dominant colors to extract (default: 5)
33+
NumColors int
34+
35+
// Quality controls downscale size for speed/accuracy tradeoff (0-100, default: 50)
36+
Quality int
37+
38+
// ExcludeWhite filters out colors close to white
39+
ExcludeWhite bool
40+
41+
// ExcludeBlack filters out colors close to black
42+
ExcludeBlack bool
43+
44+
// MinContrast is the minimum color distance between palette colors
45+
MinContrast float64
46+
47+
// ColorThreshold is the distance threshold for white/black detection (default: 30)
48+
ColorThreshold float64
49+
}
50+
51+
// DefaultOptions returns sensible default extraction options.
52+
func DefaultOptions() *Options {
53+
return &Options{
54+
NumColors: 5,
55+
Quality: 50,
56+
ColorThreshold: 30,
57+
}
58+
}
59+
60+
// ExtractFromFile extracts dominant colors from an image file.
61+
// Supported formats: JPEG, PNG, WebP, GIF, BMP, TIFF.
62+
func ExtractFromFile(path string, opts *Options) ([]Color, error) {
63+
if opts == nil {
64+
opts = DefaultOptions()
65+
}
66+
67+
loadOpts := imageio.LoadOptions{
68+
MaxDimension: imageio.QualityToMaxDimension(opts.Quality),
69+
}
70+
71+
img, err := imageio.LoadImage(path, loadOpts)
72+
if err != nil {
73+
return nil, err
74+
}
75+
76+
return ExtractFromImage(img, opts)
77+
}
78+
79+
// ExtractFromImage extracts dominant colors from an image.Image.
80+
func ExtractFromImage(img image.Image, opts *Options) ([]Color, error) {
81+
if opts == nil {
82+
opts = DefaultOptions()
83+
}
84+
85+
pixels := imageio.GetPixels(img)
86+
if len(pixels) == 0 {
87+
return nil, nil
88+
}
89+
90+
// Request more colors if filtering
91+
requestColors := opts.NumColors
92+
if opts.ExcludeWhite || opts.ExcludeBlack || opts.MinContrast > 0 {
93+
requestColors = opts.NumColors * 2
94+
if requestColors > 50 {
95+
requestColors = 50
96+
}
97+
}
98+
99+
// Run clustering
100+
results := cluster.KMeans(pixels, requestColors)
101+
102+
// Apply filters
103+
threshold := opts.ColorThreshold
104+
if threshold == 0 {
105+
threshold = 30
106+
}
107+
results = utils.FilterColors(results, opts.ExcludeWhite, opts.ExcludeBlack, threshold)
108+
results = utils.FilterByMinContrast(results, opts.MinContrast)
109+
110+
// Limit to requested count
111+
if len(results) > opts.NumColors {
112+
results = results[:opts.NumColors]
113+
}
114+
115+
// Convert to public type
116+
colors := make([]Color, len(results))
117+
for i, r := range results {
118+
colors[i] = Color{
119+
Hex: r.Hex,
120+
Percentage: r.Percentage,
121+
R: r.RGB.R,
122+
G: r.RGB.G,
123+
B: r.RGB.B,
124+
}
125+
}
126+
127+
return colors, nil
128+
}
129+
130+
// PaletteOptions configures palette image generation.
131+
type PaletteOptions struct {
132+
Width int
133+
Height int
134+
}
135+
136+
// DefaultPaletteOptions returns default palette dimensions.
137+
func DefaultPaletteOptions() *PaletteOptions {
138+
return &PaletteOptions{
139+
Width: 1000,
140+
Height: 200,
141+
}
142+
}
143+
144+
// GeneratePalette creates a palette PNG image from colors.
145+
func GeneratePalette(colors []Color, outputPath string, opts *PaletteOptions) error {
146+
if opts == nil {
147+
opts = DefaultPaletteOptions()
148+
}
149+
150+
// Convert to internal type
151+
internal := make([]utils.ColorResult, len(colors))
152+
for i, c := range colors {
153+
internal[i] = utils.ColorResult{
154+
Hex: c.Hex,
155+
Percentage: c.Percentage,
156+
RGB: utils.RGB{R: c.R, G: c.G, B: c.B},
157+
}
158+
}
159+
160+
return palette.GeneratePNG(internal, outputPath, palette.Options{
161+
Width: opts.Width,
162+
Height: opts.Height,
163+
})
164+
}
165+

pkg/swatchify/swatchify_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package swatchify
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestExtractFromFile(t *testing.T) {
10+
imagePath := filepath.Join("..", "..", "examples", "image1.jpg")
11+
if _, err := os.Stat(imagePath); os.IsNotExist(err) {
12+
t.Skip("Example image not found")
13+
}
14+
15+
colors, err := ExtractFromFile(imagePath, nil)
16+
if err != nil {
17+
t.Fatalf("ExtractFromFile failed: %v", err)
18+
}
19+
20+
if len(colors) == 0 {
21+
t.Fatal("No colors extracted")
22+
}
23+
24+
if len(colors) > 5 {
25+
t.Errorf("Expected max 5 colors with defaults, got %d", len(colors))
26+
}
27+
28+
for _, c := range colors {
29+
if len(c.Hex) != 7 || c.Hex[0] != '#' {
30+
t.Errorf("Invalid hex format: %s", c.Hex)
31+
}
32+
}
33+
}
34+
35+
func TestExtractFromFile_CustomOptions(t *testing.T) {
36+
imagePath := filepath.Join("..", "..", "examples", "image1.jpg")
37+
if _, err := os.Stat(imagePath); os.IsNotExist(err) {
38+
t.Skip("Example image not found")
39+
}
40+
41+
opts := &Options{
42+
NumColors: 8,
43+
Quality: 100,
44+
ExcludeWhite: true,
45+
ExcludeBlack: true,
46+
}
47+
48+
colors, err := ExtractFromFile(imagePath, opts)
49+
if err != nil {
50+
t.Fatalf("ExtractFromFile failed: %v", err)
51+
}
52+
53+
if len(colors) > 8 {
54+
t.Errorf("Expected max 8 colors, got %d", len(colors))
55+
}
56+
}
57+
58+
func TestGeneratePalette(t *testing.T) {
59+
colors := []Color{
60+
{Hex: "#FF0000", Percentage: 50, R: 255, G: 0, B: 0},
61+
{Hex: "#00FF00", Percentage: 30, R: 0, G: 255, B: 0},
62+
{Hex: "#0000FF", Percentage: 20, R: 0, G: 0, B: 255},
63+
}
64+
65+
tmpDir := t.TempDir()
66+
outPath := filepath.Join(tmpDir, "palette.png")
67+
68+
err := GeneratePalette(colors, outPath, nil)
69+
if err != nil {
70+
t.Fatalf("GeneratePalette failed: %v", err)
71+
}
72+
73+
if _, err := os.Stat(outPath); os.IsNotExist(err) {
74+
t.Error("Palette file was not created")
75+
}
76+
}
77+
78+
func TestDefaultOptions(t *testing.T) {
79+
opts := DefaultOptions()
80+
if opts.NumColors != 5 {
81+
t.Errorf("Default NumColors = %d, want 5", opts.NumColors)
82+
}
83+
if opts.Quality != 50 {
84+
t.Errorf("Default Quality = %d, want 50", opts.Quality)
85+
}
86+
}
87+

0 commit comments

Comments
 (0)