Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*.wasm
*.dSYM/
.playwright-mcp/
.serena/
.DS_Store
Thumbs.db
node_modules/
Expand Down
235 changes: 144 additions & 91 deletions crates/glyphnet-scanner/src/decode_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,6 @@ pub(crate) fn decode_candidate(
if let Ok(decoded) = decode_fractional_ribbon_candidate(image) {
return Ok(decoded);
}
let target_module_px = 4;
let resized = image::imageops::resize(
image,
104 * target_module_px,
44 * target_module_px,
image::imageops::FilterType::Triangle,
);
let resized = DynamicImage::ImageRgba8(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) {
return Ok(decoded);
}
return Err(DecodeError::AutoDetectFailed);
}

Expand Down Expand Up @@ -117,92 +100,112 @@ fn decode_exact_ribbon_candidate(
image: &DynamicImage,
region: ScanRegion,
) -> std::result::Result<AutoDecodedSymbol, DecodeError> {
if region.width >= 104 && region.height >= 44 {
let module_px = (region.width / 104).max(1);
if region.width == 104 * module_px && region.height == 44 * module_px {
for threshold in [160, 192, 224] {
let exact = RasterDecoder::new(DecodeOptions {
module_px,
quiet_zone_modules: 4,
threshold,
layout: LayoutFamily::RibbonWeave,
for module_px in ribbon_module_px_candidates(region) {
let symbol_width = region.width / module_px - 8;
let symbol_height = region.height / module_px - 8;
if !reference_ribbon_geometry(symbol_width, symbol_height) {
continue;
}
for threshold in [160, 192, 224] {
let exact = RasterDecoder::new(DecodeOptions {
module_px,
quiet_zone_modules: 4,
threshold,
layout: LayoutFamily::RibbonWeave,
});
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,
},
});
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,
},
});
}
}
}
}
Err(DecodeError::AutoDetectFailed)
}

fn ribbon_module_px_candidates(region: ScanRegion) -> Vec<u32> {
let gcd = gcd_u32(region.width, region.height);
let mut candidates = Vec::new();
for module_px in 1..=32 {
if gcd % module_px != 0 {
continue;
}
let width_modules = region.width / module_px;
let height_modules = region.height / module_px;
if width_modules <= 8 || height_modules <= 8 {
continue;
}
if reference_ribbon_geometry(width_modules - 8, height_modules - 8) {
candidates.push(module_px);
}
}
candidates.sort_unstable_by(|a, b| b.cmp(a));
candidates
}

fn decode_fractional_ribbon_candidate(
image: &DynamicImage,
) -> std::result::Result<AutoDecodedSymbol, DecodeError> {
const SYMBOL_WIDTH: u16 = 96;
const SYMBOL_HEIGHT: u16 = 36;
const TOTAL_WIDTH_MODULES: f32 = 104.0;
const TOTAL_HEIGHT_MODULES: f32 = 44.0;
const QUIET_MODULES: f32 = 4.0;

let luma = image.to_luma8();
if luma.width() < 104 || luma.height() < 44 {
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 = vec![otsu, 160, 192, 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 {
for geometry in fractional_ribbon_geometry_candidates(luma.width(), luma.height()) {
let base_scale_x = luma.width() as f32 / geometry.total_width_modules() as f32;
let base_scale_y = luma.height() as f32 / geometry.total_height_modules() as f32;
if base_scale_x < 1.0 || base_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,
) {
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(3) {
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 let Ok(decoded) = decode_fractional_with_params(
&integral, origin_x, origin_y, scale_x, scale_y, threshold,
if !fractional_grid_fits(
&luma,
origin_x,
origin_y,
scale_x,
scale_y,
geometry.symbol_width,
geometry.symbol_height,
) {
return Ok(decoded);
continue;
}
for &threshold in &thresholds {
if !fractional_header_precheck(
&integral, geometry, origin_x, origin_y, scale_x, scale_y, threshold,
) {
continue;
}
if let Ok(decoded) = decode_fractional_with_params(
&integral, geometry, origin_x, origin_y, scale_x, scale_y, threshold,
) {
return Ok(decoded);
}
}
}
}
Expand All @@ -212,28 +215,77 @@ fn decode_fractional_ribbon_candidate(
Err(DecodeError::AutoDetectFailed)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RibbonGeometry {
symbol_width: u16,
symbol_height: u16,
}

impl RibbonGeometry {
const fn total_width_modules(self) -> u16 {
self.symbol_width + 8
}

const fn total_height_modules(self) -> u16 {
self.symbol_height + 8
}
}

fn fractional_ribbon_geometry_candidates(
image_width: u32,
image_height: u32,
) -> Vec<RibbonGeometry> {
const DEFAULT_PRINT_RIBBON: RibbonGeometry = RibbonGeometry {
symbol_width: 96,
symbol_height: 36,
};

let scale_x = image_width as f32 / DEFAULT_PRINT_RIBBON.total_width_modules() as f32;
let scale_y = image_height as f32 / DEFAULT_PRINT_RIBBON.total_height_modules() as f32;
if scale_x >= 1.0 && scale_y >= 1.0 && (0.6..=1.7).contains(&(scale_x / scale_y)) {
vec![DEFAULT_PRINT_RIBBON]
} else {
Vec::new()
}
Comment on lines +234 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Fractional decoding is still pinned to the reference geometry.

fractional_ribbon_geometry_candidates() only ever returns DEFAULT_PRINT_RIBBON, so the fractional path never explores the non-reference ribbon sizes this PR is introducing. That means any variable-geometry ribbon that needs the fractional fallback will still end up at AutoDetectFailed even though the exact path was generalized.

Please generate candidate RibbonGeometry values from the observed image dimensions instead of hard-coding 96x36 here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/glyphnet-scanner/src/decode_paths.rs` around lines 234 - 249, The
fractional_ribbon_geometry_candidates function currently always returns
DEFAULT_PRINT_RIBBON (96x36); instead, compute candidate RibbonGeometry values
from the observed image_width and image_height by deriving a scale = image_width
/ DEFAULT_PRINT_RIBBON.total_width_modules() and/or using both scale_x and
scale_y (via total_width_modules() and total_height_modules()) to compute
symbol_width and symbol_height, round or floor/ceil to nearby integer module
sizes, and produce a Vec of plausible RibbonGeometry variants around that scale
(e.g., exact rounded, +/-1 module sizes, and swapped scale fits) rather than the
hard-coded DEFAULT_PRINT_RIBBON; update fractional_ribbon_geometry_candidates to
return those computed candidates (referencing DEFAULT_PRINT_RIBBON,
fractional_ribbon_geometry_candidates, total_width_modules(),
total_height_modules()) so the fractional path can explore non-reference ribbon
sizes.

}

fn reference_ribbon_geometry(width: u32, height: u32) -> bool {
width >= 96
&& height >= 28
&& width % 4 == 0
&& height % 4 == 0
&& (2.0..=8.0).contains(&(width as f32 / height.max(1) as f32))
}

fn gcd_u32(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
let tmp = a % b;
a = b;
b = tmp;
}
a
}

fn module_shifts(radius: i32) -> impl Iterator<Item = f32> {
(-radius * 2..=radius * 2).map(|value| value as f32 * 0.5)
}

fn fractional_header_precheck(
integral: &IntegralGray,
geometry: RibbonGeometry,
origin_x_modules: f32,
origin_y_modules: f32,
scale_x: f32,
scale_y: f32,
threshold: u8,
) -> bool {
const SYMBOL_WIDTH: u16 = 96;
const SYMBOL_HEIGHT: u16 = 36;

let mut bits = Vec::with_capacity(HEADER_LEN * 8);
'rows: for y in 0..SYMBOL_HEIGHT {
for x in 0..SYMBOL_WIDTH {
'rows: for y in 0..geometry.symbol_height {
for x in 0..geometry.symbol_width {
if !layout::is_data_module_for(
LayoutFamily::RibbonWeave,
SYMBOL_WIDTH,
SYMBOL_HEIGHT,
geometry.symbol_width,
geometry.symbol_height,
x,
y,
) {
Expand Down Expand Up @@ -279,23 +331,24 @@ fn fractional_grid_fits(

fn decode_fractional_with_params(
integral: &IntegralGray,
geometry: RibbonGeometry,
origin_x_modules: f32,
origin_y_modules: f32,
scale_x: f32,
scale_y: f32,
threshold: u8,
) -> std::result::Result<AutoDecodedSymbol, DecodeError> {
const SYMBOL_WIDTH: u16 = 96;
const SYMBOL_HEIGHT: u16 = 36;

let mut matrix =
SymbolMatrix::with_layout(SYMBOL_WIDTH, SYMBOL_HEIGHT, LayoutFamily::RibbonWeave);
for y in 0..SYMBOL_HEIGHT {
for x in 0..SYMBOL_WIDTH {
let mut matrix = SymbolMatrix::with_layout(
geometry.symbol_width,
geometry.symbol_height,
LayoutFamily::RibbonWeave,
);
for y in 0..geometry.symbol_height {
for x in 0..geometry.symbol_width {
if let Some(cell) = layout::function_cell_for(
LayoutFamily::RibbonWeave,
SYMBOL_WIDTH,
SYMBOL_HEIGHT,
geometry.symbol_width,
geometry.symbol_height,
x,
y,
) {
Expand Down
34 changes: 34 additions & 0 deletions crates/glyphnet-scanner/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,40 @@ mod tests {
assert_scan_payload(&image, payload);
}

#[test]
fn decode_candidate_accepts_variable_size_ribbon_geometry() {
let payload = vec![0x47; 384];
let encoded = Encoder::default().encode_static(&payload).unwrap();
assert_ne!(
(encoded.matrix.width(), encoded.matrix.height()),
(96, 36),
"payload should force a non-reference RibbonWeave geometry"
);
let symbol = RasterRenderer::new(RenderOptions {
module_px: 3,
quiet_zone_modules: 4,
..RenderOptions::default()
})
.render(&encoded.matrix)
.unwrap();
let image = DynamicImage::ImageRgba8(symbol.clone());
let candidate = ScanCandidate::new(
CandidateDetector::RibbonWeave,
Some(LayoutFamily::RibbonWeave),
"dark-ribbon",
ScanRegion {
x: 0,
y: 0,
width: symbol.width(),
height: symbol.height(),
},
);

let result = decode_candidate(&RasterDecoder::default(), &image, candidate).unwrap();
assert_eq!(result.decoded.frame.payload, payload);
assert_eq!(result.info.layout, LayoutFamily::RibbonWeave);
}

#[test]
fn scan_still_decodes_generated_matrix_canvas() {
let payload = b"matrix baseline";
Expand Down
Loading