diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec9e143 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +binaries/ +release/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b904e42 --- /dev/null +++ b/Makefile @@ -0,0 +1,133 @@ +VERSION = 0.0.1 + +APP := image-palette-tools +PACKAGES := $(shell go list -f {{.Dir}} ./...) +GOFILES := $(addsuffix /*.go,$(PACKAGES)) +GOFILES := $(wildcard $(GOFILES)) + +.PHONY: clean release release-ci release-manual README.md + +clean: + rm -rf binaries/ + rm -rf release/ + +release-ci: README.md zip + +release: README.md + git reset + git add README.md + git add Makefile + git commit -am "Release $(VERSION)" || true + git tag "$(VERSION)" + git push + git push origin "$(VERSION)" + +# go get -u github.com/github/hub +release-manual: README.md zip + git push + hub release create $(VERSION) -m "$(VERSION)" -a release/extract-palette_$(VERSION)_osx_x86_64.tar.gz -a release/extract-palette_$(VERSION)_windows_x86_64.zip -a release/extract-palette_$(VERSION)_linux_x86_64.tar.gz -a release/extract-palette_$(VERSION)_osx_x86_32.tar.gz -a release/extract-palette_$(VERSION)_windows_x86_32.zip -a release/extract-palette_$(VERSION)_linux_x86_32.tar.gz -a release/extract-palette_$(VERSION)_linux_arm64.tar.gz -a release/cluster-by-palette_$(VERSION)_osx_x86_64.tar.gz -a release/cluster-by-palette_$(VERSION)_windows_x86_64.zip -a release/cluster-by-palette_$(VERSION)_linux_x86_64.tar.gz -a release/cluster-by-palette_$(VERSION)_osx_x86_32.tar.gz -a release/cluster-by-palette_$(VERSION)_windows_x86_32.zip -a release/cluster-by-palette_$(VERSION)_linux_x86_32.tar.gz -a release/cluster-by-palette_$(VERSION)_linux_arm64.tar.gz + +README.md: + sed "s/\$${VERSION}/$(VERSION)/g;s/\$${APP}/$(APP)/g;" README.template.md > README.md + +zip: release/extract-palette_$(VERSION)_osx_x86_64.tar.gz release/extract-palette_$(VERSION)_windows_x86_64.zip release/extract-palette_$(VERSION)_linux_x86_64.tar.gz release/extract-palette_$(VERSION)_osx_x86_32.tar.gz release/extract-palette_$(VERSION)_windows_x86_32.zip release/extract-palette_$(VERSION)_linux_x86_32.tar.gz release/extract-palette_$(VERSION)_linux_arm64.tar.gz release/cluster-by-palette_$(VERSION)_osx_x86_64.tar.gz release/cluster-by-palette_$(VERSION)_windows_x86_64.zip release/cluster-by-palette_$(VERSION)_linux_x86_64.tar.gz release/cluster-by-palette_$(VERSION)_osx_x86_32.tar.gz release/cluster-by-palette_$(VERSION)_windows_x86_32.zip release/cluster-by-palette_$(VERSION)_linux_x86_32.tar.gz release/cluster-by-palette_$(VERSION)_linux_arm64.tar.gz + +binaries: binaries/osx_x86_64/extract-palette binaries/windows_x86_64/extract-palette.exe binaries/linux_x86_64/extract-palette binaries/osx_x86_32/extract-palette binaries/windows_x86_32/extract-palette.exe binaries/linux_x86_32/extract-palette binaries/osx_x86_64/cluster-by-palette binaries/windows_x86_64/cluster-by-palette.exe binaries/linux_x86_64/cluster-by-palette binaries/osx_x86_32/cluster-by-palette binaries/windows_x86_32/cluster-by-palette.exe binaries/linux_x86_32/cluster-by-palette + +release/extract-palette_$(VERSION)_osx_x86_64.tar.gz: binaries/osx_x86_64/extract-palette + mkdir -p release + tar cfz release/extract-palette_$(VERSION)_osx_x86_64.tar.gz -C binaries/osx_x86_64 extract-palette + +binaries/osx_x86_64/extract-palette: $(GOFILES) + GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/osx_x86_64/extract-palette ./cmd/extract-palette + +release/extract-palette_$(VERSION)_windows_x86_64.zip: binaries/windows_x86_64/extract-palette.exe + mkdir -p release + cd ./binaries/windows_x86_64 && zip -r -D ../../release/extract-palette_$(VERSION)_windows_x86_64.zip extract-palette.exe + +binaries/windows_x86_64/extract-palette.exe: $(GOFILES) + GOOS=windows GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/windows_x86_64/extract-palette.exe ./cmd/extract-palette + +release/extract-palette_$(VERSION)_linux_x86_64.tar.gz: binaries/linux_x86_64/extract-palette + mkdir -p release + tar cfz release/extract-palette_$(VERSION)_linux_x86_64.tar.gz -C binaries/linux_x86_64 extract-palette + +binaries/linux_x86_64/extract-palette: $(GOFILES) + GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_x86_64/extract-palette ./cmd/extract-palette + +release/extract-palette_$(VERSION)_osx_x86_32.tar.gz: binaries/osx_x86_32/extract-palette + mkdir -p release + tar cfz release/extract-palette_$(VERSION)_osx_x86_32.tar.gz -C binaries/osx_x86_32 extract-palette + +binaries/osx_x86_32/extract-palette: $(GOFILES) + GOOS=darwin GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/osx_x86_32/extract-palette ./cmd/extract-palette + +release/extract-palette_$(VERSION)_windows_x86_32.zip: binaries/windows_x86_32/extract-palette.exe + mkdir -p release + cd ./binaries/windows_x86_32 && zip -r -D ../../release/extract-palette_$(VERSION)_windows_x86_32.zip extract-palette.exe + +binaries/windows_x86_32/extract-palette.exe: $(GOFILES) + GOOS=windows GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/windows_x86_32/extract-palette.exe ./cmd/extract-palette + +release/extract-palette_$(VERSION)_linux_x86_32.tar.gz: binaries/linux_x86_32/extract-palette + mkdir -p release + tar cfz release/extract-palette_$(VERSION)_linux_x86_32.tar.gz -C binaries/linux_x86_32 extract-palette + +binaries/linux_x86_32/extract-palette: $(GOFILES) + GOOS=linux GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_x86_32/extract-palette ./cmd/extract-palette + +release/extract-palette_$(VERSION)_linux_arm64.tar.gz: binaries/linux_arm64/extract-palette + mkdir -p release + tar cfz release/extract-palette_$(VERSION)_linux_arm64.tar.gz -C binaries/linux_arm64 extract-palette + +binaries/linux_arm64/extract-palette: $(GOFILES) + GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_arm64/extract-palette ./cmd/extract-palette + +release/cluster-by-palette_$(VERSION)_osx_x86_64.tar.gz: binaries/osx_x86_64/cluster-by-palette + mkdir -p release + tar cfz release/cluster-by-palette_$(VERSION)_osx_x86_64.tar.gz -C binaries/osx_x86_64 cluster-by-palette + +binaries/osx_x86_64/cluster-by-palette: $(GOFILES) + GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/osx_x86_64/cluster-by-palette ./cmd/cluster-by-palette + +release/cluster-by-palette_$(VERSION)_windows_x86_64.zip: binaries/windows_x86_64/cluster-by-palette.exe + mkdir -p release + cd ./binaries/windows_x86_64 && zip -r -D ../../release/cluster-by-palette_$(VERSION)_windows_x86_64.zip cluster-by-palette.exe + +binaries/windows_x86_64/cluster-by-palette.exe: $(GOFILES) + GOOS=windows GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/windows_x86_64/cluster-by-palette.exe ./cmd/cluster-by-palette + +release/cluster-by-palette_$(VERSION)_linux_x86_64.tar.gz: binaries/linux_x86_64/cluster-by-palette + mkdir -p release + tar cfz release/cluster-by-palette_$(VERSION)_linux_x86_64.tar.gz -C binaries/linux_x86_64 cluster-by-palette + +binaries/linux_x86_64/cluster-by-palette: $(GOFILES) + GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_x86_64/cluster-by-palette ./cmd/cluster-by-palette + +release/cluster-by-palette_$(VERSION)_osx_x86_32.tar.gz: binaries/osx_x86_32/cluster-by-palette + mkdir -p release + tar cfz release/cluster-by-palette_$(VERSION)_osx_x86_32.tar.gz -C binaries/osx_x86_32 cluster-by-palette + +binaries/osx_x86_32/cluster-by-palette: $(GOFILES) + GOOS=darwin GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/osx_x86_32/cluster-by-palette ./cmd/cluster-by-palette + +release/cluster-by-palette_$(VERSION)_windows_x86_32.zip: binaries/windows_x86_32/cluster-by-palette.exe + mkdir -p release + cd ./binaries/windows_x86_32 && zip -r -D ../../release/cluster-by-palette_$(VERSION)_windows_x86_32.zip cluster-by-palette.exe + +binaries/windows_x86_32/cluster-by-palette.exe: $(GOFILES) + GOOS=windows GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/windows_x86_32/cluster-by-palette.exe ./cmd/cluster-by-palette + +release/cluster-by-palette_$(VERSION)_linux_x86_32.tar.gz: binaries/linux_x86_32/cluster-by-palette + mkdir -p release + tar cfz release/cluster-by-palette_$(VERSION)_linux_x86_32.tar.gz -C binaries/linux_x86_32 cluster-by-palette + +binaries/linux_x86_32/cluster-by-palette: $(GOFILES) + GOOS=linux GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_x86_32/cluster-by-palette ./cmd/cluster-by-palette + +release/cluster-by-palette_$(VERSION)_linux_arm64.tar.gz: binaries/linux_arm64/cluster-by-palette + mkdir -p release + tar cfz release/cluster-by-palette_$(VERSION)_linux_arm64.tar.gz -C binaries/linux_arm64 cluster-by-palette + +binaries/linux_arm64/cluster-by-palette: $(GOFILES) + GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_arm64/cluster-by-palette ./cmd/cluster-by-palette diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b908de --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# image-palette-tools (WIP) + +`image-palette-tools` is a set of related image tools: + +- `extract-palette` - generates a color palette from an image +- `cluster-by-palette` - clusters a set of images by their color palettes + +The tools support PNG, JPEG, and GIF images. + +| Image | Palette | +|------------------------|----------------------------------------| +| ![img1](docs/img1.jpg) | ![img1-palette](docs/img1-palette.png) | +| ![img2](docs/img2.jpg) | ![img2-palette](docs/img2-palette.png) | +| ![img3](docs/img3.jpg) | ![img3-palette](docs/img3-palette.png) | + + + +- [Get it](#get-it) +- [Use it](#use-it) + - [`extract-palette`](#extract-palette) + - [Examples](#examples) + - [`cluster-by-palette`](#cluster-by-palette) + - [Examples](#examples-1) +- [Comments](#comments) + + + +## Get it + +Using go get: + +```sh +go get -u github.com/sgreben/image-palette-tools/cmd/extract-palette +go get -u github.com/sgreben/image-palette-tools/cmd/cluster-by-palette +``` + +Or [download the binaries](https://github.com/sgreben/image-palette-tools/releases/latest) from the releases page. + +```sh +# Linux +curl -L https://github.com/sgreben/image-palette-tools/releases/download/0.0.1/extract-palette_0.0.1_linux_x86_64.tar.gz | tar xz +curl -L https://github.com/sgreben/image-palette-tools/releases/download/0.0.1/cluster-by-palette_0.0.1_linux_x86_64.tar.gz | tar xz + +# OS X +curl -L https://github.com/sgreben/image-palette-tools/releases/download/0.0.1/extract-palette_0.0.1_osx_x86_64.tar.gz | tar xz +curl -L https://github.com/sgreben/image-palette-tools/releases/download/0.0.1/cluster-by-palette_0.0.1_osx_x86_64.tar.gz | tar xz + +# Windows +curl -LO https://github.com/sgreben/image-palette-tools/releases/download/0.0.1/extract-palette_0.0.1_windows_x86_64.zip +unzip extract-palette_0.0.1_windows_x86_64.zip + +curl -LO https://github.com/sgreben/image-palette-tools/releases/download/0.0.1/cluster-by-palette_0.0.1_windows_x86_64.zip +unzip cluster-by-palette_0.0.1_windows_x86_64.zip +``` + +## Use it + +### `extract-palette` + +```text +Usage of extract-palette: + -k int + number of colors to extract (default 8) + -out-json value + path of output JSON file (go template) + -out-png value + path of output palette image (PNG) (go template) + -out-png-height int + size of each color square in the palette output image (default 100) + -out-txt value + path of output text file (go template) + -p int + number of images to process in parallel (default 8) +``` + +#### Examples + +> For each image file, extract an 8-color palette. Generate a PNG image of the palette with suffix `-palette-8.png`. Write a JSON file containing the palette colors to a file with suffix `-palette-8.json`. + +```sh +extract-palette \ + -k 8 \ + -out-png '{{.Path}}-pallette-{{.K}}.png' \ + -out-json '{{.Path}}-pallette-{{.K}}.json' \ + *.jpg +``` + +### `cluster-by-palette` + +```text +Usage of cluster-by-palette: + -n int + number of image clusters to make (default 5) + -glob value + glob expression matching image files to cluster + -k int + palette size (default 4) + -out-shell value + shell command to run for each image (go template) + -out-summary-json value + path of output JSON containing the clustering (go template) + -out-cluster-png value + output path for cluster palette image (PNG) (go template) + -out-cluster-png-height int + size of each color square in the palette output image (default 100) + -in-json value + path to read palette JSON from (go template) + -out-json value + path to write palette JSON to (go template) + -p int + number of images to process in parallel (default 8) +``` + +#### Examples + + +> Create 8 clusters of images based on their 4-color palettes. Make directories `cluster-0`...`cluster-7` and copy the files to their respective cluster's directory. For each cluster, create PNG palette images named `cluster-8-0.png`...`cluster-8-7.png`. + +```sh +cluster-by-palette \ + -n 8 \ + -k 4 \ + -out-shell 'd="cluster-{{.Label}}"; mkdir -p "$d"; cp "{{.Path}}" "$d"' \ + -out-cluster-png "cluster-{{.N}}-{{.Label}}.png" \ + *.jpg +``` + +## Comments + +Feel free to [leave a comment](https://github.com/sgreben/image-palette-tools/issues/1) or create an issue. diff --git a/README.template.md b/README.template.md new file mode 100644 index 0000000..5732c5c --- /dev/null +++ b/README.template.md @@ -0,0 +1,130 @@ +# ${APP} (WIP) + +`${APP}` is a set of related image tools: + +- `extract-palette` - generates a color palette from an image +- `cluster-by-palette` - clusters a set of images by their color palettes + +The tools support PNG, JPEG, and GIF images. + +| Image | Palette | +|------------------------|----------------------------------------| +| ![img1](docs/img1.jpg) | ![img1-palette](docs/img1-palette.png) | +| ![img2](docs/img2.jpg) | ![img2-palette](docs/img2-palette.png) | +| ![img3](docs/img3.jpg) | ![img3-palette](docs/img3-palette.png) | + + + +- [Get it](#get-it) +- [Use it](#use-it) + - [`extract-palette`](#extract-palette) + - [Examples](#examples) + - [`cluster-by-palette`](#cluster-by-palette) + - [Examples](#examples-1) +- [Comments](#comments) + + + +## Get it + +Using go get: + +```sh +go get -u github.com/sgreben/${APP}/cmd/extract-palette +go get -u github.com/sgreben/${APP}/cmd/cluster-by-palette +``` + +Or [download the binaries](https://github.com/sgreben/${APP}/releases/latest) from the releases page. + +```sh +# Linux +curl -L https://github.com/sgreben/${APP}/releases/download/${VERSION}/extract-palette_${VERSION}_linux_x86_64.tar.gz | tar xz +curl -L https://github.com/sgreben/${APP}/releases/download/${VERSION}/cluster-by-palette_${VERSION}_linux_x86_64.tar.gz | tar xz + +# OS X +curl -L https://github.com/sgreben/${APP}/releases/download/${VERSION}/extract-palette_${VERSION}_osx_x86_64.tar.gz | tar xz +curl -L https://github.com/sgreben/${APP}/releases/download/${VERSION}/cluster-by-palette_${VERSION}_osx_x86_64.tar.gz | tar xz + +# Windows +curl -LO https://github.com/sgreben/${APP}/releases/download/${VERSION}/extract-palette_${VERSION}_windows_x86_64.zip +unzip extract-palette_${VERSION}_windows_x86_64.zip + +curl -LO https://github.com/sgreben/${APP}/releases/download/${VERSION}/cluster-by-palette_${VERSION}_windows_x86_64.zip +unzip cluster-by-palette_${VERSION}_windows_x86_64.zip +``` + +## Use it + +### `extract-palette` + +```text +Usage of extract-palette: + -k int + number of colors to extract (default 8) + -out-json value + path of output JSON file (go template) + -out-png value + path of output palette image (PNG) (go template) + -out-png-height int + size of each color square in the palette output image (default 100) + -out-txt value + path of output text file (go template) + -p int + number of images to process in parallel (default 8) +``` + +#### Examples + +> For each image file, extract an 8-color palette. Generate a PNG image of the palette with suffix `-palette-8.png`. Write a JSON file containing the palette colors to a file with suffix `-palette-8.json`. + +```sh +extract-palette \ + -k 8 \ + -out-png '{{.Path}}-pallette-{{.K}}.png' \ + -out-json '{{.Path}}-pallette-{{.K}}.json' \ + *.jpg +``` + +### `cluster-by-palette` + +```text +Usage of cluster-by-palette: + -n int + number of image clusters to make (default 5) + -glob value + glob expression matching image files to cluster + -k int + palette size (default 4) + -out-shell value + shell command to run for each image (go template) + -out-summary-json value + path of output JSON containing the clustering (go template) + -out-cluster-png value + output path for cluster palette image (PNG) (go template) + -out-cluster-png-height int + size of each color square in the palette output image (default 100) + -in-json value + path to read palette JSON from (go template) + -out-json value + path to write palette JSON to (go template) + -p int + number of images to process in parallel (default 8) +``` + +#### Examples + + +> Create 8 clusters of images based on their 4-color palettes. Make directories `cluster-0`...`cluster-7` and copy the files to their respective cluster's directory. For each cluster, create PNG palette images named `cluster-8-0.png`...`cluster-8-7.png`. + +```sh +cluster-by-palette \ + -n 8 \ + -k 4 \ + -out-shell 'd="cluster-{{.Label}}"; mkdir -p "$d"; cp "{{.Path}}" "$d"' \ + -out-cluster-png "cluster-{{.N}}-{{.Label}}.png" \ + *.jpg +``` + +## Comments + +Feel free to [leave a comment](https://github.com/sgreben/${APP}/issues/1) or create an issue. \ No newline at end of file diff --git a/cmd/cluster-by-palette/main.go b/cmd/cluster-by-palette/main.go new file mode 100644 index 0000000..49d3086 --- /dev/null +++ b/cmd/cluster-by-palette/main.go @@ -0,0 +1,378 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "image" + "image/color" + _ "image/gif" + _ "image/jpeg" + "image/png" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "text/template" + + "github.com/kballard/go-shellquote" + flagvarGlob "github.com/sgreben/flagvar/glob" + "github.com/sgreben/flagvar/template" + "github.com/sgreben/image-palette-tools/pkg/palette" +) + +type paletteJSON struct { + Path string `json:"path"` + Palette []string `json:"palette"` +} + +type pathPalette struct { + Path string + Palette []color.RGBA +} + +var ( + kImage int + kPalette int + outPng = flagvar.Template{Root: templateSettings} + inJSON = flagvar.Template{Root: templateSettings} + outClusterJSON = flagvar.Template{Root: templateSettings} + outJSON = flagvar.Template{Root: templateSettings} + outShell = flagvar.Template{Root: templateSettings} + globSelect flagvarGlob.Glob + outColorSize int + maxParallel int + + templateSettings = template.New("").Funcs(map[string]interface{}{ + "abs": func(s string) (string, error) { return filepath.Abs(s) }, + "basename": func(s string) string { return filepath.Base(s) }, + "dirname": func(s string) string { return filepath.Dir(s) }, + "ext": func(s string) string { return filepath.Ext(s) }, + "html": func(c color.RGBA) string { return html(c) }, + }) + colorCache = palette.NewColorCache(512) + paletteCache = palette.NewPaletteCache(512) + printBuffer = 1024 + clusterBuffer = 16 +) + +const defaultInJSON = "{{.Path}}.palette-{{.K}}.json" +const defaultOutJSON = defaultInJSON + +func init() { + log.SetOutput(os.Stderr) + flag.IntVar(&kImage, "n", 5, "number of image clusters to make") + flag.IntVar(&kPalette, "k", 4, "palette size") + flag.IntVar(&maxParallel, "p", runtime.GOMAXPROCS(0), "number of images to process in parallel") + flag.Var(&globSelect, "glob", "glob expression matching image files to cluster") + flag.Var(&inJSON, "in-json", "path to read palette JSON from (go template)") + flag.Var(&outJSON, "out-json", "path to write palette JSON to (go template)") + flag.Var(&outPng, "out-cluster-png", "path of output palette image (PNG) (go template)") + flag.IntVar(&outColorSize, "out-cluster-png-height", 100, "size of each color square in the palette output image") + flag.Var(&outClusterJSON, "out-summary-json", "path of output JSON containing the clustering (go template)") + flag.Var(&outShell, "out-shell", "shell command to run for each image (go template)") + flag.Parse() + + if inJSON.Value == nil && inJSON.Text != "" { + inJSON.Set(defaultInJSON) + } + if outJSON.Value == nil && outJSON.Text != "" { + outJSON.Set(defaultOutJSON) + } +} + +func writeOutJSON(sourcePath string, pp pathPalette) { + b := bytes.NewBuffer(nil) + outJSON.Value.Execute(b, map[string]interface{}{ + "Path": sourcePath, + "K": kPalette, + "Palette": pp.Palette, + }) + targetPath := b.String() + fOut, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, 0600) + log.Println("writing", targetPath) + if err != nil { + log.Println(err) + return + } + defer fOut.Close() + var obj paletteJSON + obj.Path = pp.Path + obj.Palette = htmls(pp.Palette) + bytes, err := json.Marshal(obj) + if err != nil { + log.Println(err) + return + } + _, err = fOut.Write(bytes) + if err != nil { + log.Println(err) + return + } +} + +func writeOutClusterJSON(obj interface{}) { + b := bytes.NewBuffer(nil) + outClusterJSON.Value.Execute(b, map[string]interface{}{ + "N": kImage, + "K": kPalette, + }) + targetPath := b.String() + fOut, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, 0600) + log.Println("writing", targetPath) + if err != nil { + log.Println(err) + return + } + defer fOut.Close() + bytes, err := json.Marshal(obj) + if err != nil { + log.Println(err) + return + } + _, err = fOut.Write(bytes) + if err != nil { + log.Println(err) + return + } +} + +func runOutShell(path string, label int, p []color.RGBA) { + b := bytes.NewBuffer(nil) + outShell.Value.Execute(b, map[string]interface{}{ + "Path": path, + "N": kImage, + "K": kPalette, + "Label": label, + "I": label, + "Palette": p, + }) + shellCmd := b.String() + + var cmd *exec.Cmd + if shell, ok := os.LookupEnv("SHELL"); ok { + cmd = exec.Command(shell, "-c", shellCmd) + } else { + parts, err := shellquote.Split(shellCmd) + if err != nil { + log.Println(path, "error:", err) + return + } + if len(parts) < 1 { + log.Println(path, "error:", "empty shell command") + return + } + cmd = exec.Command(parts[0], parts[1:]...) + } + log.Println(path, "running", cmd.Args) + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + log.Println(path, "shell error:", err) + } +} + +func writeOutPng(label int, p []color.RGBA) { + b := bytes.NewBuffer(nil) + outPng.Value.Execute(b, map[string]interface{}{ + "N": kImage, + "K": kPalette, + "Label": label, + "I": label, + "Palette": p, + }) + targetPath := b.String() + fOut, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + log.Println(err) + } + defer fOut.Close() + png.Encode(fOut, palette.Render(p, outColorSize)) +} + +func html(c color.RGBA) string { + if c.A == 255 { + return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B) + } + return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A) +} + +func htmls(p []color.RGBA) (out []string) { + out = make([]string, len(p)) + for i := range p { + out[i] = html(p[i]) + } + return +} + +func htmlss(ps [][]color.RGBA) (out [][]string) { + out = make([][]string, len(ps)) + for i := range ps { + out[i] = htmls(ps[i]) + } + return +} + +func extractPalette(path string) ([]color.RGBA, error) { + f, err := os.Open(path) + if err != nil { + log.Println(path, "error:", err) + return nil, err + } + defer f.Close() + i, fmt, err := image.Decode(f) + if err != nil { + log.Println(path, "error:", err) + return nil, err + } + log.Println(path, "loaded:", fmt, i.Bounds().Size().String()) + return palette.Extract(colorCache, kPalette, i) +} + +func loadPalette(path string) ([]color.RGBA, error) { + b := bytes.NewBuffer(nil) + inJSON.Value.Execute(b, map[string]interface{}{ + "Path": path, + "N": kImage, + "K": kPalette, + }) + targetPath := b.String() + f, err := os.Open(targetPath) + if err != nil { + return nil, err + } + log.Println("loading", targetPath) + dec := json.NewDecoder(f) + var pj paletteJSON + err = dec.Decode(&pj) + if err != nil { + return nil, err + } + out := make([]color.RGBA, len(pj.Palette)) + for i := range out { + var c color.RGBA + fmt.Sscanf(pj.Palette[i], "#%02x%02x%02x", &c.R, &c.G, &c.B) + c.A = 0xFF + out[i] = c + } + return out, nil +} + +func main() { + print := make(chan interface{}, printBuffer) + var printWg sync.WaitGroup + + printWg.Add(1) + go func() { + enc := json.NewEncoder(os.Stdout) + defer printWg.Done() + for obj := range print { + enc.Encode(obj) + } + }() + + cluster := make(chan pathPalette, clusterBuffer) + var clusterWg sync.WaitGroup + + clusterWg.Add(1) + go func() { + var paths []string + var palettes [][]color.RGBA + defer clusterWg.Done() + for pathPalette := range cluster { + paths = append(paths, pathPalette.Path) + palettes = append(palettes, pathPalette.Palette) + } + labels, centroids, err := palette.Cluster(paletteCache, kImage, palettes) + if err != nil { + log.Fatal(err) + } + if outPng.Value != nil { + for i, p := range centroids { + writeOutPng(i, p) + } + } + if outShell.Value != nil { + for i, l := range labels { + runOutShell(paths[i], l, palettes[i]) + } + } + m := make(map[string]int, len(labels)) + for i, l := range labels { + m[paths[i]] = l + } + obj := map[string]interface{}{ + "centroids": htmlss(centroids), + "mapping": m, + } + if outClusterJSON.Value != nil { + writeOutClusterJSON(obj) + } + print <- obj + }() + + var extractWg sync.WaitGroup + work := make(chan string, maxParallel) + extractWg.Add(maxParallel) + for i := 0; i < maxParallel; i++ { + go func() { + defer extractWg.Done() + var p []color.RGBA + var err error + for path := range work { + shouldWriteOutJSON := outJSON.Value != nil + if inJSON.Value != nil { + p, err = loadPalette(path) + if len(p) < kPalette || err != nil { + p, err = extractPalette(path) + if err != nil { + log.Println(path, "error:", err) + continue + } + } else { + shouldWriteOutJSON = false + } + } else { + p, err = extractPalette(path) + if err != nil { + log.Println(path, "error:", err) + continue + } + } + pp := pathPalette{Path: path, Palette: p} + if shouldWriteOutJSON { + writeOutJSON(path, pp) + } + cluster <- pp + } + }() + } + + paths := flag.Args() + if globSelect.Value != nil { + globPaths, err := filepath.Glob(globSelect.Text) + if err != nil { + log.Println(err) + } + if len(globPaths) == 0 { + log.Println("no paths matched glob", globSelect.Text) + } + paths = append(paths, globPaths...) + } + log.Println("processing", len(paths), "files") + for i, path := range paths { + log.Printf("image %d/%d (%2.2f%%)", i, len(paths), float64(i)/float64(len(paths))*100) + work <- path + } + close(work) + extractWg.Wait() + close(cluster) + clusterWg.Wait() + close(print) + printWg.Wait() +} diff --git a/cmd/extract-palette/main.go b/cmd/extract-palette/main.go new file mode 100644 index 0000000..7972894 --- /dev/null +++ b/cmd/extract-palette/main.go @@ -0,0 +1,200 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "image" + "image/color" + _ "image/gif" + _ "image/jpeg" + "image/png" + "io" + "log" + "os" + "path/filepath" + "runtime" + "sync" + "text/template" + + "github.com/sgreben/flagvar/template" + "github.com/sgreben/image-palette-tools/pkg/palette" +) + +var ( + k int + outPng = flagvar.Template{Root: templateSettings} + outTxt = flagvar.Template{Root: templateSettings} + outJSON = flagvar.Template{Root: templateSettings} + outColorSize int + maxParallel int + + templateSettings = template.New("").Funcs(map[string]interface{}{ + "abs": func(s string) (string, error) { return filepath.Abs(s) }, + "basename": func(s string) string { return filepath.Base(s) }, + "dirname": func(s string) string { return filepath.Dir(s) }, + "ext": func(s string) string { return filepath.Ext(s) }, + "html": func(c color.RGBA) string { return html(c) }, + }) + cache = palette.NewColorCache(512) + printBuffer = 1024 +) + +func init() { + log.SetOutput(os.Stderr) + flag.IntVar(&k, "k", 8, "number of colors to extract") + flag.IntVar(&maxParallel, "p", runtime.GOMAXPROCS(0), "number of images to process in parallel") + flag.Var(&outPng, "out-png", "path of output palette image (PNG) (go template)") + flag.IntVar(&outColorSize, "out-png-height", 100, "size of each color square in the palette output image") + flag.Var(&outTxt, "out-txt", "path of output text file (go template)") + flag.Var(&outJSON, "out-json", "path of output JSON file (go template)") + flag.Parse() +} + +func writeOutPng(sourcePath string, p []color.RGBA) { + b := bytes.NewBuffer(nil) + outPng.Value.Execute(b, map[string]interface{}{ + "Path": sourcePath, + "K": k, + "Palette": p, + }) + targetPath := b.String() + fOut, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, 0600) + log.Println("writing", targetPath) + if err != nil { + log.Println(err) + } + defer fOut.Close() + png.Encode(fOut, palette.Render(p, outColorSize)) +} + +func html(c color.RGBA) string { + if c.A == 255 { + return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B) + } + return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A) +} + +func htmls(p []color.RGBA) (out []string) { + out = make([]string, len(p)) + for i := range p { + out[i] = html(p[i]) + } + return +} + +func writeOutTxt(sourcePath string, p []color.RGBA) { + b := bytes.NewBuffer(nil) + outTxt.Value.Execute(b, map[string]interface{}{ + "Path": sourcePath, + "K": k, + "Palette": p, + }) + targetPath := b.String() + fOut, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, 0600) + log.Println("writing", targetPath) + if err != nil { + log.Println(err) + } + defer fOut.Close() + for _, c := range p { + io.WriteString(fOut, html(c)) + io.WriteString(fOut, "\n") + } +} + +func writeOutJSON(sourcePath string, p []color.RGBA, obj interface{}) { + b := bytes.NewBuffer(nil) + outJSON.Value.Execute(b, map[string]interface{}{ + "Path": sourcePath, + "K": k, + "Palette": p, + }) + targetPath := b.String() + fOut, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, 0600) + log.Println("writing", targetPath) + if err != nil { + log.Println(err) + return + } + defer fOut.Close() + bytes, err := json.Marshal(obj) + if err != nil { + log.Println(err) + return + } + _, err = fOut.Write(bytes) + if err != nil { + log.Println(err) + return + } +} + +func extractPalette(path string) ([]color.RGBA, error) { + f, err := os.Open(path) + if err != nil { + log.Println(path, "error:", err) + return nil, err + } + defer f.Close() + i, typ, err := image.Decode(f) + if err != nil { + log.Println(path, "error:", err) + return nil, err + } + log.Println(path, "loaded:", typ, i.Bounds().Size().String()) + return palette.Extract(cache, k, i) +} + +func main() { + print := make(chan interface{}, printBuffer) + var printWg sync.WaitGroup + + printWg.Add(1) + go func() { + enc := json.NewEncoder(os.Stdout) + defer printWg.Done() + for obj := range print { + enc.Encode(obj) + } + }() + + var workWg sync.WaitGroup + work := make(chan string, maxParallel) + workWg.Add(maxParallel) + for i := 0; i < maxParallel; i++ { + go func() { + defer workWg.Done() + for path := range work { + p, err := extractPalette(path) + if err != nil { + log.Println(path, "error:", err) + continue + } + if outPng.Value != nil { + writeOutPng(path, p) + } + if outTxt.Value != nil { + writeOutTxt(path, p) + } + jsonObj := map[string]interface{}{ + "path": path, + "palette": htmls(p), + } + if outJSON.Value != nil { + writeOutJSON(path, p, jsonObj) + } + log.Println(path, htmls(p)) + print <- jsonObj + } + }() + } + for _, path := range flag.Args() { + work <- path + } + close(work) + workWg.Wait() + close(print) + printWg.Wait() +} diff --git a/docs/img1-palette.json b/docs/img1-palette.json new file mode 100644 index 0000000..9e524d0 --- /dev/null +++ b/docs/img1-palette.json @@ -0,0 +1 @@ +{"palette":["#cba19d","#133a70","#4e70a7","#bfc6e0","#4d3852","#3c2437","#2a121d","#956e70"],"path":"docs/img1.jpg"} \ No newline at end of file diff --git a/docs/img1-palette.png b/docs/img1-palette.png new file mode 100644 index 0000000..c59ea00 Binary files /dev/null and b/docs/img1-palette.png differ diff --git a/docs/img1.jpg b/docs/img1.jpg new file mode 100644 index 0000000..73e8eb0 Binary files /dev/null and b/docs/img1.jpg differ diff --git a/docs/img2-palette.json b/docs/img2-palette.json new file mode 100644 index 0000000..b24d814 --- /dev/null +++ b/docs/img2-palette.json @@ -0,0 +1 @@ +{"palette":["#2e3425","#1d2216","#213608","#375613","#11160c","#050604","#3f4e34","#536c42"],"path":"docs/img2.jpg"} \ No newline at end of file diff --git a/docs/img2-palette.png b/docs/img2-palette.png new file mode 100644 index 0000000..10fea63 Binary files /dev/null and b/docs/img2-palette.png differ diff --git a/docs/img2.jpg b/docs/img2.jpg new file mode 100644 index 0000000..2d627bd Binary files /dev/null and b/docs/img2.jpg differ diff --git a/docs/img3-palette.json b/docs/img3-palette.json new file mode 100644 index 0000000..6067af4 --- /dev/null +++ b/docs/img3-palette.json @@ -0,0 +1 @@ +{"palette":["#704a37","#5f3c2a","#a0806f","#8d6650","#99725c","#805a44","#aec3dd","#a6b4c9"],"path":"docs/img3.jpg"} \ No newline at end of file diff --git a/docs/img3-palette.png b/docs/img3-palette.png new file mode 100644 index 0000000..2c01beb Binary files /dev/null and b/docs/img3-palette.png differ diff --git a/docs/img3.jpg b/docs/img3.jpg new file mode 100644 index 0000000..986a5ab Binary files /dev/null and b/docs/img3.jpg differ diff --git a/pkg/palette/color_cache.go b/pkg/palette/color_cache.go new file mode 100644 index 0000000..e62a1a7 --- /dev/null +++ b/pkg/palette/color_cache.go @@ -0,0 +1,46 @@ +package palette + +import ( + "encoding/binary" + "sync" + + "github.com/oxtoacart/bpool" +) + +type ColorCache struct { + sync.Mutex + Colors map[string][]float64 + BufferPool *bpool.BufferPool +} + +func (c *ColorCache) Key(r, g, b uint32) (key string) { + buf := c.BufferPool.Get() + defer c.BufferPool.Put(buf) + binary.Write(buf, binary.LittleEndian, r) + binary.Write(buf, binary.LittleEndian, g) + binary.Write(buf, binary.LittleEndian, b) + key = buf.String() + return +} + +func (c *ColorCache) Get(r, g, b uint32) []float64 { + key := c.Key(r, g, b) + c.Lock() + defer c.Unlock() + if point, ok := c.Colors[key]; ok { + return point + } + point := make([]float64, 3) + point[0] = float64(r) / float64(0xffff) + point[1] = float64(g) / float64(0xffff) + point[2] = float64(b) / float64(0xffff) + c.Colors[key] = point + return point +} + +func NewColorCache(size int) *ColorCache { + return &ColorCache{ + Colors: make(map[string][]float64, size), + BufferPool: bpool.NewBufferPool(size), + } +} diff --git a/pkg/palette/palette.go b/pkg/palette/palette.go new file mode 100644 index 0000000..26cc94f --- /dev/null +++ b/pkg/palette/palette.go @@ -0,0 +1,183 @@ +package palette + +import ( + "image" + "image/color" + "math" + "sort" + + "github.com/bugra/kmeans" +) + +func LessLSH(centroid []color.RGBA, i, j int) bool { + hi, si, li := hsl(centroid[i].R, centroid[i].G, centroid[i].B) + hj, sj, lj := hsl(centroid[j].R, centroid[j].G, centroid[j].B) + if li == lj { + if si == sj { + return hi < hj + } + return si < sj + } + return li < lj +} + +func LessHLS(centroid []color.RGBA, i, j int) bool { + hi, si, li := hsl(centroid[i].R, centroid[i].G, centroid[i].B) + hj, sj, lj := hsl(centroid[j].R, centroid[j].G, centroid[j].B) + if hi == hj { + if li == lj { + return si < sj + } + return li < lj + } + return hi < hj +} + +func imagePoints(cache *ColorCache, i image.Image) (out [][]float64) { + size := i.Bounds().Size() + out = make([][]float64, size.X*size.Y) + j := 0 + for x := 0; x < size.X; x++ { + for y := 0; y < size.Y; y++ { + c := i.At(x, y) + r, g, b, _ := c.RGBA() + out[j] = cache.Get(r, g, b) + j++ + } + } + return +} + +func Render(palette []color.RGBA, size int) image.Image { + p := make(color.Palette, len(palette)) + for i := range palette { + c := palette[i] + c.A = 255 + p[i] = c + } + i := image.NewPaletted(image.Rectangle{ + Max: image.Point{ + X: size * len(palette), + Y: size, + }, + }, p) + for j := range palette { + for x := j * size; x < (j+1)*size; x++ { + for y := 0; y < size; y++ { + i.SetColorIndex(x, y, uint8(j)) + } + } + } + return i +} + +func hsl(rb, gb, bb uint8) (h, s, l float64) { + r := float64(rb) / 255.0 + g := float64(gb) / 255.0 + b := float64(bb) / 255.0 + + max := math.Max(math.Max(r, g), b) + min := math.Min(math.Min(r, g), b) + l = (max + min) / 2 + delta := max - min + if delta != 0 { + if l < 0.5 { + s = delta / (max + min) + } else { + s = delta / (2 - max - min) + } + r2 := (((max - r) / 6) + (delta / 2)) / delta + g2 := (((max - g) / 6) + (delta / 2)) / delta + b2 := (((max - b) / 6) + (delta / 2)) / delta + switch { + case r == max: + h = b2 - g2 + case g == max: + h = (1.0 / 3.0) + r2 - b2 + case b == max: + h = (2.0 / 3.0) + g2 - r2 + } + } + + switch { + case h < 0: + h += 1 + case h > 1: + h -= 1 + } + return +} + +// Cluster clusters palettes +func Cluster(cache *PaletteCache, k int, ps [][]color.RGBA) ([]int, [][]color.RGBA, error) { + if len(ps) == 0 { + return nil, nil, nil + } + n := len(ps[0]) + + points := make([][]float64, len(ps)) + for i := range ps { + points[i] = cache.Get(ps[i]) + } + + labels, err := kmeans.Kmeans(points, k, kmeans.EuclideanDistance, int(math.MaxInt32)) + if err != nil { + return nil, nil, err + } + + centroidPoints := make([]kmeans.Observation, k) + centroidPointCount := make([]uint64, k) + for i, label := range labels { + if centroidPoints[label] == nil { + centroidPoints[label] = make(kmeans.Observation, n*3) + } + centroidPoints[label].Add(kmeans.Observation(points[i])) + centroidPointCount[label]++ + } + + centroid := make([][]color.RGBA, k) + for j, point := range centroidPoints { + count := float64(centroidPointCount[j]) + centroid[j] = make([]color.RGBA, n) + for i := 0; i < n; i++ { + r := uint32(point[0+3*i] / count * 255.0) + g := uint32(point[1+3*i] / count * 255.0) + b := uint32(point[2+3*i] / count * 255.0) + centroid[j][i] = color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} + } + } + + return labels, centroid, nil +} + +// Extract extracts a `k`-color palette from an image +func Extract(cache *ColorCache, k int, i image.Image) ([]color.RGBA, error) { + points := imagePoints(cache, i) + labels, err := kmeans.Kmeans(points, k, kmeans.EuclideanDistance, int(math.MaxInt32)) + if err != nil { + return nil, err + } + + centroidPoints := make([]kmeans.Observation, k) + for label := range centroidPoints { + centroidPoints[label] = make(kmeans.Observation, 3) + } + centroidPointCount := make([]uint64, k) + for j, label := range labels { + centroidPoints[label].Add(kmeans.Observation(points[j])) + centroidPointCount[label]++ + } + + centroid := make([]color.RGBA, k) + for j, point := range centroidPoints { + n := float64(centroidPointCount[j]) + r := uint32(point[0] / n * 255.0) + g := uint32(point[1] / n * 255.0) + b := uint32(point[2] / n * 255.0) + centroid[j] = color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} + } + + sort.Slice(centroid, func(i int, j int) bool { return LessHLS(centroid, i, j) }) + + return centroid, nil +} diff --git a/pkg/palette/palette_cache.go b/pkg/palette/palette_cache.go new file mode 100644 index 0000000..5f4b7b1 --- /dev/null +++ b/pkg/palette/palette_cache.go @@ -0,0 +1,51 @@ +package palette + +import ( + "encoding/binary" + "image/color" + "sync" + + "github.com/oxtoacart/bpool" +) + +type PaletteCache struct { + sync.Mutex + Palettes map[string][]float64 + BufferPool *bpool.BufferPool +} + +func (c *PaletteCache) Key(p []color.RGBA) (key string) { + buf := c.BufferPool.Get() + defer c.BufferPool.Put(buf) + for _, c := range p { + binary.Write(buf, binary.LittleEndian, c.R) + binary.Write(buf, binary.LittleEndian, c.G) + binary.Write(buf, binary.LittleEndian, c.B) + } + key = buf.String() + return +} + +func (c *PaletteCache) Get(p []color.RGBA) []float64 { + key := c.Key(p) + c.Lock() + defer c.Unlock() + if point, ok := c.Palettes[key]; ok { + return point + } + point := make([]float64, 3*len(p)) + for i, c := range p { + point[0+3*i] = float64(c.R) / float64(0xff) + point[1+3*i] = float64(c.G) / float64(0xff) + point[2+3*i] = float64(c.B) / float64(0xff) + } + c.Palettes[key] = point + return point +} + +func NewPaletteCache(size int) *PaletteCache { + return &PaletteCache{ + Palettes: make(map[string][]float64, size), + BufferPool: bpool.NewBufferPool(size), + } +}