├── .gitignore ├── LICENSE.txt ├── README.md ├── cmd ├── qoibench │ └── main.go └── qoiconv │ └── main.go ├── go.mod └── qoi.go /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/qoiconv/qoiconv 2 | cmd/qoibench/qoibench -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Xavier-Frédéric Moulet 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QOI - The “Quite OK Image” format for fast, lossless image compression - in Go 2 | 3 | Package and small utilities in pure Go, quite OK implementation 4 | 5 | See [qoi.h](https://github.com/phoboslab/qoi/blob/master/qoi.h) for 6 | the documentation. 7 | 8 | More info at https://qoiformat.org/ 9 | 10 | ## Performance 11 | 12 | Performance is currently around half C version (optimized at `-O3`) 13 | 14 | ## Example Usage 15 | 16 | - `cmd/qoiconv` converts between png <> qoi 17 | - `cmd/qoibench` bench the en/decoding vs. golang png implementation 18 | -------------------------------------------------------------------------------- /cmd/qoibench/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/png" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/xfmoulet/qoi" 15 | ) 16 | 17 | func main() { 18 | 19 | if len(os.Args) != 2 { 20 | fmt.Println("usage: " + os.Args[0] + " directory") 21 | return 22 | } 23 | dir := os.Args[1] 24 | 25 | filepath.WalkDir(dir, func(path string, infile fs.DirEntry, err error) error { 26 | if err != nil { 27 | return err 28 | } 29 | if infile.IsDir() { 30 | return nil 31 | } 32 | 33 | if !strings.HasSuffix(infile.Name(), ".png") { 34 | return nil 35 | } 36 | f, err := os.Open(path) 37 | if err != nil { 38 | fmt.Println("Error opening file:", err) 39 | return err 40 | } 41 | 42 | img, _, err := image.Decode(f) 43 | if err != nil { 44 | fmt.Println("Error decoding file:", err) 45 | return err 46 | } 47 | 48 | var of bytes.Buffer 49 | 50 | // QOI 51 | start := time.Now() 52 | qoi.Encode(&of, img) 53 | enc_qoi_duration := time.Since(start) 54 | 55 | start = time.Now() 56 | image.Decode(&of) 57 | dec_qoi_duration := time.Since(start) 58 | 59 | of.Reset() 60 | 61 | // PNG 62 | start = time.Now() 63 | png.Encode(&of, img) 64 | enc_png_duration := time.Since(start) 65 | 66 | start = time.Now() 67 | image.Decode(bytes.NewBuffer(of.Bytes())) 68 | dec_png_duration := time.Since(start) 69 | 70 | fmt.Printf("Encoding: QOI %4dms - PNG %4dms - Decoding: QOI %4dms PNG %4dms - %s\n", 71 | enc_qoi_duration.Milliseconds(), 72 | enc_png_duration.Milliseconds(), 73 | dec_qoi_duration.Milliseconds(), 74 | dec_png_duration.Milliseconds(), 75 | path) 76 | 77 | return nil 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /cmd/qoiconv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/png" 7 | "os" 8 | "strings" 9 | 10 | "github.com/xfmoulet/qoi" 11 | ) 12 | 13 | func main() { 14 | if len(os.Args) != 3 { 15 | fmt.Println("usage: " + os.Args[0] + " infile outfile\ninfile being png or qoi") 16 | return 17 | } 18 | infile := os.Args[1] 19 | outfile := os.Args[2] 20 | 21 | f, err := os.Open(infile) 22 | if err != nil { 23 | fmt.Println("Error opening file:", err) 24 | return 25 | } 26 | 27 | img, _, err := image.Decode(f) 28 | if err != nil { 29 | fmt.Println("Error decoding file:", err) 30 | return 31 | } 32 | 33 | if !strings.HasSuffix(outfile, ".png") && !strings.HasSuffix(outfile, ".qoi") { 34 | fmt.Println("Only png or qoi files are supported.") 35 | } 36 | 37 | of, err := os.Create(outfile) 38 | if err != nil { 39 | fmt.Println("Error creating file:", err) 40 | return 41 | } 42 | if strings.HasSuffix(outfile, ".png") { 43 | png.Encode(of, img) 44 | } else { 45 | qoi.Encode(of, img) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/xfmoulet/qoi 2 | 3 | go 1.17 4 | -------------------------------------------------------------------------------- /qoi.go: -------------------------------------------------------------------------------- 1 | package qoi 2 | 3 | /* 4 | 5 | QOI - The “Quite OK Image” format for fast, lossless image compression 6 | 7 | Original version by Dominic Szablewski - https://phoboslab.org 8 | Go version by Xavier-Frédéric Moulet 9 | 10 | */ 11 | 12 | import ( 13 | "bufio" 14 | "encoding/binary" 15 | "errors" 16 | "image" 17 | "image/color" 18 | "io" 19 | ) 20 | 21 | const ( 22 | qoi_INDEX byte = 0b00_000000 23 | qoi_DIFF byte = 0b01_000000 24 | qoi_LUMA byte = 0b10_000000 25 | qoi_RUN byte = 0b11_000000 26 | qoi_RGB byte = 0b1111_1110 27 | qoi_RGBA byte = 0b1111_1111 28 | 29 | qoi_MASK_2 byte = 0b11_000000 30 | ) 31 | 32 | const qoiMagic = "qoif" 33 | 34 | const qoiPixelsMax = 400_000_000 // 400 million pixels ought to be enough for anybody 35 | 36 | func qoi_COLOR_HASH(r, g, b, a byte) byte { 37 | return byte(r*3 + g*5 + b*7 + a*11) 38 | } 39 | 40 | type pixel [4]byte 41 | 42 | func Decode(r io.Reader) (image.Image, error) { 43 | cfg, err := DecodeConfig(r) 44 | if err != nil { 45 | return nil, err 46 | } 47 | NBPixels := cfg.Width * cfg.Height 48 | if NBPixels == 0 || NBPixels > qoiPixelsMax { 49 | return nil, errors.New("bad image dimensions") 50 | } 51 | 52 | b := bufio.NewReader(r) 53 | 54 | img := image.NewNRGBA(image.Rect(0, 0, cfg.Width, cfg.Height)) 55 | 56 | var index [64]pixel 57 | 58 | run := 0 59 | 60 | pixels := img.Pix // pixels yet to write 61 | px := pixel{0, 0, 0, 255} 62 | for len(pixels) > 0 { 63 | if run > 0 { 64 | run-- 65 | } else { 66 | b1, err := b.ReadByte() 67 | if err == io.EOF { 68 | return img, nil 69 | } 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | switch { 75 | case b1 == qoi_RGB: 76 | _, err = io.ReadFull(b, px[:3]) 77 | if err != nil { 78 | return nil, err 79 | } 80 | case b1 == qoi_RGBA: 81 | _, err = io.ReadFull(b, px[:]) 82 | if err != nil { 83 | return nil, err 84 | } 85 | case b1&qoi_MASK_2 == qoi_INDEX: 86 | px = index[b1] 87 | case b1&qoi_MASK_2 == qoi_DIFF: 88 | px[0] += ((b1 >> 4) & 0x03) - 2 89 | px[1] += ((b1 >> 2) & 0x03) - 2 90 | px[2] += (b1 & 0x03) - 2 91 | case b1&qoi_MASK_2 == qoi_LUMA: 92 | b2, err := b.ReadByte() 93 | if err != nil { 94 | return nil, err 95 | } 96 | vg := (b1 & 0b00111111) - 32 97 | px[0] += vg - 8 + ((b2 >> 4) & 0x0f) 98 | px[1] += vg 99 | px[2] += vg - 8 + (b2 & 0x0f) 100 | case b1&qoi_MASK_2 == qoi_RUN: 101 | run = int(b1 & 0b00111111) 102 | default: 103 | px = pixel{255, 0, 255, 255} // should not happen 104 | } 105 | 106 | index[int(qoi_COLOR_HASH(px[0], px[1], px[2], px[3]))%len(index)] = px 107 | } 108 | 109 | // TODO stride .. 110 | copy(pixels[:4], px[:]) 111 | pixels = pixels[4:] // advance 112 | } 113 | return img, nil 114 | } 115 | 116 | func Encode(w io.Writer, m image.Image) error { 117 | 118 | var out = bufio.NewWriter(w) 119 | 120 | minX := m.Bounds().Min.X 121 | maxX := m.Bounds().Max.X 122 | minY := m.Bounds().Min.Y 123 | maxY := m.Bounds().Max.Y 124 | 125 | NBPixels := (maxX - minX) * (maxY - minY) 126 | if NBPixels == 0 || NBPixels >= qoiPixelsMax { 127 | return errors.New("Bad image Size") 128 | } 129 | 130 | // write header to output 131 | if err := binary.Write(out, binary.BigEndian, []byte(qoiMagic)); err != nil { 132 | return err 133 | } 134 | // width 135 | if err := binary.Write(out, binary.BigEndian, uint32(maxX-minX)); err != nil { 136 | return err 137 | } 138 | // height 139 | if err := binary.Write(out, binary.BigEndian, uint32(maxY-minY)); err != nil { 140 | return err 141 | } 142 | // channels 143 | if err := binary.Write(out, binary.BigEndian, uint8(4)); err != nil { 144 | return err 145 | } 146 | // 0b0000rgba colorspace 147 | if err := binary.Write(out, binary.BigEndian, uint8(0)); err != nil { 148 | return err 149 | } 150 | 151 | var index [64]pixel 152 | px_prev := pixel{0, 0, 0, 255} 153 | run := 0 154 | 155 | for y := minY; y < maxY; y++ { 156 | for x := minX; x < maxX; x++ { 157 | // extract pixel and convert to non-premultiplied 158 | c := color.NRGBAModel.Convert(m.At(x, y)) 159 | c_r, c_g, c_b, c_a := c.RGBA() 160 | px := pixel{byte(c_r >> 8), byte(c_g >> 8), byte(c_b >> 8), byte(c_a >> 8)} 161 | 162 | if px == px_prev { 163 | run++ 164 | last_pixel := x == (maxX-1) && y == (maxY-1) 165 | if run == 62 || last_pixel { 166 | out.WriteByte(qoi_RUN | byte(run-1)) 167 | run = 0 168 | } 169 | } else { 170 | if run > 0 { 171 | out.WriteByte(qoi_RUN | byte(run-1)) 172 | run = 0 173 | } 174 | var index_pos byte = qoi_COLOR_HASH(px[0], px[1], px[2], px[3]) % 64 175 | if index[index_pos] == px { 176 | out.WriteByte(qoi_INDEX | index_pos) 177 | } else { 178 | index[index_pos] = px 179 | 180 | if px[3] == px_prev[3] { 181 | vr := int8(int(px[0]) - int(px_prev[0])) 182 | vg := int8(int(px[1]) - int(px_prev[1])) 183 | vb := int8(int(px[2]) - int(px_prev[2])) 184 | 185 | vg_r := vr - vg 186 | vg_b := vb - vg 187 | 188 | if vr > -3 && vr < 2 && vg > -3 && vg < 2 && vb > -3 && vb < 2 { 189 | out.WriteByte(qoi_DIFF | byte((vr+2)<<4|(vg+2)<<2|(vb+2))) 190 | } else if vg_r > -9 && vg_r < 8 && vg > -33 && vg < 32 && vg_b > -9 && vg_b < 8 { 191 | out.WriteByte(qoi_LUMA | byte(vg+32)) 192 | out.WriteByte(byte((vg_r+8)<<4) | byte(vg_b+8)) 193 | } else { 194 | out.WriteByte(qoi_RGB) 195 | out.WriteByte(px[0]) 196 | out.WriteByte(px[1]) 197 | out.WriteByte(px[2]) 198 | } 199 | 200 | } else { 201 | out.WriteByte(qoi_RGBA) 202 | for i := 0; i < 4; i++ { 203 | out.WriteByte(px[i]) 204 | } 205 | } 206 | 207 | } 208 | } 209 | 210 | px_prev = px 211 | } 212 | } 213 | binary.Write(out, binary.BigEndian, uint32(0)) // padding 214 | binary.Write(out, binary.BigEndian, uint32(1)) // padding 215 | 216 | return out.Flush() 217 | } 218 | 219 | func DecodeConfig(r io.Reader) (cfg image.Config, err error) { 220 | var header [4 + 4 + 4 + 1 + 1]byte 221 | if _, err = io.ReadAtLeast(r, header[:], len(header)); err != nil { 222 | return 223 | } 224 | 225 | if string(header[:4]) != qoiMagic { 226 | return cfg, errors.New("Invalid magic") 227 | } 228 | // only decodes as NRGBA images 229 | return image.Config{ 230 | Width: int(binary.BigEndian.Uint32(header[4:])), 231 | Height: int(binary.BigEndian.Uint32(header[8:])), 232 | ColorModel: color.NRGBAModel, 233 | }, err 234 | } 235 | 236 | func init() { 237 | image.RegisterFormat("qoi", qoiMagic, Decode, DecodeConfig) 238 | } 239 | --------------------------------------------------------------------------------