From e8178ad95305a3096cef410626164559693052f6 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Wed, 18 Oct 2023 12:41:57 -0700 Subject: image: add JPEG quality and web client --- image/jpeg/quality.go | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++ image/webp/client.go | 117 +++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 image/jpeg/quality.go create mode 100644 image/webp/client.go diff --git a/image/jpeg/quality.go b/image/jpeg/quality.go new file mode 100644 index 0000000..674f0b8 --- /dev/null +++ b/image/jpeg/quality.go @@ -0,0 +1,158 @@ +package jpeg + +import ( + "fmt" + "io" +) + +var defaultTables = [][]int{ + { // Luminance + 16, 11, 12, 14, 12, 10, 16, 14, + 13, 14, 18, 17, 16, 19, 24, 40, + 26, 24, 22, 22, 24, 49, 35, 37, + 29, 40, 58, 51, 61, 60, 57, 51, + 56, 55, 64, 72, 92, 78, 64, 68, + 87, 69, 55, 56, 80, 109, 81, 87, + 95, 98, 103, 104, 103, 62, 77, 113, + 121, 112, 100, 120, 92, 101, 103, 99, + }, + { // Chrominance + 17, 18, 18, 24, 21, 24, 47, 26, + 26, 47, 99, 66, 56, 66, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + }, +} + +func read2Bytes(r io.Reader) ([]byte, error) { + b := make([]byte, 2) + if _, err := r.Read(b); err != nil { + return nil, err + } + return b, nil +} + +// ReadJPEGQuality returns the JPEG quality estimate. +// +// Cleaned up version of github.com/liut/jpegquality +func ReadJPEGQuality(r io.ReadSeeker) (int, error) { + sign, err := read2Bytes(r) + if err != nil { + return 0, err + } + + if sign[0] != 0xff && sign[1] != 0xd8 { + return 0, fmt.Errorf("invalid jpeg header") + } + + for { + mark, err := read2Bytes(r) + if err != nil { + return 0, err + } + + if mark[0] != 0xff || mark[1] == 0xff || mark[1] == 0x00 { + // Would also be valid to just keep re-reading to find the marker + return 0, fmt.Errorf("invalid marker") + } + + marker := int(mark[0])<<8 + int(mark[1]) + if marker == 0 { + return 0, fmt.Errorf("invalid jpeg header") + } + + sign, err := read2Bytes(r) + if err != nil { + return 0, err + } + + length := int(sign[0])<<8 + int(sign[1]) - 2 + if length < 0 { + return 0, fmt.Errorf("short segment read") + } + + if (marker & 0xff) != 0xdb { // not a quantization table + if _, err := r.Seek(int64(length), 1); err != nil { + return 0, err + } + continue + } + + if length%65 != 0 { + return 0, fmt.Errorf("wrong size for quantization table") + } + + tabuf := make([]byte, length) + n, err := r.Read(tabuf) + if err != nil { + return 0, err + } + tabuf = tabuf[0:n] + + allones := 1 + + var reftable []int + var cumsf, cumsf2 float64 + + for a := 0; a < n; { + tableindex := int(tabuf[a] & 0x0f) + a++ + + if tableindex < 2 { + reftable = defaultTables[tableindex] + } + + // Read in the table, compute statistics relative to reference table + if a+64 > n { + return 0, fmt.Errorf("DQT segment too short") + } + + for coefindex := 0; coefindex < 64 && a < n; coefindex++ { + var val int + + if tableindex>>4 != 0 { + temp := int(tabuf[a]) + a++ + temp *= 256 + val = int(tabuf[a]) + temp + a++ + } else { + val = int(tabuf[a]) + a++ + } + + // scaling factor in percent + x := 100.0 * float64(val) / float64(reftable[coefindex]) + cumsf += x + cumsf2 += x * x + + // separate check for all-ones table (Q 100) + if val != 1 { + allones = 0 + } + } + + if 0 != len(reftable) { // terse output includes quality + var qual float64 + cumsf /= 64.0 // mean scale factor + cumsf2 /= 64.0 + + if allones == 1 { // special case for all-ones table + qual = 100.0 + } else if cumsf <= 100.0 { + qual = (200.0 - cumsf) / 2.0 + } else { + qual = 5000.0 / cumsf + } + + if tableindex == 0 { + return (int)(qual + 0.5), nil + } + } + } + } +} diff --git a/image/webp/client.go b/image/webp/client.go new file mode 100644 index 0000000..6b08b72 --- /dev/null +++ b/image/webp/client.go @@ -0,0 +1,117 @@ +package webp + +import ( + "bytes" + "context" + "fmt" + "image" + "io" + "net" + "net/http" + "os" + "os/exec" + "path" + "strconv" + "syscall" + + "golang.org/x/image/tiff" +) + +// WebPConvertClient starts a webp conversion server and manages +// its lifecycle. Call Convert to convert an image.Image to a webp +// bytestream. This struct is only threadsafe after Start has been +// called. +type WebPConvertClient struct { + p *exec.Cmd + c *http.Client +} + +// Start starts the server, cancelling the passed context will stop +// the server process. It is important to pass a real context into the +// process and not just context.Background. This function will return an +// error if setup fails but does not block. +func (c *WebPConvertClient) Start(ctx context.Context) error { + sockDir, err := os.MkdirTemp("", "webp-*") + if err != nil { + return err + } + + sockPath := path.Join(sockDir, "webp.sock") + + c.c = &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", sockPath) + }, + }, + } + + c.p = exec.CommandContext(ctx, "webp-convert-server", sockPath) + if c.p.Err != nil { + return c.p.Err + } + + c.p.Cancel = func() error { + if err := c.p.Process.Signal(syscall.SIGTERM); err != nil { + return err + } + return os.ErrProcessDone + } + + ec := make(chan error, 1) + go func() { + ec <- c.p.Run() + os.RemoveAll(sockDir) + }() + + select { + case err = <-ec: + return err + default: + return nil + } +} + +// Convert encodes an image as TIFF and sends it to the conversion +// server. It returns either an error or the webp encoded bytes from the +// input image. +func (c *WebPConvertClient) Convert(ctx context.Context, img image.Image, quality int, lossless, exact bool) ([]byte, error) { + if c.c == nil { + return nil, fmt.Errorf("WebPConvertClient has not been started") + } + + bb := &bytes.Buffer{} + if err := tiff.Encode(bb, img, &tiff.Options{Compression: tiff.Uncompressed}); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://unix/", bb) + if err != nil { + return nil, err + } + req.Header.Add("quality", strconv.Itoa(quality)) + req.Header.Add("lossless", fmt.Sprintf("%t", lossless)) + req.Header.Add("exact", fmt.Sprintf("%t", exact)) + + res, err := c.c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + e, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("WebPConvertClient server error: %s", e) + } + + bb.Reset() + if _, err := io.Copy(bb, res.Body); err != nil { + return nil, err + } + + return bb.Bytes(), nil +} -- cgit v1.2.3