mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Image Trace: Raster::new returns an error for a buffer of the wrong length
Raster::new checked its RGBA length with assert_eq!, so bytes of the wrong length (from a file, a plug-in or a control message) would abort instead of reporting an error. It now returns Result<Raster, TraceError> and gives TraceError::Decode naming the byte count and the size (the existing variant, whose text is already translated, instead of a new message every language would need). The size product is checked for overflow. Raster::pixel indexed the bytes directly; it now gives transparent black outside the raster, or past the end of a raster whose public fields were set to a size its bytes don't have (which mosaic reads). The public signature changes: every caller in the workspace was a test (trace unit tests, Flat Logo tests and the quality harness), updated to unwrap. No shipped code built a Raster with new; decode, decode_to_fit and from_fn always make the right length, and trace_within already refuses a raster whose fields disagree. Test (code-drawn): a_raster_of_the_wrong_length_is_an_error_not_a_crash (short, long, empty, zero-width and overflowing sizes; pixel, trace and mosaic on a raster whose fields disagree). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
0594ffe30f
commit
a2e2eba2e2
+18
-5
@@ -123,10 +123,15 @@ pub struct Raster {
|
||||
}
|
||||
|
||||
impl Raster {
|
||||
/// A raster from RGBA bytes (`width * height * 4` of them).
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Self {
|
||||
assert_eq!(rgba.len(), width as usize * height as usize * 4, "rgba length must be width*height*4");
|
||||
Self { width, height, rgba }
|
||||
/// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other
|
||||
/// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not
|
||||
/// a crash. (That error's text is already translated; its detail names the sizes.)
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, TraceError> {
|
||||
let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
|
||||
if want != Some(rgba.len()) {
|
||||
return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len())));
|
||||
}
|
||||
Ok(Self { width, height, rgba })
|
||||
}
|
||||
/// A raster whose pixel `(x, y)` is `f(x, y)`.
|
||||
pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self {
|
||||
@@ -192,9 +197,17 @@ impl Raster {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose
|
||||
/// fields were set to a size its bytes don't have).
|
||||
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||||
if x >= self.width || y >= self.height {
|
||||
return [0; 4];
|
||||
}
|
||||
let i = (y as usize * self.width as usize + x as usize) * 4;
|
||||
[self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
|
||||
match self.rgba.get(i..i + 4) {
|
||||
Some(&[r, g, b, a]) => [r, g, b, a],
|
||||
_ => [0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]>
|
||||
let un = |v: f64| if a > 1e-9 { ((v / a) * 255.0).round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
rgba.extend_from_slice(&[un(p[0]), un(p[1]), un(p[2]), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
|
||||
}
|
||||
Raster::new(w, h, rgba)
|
||||
Raster::new(w, h, rgba).unwrap()
|
||||
}
|
||||
|
||||
const RED: [u8; 3] = [210, 40, 50];
|
||||
@@ -235,12 +235,12 @@ fn assert_sane(res: &TraceResult, w: u32, h: u32) {
|
||||
fn degenerate_images_are_fine() {
|
||||
let p = logo_params();
|
||||
for (w, h, img) in [
|
||||
(0, 0, Raster::new(0, 0, vec![])),
|
||||
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255])),
|
||||
(3, 3, Raster::new(3, 3, vec![0; 36])),
|
||||
(2, 2, Raster::new(2, 2, vec![255; 16])),
|
||||
(5, 1, Raster::new(5, 1, vec![9; 20])),
|
||||
(1, 7, Raster::new(1, 7, vec![130; 28])),
|
||||
(0, 0, Raster::new(0, 0, vec![]).unwrap()),
|
||||
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255]).unwrap()),
|
||||
(3, 3, Raster::new(3, 3, vec![0; 36]).unwrap()),
|
||||
(2, 2, Raster::new(2, 2, vec![255; 16]).unwrap()),
|
||||
(5, 1, Raster::new(5, 1, vec![9; 20]).unwrap()),
|
||||
(1, 7, Raster::new(1, 7, vec![130; 28]).unwrap()),
|
||||
] {
|
||||
match trace_within(&img, &p, 1000) {
|
||||
Ok(res) => assert_sane(&res, w, h),
|
||||
@@ -398,7 +398,7 @@ fn random_small_images_and_settings_never_panic() {
|
||||
});
|
||||
}
|
||||
}
|
||||
let img = Raster::new(w, h, rgba);
|
||||
let img = Raster::new(w, h, rgba).unwrap();
|
||||
let p = TraceParams {
|
||||
paths: weird[(next() % 8) as usize],
|
||||
corners: weird[(next() % 8) as usize],
|
||||
|
||||
@@ -602,7 +602,7 @@ fn create_strokes_survives_noise_and_tiny_images() {
|
||||
seed
|
||||
};
|
||||
let noise: Vec<u8> = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect();
|
||||
let noise = Raster::new(120, 120, noise);
|
||||
let noise = Raster::new(120, 120, noise).unwrap();
|
||||
for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] {
|
||||
for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] {
|
||||
for noise in [0, 1, 4] {
|
||||
@@ -1652,3 +1652,25 @@ fn a_higher_paths_never_gives_fewer_segments() {
|
||||
}
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_of_the_wrong_length_is_an_error_not_a_crash() {
|
||||
for (w, h, len) in [(4, 4, 63), (4, 4, 65), (4, 4, 0), (0, 4, 4), (1, 1, 3), (u32::MAX, u32::MAX, 16)] {
|
||||
match Raster::new(w, h, vec![7; len]) {
|
||||
Err(TraceError::Decode(why)) => assert!(why.starts_with(&format!("{len} bytes")) && why.contains(&format!("{w} × {h}")), "{why}"),
|
||||
other => panic!("{w} × {h}, {len} bytes: {other:?}"),
|
||||
}
|
||||
}
|
||||
let e = Raster::new(3, 2, vec![0; 20]).unwrap_err().to_string();
|
||||
assert!(e.contains("20 bytes") && e.contains("3 × 2"), "{e}");
|
||||
assert_eq!(Raster::new(3, 2, vec![9; 24]).unwrap().pixel(2, 1), [9; 4]);
|
||||
assert!(Raster::new(0, 0, vec![]).is_ok());
|
||||
// A raster whose public fields disagree is still no crash: no pixel past its bytes, no trace,
|
||||
// no mosaic tile out of range.
|
||||
let broken = Raster { width: 8, height: 8, rgba: vec![255; 40] };
|
||||
assert_eq!(broken.pixel(7, 7), [0; 4]);
|
||||
assert_eq!(broken.pixel(1, 1), [255; 4]);
|
||||
assert_eq!(broken.pixel(9, 0), [0; 4]);
|
||||
assert!(trace(&broken, &TraceParams::default()).paths.is_empty());
|
||||
assert_eq!(mosaic(&broken, 2, 2).len(), 4);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ fn evaluate(sc: &Scene, input: &'static str, cov: &[f32]) -> Metrics {
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let img = Raster::new(sc.w as u32, sc.h as u32, rgba);
|
||||
let img = Raster::new(sc.w as u32, sc.h as u32, rgba).unwrap();
|
||||
let params = TraceParams { strokes: true, fills: true, stroke_width: sc.wmax, ignore_white: true, noise: 4, ..TraceParams::default() };
|
||||
let t0 = Instant::now();
|
||||
let res = trace_within(&img, ¶ms, super::MAX_ANCHORS).unwrap();
|
||||
|
||||
@@ -140,7 +140,7 @@ fn from_rgb(w: usize, h: usize, buf: &[[f32; 3]]) -> Raster {
|
||||
rgba.extend(p.map(|v| v.round().clamp(0.0, 255.0) as u8));
|
||||
rgba.push(255);
|
||||
}
|
||||
Raster::new(w as u32, h as u32, rgba)
|
||||
Raster::new(w as u32, h as u32, rgba).unwrap()
|
||||
}
|
||||
|
||||
/// A deterministic hash of a pixel position and a salt, as a value in `-1.0..1.0`.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Image Trace quality
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-17 · **Change:** minor (Image Trace: Raster::new returns an error for a buffer of the wrong length; earlier: Gradients: grown over noise and soft rims)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Create Strokes: maximal-disc pruning and junction merging; centre-lines rerun)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Create Strokes: exact crop distances and Guo–Hall thinning; centre-lines rerun)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (centre-lines baseline; no tracing behaviour change)
|
||||
@@ -1211,6 +1212,7 @@ The remaining spur/extra-junction target is not met by this graph-only change.
|
||||
|
||||
| date | change |
|
||||
|:--|:--|
|
||||
| 2026-10-17 | `Raster::new` returns an error for RGBA bytes of the wrong length (it failed an assertion) and `Raster::pixel` reads nothing past the raster; traces of valid images unchanged |
|
||||
| 2026-10-17 | M12: maximal-disc graph pruning, split-crossing merges and degree-two joins; correct junction degrees 30→44/45 aa and hard, none missed; remaining spurs unchanged. Centre-lines rerun, scores and earlier tables unchanged. |
|
||||
| 2026-10-17 | M12: exact per-blob Euclidean distances and Guo–Hall thinning; centre-lines rerun on 46 inputs. Ends found: aa 638→682/688, hard 631→681/688. Whole-blob stroke/fill choice retained; preset/cost tables unchanged. |
|
||||
| 2026-10-17 | M12: add the ignored centre-lines class and measure the existing Create Strokes output on 23 code-drawn scenes, aa and hard; centre/width/topology/end/coverage metrics, no new images, dependencies or behaviour |
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user