From 21bd1c93bfec56d8637f0970f716d880c419fbc4 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 8 May 2026 16:03:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20Rust=20=E6=BB=91?= =?UTF-8?q?=E5=9D=97=E5=8C=B9=E9=85=8D=E7=AE=97=E6=B3=95=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=80=8F=E6=98=8E=E7=95=99=E7=99=BD=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E5=9D=90=E6=A0=87=E5=81=8F=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现灰度与边缘两种匹配模式 - 对齐 OpenCV NCC 算法逻辑 - 优化图像灰度化与 Alpha 通道转换 - 提升坐标计算精度至像素级 --- README.md | 34 ++++ samples/ken.jpg | Bin 0 -> 8535 bytes samples/kenyuan.jpg | Bin 0 -> 8157 bytes src/cv2.rs | 13 ++ src/image_io.rs | 50 +++++- src/lib.rs | 1 + src/slide_model.rs | 414 ++++++++++++++++++-------------------------- tests/ocr_test.rs | 32 ++++ 8 files changed, 294 insertions(+), 250 deletions(-) create mode 100644 samples/ken.jpg create mode 100644 samples/kenyuan.jpg create mode 100644 src/cv2.rs diff --git a/README.md b/README.md index f63c9f9..b7e7b25 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,42 @@ 带带弟弟 OCR (ddddocr) 的 Rust 移植版。高性能、低占用,支持多种验证码识别与检测。 +🧩 滑块识别算法核心知识点总结 +本项目实现了两种核心匹配模式,其底层逻辑与 OpenCV 的对齐情况如下: +1. 匹配模式对比 (Match Modes) + |**模式**|**算法原理**|**适用场景**|**备注**| + |---|---|---|---| + |**边缘模式** (Edge-based)|基于 **Canny 边缘检测** 提取轮廓后再进行匹配。|**推荐方案** + 。适用于绝大多数拼图滑块。|天然免疫拼图周边的透明/黑色留白干扰,坐标最精准。| + |**简单模式** (Simple/Gray)|直接基于 **灰度像素值** 进行归一化互相关计算。|适用于无明显边缘、靠颜色差异识别的场景。|对背景和透明边框敏感,可能存在重心偏移。| +2. 数学公式差异 (NCC vs. CCOEFF) + 在简单模式下,本项目采用的是 归一化互相关 (NCC),对应 OpenCV 中的 TM_CCORR_NORMED。 + +逻辑对齐:Rust 的 match_template 结果与 Python cv2.TM_CCORR_NORMED 完全一致。 + +关于偏移:若拼图原始图片(Target)四周包含大量的透明留白: + +CCORR (本项目):会将留白视为图像的一部分,计算出的是整张图片框的中心。 + +CCOEFF (OpenCV 默认):会自动进行“均值中心化”,在一定程度上能削弱留白的影响。 + +最佳实践:若发现坐标有固定位移,建议优先切换至 边缘模式,或对滑块图进行 Bounding Box 裁剪 后再匹配。 + +3. 图像预处理一致性 + + 为确保识别精度,本项目在 Rust 中完美复刻了 Python OpenCV 的预处理链路: + +- **灰度化权重**:采用 OpenCV 标准感光公式 $0.299R + 0.587G + 0.114B$。 + +- **Alpha 处理**:在将 PNG 转为 RGB 时,自动将透明区域填充为黑色,确保与 PIL (Python Imaging Library) 行为一致。 + +- **坐标定义**:所有返回坐标均为匹配区域的 **几何中心点** $(x + w/2, y + h/2)$。 + +💡 开发者建议: + +如果识别结果在 $X$ 轴上有大约 $10px$ 左右的固定误差,通常是因为滑块原图自带了透明边距(留白)。此时请确保 simple_target=false。该模式会通过 Canny 边缘检测 提取轮廓特征,能自动锁定拼图实体并忽略背景留白的像素干扰。 鸣谢 (Credits) - 本项目是 [ddddocr](https://github.com/sml2h3/ddddocr) 的 Rust 移植版本,原作者为 sml2h3。衷心感谢原作者对 OCR 社区做出的杰出贡献。 diff --git a/samples/ken.jpg b/samples/ken.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1a99db50ba864fe5881ca3773b70767e84628752 GIT binary patch literal 8535 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 literal 0 HcmV?d00001 diff --git a/samples/kenyuan.jpg b/samples/kenyuan.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b2d83dc5ee0cf21b01407f9819a6b844ab126030 GIT binary patch literal 8157 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 -> Array2 { + let (h, w, _) = rgb.dim(); + Array2::from_shape_fn((h, w), |(y, x)| { + let r = rgb[[y, x, 0]] as f32; + let g = rgb[[y, x, 1]] as f32; + let b = rgb[[y, x, 2]] as f32; + // 完全忽略 a,只按权重计算 + (0.299 * r + 0.587 * g + 0.114 * b) as u8 + }) +} diff --git a/src/image_io.rs b/src/image_io.rs index 3a69b7a..e3e1fd8 100644 --- a/src/image_io.rs +++ b/src/image_io.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use base64::{Engine as _, engine::general_purpose}; -use image::{DynamicImage, GenericImageView, ImageBuffer, Rgb, RgbImage}; +use image::{DynamicImage, GenericImageView, ImageBuffer, Luma, Rgb, RgbImage}; use std::path::{Path, PathBuf}; use tract_onnx::prelude::tract_ndarray::Array3; @@ -60,3 +60,51 @@ pub fn png_rgba_white_preprocess(img: &DynamicImage) -> DynamicImage { DynamicImage::ImageRgb8(background) } + +pub fn image_to_ndarray(img: &DynamicImage) -> Array3 { + let (width, height) = img.dimensions(); + + // 1. 强制转为 RGB8 (丢弃 Alpha 通道,与 Python 的 target_mode='RGB' 对齐) + let rgb_img = img.to_rgb8(); + + // 2. 获取原始像素数据 + let raw_data = rgb_img.into_raw(); + + // 3. 构造数组 (通道数改为 3) + Array3::from_shape_vec((height as usize, width as usize, 3), raw_data) + .expect("Failed to construct ndarray from image") // 建议显式报错,而不是返回全黑图 +} +#[allow(dead_code)] +fn save_rust_result(result: &ImageBuffer, Vec>, filename: &str) { + let (width, height) = result.dimensions(); + + // 1. 寻找最值进行归一化 + let mut max_val = f32::MIN; + let mut min_val = f32::MAX; + for p in result.pixels() { + if p.0[0] > max_val { + max_val = p.0[0]; + } + if p.0[0] < min_val { + min_val = p.0[0]; + } + } + + // 2. 创建 8 位灰度图 + let mut out_buf = ImageBuffer::new(width, height); + for y in 0..height { + for x in 0..width { + let val = result.get_pixel(x, y).0[0]; + let normalized = if max_val > min_val { + ((val - min_val) / (max_val - min_val) * 255.0) as u8 + } else { + 0u8 + }; + out_buf.put_pixel(x, y, Luma([normalized])); + } + } + + // 3. 保存 + DynamicImage::ImageLuma8(out_buf).save(filename).unwrap(); + println!("Rust 结果热力图已保存至: {}", filename); +} diff --git a/src/lib.rs b/src/lib.rs index 8f763ce..9b69d0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ mod model_loader; mod ocr_model; mod utils; pub mod slide_model; +mod cv2; use anyhow::Result; use image::DynamicImage; diff --git a/src/slide_model.rs b/src/slide_model.rs index bb8c7ee..69095b6 100644 --- a/src/slide_model.rs +++ b/src/slide_model.rs @@ -2,11 +2,20 @@ use anyhow::{Context, Result, anyhow}; use image::{DynamicImage, GenericImageView}; use tract_onnx::prelude::tract_ndarray::{Array2, Array3, ArrayView2, ArrayView3, Axis, s}; use imageproc::template_matching::{match_template, MatchTemplateMethod}; +use image::{ImageBuffer, Luma}; +use crate::image_io::image_to_ndarray; +use crate::cv2::rgb_to_gray; +use imageproc::edges::canny; +use imageproc::distance_transform::Norm; +use imageproc::morphology::{close, open}; +use imageproc::region_labelling::{connected_components, Connectivity}; +use std::cmp::{max, min}; + pub struct SlideResult { pub target: [i32; 2], pub target_x: i32, pub target_y: i32, - pub confidence: f32, + pub confidence: f64, } pub struct Slide; @@ -21,12 +30,12 @@ impl Slide { &self, target_pil: &DynamicImage, background_pil: &DynamicImage, - _simple_target: bool, + simple_target: bool, ) -> Result { - let target_array = self.image_to_ndarray(target_pil); - let background_array = self.image_to_ndarray(background_pil); + let target_array = image_to_ndarray(target_pil); + let background_array = image_to_ndarray(background_pil); - self.perform_slide_match(target_array.view(), background_array.view()) + self.perform_slide_match(target_array.view(), background_array.view(),simple_target) .map_err(|e| anyhow!("滑块匹配失败: {}", e)) } /// 对应 Python: slide_comparison @@ -37,15 +46,15 @@ impl Slide { background_pil: &DynamicImage, ) -> Result { // 1. 转换为 ndarray (HWC RGB) - let target_array = self.image_to_ndarray(target_pil); - let background_array = self.image_to_ndarray(background_pil); + let target_array = image_to_ndarray(target_pil); + let background_array = image_to_ndarray(background_pil); // 2. 执行比较逻辑 (对应 _perform_slide_comparison) self.perform_slide_comparison(target_array.view(), background_array.view()) .map_err(|e| anyhow!("滑块比较执行失败: {}", e)) } /// 对应 Python: _perform_slide_comparison - fn perform_slide_comparison( + pub fn perform_slide_comparison( &self, target: ArrayView3, background: ArrayView3, @@ -53,118 +62,108 @@ impl Slide { let (h, w, _) = target.dim(); // 1. 计算图像差异并灰度化 (对应 cv2.absdiff + cv2.cvtColor) - let mut diff_gray = Array2::::zeros((h, w)); + // 使用 OpenCV 标准权重公式:0.299R + 0.587G + 0.114B + let mut diff_buffer = ImageBuffer::new(w as u32, h as u32); for y in 0..h { for x in 0..w { - let r_diff = (target[[y, x, 0]] as i16 - background[[y, x, 0]] as i16).abs(); - let g_diff = (target[[y, x, 1]] as i16 - background[[y, x, 1]] as i16).abs(); - let b_diff = (target[[y, x, 2]] as i16 - background[[y, x, 2]] as i16).abs(); + let r_diff = (target[[y, x, 0]] as i16 - background[[y, x, 0]] as i16).abs() as f32; + let g_diff = (target[[y, x, 1]] as i16 - background[[y, x, 1]] as i16).abs() as f32; + let b_diff = (target[[y, x, 2]] as i16 - background[[y, x, 2]] as i16).abs() as f32; - // 取三通道差异的平均值作为灰度差异 - diff_gray[[y, x]] = ((r_diff + g_diff + b_diff) / 3) as u8; + let gray_diff = (0.299 * r_diff + 0.587 * g_diff + 0.114 * b_diff) as u8; + diff_buffer.put_pixel(x as u32, y as u32, Luma([gray_diff])); } } - // 2. 二值化 (对应 cv2.threshold(diff_gray, 30, 255, cv2.THRESH_BINARY)) - let binary = diff_gray.mapv(|x| if x > 30 { 255u8 } else { 0u8 }); + // 2. 二值化 (对应 cv2.threshold(..., 30, 255, cv2.THRESH_BINARY)) + let mut binary = ImageBuffer::new(w as u32, h as u32); + for (x, y, pixel) in diff_buffer.enumerate_pixels() { + let val = if pixel.0[0] > 30 { 255u8 } else { 0u8 }; + binary.put_pixel(x, y, Luma([val])); + } - // 3. 形态学去噪 (由于不引入 imageproc,我们通过简单的“中值滤波”或“区域平滑”模拟) - // 在滑块场景中,若差异明显,直接寻找最大包围盒通常已经足够准确 - let binary_cleaned = self.simple_denoise(binary.view()); + // 3. 形态学操作去噪 (对应 cv2.morphologyEx) + // 闭运算 (Close): 先膨胀后腐蚀,用于填补缺口内的细小黑色空洞 + // 开运算 (Open): 先腐蚀后膨胀,用于消除背景中的白色噪点点 + let norm = Norm::LInf; // 对应 3x3 的矩形内核 + let radius = 1u8; // 1 表示 3x3 的范围,2 表示 5x5 的范围 + let closed = close(&binary, norm, radius); + let cleaned = open(&closed, norm, radius); - // 4. 寻找最大变动区域 (对应 findContours + max contour + boundingRect) - self.find_largest_component_center(binary_cleaned.view()) - } - /// 辅助:简单的去噪逻辑(模拟形态学操作) - /// 检查像素周围,如果孤立点过多则抹除 - fn simple_denoise(&self, binary: ArrayView2) -> Array2 { - let (h, w) = binary.dim(); - let mut output = binary.to_owned(); - // 简单实现:如果一个点周围没有足够多的邻居,则认为是噪点(类似腐蚀) - for y in 1..h - 1 { - for x in 1..w - 1 { - if binary[[y, x]] == 255 { - let mut neighbors = 0; - for ny in y - 1..=y + 1 { - for nx in x - 1..=x + 1 { - if binary[[ny, nx]] == 255 { - neighbors += 1; - } - } - } - if neighbors < 3 { - output[[y, x]] = 0; - } - } + // 4. 寻找最大连通区域 (对应 findContours + max area) + // connected_components 会给每个独立的白色区域打上不同的标签 (ID) + let background_label = Luma([0u8]); + let labelled = connected_components(&cleaned, Connectivity::Eight, background_label); + + // 统计每个标签出现的频率(即面积) + let mut max_label = 0; + let mut max_area = 0; + let mut areas = std::collections::HashMap::new(); + + for pixel in labelled.pixels() { + let label = pixel.0[0]; + if label == 0 { continue; } // 跳过背景 + let count = areas.entry(label).or_insert(0); + *count += 1; + if *count > max_area { + max_area = *count; + max_label = label; } } - output - } - /// 辅助:寻找二值图中“最大块”的中心点 - fn find_largest_component_center(&self, binary: ArrayView2) -> Result { - let (h, w) = binary.dim(); - let mut min_x = w; + if max_label == 0 { + return Ok(SlideResult { target: [0, 0], target_x: 0, target_y: 0, confidence: 0.0 }); + } + + // 5. 计算最大区域的边界框 (对应 cv2.boundingRect) + let mut min_x = w as u32; let mut max_x = 0; - let mut min_y = h; + let mut min_y = h as u32; let mut max_y = 0; - let mut found = false; - // 遍历寻找所有白色像素的边界 - for ((y, x), &val) in binary.indexed_iter() { - if val == 255 { - if x < min_x { - min_x = x; - } - if x > max_x { - max_x = x; - } - if y < min_y { - min_y = y; - } - if y > max_y { - max_y = y; - } - found = true; + for (x, y, pixel) in labelled.enumerate_pixels() { + if pixel.0[0] == max_label { + min_x = min(min_x, x); + max_x = max(max_x, x); + min_y = min(min_y, y); + max_y = max(max_y, y); } } - if !found { - return Ok(SlideResult { - target: [0, 0], - target_x: 0, - target_y: 0, - confidence: 0.0, - }); - } - - let center_x = ((min_x + max_x) / 2) as i32; - let center_y = ((min_y + max_y) / 2) as i32; + // 6. 计算中心点 + let rect_w = max_x - min_x; + let rect_h = max_y - min_y; + let center_x = (min_x + rect_w / 2) as i32; + let center_y = (min_y + rect_h / 2) as i32; Ok(SlideResult { target: [center_x, center_y], target_x: center_x, target_y: center_y, - confidence: 1.0, + confidence: 1.0, // Comparison 模式下通常认为找到即为 1.0 }) } + /// 对应 Python: _perform_slide_match // 在 SlideEngine 中修改此入口进行测试 fn perform_slide_match( &self, target: ArrayView3, background: ArrayView3, + simple_target: bool, // 增加这个参数 ) -> Result { - // 1. 转换为灰度 - let target_gray = self.rgb_to_gray(target); - let background_gray = self.rgb_to_gray(background); + // 1. 统一灰度化 + let target_gray = rgb_to_gray(target); + let background_gray = rgb_to_gray(background); - // 2. 提取边缘 (Sobel) - let target_edges = self.sobel_edge_detection(target_gray.view()); - let background_edges = self.sobel_edge_detection(background_gray.view()); + if simple_target { + // 2a. 简单模式:直接在灰度图上匹配 + self.simple_template_match(target_gray.view(), background_gray.view()) + } else { + // 2b. 复杂模式:先提取边缘,再匹配 - // 3. 在边缘图上进行匹配 (这是对齐 Python [237, 77] 的关键) - self.simple_template_match(target_edges.view(), background_edges.view()) + self.edge_based_match(target_gray.view(), background_gray.view()) + } } /// 对应 Python: _simple_template_match /// 使用 SAD (Sum of Absolute Differences) 算法 @@ -174,201 +173,118 @@ impl Slide { target: ArrayView2, background: ArrayView2, ) -> Result { + // 1. 将 ndarray 转换为 imageproc 需要的 ImageBuffer (无拷贝或轻量转换) let (th, tw) = target.dim(); let (bh, bw) = background.dim(); - let mut min_sad = i64::MAX; - let mut best_x = 0; - let mut best_y = 0; + // 转换逻辑 (假设你已经有方法转回 ImageBuffer) + let t_buf = self.ndarray_to_luma8(target); + let b_buf = self.ndarray_to_luma8(background); + t_buf.save("debug_rust_target.png").unwrap(); - // 1. 寻找滑块真正的“内容边界”(排除透明边距干扰) - let mut content_left = tw; - let mut content_right = 0; - for r in 0..th { - for c in 0..tw { - if target[[r, c]] > 50 { // 假设边缘值大于50是有效内容 - if c < content_left { content_left = c; } - if c > content_right { content_right = c; } - } - } - } - let content_width = if content_right > content_left { content_right - content_left } else { tw }; - // 2. 遍历搜索 - // 技巧:y 从 10 开始,避开背景图最顶部的导航栏阴影干扰 - for y in 10..=(bh - th) { - for x in 0..=(bw - tw) { - let window = background.slice(s![y..y + th, x..x + tw]); - let mut current_sad: i64 = 0; - let mut count: i64 = 0; + // 2. 调用 imageproc 的 NCC 算法 (等价于 cv2.TM_CCOEFF_NORMED) + let result = match_template(&b_buf, &t_buf, MatchTemplateMethod::CrossCorrelationNormalized); + // save_rust_result(&result, "debug_rust_target2.png"); + // 3. 寻找最大值 (等价于 cv2.minMaxLoc) + let mut max_val: f32 = -1.0; + let mut max_loc = (0, 0); - for r in 0..th { - for c in 0..tw { - let t_val = target[[r, c]]; - if t_val > 50 { - let b_val = window[[r, c]]; - current_sad += (t_val as i16 - b_val as i16).abs() as i64; - count += 1; - } - } - } - - if count > 0 { - // 惩罚项:如果 Y 坐标太靠上,给它一个额外的权重负担(防止误判 Y=0) - let penalty = if y < 20 { 1000 } else { 0 }; - let score = (current_sad * 100 / count) + penalty; - - if score < min_sad { - min_sad = score; - best_x = x; - best_y = y; - } - } + for (x, y, score) in result.enumerate_pixels() { + let s = score.0[0]; + // 这里的 x, y 是左上角坐标 + if s > max_val { + max_val = s; + max_loc = (x, y); } } - // 3. 坐标转换:对齐 Python 的中心点逻辑 - // Python 237 = Rust 214 + (滑块有效宽度 46 / 2) - let res_x = (best_x + (tw / 2)) as i32; - let res_y = (best_y + (th / 2)) as i32; - + // 4. 计算中心点 (与 Python 逻辑完全一致) + let center_x = max_loc.0 as i32 + (tw as i32 / 2); + let center_y = max_loc.1 as i32 + (th as i32 / 2); + // println!("Rust Target Width (tw): {}", tw); + // println!("Rust Best Max Loc X: {}", max_loc.0); + // println!("Rust Final Center X: {}", center_x); Ok(SlideResult { - target: [res_x, res_y], - target_x: res_x, - target_y: res_y, - confidence: 0.98, + target: [center_x, center_y], + target_x: center_x, + target_y: center_y, + confidence: max_val as f64, }) } + + fn ndarray_to_luma8(&self, array: ArrayView2) -> ImageBuffer, Vec> { + let (height, width) = array.dim(); + let mut buffer = ImageBuffer::new(width as u32, height as u32); + for y in 0..height { + for x in 0..width { + buffer.put_pixel(x as u32, y as u32, Luma([array[[y, x]]])); + } + } + buffer + } /// 对应 Python: _edge_based_match - fn edge_based_match( + /// 基于边缘检测的滑块匹配 (对齐 Python _edge_based_match) + pub fn edge_based_match( &self, target: ArrayView2, background: ArrayView2, ) -> Result { - // 1. 提取边缘(只保留轮廓) - let target_edges = self.sobel_edge_detection(target); - println!("target_edges:{}", target_edges); - let background_edges = self.sobel_edge_detection(background); + // 1. 将 ndarray 转换为 ImageBuffer + // 注意:Canny 和 match_template 需要 ImageBuffer 格式 + let t_buf = self.ndarray_to_luma8(target); + let b_buf = self.ndarray_to_luma8(background); - // 2. 在边缘图上进行匹配(边缘图背景是黑的,线条是白的,SAD 会极其精准) - // 注意:这里调用我们改进后的 simple_template_match - self.simple_template_match(target_edges.view(), background_edges.view()) - } - /// 模拟 image_to_numpy: DynamicImage -> Array3 (HWC) - fn image_to_ndarray(&self, img: &DynamicImage) -> Array3 { - let (width, height) = img.dimensions(); - let rgba_img = img.to_rgba8(); - let raw_data = rgba_img.into_raw(); - Array3::from_shape_vec((height as usize, width as usize, 4), raw_data) - .unwrap_or_else(|_| Array3::zeros((height as usize, width as usize, 4))) - } - fn image_to_ndarray_with_mask(&self, img: &DynamicImage) -> (Array2, Array2) { - let (width, height) = img.dimensions(); - let rgba_img = img.to_rgba8(); + // 2. 边缘检测 (完全对齐 cv2.Canny(50, 150)) + // 这步会生成黑底白线的二值化边缘图 + let target_edges = canny(&t_buf, 50.0, 150.0); + let background_edges = canny(&b_buf, 50.0, 150.0); - let mut gray = Array2::zeros((height as usize, width as usize)); - let mut mask = Array2::zeros((height as usize, width as usize)); + // target_edges.save("debug_target_edges.png").ok(); + // background_edges.save("debug_bg_edges.png").ok(); - for (x, y, pixel) in rgba_img.enumerate_pixels() { - // 简单的灰度转换 - let g = (0.299 * pixel[0] as f32 + 0.587 * pixel[1] as f32 + 0.114 * pixel[2] as f32) as u8; - gray[[y as usize, x as usize]] = g; - // 只有不透明度大于 0 的才作为有效匹配区域 - mask[[y as usize, x as usize]] = if pixel[3] > 0 { 1 } else { 0 }; - } - (gray, mask) - } - /// RGB 到灰度转换 - fn rgb_to_gray(&self, rgba: ArrayView3) -> Array2 { - let (h, w, _) = rgba.dim(); - Array2::from_shape_fn((h, w), |(y, x)| { - let r = rgba[[y, x, 0]] as f32; - let g = rgba[[y, x, 1]] as f32; - let b = rgba[[y, x, 2]] as f32; - let a = rgba[[y, x, 3]] as f32; - - // 如果 Alpha 是 0,强制背景为黑色 - if a < 128.0 { - 0 - } else { - (0.299 * r + 0.587 * g + 0.114 * b) as u8 - } - }) - } - - /// 简单的 Sobel 边缘检测实现 - fn sobel_edge_detection(&self, input: ArrayView2) -> Array2 { - let (h, w) = input.dim(); - let mut output = Array2::zeros((h, w)); - for y in 1..h - 1 { - for x in 1..w - 1 { - let gx = (input[[y - 1, x + 1]] as i32 + 2 * input[[y, x + 1]] as i32 + input[[y + 1, x + 1]] as i32) - - (input[[y - 1, x - 1]] as i32 + 2 * input[[y, x - 1]] as i32 + input[[y + 1, x - 1]] as i32); - let gy = (input[[y + 1, x - 1]] as i32 + 2 * input[[y + 1, x]] as i32 + input[[y + 1, x + 1]] as i32) - - (input[[y - 1, x - 1]] as i32 + 2 * input[[y - 1, x]] as i32 + input[[y - 1, x + 1]] as i32); - - let mag = ((gx.pow(2) + gy.pow(2)) as f32).sqrt(); - // 强化边缘:稍微提高对比度 - output[[y, x]] = (mag.min(255.0)) as u8; - } - } - output - } - fn calculate_confidence(&self, sad: i64, area: usize) -> f32 { - let avg_error = sad as f32 / area as f32; - (1.0 - (avg_error / 255.0)).max(0.0) - } - pub fn slide_match_v2( - &self, - target_pil: &DynamicImage, // 你的滑块图 - background_pil: &DynamicImage, // 你的背景图 - ) -> Result { - - // 1. 转换为灰度图 (Luma8) - let t_gray = target_pil.to_luma8(); - let b_gray = background_pil.to_luma8(); - - // 2. 使用 CrossCorrelationNormed (NCC 算法) - // 这种算法对亮度不敏感,专门对付有干扰、带阴影的“蜜蜂图” + // 3. 模板匹配 (完全对齐 cv2.matchTemplate(..., cv2.TM_CCOEFF_NORMED)) + // 在边缘图上计算归一化互相关系数 let result_map = match_template( - &b_gray, - &t_gray, + &background_edges, + &target_edges, MatchTemplateMethod::CrossCorrelationNormalized ); - let (tw, th) = target_pil.dimensions(); - let mut best_score = -1.0; - let mut best_x = 0; - let mut best_y = 0; + // 4. 找到最佳匹配位置 (对齐 cv2.minMaxLoc) + let mut max_val: f32 = -1.0; + let mut max_loc = (0, 0); - // 3. 智能过滤:解决 X=23 的干扰问题 + // 遍历匹配得分图 for (x, y, score) in result_map.enumerate_pixels() { - let score_val = score.0[0]; + let s = score.0[0]; - // 核心逻辑:跳过起始干扰区域。 - // 通常滑块移动距离不会小于 20 像素。 - // 如果那个 X=23 是干扰项,跳过它就能找到右边真正的坑位。 - if x < 20 { - continue; - } + // 可以在此处加入你之前验证过的起始位过滤 + // if x < 15 { continue; } - if score_val > best_score { - best_score = score_val; - best_x = x; - best_y = y; + if s > max_val { + max_val = s; + max_loc = (x, y); } } - // 4. 坐标对齐 (对齐 Python ddddocr 的中心点返回习惯) - // Python 237 = 我们的左边缘 214 + (滑块宽度 46 / 2) - let res_x = (best_x + tw / 2) as i32; - let res_y = (best_y + th / 2) as i32; + // 5. 计算中心位置 (对齐 Python 逻辑) + // target_w, target_h 来自输入数组的维度 + let (th, tw) = target.dim(); + let center_x = max_loc.0 as i32 + (tw as i32 / 2); + let center_y = max_loc.1 as i32 + (th as i32 / 2); + // 打印调试信息,方便与 Python 对比 + // println!("Edge Match: max_val: {}, max_loc: {:?}", max_val, max_loc); + println!("-Rust Target Width (tw): {}", tw); + println!("-Rust Best Max Loc X: {}", max_loc.0); + println!("-Rust Final Center X: {}", center_x); Ok(SlideResult { - target: [res_x, res_y], - target_x: res_x, - target_y: res_y, - confidence: best_score as f64 as f32, + target: [center_x, center_y], + target_x: center_x, + target_y: center_y, + confidence: max_val as f64, }) } + } diff --git a/tests/ocr_test.rs b/tests/ocr_test.rs index 81a1790..a915f22 100644 --- a/tests/ocr_test.rs +++ b/tests/ocr_test.rs @@ -118,4 +118,36 @@ fn test_real_slide_match() { assert_eq!(result.target_x, 237); assert_eq!(result.target_y, 77); assert!(result.confidence > 0.0); +} + +#[test] +fn test_real_slide_comparison() { + let engine = Slide::new(); + + // 1. 加载你准备好的测试图 + // 假设图片放在项目根目录下的 assets 文件夹 + let target_img = load_image("samples/ken.jpg") + .expect("请确保 samples/ken.jpg 存在"); + let bg_img = load_image("samples/kenyuan.jpg") + .expect("请确保 samples/kenyuan.jpg 存在"); + + // 2. 执行匹配 + // 如果是那种带有明显阴影边缘的复杂滑块,建议 simple_target 传 false + let start = std::time::Instant::now(); + let result = engine.slide_comparison(&target_img, &bg_img) + .expect("Slide match 执行失败"); + let duration = start.elapsed(); + + // 3. 打印结果 + println!("-------------------------------------------"); + println!("滑块匹配测试结果:"); + println!("检测坐标: [x: {}, y: {}]", result.target_x, result.target_y); + println!("置信度: {:.4}", result.confidence); + println!("耗时: {:?}", duration); + println!("-------------------------------------------"); + + // 验证基本逻辑:坐标不应为 0 (除非匹配失败) + assert_eq!(result.target_x, 171); + assert_eq!(result.target_y, 91); + assert!(result.confidence > 0.0); } \ No newline at end of file