Skip to main content

blazediff_interpret/region/
mod.rs

1mod label_extract;
2mod morphology;
3
4use super::types::BoundingBox;
5use blazediff_shared::yiq::color_delta;
6use blazediff_shared::Image;
7use label_extract::extract_labeled_regions;
8use morphology::morph_close;
9
10struct UnionFind {
11    parent: Vec<u32>,
12    rank: Vec<u8>,
13}
14
15impl UnionFind {
16    fn new(size: usize) -> Self {
17        Self {
18            parent: (0..size as u32).collect(),
19            rank: vec![0; size],
20        }
21    }
22
23    fn find(&mut self, mut x: u32) -> u32 {
24        while self.parent[x as usize] != x {
25            self.parent[x as usize] = self.parent[self.parent[x as usize] as usize];
26            x = self.parent[x as usize];
27        }
28        x
29    }
30
31    fn union(&mut self, a: u32, b: u32) {
32        let ra = self.find(a);
33        let rb = self.find(b);
34        if ra == rb {
35            return;
36        }
37        match self.rank[ra as usize].cmp(&self.rank[rb as usize]) {
38            std::cmp::Ordering::Less => self.parent[ra as usize] = rb,
39            std::cmp::Ordering::Greater => self.parent[rb as usize] = ra,
40            std::cmp::Ordering::Equal => {
41                self.parent[rb as usize] = ra;
42                self.rank[ra as usize] += 1;
43            }
44        }
45    }
46}
47
48pub struct ComponentInfo {
49    pub bbox: BoundingBox,
50    pub pixel_count: u32,
51}
52
53/// Extract change mask from output image: a pixel is changed if it's not grayscale (R != G or R != B).
54pub fn extract_change_mask(output_data: &[u8], width: u32, height: u32) -> Vec<bool> {
55    let total = (width * height) as usize;
56    let mut mask = vec![false; total];
57
58    #[cfg(target_arch = "aarch64")]
59    {
60        // SAFETY: aarch64 always has NEON
61        unsafe { extract_mask_neon(output_data.as_ptr(), &mut mask, total) };
62        return mask;
63    }
64
65    #[cfg(not(target_arch = "aarch64"))]
66    {
67        for i in 0..total {
68            let pos = i * 4;
69            let r = output_data[pos];
70            let g = output_data[pos + 1];
71            let b = output_data[pos + 2];
72            mask[i] = r != g || r != b;
73        }
74        mask
75    }
76}
77
78/// NEON-accelerated change mask extraction. Processes 16 pixels at a time.
79#[cfg(target_arch = "aarch64")]
80unsafe fn extract_mask_neon(data: *const u8, mask: &mut [bool], count: usize) {
81    use std::arch::aarch64::*;
82
83    let chunks = count / 16;
84    let mask_ptr = mask.as_mut_ptr() as *mut u8;
85
86    for i in 0..chunks {
87        let base = i * 16 * 4;
88        // Load 64 bytes (16 RGBA pixels) as 4 interleaved channels
89        let rgba = vld4q_u8(data.add(base));
90        let r = rgba.0;
91        let g = rgba.1;
92        let b = rgba.2;
93
94        // R != G
95        let ne_rg = vmvnq_u8(vceqq_u8(r, g));
96        // R != B
97        let ne_rb = vmvnq_u8(vceqq_u8(r, b));
98        // changed = (R != G) | (R != B)
99        let changed = vorrq_u8(ne_rg, ne_rb);
100
101        // Convert 0xFF to 0x01 for bool representation
102        let ones = vdupq_n_u8(1);
103        let result = vandq_u8(changed, ones);
104
105        vst1q_u8(mask_ptr.add(i * 16), result);
106    }
107
108    // Scalar tail
109    let processed = chunks * 16;
110    for i in processed..count {
111        let pos = i * 4;
112        let r = *data.add(pos);
113        let g = *data.add(pos + 1);
114        let b = *data.add(pos + 2);
115        mask[i] = r != g || r != b;
116    }
117}
118
119/// Test-only: CC labeling without morph close, for unit testing the CC algorithm directly.
120#[cfg(test)]
121fn find_connected_components(mask: &[bool], width: u32, height: u32) -> Vec<ComponentInfo> {
122    let labels = label_connected_components(mask, width, height);
123    extract_labeled_regions(&labels, mask, width)
124}
125
126/// Label each foreground pixel with its connected component ID (4-connectivity).
127/// Returns a label map: 0 = background, >0 = component label.
128fn label_connected_components(mask: &[bool], width: u32, height: u32) -> Vec<i32> {
129    let w = width as usize;
130    let h = height as usize;
131    let total = w * h;
132    let mut labels = vec![0i32; total];
133
134    if total == 0 {
135        return labels;
136    }
137
138    let mut uf = UnionFind::new(total);
139
140    for y in 0..h {
141        for x in 0..w {
142            let idx = y * w + x;
143            if !mask[idx] {
144                continue;
145            }
146            if x > 0 && mask[idx - 1] {
147                uf.union(idx as u32, (idx - 1) as u32);
148            }
149            if y > 0 && mask[idx - w] {
150                uf.union(idx as u32, (idx - w) as u32);
151            }
152        }
153    }
154
155    // Map roots to sequential labels
156    let mut root_to_label: std::collections::HashMap<u32, i32> = std::collections::HashMap::new();
157    let mut next_label = 1i32;
158
159    for i in 0..total {
160        if !mask[i] {
161            continue;
162        }
163        let root = uf.find(i as u32);
164        let label = *root_to_label.entry(root).or_insert_with(|| {
165            let l = next_label;
166            next_label += 1;
167            l
168        });
169        labels[i] = label;
170    }
171
172    labels
173}
174
175/// Squared YIQ delta above which a pixel counts as touched at all. Far below
176/// the diff's own threshold on purpose: the question here is not what changed
177/// enough to report, it is whether the pixels *between* two reported patches
178/// were left alone. 16.0 is a YIQ-weighted distance of 4, a perceptual delta
179/// of roughly 0.007.
180const TOUCHED_DELTA: f64 = 16.0;
181
182/// Share of a bounding box that must be touched for the patches inside it to
183/// count as fragments of one change.
184const TOUCHED_SHARE_FLOOR: f64 = 0.45;
185
186/// How much of any rectangle differs between the two images at all, answered
187/// in constant time from a summed-area table.
188///
189/// Merging patches is a question about the gaps between them, and the change
190/// mask cannot answer it: everything below the diff threshold reads as
191/// background there. A regenerated or recolored area leaves a faint delta
192/// across the whole of itself, so its patches sit in touched space; two
193/// distinct edits are separated by pixels that are identical in both images.
194pub struct ChangeDensity {
195    /// Inclusive prefix sums of touched pixels, `(width + 1) * (height + 1)`.
196    sums: Vec<u32>,
197    width: usize,
198}
199
200impl ChangeDensity {
201    pub fn new(img1: &Image, img2: &Image) -> Self {
202        let width = img1.width as usize;
203        let height = img1.height as usize;
204        let pixels1 = img1.as_u32();
205        let pixels2 = img2.as_u32();
206        let stride = width + 1;
207        let mut sums = vec![0u32; stride * (height + 1)];
208
209        for y in 0..height {
210            let mut row_total = 0u32;
211            for x in 0..width {
212                let index = y * width + x;
213                if color_delta(pixels1[index], pixels2[index], index).abs() > TOUCHED_DELTA {
214                    row_total += 1;
215                }
216                sums[(y + 1) * stride + x + 1] = sums[y * stride + x + 1] + row_total;
217            }
218        }
219
220        Self { sums, width }
221    }
222
223    /// Share of `bbox` that differs between the two images, in `0.0..=1.0`.
224    pub fn share(&self, bbox: &BoundingBox) -> f64 {
225        let area = (bbox.width as f64) * (bbox.height as f64);
226        if area <= 0.0 {
227            return 0.0;
228        }
229        let stride = self.width + 1;
230        let height = self.sums.len() / stride - 1;
231        let x0 = (bbox.x as usize).min(self.width);
232        let y0 = (bbox.y as usize).min(height);
233        let x1 = ((bbox.x + bbox.width) as usize).min(self.width);
234        let y1 = ((bbox.y + bbox.height) as usize).min(height);
235        let touched = self.sums[y1 * stride + x1] + self.sums[y0 * stride + x0]
236            - self.sums[y0 * stride + x1]
237            - self.sums[y1 * stride + x0];
238        touched as f64 / area
239    }
240}
241
242fn union(a: &BoundingBox, b: &BoundingBox) -> BoundingBox {
243    let x = a.x.min(b.x);
244    let y = a.y.min(b.y);
245    let right = (a.x + a.width).max(b.x + b.width);
246    let bottom = (a.y + a.height).max(b.y + b.height);
247    BoundingBox {
248        x,
249        y,
250        width: right - x,
251        height: bottom - y,
252    }
253}
254
255/// Merge components whose bounding boxes overlap (or sit within `slack`
256/// pixels of each other) into single regions. Fragmented detections — an
257/// inpainted photo region shattered into dozens of patches by the diff —
258/// have heavily interleaved bboxes, while genuinely separate changes do not,
259/// so bbox proximity is a reliable merge criterion where a larger
260/// morphological radius would bridge unrelated regions across background.
261///
262/// Proximity alone is not enough, because every merge widens the box and a
263/// wider box reaches further: on a dense screenshot single linkage walks from
264/// one change to the next until the region spans half the image. A merge is
265/// therefore refused unless [`ChangeDensity`] finds the enclosing box mostly
266/// touched, which is true of one change scattered into patches and false of
267/// two changes with untouched background between them.
268pub fn merge_overlapping_components(
269    mut components: Vec<ComponentInfo>,
270    slack_x: u32,
271    slack_y: u32,
272    density: &ChangeDensity,
273) -> Vec<ComponentInfo> {
274    let near = |a: &BoundingBox, b: &BoundingBox| -> bool {
275        a.x <= b.x + b.width + slack_x
276            && b.x <= a.x + a.width + slack_x
277            && a.y <= b.y + b.height + slack_y
278            && b.y <= a.y + a.height + slack_y
279    };
280
281    loop {
282        let mut merged_any = false;
283        let mut result: Vec<ComponentInfo> = Vec::with_capacity(components.len());
284        'outer: for component in components {
285            for existing in &mut result {
286                if !near(&existing.bbox, &component.bbox) {
287                    continue;
288                }
289                let merged_bbox = union(&existing.bbox, &component.bbox);
290                // A component that already sits inside the region adds no
291                // background, whatever the density says.
292                if merged_bbox != existing.bbox && density.share(&merged_bbox) < TOUCHED_SHARE_FLOOR
293                {
294                    continue;
295                }
296                existing.bbox = merged_bbox;
297                existing.pixel_count += component.pixel_count;
298                merged_any = true;
299                continue 'outer;
300            }
301            result.push(component);
302        }
303        components = result;
304        if !merged_any {
305            components.sort_by(|a, b| b.pixel_count.cmp(&a.pixel_count));
306            return components;
307        }
308    }
309}
310
311/// Full region detection pipeline:
312/// binary mask → morph close → connected components → extract with original mask
313pub fn detect_regions(mask: &[bool], width: u32, height: u32) -> Vec<ComponentInfo> {
314    let total = (width * height) as usize;
315    if total == 0 || !mask.iter().any(|&m| m) {
316        return Vec::new();
317    }
318
319    // 1. Morphological close to bridge small gaps
320    let closed = morph_close(mask, width, height);
321
322    // 2. Connected components on closed mask
323    let labels = label_connected_components(&closed, width, height);
324
325    // 3. Extract regions using original mask for pixel counts
326    extract_labeled_regions(&labels, mask, width)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::test_helpers::{fill_block, make_solid_image};
333
334    fn comp(x: u32, y: u32, w: u32, h: u32, pixels: u32) -> ComponentInfo {
335        ComponentInfo {
336            bbox: BoundingBox {
337                x,
338                y,
339                width: w,
340                height: h,
341            },
342            pixel_count: pixels,
343        }
344    }
345
346    #[test]
347    fn change_density_share_matches_brute_force() {
348        // A recolored strip down the middle; the background is untouched.
349        let img1 = make_solid_image(40, 30, 10, 10, 10);
350        let mut img2 = make_solid_image(40, 30, 10, 10, 10);
351        fill_block(&mut img2, 12, 0, 8, 30, 200, 40, 40);
352        let density = ChangeDensity::new(&img1, &img2);
353
354        // The strip is fully touched, the whole image is 8/40 touched.
355        let strip = BoundingBox {
356            x: 12,
357            y: 0,
358            width: 8,
359            height: 30,
360        };
361        assert!((density.share(&strip) - 1.0).abs() < 1e-9);
362        let full = BoundingBox {
363            x: 0,
364            y: 0,
365            width: 40,
366            height: 30,
367        };
368        assert!((density.share(&full) - 8.0 / 40.0).abs() < 1e-9);
369        // An untouched corner is empty.
370        let corner = BoundingBox {
371            x: 0,
372            y: 0,
373            width: 8,
374            height: 8,
375        };
376        assert_eq!(density.share(&corner), 0.0);
377    }
378
379    #[test]
380    fn merge_bridges_fragments_of_one_change() {
381        // Two nearby patches with the gap between them also changed: one
382        // fragmented change, so the union is dense and they merge.
383        let img1 = make_solid_image(60, 20, 10, 10, 10);
384        let mut img2 = make_solid_image(60, 20, 10, 10, 10);
385        fill_block(&mut img2, 5, 5, 32, 10, 220, 30, 30);
386        let density = ChangeDensity::new(&img1, &img2);
387
388        // 8px gap between the boxes, within the 12px slack.
389        let components = vec![comp(5, 5, 12, 10, 120), comp(25, 5, 12, 10, 120)];
390        let merged = merge_overlapping_components(components, 12, 8, &density);
391        assert_eq!(merged.len(), 1, "dense union should merge");
392    }
393
394    #[test]
395    fn merge_keeps_distinct_changes_apart() {
396        // Two distinct changes, each a thin mark inside a larger box with
397        // untouched background around it — the shape a map label or a table
398        // cell takes. The boxes sit within the 12px slack, so proximity alone
399        // would merge them; the sparse union must stop it.
400        let img1 = make_solid_image(60, 20, 10, 10, 10);
401        let mut img2 = make_solid_image(60, 20, 10, 10, 10);
402        fill_block(&mut img2, 7, 5, 1, 12, 220, 30, 30);
403        fill_block(&mut img2, 20, 5, 1, 12, 30, 30, 220);
404        let density = ChangeDensity::new(&img1, &img2);
405
406        let components = vec![comp(5, 5, 10, 12, 12), comp(18, 5, 10, 12, 12)];
407        let merged = merge_overlapping_components(components, 12, 8, &density);
408        assert_eq!(merged.len(), 2, "sparse union should stay split");
409    }
410
411    #[test]
412    fn test_extract_change_mask_gray_pixels() {
413        let data = vec![128, 128, 128, 255, 128, 128, 128, 255];
414        let mask = extract_change_mask(&data, 2, 1);
415        assert_eq!(mask, vec![false, false]);
416    }
417
418    #[test]
419    fn test_extract_change_mask_colored_pixels() {
420        let data = vec![255, 0, 0, 255, 128, 128, 128, 255];
421        let mask = extract_change_mask(&data, 2, 1);
422        assert_eq!(mask, vec![true, false]);
423    }
424
425    #[test]
426    fn test_find_connected_components_empty() {
427        let mask = vec![false; 9];
428        let components = find_connected_components(&mask, 3, 3);
429        assert!(components.is_empty());
430    }
431
432    #[test]
433    fn test_find_connected_components_single_pixel() {
434        let mut mask = vec![false; 9];
435        mask[4] = true;
436        let components = find_connected_components(&mask, 3, 3);
437        assert_eq!(components.len(), 1);
438        assert_eq!(components[0].pixel_count, 1);
439        assert_eq!(
440            components[0].bbox,
441            BoundingBox {
442                x: 1,
443                y: 1,
444                width: 1,
445                height: 1,
446            }
447        );
448    }
449
450    #[test]
451    fn test_find_connected_components_two_separate() {
452        let mask = vec![true, false, false, false, true];
453        let components = find_connected_components(&mask, 5, 1);
454        assert_eq!(components.len(), 2);
455    }
456
457    #[test]
458    fn test_find_connected_components_l_shape() {
459        let mask = vec![true, false, false, true, false, false, true, true, true];
460        let components = find_connected_components(&mask, 3, 3);
461        assert_eq!(components.len(), 1);
462        assert_eq!(components[0].pixel_count, 5);
463        assert_eq!(
464            components[0].bbox,
465            BoundingBox {
466                x: 0,
467                y: 0,
468                width: 3,
469                height: 3,
470            }
471        );
472    }
473
474    #[test]
475    fn test_detect_regions_empty() {
476        let mask = vec![false; 100];
477        let regions = detect_regions(&mask, 10, 10);
478        assert!(regions.is_empty());
479    }
480
481    #[test]
482    fn test_detect_regions_single_blob() {
483        let mut mask = vec![false; 10000]; // 100x100
484        for y in 40..60 {
485            for x in 40..60 {
486                mask[y * 100 + x] = true;
487            }
488        }
489        let regions = detect_regions(&mask, 100, 100);
490        assert_eq!(regions.len(), 1);
491        assert_eq!(regions[0].pixel_count, 400);
492    }
493
494    #[test]
495    fn test_detect_regions_two_separate_blobs() {
496        // Two blobs far apart - should stay separate
497        let mut mask = vec![false; 10000]; // 100x100
498        for y in 5..15 {
499            for x in 5..15 {
500                mask[y * 100 + x] = true;
501            }
502        }
503        for y in 80..90 {
504            for x in 80..90 {
505                mask[y * 100 + x] = true;
506            }
507        }
508        let regions = detect_regions(&mask, 100, 100);
509        assert_eq!(regions.len(), 2);
510    }
511
512    #[test]
513    fn test_detect_regions_nearby_blobs_bridged() {
514        // Two blobs with small gap - morph close should bridge
515        let mut mask = vec![false; 10000]; // 100x100
516                                           // Blob 1
517        for y in 40..50 {
518            for x in 40..48 {
519                mask[y * 100 + x] = true;
520            }
521        }
522        // Blob 2 - gap of 3px
523        for y in 40..50 {
524            for x in 51..60 {
525                mask[y * 100 + x] = true;
526            }
527        }
528        let regions = detect_regions(&mask, 100, 100);
529        // With adaptive radius=2 for 100x100, a 3px gap should be bridged
530        assert_eq!(
531            regions.len(),
532            1,
533            "nearby blobs should be merged by morph close"
534        );
535    }
536
537    #[test]
538    fn test_detect_regions_connected_bridge() {
539        // Two blobs connected by a 1px bridge - single connected component
540        let mut mask = vec![false; 50 * 20]; // 50x20
541        for y in 6..14 {
542            for x in 2..10 {
543                mask[y * 50 + x] = true;
544            }
545        }
546        for y in 6..14 {
547            for x in 30..38 {
548                mask[y * 50 + x] = true;
549            }
550        }
551        for x in 10..30 {
552            mask[10 * 50 + x] = true;
553        }
554        let regions = detect_regions(&mask, 50, 20);
555        assert_eq!(regions.len(), 1, "connected blobs should be one region");
556    }
557
558    #[test]
559    fn test_extract_mask_simd_matches_scalar() {
560        // Test with various sizes to exercise SIMD + scalar tail
561        for size in [1, 15, 16, 17, 31, 32, 33, 64, 100] {
562            let mut data = vec![128u8; size * 4]; // all gray
563                                                  // Sprinkle some colored pixels
564            for i in (0..size).step_by(3) {
565                data[i * 4] = 255; // R=255, G=128, B=128 → changed
566            }
567
568            let mask = extract_change_mask(&data, size as u32, 1);
569
570            for i in 0..size {
571                let r = data[i * 4];
572                let g = data[i * 4 + 1];
573                let b = data[i * 4 + 2];
574                let expected = r != g || r != b;
575                assert_eq!(
576                    mask[i], expected,
577                    "mismatch at pixel {i} (size={size}): r={r} g={g} b={b}"
578                );
579            }
580        }
581    }
582}