diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5b200ab..0403b54 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1545,7 +1545,16 @@ pub async fn catalog_artist_socials( // v4: the alpha ramp now scales to the measured ink distance, so every cached // mask predates the anti-aliasing this pipeline relies on and must be redrawn. const COVER_LOGO_RECIPE: &str = "cover-logo-v4-ink-scaled-alpha"; -const COVER_LOGO_MAX_COVERS: usize = 14; +// A prolific artist's wordmark is often nowhere near the top of this list. The +// rank puts albums first and penalises `remix|mix|edit|live` titles — but for +// Poolside (74 releases) every one of the SEVEN covers that OCR reads as +// "POOLSIDE" is a remix single, so all seven sat outside a 14-cover window and +// the artist silently lost its logo. Raising the cap costs nothing for artists +// that hit early: the locate loop stops as soon as COVER_LOGO_MAX_HITS is +// reached, so the extra covers are only ever fetched when the early ones miss. +// The price is paid by artists with no wordmark anywhere, who now OCR more +// covers before giving up — once, then it is cached. +const COVER_LOGO_MAX_COVERS: usize = 40; const COVER_LOGO_MAX_HITS: usize = 5; const COVER_LOGO_LOCATE_WINDOW: usize = 4; const COVER_LOGO_UPGRADE_WINDOW: usize = 4; diff --git a/src-tauri/src/cover_logo.rs b/src-tauri/src/cover_logo.rs index 8b63fc2..2c06727 100644 --- a/src-tauri/src/cover_logo.rs +++ b/src-tauri/src/cover_logo.rs @@ -45,6 +45,10 @@ fn normalize(s: &str) -> String { // Segmentation tuning (RGB distance space, 0..~441). 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 +/// Cover long edge the background histogram and dominance guard are calibrated +/// at. Larger sources are normalised down for that test only — see the note at +/// the guard for why its verdict is otherwise resolution-dependent. +const DOMINANCE_REF_COVER: f32 = 1000.0; const ALPHA_LO: f32 = 46.0; // distance-from-bg below which a pixel is background (transparent) 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 @@ -123,11 +127,36 @@ pub(crate) fn extract_wordmark( let crop = image.crop_imm(x0, y0, x1 - x0, y1 - y0).to_rgba8(); let (w, h) = crop.dimensions(); + // Both the histogram and the dominance guard read a copy normalised to the + // cover resolution they were tuned at. + // + // They are scale-sensitive in opposite directions to intuition: a coarse 16³ + // histogram over a BIGGER source resolves more film grain and JPEG texture, + // which spreads pixels across bins and shrinks the dominant colour's share. + // Alvvays' "Blue Rev" measures dominance 0.746 at 1000px and 0.226 at 3000px + // for the identical crop — so once the extractor started receiving hi-res + // artwork, covers that had segmented cleanly for months began failing the + // 0.5 guard as "text over a photo". The guard did not change; its input grew. + // + // Normalising here keeps the verdict identical whatever resolution the + // upgrade path supplies, while the alpha ramp below still runs at FULL + // resolution, which is the whole point of fetching the larger cover. + let guard_scale = DOMINANCE_REF_COVER / (iw.max(ih) as f32); + let guard_copy = (guard_scale < 1.0).then(|| { + image::imageops::resize( + &crop, + ((w as f32 * guard_scale).round() as u32).max(1), + ((h as f32 * guard_scale).round() as u32).max(1), + image::imageops::FilterType::Triangle, + ) + }); + let guard = guard_copy.as_ref().unwrap_or(&crop); + // Background = the dominant colour, via a coarse 16³ RGB histogram over the // whole crop. Robust to sparse texture (faint line-art on black) in a way a // ring mean is not — the text is a minority, so the mode is the background. let mut hist = [0u32; 16 * 16 * 16]; - for p in crop.pixels() { + for p in guard.pixels() { let (r, g, b) = (p.0[0] >> 4, p.0[1] >> 4, p.0[2] >> 4); hist[(r as usize) * 256 + (g as usize) * 16 + b as usize] += 1; } @@ -145,8 +174,8 @@ pub(crate) fn extract_wordmark( // Dominance guard: what share of the crop is near that background colour? // A clean wordmark has a dominant background (text is the minority); a photo // has no single dominant colour, so this share is low → reject. - let total = (w * h) as f32; - let near = crop + let total = (guard.width() * guard.height()) as f32; + let near = guard .pixels() .filter(|p| dist([p.0[0] as f32, p.0[1] as f32, p.0[2] as f32], bg) < BG_NEAR_DIST) .count() as f32; diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 6621b74..950fb9b 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -1285,12 +1285,17 @@ function albumLogoRank(a) { return typeRank + noisy; } +// Must not undercut the backend's own cap (COVER_LOGO_MAX_COVERS): whatever is +// trimmed here the extractor can never see. A prolific artist's wordmark is +// frequently on a low-ranked remix single — all seven of Poolside's readable +// covers sat past position 14 — and the backend stops early once it has enough +// hits, so a longer list costs nothing for artists whose top covers work. function coverLogoCandidates(page) { return [...(page.albums ?? [])] .sort((a, b) => albumLogoRank(a) - albumLogoRank(b)) .map((a) => ({ title: a.title ?? "", url: a.cover })) .filter((c) => c.url) - .slice(0, 14); + .slice(0, 40); } function coverLogoInputKey(artistId, page, covers) {