Skip to content

Commit 79cf00e

Browse files
authored
feat(scanner): support variable Ribbon geometries (#36)
* feat(scanner): support variable Ribbon geometries * fix(scanner): bound variable ribbon fallback cost
1 parent a482d29 commit 79cf00e

3 files changed

Lines changed: 179 additions & 91 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*.wasm
99
*.dSYM/
1010
.playwright-mcp/
11+
.serena/
1112
.DS_Store
1213
Thumbs.db
1314
node_modules/

crates/glyphnet-scanner/src/decode_paths.rs

Lines changed: 144 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,6 @@ pub(crate) fn decode_candidate(
2424
if let Ok(decoded) = decode_fractional_ribbon_candidate(image) {
2525
return Ok(decoded);
2626
}
27-
let target_module_px = 4;
28-
let resized = image::imageops::resize(
29-
image,
30-
104 * target_module_px,
31-
44 * target_module_px,
32-
image::imageops::FilterType::Triangle,
33-
);
34-
let resized = DynamicImage::ImageRgba8(resized);
35-
let normalized_region = ScanRegion {
36-
x: 0,
37-
y: 0,
38-
width: 104 * target_module_px,
39-
height: 44 * target_module_px,
40-
};
41-
if let Ok(decoded) = decode_exact_ribbon_candidate(&resized, normalized_region) {
42-
return Ok(decoded);
43-
}
4427
return Err(DecodeError::AutoDetectFailed);
4528
}
4629

@@ -117,92 +100,112 @@ fn decode_exact_ribbon_candidate(
117100
image: &DynamicImage,
118101
region: ScanRegion,
119102
) -> std::result::Result<AutoDecodedSymbol, DecodeError> {
120-
if region.width >= 104 && region.height >= 44 {
121-
let module_px = (region.width / 104).max(1);
122-
if region.width == 104 * module_px && region.height == 44 * module_px {
123-
for threshold in [160, 192, 224] {
124-
let exact = RasterDecoder::new(DecodeOptions {
125-
module_px,
126-
quiet_zone_modules: 4,
127-
threshold,
128-
layout: LayoutFamily::RibbonWeave,
103+
for module_px in ribbon_module_px_candidates(region) {
104+
let symbol_width = region.width / module_px - 8;
105+
let symbol_height = region.height / module_px - 8;
106+
if !reference_ribbon_geometry(symbol_width, symbol_height) {
107+
continue;
108+
}
109+
for threshold in [160, 192, 224] {
110+
let exact = RasterDecoder::new(DecodeOptions {
111+
module_px,
112+
quiet_zone_modules: 4,
113+
threshold,
114+
layout: LayoutFamily::RibbonWeave,
115+
});
116+
if let Ok(decoded) = exact.decode(image) {
117+
return Ok(AutoDecodedSymbol {
118+
decoded,
119+
info: glyphnet_decode::AutoDecodeInfo {
120+
module_px,
121+
quiet_zone_modules: 4,
122+
threshold,
123+
layout: LayoutFamily::RibbonWeave,
124+
},
129125
});
130-
if let Ok(decoded) = exact.decode(image) {
131-
return Ok(AutoDecodedSymbol {
132-
decoded,
133-
info: glyphnet_decode::AutoDecodeInfo {
134-
module_px,
135-
quiet_zone_modules: 4,
136-
threshold,
137-
layout: LayoutFamily::RibbonWeave,
138-
},
139-
});
140-
}
141126
}
142127
}
143128
}
144129
Err(DecodeError::AutoDetectFailed)
145130
}
146131

132+
fn ribbon_module_px_candidates(region: ScanRegion) -> Vec<u32> {
133+
let gcd = gcd_u32(region.width, region.height);
134+
let mut candidates = Vec::new();
135+
for module_px in 1..=32 {
136+
if gcd % module_px != 0 {
137+
continue;
138+
}
139+
let width_modules = region.width / module_px;
140+
let height_modules = region.height / module_px;
141+
if width_modules <= 8 || height_modules <= 8 {
142+
continue;
143+
}
144+
if reference_ribbon_geometry(width_modules - 8, height_modules - 8) {
145+
candidates.push(module_px);
146+
}
147+
}
148+
candidates.sort_unstable_by(|a, b| b.cmp(a));
149+
candidates
150+
}
151+
147152
fn decode_fractional_ribbon_candidate(
148153
image: &DynamicImage,
149154
) -> std::result::Result<AutoDecodedSymbol, DecodeError> {
150-
const SYMBOL_WIDTH: u16 = 96;
151-
const SYMBOL_HEIGHT: u16 = 36;
152-
const TOTAL_WIDTH_MODULES: f32 = 104.0;
153-
const TOTAL_HEIGHT_MODULES: f32 = 44.0;
154155
const QUIET_MODULES: f32 = 4.0;
155156

156157
let luma = image.to_luma8();
157158
if luma.width() < 104 || luma.height() < 44 {
158159
return Err(DecodeError::AutoDetectFailed);
159160
}
160-
let base_scale_x = luma.width() as f32 / TOTAL_WIDTH_MODULES;
161-
let base_scale_y = luma.height() as f32 / TOTAL_HEIGHT_MODULES;
162-
if base_scale_x < 1.0 || base_scale_y < 1.0 {
163-
return Err(DecodeError::AutoDetectFailed);
164-
}
165161

166162
let otsu = fractional_threshold(&luma);
167163
let integral = IntegralGray::new(&luma);
168164
let mut thresholds = vec![otsu, 160, 192, 224];
169165
thresholds.sort_unstable();
170166
thresholds.dedup();
171167

172-
for scale_adjust in [1.0_f32, 0.985, 1.015, 0.97, 1.03] {
173-
let scale_x = base_scale_x * scale_adjust;
174-
let scale_y = base_scale_y * scale_adjust;
175-
if scale_x < 1.0 || scale_y < 1.0 {
168+
for geometry in fractional_ribbon_geometry_candidates(luma.width(), luma.height()) {
169+
let base_scale_x = luma.width() as f32 / geometry.total_width_modules() as f32;
170+
let base_scale_y = luma.height() as f32 / geometry.total_height_modules() as f32;
171+
if base_scale_x < 1.0 || base_scale_y < 1.0 {
176172
continue;
177173
}
178-
for y_shift in module_shifts(3) {
179-
for x_shift in module_shifts(2) {
180-
let origin_x = QUIET_MODULES + x_shift;
181-
let origin_y = QUIET_MODULES + y_shift;
182-
if origin_x < -2.0 || origin_y < -8.0 {
183-
continue;
184-
}
185-
if !fractional_grid_fits(
186-
&luma,
187-
origin_x,
188-
origin_y,
189-
scale_x,
190-
scale_y,
191-
SYMBOL_WIDTH,
192-
SYMBOL_HEIGHT,
193-
) {
194-
continue;
195-
}
196-
for &threshold in &thresholds {
197-
if !fractional_header_precheck(
198-
&integral, origin_x, origin_y, scale_x, scale_y, threshold,
199-
) {
174+
for scale_adjust in [1.0_f32, 0.985, 1.015, 0.97, 1.03] {
175+
let scale_x = base_scale_x * scale_adjust;
176+
let scale_y = base_scale_y * scale_adjust;
177+
if scale_x < 1.0 || scale_y < 1.0 {
178+
continue;
179+
}
180+
for y_shift in module_shifts(3) {
181+
for x_shift in module_shifts(3) {
182+
let origin_x = QUIET_MODULES + x_shift;
183+
let origin_y = QUIET_MODULES + y_shift;
184+
if origin_x < -2.0 || origin_y < -8.0 {
200185
continue;
201186
}
202-
if let Ok(decoded) = decode_fractional_with_params(
203-
&integral, origin_x, origin_y, scale_x, scale_y, threshold,
187+
if !fractional_grid_fits(
188+
&luma,
189+
origin_x,
190+
origin_y,
191+
scale_x,
192+
scale_y,
193+
geometry.symbol_width,
194+
geometry.symbol_height,
204195
) {
205-
return Ok(decoded);
196+
continue;
197+
}
198+
for &threshold in &thresholds {
199+
if !fractional_header_precheck(
200+
&integral, geometry, origin_x, origin_y, scale_x, scale_y, threshold,
201+
) {
202+
continue;
203+
}
204+
if let Ok(decoded) = decode_fractional_with_params(
205+
&integral, geometry, origin_x, origin_y, scale_x, scale_y, threshold,
206+
) {
207+
return Ok(decoded);
208+
}
206209
}
207210
}
208211
}
@@ -212,28 +215,77 @@ fn decode_fractional_ribbon_candidate(
212215
Err(DecodeError::AutoDetectFailed)
213216
}
214217

218+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219+
struct RibbonGeometry {
220+
symbol_width: u16,
221+
symbol_height: u16,
222+
}
223+
224+
impl RibbonGeometry {
225+
const fn total_width_modules(self) -> u16 {
226+
self.symbol_width + 8
227+
}
228+
229+
const fn total_height_modules(self) -> u16 {
230+
self.symbol_height + 8
231+
}
232+
}
233+
234+
fn fractional_ribbon_geometry_candidates(
235+
image_width: u32,
236+
image_height: u32,
237+
) -> Vec<RibbonGeometry> {
238+
const DEFAULT_PRINT_RIBBON: RibbonGeometry = RibbonGeometry {
239+
symbol_width: 96,
240+
symbol_height: 36,
241+
};
242+
243+
let scale_x = image_width as f32 / DEFAULT_PRINT_RIBBON.total_width_modules() as f32;
244+
let scale_y = image_height as f32 / DEFAULT_PRINT_RIBBON.total_height_modules() as f32;
245+
if scale_x >= 1.0 && scale_y >= 1.0 && (0.6..=1.7).contains(&(scale_x / scale_y)) {
246+
vec![DEFAULT_PRINT_RIBBON]
247+
} else {
248+
Vec::new()
249+
}
250+
}
251+
252+
fn reference_ribbon_geometry(width: u32, height: u32) -> bool {
253+
width >= 96
254+
&& height >= 28
255+
&& width % 4 == 0
256+
&& height % 4 == 0
257+
&& (2.0..=8.0).contains(&(width as f32 / height.max(1) as f32))
258+
}
259+
260+
fn gcd_u32(mut a: u32, mut b: u32) -> u32 {
261+
while b != 0 {
262+
let tmp = a % b;
263+
a = b;
264+
b = tmp;
265+
}
266+
a
267+
}
268+
215269
fn module_shifts(radius: i32) -> impl Iterator<Item = f32> {
216270
(-radius * 2..=radius * 2).map(|value| value as f32 * 0.5)
217271
}
218272

219273
fn fractional_header_precheck(
220274
integral: &IntegralGray,
275+
geometry: RibbonGeometry,
221276
origin_x_modules: f32,
222277
origin_y_modules: f32,
223278
scale_x: f32,
224279
scale_y: f32,
225280
threshold: u8,
226281
) -> bool {
227-
const SYMBOL_WIDTH: u16 = 96;
228-
const SYMBOL_HEIGHT: u16 = 36;
229-
230282
let mut bits = Vec::with_capacity(HEADER_LEN * 8);
231-
'rows: for y in 0..SYMBOL_HEIGHT {
232-
for x in 0..SYMBOL_WIDTH {
283+
'rows: for y in 0..geometry.symbol_height {
284+
for x in 0..geometry.symbol_width {
233285
if !layout::is_data_module_for(
234286
LayoutFamily::RibbonWeave,
235-
SYMBOL_WIDTH,
236-
SYMBOL_HEIGHT,
287+
geometry.symbol_width,
288+
geometry.symbol_height,
237289
x,
238290
y,
239291
) {
@@ -279,23 +331,24 @@ fn fractional_grid_fits(
279331

280332
fn decode_fractional_with_params(
281333
integral: &IntegralGray,
334+
geometry: RibbonGeometry,
282335
origin_x_modules: f32,
283336
origin_y_modules: f32,
284337
scale_x: f32,
285338
scale_y: f32,
286339
threshold: u8,
287340
) -> std::result::Result<AutoDecodedSymbol, DecodeError> {
288-
const SYMBOL_WIDTH: u16 = 96;
289-
const SYMBOL_HEIGHT: u16 = 36;
290-
291-
let mut matrix =
292-
SymbolMatrix::with_layout(SYMBOL_WIDTH, SYMBOL_HEIGHT, LayoutFamily::RibbonWeave);
293-
for y in 0..SYMBOL_HEIGHT {
294-
for x in 0..SYMBOL_WIDTH {
341+
let mut matrix = SymbolMatrix::with_layout(
342+
geometry.symbol_width,
343+
geometry.symbol_height,
344+
LayoutFamily::RibbonWeave,
345+
);
346+
for y in 0..geometry.symbol_height {
347+
for x in 0..geometry.symbol_width {
295348
if let Some(cell) = layout::function_cell_for(
296349
LayoutFamily::RibbonWeave,
297-
SYMBOL_WIDTH,
298-
SYMBOL_HEIGHT,
350+
geometry.symbol_width,
351+
geometry.symbol_height,
299352
x,
300353
y,
301354
) {

crates/glyphnet-scanner/src/lib.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,40 @@ mod tests {
13021302
assert_scan_payload(&image, payload);
13031303
}
13041304

1305+
#[test]
1306+
fn decode_candidate_accepts_variable_size_ribbon_geometry() {
1307+
let payload = vec![0x47; 384];
1308+
let encoded = Encoder::default().encode_static(&payload).unwrap();
1309+
assert_ne!(
1310+
(encoded.matrix.width(), encoded.matrix.height()),
1311+
(96, 36),
1312+
"payload should force a non-reference RibbonWeave geometry"
1313+
);
1314+
let symbol = RasterRenderer::new(RenderOptions {
1315+
module_px: 3,
1316+
quiet_zone_modules: 4,
1317+
..RenderOptions::default()
1318+
})
1319+
.render(&encoded.matrix)
1320+
.unwrap();
1321+
let image = DynamicImage::ImageRgba8(symbol.clone());
1322+
let candidate = ScanCandidate::new(
1323+
CandidateDetector::RibbonWeave,
1324+
Some(LayoutFamily::RibbonWeave),
1325+
"dark-ribbon",
1326+
ScanRegion {
1327+
x: 0,
1328+
y: 0,
1329+
width: symbol.width(),
1330+
height: symbol.height(),
1331+
},
1332+
);
1333+
1334+
let result = decode_candidate(&RasterDecoder::default(), &image, candidate).unwrap();
1335+
assert_eq!(result.decoded.frame.payload, payload);
1336+
assert_eq!(result.info.layout, LayoutFamily::RibbonWeave);
1337+
}
1338+
13051339
#[test]
13061340
fn scan_still_decodes_generated_matrix_canvas() {
13071341
let payload = b"matrix baseline";

0 commit comments

Comments
 (0)