use crate::config::DetectionConfig;
const SLOPE_DENOM_FLOOR: f32 = 10.0;
const FREE_MARGIN_FACTOR: f32 = 1.44;
pub(super) struct Grouped {
pub horizontal: Vec<[f32; 4]>,
pub free: Vec<[[f32; 2]; 4]>,
}
#[derive(Clone, Copy)]
struct HBox {
x_min: f32,
x_max: f32,
y_min: f32,
y_max: f32,
ycenter: f32,
height: f32,
}
enum Classified {
Horizontal(HBox),
Free([[f32; 2]; 4]),
}
pub(super) fn group_boxes(boxes: &[[[f32; 2]; 4]], config: &DetectionConfig) -> Grouped {
let mut horizontals: Vec<HBox> = Vec::new();
let mut free: Vec<[[f32; 2]; 4]> = Vec::new();
for corners in boxes {
match classify(corners, config) {
Classified::Horizontal(hbox) => horizontals.push(hbox),
Classified::Free(quad) => free.push(quad),
}
}
horizontals.sort_by(|a, b| a.ycenter.partial_cmp(&b.ycenter).unwrap_or(std::cmp::Ordering::Equal));
let mut horizontal: Vec<[f32; 4]> = Vec::new();
for line in combine_into_lines(&horizontals, config.ycenter_ths) {
horizontal.extend(merge_line(&line, config));
}
Grouped { horizontal, free }
}
fn classify(corners: &[[f32; 2]; 4], config: &DetectionConfig) -> Classified {
let (x1, y1) = (corners[0][0], corners[0][1]);
let (x2, y2) = (corners[1][0], corners[1][1]);
let (x3, y3) = (corners[2][0], corners[2][1]);
let (x4, y4) = (corners[3][0], corners[3][1]);
let slope_up = (y2 - y1) / (x2 - x1).max(SLOPE_DENOM_FLOOR);
let slope_down = (y3 - y4) / (x3 - x4).max(SLOPE_DENOM_FLOOR);
if slope_up.abs().max(slope_down.abs()) < config.slope_ths {
let x_min = x1.min(x2).min(x3).min(x4);
let x_max = x1.max(x2).max(x3).max(x4);
let y_min = y1.min(y2).min(y3).min(y4);
let y_max = y1.max(y2).max(y3).max(y4);
Classified::Horizontal(HBox {
x_min,
x_max,
y_min,
y_max,
ycenter: 0.5 * (y_min + y_max),
height: y_max - y_min,
})
} else {
Classified::Free(expand_free_quad(corners, config.add_margin))
}
}
fn expand_free_quad(corners: &[[f32; 2]; 4], add_margin: f32) -> [[f32; 2]; 4] {
let (x1, y1) = (corners[0][0], corners[0][1]);
let (x2, y2) = (corners[1][0], corners[1][1]);
let (x3, y3) = (corners[2][0], corners[2][1]);
let (x4, y4) = (corners[3][0], corners[3][1]);
let width = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt();
let height = ((x4 - x1).powi(2) + (y4 - y1).powi(2)).sqrt();
let margin = (FREE_MARGIN_FACTOR * add_margin * width.min(height)).trunc();
let theta13 = ((y1 - y3) / (x1 - x3).max(SLOPE_DENOM_FLOOR)).atan().abs();
let theta24 = ((y2 - y4) / (x2 - x4).max(SLOPE_DENOM_FLOOR)).atan().abs();
[
[x1 - theta13.cos() * margin, y1 - theta13.sin() * margin],
[x2 + theta24.cos() * margin, y2 - theta24.sin() * margin],
[x3 + theta13.cos() * margin, y3 + theta13.sin() * margin],
[x4 - theta24.cos() * margin, y4 + theta24.sin() * margin],
]
}
fn combine_into_lines(sorted: &[HBox], ycenter_ths: f32) -> Vec<Vec<HBox>> {
let mut combined: Vec<Vec<HBox>> = Vec::new();
let mut current: Vec<HBox> = Vec::new();
let mut height_sum = 0.0_f32;
let mut ycenter_sum = 0.0_f32;
let mut count = 0.0_f32;
for &hbox in sorted {
let same_line =
!current.is_empty() && (ycenter_sum / count - hbox.ycenter).abs() < ycenter_ths * (height_sum / count);
if current.is_empty() || same_line {
height_sum += hbox.height;
ycenter_sum += hbox.ycenter;
count += 1.0;
} else {
combined.push(std::mem::take(&mut current));
height_sum = hbox.height;
ycenter_sum = hbox.ycenter;
count = 1.0;
}
current.push(hbox);
}
combined.push(current);
combined
}
fn merge_line(line: &[HBox], config: &DetectionConfig) -> Vec<[f32; 4]> {
if line.len() == 1 {
return vec![margin_entry(&line[0], config.add_margin)];
}
let mut sorted: Vec<HBox> = line.to_vec();
sorted.sort_by(|a, b| a.x_min.partial_cmp(&b.x_min).unwrap_or(std::cmp::Ordering::Equal));
group_adjacent(&sorted, config)
.into_iter()
.map(|group| merge_group(&group, config.add_margin))
.collect()
}
fn group_adjacent(sorted: &[HBox], config: &DetectionConfig) -> Vec<Vec<HBox>> {
let mut groups: Vec<Vec<HBox>> = Vec::new();
let mut current: Vec<HBox> = Vec::new();
let mut height_sum = 0.0_f32;
let mut count = 0.0_f32;
let mut running_x_max = 0.0_f32;
for &hbox in sorted {
let mergeable = !current.is_empty()
&& (height_sum / count - hbox.height).abs() < config.height_ths * (height_sum / count)
&& (hbox.x_min - running_x_max) < config.width_ths * (hbox.y_max - hbox.y_min);
if current.is_empty() || mergeable {
height_sum += hbox.height;
count += 1.0;
} else {
groups.push(std::mem::take(&mut current));
height_sum = hbox.height;
count = 1.0;
}
running_x_max = hbox.x_max;
current.push(hbox);
}
if !current.is_empty() {
groups.push(current);
}
groups
}
fn margin_entry(b: &HBox, add_margin: f32) -> [f32; 4] {
let margin = (add_margin * (b.x_max - b.x_min).min(b.y_max - b.y_min)).trunc();
[b.x_min - margin, b.x_max + margin, b.y_min - margin, b.y_max + margin]
}
fn merge_group(group: &[HBox], add_margin: f32) -> [f32; 4] {
if group.len() == 1 {
return margin_entry(&group[0], add_margin);
}
let x_min = group.iter().map(|b| b.x_min).fold(f32::INFINITY, f32::min);
let x_max = group.iter().map(|b| b.x_max).fold(f32::NEG_INFINITY, f32::max);
let y_min = group.iter().map(|b| b.y_min).fold(f32::INFINITY, f32::min);
let y_max = group.iter().map(|b| b.y_max).fold(f32::NEG_INFINITY, f32::max);
let margin = (add_margin * (x_max - x_min).min(y_max - y_min)).trunc();
[x_min - margin, x_max + margin, y_min - margin, y_max + margin]
}
#[cfg(test)]
mod tests {
use super::*;
fn axis_box(x0: f32, x1: f32, y0: f32, y1: f32) -> [[f32; 2]; 4] {
[[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
}
fn config_with(add_margin: f32) -> DetectionConfig {
DetectionConfig {
add_margin,
..DetectionConfig::default()
}
}
#[test]
fn should_classify_level_box_as_horizontal_with_correct_extents() {
let config = config_with(0.0);
let grouped = group_boxes(&[axis_box(10.0, 50.0, 10.0, 30.0)], &config);
assert!(grouped.free.is_empty());
assert_eq!(grouped.horizontal, vec![[10.0, 50.0, 10.0, 30.0]]);
}
#[test]
fn should_classify_steeply_slanted_box_as_free() {
let config = config_with(0.0);
let slanted = [[10.0, 10.0], [50.0, 40.0], [50.0, 60.0], [10.0, 30.0]];
let grouped = group_boxes(&[slanted], &config);
assert!(grouped.horizontal.is_empty());
assert_eq!(grouped.free.len(), 1);
}
#[test]
fn should_merge_two_adjacent_same_height_boxes_on_one_line() {
let config = config_with(0.0);
let left = axis_box(10.0, 50.0, 10.0, 30.0);
let right = axis_box(55.0, 95.0, 10.0, 30.0);
let grouped = group_boxes(&[left, right], &config);
assert_eq!(grouped.horizontal, vec![[10.0, 95.0, 10.0, 30.0]]);
}
#[test]
fn should_keep_vertically_distant_boxes_as_separate_entries() {
let config = config_with(0.0);
let top = axis_box(10.0, 50.0, 10.0, 30.0);
let bottom = axis_box(10.0, 50.0, 200.0, 220.0);
let grouped = group_boxes(&[top, bottom], &config);
assert_eq!(grouped.horizontal.len(), 2);
assert!(grouped.horizontal.contains(&[10.0, 50.0, 10.0, 30.0]));
assert!(grouped.horizontal.contains(&[10.0, 50.0, 200.0, 220.0]));
}
#[test]
fn should_apply_margin_when_add_margin_is_positive() {
let no_margin = group_boxes(&[axis_box(10.0, 50.0, 10.0, 30.0)], &config_with(0.0));
let with_margin = group_boxes(&[axis_box(10.0, 50.0, 10.0, 30.0)], &config_with(0.1));
assert_eq!(no_margin.horizontal, vec![[10.0, 50.0, 10.0, 30.0]]);
assert_eq!(with_margin.horizontal, vec![[8.0, 52.0, 8.0, 32.0]]);
}
#[test]
fn should_return_empty_grouped_for_no_boxes() {
let grouped = group_boxes(&[], &DetectionConfig::default());
assert!(grouped.horizontal.is_empty());
assert!(grouped.free.is_empty());
}
#[test]
fn should_merge_line_of_three_and_keep_distant_line_separate() {
let config = config_with(0.0);
let a = axis_box(10.0, 50.0, 10.0, 30.0);
let b = axis_box(55.0, 95.0, 10.0, 30.0);
let c = axis_box(100.0, 140.0, 10.0, 30.0);
let d = axis_box(10.0, 50.0, 200.0, 220.0);
let grouped = group_boxes(&[a, b, c, d], &config);
assert_eq!(
grouped.horizontal,
vec![[10.0, 140.0, 10.0, 30.0], [10.0, 50.0, 200.0, 220.0]]
);
}
}