//! 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, } impl Rect { fn scaled(&self, sx: f32, sy: f32) -> Rect { Rect { x: self.x * sx, y: self.y * sy, w: self.w * sx, h: self.h * sy, } } } impl OcrLine { /// Re-express an OCR result read at one resolution in another's pixels. fn scaled(&self, sx: f32, sy: f32) -> OcrLine { OcrLine { text: self.text.clone(), rect: self.rect.scaled(sx, sy), words: self .words .iter() .map(|word| OcrWord { text: word.text.clone(), rect: word.rect.scaled(sx, sy), }) .collect(), } } } /// Cover long edge to retry OCR at when the native read comes back BLIND. /// /// Windows OCR is markedly scale-sensitive on large display type: Wand's /// "Machine Man / M.E." prints WAND in warm grey across the bottom corner and /// is read at 512px and at NO other size tested (1000/768/640, raw or /// binarized). A very large mark can overflow what the recogniser will treat as /// one line, so SHRINKING the cover is what brings it into range — the opposite /// of the usual "upscale to read small text" instinct. /// /// Deliberately one scale, and deliberately gated on a blind read rather than /// on "no match": every escalating cover pays another full raw+binarized pass, /// and an artist with no wordmark anywhere OCRs all forty of them. Gating this /// way kept a 21-artist regeneration at 1m47s where "no match" took ~6min, and /// 768/640 were never observed to rescue a cover 512 did not. A read that /// returned LINES has already answered the question — the cover has text and /// the name is not in it — while zero lines means OCR simply failed, which is /// the signature this exists to rescue. const OCR_ESCALATION_SCALE: u32 = 512; /// 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 /// 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) /// …but never more than this share of the ink's own distance. /// /// The ramp's TOP already tracks the measured ink distance; its BOTTOM stayed a /// fixed constant, and on a faint mark that constant eats the edge. Wand's /// "Machine Man / M.E." prints at an ink distance of 97, so a floor of 46 /// discards the outer HALF of every edge gradient — measured across one glyph /// edge, the pixel carrying ~20% ink coverage (distance 19) is forced fully /// transparent. The boundary then quantises to whole source pixels, and that /// ±1px wobble is what becomes ±8px of staircase once the tracer enlarges a /// small mask 8x. /// /// Scaling the floor keeps sub-pixel edge position, which is the thing the /// tracer actually needs. High-contrast marks are untouched by construction: /// white on black sits near 438, and 0.15 x 438 is well past 46, so the /// constant still wins for anything with an ink distance above ~307. const ALPHA_LO_INK_SHARE: f32 = 0.15; 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. /// Ceiling on the post-ramp alpha normalisation below. /// /// A faint mark needs its glyph BODY opaque — every test here counts ink at /// `alpha > 200`, and Wand's "Machine Man / M.E." (warm grey on white, ink /// distance 90) tops out near 64% against a ramp ceiling of 120, so it measured /// NINE ink pixels against 1590 partial and was thrown away as empty. /// /// The fix is NOT to narrow the ramp onto the ink. That window is what carries /// the source's anti-aliasing, which is the very thing the tracer recovers /// curves from — and on a grainy source it also sits closer to the paper noise, /// so narrowing lets grain bite the glyph edges (measured: paper at distance 60 /// reads alpha 74 through a 46-81 window against 20 through 46-120). The result /// was a near-binary, noise-eaten mask that stair-steps under the 8x /// enlargement the trace runs at. /// /// So the ramp keeps its full width and the finished alpha is RESCALED so the /// body lands at 255. Shape is preserved, grain stays near zero, and the mark /// saturates. Bounded so an almost-invisible mark cannot be amplified into one. const ALPHA_BODY_MAX_GAIN: f32 = 4.0; 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() } 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, target_edge: u32, ) -> Option { try_extract_wordmark(image, loc, target_edge).ok() } /// [`extract_wordmark`] with the reason it gave up. /// /// Every gate below has fired on a cover that visibly prints the artist name, /// and "it returned nothing" is not a usable answer when one goes wrong — /// diagnosing Wand cost several rebuilds and a JS replication of this function /// that silently drifted from it. The debug command reports this string. pub(crate) fn try_extract_wordmark( image: &image::DynamicImage, loc: &BandLocation, // Mask long edge to resample the crop up to before the alpha ramp, or 0 // to segment at native resolution. Candidate SELECTION must pass 0: the // mark's height and its aa/coverage/bleed score are all measured on the // mask, so supersampling during scoring silently re-measures every // candidate and changes which COVER wins (Hot Toddy swapped a clean bold // wordmark for a thin speckled one that way). Only the winner is worth // resampling, and by then the comparison is already settled. target_edge: u32, ) -> Result { 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. // 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; 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 Err("crop-too-small"); } 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 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; } 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 = (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; let dominance = near / total; if dominance < BG_MIN_DOMINANCE { return Err("bg-photographic"); // 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); // The ramp's bottom scales with the ink too — see [`ALPHA_LO_INK_SHARE`]. let alpha_lo = ALPHA_LO.min(ink_distance * ALPHA_LO_INK_SHARE); // Where the glyph body lands on that ramp, and the gain that puts it at // full opacity — see [`ALPHA_BODY_MAX_GAIN`]. let body_alpha = smoothstep(alpha_lo, alpha_hi, ink_distance); let alpha_gain = if body_alpha > 0.01 { (1.0 / body_alpha).min(ALPHA_BODY_MAX_GAIN) } else { 1.0 }; // Supersample the CROP before the ramp, not the finished mask after it. // // The tracer enlarges whatever it gets to 2400px, so a small mark is already // being upscaled — but upscaling the MASK interpolates between alphas that // have already been quantised by the ramp, which cannot recover an edge // position finer than one source pixel. Wand's WAND is ~35px tall in its // 1500px master and the source edge spans a single pixel (measured: distance // 19 → 78 → 97 across three pixels), so that quantisation is ±1 source pixel // of boundary wobble, and the trace multiplies it by 8. // // Resampling first means the ramp evaluates smoothstep on INTERPOLATED // colour, which places the edge between source pixels instead of on them. // Lanczos rather than the tracer's bilinear, and only up to a target size — // a mark that is already large gains nothing and would just cost memory. // // The scale is FRACTIONAL, and that is the whole point of the target. An // integer factor is `floor(target / crop)`, which is 1 for every crop wider // than half the target — so at target 2400 a crop of 1201px was supersampled // by exactly nothing while a crop of 1200px got 2x. That cliff is not // hypothetical and it does not land on marks that can spare the quality: a // big printed wordmark on a 1500px master crops to 1200-1800px, so the // artists it silenced were Hot Toddy (zig 0.312, a visible staircase around // the O) and Crazy P (0.220) — the two worst edges measured across eight // artists, both of which fall to ~0.00 the moment any supersampling happens. // Raising the constant only moves the cliff to 1801px; scaling to the target // removes it. // // The 8x ceiling now binds only below a ~300px crop, where the ramp has so // few real source pixels to interpolate between that more of them stops // paying (measured on Poolside, the smallest mark here, at 4x vs 8x). let (crop, w, h, supersample) = { let factor = if target_edge == 0 { 1.0 } else { (target_edge as f32 / w.max(h) as f32).clamp(1.0, 8.0) }; if factor > 1.01 { let (sw, sh) = ( (w as f32 * factor).round().max(1.0) as u32, (h as f32 * factor).round().max(1.0) as u32, ); ( image::imageops::resize(&crop, sw, sh, image::imageops::FilterType::Lanczos3), sw, sh, factor, ) } else { (crop, w, h, 1.0) } }; // 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) * supersample).clamp(0.0, (w - 1) as f32) as u32; let name_right = ((rect.x + rect.w - x0 as f32) * supersample).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 { 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) * alpha_gain).min(1.0); 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 && x >= name_left && x <= name_right { 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. // 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 / name_span > 0.85; let center = ((((rect.y + rect.h / 2.0 - y0 as f32).max(0.0)) * supersample) as u32).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 * supersample).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 Err("band-too-short"); } // 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 Err("band-too-narrow"); } // Crop to the wordmark's row band and tally ink/partial for scoring. 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 (dx, x) in (band_left..=band_right).enumerate() { let px = *full.get_pixel(x, y); out.put_pixel(dx as u32, dy as u32, px); if px.0[3] > 200 { ink += 1; } else if px.0[3] > 20 { partial += 1; } } } let coverage = ink as f32 / (band_w * band_h) as f32; if coverage < 0.008 { return Err("coverage-low"); } if coverage > 0.6 { return Err("coverage-high"); } // 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 = ((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); Ok(Wordmark { image: out, score: aa + aspect + cover_score - bleed, }) } // ---- 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 mut lines = ocr_lines_multi(image)?; // Retry a BLIND read once, smaller — see [`OCR_ESCALATION_SCALE`]. if lines.is_empty() { let (iw, ih) = { use image::GenericImageView as _; image.dimensions() }; if iw.max(ih) > OCR_ESCALATION_SCALE { let small = image.resize( OCR_ESCALATION_SCALE, OCR_ESCALATION_SCALE, image::imageops::FilterType::Lanczos3, ); if let Ok(found) = ocr_lines_multi(&small) { // Back to source pixels: the clamp midpoints, the extraction // crop and the hi-res rescale all work in the coordinates of // the image that was passed in. let (sw, sh) = { use image::GenericImageView as _; small.dimensions() }; lines = found .into_iter() .map(|line| line.scaled(iw as f32 / sw as f32, ih as f32 / sh as f32)) .collect(); } } } // 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) } }