├── README ├── example ├── Hypsiboas-cinerascens-8a.best.jpg ├── Hypsiboas-cinerascens-8a.jpg ├── Hypsiboas-cinerascens-8a_100.jpg ├── Hypsiboas-cinerascens-8a_200.jpg └── generate.sh └── main.go /README: -------------------------------------------------------------------------------- 1 | smlr 2 | 3 | * This project is experimental and needs some tuning. * 4 | 5 | Re-encode jpeg images with no perceivable quality loss. 6 | 7 | Uses the butteraugli psychovisual comparison and k-ary search to determine the best jpeg quality setting that will not "appear" degraded. 8 | 9 | Installation: 10 | 11 | 1. Clone https://github.com/google/butteraugli. 12 | 2. Run `make` in the `src/` directory to build the `compare_pngs` binary. 13 | 3. Move `compare_pngs` to some folder in your `PATH` 14 | 4. `go get github.com/jasonmoo/smlr` 15 | 16 | Use: 17 | 18 | smlr -if my_image.jpg -of my_image.best.jpg 19 | 20 | Flags available: 21 | -if string 22 | file to process 23 | -of string 24 | output file 25 | -width int 26 | width to resize to. omitting either width or height will maintain proportion. 27 | -height int 28 | height to resize to. omitting either width or height will maintain proportion. 29 | -max float 30 | maximum deviation detected (default 1.1) 31 | -cores int 32 | how many cores to use (default runtime.NumCPU()) 33 | 34 | 35 | Inspired by: 36 | 37 | https://medium.com/@duhroach/reducing-jpg-file-size-e5b27df3257c 38 | 39 | 40 | LICENSE: MIT 41 | -------------------------------------------------------------------------------- /example/Hypsiboas-cinerascens-8a.best.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonmoo/smlr/9dad013aa863e859ea5a497b1d709e6420a762c4/example/Hypsiboas-cinerascens-8a.best.jpg -------------------------------------------------------------------------------- /example/Hypsiboas-cinerascens-8a.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonmoo/smlr/9dad013aa863e859ea5a497b1d709e6420a762c4/example/Hypsiboas-cinerascens-8a.jpg -------------------------------------------------------------------------------- /example/Hypsiboas-cinerascens-8a_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonmoo/smlr/9dad013aa863e859ea5a497b1d709e6420a762c4/example/Hypsiboas-cinerascens-8a_100.jpg -------------------------------------------------------------------------------- /example/Hypsiboas-cinerascens-8a_200.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonmoo/smlr/9dad013aa863e859ea5a497b1d709e6420a762c4/example/Hypsiboas-cinerascens-8a_200.jpg -------------------------------------------------------------------------------- /example/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | smlr -if Hypsiboas-cinerascens-8a.jpg -of Hypsiboas-cinerascens-8a.best.jpg 6 | smlr -if Hypsiboas-cinerascens-8a.jpg -of Hypsiboas-cinerascens-8a_200.jpg -width 200 7 | smlr -if Hypsiboas-cinerascens-8a.jpg -of Hypsiboas-cinerascens-8a_100.jpg -width 100 8 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "image" 8 | "image/jpeg" 9 | "image/png" 10 | "io/ioutil" 11 | "log" 12 | "os" 13 | "os/exec" 14 | "runtime" 15 | "strconv" 16 | "strings" 17 | "time" 18 | 19 | "github.com/nfnt/resize" 20 | ) 21 | 22 | var ( 23 | maxRating = flag.Float64("max", 1.1, "maximum deviation detected") 24 | width = flag.Int("width", 0, "width to resize to. omitting either width or height will maintain proportion") 25 | height = flag.Int("height", 0, "height to resize to. omitting either width or height will maintain proportion") 26 | infile = flag.String("if", "", "file to process") 27 | outfile = flag.String("of", "", "output file") 28 | cores = flag.Int("cores", runtime.NumCPU(), "how many cores to use") 29 | ) 30 | 31 | func init() { 32 | 33 | flag.Parse() 34 | 35 | if *infile == "" || *outfile == "" { 36 | flag.PrintDefaults() 37 | os.Exit(1) 38 | } 39 | 40 | log.SetFlags(log.LstdFlags | log.Lshortfile) 41 | 42 | } 43 | 44 | func main() { 45 | 46 | start := time.Now() 47 | 48 | file, err := os.Open(*infile) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | 53 | info, err := file.Stat() 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | 58 | img, _, err := image.Decode(file) 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | 63 | if err := file.Close(); err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | if *width > 0 || *height > 0 { 68 | img = resize.Resize(uint(*width), uint(*height), img, resize.Lanczos3) 69 | } 70 | 71 | const maxQuality = 100 72 | 73 | bestjpeg := jpegToPNG(img, maxQuality) 74 | 75 | done := make(chan struct{}) 76 | go func() { 77 | tick := time.NewTicker(time.Second) 78 | defer tick.Stop() 79 | for { 80 | select { 81 | case <-done: 82 | return 83 | case <-tick.C: 84 | fmt.Print(".") 85 | } 86 | } 87 | }() 88 | 89 | quality := karySearch(maxQuality, *cores, func(q int) bool { 90 | 91 | current := jpegToPNG(img, q) 92 | 93 | var buf bytes.Buffer 94 | cmd := exec.Command("compare_pngs", bestjpeg, current) 95 | cmd.Env = []string{} 96 | cmd.Stderr = os.Stderr 97 | cmd.Stdout = &buf 98 | if err := cmd.Run(); err != nil { 99 | log.Fatal(err) 100 | } 101 | 102 | rating, err := strconv.ParseFloat(strings.TrimSpace(buf.String()), 64) 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | 107 | if err := os.Remove(current); err != nil { 108 | log.Fatal(err) 109 | } 110 | 111 | return rating < *maxRating 112 | 113 | }) 114 | 115 | toJPEG(*outfile, img, quality) 116 | 117 | outinfo, err := os.Stat(*outfile) 118 | if err != nil { 119 | log.Fatal(err) 120 | } 121 | 122 | close(done) 123 | 124 | fmt.Println("\nCompleted in", time.Since(start)) 125 | fmt.Println("Best JPG quality:", quality) 126 | fmt.Println(*infile+":", human(info.Size())) 127 | fmt.Println(*outfile+":", human(outinfo.Size())) 128 | 129 | } 130 | 131 | var sizes []string = []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} 132 | 133 | func human(b int64) string { 134 | var i int 135 | n := float64(b) 136 | for n >= 1024 { 137 | i++ 138 | n /= 1024 139 | } 140 | return strconv.FormatFloat(n, 'f', 1, 64) + sizes[i] 141 | 142 | } 143 | 144 | func toJPEG(name string, img image.Image, quality int) { 145 | 146 | file, err := os.Create(name) 147 | if err != nil { 148 | log.Fatal(err) 149 | } 150 | 151 | if err := jpeg.Encode(file, img, &jpeg.Options{Quality: quality}); err != nil { 152 | log.Fatal(err) 153 | } 154 | 155 | if err := file.Close(); err != nil { 156 | log.Fatal(err) 157 | } 158 | 159 | } 160 | 161 | func jpegToPNG(img image.Image, quality int) string { 162 | 163 | var buf bytes.Buffer 164 | 165 | if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil { 166 | log.Fatal(err) 167 | } 168 | 169 | tmpimg, err := jpeg.Decode(&buf) 170 | if err != nil { 171 | log.Fatal(err) 172 | } 173 | 174 | pngout, err := ioutil.TempFile(os.TempDir(), "_butter_"+strconv.Itoa(quality)+".png") 175 | if err != nil { 176 | log.Fatal(err) 177 | } 178 | 179 | if err := png.Encode(pngout, tmpimg); err != nil { 180 | log.Fatal(err) 181 | } 182 | 183 | if err := pngout.Close(); err != nil { 184 | log.Fatal(err) 185 | } 186 | 187 | return pngout.Name() 188 | 189 | } 190 | 191 | func karySearch(n, k int, f func(int) bool) int { 192 | 193 | if k < 2 { 194 | k = 2 195 | } 196 | 197 | var search func(start, end int) int 198 | 199 | search = func(start, end int) int { 200 | 201 | type resp struct { 202 | i int 203 | ok bool 204 | } 205 | 206 | var size, chunk int 207 | 208 | if end-start > k { 209 | chunk = (end - start) / k 210 | size = k 211 | } else { 212 | chunk = 1 213 | size = end - start 214 | } 215 | 216 | resps := make(chan resp, size) 217 | 218 | for i := k; i > 0; i-- { 219 | go func(i int) { 220 | resps <- resp{i: i, ok: f(i)} 221 | }(start + (i * chunk)) 222 | } 223 | 224 | for i := 0; i < cap(resps); i++ { 225 | r := <-resps 226 | // start should always be !ok 227 | // end should always be ok 228 | if !r.ok && r.i > start && r.i < end { 229 | start = r.i 230 | } else if r.ok && r.i < end && r.i > start { 231 | end = r.i 232 | } 233 | } 234 | 235 | if end-start == 1 { 236 | return end 237 | } 238 | 239 | return search(start, end) 240 | 241 | } 242 | 243 | return search(-1, n) 244 | 245 | } 246 | --------------------------------------------------------------------------------