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
53pub 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 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#[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 let rgba = vld4q_u8(data.add(base));
90 let r = rgba.0;
91 let g = rgba.1;
92 let b = rgba.2;
93
94 let ne_rg = vmvnq_u8(vceqq_u8(r, g));
96 let ne_rb = vmvnq_u8(vceqq_u8(r, b));
98 let changed = vorrq_u8(ne_rg, ne_rb);
100
101 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 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#[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
126fn 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 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
175const TOUCHED_DELTA: f64 = 16.0;
181
182const TOUCHED_SHARE_FLOOR: f64 = 0.45;
185
186pub struct ChangeDensity {
195 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 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
255pub 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 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
311pub 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 let closed = morph_close(mask, width, height);
321
322 let labels = label_connected_components(&closed, width, height);
324
325 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 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 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 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 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 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 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]; 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 let mut mask = vec![false; 10000]; 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 let mut mask = vec![false; 10000]; for y in 40..50 {
518 for x in 40..48 {
519 mask[y * 100 + x] = true;
520 }
521 }
522 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 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 let mut mask = vec![false; 50 * 20]; 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 for size in [1, 15, 16, 17, 31, 32, 33, 64, 100] {
562 let mut data = vec![128u8; size * 4]; for i in (0..size).step_by(3) {
565 data[i * 4] = 255; }
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}