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.
This commit is contained in:
KeelrLeek committed 2026-07-25 19:47:15 -05:00
1 parent b16fc340b4
commit 0955dc02ce
1 file changed
+103 -10
+103 -10
View File
@@ -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,
})
}