diff --git a/.gitignore b/.gitignore index 617b3d6..21e5310 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ Thumbs.db node_modules/ pkg/ +out/ diff --git a/Cargo.lock b/Cargo.lock index dc73e60..ba16c13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -710,6 +710,7 @@ dependencies = [ "glyphnet-core", "glyphnet-cv", "glyphnet-decode", + "glyphnet-ecc", "glyphnet-encode", "glyphnet-render", "glyphnet-testkit", diff --git a/crates/glyphnet-decode/src/lib.rs b/crates/glyphnet-decode/src/lib.rs index bb5aded..ae1d8a5 100644 --- a/crates/glyphnet-decode/src/lib.rs +++ b/crates/glyphnet-decode/src/lib.rs @@ -5,6 +5,8 @@ use glyphnet_core::{ }; use glyphnet_ecc::RecoveryTelemetry; use image::{DynamicImage, GrayImage}; +#[cfg(not(target_arch = "wasm32"))] +use std::path::PathBuf; use thiserror::Error; mod autodetect; @@ -134,6 +136,7 @@ impl RasterDecoder { /// Decode a rendered GlyphNet image and return the inferred parameters. pub fn decode_auto_with_info(&self, image: &DynamicImage) -> Result { + let mut debug = DecodeAutoDebug::from_env(); let luma = image.to_luma8(); let width = luma.width(); let height = luma.height(); @@ -142,22 +145,42 @@ impl RasterDecoder { } let gcd = gcd_u32(width, height); let mut candidates = module_candidates(gcd, self.options.module_px); + debug.log(format!( + "auto_infer start width={} height={} gcd={} module_candidates={:?}", + width, height, gcd, candidates + )); if candidates.is_empty() { + debug.log("auto_infer no module candidates"); return Err(DecodeError::AutoDetectFailed); } let thresholds = threshold_candidates(self.options.threshold, &luma); let layouts = layout_candidates(self.options.layout); + debug.log(format!("auto_infer thresholds={:?}", thresholds)); + debug.log(format!("auto_infer layouts={:?}", layouts)); for module_px in candidates.drain(..) { let width_modules = width / module_px; let height_modules = height / module_px; + debug.log(format!( + "candidate module_px={} width_modules={} height_modules={}", + module_px, width_modules, height_modules + )); for (quiet_zone_x, quiet_zone_y) in quiet_zone_candidates(width_modules, height_modules) { + debug.log(format!( + " quiet_zone_x={} quiet_zone_y={}", + quiet_zone_x, quiet_zone_y + )); if width_modules <= quiet_zone_x * 2 || height_modules <= quiet_zone_y * 2 { + debug.log(" rejected: quiet zone consumes full image"); continue; } let symbol_width = width_modules - quiet_zone_x * 2; let symbol_height = height_modules - quiet_zone_y * 2; if !plausible_symbol_geometry(symbol_width, symbol_height) { + debug.log(format!( + " rejected: implausible symbol geometry {}x{}", + symbol_width, symbol_height + )); continue; } for layout in &layouts { @@ -176,6 +199,10 @@ impl RasterDecoder { symbol_width as u16, symbol_height as u16, ) { + debug.log(format!( + " precheck failed layout={:?} threshold={}", + layout, threshold + )); continue; } let (matrix, bit_confidence) = @@ -186,7 +213,10 @@ impl RasterDecoder { quiet_zone_y, ) { Ok(matrix) => matrix, - Err(_) => continue, + Err(_) => { + debug.log(" sample_matrix failed"); + continue; + } }; let suspect_bytes = suspect_bytes_from_bit_confidence( &matrix, @@ -194,6 +224,13 @@ impl RasterDecoder { MAX_SUSPECT_BYTES, ); if let Ok(decoded) = decode_matrix_with_suspects(&matrix, &suspect_bytes) { + debug.log(format!( + " success layout={:?} module_px={} quiet={} threshold={}", + layout, + module_px, + quiet_zone_x.min(quiet_zone_y), + threshold + )); return Ok(AutoDecodedSymbol { decoded, info: AutoDecodeInfo { @@ -204,10 +241,18 @@ impl RasterDecoder { }, }); } + debug.log(format!( + " decode failed layout={:?} module_px={} quiet={} threshold={}", + layout, + module_px, + quiet_zone_x.min(quiet_zone_y), + threshold + )); } } } } + debug.log("auto_infer exhausted candidates -> AutoDetectFailed"); Err(DecodeError::AutoDetectFailed) } @@ -325,6 +370,44 @@ impl RasterDecoder { } } +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Default)] +struct DecodeAutoDebug { + dir: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +impl DecodeAutoDebug { + fn from_env() -> Self { + let dir = std::env::var_os("GLYPHNET_SCAN_DEBUG_DIR").map(PathBuf::from); + if let Some(dir) = &dir { + let _ = std::fs::create_dir_all(dir); + } + Self { dir } + } + + fn log(&mut self, line: impl AsRef) { + let Some(dir) = &self.dir else { return }; + let path = dir.join("06_decode_auto_infer.log"); + let mut current = std::fs::read_to_string(&path).unwrap_or_default(); + current.push_str(line.as_ref()); + current.push('\n'); + let _ = std::fs::write(path, current); + } +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Default)] +struct DecodeAutoDebug; + +#[cfg(target_arch = "wasm32")] +impl DecodeAutoDebug { + fn from_env() -> Self { + Self + } + fn log(&mut self, _line: impl AsRef) {} +} + impl Default for RasterDecoder { fn default() -> Self { Self::new(DecodeOptions::default()) diff --git a/crates/glyphnet-scanner/Cargo.toml b/crates/glyphnet-scanner/Cargo.toml index 7666d32..7928565 100644 --- a/crates/glyphnet-scanner/Cargo.toml +++ b/crates/glyphnet-scanner/Cargo.toml @@ -18,6 +18,7 @@ async = ["tokio"] glyphnet-core = { path = "../glyphnet-core", version = "0.1.0" } glyphnet-cv = { path = "../glyphnet-cv", version = "0.1.0" } glyphnet-decode = { path = "../glyphnet-decode", version = "0.1.0" } +glyphnet-ecc = { path = "../glyphnet-ecc", version = "0.1.0" } image.workspace = true thiserror.workspace = true tokio = { workspace = true, optional = true } diff --git a/crates/glyphnet-scanner/src/decode_paths.rs b/crates/glyphnet-scanner/src/decode_paths.rs index 13e4fdd..2e85dbe 100644 --- a/crates/glyphnet-scanner/src/decode_paths.rs +++ b/crates/glyphnet-scanner/src/decode_paths.rs @@ -1,8 +1,17 @@ -use glyphnet_core::{Cell, FrameHeader, HEADER_LEN, LayoutFamily, SymbolMatrix, bitstream, layout}; +use glyphnet_core::{ + Cell, Frame, FrameHeader, HEADER_LEN, LayoutFamily, SymbolMatrix, bitstream, layout, +}; +use glyphnet_cv::{Point, Quad, VisionProfile, adaptive_threshold, warp_perspective_gray}; use glyphnet_decode::{ - AutoDecodedSymbol, DecodeError, DecodeOptions, RasterDecoder, decode_matrix, + AutoDecodeInfo, AutoDecodedSymbol, DecodeError, DecodeOptions, DecodedSymbol, RasterDecoder, + decode_matrix, }; -use image::{DynamicImage, GrayImage}; +use glyphnet_ecc::{RecoveryMethod, RecoveryTelemetry}; +use image::{DynamicImage, GrayImage, Luma}; +#[cfg(not(target_arch = "wasm32"))] +use std::path::PathBuf; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::atomic::{AtomicUsize, Ordering}; use crate::ScanRegion; @@ -10,15 +19,31 @@ pub(crate) fn decode_candidate( decoder: &RasterDecoder, image: &DynamicImage, candidate: crate::detectors::ScanCandidate, + fast_mode: bool, ) -> std::result::Result { let region = candidate.region; - if matches!(candidate.stage, "signature-window" | "coarse-grid") { - if let Ok(decoded) = decode_exact_ribbon_candidate(image, region) { + if matches!( + candidate.stage, + "signature-window" | "coarse-grid" | "photo-grid" | "centered-full-frame" + ) { + debug_dump_dynamic("decode_input", image); + if let Ok(decoded) = decode_refined_ribbon_candidate(image, fast_mode) { + return Ok(decoded); + } + if let Ok(decoded) = decode_fractional_ribbon_no_quiet(image, fast_mode) { return Ok(decoded); } - if let Ok(decoded) = decode_fractional_ribbon_candidate(image) { + if let Ok(decoded) = decode_thresholded_ribbon_candidate(image, fast_mode) { return Ok(decoded); } + if let Ok(decoded) = decode_exact_ribbon_candidate(image, region, fast_mode) { + return Ok(decoded); + } + if !fast_mode { + if let Ok(decoded) = decode_fractional_ribbon_candidate(image) { + return Ok(decoded); + } + } let target_module_px = 4; let resized = image::imageops::resize( image, @@ -27,13 +52,14 @@ pub(crate) fn decode_candidate( image::imageops::FilterType::Triangle, ); let resized = DynamicImage::ImageRgba8(resized); + debug_dump_dynamic("decode_resized_104x44", &resized); let normalized_region = ScanRegion { x: 0, y: 0, width: 104 * target_module_px, height: 44 * target_module_px, }; - if let Ok(decoded) = decode_exact_ribbon_candidate(&resized, normalized_region) { + if let Ok(decoded) = decode_exact_ribbon_candidate(&resized, normalized_region, fast_mode) { return Ok(decoded); } return Err(DecodeError::AutoDetectFailed); @@ -43,19 +69,873 @@ pub(crate) fn decode_candidate( candidate.stage, "reference-sweep" | "component-reference" | "dark-bounds" | "dark-ribbon" ) { - if let Ok(decoded) = decode_exact_ribbon_candidate(image, region) { + if let Ok(decoded) = decode_exact_ribbon_candidate(image, region, fast_mode) { return Ok(decoded); } - if let Ok(decoded) = decode_fractional_ribbon_candidate(image) { - return Ok(decoded); + if !fast_mode { + if let Ok(decoded) = decode_fractional_ribbon_candidate(image) { + return Ok(decoded); + } } } decoder.decode_auto_with_info(image) } +fn decode_refined_ribbon_candidate( + image: &DynamicImage, + fast_mode: bool, +) -> std::result::Result { + let luma = image.to_luma8(); + let profile = VisionProfile::for_mode(glyphnet_core::TransmissionMode::Print); + let binary = adaptive_threshold(&luma, profile.threshold_radius, profile.threshold_bias) + .map_err(|_| DecodeError::AutoDetectFailed)?; + let Some((min_x, min_y, max_x, max_y)) = dark_bounds_ignoring_border(&binary) else { + return Err(DecodeError::AutoDetectFailed); + }; + let bw = max_x.saturating_sub(min_x).saturating_add(1); + let bh = max_y.saturating_sub(min_y).saturating_add(1); + if bw < 96 || bh < 36 { + return Err(DecodeError::AutoDetectFailed); + } + let module_x = (bw / 96).max(1); + let module_y = (bh / 36).max(1); + let pads = [ + (0u32, 0u32), + (module_x, module_y), + (module_x * 2, module_y * 2), + (module_x * 4, module_y * 4), + ]; + for (pad_x, pad_y) in pads { + let x0 = min_x.saturating_sub(pad_x); + let y0 = min_y.saturating_sub(pad_y); + let x1 = (max_x.saturating_add(pad_x)).min(luma.width().saturating_sub(1)); + let y1 = (max_y.saturating_add(pad_y)).min(luma.height().saturating_sub(1)); + let w = x1.saturating_sub(x0).saturating_add(1); + let h = y1.saturating_sub(y0).saturating_add(1); + if w < 96 || h < 36 { + continue; + } + let cropped = image::imageops::crop_imm(image, x0, y0, w, h).to_image(); + let cropped = DynamicImage::ImageRgba8(cropped); + debug_dump_dynamic("decode_refined_crop", &cropped); + if let Ok(decoded) = decode_perspective_ribbon_candidate(&cropped, fast_mode) { + return Ok(decoded); + } + if let Some(normalized) = normalize_ribbon_crop(&cropped, 104, 44) { + debug_dump_dynamic("decode_refined_normalized_104x44", &normalized); + if let Ok(decoded) = decode_exact_ribbon_candidate( + &normalized, + ScanRegion { + x: 0, + y: 0, + width: normalized.width(), + height: normalized.height(), + }, + fast_mode, + ) { + return Ok(decoded); + } + if let Ok(decoded) = + decode_fractional_ribbon_candidate_with_mode(&normalized, fast_mode) + { + return Ok(decoded); + } + } + if let Some(normalized) = normalize_ribbon_crop(&cropped, 96, 36) { + debug_dump_dynamic("decode_refined_normalized_96x36", &normalized); + if let Ok(decoded) = decode_exact_ribbon_no_quiet_candidate(&normalized, fast_mode) { + return Ok(decoded); + } + if let Ok(decoded) = decode_fractional_ribbon_no_quiet(&normalized, fast_mode) { + return Ok(decoded); + } + } + let region = ScanRegion { + x: 0, + y: 0, + width: w, + height: h, + }; + if let Ok(decoded) = decode_exact_ribbon_candidate(&cropped, region, fast_mode) { + return Ok(decoded); + } + if let Ok(decoded) = decode_fractional_ribbon_candidate_with_mode(&cropped, fast_mode) { + return Ok(decoded); + } + if let Ok(decoded) = decode_fractional_ribbon_no_quiet(&cropped, fast_mode) { + return Ok(decoded); + } + if !fast_mode && let Ok(decoded) = decode_fractional_ribbon_candidate(&cropped) { + return Ok(decoded); + } + } + Err(DecodeError::AutoDetectFailed) +} + +fn decode_perspective_ribbon_candidate( + image: &DynamicImage, + fast_mode: bool, +) -> std::result::Result { + let luma = image.to_luma8(); + if luma.width() < 96 || luma.height() < 36 { + return Err(DecodeError::AutoDetectFailed); + } + let profile = VisionProfile::for_mode(glyphnet_core::TransmissionMode::Print); + let binary = adaptive_threshold(&luma, profile.threshold_radius, profile.threshold_bias) + .map_err(|_| DecodeError::AutoDetectFailed)?; + let Some(quad) = estimate_ribbon_totem_quad(&binary) else { + return Err(DecodeError::AutoDetectFailed); + }; + for pad_modules in [0.0_f32] { + let quad = expand_quad_by_modules(quad, 96.0, 36.0, pad_modules); + let width_top = point_distance(quad.top_left, quad.top_right); + let width_bottom = point_distance(quad.bottom_left, quad.bottom_right); + let module_px = ((width_top.max(width_bottom) / 96.0).round() as u32).clamp(2, 16); + let warp_width = 96 * module_px; + let warp_height = 36 * module_px; + let Ok(warped_luma) = warp_perspective_gray(&luma, quad, warp_width, warp_height) else { + continue; + }; + let warped = DynamicImage::ImageLuma8(warped_luma.clone()); + debug_dump_dynamic("decode_perspective_no_quiet", &warped); + if let Ok(decoded) = decode_exact_ribbon_no_quiet_candidate(&warped, fast_mode) { + return Ok(decoded); + } + if let Ok(decoded) = decode_fractional_ribbon_no_quiet(&warped, fast_mode) { + return Ok(decoded); + } + let Ok(binary_warped) = adaptive_threshold( + &warped_luma, + profile.threshold_radius, + profile.threshold_bias, + ) else { + continue; + }; + let binary_warped = DynamicImage::ImageLuma8(binary_warped); + debug_dump_dynamic("decode_perspective_thresholded_no_quiet", &binary_warped); + if let Ok(decoded) = decode_exact_ribbon_no_quiet_candidate(&binary_warped, fast_mode) { + return Ok(decoded); + } + if let Some(luma) = binary_warped.as_luma8() + && let Ok(decoded) = decode_shifted_no_quiet_luma(luma, module_px) + { + return Ok(decoded); + } + if let Ok(decoded) = decode_fractional_ribbon_no_quiet(&binary_warped, fast_mode) { + return Ok(decoded); + } + } + Err(DecodeError::AutoDetectFailed) +} + +fn decode_shifted_no_quiet_luma( + luma: &GrayImage, + module_px: u32, +) -> std::result::Result { + const SYMBOL_WIDTH: u16 = 96; + const SYMBOL_HEIGHT: u16 = 36; + let integral = IntegralGray::new(luma); + for scale_adjust in [1.0_f32, 0.95, 1.05, 0.9, 1.1] { + let scale = module_px as f32 * scale_adjust; + for y_step in -8..=12 { + let origin_y = y_step as f32 * 0.5; + for x_step in -16..=16 { + let origin_x = x_step as f32 * 0.5; + if !fractional_grid_fits( + luma, + origin_x, + origin_y, + scale, + scale, + SYMBOL_WIDTH, + SYMBOL_HEIGHT, + ) { + continue; + } + if !fractional_header_precheck( + &integral, + FractionalSampleParams { + origin_x_modules: origin_x, + origin_y_modules: origin_y, + scale_x: scale, + scale_y: scale, + symbol_height: SYMBOL_HEIGHT, + vertical_warp: 0.0, + }, + 128, + ) { + continue; + } + if let Ok(decoded) = decode_fractional_with_params( + &integral, + FractionalDecodeParams { + sample: FractionalSampleParams { + origin_x_modules: origin_x, + origin_y_modules: origin_y, + scale_x: scale, + scale_y: scale, + symbol_height: SYMBOL_HEIGHT, + vertical_warp: 0.0, + }, + threshold: 128, + quiet_zone_modules: 0, + allow_recovery: false, + }, + ) { + return Ok(decoded); + } + } + } + } + Err(DecodeError::AutoDetectFailed) +} + +#[derive(Debug, Clone, Copy)] +struct VerticalSignature { + min_x: u32, + max_x: u32, + min_y: u32, + max_y: u32, + dark: u32, + transitions: u32, +} + +fn estimate_ribbon_totem_quad(binary: &GrayImage) -> Option { + let width = binary.width(); + let height = binary.height(); + if width < 96 || height < 36 { + return None; + } + + let mut columns = Vec::new(); + let y_start = (height / 64).max(1); + let y_end = height.saturating_sub(y_start + 1); + for x in 0..width { + let mut dark = 0u32; + let mut min_y = height; + let mut max_y = 0u32; + let mut transitions = 0u32; + let mut last_dark = false; + let mut seen = false; + for y in y_start..=y_end { + let is_dark = binary.get_pixel(x, y).0[0] == 0; + if is_dark { + dark += 1; + min_y = min_y.min(y); + max_y = max_y.max(y); + } + if seen && is_dark != last_dark { + transitions += 1; + } + seen = true; + last_dark = is_dark; + } + if dark == 0 { + continue; + } + let span = max_y.saturating_sub(min_y).saturating_add(1); + if span >= height / 3 + && min_y <= height / 3 + && max_y >= height.saturating_mul(3) / 5 + && transitions >= 8 + && dark >= span / 18 + { + columns.push(VerticalSignature { + min_x: x, + max_x: x, + min_y, + max_y, + dark, + transitions, + }); + } + } + + let mut groups: Vec = Vec::new(); + for column in columns { + match groups.last_mut() { + Some(group) if column.min_x <= group.max_x + 8 => { + group.max_x = column.max_x; + group.min_y = group.min_y.min(column.min_y); + group.max_y = group.max_y.max(column.max_y); + group.dark += column.dark; + group.transitions += column.transitions; + } + _ => groups.push(column), + } + } + if groups.len() < 2 { + debug_log_decode(format!("perspective groups insufficient: {}", groups.len())); + return None; + } + debug_log_decode(format!("perspective groups: {:?}", groups)); + + let left_zone_max = width.saturating_mul(2) / 5; + let right_zone_min = width.saturating_mul(3) / 5; + let min_separation = width / 2; + let edge_left = groups + .iter() + .copied() + .filter(|group| { + signature_center_x(*group) <= left_zone_max + && group.dark >= 300 + && group.transitions >= 80 + }) + .min_by_key(|group| signature_center_x(*group)); + let edge_right = groups + .iter() + .copied() + .filter(|group| { + signature_center_x(*group) >= right_zone_min + && group.dark >= 300 + && group.transitions >= 80 + }) + .max_by_key(|group| signature_center_x(*group)); + + let edge_pair = edge_left.zip(edge_right).and_then(|(left, right)| { + (right.max_x.saturating_sub(left.min_x) >= min_separation).then_some((left, right)) + }); + + let mut best_pair: Option<(VerticalSignature, VerticalSignature, u64)> = None; + for (left_index, left) in groups.iter().enumerate() { + if signature_center_x(*left) > left_zone_max { + continue; + } + for right in groups.iter().skip(left_index + 1) { + if signature_center_x(*right) < right_zone_min { + continue; + } + if right.max_x.saturating_sub(left.min_x) < min_separation { + continue; + } + let left_span = left.max_y.saturating_sub(left.min_y).saturating_add(1); + let right_span = right.max_y.saturating_sub(right.min_y).saturating_add(1); + let separation = right.max_x.saturating_sub(left.min_x); + let score = u64::from(separation) + .saturating_mul(u64::from(separation)) + .saturating_mul(u64::from(left_span.min(right_span)).max(1)) + .saturating_mul(u64::from(left.dark.min(right.dark)).max(1)) + .saturating_mul(u64::from(left.transitions.min(right.transitions)).max(1)); + if best_pair.is_none_or(|(_, _, best_score)| score > best_score) { + best_pair = Some((*left, *right, score)); + } + } + } + let (left, right) = edge_pair.or_else(|| best_pair.map(|(left, right, _)| (left, right)))?; + let quad = ribbon_symbol_quad_from_signature_lines(binary, left, right) + .unwrap_or_else(|| ribbon_symbol_quad_from_totem_refs(binary, left, right)); + debug_log_decode(format!( + "perspective picked left={left:?} right={right:?} quad=({:.1},{:.1}) ({:.1},{:.1}) ({:.1},{:.1}) ({:.1},{:.1})", + quad.top_left.x, + quad.top_left.y, + quad.top_right.x, + quad.top_right.y, + quad.bottom_right.x, + quad.bottom_right.y, + quad.bottom_left.x, + quad.bottom_left.y, + )); + Some(quad) +} + +fn signature_center_x(signature: VerticalSignature) -> u32 { + signature.min_x.saturating_add(signature.max_x) / 2 +} + +#[derive(Debug, Clone, Copy)] +struct HorizontalSignature { + min_x: u32, + max_x: u32, + min_y: u32, + max_y: u32, + dark: u32, + transitions: u32, +} + +fn ribbon_symbol_quad_from_signature_lines( + binary: &GrayImage, + left: VerticalSignature, + right: VerticalSignature, +) -> Option { + let horizontal = horizontal_signatures(binary); + debug_log_decode(format!("perspective horizontal groups: {:?}", horizontal)); + let top = horizontal + .iter() + .copied() + .filter(|group| horizontal_center_y(*group) <= binary.height() / 3) + .min_by_key(|group| horizontal_center_y(*group))?; + let bottom = horizontal + .iter() + .copied() + .filter(|group| horizontal_center_y(*group) >= binary.height() / 2) + .max_by_key(|group| horizontal_center_y(*group))?; + + let left_line = fit_signature_center_line(binary, left)?; + let right_line = fit_signature_center_line(binary, right)?; + let top_line = + horizontal_line_at_y(fit_horizontal_signature_line(binary, top)?, top, top.min_y); + let bottom_line = horizontal_line_at_y( + fit_horizontal_signature_line(binary, bottom)?, + bottom, + bottom.max_y, + ); + + let ref_tl = intersect_vertical_horizontal(left_line, top_line)?; + let ref_tr = intersect_vertical_horizontal(right_line, top_line)?; + let ref_bl = intersect_vertical_horizontal(left_line, bottom_line)?; + let ref_br = intersect_vertical_horizontal(right_line, bottom_line)?; + + // Reference intersections are approximately at x=6/x=89 and y=3/y=32. + let top_module = point_scale(point_sub(ref_tr, ref_tl), 1.0 / 83.0); + let bottom_module = point_scale(point_sub(ref_br, ref_bl), 1.0 / 83.0); + let left_module_y = point_scale(point_sub(ref_bl, ref_tl), 1.0 / 29.0); + let right_module_y = point_scale(point_sub(ref_br, ref_tr), 1.0 / 29.0); + + Some(Quad { + top_left: point_add( + point_add(ref_tl, point_scale(top_module, -6.0)), + point_scale(left_module_y, -3.0), + ), + top_right: point_add( + point_add(ref_tr, point_scale(top_module, 6.0)), + point_scale(right_module_y, -3.0), + ), + bottom_right: point_add( + point_add(ref_br, point_scale(bottom_module, 6.0)), + point_scale(right_module_y, 3.0), + ), + bottom_left: point_add( + point_add(ref_bl, point_scale(bottom_module, -6.0)), + point_scale(left_module_y, 3.0), + ), + }) +} + +fn horizontal_signatures(binary: &GrayImage) -> Vec { + let width = binary.width(); + let height = binary.height(); + let mut rows = Vec::new(); + for y in 0..height { + let mut dark = 0u32; + let mut min_x = width; + let mut max_x = 0u32; + let mut transitions = 0u32; + let mut last_dark = false; + let mut seen = false; + for x in 0..width { + let is_dark = binary.get_pixel(x, y).0[0] == 0; + if is_dark { + dark += 1; + min_x = min_x.min(x); + max_x = max_x.max(x); + } + if seen && is_dark != last_dark { + transitions += 1; + } + seen = true; + last_dark = is_dark; + } + if dark == 0 { + continue; + } + let span = max_x.saturating_sub(min_x).saturating_add(1); + if span >= width / 2 && transitions >= 28 && dark >= span / 20 { + rows.push(HorizontalSignature { + min_x, + max_x, + min_y: y, + max_y: y, + dark, + transitions, + }); + } + } + + let mut groups: Vec = Vec::new(); + for row in rows { + match groups.last_mut() { + Some(group) if row.min_y <= group.max_y + 6 => { + group.max_y = row.max_y; + group.min_x = group.min_x.min(row.min_x); + group.max_x = group.max_x.max(row.max_x); + group.dark += row.dark; + group.transitions += row.transitions; + } + _ => groups.push(row), + } + } + groups +} + +fn horizontal_center_y(signature: HorizontalSignature) -> u32 { + signature.min_y.saturating_add(signature.max_y) / 2 +} + +fn horizontal_line_at_y( + line: HorizontalLine, + signature: HorizontalSignature, + y: u32, +) -> HorizontalLine { + let center_x = (signature.min_x + signature.max_x) as f32 * 0.5; + HorizontalLine { + slope: line.slope, + intercept: y as f32 - line.slope * center_x, + } +} + +fn ribbon_symbol_quad_from_totem_refs( + binary: &GrayImage, + left: VerticalSignature, + right: VerticalSignature, +) -> Quad { + let left_line = fit_signature_center_line(binary, left); + let right_line = fit_signature_center_line(binary, right); + let left_top = point_on_signature(left_line, left, left.min_y); + let left_bottom = point_on_signature(left_line, left, left.max_y); + let right_top = point_on_signature(right_line, right, right.min_y); + let right_bottom = point_on_signature(right_line, right, right.max_y); + + // RibbonWeave side totems sit near module x=5 and x=90, spanning y=3..32. + // Expand those reference lines back to the full 96x36 no-quiet symbol. + let top_module = point_scale(point_sub(right_top, left_top), 1.0 / 85.0); + let bottom_module = point_scale(point_sub(right_bottom, left_bottom), 1.0 / 85.0); + let left_module_y = point_scale(point_sub(left_bottom, left_top), 1.0 / 29.0); + let right_module_y = point_scale(point_sub(right_bottom, right_top), 1.0 / 29.0); + + Quad { + top_left: point_add( + point_add(left_top, point_scale(top_module, -5.0)), + point_scale(left_module_y, -3.0), + ), + top_right: point_add( + point_add(right_top, point_scale(top_module, 5.0)), + point_scale(right_module_y, -3.0), + ), + bottom_right: point_add( + point_add(right_bottom, point_scale(bottom_module, 5.0)), + point_scale(right_module_y, 3.0), + ), + bottom_left: point_add( + point_add(left_bottom, point_scale(bottom_module, -5.0)), + point_scale(left_module_y, 3.0), + ), + } +} + +#[derive(Debug, Clone, Copy)] +struct VerticalLine { + slope: f32, + intercept: f32, +} + +#[derive(Debug, Clone, Copy)] +struct HorizontalLine { + slope: f32, + intercept: f32, +} + +fn fit_signature_center_line( + binary: &GrayImage, + signature: VerticalSignature, +) -> Option { + let mut count = 0.0_f32; + let mut sum_y = 0.0_f32; + let mut sum_x = 0.0_f32; + let mut sum_yy = 0.0_f32; + let mut sum_yx = 0.0_f32; + for y in signature.min_y..=signature.max_y { + let mut min_x = binary.width(); + let mut max_x = 0u32; + let mut found = false; + for x in signature.min_x..=signature.max_x { + if binary.get_pixel(x, y).0[0] == 0 { + min_x = min_x.min(x); + max_x = max_x.max(x); + found = true; + } + } + if !found { + continue; + } + let x = (min_x + max_x) as f32 * 0.5; + let y = y as f32; + count += 1.0; + sum_y += y; + sum_x += x; + sum_yy += y * y; + sum_yx += y * x; + } + if count < 8.0 { + return None; + } + let denominator = count * sum_yy - sum_y * sum_y; + if denominator.abs() < f32::EPSILON { + return None; + } + let slope = (count * sum_yx - sum_y * sum_x) / denominator; + let intercept = (sum_x - slope * sum_y) / count; + Some(VerticalLine { slope, intercept }) +} + +fn point_on_signature(line: Option, signature: VerticalSignature, y: u32) -> Point { + let fallback_x = (signature.min_x + signature.max_x) as f32 * 0.5; + let y = y as f32; + Point { + x: line + .map(|line| line.slope * y + line.intercept) + .unwrap_or(fallback_x), + y, + } +} + +fn fit_horizontal_signature_line( + binary: &GrayImage, + signature: HorizontalSignature, +) -> Option { + let y_start = signature.min_y.saturating_sub(3); + let y_end = (signature.max_y + 3).min(binary.height().saturating_sub(1)); + let mut count = 0.0_f32; + let mut sum_x = 0.0_f32; + let mut sum_y = 0.0_f32; + let mut sum_xx = 0.0_f32; + let mut sum_xy = 0.0_f32; + for x in signature.min_x..=signature.max_x { + let mut min_y = binary.height(); + let mut max_y = 0u32; + let mut found = false; + for y in y_start..=y_end { + if binary.get_pixel(x, y).0[0] == 0 { + min_y = min_y.min(y); + max_y = max_y.max(y); + found = true; + } + } + if !found { + continue; + } + let x = x as f32; + let y = (min_y + max_y) as f32 * 0.5; + count += 1.0; + sum_x += x; + sum_y += y; + sum_xx += x * x; + sum_xy += x * y; + } + if count < 16.0 { + return None; + } + let denominator = count * sum_xx - sum_x * sum_x; + if denominator.abs() < f32::EPSILON { + return None; + } + let slope = (count * sum_xy - sum_x * sum_y) / denominator; + let intercept = (sum_y - slope * sum_x) / count; + Some(HorizontalLine { slope, intercept }) +} + +fn intersect_vertical_horizontal( + vertical: VerticalLine, + horizontal: HorizontalLine, +) -> Option { + let denominator = 1.0 - horizontal.slope * vertical.slope; + if denominator.abs() < 1.0e-4 { + return None; + } + let y = (horizontal.slope * vertical.intercept + horizontal.intercept) / denominator; + let x = vertical.slope * y + vertical.intercept; + Some(Point { x, y }) +} + +fn expand_quad_by_modules(quad: Quad, symbol_width: f32, symbol_height: f32, modules: f32) -> Quad { + if modules == 0.0 { + return quad; + } + let x_fraction = modules / symbol_width; + let y_fraction = modules / symbol_height; + Quad { + top_left: expand_quad_point( + quad.top_left, + quad.top_right, + quad.bottom_left, + x_fraction, + y_fraction, + ), + top_right: expand_quad_point( + quad.top_right, + quad.top_left, + quad.bottom_right, + x_fraction, + y_fraction, + ), + bottom_right: expand_quad_point( + quad.bottom_right, + quad.bottom_left, + quad.top_right, + x_fraction, + y_fraction, + ), + bottom_left: expand_quad_point( + quad.bottom_left, + quad.bottom_right, + quad.top_left, + x_fraction, + y_fraction, + ), + } +} + +fn expand_quad_point( + point: Point, + horizontal: Point, + vertical: Point, + x_fraction: f32, + y_fraction: f32, +) -> Point { + Point { + x: point.x + (point.x - horizontal.x) * x_fraction + (point.x - vertical.x) * y_fraction, + y: point.y + (point.y - horizontal.y) * x_fraction + (point.y - vertical.y) * y_fraction, + } +} + +fn point_distance(a: Point, b: Point) -> f32 { + let dx = a.x - b.x; + let dy = a.y - b.y; + (dx * dx + dy * dy).sqrt() +} + +fn point_add(a: Point, b: Point) -> Point { + Point { + x: a.x + b.x, + y: a.y + b.y, + } +} + +fn point_sub(a: Point, b: Point) -> Point { + Point { + x: a.x - b.x, + y: a.y - b.y, + } +} + +fn point_scale(point: Point, scale: f32) -> Point { + Point { + x: point.x * scale, + y: point.y * scale, + } +} + +fn normalize_ribbon_crop( + image: &DynamicImage, + total_width_modules: u32, + total_height_modules: u32, +) -> Option { + if image.width() < total_width_modules || image.height() < total_height_modules { + return None; + } + let target_aspect = total_width_modules as f32 / total_height_modules as f32; + let current_aspect = image.width() as f32 / image.height().max(1) as f32; + let (crop_x, crop_y, crop_w, crop_h) = if current_aspect > target_aspect { + let crop_w = (image.height() as f32 * target_aspect).round() as u32; + ( + (image.width().saturating_sub(crop_w)) / 2, + 0, + crop_w, + image.height(), + ) + } else { + let crop_h = (image.width() as f32 / target_aspect).round() as u32; + ( + 0, + (image.height().saturating_sub(crop_h)) / 2, + image.width(), + crop_h, + ) + }; + if crop_w < total_width_modules || crop_h < total_height_modules { + return None; + } + let cropped = image::imageops::crop_imm(image, crop_x, crop_y, crop_w, crop_h).to_image(); + let module_px = ((crop_w as f32 / total_width_modules as f32) + .min(crop_h as f32 / total_height_modules as f32)) + .round() + .max(1.0) as u32; + let target_w = total_width_modules.saturating_mul(module_px); + let target_h = total_height_modules.saturating_mul(module_px); + Some(DynamicImage::ImageRgba8(image::imageops::resize( + &DynamicImage::ImageRgba8(cropped), + target_w, + target_h, + image::imageops::FilterType::Triangle, + ))) +} + +fn decode_exact_ribbon_no_quiet_candidate( + image: &DynamicImage, + fast_mode: bool, +) -> std::result::Result { + if image.width() >= 96 && image.height() >= 36 { + let module_px = (image.width() / 96).max(1); + if image.width() == 96 * module_px && image.height() == 36 * module_px { + for threshold in [144, 160, 176, 192, 208, 224] { + let exact = RasterDecoder::new(DecodeOptions { + module_px, + quiet_zone_modules: 0, + threshold, + layout: LayoutFamily::RibbonWeave, + }); + let info = AutoDecodeInfo { + module_px, + quiet_zone_modules: 0, + threshold, + layout: LayoutFamily::RibbonWeave, + }; + if fast_mode { + if let Ok(matrix) = exact.sample_matrix(image) { + debug_dump_module_grid(&format!("exact_no_quiet_t{threshold}"), &matrix); + if let Ok(decoded) = auto_decoded_from_crc_valid_matrix(matrix, info) { + return Ok(decoded); + } + } + continue; + } + if let Ok(decoded) = exact.decode(image) { + return Ok(AutoDecodedSymbol { decoded, info }); + } + } + } + } + Err(DecodeError::AutoDetectFailed) +} + +fn dark_bounds_ignoring_border(binary: &GrayImage) -> Option<(u32, u32, u32, u32)> { + if binary.width() < 16 || binary.height() < 16 { + return None; + } + let ignore_x = (binary.width() / 32).max(2); + let ignore_y = (binary.height() / 32).max(2); + let mut min_x = binary.width(); + let mut min_y = binary.height(); + let mut max_x = 0u32; + let mut max_y = 0u32; + let mut found = false; + for y in ignore_y..binary.height().saturating_sub(ignore_y) { + for x in ignore_x..binary.width().saturating_sub(ignore_x) { + if binary.get_pixel(x, y).0[0] == 0 { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + found = true; + } + } + } + found.then_some((min_x, min_y, max_x, max_y)) +} + fn decode_exact_ribbon_candidate( image: &DynamicImage, region: ScanRegion, + fast_mode: bool, ) -> std::result::Result { if region.width >= 104 && region.height >= 44 { let module_px = (region.width / 104).max(1); @@ -67,16 +947,23 @@ fn decode_exact_ribbon_candidate( threshold, layout: LayoutFamily::RibbonWeave, }); + let info = AutoDecodeInfo { + module_px, + quiet_zone_modules: 4, + threshold, + layout: LayoutFamily::RibbonWeave, + }; + if fast_mode { + if let Ok(matrix) = exact.sample_matrix(image) { + debug_dump_module_grid(&format!("exact_quiet_t{threshold}"), &matrix); + if let Ok(decoded) = auto_decoded_from_crc_valid_matrix(matrix, info) { + return Ok(decoded); + } + } + continue; + } if let Ok(decoded) = exact.decode(image) { - return Ok(AutoDecodedSymbol { - decoded, - info: glyphnet_decode::AutoDecodeInfo { - module_px, - quiet_zone_modules: 4, - threshold, - layout: LayoutFamily::RibbonWeave, - }, - }); + return Ok(AutoDecodedSymbol { decoded, info }); } } } @@ -86,6 +973,13 @@ fn decode_exact_ribbon_candidate( fn decode_fractional_ribbon_candidate( image: &DynamicImage, +) -> std::result::Result { + decode_fractional_ribbon_candidate_with_mode(image, false) +} + +fn decode_fractional_ribbon_candidate_with_mode( + image: &DynamicImage, + fast_mode: bool, ) -> std::result::Result { const SYMBOL_WIDTH: u16 = 96; const SYMBOL_HEIGHT: u16 = 36; @@ -105,44 +999,236 @@ fn decode_fractional_ribbon_candidate( let otsu = fractional_threshold(&luma); let integral = IntegralGray::new(&luma); - let mut thresholds = vec![otsu, 160, 192, 224]; + let mut thresholds = if fast_mode { + vec![otsu, 176, 192, 208] + } else { + vec![otsu, 144, 160, 176, 192, 208, 224] + }; thresholds.sort_unstable(); thresholds.dedup(); - for scale_adjust in [1.0_f32, 0.985, 1.015, 0.97, 1.03] { - let scale_x = base_scale_x * scale_adjust; - let scale_y = base_scale_y * scale_adjust; - if scale_x < 1.0 || scale_y < 1.0 { - continue; - } - for y_shift in module_shifts(3) { - for x_shift in module_shifts(2) { - let origin_x = QUIET_MODULES + x_shift; - let origin_y = QUIET_MODULES + y_shift; - if origin_x < -2.0 || origin_y < -8.0 { - continue; - } - if !fractional_grid_fits( - &luma, - origin_x, - origin_y, - scale_x, - scale_y, - SYMBOL_WIDTH, - SYMBOL_HEIGHT, - ) { - continue; - } - for &threshold in &thresholds { - if !fractional_header_precheck( - &integral, origin_x, origin_y, scale_x, scale_y, threshold, - ) { - continue; + let scale_adjusts_x: &[f32] = if fast_mode { + &[1.0] + } else { + &[1.0, 0.99, 1.01, 0.98, 1.02] + }; + let scale_adjusts_y: &[f32] = if fast_mode { + &[1.0] + } else { + &[1.0, 0.99, 1.01, 0.98, 1.02] + }; + let shift_radius = if fast_mode { 0 } else { 3 }; + let phase_offsets: &[f32] = if fast_mode { + &[0.0, -0.25, 0.25] + } else { + &[0.0, -0.25, 0.25, -0.5, 0.5] + }; + let vertical_warps: &[f32] = if fast_mode { + &[0.0, -0.06, -0.12, 0.06, 0.12] + } else { + &[0.0] + }; + + let mut decode_trials = 0usize; + for scale_adjust_x in scale_adjusts_x { + for scale_adjust_y in scale_adjusts_y { + let scale_x = base_scale_x * scale_adjust_x; + let scale_y = base_scale_y * scale_adjust_y; + if scale_x < 1.0 || scale_y < 1.0 { + continue; + } + for y_shift in module_shifts(shift_radius) { + for x_shift in module_shifts(shift_radius) { + for y_phase in phase_offsets { + for x_phase in phase_offsets { + let origin_x = QUIET_MODULES + x_shift + x_phase; + let origin_y = QUIET_MODULES + y_shift + y_phase; + if origin_x < -2.0 || origin_y < -8.0 { + continue; + } + if !fractional_grid_fits( + &luma, + origin_x, + origin_y, + scale_x, + scale_y, + SYMBOL_WIDTH, + SYMBOL_HEIGHT, + ) { + continue; + } + for &vertical_warp in vertical_warps { + for &threshold in &thresholds { + if !fractional_header_precheck( + &integral, + FractionalSampleParams { + origin_x_modules: origin_x, + origin_y_modules: origin_y, + scale_x, + scale_y, + symbol_height: SYMBOL_HEIGHT, + vertical_warp, + }, + threshold, + ) { + continue; + } + decode_trials += 1; + if fast_mode && decode_trials > 4 { + return Err(DecodeError::AutoDetectFailed); + } + if let Ok(decoded) = decode_fractional_with_params( + &integral, + FractionalDecodeParams { + sample: FractionalSampleParams { + origin_x_modules: origin_x, + origin_y_modules: origin_y, + scale_x, + scale_y, + symbol_height: SYMBOL_HEIGHT, + vertical_warp, + }, + threshold, + quiet_zone_modules: 4, + allow_recovery: !fast_mode, + }, + ) { + return Ok(decoded); + } + } + } + } } - if let Ok(decoded) = decode_fractional_with_params( - &integral, origin_x, origin_y, scale_x, scale_y, threshold, - ) { - return Ok(decoded); + } + } + } + } + + Err(DecodeError::AutoDetectFailed) +} + +fn decode_fractional_ribbon_no_quiet( + image: &DynamicImage, + fast_mode: bool, +) -> std::result::Result { + debug_dump_dynamic("decode_no_quiet_input", image); + const SYMBOL_WIDTH: u16 = 96; + const SYMBOL_HEIGHT: u16 = 36; + const TOTAL_WIDTH_MODULES: f32 = 96.0; + const TOTAL_HEIGHT_MODULES: f32 = 36.0; + + let luma = image.to_luma8(); + if luma.width() < 96 || luma.height() < 36 { + return Err(DecodeError::AutoDetectFailed); + } + let base_scale_x = luma.width() as f32 / TOTAL_WIDTH_MODULES; + let base_scale_y = luma.height() as f32 / TOTAL_HEIGHT_MODULES; + if base_scale_x < 1.0 || base_scale_y < 1.0 { + return Err(DecodeError::AutoDetectFailed); + } + + let otsu = fractional_threshold(&luma); + let integral = IntegralGray::new(&luma); + let mut thresholds = if fast_mode { + vec![otsu, 176, 192, 208] + } else { + vec![otsu, 144, 160, 176, 192, 208, 224] + }; + thresholds.sort_unstable(); + thresholds.dedup(); + + let scale_adjusts_x: &[f32] = if fast_mode { + &[1.0] + } else { + &[1.0, 0.99, 1.01, 0.98, 1.02] + }; + let scale_adjusts_y: &[f32] = if fast_mode { + &[1.0] + } else { + &[1.0, 0.99, 1.01, 0.98, 1.02] + }; + let shift_radius = if fast_mode { 1 } else { 3 }; + let x_phase_offsets: &[f32] = if fast_mode { + &[0.0] + } else { + &[0.0, -0.25, 0.25, -0.5, 0.5] + }; + let y_phase_offsets: &[f32] = if fast_mode { + &[0.0] + } else { + &[0.0, -0.25, 0.25, -0.5, 0.5] + }; + let vertical_warps: &[f32] = if fast_mode { + &[0.0, -0.06, -0.12, 0.06, 0.12] + } else { + &[0.0] + }; + let mut decode_trials = 0usize; + for scale_adjust_x in scale_adjusts_x { + for scale_adjust_y in scale_adjusts_y { + let scale_x = base_scale_x * scale_adjust_x; + let scale_y = base_scale_y * scale_adjust_y; + if scale_x < 1.0 || scale_y < 1.0 { + continue; + } + for y_shift in module_shifts(shift_radius) { + for x_shift in module_shifts(shift_radius) { + for y_phase in y_phase_offsets { + for x_phase in x_phase_offsets { + let origin_x = x_shift + x_phase; + let origin_y = y_shift + y_phase; + if !fractional_grid_fits( + &luma, + origin_x, + origin_y, + scale_x, + scale_y, + SYMBOL_WIDTH, + SYMBOL_HEIGHT, + ) { + continue; + } + for &vertical_warp in vertical_warps { + for &threshold in &thresholds { + if !fractional_header_precheck( + &integral, + FractionalSampleParams { + origin_x_modules: origin_x, + origin_y_modules: origin_y, + scale_x, + scale_y, + symbol_height: SYMBOL_HEIGHT, + vertical_warp, + }, + threshold, + ) { + continue; + } + decode_trials += 1; + if fast_mode && decode_trials > 4 { + return Err(DecodeError::AutoDetectFailed); + } + if let Ok(decoded) = decode_fractional_with_params( + &integral, + FractionalDecodeParams { + sample: FractionalSampleParams { + origin_x_modules: origin_x, + origin_y_modules: origin_y, + scale_x, + scale_y, + symbol_height: SYMBOL_HEIGHT, + vertical_warp, + }, + threshold, + quiet_zone_modules: 0, + allow_recovery: !fast_mode, + }, + ) { + return Ok(decoded); + } + } + } + } } } } @@ -152,16 +1238,68 @@ fn decode_fractional_ribbon_candidate( Err(DecodeError::AutoDetectFailed) } +fn decode_thresholded_ribbon_candidate( + image: &DynamicImage, + fast_mode: bool, +) -> std::result::Result { + let gray = image.to_luma8(); + let profile = VisionProfile::for_mode(glyphnet_core::TransmissionMode::Print); + let binary = adaptive_threshold(&gray, profile.threshold_radius, profile.threshold_bias) + .map_err(|_| DecodeError::AutoDetectFailed)?; + let bin = DynamicImage::ImageLuma8(binary); + debug_dump_dynamic("decode_thresholded_input", &bin); + let region = ScanRegion { + x: 0, + y: 0, + width: bin.width(), + height: bin.height(), + }; + if let Ok(decoded) = decode_exact_ribbon_candidate(&bin, region, fast_mode) { + return Ok(decoded); + } + decode_fractional_ribbon_no_quiet(&bin, fast_mode) +} + +#[cfg(not(target_arch = "wasm32"))] +fn debug_dump_dynamic(stem: &str, image: &DynamicImage) { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let Some(dir) = std::env::var_os("GLYPHNET_SCAN_DEBUG_DIR").map(PathBuf::from) else { + return; + }; + let _ = std::fs::create_dir_all(&dir); + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = dir.join(format!("04_decode_{stem}_{index:03}.png")); + let _ = image.save(path); +} + +#[cfg(target_arch = "wasm32")] +fn debug_dump_dynamic(_stem: &str, _image: &DynamicImage) {} + fn module_shifts(radius: i32) -> impl Iterator { (-radius * 2..=radius * 2).map(|value| value as f32 * 0.5) } -fn fractional_header_precheck( - integral: &IntegralGray, +#[derive(Clone, Copy)] +struct FractionalSampleParams { origin_x_modules: f32, origin_y_modules: f32, scale_x: f32, scale_y: f32, + symbol_height: u16, + vertical_warp: f32, +} + +#[derive(Clone, Copy)] +struct FractionalDecodeParams { + sample: FractionalSampleParams, + threshold: u8, + quiet_zone_modules: u32, + allow_recovery: bool, +} + +fn fractional_header_precheck( + integral: &IntegralGray, + sample: FractionalSampleParams, threshold: u8, ) -> bool { const SYMBOL_WIDTH: u16 = 96; @@ -179,13 +1317,7 @@ fn fractional_header_precheck( ) { continue; } - let avg = fractional_module_luma( - integral, - origin_x_modules + f32::from(x), - origin_y_modules + f32::from(y), - scale_x, - scale_y, - ); + let avg = fractional_module_luma_projected(integral, x, y, sample); bits.push(avg < threshold); if bits.len() == HEADER_LEN * 8 { break 'rows; @@ -219,11 +1351,7 @@ fn fractional_grid_fits( fn decode_fractional_with_params( integral: &IntegralGray, - origin_x_modules: f32, - origin_y_modules: f32, - scale_x: f32, - scale_y: f32, - threshold: u8, + params: FractionalDecodeParams, ) -> std::result::Result { const SYMBOL_WIDTH: u16 = 96; const SYMBOL_HEIGHT: u16 = 36; @@ -242,29 +1370,101 @@ fn decode_fractional_with_params( matrix.set(x, y, cell)?; continue; } - let avg = fractional_module_luma( - integral, - origin_x_modules + f32::from(x), - origin_y_modules + f32::from(y), - scale_x, - scale_y, - ); - matrix.set(x, y, Cell::Data(avg < threshold))?; + let avg = fractional_module_luma_projected(integral, x, y, params.sample); + matrix.set(x, y, Cell::Data(avg < params.threshold))?; } } + debug_dump_module_grid( + &format!("fractional_modules_warp_{:.2}", params.sample.vertical_warp), + &matrix, + ); + let info = AutoDecodeInfo { + module_px: params.sample.scale_x.round().max(1.0) as u32, + quiet_zone_modules: params.quiet_zone_modules, + threshold: params.threshold, + layout: LayoutFamily::RibbonWeave, + }; + if !params.allow_recovery { + return auto_decoded_from_crc_valid_matrix(matrix, info); + } let decoded = decode_matrix(&matrix)?; + Ok(AutoDecodedSymbol { decoded, info }) +} + +fn auto_decoded_from_crc_valid_matrix( + matrix: SymbolMatrix, + info: AutoDecodeInfo, +) -> std::result::Result { + let sampled_bytes = bitstream::bits_to_bytes(&matrix.read_data_bits()); + let frame = Frame::decode(&sampled_bytes).map_err(|_| DecodeError::AutoDetectFailed)?; Ok(AutoDecodedSymbol { - decoded, - info: glyphnet_decode::AutoDecodeInfo { - module_px: scale_x.round().max(1.0) as u32, - quiet_zone_modules: 4, - threshold, - layout: LayoutFamily::RibbonWeave, + decoded: DecodedSymbol { + matrix, + frame, + sampled_bytes, + recovery: RecoveryTelemetry { + attempted: false, + recovered: false, + attempts: 0, + method: RecoveryMethod::None, + suspect_count: 0, + max_attempts_exceeded: false, + }, }, + info, }) } +#[cfg(not(target_arch = "wasm32"))] +fn debug_dump_module_grid(stem: &str, matrix: &SymbolMatrix) { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let Some(dir) = std::env::var_os("GLYPHNET_SCAN_DEBUG_DIR").map(PathBuf::from) else { + return; + }; + let _ = std::fs::create_dir_all(&dir); + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + + let mut image = GrayImage::new(u32::from(matrix.width()), u32::from(matrix.height())); + let mut text = String::new(); + for y in 0..matrix.height() { + for x in 0..matrix.width() { + let dark = matrix.get(x, y).map(|cell| cell.is_dark()).unwrap_or(false); + image.put_pixel( + u32::from(x), + u32::from(y), + Luma([if dark { 0 } else { 255 }]), + ); + text.push(if dark { '1' } else { '0' }); + } + text.push('\n'); + } + + let png_path = dir.join(format!("05_decode_{stem}_{index:03}.png")); + let txt_path = dir.join(format!("05_decode_{stem}_{index:03}.txt")); + let _ = image.save(png_path); + let _ = std::fs::write(txt_path, text); +} + +#[cfg(target_arch = "wasm32")] +fn debug_dump_module_grid(_stem: &str, _matrix: &SymbolMatrix) {} + +#[cfg(not(target_arch = "wasm32"))] +fn debug_log_decode(line: impl AsRef) { + let Some(dir) = std::env::var_os("GLYPHNET_SCAN_DEBUG_DIR").map(PathBuf::from) else { + return; + }; + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("07_decode_perspective.log"); + let mut current = std::fs::read_to_string(&path).unwrap_or_default(); + current.push_str(line.as_ref()); + current.push('\n'); + let _ = std::fs::write(path, current); +} + +#[cfg(target_arch = "wasm32")] +fn debug_log_decode(_line: impl AsRef) {} + fn fractional_threshold(luma: &GrayImage) -> u8 { let mut histogram = [0u32; 256]; for pixel in luma.pixels() { @@ -313,58 +1513,74 @@ fn fractional_module_luma( scale_x: f32, scale_y: f32, ) -> u8 { - let left = (module_x * scale_x).floor().max(0.0) as u32; - let top = (module_y * scale_y).floor().max(0.0) as u32; - let right = ((module_x + 1.0) * scale_x).ceil().max(1.0) as u32; - let bottom = ((module_y + 1.0) * scale_y).ceil().max(1.0) as u32; + // Ribbon modules are rendered as capsules/diamonds, so averaging the full + // square makes dark modules look too light in photos. Sample the center. + let left = ((module_x + 0.25) * scale_x).floor().max(0.0) as u32; + let top = ((module_y + 0.25) * scale_y).floor().max(0.0) as u32; + let right = ((module_x + 0.75) * scale_x).ceil().max(1.0) as u32; + let bottom = ((module_y + 0.75) * scale_y).ceil().max(1.0) as u32; let right = right.saturating_sub(1); let bottom = bottom.saturating_sub(1); - let sum = integral.sum_inclusive(left, top, right, bottom); - let area = right - .saturating_sub(left) - .saturating_add(1) - .saturating_mul(bottom.saturating_sub(top).saturating_add(1)); - (sum / area.max(1)) as u8 + let mut sum = 0u32; + let mut min_luma = u8::MAX; + let mut count = 0u32; + for y in top..=bottom { + for x in left..=right { + let value = integral.pixel(x, y); + sum += u32::from(value); + min_luma = min_luma.min(value); + count += 1; + } + } + if count == 0 { + return 255; + } + let mean = (sum / count) as u8; + ((u16::from(mean) + u16::from(min_luma)) / 2) as u8 +} + +fn fractional_module_luma_projected( + integral: &IntegralGray, + module_x: u16, + module_y: u16, + sample: FractionalSampleParams, +) -> u8 { + let local_y = f32::from(module_y); + let height = f32::from(sample.symbol_height).max(1.0); + let t = local_y / height; + let warped_y = local_y + sample.vertical_warp * t * (1.0 - t) * height; + fractional_module_luma( + integral, + sample.origin_x_modules + f32::from(module_x), + sample.origin_y_modules + warped_y, + sample.scale_x, + sample.scale_y, + ) } pub(crate) struct IntegralGray { width: u32, height: u32, - stride: usize, - sums: Vec, + pixels: Vec, } impl IntegralGray { pub(crate) fn new(image: &GrayImage) -> Self { let width = image.width(); let height = image.height(); - let stride = width as usize + 1; - let mut sums = vec![0u32; (height as usize + 1) * stride]; - for y in 0..height { - let mut row_sum = 0u32; - for x in 0..width { - row_sum = row_sum.saturating_add(u32::from(image.get_pixel(x, y).0[0])); - let idx = (y as usize + 1) * stride + (x as usize + 1); - sums[idx] = sums[y as usize * stride + (x as usize + 1)].saturating_add(row_sum); - } - } Self { width, height, - stride, - sums, + pixels: image.as_raw().clone(), } } - pub(crate) fn sum_inclusive(&self, x0: u32, y0: u32, x1: u32, y1: u32) -> u32 { - let x0 = x0.min(self.width) as usize; - let y0 = y0.min(self.height) as usize; - let x1 = x1.saturating_add(1).min(self.width) as usize; - let y1 = y1.saturating_add(1).min(self.height) as usize; - let a = self.sums[y0 * self.stride + x0]; - let b = self.sums[y0 * self.stride + x1]; - let c = self.sums[y1 * self.stride + x0]; - let d = self.sums[y1 * self.stride + x1]; - d + a - b - c + fn pixel(&self, x: u32, y: u32) -> u8 { + let x = x.min(self.width.saturating_sub(1)); + let y = y.min(self.height.saturating_sub(1)); + self.pixels + .get(y as usize * self.width as usize + x as usize) + .copied() + .unwrap_or(255) } } diff --git a/crates/glyphnet-scanner/src/lib.rs b/crates/glyphnet-scanner/src/lib.rs index 3dc8089..67807cf 100644 --- a/crates/glyphnet-scanner/src/lib.rs +++ b/crates/glyphnet-scanner/src/lib.rs @@ -1,6 +1,10 @@ //! Real-time scanner orchestration for GlyphNet. use std::collections::{BTreeMap, HashMap}; +#[cfg(not(target_arch = "wasm32"))] +use std::path::PathBuf; +#[cfg(not(target_arch = "wasm32"))] +use std::{fs::OpenOptions, io::Write}; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant as ScanInstant; @@ -36,6 +40,7 @@ use candidates::{ }; use decode_paths::decode_candidate; pub use detectors::CandidateDetector; +use detectors::ScanCandidate; use rectification::{scan_quad_candidates as build_quad_candidates, should_try_quad_rectification}; pub use types::{FailedStillScan, ScanAttempt, ScanTimings, StillScanResult}; @@ -214,10 +219,43 @@ fn scan_still_with_diagnostics_inner( allow_downscale_fast_path: bool, ) -> std::result::Result { let _ = allow_downscale_fast_path; + const DOWNSCALE_PREPASS_MAX_DIM_PX: u32 = 1024; + const DOWNSCALE_PREPASS_MAX_CANDIDATES: usize = 8; + const MAX_NON_ROBUST_DECODE_ATTEMPTS: usize = 24; + const NON_ROBUST_MAX_TOTAL_MICROS: u64 = 15_000_000; + const NON_ROBUST_MAX_QUAD_MICROS: u64 = 2_000_000; + const NON_ROBUST_MAX_DECODE_MICROS: u64 = 8_000_000; + const NON_ROBUST_MAX_CANDIDATE_AREA: u32 = 8_000_000; let started = scan_instant_now(); let mut timings = ScanTimings::default(); + let mut debug = ScanDebugDumper::from_env(); + let debug_unbounded = debug.is_enabled(); + let debug_max_attempts = debug.max_attempts(); + debug.dump_input(image); + debug.log_line("scan started"); let decoder = RasterDecoder::default(); + let profile = VisionProfile::for_mode(mode); + + debug.log_line("prepass quad disabled in non-robust mode"); + + let _ = (allow_downscale_fast_path, DOWNSCALE_PREPASS_MAX_CANDIDATES); + + let mut cv_scale = 1.0_f32; + let mut cv_image = image.clone(); + if !robust && image.width().max(image.height()) > DOWNSCALE_PREPASS_MAX_DIM_PX { + cv_scale = DOWNSCALE_PREPASS_MAX_DIM_PX as f32 / image.width().max(image.height()) as f32; + let cv_width = ((image.width() as f32 * cv_scale).round() as u32).max(1); + let cv_height = ((image.height() as f32 * cv_scale).round() as u32).max(1); + cv_image = DynamicImage::ImageRgba8(image::imageops::resize( + image, + cv_width, + cv_height, + image::imageops::FilterType::Triangle, + )); + debug.log_line(&format!("cv downscale {}x{}", cv_width, cv_height)); + } + if should_try_full_frame_decode(image) { let stage = scan_instant_now(); if let Ok(decoded) = decoder.decode_auto_with_info(image) { @@ -235,44 +273,58 @@ fn scan_still_with_diagnostics_inner( timings.full_frame_micros = elapsed_micros(stage); } - let profile = VisionProfile::for_mode(mode); let stage = scan_instant_now(); - let gray = grayscale(image).map_err(|error| failed_cv(error, timings, started))?; + let gray = grayscale(&cv_image).map_err(|error| failed_cv(error, timings, started))?; timings.grayscale_micros = elapsed_micros(stage); + debug.dump_gray("01_grayscale", &gray); + debug.log_line(&format!("stage grayscale_us={}", timings.grayscale_micros)); let stage = scan_instant_now(); let binary = adaptive_threshold(&gray, profile.threshold_radius, profile.threshold_bias) .map_err(|error| failed_cv(error, timings, started))?; timings.threshold_micros = elapsed_micros(stage); + debug.dump_gray("02_threshold", &binary); + debug.log_line(&format!("stage threshold_us={}", timings.threshold_micros)); - if should_try_quad_rectification(image.width(), image.height(), robust) { + if should_try_quad_rectification(cv_image.width(), cv_image.height(), robust) { let candidates = find_anchor_candidates(&binary, profile) .map_err(|error| failed_cv(error, timings, started))?; let estimated_quad = estimate_quad(&binary, &candidates); - let dark_bounds_region = - if should_try_dark_bounds_fallback(image.width(), image.height(), candidates.len()) { - dark_bounds(&binary) - } else { - None - }; + let dark_bounds_region = if should_try_dark_bounds_fallback( + cv_image.width(), + cv_image.height(), + candidates.len(), + ) { + dark_bounds(&binary) + } else { + None + }; let quad_candidates = build_quad_candidates( estimated_quad, dark_bounds_region, - image.width(), - image.height(), + cv_image.width(), + cv_image.height(), ); for (index, quad) in quad_candidates .into_iter() .take(MAX_QUAD_ATTEMPTS) .enumerate() { + if !robust && !debug_unbounded && elapsed_micros(started) >= NON_ROBUST_MAX_TOTAL_MICROS + { + break; + } + if !robust && !debug_unbounded && elapsed_micros(stage) >= NON_ROBUST_MAX_QUAD_MICROS { + break; + } let (warp_width, warp_height) = quad_dimensions(quad); if warp_width < 32 || warp_height < 32 { continue; } if let Ok(warped) = warp_perspective_gray(&gray, quad, warp_width, warp_height) { + debug.dump_gray(&format!("03_quad_warp_{index:02}"), &warped); let warped = DynamicImage::ImageLuma8(warped); - let decoded = if index == 0 { + let decoded = if robust && index == 0 { decoder .decode_auto_with_info(&warped) .or_else(|_| decode_resampled_full_frame(&decoder, &warped)) @@ -299,17 +351,44 @@ fn scan_still_with_diagnostics_inner( let mut attempts = Vec::new(); let padding = profile.min_anchor_px.max(8); let stage = scan_instant_now(); - let regions = still_scan_candidates(image, &binary, profile, padding, robust); + let mut regions = still_scan_candidates(&cv_image, &binary, profile, padding, robust); + if regions.is_empty() + && !robust + && let Some(bounds) = dark_bounds(&binary) + && let Some(region) = + fallback_ribbon_region_from_bounds(bounds, cv_image.width(), cv_image.height()) + { + regions.push(ScanCandidate::new( + CandidateDetector::RibbonWeave, + Some(glyphnet_core::LayoutFamily::RibbonWeave), + "binary-dark-bounds-fallback", + region, + )); + debug.log_line("added binary-dark-bounds-fallback candidate"); + } + if !robust && regions.len() > MAX_NON_ROBUST_DECODE_ATTEMPTS { + regions.truncate(MAX_NON_ROBUST_DECODE_ATTEMPTS); + } + debug.log_line(&format!("candidate_count={}", regions.len())); timings.candidate_micros = elapsed_micros(stage); let decode_started = scan_instant_now(); #[cfg(not(target_arch = "wasm32"))] - if !robust && regions.len() >= PARALLEL_DECODE_CANDIDATE_THRESHOLD { + if !robust + && regions.len() >= PARALLEL_DECODE_CANDIDATE_THRESHOLD + && image.width().saturating_mul(image.height()) <= 1_200_000 + { let mut results: Vec<(usize, ScanAttempt, Option)> = regions .into_par_iter() .enumerate() .map(|(index, candidate)| { let attempt_started = scan_instant_now(); + let candidate = adjust_candidate_for_full_resolution( + candidate, + image.width(), + image.height(), + 1.0, + ); let region = candidate.region; let cropped = image::imageops::crop_imm( image, @@ -319,9 +398,9 @@ fn scan_still_with_diagnostics_inner( region.height, ) .to_image(); - let cropped = DynamicImage::ImageRgba8(cropped); + let cropped = maybe_downscale_candidate_crop(DynamicImage::ImageRgba8(cropped)); let local_decoder = RasterDecoder::default(); - match decode_candidate(&local_decoder, &cropped, candidate) { + match decode_candidate(&local_decoder, &cropped, candidate, true) { Ok(decoded) => ( index, ScanAttempt { @@ -399,14 +478,58 @@ fn scan_still_with_diagnostics_inner( attempts.extend(results.into_iter().map(|(_, attempt, _)| attempt)); } else { for candidate in regions { + if let Some(max_attempts) = debug_max_attempts + && attempts.len() >= max_attempts + { + debug.log_line("debug max attempts reached"); + break; + } + if !robust && !debug_unbounded && elapsed_micros(started) >= NON_ROBUST_MAX_TOTAL_MICROS + { + break; + } + if !robust + && !debug_unbounded + && elapsed_micros(decode_started) >= NON_ROBUST_MAX_DECODE_MICROS + { + break; + } let attempt_started = scan_instant_now(); + let candidate = if cv_scale < 1.0 { + adjust_candidate_for_full_resolution( + candidate, + image.width(), + image.height(), + cv_scale, + ) + } else { + adjust_candidate_for_full_resolution(candidate, image.width(), image.height(), 1.0) + }; let region = candidate.region; + if !robust && region.width.saturating_mul(region.height) > NON_ROBUST_MAX_CANDIDATE_AREA + { + debug.log_line(&format!( + "skip candidate too-large area={} stage={}", + region.width.saturating_mul(region.height), + candidate.stage + )); + continue; + } + let crop_stage = scan_instant_now(); let cropped = image::imageops::crop_imm(image, region.x, region.y, region.width, region.height) .to_image(); - let cropped = DynamicImage::ImageRgba8(cropped); - match decode_candidate(&decoder, &cropped, candidate) { + let crop_us = elapsed_micros(crop_stage); + debug.dump_rgba_candidate(region, candidate.stage, &cropped); + let cropped = maybe_downscale_candidate_crop(DynamicImage::ImageRgba8(cropped)); + let decode_stage = scan_instant_now(); + match decode_candidate(&decoder, &cropped, candidate, !robust) { Ok(decoded) => { + let decode_us = elapsed_micros(decode_stage); + debug.log_line(&format!( + "candidate ok stage={} crop_us={} decode_us={}", + candidate.stage, crop_us, decode_us + )); attempts.push(ScanAttempt { detector: candidate.detector.as_str(), layout_hint: candidate.layout_hint, @@ -427,28 +550,47 @@ fn scan_still_with_diagnostics_inner( timings, }); } - Err(error) => attempts.push(ScanAttempt { - detector: candidate.detector.as_str(), - layout_hint: candidate.layout_hint, - stage: candidate.stage, - region, - decoded: false, - error: Some(error.to_string()), - duration_micros: elapsed_micros(attempt_started), - }), + Err(error) => { + let decode_us = elapsed_micros(decode_stage); + debug.log_line(&format!( + "candidate err stage={} crop_us={} decode_us={} err={}", + candidate.stage, crop_us, decode_us, error + )); + attempts.push(ScanAttempt { + detector: candidate.detector.as_str(), + layout_hint: candidate.layout_hint, + stage: candidate.stage, + region, + decoded: false, + error: Some(error.to_string()), + duration_micros: elapsed_micros(attempt_started), + }) + } } } } #[cfg(target_arch = "wasm32")] for candidate in regions { + if !robust && !debug_unbounded && elapsed_micros(started) >= NON_ROBUST_MAX_TOTAL_MICROS { + break; + } + if !robust + && !debug_unbounded + && elapsed_micros(decode_started) >= NON_ROBUST_MAX_DECODE_MICROS + { + break; + } let attempt_started = scan_instant_now(); + let candidate = + adjust_candidate_for_full_resolution(candidate, image.width(), image.height(), 1.0); let region = candidate.region; let cropped = image::imageops::crop_imm(image, region.x, region.y, region.width, region.height) .to_image(); - let cropped = DynamicImage::ImageRgba8(cropped); - match decode_candidate(&decoder, &cropped, candidate) { + debug.dump_rgba_candidate(region, candidate.stage, &cropped); + let cropped = maybe_downscale_candidate_crop(DynamicImage::ImageRgba8(cropped)); + match decode_candidate(&decoder, &cropped, candidate, !robust) { Ok(decoded) => { attempts.push(ScanAttempt { detector: candidate.detector.as_str(), @@ -483,6 +625,13 @@ fn scan_still_with_diagnostics_inner( } timings.decode_attempts_micros = elapsed_micros(decode_started); timings.total_micros = elapsed_micros(started); + debug.log_line(&format!("stage candidate_us={}", timings.candidate_micros)); + debug.log_line(&format!( + "stage decode_attempts_us={}", + timings.decode_attempts_micros + )); + debug.log_line(&format!("stage total_us={}", timings.total_micros)); + log_slowest_attempts(&mut debug, &attempts); if std::env::var_os("GLYPHNET_SCAN_DEBUG").is_some() { eprintln!("scan attempts: {attempts:#?}"); @@ -494,6 +643,156 @@ fn scan_still_with_diagnostics_inner( }) } +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Default)] +struct ScanDebugDumper { + dir: Option, + attempt: u32, + log: Option, + heatmap: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ScanDebugDumper { + fn from_env() -> Self { + let dir = std::env::var_os("GLYPHNET_SCAN_DEBUG_DIR").map(PathBuf::from); + let mut log = None; + if let Some(path) = &dir { + let _ = std::fs::create_dir_all(path); + if let Ok(file) = OpenOptions::new() + .create(true) + .append(true) + .open(path.join("heartbeat.log")) + { + log = Some(file); + } + } + Self { + dir, + attempt: 0, + log, + heatmap: None, + } + } + + fn dump_gray(&self, stem: &str, image: &image::GrayImage) { + let Some(dir) = &self.dir else { + return; + }; + let path = dir.join(format!("{stem}.png")); + let _ = image.save(path); + } + + fn is_enabled(&self) -> bool { + self.dir.is_some() + } + + fn max_attempts(&self) -> Option { + std::env::var("GLYPHNET_SCAN_DEBUG_MAX_ATTEMPTS") + .ok() + .and_then(|value| value.parse().ok()) + } + + fn dump_input(&mut self, image: &DynamicImage) { + let Some(dir) = &self.dir else { + return; + }; + let _ = image.save(dir.join("00_input.png")); + self.heatmap = Some(image.to_rgba8()); + self.log_line("saved 00_input.png"); + } + + fn log_line(&mut self, line: &str) { + let Some(file) = &mut self.log else { + return; + }; + let _ = writeln!(file, "{line}"); + let _ = file.flush(); + } + + fn dump_rgba_candidate(&mut self, region: ScanRegion, stage: &str, image: &image::RgbaImage) { + let Some(dir) = self.dir.clone() else { + return; + }; + self.log_line(&format!( + "attempt {:03} stage={stage} region=({},{} {}x{})", + self.attempt, region.x, region.y, region.width, region.height + )); + let path = dir.join(format!( + "attempt_{:03}_{stage}_x{}_y{}_w{}_h{}.png", + self.attempt, region.x, region.y, region.width, region.height + )); + self.attempt = self.attempt.saturating_add(1); + let _ = image.save(path); + self.paint_heat(region); + self.log_line("candidate image saved"); + } + + fn paint_heat(&mut self, region: ScanRegion) { + let Some(heatmap) = &mut self.heatmap else { + return; + }; + let x0 = region.x.min(heatmap.width().saturating_sub(1)); + let y0 = region.y.min(heatmap.height().saturating_sub(1)); + let x1 = region + .x + .saturating_add(region.width) + .min(heatmap.width()) + .max(x0.saturating_add(1)); + let y1 = region + .y + .saturating_add(region.height) + .min(heatmap.height()) + .max(y0.saturating_add(1)); + for y in y0..y1 { + for x in x0..x1 { + let pixel = heatmap.get_pixel_mut(x, y); + let r = pixel.0[0].saturating_add(18); + let g = pixel.0[1].saturating_sub(8); + let b = pixel.0[2].saturating_sub(8); + pixel.0 = [r, g, b, 255]; + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for ScanDebugDumper { + fn drop(&mut self) { + let (Some(dir), Some(heatmap)) = (&self.dir, &self.heatmap) else { + return; + }; + let _ = heatmap.save(dir.join("99_attempt_heatmap.png")); + } +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Default)] +struct ScanDebugDumper; + +#[cfg(target_arch = "wasm32")] +impl ScanDebugDumper { + fn from_env() -> Self { + Self + } + fn dump_gray(&self, _stem: &str, _image: &image::GrayImage) {} + fn is_enabled(&self) -> bool { + false + } + fn max_attempts(&self) -> Option { + None + } + fn dump_input(&mut self, _image: &DynamicImage) {} + fn log_line(&mut self, _line: &str) {} + fn dump_rgba_candidate( + &mut self, + _region: ScanRegion, + _stage: &str, + _image: &image::RgbaImage, + ) { + } +} + fn decode_resampled_full_frame( decoder: &RasterDecoder, image: &DynamicImage, @@ -526,6 +825,357 @@ fn decode_resampled_full_frame( Err(DecodeError::AutoDetectFailed) } +fn maybe_downscale_candidate_crop(image: DynamicImage) -> DynamicImage { + const MAX_CANDIDATE_DIM: u32 = 1280; + let width = image.width(); + let height = image.height(); + let longest = width.max(height); + if longest <= MAX_CANDIDATE_DIM { + return image; + } + let scale = MAX_CANDIDATE_DIM as f32 / longest as f32; + let target_width = ((width as f32 * scale).round() as u32).max(1); + let target_height = ((height as f32 * scale).round() as u32).max(1); + DynamicImage::ImageRgba8(image::imageops::resize( + &image, + target_width, + target_height, + image::imageops::FilterType::Triangle, + )) +} + +fn fallback_ribbon_region_from_bounds( + bounds: ScanRegion, + image_width: u32, + image_height: u32, +) -> Option { + if image_width < 104 || image_height < 44 { + return None; + } + let expanded = ScanRegion { + x: bounds.x.saturating_sub(bounds.width / 4), + y: bounds.y.saturating_sub(bounds.height / 3), + width: bounds.width.saturating_add(bounds.width / 2), + height: bounds.height.saturating_add(bounds.height / 2), + }; + let x = expanded.x.min(image_width.saturating_sub(1)); + let y = expanded.y.min(image_height.saturating_sub(1)); + let max_x = expanded + .x + .saturating_add(expanded.width) + .min(image_width) + .max(x.saturating_add(1)); + let max_y = expanded + .y + .saturating_add(expanded.height) + .min(image_height) + .max(y.saturating_add(1)); + let width = max_x.saturating_sub(x).max(1); + let height = max_y.saturating_sub(y).max(1); + if width < 104 || height < 44 { + return None; + } + Some(ScanRegion { + x, + y, + width, + height, + }) +} + +fn log_slowest_attempts(debug: &mut ScanDebugDumper, attempts: &[ScanAttempt]) { + if attempts.is_empty() { + debug.log_line("slowest attempts: none"); + return; + } + let mut sorted: Vec<&ScanAttempt> = attempts.iter().collect(); + sorted.sort_by_key(|attempt| std::cmp::Reverse(attempt.duration_micros)); + for (index, attempt) in sorted.into_iter().take(5).enumerate() { + debug.log_line(&format!( + "slowest[{index}] detector={} stage={} decoded={} us={} region=({},{} {}x{})", + attempt.detector, + attempt.stage, + attempt.decoded, + attempt.duration_micros, + attempt.region.x, + attempt.region.y, + attempt.region.width, + attempt.region.height + )); + } +} + +#[allow(dead_code)] +fn try_downscale_prepass_decode( + image: &DynamicImage, + profile: VisionProfile, + decoder: &RasterDecoder, + max_dim_px: u32, + max_candidates: usize, +) -> Result)>> { + let width = image.width(); + let height = image.height(); + if width == 0 || height == 0 { + return Ok(None); + } + + let longest = width.max(height); + if longest <= max_dim_px { + return Ok(None); + } + let scale = max_dim_px as f32 / longest as f32; + let small_width = ((width as f32 * scale).round() as u32).max(1); + let small_height = ((height as f32 * scale).round() as u32).max(1); + let downscaled = image::imageops::resize( + image, + small_width, + small_height, + image::imageops::FilterType::Triangle, + ); + let downscaled = DynamicImage::ImageRgba8(downscaled); + + let gray = grayscale(&downscaled)?; + let binary = adaptive_threshold(&gray, profile.threshold_radius, profile.threshold_bias)?; + let padding = profile.min_anchor_px.max(8); + let candidates = still_scan_candidates(&downscaled, &binary, profile, padding, false); + + let mut attempts = Vec::new(); + for candidate in candidates.into_iter().take(max_candidates) { + let mapped = map_candidate_to_full_resolution(candidate, width, height, scale); + let region = mapped.region; + let attempt_started = scan_instant_now(); + let cropped = + image::imageops::crop_imm(image, region.x, region.y, region.width, region.height) + .to_image(); + let cropped = DynamicImage::ImageRgba8(cropped); + match decode_candidate(decoder, &cropped, mapped, true) { + Ok(decoded) => { + attempts.push(ScanAttempt { + detector: "downscale-prepass", + layout_hint: candidate.layout_hint, + stage: candidate.stage, + region, + decoded: true, + error: None, + duration_micros: elapsed_micros(attempt_started), + }); + return Ok(Some((decoded, region, attempts))); + } + Err(error) => attempts.push(ScanAttempt { + detector: "downscale-prepass", + layout_hint: candidate.layout_hint, + stage: candidate.stage, + region, + decoded: false, + error: Some(error.to_string()), + duration_micros: elapsed_micros(attempt_started), + }), + } + } + + Ok(None) +} + +fn map_candidate_to_full_resolution( + candidate: ScanCandidate, + full_width: u32, + full_height: u32, + downscale_ratio: f32, +) -> ScanCandidate { + let inv = 1.0 / downscale_ratio.max(0.01); + let region = candidate.region; + let pad_x = (region.width as f32 * 0.08).round() as u32; + let pad_y = (region.height as f32 * 0.08).round() as u32; + let x0 = (region.x.saturating_sub(pad_x) as f32 * inv).floor() as u32; + let y0 = (region.y.saturating_sub(pad_y) as f32 * inv).floor() as u32; + let x1 = ((region.x + region.width + pad_x) as f32 * inv).ceil() as u32; + let y1 = ((region.y + region.height + pad_y) as f32 * inv).ceil() as u32; + let x = x0.min(full_width.saturating_sub(1)); + let y = y0.min(full_height.saturating_sub(1)); + let max_x = x1.min(full_width).max(x.saturating_add(1)); + let max_y = y1.min(full_height).max(y.saturating_add(1)); + ScanCandidate::new( + candidate.detector, + candidate.layout_hint, + candidate.stage, + ScanRegion { + x, + y, + width: max_x.saturating_sub(x).max(1), + height: max_y.saturating_sub(y).max(1), + }, + ) +} + +fn adjust_candidate_for_full_resolution( + candidate: ScanCandidate, + full_width: u32, + full_height: u32, + downscale_ratio: f32, +) -> ScanCandidate { + let mapped = if downscale_ratio < 1.0 { + map_candidate_to_full_resolution(candidate, full_width, full_height, downscale_ratio) + } else { + candidate + }; + ScanCandidate { + region: expand_edge_clipped_signature_region( + mapped.region, + mapped.stage, + full_width, + full_height, + ), + ..mapped + } +} + +fn expand_edge_clipped_signature_region( + region: ScanRegion, + stage: &'static str, + image_width: u32, + image_height: u32, +) -> ScanRegion { + if !matches!(stage, "signature-window" | "photo-grid" | "coarse-grid") { + return region; + } + if image_width < 104 || image_height < 44 { + return region; + } + + let right = region.x.saturating_add(region.width); + let bottom = region.y.saturating_add(region.height); + let touches_left = region.x == 0; + let touches_top = region.y == 0; + let touches_right = right >= image_width; + let touches_bottom = bottom >= image_height; + if !(touches_left || touches_top || touches_right || touches_bottom) { + return region; + } + + let mut width = region.width; + let mut height = region.height; + if touches_left || touches_right { + width = width.saturating_mul(4).div_ceil(3).min(image_width); + } + if touches_top || touches_bottom { + height = height.saturating_mul(4).div_ceil(3).min(image_height); + } + + // Edge-clamped rail detections often cover only a partial signature rail. + // Grow to the known RibbonWeave outer aspect so refinement sees the full symbol. + let aspect_width = height.saturating_mul(104).div_ceil(44); + width = width.max(aspect_width).min(image_width); + let aspect_height = width.saturating_mul(44).div_ceil(104); + height = height.max(aspect_height).min(image_height); + + let x = if touches_left { + 0 + } else if touches_right { + image_width.saturating_sub(width) + } else { + region + .x + .saturating_add(region.width / 2) + .saturating_sub(width / 2) + .min(image_width.saturating_sub(width)) + }; + let y = if touches_top { + 0 + } else if touches_bottom { + image_height.saturating_sub(height) + } else { + region + .y + .saturating_add(region.height / 2) + .saturating_sub(height / 2) + .min(image_height.saturating_sub(height)) + }; + + ScanRegion { + x, + y, + width, + height, + } +} + +#[allow(dead_code)] +fn try_quad_prepass_decode( + image: &DynamicImage, + profile: VisionProfile, + decoder: &RasterDecoder, + max_dim_px: u32, + max_quad_attempts: usize, +) -> Result> { + const PREPASS_MAX_MICROS: u64 = 35_000; + let prepass_started = scan_instant_now(); + let width = image.width(); + let height = image.height(); + if width == 0 || height == 0 { + return Ok(None); + } + let longest = width.max(height); + if longest <= max_dim_px { + return Ok(None); + } + + let scale = max_dim_px as f32 / longest as f32; + let small_width = ((width as f32 * scale).round() as u32).max(1); + let small_height = ((height as f32 * scale).round() as u32).max(1); + let downscaled = image::imageops::resize( + image, + small_width, + small_height, + image::imageops::FilterType::Triangle, + ); + let downscaled = DynamicImage::ImageRgba8(downscaled); + + let gray = grayscale(&downscaled)?; + if elapsed_micros(prepass_started) >= PREPASS_MAX_MICROS { + return Ok(None); + } + let binary = adaptive_threshold(&gray, profile.threshold_radius, profile.threshold_bias)?; + if elapsed_micros(prepass_started) >= PREPASS_MAX_MICROS { + return Ok(None); + } + let anchors = find_anchor_candidates(&binary, profile)?; + if elapsed_micros(prepass_started) >= PREPASS_MAX_MICROS { + return Ok(None); + } + let estimated_quad = estimate_quad(&binary, &anchors); + let dark_bounds_region = + if should_try_dark_bounds_fallback(small_width, small_height, anchors.len()) { + dark_bounds(&binary) + } else { + None + }; + let quad_candidates = build_quad_candidates( + estimated_quad, + dark_bounds_region, + small_width, + small_height, + ); + for quad in quad_candidates.into_iter().take(max_quad_attempts) { + if elapsed_micros(prepass_started) >= PREPASS_MAX_MICROS { + break; + } + let (warp_width, warp_height) = quad_dimensions(quad); + if warp_width < 32 || warp_height < 32 { + continue; + } + if let Ok(warped) = warp_perspective_gray(&gray, quad, warp_width, warp_height) { + let warped = DynamicImage::ImageLuma8(warped); + if let Ok(decoded) = decoder + .decode_auto_with_info(&warped) + .or_else(|_| decode_resampled_full_frame(decoder, &warped)) + { + return Ok(Some(decoded)); + } + } + } + Ok(None) +} + fn failed_cv( error: glyphnet_cv::CvError, mut timings: ScanTimings,