From c3acf39f19cbd3043447669017f76be532bb6da9 Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Sat, 9 May 2026 02:16:55 -0700 Subject: [PATCH] =?UTF-8?q?sources=20at=20native=20dims=20=E2=80=94=20no?= =?UTF-8?q?=20project=20DPI,=20no=20canvas=20letterbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-frontend/src/App.jsx | 10 +- src-frontend/src/components/NodeGraph.jsx | 2 - src-frontend/src/store.js | 12 +- src/detect.rs | 36 +++--- src/lib.rs | 128 ++++++++++------------ 5 files changed, 89 insertions(+), 99 deletions(-) diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 9eff1157..9e56039d 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -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, diff --git a/src-frontend/src/components/NodeGraph.jsx b/src-frontend/src/components/NodeGraph.jsx index 97fab861..c9f0f137 100644 --- a/src-frontend/src/components/NodeGraph.jsx +++ b/src-frontend/src/components/NodeGraph.jsx @@ -557,8 +557,6 @@ export default function NodeGraph({ graph, onChange, nodePreviews, nodeWidth = 2 {node.file_path} )} - updateNode(node.id, { dpi: v })} /> )} {node.kind === 'Kernel' && (<> diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js index 9398df90..8596acec 100644 --- a/src-frontend/src/store.js +++ b/src-frontend/src/store.js @@ -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() { diff --git a/src/detect.rs b/src/detect.rs index bec8292b..1a70610f 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -543,24 +543,29 @@ pub struct GraphMaps { pub raw_maps: std::collections::HashMap>, } -/// 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, - 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 { + 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 } } diff --git a/src/lib.rs b/src/lib.rs index 4466c6a4..40e387d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -686,8 +686,6 @@ fn render_hull_preview(response: &[u8], hulls_list: &[hulls::Hull], fn process_pass_work( source_rgbs: std::collections::HashMap, - canvas_w: u32, - canvas_h: u32, payload: ProcessPassPayload, mut cache: NodeCache, ) -> (Vec, Vec, Vec, 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 = 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> = Default::default(); let mut first_hull_response: Option> = 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_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 = 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 = Vec::new(); let mut pen_output_results: Vec = 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>) { #[tauri::command] async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex>) -> Result { - // 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 = { let st = state.lock().unwrap(); @@ -1188,26 +1196,11 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex 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 = 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 };