File: palette.go

package info (click to toggle)
golang-github-charmbracelet-x 0.0~git20251028.0cf22f8%2Bds-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,940 kB
  • sloc: sh: 124; makefile: 5
file content (348 lines) | stat: -rw-r--r-- 12,116 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
package sixel

import (
	"container/heap"
	"image"
	"image/color"
	"math"
)

// sixelPalette is a palette of up to 256 colors that lists the colors that will be used by
// a SixelImage. Most images, especially jpegs, have more than 256 colors, so creating a sixelPalette
// requires the use of color quantization. For this we use the Median Cut algorithm.
//
// Median cut requires all pixels in an image to be positioned in a 4D color cube, with one axis per channel.
// The cube is sliced in half along its longest axis such that half the pixels in the cube end up in one of
// the sub-cubes and half end up in the other. We continue slicing the cube with the longest axis in half
// along that axis until there are 256 sub-cubes.  Then, the average of all pixels in each subcube is used
// as that cube's color.
//
// Colors are converted to palette colors based on which they are closest to (it's not
// always their cube's color).
//
// This implementation has a few minor (but seemingly very common) differences from the Official algorithm:
//   - When determining the longest axis, the number of pixels in the cube are multiplied against axis length
//     This improves color selection by quite a bit in cases where an image has a lot of space taken up by different
//     shades of the same color.
//   - If a single color sits on a cut line, all pixels of that color are assigned to one of the subcubes
//     rather than try to split them up between the subcubes.  This allows us to use a slice of unique colors
//     and a map of pixel counts rather than try to represent each pixel individually.
type sixelPalette struct {
	// Map used to convert colors from the image to palette colors
	colorConvert map[sixelColor]sixelColor
	// Lookup to get palette index from image color
	paletteIndexes map[sixelColor]int
	PaletteColors  []sixelColor
}

// quantizationChannel is an enum type which indicates an axis in the color cube. Used to indicate which
// axis in a cube is the longest.
type quantizationChannel int

const (
	// MaxColors is the maximum number of colors a sixelPalette can contain.
	MaxColors int = 256

	quantizationRed quantizationChannel = iota
	quantizationGreen
	quantizationBlue
	quantizationAlpha
)

// quantizationCube represents a single cube in the median cut algorithm.
type quantizationCube struct {
	// startIndex is the index in the uniqueColors slice where this cube starts
	startIndex int
	// length is the number of elements in the uniqueColors slice this cube occupies
	length int
	// sliceChannel is the axis that will be cut if this cube is cut in half
	sliceChannel quantizationChannel
	// score is a heuristic value: higher means this cube is more likely to be cut
	score uint64
	// pixelCount is how many pixels are contained in this cube
	pixelCount uint64
}

// cubePriorityQueue is a heap used to sort quantizationCube objects in order to select the correct
// one to cut next. Pop will remove the queue with the highest score.
type cubePriorityQueue []any

func (p *cubePriorityQueue) Push(x any) {
	*p = append(*p, x)
}

func (p *cubePriorityQueue) Pop() any {
	popped := (*p)[len(*p)-1]
	*p = (*p)[:len(*p)-1]
	return popped
}

func (p *cubePriorityQueue) Len() int {
	return len(*p)
}

func (p *cubePriorityQueue) Less(i, j int) bool {
	left := (*p)[i].(quantizationCube)
	right := (*p)[j].(quantizationCube)

	// We want the largest channel variance
	return left.score > right.score
}

func (p *cubePriorityQueue) Swap(i, j int) {
	(*p)[i], (*p)[j] = (*p)[j], (*p)[i]
}

// createCube is used to initialize a new quantizationCube containing a region of the uniqueColors slice.
func (p *sixelPalette) createCube(uniqueColors []sixelColor, pixelCounts map[sixelColor]uint64, startIndex, bucketLength int) quantizationCube {
	minRed, minGreen, minBlue, minAlpha := uint32(0xffff), uint32(0xffff), uint32(0xffff), uint32(0xffff)
	maxRed, maxGreen, maxBlue, maxAlpha := uint32(0), uint32(0), uint32(0), uint32(0)
	totalWeight := uint64(0)

	// Figure out which channel has the greatest variance
	for i := startIndex; i < startIndex+bucketLength; i++ {
		r, g, b, a := uniqueColors[i].Red, uniqueColors[i].Green, uniqueColors[i].Blue, uniqueColors[i].Alpha
		totalWeight += pixelCounts[uniqueColors[i]]

		if r < minRed {
			minRed = r
		}
		if r > maxRed {
			maxRed = r
		}
		if g < minGreen {
			minGreen = g
		}
		if g > maxGreen {
			maxGreen = g
		}
		if b < minBlue {
			minBlue = b
		}
		if b > maxBlue {
			maxBlue = b
		}
		if a < minAlpha {
			minAlpha = a
		}
		if a > maxAlpha {
			maxAlpha = a
		}
	}

	dRed := maxRed - minRed
	dGreen := maxGreen - minGreen
	dBlue := maxBlue - minBlue
	dAlpha := maxAlpha - minAlpha

	cube := quantizationCube{
		startIndex: startIndex,
		length:     bucketLength,
		pixelCount: totalWeight,
	}

	if dRed >= dGreen && dRed >= dBlue && dRed >= dAlpha {
		cube.sliceChannel = quantizationRed
		cube.score = uint64(dRed)
	} else if dGreen >= dBlue && dGreen >= dAlpha {
		cube.sliceChannel = quantizationGreen
		cube.score = uint64(dGreen)
	} else if dBlue >= dAlpha {
		cube.sliceChannel = quantizationBlue
		cube.score = uint64(dBlue)
	} else {
		cube.sliceChannel = quantizationAlpha
		cube.score = uint64(dAlpha)
	}

	// Boost the score of cubes with more pixels in them
	cube.score *= totalWeight

	return cube
}

// quantize is a method that will initialize the palette's colors and lookups, provided a set
// of unique colors and a map containing pixel counts for those colors.
func (p *sixelPalette) quantize(uniqueColors []sixelColor, pixelCounts map[sixelColor]uint64, maxColors int) {
	p.colorConvert = make(map[sixelColor]sixelColor)
	p.paletteIndexes = make(map[sixelColor]int)

	// We don't need to quantize if we don't even have more than the maximum colors, and in fact, this code will explode
	// if we have fewer than maximum colors
	if len(uniqueColors) <= maxColors {
		p.PaletteColors = uniqueColors
		return
	}

	cubeHeap := make(cubePriorityQueue, 0, maxColors)

	// Start with a cube that contains all colors
	heap.Init(&cubeHeap)
	heap.Push(&cubeHeap, p.createCube(uniqueColors, pixelCounts, 0, len(uniqueColors)))

	// Slice the best cube into two cubes until we have max colors, then we have our palette
	for cubeHeap.Len() < maxColors {
		cubeToSplit := heap.Pop(&cubeHeap).(quantizationCube)

		//nolint:godox
		// TODO: Use slices.SortFunc and cmp.Compare in the future (>=1.24)
		// Then can delete palette_sort.go
		sortFunc(uniqueColors[cubeToSplit.startIndex:cubeToSplit.startIndex+cubeToSplit.length],
			func(left sixelColor, right sixelColor) int {
				switch cubeToSplit.sliceChannel { //nolint:exhaustive // alpha channel not used
				case quantizationRed:
					return compare(left.Red, right.Red)
				case quantizationGreen:
					return compare(left.Green, right.Green)
				case quantizationBlue:
					return compare(left.Blue, right.Blue)
				default:
					return compare(left.Alpha, right.Alpha)
				}
			})

		// We need to split up the colors in this cube so that the pixels are evenly split between the two,
		// or at least as close as we can reasonably get. What we do is count up the pixels as we go through
		// and place the cut point where around half of the pixels are on the left side
		countSoFar := pixelCounts[uniqueColors[cubeToSplit.startIndex]]
		targetCount := cubeToSplit.pixelCount / 2
		leftLength := 1

		for i := cubeToSplit.startIndex + 1; i < cubeToSplit.startIndex+cubeToSplit.length; i++ {
			c := uniqueColors[i]
			weight := pixelCounts[c]
			if countSoFar+weight > targetCount {
				break
			}
			leftLength++
			countSoFar += weight
		}

		rightLength := cubeToSplit.length - leftLength
		rightIndex := cubeToSplit.startIndex + leftLength
		heap.Push(&cubeHeap, p.createCube(uniqueColors, pixelCounts, cubeToSplit.startIndex, leftLength))
		heap.Push(&cubeHeap, p.createCube(uniqueColors, pixelCounts, rightIndex, rightLength))
	}

	// Once we've got max cubes in the heap, pull them all out and load them into the palette
	for cubeHeap.Len() > 0 {
		bucketToLoad := heap.Pop(&cubeHeap).(quantizationCube)
		p.loadColor(uniqueColors, pixelCounts, bucketToLoad.startIndex, bucketToLoad.length)
	}
}

// ColorIndex accepts a raw image color (NOT a palette color) and provides the palette index of that color.
func (p *sixelPalette) ColorIndex(c sixelColor) int {
	return p.paletteIndexes[c]
}

// loadColor accepts a range of colors representing a single median cut cube. It calculates the
// average color in the cube and adds it to the palette.
func (p *sixelPalette) loadColor(uniqueColors []sixelColor, pixelCounts map[sixelColor]uint64, startIndex, cubeLen int) {
	totalRed, totalGreen, totalBlue, totalAlpha := uint64(0), uint64(0), uint64(0), uint64(0)
	totalCount := uint64(0)
	for i := startIndex; i < startIndex+cubeLen; i++ {
		count := pixelCounts[uniqueColors[i]]
		totalRed += uint64(uniqueColors[i].Red) * count
		totalGreen += uint64(uniqueColors[i].Green) * count
		totalBlue += uint64(uniqueColors[i].Blue) * count
		totalAlpha += uint64(uniqueColors[i].Alpha) * count
		totalCount += count
	}

	averageColor := sixelColor{
		Red:   uint32(totalRed / totalCount),   //nolint:gosec
		Green: uint32(totalGreen / totalCount), //nolint:gosec
		Blue:  uint32(totalBlue / totalCount),  //nolint:gosec
		Alpha: uint32(totalAlpha / totalCount), //nolint:gosec
	}

	p.PaletteColors = append(p.PaletteColors, averageColor)
}

// sixelColor is a flat struct that contains a single color: all channels are 0-100
// instead of anything sensible.
type sixelColor struct {
	Red   uint32
	Green uint32
	Blue  uint32
	Alpha uint32
}

// sixelConvertColor accepts an ordinary Go color and converts it to a sixelColor, which
// has channels ranging from 0-100.
func sixelConvertColor(c color.Color) sixelColor {
	r, g, b, a := c.RGBA()
	return sixelColor{
		Red:   sixelConvertChannel(r),
		Green: sixelConvertChannel(g),
		Blue:  sixelConvertChannel(b),
		Alpha: sixelConvertChannel(a),
	}
}

// sixelConvertChannel converts a single color channel from go's standard 0-0xffff range to
// sixel's 0-100 range.
func sixelConvertChannel(channel uint32) uint32 {
	// We add 328 because that is about 0.5 in the sixel 0-100 color range, we're trying to
	// round to the nearest value
	return (channel + 328) * 100 / 0xffff
}

// newSixelPalette accepts an image and produces an N-color quantized color palette using the median cut
// algorithm. The produced sixelPalette can convert colors from the image to the quantized palette
// in O(1) time.
func newSixelPalette(image image.Image, maxColors int) sixelPalette {
	pixelCounts := make(map[sixelColor]uint64)

	height := image.Bounds().Dy()
	width := image.Bounds().Dx()

	// Record pixel counts for every color while also getting a set of all unique colors in the image
	for y := range height {
		for x := range width {
			c := sixelConvertColor(image.At(x, y))
			count := pixelCounts[c]
			count++

			pixelCounts[c] = count
		}
	}

	p := sixelPalette{}
	uniqueColors := make([]sixelColor, 0, len(pixelCounts))
	for c := range pixelCounts {
		uniqueColors = append(uniqueColors, c)
	}

	// Build up p.PaletteColors using the median cut algorithm
	p.quantize(uniqueColors, pixelCounts, maxColors)

	// The average color for a cube a color occupies is not always the closest palette color.  As a result,
	// we need to use this very upsetting double loop to find the lookup palette color for each
	// unique color in the image.
	for _, c := range uniqueColors {
		var bestColor sixelColor
		var bestColorIndex int
		bestScore := uint32(math.MaxUint32)

		for paletteIndex, paletteColor := range p.PaletteColors {
			redDiff := c.Red - paletteColor.Red
			greenDiff := c.Green - paletteColor.Green
			blueDiff := c.Blue - paletteColor.Blue
			alphaDiff := c.Alpha - paletteColor.Alpha

			score := (redDiff * redDiff) + (greenDiff * greenDiff) + (blueDiff * blueDiff) + (alphaDiff * alphaDiff)
			if score < bestScore {
				bestColor = paletteColor
				bestColorIndex = paletteIndex
				bestScore = score
			}
		}

		p.paletteIndexes[c] = bestColorIndex
		p.colorConvert[c] = bestColor
	}

	return p
}