|
6 | 6 |
|
7 | 7 | use image::{GrayImage, Luma}; |
8 | 8 | use imageproc::corners::{oriented_fast, OrientedFastCorner}; |
9 | | -use imageproc::geometric_transformations::{rotate_about_center, Interpolation}; |
10 | 9 |
|
11 | 10 | /// Size of the square patch extracted around each keypoint. |
12 | 11 | pub const PATCH_SIZE: usize = 64; |
@@ -74,162 +73,20 @@ pub fn detect_keypoints(gray: &GrayImage, max_keypoints: usize) -> Vec<FeaturePo |
74 | 73 | /// rotated to canonical orientation (orientation angle removed). |
75 | 74 | /// |
76 | 75 | /// Returns the patch as a `Vec<f64>` in row-major order. |
77 | | -pub fn extract_normalized_patch(gray: &GrayImage, kp: &FeaturePoint) -> Vec<f64> { |
78 | | - // Extract a larger region to account for rotation (diagonal of patch) |
79 | | - let extract_radius = (HALF_PATCH as f64 * std::f64::consts::SQRT_2).ceil() as u32 + 2; |
80 | | - let extract_size = extract_radius * 2 + 1; |
81 | | - |
82 | | - // Extract the region centered on keypoint |
83 | | - let mut region = GrayImage::new(extract_size, extract_size); |
84 | | - let cx = kp.x as i64; |
85 | | - let cy = kp.y as i64; |
86 | | - |
87 | | - for ry in 0..extract_size { |
88 | | - for rx in 0..extract_size { |
89 | | - let sx = cx - extract_radius as i64 + rx as i64; |
90 | | - let sy = cy - extract_radius as i64 + ry as i64; |
91 | | - if sx >= 0 && sy >= 0 && (sx as u32) < gray.width() && (sy as u32) < gray.height() { |
92 | | - region.put_pixel(rx, ry, *gray.get_pixel(sx as u32, sy as u32)); |
93 | | - } |
94 | | - } |
95 | | - } |
96 | | - |
97 | | - // Rotate to cancel the keypoint orientation → canonical angle |
98 | | - let rotated = rotate_about_center( |
99 | | - ®ion, |
100 | | - -kp.orientation, |
101 | | - Interpolation::Bilinear, |
102 | | - Luma([0u8]), |
103 | | - ); |
104 | | - |
105 | | - // Crop the center PATCH_SIZE × PATCH_SIZE from the rotated region |
106 | | - let rot_cx = rotated.width() / 2; |
107 | | - let rot_cy = rotated.height() / 2; |
108 | | - let mut patch = vec![0.0f64; PATCH_SIZE * PATCH_SIZE]; |
109 | | - |
110 | | - for py in 0..PATCH_SIZE { |
111 | | - for px in 0..PATCH_SIZE { |
112 | | - let sx = rot_cx as i64 - HALF_PATCH as i64 + px as i64; |
113 | | - let sy = rot_cy as i64 - HALF_PATCH as i64 + py as i64; |
114 | | - if sx >= 0 && sy >= 0 && (sx as u32) < rotated.width() && (sy as u32) < rotated.height() |
115 | | - { |
116 | | - patch[py * PATCH_SIZE + px] = rotated.get_pixel(sx as u32, sy as u32).0[0] as f64; |
117 | | - } |
118 | | - } |
119 | | - } |
120 | | - |
121 | | - patch |
122 | | -} |
123 | 76 |
|
124 | 77 | /// Write a modified patch back to the image at the keypoint location, |
125 | 78 | /// reversing the canonical rotation. `mask` is an optional Gaussian blending |
126 | 79 | /// mask (same size as patch) with values 0.0-1.0. |
127 | | -pub fn write_patch_back(channel: &mut [Vec<f64>], kp: &FeaturePoint, patch: &[f64], mask: &[f64]) { |
128 | | - let height = channel.len() as u32; |
129 | | - let width = channel[0].len() as u32; |
130 | | - |
131 | | - // Build a GrayImage from the patch for rotation |
132 | | - let mut patch_img = GrayImage::new(PATCH_SIZE as u32, PATCH_SIZE as u32); |
133 | | - for py in 0..PATCH_SIZE { |
134 | | - for px in 0..PATCH_SIZE { |
135 | | - let v = patch[py * PATCH_SIZE + px].round().clamp(0.0, 255.0) as u8; |
136 | | - patch_img.put_pixel(px as u32, py as u32, Luma([v])); |
137 | | - } |
138 | | - } |
139 | | - |
140 | | - // Rotate back by +orientation (undo the canonical rotation) |
141 | | - let rotated = rotate_about_center( |
142 | | - &patch_img, |
143 | | - kp.orientation, |
144 | | - Interpolation::Bilinear, |
145 | | - Luma([0u8]), |
146 | | - ); |
147 | | - |
148 | | - // Also rotate the mask |
149 | | - let mut mask_img = GrayImage::new(PATCH_SIZE as u32, PATCH_SIZE as u32); |
150 | | - for py in 0..PATCH_SIZE { |
151 | | - for px in 0..PATCH_SIZE { |
152 | | - let v = (mask[py * PATCH_SIZE + px] * 255.0) |
153 | | - .round() |
154 | | - .clamp(0.0, 255.0) as u8; |
155 | | - mask_img.put_pixel(px as u32, py as u32, Luma([v])); |
156 | | - } |
157 | | - } |
158 | | - let rotated_mask = rotate_about_center( |
159 | | - &mask_img, |
160 | | - kp.orientation, |
161 | | - Interpolation::Bilinear, |
162 | | - Luma([0u8]), |
163 | | - ); |
164 | | - |
165 | | - // Blend rotated patch back into the channel using the rotated mask |
166 | | - let rot_cx = rotated.width() / 2; |
167 | | - let rot_cy = rotated.height() / 2; |
168 | | - |
169 | | - for ry in 0..rotated.height() { |
170 | | - for rx in 0..rotated.width() { |
171 | | - let dx = rx as i64 - rot_cx as i64; |
172 | | - let dy = ry as i64 - rot_cy as i64; |
173 | | - let tx = kp.x as i64 + dx; |
174 | | - let ty = kp.y as i64 + dy; |
175 | | - |
176 | | - if tx >= 0 && ty >= 0 && (tx as u32) < width && (ty as u32) < height { |
177 | | - let alpha = rotated_mask.get_pixel(rx, ry).0[0] as f64 / 255.0; |
178 | | - if alpha > 0.001 { |
179 | | - let old_val = channel[ty as usize][tx as usize]; |
180 | | - let new_val = rotated.get_pixel(rx, ry).0[0] as f64; |
181 | | - channel[ty as usize][tx as usize] = old_val * (1.0 - alpha) + new_val * alpha; |
182 | | - } |
183 | | - } |
184 | | - } |
185 | | - } |
186 | | -} |
187 | 80 |
|
188 | 81 | /// Generate a circular Gaussian blending mask for a PATCH_SIZE × PATCH_SIZE patch. |
189 | 82 | /// |
190 | 83 | /// Center pixels have weight 1.0, edges fall off smoothly to 0.0. |
191 | 84 | /// This prevents visible seams when blending watermarked patches back. |
192 | | -pub fn gaussian_blend_mask() -> Vec<f64> { |
193 | | - let mut mask = vec![0.0f64; PATCH_SIZE * PATCH_SIZE]; |
194 | | - let center = PATCH_SIZE as f64 / 2.0; |
195 | | - let sigma = PATCH_SIZE as f64 / 4.0; // Gaussian sigma |
196 | | - |
197 | | - for py in 0..PATCH_SIZE { |
198 | | - for px in 0..PATCH_SIZE { |
199 | | - let dx = px as f64 + 0.5 - center; |
200 | | - let dy = py as f64 + 0.5 - center; |
201 | | - let dist_sq = dx * dx + dy * dy; |
202 | | - let weight = (-dist_sq / (2.0 * sigma * sigma)).exp(); |
203 | | - mask[py * PATCH_SIZE + px] = weight; |
204 | | - } |
205 | | - } |
206 | | - mask |
207 | | -} |
208 | 85 |
|
209 | 86 | #[cfg(test)] |
210 | 87 | mod tests { |
211 | 88 | use super::*; |
212 | 89 |
|
213 | | - #[test] |
214 | | - fn test_gaussian_mask_properties() { |
215 | | - let mask = gaussian_blend_mask(); |
216 | | - assert_eq!(mask.len(), PATCH_SIZE * PATCH_SIZE); |
217 | | - |
218 | | - // Center should be close to 1.0 |
219 | | - let center_idx = (PATCH_SIZE / 2) * PATCH_SIZE + PATCH_SIZE / 2; |
220 | | - assert!(mask[center_idx] > 0.95, "Center weight should be ~1.0"); |
221 | | - |
222 | | - // Corners should be much lower |
223 | | - assert!(mask[0] < 0.2, "Corner weight should be low"); |
224 | | - |
225 | | - // Symmetric |
226 | | - let last = PATCH_SIZE * PATCH_SIZE - 1; |
227 | | - assert!( |
228 | | - (mask[0] - mask[last]).abs() < 1e-10, |
229 | | - "Mask should be symmetric" |
230 | | - ); |
231 | | - } |
232 | | - |
233 | 90 | #[test] |
234 | 91 | fn test_detect_keypoints_small_image() { |
235 | 92 | // Image too small for any keypoints |
@@ -260,27 +117,6 @@ mod tests { |
260 | 117 | } |
261 | 118 | } |
262 | 119 |
|
263 | | - #[test] |
264 | | - fn test_extract_normalized_patch_dimensions() { |
265 | | - let mut img = GrayImage::from_pixel(256, 256, Luma([128u8])); |
266 | | - // Add some texture |
267 | | - for y in 0..256u32 { |
268 | | - for x in 0..256u32 { |
269 | | - img.put_pixel(x, y, Luma([((x * 7 + y * 13) % 256) as u8])); |
270 | | - } |
271 | | - } |
272 | | - |
273 | | - let kp = FeaturePoint { |
274 | | - x: 128, |
275 | | - y: 128, |
276 | | - orientation: 0.0, |
277 | | - score: 100.0, |
278 | | - }; |
279 | | - |
280 | | - let patch = extract_normalized_patch(&img, &kp); |
281 | | - assert_eq!(patch.len(), PATCH_SIZE * PATCH_SIZE); |
282 | | - } |
283 | | - |
284 | 120 | #[test] |
285 | 121 | fn test_keypoints_deterministic() { |
286 | 122 | let mut img = GrayImage::from_pixel(256, 256, Luma([30u8])); |
|
0 commit comments