Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+655
View File
@@ -0,0 +1,655 @@
//! 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<OcrWord>,
}
/// 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<Wordmark> {
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<Vec<OcrLine>, 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<Vec<OcrLine>, 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<Option<BandLocation>, 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<Rect> {
// 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<Option<(BandLocation, String, f32)>, 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<Option<WinRtGuard>, 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<SoftwareBitmap, String> {
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<Vec<OcrLine>, 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)
}
}