use crate::core::{Numa, Pix, PixelDepth, Pta};
use crate::morph::sequence::morph_sequence;
use crate::recog::skew::SkewDetectOptions;
use crate::recog::{RecogError, RecogResult};
#[derive(Debug, Clone)]
pub struct BaselineOptions {
pub min_block_width: u32,
pub peak_threshold: u32,
pub num_slices: u32,
}
impl Default for BaselineOptions {
fn default() -> Self {
Self {
min_block_width: 80,
peak_threshold: 80,
num_slices: 10,
}
}
}
impl BaselineOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_min_block_width(mut self, width: u32) -> Self {
self.min_block_width = width;
self
}
pub fn with_peak_threshold(mut self, threshold: u32) -> Self {
self.peak_threshold = threshold;
self
}
pub fn with_num_slices(mut self, slices: u32) -> Self {
self.num_slices = slices;
self
}
fn validate(&self) -> RecogResult<()> {
if self.min_block_width == 0 {
return Err(RecogError::InvalidParameter(
"min_block_width must be positive".to_string(),
));
}
if self.peak_threshold == 0 || self.peak_threshold > 100 {
return Err(RecogError::InvalidParameter(
"peak_threshold must be between 1 and 100".to_string(),
));
}
if self.num_slices < 2 || self.num_slices > 20 {
return Err(RecogError::InvalidParameter(
"num_slices must be between 2 and 20".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct BaselineResult {
pub baselines: Vec<i32>,
pub endpoints: Option<Vec<(i32, i32, i32, i32)>>,
}
const MIN_DIST_FROM_PEAK: i32 = 30;
const PEAK_THRESHOLD_RATIO: i32 = 80;
const ZERO_THRESHOLD_RATIO: i32 = 100;
pub fn find_baselines(pix: &Pix, options: &BaselineOptions) -> RecogResult<BaselineResult> {
options.validate()?;
let binary = ensure_binary(pix)?;
let h = binary.height();
let preprocessed = match morph_sequence(&binary, "c25.1 + e15.1") {
Ok(p) => p,
Err(_) => binary.deep_clone(), };
let row_sums = compute_row_sums(&preprocessed);
let diff = compute_differential(&row_sums);
let baselines = find_peaks(&diff, options.peak_threshold);
let endpoints = find_endpoints(&binary, &baselines, options.min_block_width);
let (filtered_baselines, filtered_endpoints) = filter_baselines(baselines, endpoints, h);
Ok(BaselineResult {
baselines: filtered_baselines,
endpoints: Some(filtered_endpoints),
})
}
#[allow(dead_code)]
fn get_slice_skew_angles(pix: &Pix, num_slices: u32, sweep_range: f32) -> RecogResult<Vec<f32>> {
if !(2..=20).contains(&num_slices) {
return Err(RecogError::InvalidParameter(
"num_slices must be between 2 and 20".to_string(),
));
}
let binary = ensure_binary(pix)?;
let h = binary.height();
let slice_height = h / num_slices;
if slice_height < 10 {
return Err(RecogError::ImageTooSmall {
min_width: pix.width(),
min_height: num_slices * 10,
actual_width: pix.width(),
actual_height: h,
});
}
let mut angles = Vec::with_capacity(num_slices as usize);
let skew_options = SkewDetectOptions::default()
.with_sweep_range(sweep_range)
.with_sweep_reduction(2)
.with_bs_reduction(1);
let overlap = slice_height / 2;
for i in 0..num_slices {
let y_start = if i == 0 {
0
} else {
(i * slice_height).saturating_sub(overlap)
};
let y_end = if i == num_slices - 1 {
h
} else {
((i + 1) * slice_height + overlap).min(h)
};
let slice = extract_horizontal_slice(&binary, y_start, y_end)?;
match crate::recog::skew::find_skew(&slice, &skew_options) {
Ok(result) => angles.push(result.angle),
Err(_) => angles.push(0.0), }
}
Ok(angles)
}
#[allow(dead_code)]
fn deskew_local_baseline_opts(
pix: &Pix,
options: &BaselineOptions,
skew_options: &SkewDetectOptions,
) -> RecogResult<Pix> {
options.validate()?;
skew_options.validate()?;
let binary = ensure_binary(pix)?;
let angles = get_slice_skew_angles(&binary, options.num_slices, skew_options.sweep_range)?;
let angle_range = angles.iter().cloned().fold(f32::NAN, f32::max)
- angles.iter().cloned().fold(f32::NAN, f32::min);
if angle_range < 0.5 || angles.is_empty() {
let avg_angle: f32 = angles.iter().sum::<f32>() / angles.len().max(1) as f32;
return crate::recog::skew::deskew_by_angle(pix, avg_angle);
}
apply_local_deskew(pix, &angles)
}
#[allow(clippy::too_many_arguments)]
pub fn deskew_local(
pix: &Pix,
nslice: u32,
reduction: u32,
redsweep: u32,
redsearch: u32,
sweep_range: f32,
sweep_delta: f32,
min_bs_delta: f32,
) -> RecogResult<Pix> {
let nslice = if !(2..=20).contains(&nslice) {
10
} else {
nslice
};
let sweep_red = match redsweep {
1 | 2 | 4 | 8 => redsweep,
_ => 2,
};
let _ = redsearch; let sweep_range = if sweep_range <= 0.0 { 7.0 } else { sweep_range };
let sweep_delta = if sweep_delta <= 0.0 { 1.0 } else { sweep_delta };
let min_bs_delta = if min_bs_delta <= 0.0 {
0.01
} else {
min_bs_delta
};
let red = match reduction {
1 | 2 | 4 | 8 => reduction,
_ => sweep_red,
};
let (_, a, b) =
get_local_skew_angles(pix, nslice, red, sweep_range, sweep_delta, min_bs_delta)?;
let h = pix.height() as f32;
let angles: Vec<f32> = (0..nslice as usize)
.map(|i| {
let y_center = (i as f32 + 0.5) / nslice as f32 * h;
a * y_center + b
})
.collect();
apply_local_deskew(pix, &angles)
}
pub fn get_local_skew_transform(
nslice: u32,
ny: u32,
reduction: u32,
angles: &[f32],
cx: f32,
cy: f32,
) -> RecogResult<Pta> {
if angles.len() != nslice as usize {
return Err(RecogError::InvalidParameter(format!(
"angles length {} must equal nslice {}",
angles.len(),
nslice
)));
}
if nslice == 0 || ny == 0 {
return Err(RecogError::InvalidParameter(
"nslice and ny must be positive".to_string(),
));
}
let h_full = cy * 2.0 * reduction as f32;
let slice_h = h_full / nslice as f32;
let mut pta = Pta::with_capacity(nslice as usize * ny as usize);
for (i, &angle_deg) in angles.iter().enumerate() {
let tan_a = angle_deg.to_radians().tan();
for j in 0..ny as usize {
let y_frac = (j as f32 + 0.5) / ny as f32;
let y = (i as f32 + y_frac) * slice_h;
let x_shift = (y - cy) * tan_a;
pta.push(cx + x_shift, y);
}
}
Ok(pta)
}
pub fn get_local_skew_angles(
pix: &Pix,
nslice: u32,
reduction: u32,
sweep_range: f32,
sweep_delta: f32,
min_bs_delta: f32,
) -> RecogResult<(Numa, f32, f32)> {
let nslice = if !(2..=20).contains(&nslice) {
10
} else {
nslice
};
let sweep_reduction = match reduction {
1 | 2 | 4 | 8 => reduction,
_ => 2,
};
let bs_reduction = (sweep_reduction / 2).max(1);
let sweep_range = if sweep_range <= 0.0 { 7.0 } else { sweep_range };
let sweep_delta = if sweep_delta <= 0.0 { 1.0 } else { sweep_delta };
let min_bs_delta = if min_bs_delta <= 0.0 {
0.01
} else {
min_bs_delta
};
let binary = ensure_binary(pix)?;
let w = binary.width();
let h = binary.height();
let hs = (h / nslice).max(1);
let ovlap = hs / 2;
let skew_opts = crate::recog::skew::SkewDetectOptions {
sweep_range,
sweep_delta,
min_bs_delta,
sweep_reduction,
bs_reduction,
};
const MIN_CONF: f32 = 1.0;
let mut pts: Vec<(f32, f32)> = Vec::new();
for i in 0..nslice {
let y_start = if i == 0 {
0
} else {
(hs * i).saturating_sub(ovlap)
};
let y_end = if i == nslice - 1 {
h
} else {
(hs * (i + 1) + ovlap).min(h)
};
if y_end <= y_start {
continue;
}
let y_center = (y_start + y_end) as f32 / 2.0;
let slice = binary.clip_rectangle(0, y_start, w, y_end - y_start)?;
if let Ok(result) = crate::recog::skew::find_skew(&slice, &skew_opts)
&& result.confidence >= MIN_CONF
{
pts.push((y_center, result.angle));
}
}
let (a, b) = if pts.len() >= 2 {
linear_lsf(&pts)
} else {
(0.0_f32, 0.0_f32)
};
let mut naskew = Numa::with_capacity(h as usize);
for i in 0..h {
naskew.push(a * i as f32 + b);
}
Ok((naskew, a, b))
}
fn linear_lsf(pts: &[(f32, f32)]) -> (f32, f32) {
let n = pts.len() as f32;
let sum_x: f32 = pts.iter().map(|&(x, _)| x).sum();
let sum_y: f32 = pts.iter().map(|&(_, y)| y).sum();
let sum_xx: f32 = pts.iter().map(|&(x, _)| x * x).sum();
let sum_xy: f32 = pts.iter().map(|&(x, y)| x * y).sum();
let denom = n * sum_xx - sum_x * sum_x;
if denom.abs() < 1e-10 {
return (0.0, sum_y / n);
}
let a = (n * sum_xy - sum_x * sum_y) / denom;
let b = (sum_y - a * sum_x) / n;
(a, b)
}
fn ensure_binary(pix: &Pix) -> RecogResult<Pix> {
match pix.depth() {
PixelDepth::Bit1 => Ok(pix.deep_clone()),
PixelDepth::Bit8 => {
let w = pix.width();
let h = pix.height();
let binary = Pix::new(w, h, PixelDepth::Bit1)?;
let mut binary_mut = binary.try_into_mut().unwrap();
for y in 0..h {
for x in 0..w {
let val = pix.get_pixel_unchecked(x, y);
let bit = if val < 128 { 1 } else { 0 };
binary_mut.set_pixel_unchecked(x, y, bit);
}
}
Ok(binary_mut.into())
}
_ => Err(RecogError::UnsupportedDepth {
expected: "1 or 8 bpp",
actual: pix.depth().bits(),
}),
}
}
fn compute_row_sums(pix: &Pix) -> Vec<u32> {
let w = pix.width();
let h = pix.height();
let mut sums = Vec::with_capacity(h as usize);
for y in 0..h {
let mut sum = 0u32;
for x in 0..w {
let val = pix.get_pixel_unchecked(x, y);
if val != 0 {
sum += 1;
}
}
sums.push(sum);
}
sums
}
fn compute_differential(row_sums: &[u32]) -> Vec<i32> {
if row_sums.len() < 2 {
return Vec::new();
}
let mut diff = Vec::with_capacity(row_sums.len() - 1);
for i in 0..row_sums.len() - 1 {
diff.push(row_sums[i] as i32 - row_sums[i + 1] as i32);
}
diff
}
fn find_peaks(diff: &[i32], _threshold_ratio: u32) -> Vec<i32> {
if diff.is_empty() {
return Vec::new();
}
let max_val = diff.iter().cloned().max().unwrap_or(0);
if max_val <= 0 {
return Vec::new();
}
let peak_thresh = max_val / PEAK_THRESHOLD_RATIO;
let zero_thresh = max_val / ZERO_THRESHOLD_RATIO;
let mut baselines = Vec::new();
let mut in_peak = false;
let mut max_in_peak = 0i32;
let mut max_loc = 0i32;
let mut min_to_search = 0i32;
for (i, &val) in diff.iter().enumerate() {
let i = i as i32;
if !in_peak {
if val > peak_thresh {
in_peak = true;
min_to_search = i + MIN_DIST_FROM_PEAK;
max_in_peak = val;
max_loc = i;
}
} else {
if val > max_in_peak {
max_in_peak = val;
max_loc = i;
min_to_search = i + MIN_DIST_FROM_PEAK;
} else if i >= min_to_search && val <= zero_thresh {
in_peak = false;
baselines.push(max_loc);
}
}
}
if in_peak {
baselines.push(max_loc);
}
baselines
}
fn find_endpoints(
pix: &Pix,
baselines: &[i32],
min_width: u32,
) -> Vec<Option<(i32, i32, i32, i32)>> {
let w = pix.width() as i32;
let h = pix.height() as i32;
baselines
.iter()
.map(|&y| {
if y < 0 || y >= h {
return None;
}
let search_range = 5; let mut left_x = w;
let mut right_x = 0i32;
for dy in -search_range..=search_range {
let sy = y + dy;
if sy < 0 || sy >= h {
continue;
}
for x in 0..w {
let val = pix.get_pixel_unchecked(x as u32, sy as u32);
if val != 0 {
left_x = left_x.min(x);
right_x = right_x.max(x);
}
}
}
if right_x - left_x >= min_width as i32 {
Some((left_x, y, right_x, y))
} else {
None
}
})
.collect()
}
#[allow(clippy::type_complexity)]
fn filter_baselines(
baselines: Vec<i32>,
endpoints: Vec<Option<(i32, i32, i32, i32)>>,
_max_y: u32,
) -> (Vec<i32>, Vec<(i32, i32, i32, i32)>) {
let mut filtered_baselines = Vec::new();
let mut filtered_endpoints = Vec::new();
for (baseline, endpoint) in baselines.into_iter().zip(endpoints.into_iter()) {
if let Some(ep) = endpoint {
filtered_baselines.push(baseline);
filtered_endpoints.push(ep);
}
}
(filtered_baselines, filtered_endpoints)
}
#[allow(dead_code)]
fn extract_horizontal_slice(pix: &Pix, y_start: u32, y_end: u32) -> RecogResult<Pix> {
let w = pix.width();
let new_h = y_end - y_start;
if new_h == 0 {
return Err(RecogError::InvalidParameter(
"slice height is zero".to_string(),
));
}
let slice = Pix::new(w, new_h, pix.depth())?;
let mut slice_mut = slice.try_into_mut().unwrap();
for y in 0..new_h {
for x in 0..w {
let val = pix.get_pixel_unchecked(x, y_start + y);
slice_mut.set_pixel_unchecked(x, y, val);
}
}
Ok(slice_mut.into())
}
#[allow(dead_code)]
fn apply_local_deskew(pix: &Pix, angles: &[f32]) -> RecogResult<Pix> {
let w = pix.width();
let h = pix.height();
let num_slices = angles.len();
if num_slices == 0 {
return Ok(pix.deep_clone());
}
let slice_height = h as f32 / num_slices as f32;
let result = Pix::new(w, h, pix.depth())?;
let mut result_mut = result.try_into_mut().unwrap();
for y in 0..h {
for x in 0..w {
result_mut.set_pixel_unchecked(x, y, 0);
}
}
for y in 0..h {
let slice_pos = y as f32 / slice_height;
let slice_idx = (slice_pos as usize).min(num_slices - 1);
let next_idx = (slice_idx + 1).min(num_slices - 1);
let t = slice_pos - slice_idx as f32;
let angle = angles[slice_idx] * (1.0 - t) + angles[next_idx] * t;
let tan_a = angle.to_radians().tan();
for x in 0..w {
let val = pix.get_pixel_unchecked(x, y);
if val != 0 {
let shear = (x as f32 - w as f32 / 2.0) * tan_a;
let new_x = (x as f32 + shear).round() as i32;
if new_x >= 0 && new_x < w as i32 {
result_mut.set_pixel_unchecked(new_x as u32, y, val);
}
}
}
}
Ok(result_mut.into())
}
#[cfg(test)]
mod tests {
use super::*;
fn create_text_like_image(w: u32, h: u32, num_lines: u32, line_height: u32) -> Pix {
let pix = Pix::new(w, h, PixelDepth::Bit1).unwrap();
let mut pix_mut = pix.try_into_mut().unwrap();
let spacing = h / (num_lines + 1);
for line in 1..=num_lines {
let y_base = line * spacing;
for dy in 0..line_height {
let y = y_base + dy;
if y < h {
for x in (w / 10)..(w * 9 / 10) {
pix_mut.set_pixel_unchecked(x, y, 1);
}
}
}
}
pix_mut.into()
}
#[test]
fn test_baseline_options_default() {
let opts = BaselineOptions::default();
assert_eq!(opts.min_block_width, 80);
assert_eq!(opts.peak_threshold, 80);
assert_eq!(opts.num_slices, 10);
}
#[test]
fn test_baseline_options_validation() {
let opts = BaselineOptions::default();
assert!(opts.validate().is_ok());
let invalid = BaselineOptions::default().with_min_block_width(0);
assert!(invalid.validate().is_err());
let invalid = BaselineOptions::default().with_num_slices(1);
assert!(invalid.validate().is_err());
}
#[test]
fn test_compute_row_sums() {
let pix = Pix::new(10, 5, PixelDepth::Bit1).unwrap();
let mut pix_mut = pix.try_into_mut().unwrap();
for x in 0..10 {
pix_mut.set_pixel_unchecked(x, 2, 1);
}
let pix: Pix = pix_mut.into();
let sums = compute_row_sums(&pix);
assert_eq!(sums.len(), 5);
assert_eq!(sums[0], 0);
assert_eq!(sums[1], 0);
assert_eq!(sums[2], 10);
assert_eq!(sums[3], 0);
assert_eq!(sums[4], 0);
}
#[test]
fn test_compute_differential() {
let row_sums = vec![0, 0, 10, 0, 0];
let diff = compute_differential(&row_sums);
assert_eq!(diff.len(), 4);
assert_eq!(diff[0], 0); assert_eq!(diff[1], -10); assert_eq!(diff[2], 10); assert_eq!(diff[3], 0); }
#[test]
fn test_find_baselines() {
let pix = create_text_like_image(400, 300, 5, 10);
let opts = BaselineOptions::default().with_min_block_width(50);
let result = find_baselines(&pix, &opts).unwrap();
assert!(!result.baselines.is_empty());
assert!(result.baselines.len() <= 6);
}
#[test]
fn test_extract_horizontal_slice() {
let pix = Pix::new(100, 100, PixelDepth::Bit1).unwrap();
let slice = extract_horizontal_slice(&pix, 20, 40).unwrap();
assert_eq!(slice.width(), 100);
assert_eq!(slice.height(), 20);
}
#[test]
fn test_get_slice_skew_angles() {
let pix = create_text_like_image(400, 400, 10, 10);
let angles = get_slice_skew_angles(&pix, 4, 5.0).unwrap();
assert_eq!(angles.len(), 4);
for angle in angles {
assert!(angle.abs() < 2.0);
}
}
#[test]
fn test_get_local_skew_angles_returns_numa() {
let pix = create_text_like_image(400, 400, 10, 10);
let (angles, slope, _intercept) =
get_local_skew_angles(&pix, 4, 2, 7.0, 1.0, 0.01).unwrap();
assert_eq!(angles.len(), pix.height() as usize);
assert!(slope.abs() < 1.0);
}
#[test]
fn test_deskew_local_new_api() {
let pix = create_text_like_image(400, 400, 10, 10);
let result = deskew_local(&pix, 4, 2, 2, 1, 7.0, 1.0, 0.01).unwrap();
assert_eq!(result.width(), pix.width());
assert_eq!(result.height(), pix.height());
}
#[test]
fn test_get_local_skew_transform() {
let angles = vec![0.5f32, 0.3, 0.1, -0.1];
let pta = get_local_skew_transform(4, 1, 2, &angles, 200.0, 200.0).unwrap();
assert_eq!(pta.len(), 4);
}
}