/// Grid-search Bevel & Emboss MAE tuning against Krita ground-truth PNGs. /// /// Expects `_tmp/bevel_emboss_png_kra_set/` with transparent PNGs exported /// from `_tmp/bevel_emboss_kra_set/`. use std::io::Write; const GT_DIR: &str = "_tmp/bevel_emboss_png_kra_set"; static mut CANVAS: u32 = 800; const RESULTS_FILE: &str = "bevel_emboss_kra_grid_results.txt"; struct Sample { _name: String, depth: f32, size: f32, soften: f32, angle: f32, direction: String, ref_pixels: Vec, content_mask: Vec, } fn main() { let entries: Vec<_> = std::fs::read_dir(GT_DIR) .unwrap_or_else(|e| panic!("cannot read {}: {}", GT_DIR, e)) .filter_map(|e| e.ok()) .filter(|e| { let s = e.file_name(); let s = s.to_string_lossy(); s.starts_with("be_") && s.ends_with(".png") }) .collect(); println!("Found {} PNGs in {}", entries.len(), GT_DIR); let mut samples: Vec = Vec::new(); for entry in &entries { let path = entry.path(); let stem = path.file_stem().unwrap().to_string_lossy().to_string(); let params = match parse_filename(&stem) { Some(p) => p, None => { eprintln!(" SKIP: {}", stem); continue; } }; let (size, soften, depth, angle, direction) = params; let img = match image::open(&path) { Ok(i) => i, Err(e) => { eprintln!(" SKIP {}: {}", stem, e); continue; } }; let rgba = img.to_rgba8(); let (src_w, src_h) = rgba.dimensions(); if src_w != src_h { eprintln!(" SKIP {}: non-square dimensions {}x{}", stem, src_w, src_h); continue; } unsafe { CANVAS = src_w; } let src_raw = rgba.as_raw(); let mut cropped_raw = vec![0u8; unsafe { (CANVAS * CANVAS * 4) as usize }]; cropped_raw.copy_from_slice(src_raw); let npix = unsafe { (CANVAS * CANVAS) as usize }; let mut content_mask = vec![false; npix]; for i in 0..npix { content_mask[i] = cropped_raw[i * 4 + 3] > 0; } samples.push(Sample { _name: stem, depth, size, soften, angle, direction, ref_pixels: cropped_raw, content_mask, }); } println!("Loaded {} samples", samples.len()); if samples.is_empty() { eprintln!("No valid samples found."); std::process::exit(1); } let content_count = samples[0].content_mask.iter().filter(|&&b| b).count(); println!("Content pixels per image: {} / {} ({:.1}%)", content_count, npix(), content_count as f64 / npix() as f64 * 100.0); let rect_alpha: Vec = { let n = npix(); let mut a = vec![0u8; n]; for i in 0..n { a[i] = samples[0].ref_pixels[i * 4 + 3]; } a }; let lighting_blurs: &[i32] = &[0, 1, 2, 3, 5]; let hl_scales: &[f32] = &[0.3, 0.5, 0.75, 1.0, 1.5, 2.0]; let sh_scales: &[f32] = &[0.3, 0.5, 0.75, 1.0, 1.5, 2.0]; let profile_exponents: &[f32] = &[0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 3.0]; let total_combos = profile_exponents.len() * lighting_blurs.len() * hl_scales.len() * sh_scales.len(); println!("Grid: {} combos x {} samples = {} evals", total_combos, samples.len(), total_combos * samples.len()); std::io::stdout().flush().ok(); let mut best_avg_mae = f64::MAX; let mut best_params = (0i32, 0.0f32, 0.0f32, 1.0f32); let mut eval_count = 0usize; for &profile_exp in profile_exponents { for &lighting_blur in lighting_blurs { for &hl_scale in hl_scales { for &sh_scale in sh_scales { let mut total_mae = 0.0f64; for s in &samples { let style_enum = hcie_fx::types::BevelStyle::InnerBevel; let dir_enum = match s.direction.as_str() { "down" => hcie_fx::types::Direction::Down, _ => hcie_fx::types::Direction::Up, }; let (highlight, shadow) = hcie_fx::tuned::generate_bevel_tuned( &rect_alpha, unsafe { CANVAS }, unsafe { CANVAS }, s.depth, s.size, s.soften, s.angle, 30.0, &dir_enum, &hcie_fx::types::Technique::Smooth, &style_enum, lighting_blur, hl_scale, sh_scale, profile_exp, 1.0, ); let px = npix(); let mut out = vec![0u8; px * 4]; // Detect actual shape color from ground truth center pixel let c = unsafe { CANVAS }; let center = ((c / 2) * c + (c / 2)) as usize; let shape_r = s.ref_pixels[center * 4]; let shape_g = s.ref_pixels[center * 4 + 1]; let shape_b = s.ref_pixels[center * 4 + 2]; for i in 0..px { if rect_alpha[i] > 0 { out[i * 4] = shape_r; out[i * 4 + 1] = shape_g; out[i * 4 + 2] = shape_b; out[i * 4 + 3] = 255; } } // 2. Composite highlight (screen) and shadow (multiply) over the shape for i in 0..px { let sa = highlight[i * 4 + 3]; if sa > 0 { let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]]; let src = [highlight[i * 4], highlight[i * 4 + 1], highlight[i * 4 + 2], sa]; let blended = hcie_blend::blend_pixels(dst, src, hcie_blend::BlendMode::Screen, 1.0); out[i * 4..i * 4 + 4].copy_from_slice(&blended); } } for i in 0..px { let sa = shadow[i * 4 + 3]; if sa > 0 { let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]]; let src = [shadow[i * 4], shadow[i * 4 + 1], shadow[i * 4 + 2], sa]; let blended = hcie_blend::blend_pixels(dst, src, hcie_blend::BlendMode::Multiply, 1.0); out[i * 4..i * 4 + 4].copy_from_slice(&blended); } } let mut diff_sum = 0.0f64; let mut content_pixels = 0u64; for i in 0..px { if !s.content_mask[i] { continue; } content_pixels += 1; for c in 0..4 { diff_sum += (out[i * 4 + c] as i32 - s.ref_pixels[i * 4 + c] as i32).abs() as f64; } } if content_pixels > 0 { total_mae += diff_sum / content_pixels as f64 / 4.0; } } eval_count += 1; let avg_mae = total_mae / samples.len() as f64; if avg_mae < best_avg_mae { best_avg_mae = avg_mae; best_params = (lighting_blur, hl_scale, sh_scale, profile_exp); println!("*** [{}/{}] NEW BEST avg MAE = {:.4} (lb={}, hl={:.2}, sh={:.2}, pe={:.2})", eval_count, total_combos, best_avg_mae, lighting_blur, hl_scale, sh_scale, profile_exp); std::io::stdout().flush().ok(); } else if eval_count % 100 == 0 { println!(" [{}/{}] avg={:.4} best={:.4}", eval_count, total_combos, avg_mae, best_avg_mae); std::io::stdout().flush().ok(); } } } } } let (lb, hl, sh, pe) = best_params; println!("\n=== RESULT ==="); println!("Best avg MAE = {:.4}", best_avg_mae); println!(" lighting_blur = {}", lb); println!(" hl_scale = {:.2}", hl); println!(" sh_scale = {:.2}", sh); println!(" profile_exp = {:.2}", pe); if let Ok(mut file) = std::fs::OpenOptions::new() .create(true).write(true).truncate(true).open(RESULTS_FILE) { let _ = writeln!(file, "lighting_blur={} hl_scale={:.2} sh_scale={:.2} profile_exp={:.2}\n\ avg_mae={:.4} samples={} combos={}", lb, hl, sh, pe, best_avg_mae, samples.len(), total_combos); } } fn npix() -> usize { unsafe { (CANVAS * CANVAS) as usize } } fn parse_filename(name: &str) -> Option<(f32, f32, f32, f32, String)> { // be_s{size}_sf{soften}_d{depth}_a{angle}_{dir_name} let parts: Vec<&str> = name.split('_').collect(); if parts.len() < 6 || parts[0] != "be" { return None; } let size = parts[1].strip_prefix('s')?.parse::().ok()?; let soften = parts[2].strip_prefix("sf")?.parse::().ok()?; let depth = parts[3].strip_prefix('d')?.parse::().ok()?; let angle = parts[4].strip_prefix('a')?.parse::().ok()?; let direction = parts[5].to_string(); Some((size, soften, depth, angle, direction)) }