oxipng 10.2.0

A lossless PNG compression optimizer
Documentation
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
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
use indexmap::IndexSet;
use rgb::RGBA8;

use crate::{
    colors::{BitDepth, ColorType},
    headers::IhdrData,
    png::{PngImage, scan_lines::ScanLine},
};

/// Attempt to reduce the number of colors in the palette, returning the reduced image if successful
#[must_use]
pub fn reduced_palette(png: &PngImage, optimize_alpha: bool) -> Option<PngImage> {
    if png.ihdr.bit_depth != BitDepth::Eight {
        return None;
    }
    let ColorType::Indexed { palette } = &png.ihdr.color_type else {
        return None;
    };

    let mut used = [false; 256];
    for &byte in &png.data {
        used[byte as usize] = true;
    }

    let black = RGBA8::new(0, 0, 0, 255);
    let mut condensed = IndexSet::with_capacity(palette.len());
    let mut byte_map = [0; 256];
    let mut did_change = false;
    for (i, used) in used.iter().enumerate() {
        if !used {
            continue;
        }
        // There are invalid files that use pixel indices beyond palette size
        let color = *palette.get(i).unwrap_or(&black);
        byte_map[i] = add_color_to_set(color, &mut condensed, optimize_alpha);
        if byte_map[i] as usize != i {
            did_change = true;
        }
    }

    let data = if did_change {
        // Reassign data bytes to new indices
        png.data.iter().map(|b| byte_map[*b as usize]).collect()
    } else if condensed.len() != palette.len() {
        // Data is unchanged but palette is different size
        // Note the new palette could potentially be larger if the original had a missing entry
        png.data.clone()
    } else {
        // Nothing has changed
        return None;
    };

    let palette: Vec<_> = condensed.into_iter().collect();

    Some(PngImage {
        ihdr: IhdrData {
            color_type: ColorType::Indexed { palette },
            ..png.ihdr
        },
        data,
    })
}

fn add_color_to_set(mut color: RGBA8, set: &mut IndexSet<RGBA8>, optimize_alpha: bool) -> u8 {
    // If there are multiple fully transparent entries, reduce them into one
    if optimize_alpha && color.a == 0 {
        color.r = 0;
        color.g = 0;
        color.b = 0;
    }
    let (idx, _) = set.insert_full(color);
    idx as u8
}

/// Attempt to sort the colors in the palette by luma, returning the sorted image if successful
#[must_use]
pub fn sorted_palette(png: &PngImage) -> Option<PngImage> {
    if png.ihdr.bit_depth != BitDepth::Eight {
        return None;
    }
    let palette = match &png.ihdr.color_type {
        ColorType::Indexed { palette } if palette.len() > 1 => palette,
        _ => return None,
    };

    let mut enumerated: Vec<_> = palette.iter().enumerate().collect();
    // Put the most popular edge color first, which can help slightly if the filter bytes are 0
    let keep_first = most_popular_edge_color(palette.len(), png);
    let first = keep_first.map(|f| enumerated.remove(f));

    // Sort the palette
    enumerated.sort_by(|a, b| {
        // Sort by ascending alpha and descending luma
        let color_val = |color: &RGBA8| {
            let a = i32::from(color.a);
            // Put 7 high bits of alpha first, then luma, then low bit of alpha
            // This provides notable improvement in images with a lot of alpha
            ((a & 0xFE) << 18) + (a & 0x01)
            // These are coefficients for standard sRGB to luma conversion
            - i32::from(color.r) * 299
            - i32::from(color.g) * 587
            - i32::from(color.b) * 114
        };
        color_val(a.1).cmp(&color_val(b.1))
    });
    if let Some(first) = first {
        enumerated.insert(0, first);
    }

    // Extract the new palette and determine if anything changed
    let (remapping, palette): (Vec<_>, Vec<RGBA8>) = enumerated.into_iter().unzip();
    if remapping.iter().enumerate().all(|(a, b)| a == *b) {
        return None;
    }

    // Construct the new mapping and convert the data
    let mut byte_map = [0; 256];
    for (i, &v) in remapping.iter().enumerate() {
        byte_map[v] = i as u8;
    }
    let data = png.data.iter().map(|&b| byte_map[b as usize]).collect();

    Some(PngImage {
        ihdr: IhdrData {
            color_type: ColorType::Indexed { palette },
            ..png.ihdr
        },
        data,
    })
}

/// Sort the colors in the palette using the mzeng technique, returning the sorted image if successful
// (Note: This is currently unused as it is outclassed by the ezeng method)
#[must_use]
pub fn sorted_palette_mzeng(png: &PngImage, matrix: &CoOccurrenceMatrix) -> Option<PngImage> {
    let mut remapping = mzeng_reindex(matrix);
    // Put the most popular color first, but only if covers at least 15% of the image
    apply_most_popular_color(&mut remapping, matrix, png.data.len() * 3 / 20);
    apply_palette_reorder(png, &remapping)
}

/// Sort the colors in the palette using the ezeng technique, returning the sorted image if successful
#[must_use]
pub fn sorted_palette_ezeng(
    png: &PngImage,
    matrix: &CoOccurrenceMatrix,
    max_swap_dist: u8,
) -> Option<PngImage> {
    let mut remapping = ezeng_reindex(matrix);
    // Perform additional optimization with pairwise swaps
    pairwise_swap_search(&mut remapping, matrix, max_swap_dist);
    // Put the most popular color first, but only if covers at least 15% of the image
    apply_most_popular_color(&mut remapping, matrix, png.data.len() * 3 / 20);
    apply_palette_reorder(png, &remapping)
}

/// Sort the colors in the palette using the battiato technique, returning the sorted image if successful
#[must_use]
pub fn sorted_palette_battiato(png: &PngImage, matrix: &CoOccurrenceMatrix) -> Option<PngImage> {
    let mut remapping = battiato_reindex(matrix);
    // Always put the most popular color first (this helps increase diversity vs zeng)
    apply_most_popular_color(&mut remapping, matrix, 0);
    apply_palette_reorder(png, &remapping)
}

// Apply the palette reordering to the image data
fn apply_palette_reorder(png: &PngImage, remapping: &[usize]) -> Option<PngImage> {
    let ColorType::Indexed { palette } = &png.ihdr.color_type else {
        return None;
    };
    assert!(remapping.len() == palette.len());

    // Check if anything changed
    if remapping.iter().enumerate().all(|(a, b)| a == *b) {
        return None;
    }

    // Construct the palette and byte maps and convert the data
    let mut new_palette = Vec::new();
    let mut byte_map = [0; 256];
    for (i, &v) in remapping.iter().enumerate() {
        new_palette.push(palette[v]);
        byte_map[v] = i as u8;
    }
    let data = png.data.iter().map(|&b| byte_map[b as usize]).collect();

    Some(PngImage {
        ihdr: IhdrData {
            color_type: ColorType::Indexed {
                palette: new_palette,
            },
            ..png.ihdr
        },
        data,
    })
}

// Find the most popular color on the image edges (the pixels neighboring the filter bytes)
fn most_popular_edge_color(num_colors: usize, png: &PngImage) -> Option<usize> {
    let mut counts = [0_u32; 256];
    for line in png.scan_lines(false) {
        if let &[first, .., last] = line.data {
            counts[first as usize] += 1;
            counts[last as usize] += 1;
        }
    }
    let max = counts
        .iter()
        .take(num_colors)
        .enumerate()
        .max_by_key(|&(_, v)| v)
        .unwrap();
    // Ensure there's a clear winner - return None if multiple colors are tied
    let max_equal = counts.iter().filter(|&v| v == max.1).count();
    if max_equal > 1 {
        return None;
    }
    Some(max.0)
}

// Put the most popular color first, if it meets a minmum threshold
fn apply_most_popular_color(remapping: &mut [usize], matrix: &CoOccurrenceMatrix, min: usize) {
    let most_popular = matrix.most_popular_color();
    if most_popular.1 < min as u32 {
        return;
    }
    let first_idx = remapping.iter().position(|&i| i == most_popular.0).unwrap();
    // If the index is past halfway, reverse the order so as to minimize the change
    if first_idx >= remapping.len() / 2 {
        remapping.reverse();
        remapping.rotate_right(first_idx + 1);
    } else {
        remapping.rotate_left(first_idx);
    }
}

// Apply a greedy index assignment using the modified version of Zeng's techinque from
// "A note on Zeng's technique for color reindexing of palette-based images" by Pinho et al
// https://ieeexplore.ieee.org/document/1261987
// Based on the C implementation in libwebp
fn mzeng_reindex(matrix: &CoOccurrenceMatrix) -> Vec<usize> {
    // Initialize the mapping list with the two best indices.
    let edges = &matrix.weighted_edges;
    let mut remapping = vec![edges[0].0 as usize, edges[0].1 as usize];

    // Initialize the sums with the first two remappings and find the best one
    let mut sums = Vec::new();
    let mut best_sum_pos = 0;
    let mut best_sum = (0, 0);
    for i in 0..matrix.num_colors {
        let m_row = matrix.row(i);
        if i == remapping[0] || i == remapping[1] {
            continue;
        }
        let sum = (i, m_row[remapping[0]] + m_row[remapping[1]]);
        if sum.1 > best_sum.1 {
            best_sum_pos = sums.len();
            best_sum = sum;
        }
        sums.push(sum);
    }

    while !sums.is_empty() {
        let best_index = best_sum.0;
        // Compute delta to know if we need to prepend or append the best index.
        let mut delta: isize = 0;
        let n = (matrix.num_colors - sums.len()) as isize;
        let best_row = matrix.row(best_index);
        for (i, &index) in remapping.iter().enumerate() {
            delta += (n - 1 - 2 * i as isize) * best_row[index] as isize;
        }
        if delta > 0 {
            remapping.insert(0, best_index);
        } else {
            remapping.push(best_index);
        }
        // Remove best_sum from sums.
        sums.swap_remove(best_sum_pos);
        if !sums.is_empty() {
            // Update all the sums and find the best one.
            best_sum_pos = 0;
            best_sum = (0, 0);
            for (i, sum) in sums.iter_mut().enumerate() {
                sum.1 += best_row[sum.0];
                if sum.1 > best_sum.1 {
                    best_sum_pos = i;
                    best_sum = *sum;
                }
            }
        }
    }

    // Return the completed remapping
    remapping
}

// Apply a different version of Zeng's technique where the best index is inserted at a position
// minimizing total cost increase, rather than just the ends. This version is significantly more
// effective, but is more computationally expensive.
// The "e" here could mean enhanced, extended, exhaustive, or perhaps elephant if you prefer.
fn ezeng_reindex(matrix: &CoOccurrenceMatrix) -> Vec<usize> {
    // Initialize the mapping list with the two best indices.
    let edges = &matrix.weighted_edges;
    let mut remapping = vec![edges[0].0 as usize, edges[0].1 as usize];

    // Initialize the sums with the first two remappings and find the best one
    let mut sums = Vec::new();
    let mut best_sum_pos = 0;
    let mut best_sum = (0, 0);
    for i in 0..matrix.num_colors {
        let m_row = matrix.row(i);
        if i == remapping[0] || i == remapping[1] {
            continue;
        }
        let sum = (i, m_row[remapping[0]] + m_row[remapping[1]]);
        if sum.1 > best_sum.1 {
            best_sum_pos = sums.len();
            best_sum = sum;
        }
        sums.push(sum);
    }

    while !sums.is_empty() {
        let best_index = best_sum.0;
        // Try all insertion positions and pick the one minimizing total cost increase.
        // The cost increase of inserting at position p has two components:
        // 1. New element cost: Σ w(new, placed[k]) * distance(p, k_after_shift)
        // 2. Cross-pair cost: Σ w(placed[a], placed[b]) for all pairs straddling p
        //    (these pairs get pushed 1 unit farther apart by the insertion)
        let m = remapping.len();
        let mut best_pos = 0;
        let mut best_cost = i64::MAX;
        let mut cross_cost: i64 = 0;
        let best_row = matrix.row(best_index);
        for p in 0..=m {
            // New element's weighted distance to all existing elements
            let new_cost: i64 = (0..m)
                .map(|k| {
                    let dist = if k < p { p - k } else { k + 1 - p };
                    best_row[remapping[k]] as i64 * dist as i64
                })
                .sum();

            let total = new_cost + cross_cost;
            if total < best_cost {
                best_cost = total;
                best_pos = p;
            }

            // Update cross_cost for position p+1:
            // Element at position p moves from "right of split" to "left of split"
            // cross_cost(p+1) - cross_cost(p) = Σ_{b>p} w(p,b) - Σ_{a<p} w(a,p)
            if p < m {
                let row_p = matrix.row(remapping[p]);
                for &rb in &remapping[(p + 1)..m] {
                    cross_cost += row_p[rb] as i64;
                }
                for &ra in &remapping[..p] {
                    cross_cost -= row_p[ra] as i64;
                }
            }
        }
        remapping.insert(best_pos, best_index);

        // Remove best_sum from sums.
        sums.swap_remove(best_sum_pos);
        if !sums.is_empty() {
            // Update all the sums and find the best one.
            best_sum_pos = 0;
            best_sum = (0, 0);
            for (i, sum) in sums.iter_mut().enumerate() {
                sum.1 += best_row[sum.0];
                if sum.1 > best_sum.1 {
                    best_sum_pos = i;
                    best_sum = *sum;
                }
            }
        }
    }

    // Return the completed remapping
    remapping
}

// Calculate an approximate solution of the Traveling Salesman Problem using the algorithm
// from "An efficient Re-indexing algorithm for color-mapped images" by Battiato et al
// https://ieeexplore.ieee.org/document/1344033
fn battiato_reindex(matrix: &CoOccurrenceMatrix) -> Vec<usize> {
    let mut chains = Vec::new();
    // Keep track of the state of each vertex (.0) and it's chain number (.1)
    // 0 = an unvisited vertex (White)
    // 1 = an endpoint of a chain (Red)
    // 2 = part of the middle of a chain (Black)
    let mut vx = vec![(0, 0); matrix.num_colors];

    // Iterate the edges and assemble them into a chain
    for &(i, j, _) in &matrix.weighted_edges {
        let i = i as usize;
        let j = j as usize;
        let vi = vx[i];
        let vj = vx[j];
        if vi.0 == 0 && vj.0 == 0 {
            // Two unvisited vertices - create a new chain
            vx[i].0 = 1;
            vx[i].1 = chains.len();
            vx[j].0 = 1;
            vx[j].1 = chains.len();
            chains.push(vec![i, j]);
        } else if vi.0 == 0 && vj.0 == 1 {
            // An unvisited vertex connects with an endpoint of an existing chain
            vx[i].0 = 1;
            vx[i].1 = vj.1;
            vx[j].0 = 2;
            let chain = &mut chains[vj.1];
            if chain[0] == j {
                chain.insert(0, i);
            } else {
                chain.push(i);
            }
        } else if vi.0 == 1 && vj.0 == 0 {
            // An unvisited vertex connects with an endpoint of an existing chain
            vx[j].0 = 1;
            vx[j].1 = vi.1;
            vx[i].0 = 2;
            let chain = &mut chains[vi.1];
            if chain[0] == i {
                chain.insert(0, j);
            } else {
                chain.push(j);
            }
        } else if vi.0 == 1 && vj.0 == 1 && vi.1 != vj.1 {
            // Two endpoints of different chains are connected together
            vx[i].0 = 2;
            vx[j].0 = 2;
            let (a, b) = if vi.1 < vj.1 { (i, j) } else { (j, i) };
            let ca = vx[a].1;
            let cb = vx[b].1;
            let chainb = std::mem::take(&mut chains[cb]);
            for &v in &chainb {
                vx[v].1 = ca;
            }
            let chaina = &mut chains[ca];
            if chaina[0] == a && chainb[0] == b {
                for v in chainb {
                    chaina.insert(0, v);
                }
            } else if chaina[0] == a {
                chaina.splice(0..0, chainb);
            } else if chainb[0] == b {
                chaina.extend(chainb);
            } else {
                let pos = chaina.len();
                for v in chainb {
                    chaina.insert(pos, v);
                }
            }
        }

        if chains[0].len() == matrix.num_colors {
            break;
        }
    }

    // Since zero-weight edges are skipped we may not have a complete chain yet.
    // Join all remaining chains and add any unvisited vertices to complete the remapping.
    chains
        .into_iter()
        .flatten()
        .chain(
            vx.into_iter()
                .enumerate()
                .filter_map(|(i, v)| if v.0 == 0 { Some(i) } else { None }),
        )
        .collect()
}

// Pairwise swap: for each pair (a, b), swap if it reduces cost.
// This is an effective means of refining the result of another algorithm. It's currently rather
// brutish and not very efficient - there are probably ways it could be optimized, or perhaps the
// ezeng algorithm could be modified to achieve a similar effect without needing this step at all.
//
// `max_dist` limits the distance between pairs to consider to keep it performant. A value of 1 is
// quite fast and still provides a good improvement, but higher values can be a little better.
fn pairwise_swap_search(remapping: &mut [usize], matrix: &CoOccurrenceMatrix, max_dist: u8) {
    let num_colors = remapping.len();
    let b_limit = max_dist as usize + 1;

    // Keep iterating as long as at least two swaps were made
    // When we're down to only one then the chance of any further improvement is practically nil
    let mut swaps = 2;
    while swaps >= 2 {
        swaps = 0;
        for a in 0..num_colors - 1 {
            for b in (a + 1)..(a + b_limit).min(num_colors) {
                let va = remapping[a];
                let vb = remapping[b];
                let row_a = matrix.row(va);
                let row_b = matrix.row(vb);
                let mut delta: i64 = 0;
                // Dist diff is calculated as: (b - i).abs() - (a - i).abs()
                // Split by index ranges so we can simplify the calculation and avoid checks to skip a and b
                let dist = (b - a) as i64;
                for &vi in &remapping[..a] {
                    let weight_diff = row_a[vi] as i64 - row_b[vi] as i64;
                    delta += weight_diff * dist;
                }
                for (off, &vi) in remapping[(a + 1)..b].iter().enumerate() {
                    let i = (a + 1 + off) as i64;
                    let weight_diff = row_a[vi] as i64 - row_b[vi] as i64;
                    let dist_diff = (a + b) as i64 - 2 * i;
                    delta += weight_diff * dist_diff;
                }
                for &vi in &remapping[(b + 1)..] {
                    let weight_diff = row_a[vi] as i64 - row_b[vi] as i64;
                    delta -= weight_diff * dist;
                }
                if delta < 0 {
                    remapping.swap(a, b);
                    swaps += 1;
                }
            }
        }
    }
}

/// The co-occurrence matrix records the number of times each color is adjacent to every other color.
#[derive(Debug)]
pub struct CoOccurrenceMatrix {
    num_colors: usize,
    data: Vec<u32>,
    weighted_edges: Vec<(u8, u8, u32)>,
}
impl CoOccurrenceMatrix {
    /// Construct a co-occurrence matrix from the given image, or return None if it is not supported.
    ///
    /// Pre-condition: The palette must match the image data, with no unused or missing entries.
    /// I.e., the image must have been processed with `reduced_palette` first.
    pub fn from(png: &PngImage) -> Option<Self> {
        if png.ihdr.bit_depth != BitDepth::Eight {
            return None;
        }
        let num_colors = match &png.ihdr.color_type {
            // Images with only two colors will remain unchanged from previous luma sort
            ColorType::Indexed { palette } if palette.len() > 2 => palette.len(),
            _ => return None,
        };
        let data = Self::build(num_colors, png);
        let weighted_edges = Self::weighted_edges(num_colors, &data);
        Some(Self {
            num_colors,
            data,
            weighted_edges,
        })
    }

    /// Construct the co-occurrence data
    fn build(num_colors: usize, png: &PngImage) -> Vec<u32> {
        // A flat array seems to perform better than a 2D array for ezeng and swaps
        let mut data = vec![0; num_colors * num_colors];
        let mut prev: Option<ScanLine> = None;
        for line in png.scan_lines(false) {
            let mut prev_val = None;
            for i in 0..line.data.len() {
                let val = line.data[i] as usize;
                if let Some(prev_val) = prev_val.replace(val) {
                    data[prev_val * num_colors + val] += 1;
                }
                // Use safe access of the previous line bytes in case of interlacing where the line may be shorter.
                // Note: Since filtering doesn't apply across interlacing passes it could be argued that we shouldn't
                // use the previous line on a new pass. However, doing so could theoretically allow colors to be
                // "isolated" and if no edges are formed then zeng doesn't have a starting point. It's easiest to just
                // always use the previous line to ensure no possibility of isolation can occur.
                if let Some(&prev_val) = prev.as_ref().and_then(|l| l.data.get(i)) {
                    data[prev_val as usize * num_colors + val] += 1;
                }
            }
            prev = Some(line);
        }

        // Make the matrix symmetrical - this is faster to do afterward than maintaining symmetry during counting
        for i in 0..num_colors {
            let row_start = i * num_colors;
            for j in 0..=i {
                let opposite = j * num_colors + i;
                data[row_start + j] += data[opposite];
                data[opposite] = data[row_start + j];
            }
        }
        data
    }

    /// Calculate edge list sorted by weight
    fn weighted_edges(num_colors: usize, data: &[u32]) -> Vec<(u8, u8, u32)> {
        let mut edges = Vec::new();
        for i in 0..num_colors {
            let row = &data[(i * num_colors)..];
            for (j, &val) in row.iter().enumerate().take(i) {
                // For performance, skip zero-weight edges since they aren't really edges
                if val > 0 {
                    edges.push((j as u8, i as u8, val));
                }
            }
        }
        edges.sort_by(|(_, _, w1), (_, _, w2)| w2.cmp(w1));
        edges
    }

    /// Get a row of the matrix
    #[inline]
    fn row(&self, row: usize) -> &[u32] {
        let start = row * self.num_colors;
        &self.data[start..(start + self.num_colors)]
    }

    /// Find the most popular color in the matrix, along with its count
    fn most_popular_color(&self) -> (usize, u32) {
        let mut best = (0, 0);
        for i in 0..self.num_colors {
            let sum: u32 = self.row(i).iter().sum();
            if sum > best.1 {
                best = (i, sum);
            }
        }
        // Each color is counted 4 times in the matrix, so divide to get the actual count
        // This is not 100% accurate for colors on the edge of the image, but it's close enough for our purposes
        best.1 /= 4;
        best
    }
}