From 0955dc02cef03f8542a1bcceead8c3d3b4451fde Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:47:15 -0500 Subject: [PATCH] cover logo: trim the wordmark horizontally, and stop rewarding bleed The extractor trimmed the crop VERTICALLY to the wordmark's true edge, walking rows until a transparent gap. Horizontally it did nothing: left and right were the OCR box plus a pad of 5% of the word's width. Three defects fell out of that asymmetry, all measurable as ink touching the finished mask's boundary. - Glyphs were sliced when the OCR box under-covered them. Snail Mail's italic "L" was cut down the middle: 87 of 163 rows inked at the right edge. Padding now scales with TYPE SIZE rather than word length, because an overhang is proportional to letter height and a long wordmark was getting the least room. - Cover frames were swallowed when the box sat near an edge. Knocked Loose's mask had ink down its entire left column and across its entire top -- a border, not letters. - Artwork BESIDE the text bridged the row walk. A decorative shape down the left of "Lamb's Wool (with Poolside)" kept every row between the two text lines non-empty, so the walk sailed through the gap and took the line above with it: the mask read "WITH / POOLSIDE". Row ink is now tallied over the artist name's own columns, where that gap is genuinely empty. The column walk mirrors the row one exactly: outward from the name's columns while ink stays contiguous, stopping at the first transparent column (so the wider pad can never reach a neighbouring word) and at a near-fully-inked one (a rule or frame, the same boundary `solid` already recognises for rows). Candidate scoring gains a bleed penalty, because trimming alone made selection WORSE. A clean mark ends with empty borders, so ink still touching one means cover art is continuous with the text -- and that art is soft grey, which the `aa` term reads as superb edge quality: Folamour's smeared 246x89 candidate scores 0.44 against the clean 647x124's 0.03, and won. Penalising boundary ink puts the clean one back in front. Measured over the fixture roster: Knocked Loose 920x151 -> 872x88 with no frame, Snail Mail's L intact, Poolside a clean "POOLSIDE" instead of "WITH / POOLSIDE", Folamour back to 647x124. Camping in Alaska, Crazy P, Mildlife, Session Victim, Turnover, Alvvays, Motor City Drum Ensemble, Spiritbox and Todd Terje all still extract, each a little tighter now that the slack is trimmed. --- src-tauri/src/cover_logo.rs | 113 ++++++++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/cover_logo.rs b/src-tauri/src/cover_logo.rs index 2c06727..cc56cd5 100644 --- a/src-tauri/src/cover_logo.rs +++ b/src-tauri/src/cover_logo.rs @@ -113,7 +113,13 @@ pub(crate) fn extract_wordmark( let (iw, ih) = image.dimensions(); // Pad the located word box — vertical for ascenders/descenders + tracer room — // but never past the clamp to a neighbouring line. - let pad_x = (rect.w * 0.05).max(6.0); + // Horizontal padding scales with TYPE SIZE, not word length. At 5% of width + // a long wordmark got 18px of room while its glyphs overhang by far more -- + // Snail Mail's italic "L" was sliced down the middle. Nothing downstream can + // recover a letter that was never cropped in, so be generous here; the + // column trim below cuts the slack back off, and stops at the first + // transparent column so a neighbouring word can never be pulled in. + let pad_x = (rect.w * 0.05).max(rect.h * 0.6).max(6.0); let pad_y = (rect.h * 0.45).max(6.0); let x0 = (rect.x - pad_x).max(0.0) as u32; let y0 = (rect.y - pad_y).max(loc.clamp_top).max(0.0) as u32; @@ -216,6 +222,15 @@ pub(crate) fn extract_wordmark( // 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. + // The artist name's OWN columns, in crop space. Row ink is tallied over just + // this span so the row walk below cannot be bridged by artwork sitting + // beside the text: on "Lamb's Wool (with Poolside)" a decorative shape down + // the left edge kept every row between the two text lines non-empty, so the + // walk sailed through the gap and swallowed the line above -- the mask read + // "WITH / POOLSIDE". Restricted to the name's columns, that gap is empty + // again and the walk stops where it should. + let name_left = (rect.x - x0 as f32).clamp(0.0, (w - 1) as f32) as u32; + let name_right = (rect.x + rect.w - x0 as f32).clamp(0.0, (w - 1) as f32) as u32; let mut full = image::RgbaImage::new(w, h); let mut row_ink = vec![0u32; h as usize]; for y in 0..h { @@ -230,7 +245,7 @@ pub(crate) fn extract_wordmark( // colour scored below that bar and fell back to a small untinted PNG. // A single flat colour guarantees the traced, accent-tinted SVG path. full.put_pixel(x, y, image::Rgba([255, 255, 255, alpha])); - if alpha > 128 { + if alpha > 128 && x >= name_left && x <= name_right { row_ink[y as usize] += 1; } } @@ -242,10 +257,12 @@ pub(crate) fn extract_wordmark( // lies past that transparent gap is separated content — a rule bar above, the // album-title ghost below — and is dropped. We crop to the centre content, then // re-add clean transparent padding. - let empty = |r: usize| (row_ink[r] as f32 / w as f32) < 0.004; + // Denominator is the name's own span, matching what row_ink counts. + let name_span = (name_right.saturating_sub(name_left) + 1).max(1) as f32; + let empty = |r: usize| (row_ink[r] as f32 / name_span) < 0.004; // A near-fully-inked row is a solid rule/bar touching the text with no gap — // also a boundary (glyph rows always leave inter-letter gaps, never ~full). - let solid = |r: usize| row_ink[r] as f32 / w as f32 > 0.85; + let solid = |r: usize| row_ink[r] as f32 / name_span > 0.85; let center = (((rect.y + rect.h / 2.0) as u32).saturating_sub(y0)).min(h - 1) as usize; // 2 transparent rows = a real gap (bridges a 1px anti-aliasing fluke). let gap_stop = 2usize; @@ -302,14 +319,61 @@ pub(crate) fn extract_wordmark( return None; } + // COLUMN trim — the horizontal counterpart of the row walk above, which the + // extractor never had. Left and right were simply the OCR box plus a pad, so + // the crop either sliced a glyph the box under-covered or, when the box sat + // near a cover edge, swallowed the artwork's frame (Knocked Loose's mask had + // ink down its entire left column and across its entire top). + // + // Walk OUTWARD from the name's own columns while ink stays contiguous, so a + // letter the box clipped is recovered, and stop at the first transparent + // column — which is why widening pad_x cannot drag in a neighbouring word. + // A near-fully-inked column is a vertical rule or frame, the same boundary + // `solid` recognises for rows. + let mut col_ink = vec![0u32; w as usize]; + for y in band_top..=band_bottom { + for x in 0..w { + if full.get_pixel(x, y).0[3] > 128 { + col_ink[x as usize] += 1; + } + } + } + let band_f = band_h as f32; + let empty_col = |c: usize| (col_ink[c] as f32 / band_f) < 0.004; + let solid_col = |c: usize| (col_ink[c] as f32 / band_f) > 0.85; + let mut left = name_left.min(w - 1) as usize; + while left > 0 { + let next = left - 1; + if empty_col(next) || solid_col(next) { + break; + } + left = next; + } + let mut right = name_right.min(w - 1) as usize; + while right + 1 < w as usize { + let next = right + 1; + if empty_col(next) || solid_col(next) { + break; + } + right = next; + } + // Same breathing room the rows get, clamped inside the crop. + let col_pad = (((right - left + 1) as f32) * 0.02).round().clamp(3.0, 40.0) as usize; + let band_left = left.saturating_sub(col_pad) as u32; + let band_right = ((right + col_pad).min(w as usize - 1)) as u32; + let band_w = band_right - band_left + 1; + if band_w < 8 { + return None; + } + // Crop to the wordmark's row band and tally ink/partial for scoring. - let mut out = image::RgbaImage::new(w, band_h); + let mut out = image::RgbaImage::new(band_w, band_h); let mut ink = 0u32; let mut partial = 0u32; for (dy, y) in (band_top..=band_bottom).enumerate() { - for x in 0..w { + for (dx, x) in (band_left..=band_right).enumerate() { let px = *full.get_pixel(x, y); - out.put_pixel(x, dy as u32, px); + out.put_pixel(dx as u32, dy as u32, px); if px.0[3] > 200 { ink += 1; } else if px.0[3] > 20 { @@ -317,7 +381,7 @@ pub(crate) fn extract_wordmark( } } } - let coverage = ink as f32 / (w * band_h) as f32; + let coverage = ink as f32 / (band_w * band_h) as f32; if coverage < 0.008 || coverage > 0.6 { return None; } @@ -327,11 +391,40 @@ pub(crate) fn extract_wordmark( // Score: reward anti-aliasing (edge quality), a title-like wide aspect, and // a healthy ink coverage; higher wins across candidate covers. let aa = (partial as f32 / (ink as f32 + 1.0)).min(0.6); - let aspect = ((w as f32 / band_h as f32) / 10.0).min(0.6); + let aspect = ((band_w as f32 / band_h as f32) / 10.0).min(0.6); let cover_score = 1.0 - (coverage - 0.15).abs(); + // Penalise ink that runs off the trimmed edge. + // + // The column and row walks stop at a transparent gap, so a cleanly isolated + // wordmark ends with empty borders. Ink still touching a boundary therefore + // means cover art is CONTINUOUS with the text and came along with it — + // Folamour's 246x89 candidate carries a pale smear through its lower edge. + // Such a smear is also why this cannot be left to the `aa` term: soft grey + // bleed is a mass of partial alpha, which scores as excellent edge quality + // (0.44 against a clean mark's 0.03) and actively promotes the worst + // candidates. + let mut edge_ink = 0u32; + for x in 0..band_w { + if out.get_pixel(x, 0).0[3] > 128 { + edge_ink += 1; + } + if out.get_pixel(x, band_h - 1).0[3] > 128 { + edge_ink += 1; + } + } + for y in 0..band_h { + if out.get_pixel(0, y).0[3] > 128 { + edge_ink += 1; + } + if out.get_pixel(band_w - 1, y).0[3] > 128 { + edge_ink += 1; + } + } + let perimeter = (2 * (band_w + band_h)) as f32; + let bleed = (edge_ink as f32 / perimeter).min(1.0); Some(Wordmark { image: out, - score: aa + aspect + cover_score, + score: aa + aspect + cover_score - bleed, }) }