//! Derive an artist wordmark from album art for artists that have no provider //! (Fanart.tv / TheAudioDB) logo. Many covers print the band name as clean text; //! on-device OCR (Windows.Media.Ocr) locates that line, and (increment 2) it is //! segmented off a near-uniform background into a transparent PNG that the //! existing logo tracer vectorizes and tints like any provider logo. //! //! Fallible by design: any failure (non-Windows build, no OCR, no matching text) //! leaves the caller on the plain text-name fallback. /// Axis-aligned box in source-image pixels. #[derive(Clone, Copy, Debug)] pub(crate) struct Rect { pub x: f32, pub y: f32, pub w: f32, pub h: f32, } /// One recognized word with its own bounding box. #[derive(Clone, Debug)] pub(crate) struct OcrWord { pub text: String, pub rect: Rect, } /// One recognized line of text with its bounding box (union of its words) plus /// the individual words — so a wordmark can be boxed to JUST the words that spell /// the artist name, excluding a track number / remix credit sharing the line. #[derive(Clone, Debug)] pub(crate) struct OcrLine { pub text: String, pub rect: Rect, pub words: Vec, } /// Lowercase alphanumerics only — matches the normalization used elsewhere so a /// cover's "CAMPING IN ALASKA" lines up with the artist name "Camping In Alaska". fn normalize(s: &str) -> String { s.to_lowercase() .chars() .filter(|c| c.is_alphanumeric()) .collect() } // 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 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) 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() } fn smoothstep(lo: f32, hi: f32, x: f32) -> f32 { let t = ((x - lo) / (hi - lo)).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } /// A wordmark lifted off a cover, plus a quality score for picking the best cover. pub(crate) struct Wordmark { pub image: image::RgbaImage, pub score: f32, } /// The band-name line on a cover plus the vertical band it may occupy. `clamp_top` /// / `clamp_bottom` are the midpoints to the nearest OTHER text lines (or image /// edges), so the extraction crop can NEVER reach another line — the album title /// and any other cover text are structurally excluded from the logo. #[derive(Clone, Copy, Debug)] pub(crate) struct BandLocation { pub rect: Rect, pub clamp_top: f32, pub clamp_bottom: f32, } impl BandLocation { /// Scale to another resolution of the same cover (locate at 1000px where OCR /// is reliable, extract at 1800px for quality). pub fn scaled(&self, sx: f32, sy: f32) -> BandLocation { BandLocation { rect: Rect { x: self.rect.x * sx, y: self.rect.y * sy, w: self.rect.w * sx, h: self.rect.h * sy, }, clamp_top: self.clamp_top * sy, clamp_bottom: self.clamp_bottom * sy, } } } /// Segment the band-name text at `loc` off its (near-uniform) background into a /// transparent RGBA image. The crop is hard-clamped to `loc`'s band so no other /// text line can enter. Returns `None` when the background is too busy to segment /// (text over a photo) or the result is empty — those stay on the text fallback. pub(crate) fn extract_wordmark( image: &image::DynamicImage, loc: &BandLocation, ) -> Option { use image::GenericImageView as _; let rect = loc.rect; 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); 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; let x1 = ((rect.x + rect.w + pad_x).min(iw as f32)) as u32; let y1 = ((rect.y + rect.h + pad_y) .min(loc.clamp_bottom) .min(ih as f32)) as u32; if x1 <= x0 + 4 || y1 <= y0 + 4 { return None; } let crop = image.crop_imm(x0, y0, x1 - x0, y1 - y0).to_rgba8(); let (w, h) = crop.dimensions(); // 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() { 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; } let mode = hist .iter() .enumerate() .max_by_key(|(_, c)| **c) .map(|(i, _)| i) .unwrap_or(0); let bg = [ ((mode / 256) as f32) * 16.0 + 8.0, (((mode / 16) % 16) as f32) * 16.0 + 8.0, ((mode % 16) as f32) * 16.0 + 8.0, ]; // 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 .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; let dominance = near / total; if dominance < BG_MIN_DOMINANCE { return None; // busy background — text over a photo, not segmentable } // 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. let mut full = image::RgbaImage::new(w, h); let mut row_ink = vec![0u32; h as usize]; for y in 0..h { 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 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 // only does so when the mark is monochrome — a cover's varied/print // 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 { row_ink[y as usize] += 1; } } } // The wordmark is CENTRED on the located box. A single line of text never has a // fully transparent row running through it — some glyph always spans every row // (an i-dot's little gap is filled by neighbouring letters). So the first fully // transparent row above/below the centre is the wordmark's TRUE edge; whatever // 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; // 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 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; // Safety cap so a pathological run can't swallow the whole cover. let max_half = (rect.h * 1.5).round().max(6.0) as usize; let lo = center.saturating_sub(max_half); let hi = (center + max_half).min(h as usize - 1); let mut top = center; { let (mut r, mut gap) = (center, 0usize); while r > lo { r -= 1; if solid(r) { break; } if empty(r) { gap += 1; if gap >= gap_stop { break; } } else { gap = 0; top = r; } } } let mut bottom = center; { let (mut r, mut gap) = (center, 0usize); while r + 1 <= hi { r += 1; if solid(r) { break; } if empty(r) { gap += 1; if gap >= gap_stop { break; } } else { gap = 0; bottom = r; } } } // Re-add transparent breathing room (~6% of content height) for the tracer/blur. let pad = (((bottom - top + 1) as f32) * 0.06) .round() .clamp(3.0, 40.0) as usize; let band_top = top.saturating_sub(pad) as u32; let band_bottom = (bottom + pad).min(h as usize - 1) as u32; let band_h = band_bottom - band_top + 1; if band_h < 6 { 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 ink = 0u32; let mut partial = 0u32; for (dy, y) in (band_top..=band_bottom).enumerate() { for x in 0..w { let px = *full.get_pixel(x, y); out.put_pixel(x, dy as u32, px); if px.0[3] > 200 { ink += 1; } else if px.0[3] > 20 { partial += 1; } } } let coverage = ink as f32 / (w * band_h) as f32; if coverage < 0.008 || coverage > 0.6 { return None; } // Recipe D: median despeckle removes the ragged spurs a lo-fi print leaves on // the letter edges before the frontend supersamples/blurs/traces it. let out = median_alpha(&out); // 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 cover_score = 1.0 - (coverage - 0.15).abs(); Some(Wordmark { image: out, score: aa + aspect + cover_score, }) } // ---- Experimental alpha post-processing (for the quality comparison) ---- /// 3×3 median on the alpha channel — despeckles the ragged spurs a noisy print /// leaves on the letter edges, without rounding real corners much. fn median_alpha(src: &image::RgbaImage) -> image::RgbaImage { let (w, h) = src.dimensions(); let mut out = src.clone(); for y in 0..h { for x in 0..w { // The previous implementation allocated a Vec for every output // pixel. A fixed nine-byte buffer computes the identical median. let mut n = [0u8; 9]; let mut len = 0usize; for dy in -1..=1 { for dx in -1..=1 { let nx = x as i32 + dx; let ny = y as i32 + dy; if nx >= 0 && ny >= 0 && (nx as u32) < w && (ny as u32) < h { n[len] = src.get_pixel(nx as u32, ny as u32).0[3]; len += 1; } } } n[..len].sort_unstable(); let mut p = *src.get_pixel(x, y); p.0[3] = n[len / 2]; out.put_pixel(x, y, p); } } out } /// Otsu-binarized copy (black text on white / white on black, auto-thresholded on /// luma). Experimental preprocessing to test whether the OCR reads stylized /// display type better with the colour and gradients flattened away. pub(crate) fn binarize(image: &image::DynamicImage) -> image::DynamicImage { use image::GenericImageView as _; let gray = image.to_luma8(); // Otsu threshold. let mut hist = [0u32; 256]; for p in gray.pixels() { hist[p.0[0] as usize] += 1; } let total: u32 = hist.iter().sum(); let sum: f64 = hist .iter() .enumerate() .map(|(i, &c)| i as f64 * c as f64) .sum(); let (mut sum_b, mut w_b, mut max, mut thresh) = (0.0f64, 0u32, 0.0f64, 128u8); for (t, &c) in hist.iter().enumerate() { w_b += c; if w_b == 0 { continue; } let w_f = total - w_b; if w_f == 0 { break; } sum_b += t as f64 * c as f64; let m_b = sum_b / w_b as f64; let m_f = (sum - sum_b) / w_f as f64; let between = w_b as f64 * w_f as f64 * (m_b - m_f).powi(2); if between > max { max = between; thresh = t as u8; } } // Decide polarity so text is dark on light (OCR's expectation) by majority. let (w, h) = image.dimensions(); let mut out = image::RgbaImage::new(w, h); for (x, y, p) in gray.enumerate_pixels().map(|(x, y, p)| (x, y, p.0[0])) { let v = if p > thresh { 255 } else { 0 }; out.put_pixel(x, y, image::Rgba([v, v, v, 255])); } image::DynamicImage::ImageRgba8(out) } /// OCR the cover twice — once as-is and once Otsu-binarized — and merge the line /// sets. Colour/gradient display type (the cyan "CRAZY P" on Paper Years) is /// often unreadable raw but recognized once flattened to black-on-white; the raw /// pass still catches anything the threshold destroys. Both passes share pixel /// coordinates (the binarized copy is the same dimensions), so word boxes line up. pub(crate) fn ocr_lines_multi(image: &image::DynamicImage) -> Result, String> { let mut lines = ocr_lines(image)?; if let Ok(mut bin) = ocr_lines(&binarize(image)) { lines.append(&mut bin); } Ok(lines) } /// All text lines OCR finds on the image, in source-pixel coordinates. pub(crate) fn ocr_lines(image: &image::DynamicImage) -> Result, String> { #[cfg(windows)] { windows_ocr::ocr_lines(image) } #[cfg(not(windows))] { let _ = image; Err("cover-logo OCR is only available on Windows".into()) } } /// A substring match must be at least this share of its OCR line's letters, or /// the line is a tracklist / credit that merely MENTIONS the name (e.g. /// "A1. Disc Odyssey Crazy P remix" — "crazyp" is 25% of it) rather than a /// wordmark that IS the name. Precision-first: only "just the band name" lines /// (optionally with a hyphen/initial) clear this bar, never a whole credit line. const NAME_LINE_MIN_RATIO: f32 = 0.6; /// Locate the band-name line and the vertical band it may occupy — clamped at /// the midpoint to the nearest OTHER text line above/below (or the image edge), /// so extraction can never include the album title or any other cover text. /// Scores every line so a bare-name line beats one that merely embeds the name, /// and rejects lines where the name is a minority (a tracklist / remix credit). pub(crate) fn locate_wordmark( image: &image::DynamicImage, artist: &str, ) -> Result, String> { Ok(locate_wordmark_named(image, artist)?.map(|(loc, _, _)| loc)) } /// Within one OCR line, find the consecutive run of WORDS whose normalized text /// spells the artist name exactly, and return the union box of just those words. /// This lifts "Crazy P" cleanly out of "A1. Disc Odyssey Crazy P remix" or /// "CRAZY P THE PAPER YEARS" — the track number, remix credit and album title /// are never inside the returned box. `None` if no run spells the name. fn word_run_box(line: &OcrLine, want: &str) -> Option { // Only real word tokens: a "///", "A1." or "-" normalizes to empty and must // NOT be allowed into the run's box, or the decorative mark next to the name // gets extracted with it (Turnover's "/// TURNOVER"). let toks: Vec<&OcrWord> = line .words .iter() .filter(|w| !normalize(&w.text).is_empty()) .collect(); for i in 0..toks.len() { let mut acc = String::new(); for j in i..toks.len() { acc.push_str(&normalize(&toks[j].text)); if acc.len() > want.len() { break; } if acc == want { let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); for w in &toks[i..=j] { x0 = x0.min(w.rect.x); y0 = y0.min(w.rect.y); x1 = x1.max(w.rect.x + w.rect.w); y1 = y1.max(w.rect.y + w.rect.h); } return Some(Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0, }); } } } None } /// Like [`locate_wordmark`] but also returns the matched OCR text and its score /// (1.0 = a clean word-run of the name; <1 = a line the name only partly fills). /// For diagnostics — "which line on which cover did we lift?". pub(crate) fn locate_wordmark_named( image: &image::DynamicImage, artist: &str, ) -> Result, String> { let want = normalize(artist); if want.is_empty() { return Ok(None); } // Two-pass OCR (raw + binarized) so stylized colour wordmarks are read. let lines = ocr_lines_multi(image)?; // Prefer a precise word-run match (the box is JUST the name); among those, // take the widest — a cover's big printed wordmark over a tiny corner label. // Fall back to a whole-line substring only if no cover word-run matches, and // only when the name fills most of the line (else it's a tracklist/credit). let mut word_best: Option<(usize, Rect)> = None; let mut line_best: Option<(usize, f32)> = None; for (i, line) in lines.iter().enumerate() { if let Some(rect) = word_run_box(line, &want) { if word_best.is_none_or(|(_, r)| rect.w > r.w) { word_best = Some((i, rect)); } continue; } let norm = normalize(&line.text); if norm.is_empty() { continue; } let score = if norm == want { 1.0 } else if want.len() >= 4 && norm.contains(&want) { let ratio = want.len() as f32 / norm.len() as f32; if ratio >= NAME_LINE_MIN_RATIO { ratio } else { continue; } } else { continue; }; if line_best.is_none_or(|(_, s)| score > s) { line_best = Some((i, score)); } } let (bi, band, matched_score) = if let Some((i, rect)) = word_best { (i, rect, 1.0) } else if let Some((i, s)) = line_best { (i, lines[i].rect, s) } else { return Ok(None); }; let matched_text = lines[bi].text.clone(); let mut clamp_top = 0.0f32; let mut clamp_bottom = image.height() as f32; for (i, line) in lines.iter().enumerate() { if i == bi { continue; } let r = line.rect; let r_bottom = r.y + r.h; if r_bottom <= band.y { // A line above — stop halfway up to it. clamp_top = clamp_top.max((r_bottom + band.y) / 2.0); } else if r.y >= band.y + band.h { // A line below — stop halfway down to it. clamp_bottom = clamp_bottom.min((band.y + band.h + r.y) / 2.0); } } Ok(Some(( BandLocation { rect: band, clamp_top, clamp_bottom, }, matched_text, matched_score, ))) } #[cfg(windows)] mod windows_ocr { use image::{DynamicImage, ImageEncoder as _}; use windows::{ Graphics::Imaging::{BitmapDecoder, BitmapPixelFormat, SoftwareBitmap}, Media::Ocr::OcrEngine, Storage::Streams::{DataWriter, InMemoryRandomAccessStream}, Win32::System::WinRT::{RoInitialize, RoUninitialize, RO_INIT_MULTITHREADED}, }; use super::{OcrLine, OcrWord, Rect}; struct WinRtGuard; impl Drop for WinRtGuard { fn drop(&mut self) { unsafe { RoUninitialize() }; } } fn initialize_winrt() -> Result, String> { match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } { Ok(()) => Ok(Some(WinRtGuard)), // Thread already in a different apartment: WinRT is usable, we just // must not uninitialize it. Err(error) if error.code().0 as u32 == 0x8001_0106 => Ok(None), Err(error) => Err(format!("Windows Runtime initialization failed: {error}")), } } /// Load a DynamicImage into a Bgra8 SoftwareBitmap (the format OCR wants), /// reusing the same PNG→stream→decoder path the face detector uses. fn software_bitmap(image: &DynamicImage) -> Result { let rgba = image.to_rgba8(); let (width, height) = rgba.dimensions(); let mut png = Vec::new(); image::codecs::png::PngEncoder::new(&mut png) .write_image( rgba.as_raw(), width, height, image::ExtendedColorType::Rgba8, ) .map_err(|error| format!("could not encode OCR raster: {error}"))?; let stream = InMemoryRandomAccessStream::new() .map_err(|error| format!("could not allocate OCR stream: {error}"))?; let writer = DataWriter::CreateDataWriter(&stream) .map_err(|error| format!("could not create OCR writer: {error}"))?; writer .WriteBytes(&png) .map_err(|error| format!("could not write OCR raster: {error}"))?; writer .StoreAsync() .and_then(|op| op.get()) .map_err(|error| format!("could not store OCR raster: {error}"))?; writer .FlushAsync() .and_then(|op| op.get()) .map_err(|error| format!("could not flush OCR raster: {error}"))?; let _ = writer.DetachStream(); stream .Seek(0) .map_err(|error| format!("could not rewind OCR raster: {error}"))?; let decoder = BitmapDecoder::CreateAsync(&stream) .and_then(|op| op.get()) .map_err(|error| format!("could not decode OCR raster: {error}"))?; let decoded = decoder .GetSoftwareBitmapAsync() .and_then(|op| op.get()) .map_err(|error| format!("could not prepare OCR bitmap: {error}"))?; SoftwareBitmap::Convert(&decoded, BitmapPixelFormat::Bgra8) .map_err(|error| format!("could not convert OCR bitmap: {error}")) } pub(crate) fn ocr_lines(image: &DynamicImage) -> Result, String> { let _winrt = initialize_winrt()?; let bitmap = software_bitmap(image)?; let engine = OcrEngine::TryCreateFromUserProfileLanguages() .map_err(|error| format!("could not create OCR engine: {error}"))?; let result = engine .RecognizeAsync(&bitmap) .and_then(|op| op.get()) .map_err(|error| format!("OCR failed: {error}"))?; let mut lines = Vec::new(); for line in &result .Lines() .map_err(|error| format!("could not read OCR lines: {error}"))? { let text = line.Text().map(|t| t.to_string_lossy()).unwrap_or_default(); if text.trim().is_empty() { continue; } // The line has no box of its own — union its words' boxes, and keep // each word's box so a wordmark can be located word-precisely. let (mut min_x, mut min_y, mut max_x, mut max_y) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); let mut word_boxes = Vec::new(); if let Ok(words) = line.Words() { for word in &words { if let Ok(r) = word.BoundingRect() { min_x = min_x.min(r.X); min_y = min_y.min(r.Y); max_x = max_x.max(r.X + r.Width); max_y = max_y.max(r.Y + r.Height); let wtext = word.Text().map(|t| t.to_string_lossy()).unwrap_or_default(); word_boxes.push(OcrWord { text: wtext, rect: Rect { x: r.X, y: r.Y, w: r.Width, h: r.Height, }, }); } } } if word_boxes.is_empty() { continue; } lines.push(OcrLine { text: text.trim().to_string(), rect: Rect { x: min_x, y: min_y, w: max_x - min_x, h: max_y - min_y, }, words: word_boxes, }); } Ok(lines) } }