sources at native dims — no project DPI, no canvas letterbox

Removes the canvas-letterbox-to-paper step from process_pass. Each
Source image now flows through the pipeline at its native pixel dims.
Kernels run at source dims, Hull extracts at source dims, MmHull
conversion uses per-source `source.width_px / img_w_mm` for px/mm.

Project DPI is gone:
- Source card no longer has a DPI slider — kernels just operate at
  whatever pixel grid the source happens to have.
- App.jsx no longer computes max(source.dpi) for the payload.
- payload.dpi is unused (kept on the struct for back-compat with old
  saved projects; backend ignores it).

Backend changes:
- evaluate_graph drops canvas_w/canvas_h params; bg() looks up each
  node's source dims via node_rgbs to size empty maps. Combine's
  blend_maps n is the input map's length.
- process_pass_work drops canvas_w/canvas_h; computes per-source
  px_per_mm via source_rgbs map. Hull extraction uses each Hull's
  source RGB dims. MmHull conversion uses per-Hull px_per_mm.
- Coverage viz_b64 sized from first Hull's source dims (was the
  global canvas dims).
- Pen radius for preview computed inside render_pen_preview from
  pen_tip_mm and PREVIEW_PX_PER_MM (already DPI-independent).

Removed:
- Source's letterbox-to-canvas loop in process_pass (~30 lines).
- Synthetic blank canvas for text-only graphs (text strokes are mm,
  no backing pixels needed).
- defaultSourceParams.dpi
- App.jsx projectDpi calculation.

Behaviour: dropping a 4000×3000 photo into a Source now processes at
4000×3000 px directly. Kernels see actual image detail. Hull contour
vertex count tracks source resolution (intrinsic to the input file,
not a project knob). Fill and Pen output are unchanged — they're mm.

Tests: cargo test --lib (85 pass), npm test (70 pass), both builds
clean.
This commit is contained in:
Mitchell Hansen
2026-05-09 02:16:55 -07:00
parent fcc6014ea1
commit c3acf39f19
5 changed files with 89 additions and 99 deletions

View File

@@ -152,19 +152,13 @@ export default function App() {
updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 })
const t0 = performance.now()
try {
// Backend letterboxes every Source into the paper canvas, so we hand
// it the paper dimensions directly — no per-image scaling knob anymore.
// DPI lives per-Source now; the project canvas runs at the highest
// source DPI so no source loses detail. Default 150 if no Source.
// No more project DPI. Source images process at native pixel
// dims; mm conversion happens at the polygon boundary.
const paperW = gcodeConfigRef.current.paper_w_mm
const paperH = gcodeConfigRef.current.paper_h_mm
const projectDpi = Math.max(150, ...(pass.graph?.nodes ?? [])
.filter(n => n.kind === 'Source')
.map(n => n.dpi ?? 150))
const result = await tauri.processPass({
pass_index: idx,
graph: pass.graph,
dpi: projectDpi,
img_w_mm: paperW,
img_h_mm: paperH,
pen_tip_mm: gcodeConfigRef.current.pen_tip_mm ?? 0.5,

View File

@@ -557,8 +557,6 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2
{node.file_path}
</div>
)}
<Slider label="Sampling DPI" value={node.dpi ?? 150} min={50} max={600} step={25}
onChange={v => updateNode(node.id, { dpi: v })} />
</>)}
{node.kind === 'Kernel' && (<>

View File

@@ -127,12 +127,12 @@ export function defaultHullParams() {
}
export function defaultSourceParams() {
// dpi is the source's sampling resolution: how many pixels per mm of
// paper the source image gets letterboxed into. Higher = finer detail
// captured from the source. With multi-source projects, the project
// canvas runs at max(source.dpi) so the highest-detail source isn't
// limited by the lowest.
return { file_path: null, dpi: 150 }
// The source image is processed at its native pixel dims. No
// resampling, no project-wide DPI. Kernels and Hull operate on the
// source's own pixel grid; mm conversion happens at the polygon
// boundary using `source.width / img_w_mm` as the per-source
// pixels-per-mm rate.
return { file_path: null }
}
export function defaultGraph() {

View File

@@ -543,24 +543,29 @@ pub struct GraphMaps {
pub raw_maps: std::collections::HashMap<String, Vec<u8>>,
}
/// Run the detection graph. With multi-source graphs, every Kernel node
/// is tied to its tree's Source via `node_rgbs`; the lookup happens per
/// Kernel evaluation. `canvas_w/canvas_h` give the pixel dimensions for
/// the response maps — every Source has been letterboxed to this size
/// upstream so all maps share a coord frame.
/// Run the detection graph. With multi-source graphs, every Kernel
/// node is tied to its tree's Source via `node_rgbs`; the lookup
/// happens per Kernel evaluation, and each kernel processes at its
/// source image's native pixel dims. Response maps for each node are
/// at that node's source dims (so a single graph can have nodes at
/// different resolutions if their sources differ).
pub fn evaluate_graph(
graph: &DetectionGraph,
node_rgbs: &std::collections::HashMap<String, RgbImage>,
canvas_w: u32,
canvas_h: u32,
) -> GraphMaps {
use std::collections::{HashMap, VecDeque};
let n = (canvas_w * canvas_h) as usize;
let bg = || vec![255u8; n];
// Empty-map helper sized to a given node's source RGB; for nodes
// with no source (rare — e.g. orphan Combine/Output) returns empty.
let bg_for = |id: &str| -> Vec<u8> {
match node_rgbs.get(id) {
Some(rgb) => vec![255u8; (rgb.width() * rgb.height()) as usize],
None => Vec::new(),
}
};
if graph.nodes.is_empty() {
return GraphMaps { response: bg(), raw_maps: HashMap::new() };
return GraphMaps { response: Vec::new(), raw_maps: HashMap::new() };
}
// Build adjacency: incoming edges per node (sorted by port for combine)
@@ -594,7 +599,7 @@ pub fn evaluate_graph(
}
}
if order.len() != graph.nodes.len() {
return GraphMaps { response: bg(), raw_maps: HashMap::new() }; // cycle
return GraphMaps { response: Vec::new(), raw_maps: HashMap::new() }; // cycle
}
let node_map: HashMap<&str, &GraphNode> = graph.nodes.iter()
@@ -614,7 +619,7 @@ pub fn evaluate_graph(
// this kernel operates on. Missing entry = source has no file
// loaded; produce nothing.
let src_rgb = match node_rgbs.get(id) { Some(r) => r, None => {
outputs.insert(id, bg()); continue;
outputs.insert(id, bg_for(id)); continue;
}};
// If an upstream response map exists (e.g. from a Combine node),
// convert it to a grayscale RgbImage and apply the kernel to that
@@ -646,12 +651,13 @@ pub fn evaluate_graph(
let maps: Vec<&[u8]> = ins.iter()
.filter_map(|(fid, _)| outputs.get(fid).map(|v| v.as_slice()))
.collect();
Some(if maps.is_empty() { bg() } else { blend_maps(&maps, *mode, n) })
let n = maps.first().map(|m| m.len()).unwrap_or(0);
Some(if maps.is_empty() { bg_for(id) } else { blend_maps(&maps, *mode, n) })
}
NodeKind::Output => {
let upstream = incoming[id].iter()
.find_map(|(fid, _)| outputs.get(fid).cloned());
Some(upstream.unwrap_or_else(bg))
Some(upstream.unwrap_or_else(|| bg_for(id)))
}
// Hull nodes store their upstream Map so lib.rs can retrieve it for
// hull extraction without re-evaluating the detection portion.
@@ -689,7 +695,7 @@ pub fn evaluate_graph(
.find(|n| matches!(n.kind, NodeKind::Hull { .. }))
.and_then(|n| raw_maps.get(&n.id).cloned())
})
.unwrap_or_else(bg);
.unwrap_or_default();
GraphMaps { response, raw_maps }
}

View File

@@ -686,8 +686,6 @@ fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull],
fn process_pass_work(
source_rgbs: std::collections::HashMap<String, image::RgbImage>,
canvas_w: u32,
canvas_h: u32,
payload: ProcessPassPayload,
mut cache: NodeCache,
) -> (Vec<hulls::Hull>, Vec<PenResult>, Vec<u8>, ProcessResult, NodeCache) {
@@ -698,17 +696,18 @@ fn process_pass_work(
let mut cache_hits = 0u32;
let mut cache_misses = 0u32;
// Each Source is already letterboxed to the paper canvas by the caller,
// so all of them share `(canvas_w, canvas_h)` and downstream hulls land
// in a single coord frame.
let (w, h) = (canvas_w, canvas_h);
// Paper dims (mm) — shared by mm-fill conversion, preview rendering,
// and gradient-fill source sampling. Hoisted so every stage uses the
// same numbers.
// Paper dims (mm). The image's mm width on paper is `paper_w_mm_for_scale`
// (= img_w_mm payload, defaulted to A4); per-source pixels-per-mm is
// computed below from each source's native dims.
let paper_w_mm_for_scale = payload.img_w_mm.unwrap_or(210.0).max(1.0);
let paper_h_mm_for_scale = payload.img_h_mm.unwrap_or(297.0).max(1.0);
let px_per_mm_fill = w as f32 / paper_w_mm_for_scale;
// Per-source pixels-per-mm: divides each source's native width by its
// mm width on paper. Used by Hull → MmHull conversion and gradient
// fills. Each source has its own rate.
let source_px_per_mm: std::collections::HashMap<String, f32> = source_rgbs.iter()
.map(|(id, rgb)| (id.clone(), rgb.width() as f32 / paper_w_mm_for_scale))
.collect();
// ── Per-node Source RGB lookup ────────────────────────────────────────────
// Trees can't merge (frontend enforces; backend assumes), so each non-
@@ -784,7 +783,7 @@ fn process_pass_work(
}
} else {
cache_misses += 1;
let maps = detect::evaluate_graph(&det_graph, &node_rgbs, canvas_w, canvas_h);
let maps = detect::evaluate_graph(&det_graph, &node_rgbs);
cache.detect_fp = detect_fp;
cache.detect_response = maps.response.clone();
cache.detect_maps = maps.raw_maps.clone();
@@ -820,6 +819,11 @@ fn process_pass_work(
| detect::NodeKind::PenOutput { .. }
));
if !is_detect_node { continue; }
// Each node's response map is at its source RGB's native dims.
let (nw, nh) = match node_rgbs.get(id) {
Some(rgb) => rgb.dimensions(),
None => continue, // shouldn't happen for detect nodes
};
let node_fp = node_fps.get(id).copied().unwrap_or(0);
let preview = if let Some((cached_fp, cached_prev)) = cache.preview_cache.get(id) {
if *cached_fp == node_fp && node_fp != 0 {
@@ -827,13 +831,13 @@ fn process_pass_work(
cached_prev.clone()
} else {
cache_misses += 1;
let p = map_to_b64_small(map, w, h);
let p = map_to_b64_small(map, nw, nh);
cache.preview_cache.insert(id.clone(), (node_fp, p.clone()));
p
}
} else {
cache_misses += 1;
let p = map_to_b64_small(map, w, h);
let p = map_to_b64_small(map, nw, nh);
cache.preview_cache.insert(id.clone(), (node_fp, p.clone()));
p
};
@@ -847,6 +851,7 @@ fn process_pass_work(
let mut hull_resp_maps: std::collections::HashMap<String, Vec<u8>> = Default::default();
let mut first_hull_response: Option<Vec<u8>> = None;
let mut first_hull_threshold: u8 = 128;
let mut first_hull_dims: Option<(u32, u32)> = None;
for node in &det_graph.nodes {
if let detect::NodeKind::Hull {
@@ -856,6 +861,12 @@ fn process_pass_work(
Some(m) => m,
None => continue,
};
// Hull works at its source's native dims.
let src_rgb = match node_rgbs.get(&node.id) {
Some(r) => r,
None => continue,
};
let (sw, sh) = src_rgb.dimensions();
let hull_fp = node_fps.get(&node.id).copied().unwrap_or(0);
let (filtered, preview) = if hull_fp != 0 {
@@ -871,10 +882,11 @@ fn process_pass_work(
if first_hull_response.is_none() {
first_hull_response = Some(entry.resp_map.clone());
first_hull_threshold = *threshold;
first_hull_dims = Some((sw, sh));
}
all_hulls.extend(entry.hulls.clone());
let p = preview.unwrap_or_else(|| {
let p = render_hull_preview(response, &entry.hulls, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
let p = render_hull_preview(response, &entry.hulls, sw, sh, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.preview_cache.insert(node.id.clone(), (hull_fp, p.clone()));
p
});
@@ -883,20 +895,14 @@ fn process_pass_work(
}
}
cache_misses += 1;
// Cache miss — compute. Each Hull belongs to exactly one
// Source tree; pull that tree's RGB for color extraction.
let src_rgb = match node_rgbs.get(&node.id) {
Some(r) => r,
None => continue, // Hull with no upstream Source RGB
};
let hull_params = hulls::HullParams {
threshold: *threshold,
min_area: *min_area,
connectivity: if *eight_conn { hulls::Connectivity::Eight }
else { hulls::Connectivity::Four },
};
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
let preview = render_hull_preview(response, &extracted, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
let extracted = hulls::extract_hulls(response, src_rgb, sw, sh, &hull_params);
let preview = render_hull_preview(response, &extracted, sw, sh, paper_w_mm_for_scale, paper_h_mm_for_scale);
cache.hull_entries.insert(node.id.clone(), HullCacheEntry {
fp: hull_fp,
hulls: extracted.clone(),
@@ -906,18 +912,14 @@ fn process_pass_work(
(extracted, preview)
} else {
// No fingerprint — always compute, never cache
let src_rgb = match node_rgbs.get(&node.id) {
Some(r) => r,
None => continue,
};
let hull_params = hulls::HullParams {
threshold: *threshold,
min_area: *min_area,
connectivity: if *eight_conn { hulls::Connectivity::Eight }
else { hulls::Connectivity::Four },
};
let extracted = hulls::extract_hulls(response, src_rgb, w, h, &hull_params);
let preview = render_hull_preview(response, &extracted, w, h, paper_w_mm_for_scale, paper_h_mm_for_scale);
let extracted = hulls::extract_hulls(response, src_rgb, sw, sh, &hull_params);
let preview = render_hull_preview(response, &extracted, sw, sh, paper_w_mm_for_scale, paper_h_mm_for_scale);
(extracted, preview)
};
@@ -925,6 +927,7 @@ fn process_pass_work(
if first_hull_response.is_none() {
first_hull_response = Some(response.clone());
first_hull_threshold = *threshold;
first_hull_dims = Some((sw, sh));
}
hull_outputs.insert(node.id.clone(), filtered.clone());
hull_resp_maps.insert(node.id.clone(), response.clone());
@@ -975,15 +978,21 @@ fn process_pass_work(
// Compute. Convert pixel hulls to mm hulls once; mm fills handle
// their own internal rasterization at the per-Fill `dpi` knob.
// Per-source px-to-mm rate: source.width / paper_w_mm.
let response_arc: std::sync::Arc<[u8]> = resp_for_fill.into();
let (strategy, spacing_mm, angle, param, smooth_rdp, smooth_iters, fill_dpi) =
(strategy.clone(), *spacing, *angle, *param, *smooth_rdp, *smooth_iters, *fill_dpi);
let internal_px_per_mm = (fill_dpi as f32 / 25.4).max(1.0);
let src_px_per_mm = node_to_source.get(&node.id)
.and_then(|sid| source_px_per_mm.get(sid).copied())
.unwrap_or(150.0 / 25.4);
let img_w = node_to_source.get(&node.id)
.and_then(|sid| source_rgbs.get(sid))
.map(|r| r.width())
.unwrap_or(0);
let mm_hulls: Vec<hulls::MmHull> = hulls_for_fill.iter()
.map(|h| hulls::MmHull::from_pixel_hull(h, px_per_mm_fill))
.map(|h| hulls::MmHull::from_pixel_hull(h, src_px_per_mm))
.collect();
let img_w = w;
let src_px_per_mm = px_per_mm_fill;
let raw: Vec<fill::FillResult> = mm_hulls.par_iter().map(|mh| {
match strategy.as_str() {
"outline" => fill::outline_mm(mh),
@@ -1039,10 +1048,9 @@ fn process_pass_work(
t = lap!(steps, "text", t);
// ── PenOutput nodes ────────────────────────────────────────────────────────
// Pen preview stamps a disk at each Bresenham step; radius scales the
// physical pen tip into preview-canvas pixels via px_per_mm_fill.
// Pen preview renders at fixed PREVIEW_PX_PER_MM internally; the
// stamp radius is computed inside render_pen_preview from pen_tip_mm.
let pen_tip_mm = payload.pen_tip_mm.unwrap_or(0.5).max(0.05);
let pen_radius_px = (pen_tip_mm * px_per_mm_fill / 2.0).max(0.5);
let mut pen_results: Vec<PenResult> = Vec::new();
let mut pen_output_results: Vec<PenOutputResult> = Vec::new();
@@ -1092,20 +1100,22 @@ fn process_pass_work(
// ── Coverage + binary viz ──────────────────────────────────────────────────
let response_for_viz = first_hull_response.as_deref().unwrap_or(&graph_maps.response);
let threshold = first_hull_threshold;
let (viz_w, viz_h) = first_hull_dims.unwrap_or((1, 1));
let total_dark = response_for_viz.iter().filter(|&&p| p < threshold).count();
let hull_px: usize = all_hulls.iter().map(|h| h.pixels.len()).sum();
let coverage_pct = if total_dark > 0 { hull_px * 100 / total_dark } else { 0 };
let stroke_count: usize = pen_results.iter().map(|pr| pr.fill.strokes.len()).sum();
let mut rgba = vec![0u8; (w * h * 4) as usize];
let mut rgba = vec![0u8; (viz_w * viz_h * 4) as usize];
for (i, &r) in response_for_viz.iter().enumerate() {
if i * 4 + 3 >= rgba.len() { break; }
let v = if r < threshold { 0u8 } else { 220u8 };
rgba[i*4] = v; rgba[i*4+1] = v; rgba[i*4+2] = v; rgba[i*4+3] = 255;
}
t = lap!(steps, "viz build", t);
let viz_b64 = rgba_to_b64_png(&rgba, w, h);
let viz_b64 = rgba_to_b64_png(&rgba, viz_w, viz_h);
lap!(steps, "png encode", t);
steps.push(StepTime {
label: format!("total (cache {cache_hits}hit/{cache_misses}miss)"),
@@ -1118,7 +1128,7 @@ fn process_pass_work(
(all_hulls, pen_results, response_map,
ProcessResult { hull_count, coverage_pct, stroke_count, viz_b64,
pen_outputs: pen_output_results, node_previews, timings: steps,
img_w: w, img_h: h },
img_w: viz_w, img_h: viz_h },
cache)
}
@@ -1171,15 +1181,13 @@ fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
#[tauri::command]
async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppState>>) -> Result<ProcessResult, String> {
// Resolve every Source node's file_path against the image cache, then
// letterbox-fit each one into a paper-sized canvas so all Sources end
// up at identical pixel dimensions — downstream hulls/strokes share
// one coord frame, gcode export uses one img_w/img_h.
let dpi = payload.dpi.unwrap_or(150).max(1) as f32;
// Each Source node references an image in the cache; pull each one
// out at its NATIVE pixel dims. No letterbox, no synthetic canvas —
// kernels and Hull operate at the source image's own resolution,
// mm-conversion happens at the polygon boundary using
// `source.width_px / img_w_mm` as the per-source pixels-per-mm rate.
let paper_w = payload.img_w_mm.unwrap_or(210.0).max(1.0);
let paper_h = payload.img_h_mm.unwrap_or(297.0).max(1.0);
let canvas_w = ((paper_w * dpi / 25.4).round() as u32).max(1);
let canvas_h = ((paper_h * dpi / 25.4).round() as u32).max(1);
let source_rgbs: std::collections::HashMap<String, image::RgbImage> = {
let st = state.lock().unwrap();
@@ -1188,26 +1196,11 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
if n.kind != "Source" { continue; }
let path = match n.file_path.as_deref() {
Some(p) if !p.is_empty() => p,
_ => continue, // Source with no file picked yet → skip
_ => continue,
};
let raw = match st.images.get(path) {
Some(img) => img,
None => continue, // file referenced but not loaded → skip
};
// Letterbox-fit into the paper canvas: scale uniformly to fit
// inside (canvas_w × canvas_h), centre, fill background white.
let (rw, rh) = raw.dimensions();
let scale = (canvas_w as f32 / rw as f32).min(canvas_h as f32 / rh as f32);
let tw = ((rw as f32 * scale).round() as u32).max(1);
let th = ((rh as f32 * scale).round() as u32).max(1);
let resized = image::DynamicImage::ImageRgb8(raw.clone())
.resize_exact(tw, th, image::imageops::FilterType::CatmullRom)
.to_rgb8();
let mut canvas = image::RgbImage::from_pixel(canvas_w, canvas_h, image::Rgb([255, 255, 255]));
let off_x = (canvas_w.saturating_sub(tw)) / 2;
let off_y = (canvas_h.saturating_sub(th)) / 2;
image::imageops::overlay(&mut canvas, &resized, off_x as i64, off_y as i64);
out.insert(n.id.clone(), canvas);
if let Some(raw) = st.images.get(path) {
out.insert(n.id.clone(), raw.clone());
}
}
out
};
@@ -1222,7 +1215,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
let (new_hulls, new_fill, response_map, result, new_cache) =
tauri::async_runtime::spawn_blocking(move || {
process_pass_work(source_rgbs, canvas_w, canvas_h, payload, cache)
process_pass_work(source_rgbs, payload, cache)
})
.await
.map_err(|e| e.to_string())?;
@@ -2428,9 +2421,8 @@ mod blocking_tests {
if n.kind == "Source" { n.file_path = Some("test.png".into()); }
}
let (cw, ch) = (800u32, 600u32);
let work = tokio::task::spawn_blocking(move || {
process_pass_work(work_sources, cw, ch, payload, NodeCache::default())
process_pass_work(work_sources, payload, NodeCache::default())
});
// Give the blocking thread a moment to start, then try to grab the mutex.
@@ -2732,7 +2724,7 @@ mod viz_tests {
};
let node_rgbs: std::collections::HashMap<String, image::RgbImage> =
graph.nodes.iter().map(|n| (n.id.clone(), img.clone())).collect();
let gm = detect::evaluate_graph(&graph, &node_rgbs, w, h);
let gm = detect::evaluate_graph(&graph, &node_rgbs);
let response = gm.raw_maps.get("hull").cloned().unwrap_or(gm.response);
let params = HullParams { threshold, min_area,
connectivity: hulls::Connectivity::Four };