Documentation
¶
Overview ¶
Package watchimage encodes arbitrary images into the compact, palettized bitmap format a Pebble (Basalt) watch renders. It is a self-contained, dependency-free (stdlib + golang.org/x/image/draw) image pipeline: decode → scale-to-fit/heap-budget → quantize to the Pebble's 64-color GColor8 space → pack at an appropriate bit depth, returning JSON the watch consumes.
It was extracted from MirrorMsg's Google Messages bridge so the image path can be reused on its own. It knows nothing about MirrorMsg, messaging, or any bridge — callers hand it raw image bytes and sizing parameters and receive the encoded result. See EncodeImageForWatch for the entry point.
The Pebble color model (why this looks the way it does) ¶
Basalt's display is 2 bits per channel: 4×4×4 = 64 possible colors, each represented as a GColor8 byte 0b11rrggbb (top two bits are alpha=opaque). A given image is drawn from a per-image palette of up to 16 of those 64 colors (4bpp), so two things bound fidelity: the 64-color device gamut, and the 16-slot per-image palette. Crucially, an image with "hundreds of shades of blue and yellow" does NOT need hundreds of colors here — those shades collapse into a handful of GColor8 buckets once projected into 2-bit-per-channel space. The analyzer (AnalyzeColors) measures this post-quantization reality, not the source image's nominal color count, and picks the smallest bit depth that renders the image without visible loss.
Index ¶
- func Encode(rawBytes []byte, opts Options) (string, error)
- func EncodeImageForWatch(rawBytes []byte, maxW, maxH, maxBytes, quality int) (string, error)
- func SetConfig(thumbCoverage, fullCoverage float64, thumbCapColors, fullCapColors int, ...)
- type BitDepth
- type ColorAnalysis
- type Options
- type WatchImageResult
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Encode ¶
Encode decodes raw image bytes, scales to fit the requested bounding box / heap budget, quantizes to the Pebble GColor8 space, and packs the result. Returns the WatchImageResult as a JSON string.
With Options.Palettize and Options.AutoDepth, it runs the color analyzer and packs at the smallest lossless-enough depth (1/2/4 bpp), shrinking watch heap use for simple images. Without AutoDepth it always uses 4bpp (16 colors).
func EncodeImageForWatch ¶
func SetConfig ¶
func SetConfig(thumbCoverage, fullCoverage float64, thumbCapColors, fullCapColors int, compressFit bool, dither bool, ditherEdge, ditherStrength, ditherIntensity int)
SetConfig updates the runtime image-quality dials. Coverages are fractions in (0,1]; caps are a color COUNT (2, 4, or 16) which map to depth 1/2/4. Any value out of range is clamped to a safe default so a bad setting can't break encoding. Called from Kotlin at startup (from SharedPreferences) and whenever a dial moves.
Types ¶
type BitDepth ¶
type BitDepth int
BitDepth is the packed bits-per-pixel of an encoded image. Fewer bits = less watch heap (cost is w*h/(8/bpp) bytes), at the cost of fewer distinct colors.
type ColorAnalysis ¶
type ColorAnalysis struct {
// SignificantColors is the number of GColor8 buckets needed to cover
// CoverageTarget of the pixels (the long tail of rare buckets is dropped).
SignificantColors int
// DistinctBuckets is the raw count of non-empty GColor8 buckets (incl. tail).
DistinctBuckets int
// HueFamilies is how many coarse hue families the significant colors span.
HueFamilies int
// Smoothness in [0,1]: fraction of adjacent-pixel pairs that are identical
// after quantization. High => large flat/gradient regions (banding risk at
// low depth); low => busy/noisy (banding hides).
Smoothness float64
// RecommendedDepth is the chosen packed bit depth for encoding.
RecommendedDepth BitDepth
}
ColorAnalysis summarizes an image's color needs in Pebble space. It drives the bit-depth choice: the fields describe how many distinct device colors actually carry the image, how many hue families they span, and whether the image is smooth (gradient-like, prone to banding at low depth) or busy.
func AnalyzeColors ¶
func AnalyzeColors(src image.Image, maxDepth BitDepth, coverage float64) ColorAnalysis
AnalyzeColors projects an image into Pebble GColor8 space and reports how many colors it actually needs on-device, then recommends a bit depth (never above maxDepth). It analyzes a downsampled copy (analyzeMaxDim box) for speed — enough to estimate color population and smoothness without touching every pixel of a large source.
maxDepth caps the recommendation: pass Depth2 for thumbnails (choose only 2 or 4 colors, never 16), Depth4 for the enhanced view (choose 16/4/2). See chooseDepth for the cap semantics.
The recommendation logic, in order:
- Count GColor8 buckets; find the smallest set covering coverageTarget of pixels (SignificantColors), ignoring a rare-bucket noise floor.
- Map SignificantColors to a base depth (≤2→1bpp, ≤4→2bpp, ≤16→4bpp).
- Refine with hue/smoothness: a low-hue image (few hue families) can drop a step even with several shade-buckets, because shades collapse acceptably; but a SMOOTH image (gradient) is held UP a step to avoid visible banding.
- Clamp to maxDepth.
type Options ¶
type Options struct {
// MaxW, MaxH: bounding box. In heap-budget mode (MaxBytes>0) MaxH is ignored
// and width is capped at MaxW; otherwise the image fits within MaxW×MaxH.
MaxW, MaxH int
// MaxBytes: watch heap budget in bytes. >0 selects enhanced-view sizing
// (width cap + shrink to fit budget); 0 selects chat-preview box fit.
MaxBytes int
// Palettize: if false, encode full-color 8bpp (Format 0). If true, palettize.
Palettize bool
// AutoDepth: when palettizing, analyze the image and pick the smallest bit
// depth (1/2/4) that renders it without visible loss. When false, always 4bpp
// (the historical behavior). Ignored when Palettize is false.
AutoDepth bool
// MaxDepth caps the depth AutoDepth may choose. Depth2 for thumbnails (choose
// 2 or 4 colors, never 16 — keeps churny, dynamically-allocated thumbnails as
// cheap as possible); Depth4 (or zero) for the enhanced view (16/4/2). Only
// meaningful with AutoDepth; without it the depth is fixed at 4bpp regardless.
MaxDepth BitDepth
// Coverage is the fraction of pixels (by color area) the significant-color
// count must cover before the tail of rarest colors is dropped (see
// AnalyzeColors). Lower = drop more rare colors = more images fall to a lower
// depth. Thumbnails can afford a looser value than the full view. 0 falls back
// to the default (coverageTarget). Only meaningful with AutoDepth.
Coverage float64
// CompressFit: size the image so its ROW-COMPRESSED estimate fits MaxBytes
// (rather than the raw packed size). Lets the enhanced view send a LARGER image
// when it will compress — the phone applies the same compression, and the
// estimate here is byte-exact with it, so the compressed result is guaranteed to
// fit MaxBytes. Only meaningful in enhanced mode (MaxBytes>0, Palettize). When
// off, MaxBytes bounds the raw size as before.
CompressFit bool
}
Options controls how EncodeImageForWatch sizes and encodes an image.
type WatchImageResult ¶
type WatchImageResult struct {
Width int `json:"w"`
Height int `json:"h"`
Pixels string `json:"pixels"` // base64: packed indices (4/2/1 bpp) or GColor8 bytes (8bpp)
Format int `json:"fmt"` // 0 = 8bpp full color, 1 = palettized (see Depth)
Palette string `json:"palette"` // base64 of up to 2^Depth GColor8 bytes (palettized only)
Depth int `json:"depth"` // packed bits/pixel: 1, 2, 4 (palettized), or 8
// EstCompressed: byte-exact row-compressed size of this result's TIGHT packing
// (set in CompressFit mode). Lets the phone cross-check its own compression,
// which is byte-identical by design.
EstCompressed int `json:"estCompressed,omitempty"`
}
WatchImageResult is the encoded image the watch consumes. Pixels is base64; its meaning depends on Format. Palette is base64 of up to 2^Depth GColor8 bytes (empty for Format 0 / 8bpp). Depth is the packed bit depth (1/2/4); it lets the watch size its heap reservation and unpack correctly.