cover logo: supersample the winner, and scale to the target not past it

Five changes to the mask the tracer receives, plus the diagnostic needed to
tell them apart.

The alpha ramp's bottom now scales with the measured ink distance instead of
sitting at a fixed 46. On a faint mark that constant ate the outer half of
every edge gradient -- Wand's "Machine Man / M.E." inks at distance 97, so the
pixel carrying ~20% coverage was forced fully transparent and the boundary
quantised to whole source pixels. The finished alpha is then rescaled so the
glyph body reaches opacity, bounded at 4x; narrowing the ramp onto the ink was
tried instead and let paper grain bite the edges.

A BLIND OCR read (zero lines, not "no match") retries once at 512px. Windows
OCR is scale-sensitive on large display type and reads that cover at 512 and
at no other size; gating on blindness rather than on no-match kept a 21-artist
regeneration at 1m47s where the looser gate took ~6min.

The winning cover is re-cut with the crop resampled up BEFORE the alpha ramp,
so smoothstep evaluates interpolated colour and places the edge between source
pixels. Selection still runs at native scale: extract_wordmark feeds MIN_MARK_H
and the aa/coverage/bleed score, so supersampling during scoring changes which
cover wins.

That scale is fractional. A floored integer factor is 1 for every crop wider
than half the target, so at 2400 a crop of 1201px was supersampled by nothing
at all while 1200px got 2x -- and the covers on the wrong side of that cliff
are the large printed wordmarks, where the staircase was worst. Measured over
eight artists, Hot Toddy's zigzag share fell 0.312 -> 0.048 and Crazy P's
0.220 -> 0.021, with every other artist unchanged. 2400 itself is confirmed by
sweeping 1200/3600/4800: past 2400 the mask overshoots the tracer's raster so
the browser downscales what Rust just built, every shape measure goes flat and
the data URL doubles.

catalog_cover_logo_debug reports the rejection reason and the mask itself, and
its cover cap follows COVER_LOGO_MAX_COVERS instead of undercutting it at 14 --
where it hid exactly the covers production relies on. The artist page also
re-runs the logo pass when ownership changes the covers on offer; that pass
races the identity merge and had been reading the unfiltered list, which is how
Wand wore a wordmark lifted from a co-tenant's single.
This commit is contained in:
KeelrLeek committed 2026-07-26 09:33:11 -05:00
1 parent 4d6e69e324
commit 4c0c4a1ea5
3 files changed
+343 -28

No files matched your search

+212 -13
View File
@@ -33,6 +33,54 @@ pub(crate) struct OcrLine {
pub words: Vec<OcrWord>,
}
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 {
@@ -50,11 +98,46 @@ const BG_MIN_DOMINANCE: f32 = 0.5; // background must fill at least this share o
/// 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 {
@@ -107,7 +190,29 @@ impl BandLocation {
pub(crate) fn extract_wordmark(
image: &image::DynamicImage,
loc: &BandLocation,
target_edge: u32,
) -> Option<Wordmark> {
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<Wordmark, &'static str> {
use image::GenericImageView as _;
let rect = loc.rect;
let (iw, ih) = image.dimensions();
@@ -128,7 +233,7 @@ pub(crate) fn extract_wordmark(
.min(loc.clamp_bottom)
.min(ih as f32)) as u32;
if x1 <= x0 + 4 || y1 <= y0 + 4 {
return None;
return Err("crop-too-small");
}
let crop = image.crop_imm(x0, y0, x1 - x0, y1 - y0).to_rgba8();
let (w, h) = crop.dimensions();
@@ -187,7 +292,7 @@ pub(crate) fn extract_wordmark(
.count() as f32;
let dominance = near / total;
if dominance < BG_MIN_DOMINANCE {
return None; // busy background — text over a photo, not segmentable
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.
@@ -218,6 +323,68 @@ pub(crate) fn extract_wordmark(
.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 /
@@ -229,15 +396,16 @@ pub(crate) fn extract_wordmark(
// 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 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);
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
@@ -263,11 +431,12 @@ pub(crate) fn extract_wordmark(
// 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) as u32).saturating_sub(y0)).min(h - 1) as usize;
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).round().max(6.0) as usize;
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;
@@ -316,7 +485,7 @@ pub(crate) fn extract_wordmark(
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;
return Err("band-too-short");
}
// COLUMN trim — the horizontal counterpart of the row walk above, which the
@@ -363,7 +532,7 @@ pub(crate) fn extract_wordmark(
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;
return Err("band-too-narrow");
}
// Crop to the wordmark's row band and tally ink/partial for scoring.
@@ -382,8 +551,11 @@ pub(crate) fn extract_wordmark(
}
}
let coverage = ink as f32 / (band_w * band_h) as f32;
if coverage < 0.008 || coverage > 0.6 {
return None;
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.
@@ -422,7 +594,7 @@ pub(crate) fn extract_wordmark(
}
let perimeter = (2 * (band_w + band_h)) as f32;
let bleed = (edge_ink as f32 / perimeter).min(1.0);
Some(Wordmark {
Ok(Wordmark {
image: out,
score: aa + aspect + cover_score - bleed,
})
@@ -604,7 +776,34 @@ pub(crate) fn locate_wordmark_named(
return Ok(None);
}
// Two-pass OCR (raw + binarized) so stylized colour wordmarks are read.
let lines = ocr_lines_multi(image)?;
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.