diff --git a/src-tauri/src/cover_logo.rs b/src-tauri/src/cover_logo.rs index d95dbb7..8b63fc2 100644 --- a/src-tauri/src/cover_logo.rs +++ b/src-tauri/src/cover_logo.rs @@ -46,7 +46,12 @@ fn normalize(s: &str) -> String { const BG_NEAR_DIST: f32 = 46.0; // a pixel this close to the dominant colour counts as background const BG_MIN_DOMINANCE: f32 = 0.5; // background must fill at least this share of the crop, else it's a photo → reject const ALPHA_LO: f32 = 46.0; // distance-from-bg below which a pixel is background (transparent) -const ALPHA_HI: f32 = 120.0; // distance-from-bg above which a pixel is full ink (opaque) +const ALPHA_HI: f32 = 120.0; // floor for the opaque end of the ramp (low-contrast marks) +/// Share of the measured ink-vs-background distance the alpha ramp spans. The +/// ramp's top tracks the real ink distance so anti-aliased edge pixels keep +/// partial alpha instead of saturating; 0.55 puts the midpoint near the middle +/// of a genuine edge gradient. +const ALPHA_INK_SPAN: f32 = 0.55; fn dist(a: [f32; 3], b: [f32; 3]) -> f32 { ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() @@ -150,6 +155,35 @@ pub(crate) fn extract_wordmark( return None; // busy background — text over a photo, not segmentable } + // Scale the alpha ramp's top to the ACTUAL ink-vs-background distance. + // + // A fixed `ALPHA_HI` crushes the source's anti-aliasing whenever the ink is + // far from the background. White text on a black band sits at distance ~438, + // so with a ceiling of 120 every edge pixel brighter than ~27% grey is forced + // fully opaque: a hard threshold wearing a smoothstep's clothing. Measured on + // Camping in Alaska's "Needlemouth": the extracted mask's edges transitioned + // over 0.10px against the source's 0.88px -- roughly nine times harder. + // + // That matters because the logo tracer recovers smooth curves FROM + // anti-aliasing. Feeding it a near-binary mask forced heavy compensating blur + // downstream, which melts serif terminals; with the ramp scaled, the trace can + // run at lower blur and a tighter simplify tolerance and keep real detail. + // + // The 95th percentile is the glyph body rather than the brightest speck, and + // the fixed value stays a floor so low-contrast marks are unaffected. + let ink_distance = { + let mut distances: Vec = crop + .pixels() + .map(|p| dist([p.0[0] as f32, p.0[1] as f32, p.0[2] as f32], bg)) + .collect(); + distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + distances + .get((distances.len() as f32 * 0.95) as usize) + .copied() + .unwrap_or(ALPHA_HI) + }; + let alpha_hi = ALPHA_HI.max(ALPHA_INK_SPAN * ink_distance); + // Alpha ramp by distance from the background colour, tracking per-row ink so // we can trim adjacent text lines the padding caught (the album title above / // below the band name), which sit in their own ink band across an empty gap. @@ -159,7 +193,7 @@ pub(crate) fn extract_wordmark( for x in 0..w { let p = crop.get_pixel(x, y).0; let d = dist([p[0] as f32, p[1] as f32, p[2] as f32], bg); - let a = smoothstep(ALPHA_LO, ALPHA_HI, d); + let a = smoothstep(ALPHA_LO, alpha_hi, d); let alpha = (a * 255.0).round() as u8; // Flat white ink (not the cover's own colour). The frontend logo pass // only traces the ALPHA silhouette and tints it to the accent, and it diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index ed8c391..0a2d6ed 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -776,6 +776,13 @@ const LOGO_TRACE_EDGE = 2400; // supersample target long edge for hard sources const LOGO_TRACE_MAX_SCALE = 4; const LOGO_TRACE_BLUR = 1.0; // blur px per unit of supersample scale + // Cover-derived marks trace at a larger raster with far less blur than they + // used to: their alpha now carries the source's real anti-aliasing (see + // cover_logo.rs ALPHA_INK_SPAN), so detail survives instead of needing to be + // smoothed away. Measured on Camping in Alaska: 900 → ~2200 path vertices + // with no staircasing, and serif terminals that keep their shape. + const LOGO_COVER_TRACE_EDGE = 2400; + const LOGO_COVER_TRACE_BLUR = 0.5; // floor; scales up only for tiny sources const LOGO_MONO_MIN = 0.55; // dominant-colour share to accept for tinting // A logo can trade this much cleanliness for a wider, more title-like shape. // It must still pass LOGO_MONO_MIN; this only ranks already-good candidates. @@ -1562,21 +1569,32 @@ // near the top quality score, then prefer the widest result. let source, eps, minArea; if (usingCoverLogo) { - // Cover-derived wordmark (recipe D): the Rust side already - // despeckled the alpha; upscale to a fixed 1400px width and - // blur so the low-res letterforms trace as smooth curves - // rather than a staircase, then simplify at eps 0.5. + // Cover-derived wordmark (recipe D). The blur here is + // COMPENSATION, not a goal: it exists to synthesise the + // anti-aliasing a mask lacks, and it costs serif detail — + // terminals melt into blobs at ~1px of blur. // - // The blur MUST scale with the enlargement: a fixed 1px did - // almost nothing on a 29px-tall source (a ~7x upscale to - // 1400px), so its pixel-steps survived into the trace as - // aliasing. ~0.6px of blur per unit of scale synthesises the - // anti-aliasing the tracer needs — hi-res marks (scale≈1) stay - // crisp, tiny marks get proportionally more smoothing. - const scale = 1400 / candidate.img.naturalWidth; - source = rgba(candidate.img, 1400, Math.max(1, scale * 0.6)); - eps = 0.5; - minArea = 24; + // The Rust segmentation now scales its alpha ramp to the + // measured ink distance (see cover_logo.rs ALPHA_INK_SPAN), + // so the mask arrives with the source's REAL anti-aliasing + // instead of a near-binary edge. That is what makes a lighter + // touch safe: less blur and a tighter simplify tolerance now + // resolve genuine sub-pixel curvature, where before they only + // captured the pixel staircase in finer detail. + // + // A tiny mark still needs proportionally more smoothing, so + // the blur keeps scaling with the enlargement — just from a + // much lower base. + const scale = LOGO_COVER_TRACE_EDGE / candidate.img.naturalWidth; + source = rgba( + candidate.img, + LOGO_COVER_TRACE_EDGE, + Math.max(LOGO_COVER_TRACE_BLUR, scale * 0.3), + ); + eps = 0.2; + // Speck floor scales with the raster, or a 2400px trace keeps + // dust the 1400px one dropped. + minArea = 24 * (LOGO_COVER_TRACE_EDGE / 1400) ** 2; } else if (aaFraction(rgba(candidate.img)) < LOGO_AA_MIN) { // Hard-edged 1-bit source: supersample and blur to synthesise // anti-aliasing, so the tracer resolves smooth sub-pixel