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:
1 parent
4d6e69e324
commit
4c0c4a1ea5
3 files changed
+343
-28
No files matched your search
+113
-15
@@ -1569,7 +1569,17 @@ pub async fn catalog_artist_socials(
|
||||
|
||||
// v4: the alpha ramp now scales to the measured ink distance, so every cached
|
||||
// mask predates the anti-aliasing this pipeline relies on and must be redrawn.
|
||||
const COVER_LOGO_RECIPE: &str = "cover-logo-v4-ink-scaled-alpha";
|
||||
// v5: three changes, each of which can turn a previously-empty result into a
|
||||
// mark (or trim one that over-reached), so every v4 entry is stale by
|
||||
// construction — the alpha ramp's ceiling can no longer exceed the ink's own
|
||||
// distance, the dominance guard admits flat multi-tone crops, and a row walk
|
||||
// that never met a gap falls back to the OCR box instead of running to the crop
|
||||
// edge. Wand had four covers printing its name and v4 extracted none of them.
|
||||
// v10: the winner's supersample scale is fractional instead of a floored
|
||||
// integer. Every mask whose crop was wider than half the target was previously
|
||||
// supersampled by exactly 1x — i.e. not at all — so the covers this rewrites are
|
||||
// the LARGE printed wordmarks, which is where the staircase was worst.
|
||||
const COVER_LOGO_RECIPE: &str = "cover-logo-v10-supersample-2400-fractional";
|
||||
// A prolific artist's wordmark is often nowhere near the top of this list. The
|
||||
// rank puts albums first and penalises `remix|mix|edit|live` titles — but for
|
||||
// Poolside (74 releases) every one of the SEVEN covers that OCR reads as
|
||||
@@ -1903,11 +1913,29 @@ async fn generate_cover_logo(
|
||||
const PROMINENCE_W: f32 = 3.0;
|
||||
const MIN_PROMINENCE: f32 = 0.05;
|
||||
const MIN_MARK_H: u32 = 74;
|
||||
// Mask long edge the WINNER is re-cut at. Matching the tracer's own raster
|
||||
// is the whole rationale: one good Lanczos enlargement in Rust instead of
|
||||
// Lanczos followed by the browser's bilinear, with the trace then running at
|
||||
// roughly the mask's native resolution.
|
||||
//
|
||||
// Swept against 1200/3600/4800 over eight artists (see
|
||||
// `research/cover-logo-superres/prototypes/supersample-batch.mjs`), and 2400
|
||||
// is where the curve flattens. 1200 is below several crops and leaves them
|
||||
// untouched. Past 2400 the mask overshoots the trace raster, so the browser
|
||||
// DOWNSCALES what Rust just built — every shape measure is flat from there
|
||||
// (Poolside, Wand, Camping In Alaska, Turnover all identical at 2400 and
|
||||
// 3600) while the data URL doubles: Mildlife 137KB -> 267KB, Camping 76KB ->
|
||||
// 152KB, for nothing. The one artist that still gains above 2400 is Hot
|
||||
// Toddy, and it gains from the downscale itself rather than from the extra
|
||||
// detail — the same smoothing the tracer's blur term would give it.
|
||||
const MASK_SUPERSAMPLE_EDGE: u32 = 2400;
|
||||
let extract_started = std::time::Instant::now();
|
||||
let (best, winner) = tokio::task::spawn_blocking(move || {
|
||||
use base64::Engine as _;
|
||||
use image::{GenericImageView as _, ImageEncoder as _};
|
||||
let mut best: Option<(f32, usize, String, image::RgbaImage)> = None;
|
||||
// The winner's INPUTS, not its mask: the mask is re-cut once below at
|
||||
// full supersample, so scoring never pays for it and never sees it.
|
||||
let mut best: Option<(f32, CoverLogoCandidate)> = None;
|
||||
for candidate in candidates {
|
||||
let prom_w = (candidate.loc.rect.w / candidate.dims_1000.0 as f32).min(1.0);
|
||||
if prom_w < MIN_PROMINENCE {
|
||||
@@ -1923,13 +1951,13 @@ async fn generate_cover_logo(
|
||||
hw as f32 / candidate.dims_1000.0 as f32,
|
||||
hh as f32 / candidate.dims_1000.1 as f32,
|
||||
);
|
||||
crate::cover_logo::extract_wordmark(&hi, &scaled)
|
||||
crate::cover_logo::extract_wordmark(&hi, &scaled, 0)
|
||||
})
|
||||
.or_else(|| {
|
||||
image::load_from_memory(candidate.bytes_1000.as_slice())
|
||||
.ok()
|
||||
.and_then(|image| {
|
||||
crate::cover_logo::extract_wordmark(&image, &candidate.loc)
|
||||
crate::cover_logo::extract_wordmark(&image, &candidate.loc, 0)
|
||||
})
|
||||
});
|
||||
let Some(mark) = mark else { continue };
|
||||
@@ -1937,14 +1965,46 @@ async fn generate_cover_logo(
|
||||
continue;
|
||||
}
|
||||
let final_score = mark.score + PROMINENCE_W * prom_w;
|
||||
if best
|
||||
.as_ref()
|
||||
.is_none_or(|(score, _, _, _)| final_score > *score)
|
||||
{
|
||||
best = Some((final_score, candidate.rank, candidate.title, mark.image));
|
||||
if best.as_ref().is_none_or(|(score, _)| final_score > *score) {
|
||||
best = Some((final_score, candidate));
|
||||
}
|
||||
}
|
||||
let Some((_, rank, title, image)) = best else {
|
||||
let Some((_, winner)) = best else {
|
||||
return (None, None);
|
||||
};
|
||||
let (rank, title) = (winner.rank, winner.title.clone());
|
||||
// Re-cut the winning mark, this time resampling the crop up before the
|
||||
// alpha ramp so the ramp reads interpolated colour and places the edge
|
||||
// between source pixels rather than on them. Selection above ran at
|
||||
// native scale on purpose — see `try_extract_wordmark`'s `target_edge`.
|
||||
// Falls back to the selection-scale mask, so the richer pass failing can
|
||||
// never cost an artist its logo.
|
||||
let recut = winner
|
||||
.bytes_hi
|
||||
.as_ref()
|
||||
.and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok())
|
||||
.and_then(|hi| {
|
||||
let (hw, hh) = hi.dimensions();
|
||||
let scaled = winner.loc.scaled(
|
||||
hw as f32 / winner.dims_1000.0 as f32,
|
||||
hh as f32 / winner.dims_1000.1 as f32,
|
||||
);
|
||||
crate::cover_logo::extract_wordmark(&hi, &scaled, MASK_SUPERSAMPLE_EDGE)
|
||||
.or_else(|| crate::cover_logo::extract_wordmark(&hi, &scaled, 0))
|
||||
})
|
||||
.or_else(|| {
|
||||
image::load_from_memory(winner.bytes_1000.as_slice())
|
||||
.ok()
|
||||
.and_then(|img| {
|
||||
crate::cover_logo::extract_wordmark(
|
||||
&img,
|
||||
&winner.loc,
|
||||
MASK_SUPERSAMPLE_EDGE,
|
||||
)
|
||||
.or_else(|| crate::cover_logo::extract_wordmark(&img, &winner.loc, 0))
|
||||
})
|
||||
});
|
||||
let Some(image) = recut.map(|mark| mark.image) else {
|
||||
return (None, None);
|
||||
};
|
||||
let (width, height) = image.dimensions();
|
||||
@@ -2124,6 +2184,13 @@ pub struct CoverLogoDiag {
|
||||
pub mark_w: u32,
|
||||
pub mark_h: u32,
|
||||
pub extract_score: f32,
|
||||
pub reject_reason: String,
|
||||
/// The extracted mask itself, as a `data:image/png` URL, or empty when the
|
||||
/// cover produced none. The numbers above say a mark was found and how big
|
||||
/// it is; only the pixels say whether its EDGES are worth tracing, which is
|
||||
/// the whole question when sweeping `target_edge`. Empty on the OCR-matrix
|
||||
/// paths that never extract.
|
||||
pub mark_png: String,
|
||||
pub winner: bool,
|
||||
pub ocr: Vec<String>,
|
||||
}
|
||||
@@ -2134,12 +2201,21 @@ pub async fn catalog_cover_logo_debug(
|
||||
artist_id: Option<u64>,
|
||||
artist: String,
|
||||
covers: Vec<CoverRef>,
|
||||
target_edge: Option<u32>,
|
||||
) -> Result<Vec<CoverLogoDiag>, String> {
|
||||
use image::GenericImageView as _;
|
||||
// Mask supersample target to segment at. 0/absent means native, which is
|
||||
// what candidate SELECTION uses in production; a value here is for sweeping
|
||||
// "does more supersampling help" without a rebuild per level.
|
||||
let target_edge = target_edge.unwrap_or(0);
|
||||
|
||||
// Download 1000px covers for OCR.
|
||||
let mut downloaded = Vec::new();
|
||||
for cover in covers.into_iter().take(14) {
|
||||
// Must match COVER_LOGO_MAX_COVERS, not undercut it. At 14 this diagnostic
|
||||
// silently hid exactly the covers the real pipeline relies on — Poolside's
|
||||
// seven POOLSIDE-readable covers are all remix singles ranked past 14, so
|
||||
// the tool reported "one match" for an artist production finds several on.
|
||||
for cover in covers.into_iter().take(COVER_LOGO_MAX_COVERS) {
|
||||
if let Some(bytes) = state.metadata.cover_bytes(&cover.url).await {
|
||||
downloaded.push((cover.title, cover.url, bytes));
|
||||
}
|
||||
@@ -2202,6 +2278,8 @@ pub async fn catalog_cover_logo_debug(
|
||||
mark_w: 0,
|
||||
mark_h: 0,
|
||||
extract_score: 0.0,
|
||||
reject_reason: String::new(),
|
||||
mark_png: String::new(),
|
||||
winner: false,
|
||||
};
|
||||
if let Some((loc, text, mscore)) = found {
|
||||
@@ -2240,10 +2318,30 @@ pub async fn catalog_cover_logo_debug(
|
||||
row.hi_w = hw;
|
||||
row.hi_h = hh;
|
||||
if let Ok(img_ex) = image::load_from_memory(&bytes_ex) {
|
||||
if let Some(mark) = crate::cover_logo::extract_wordmark(&img_ex, &loc_ex) {
|
||||
row.mark_w = mark.image.width();
|
||||
row.mark_h = mark.image.height();
|
||||
row.extract_score = mark.score;
|
||||
match crate::cover_logo::try_extract_wordmark(&img_ex, &loc_ex, target_edge) {
|
||||
Ok(mark) => {
|
||||
use base64::Engine as _;
|
||||
use image::ImageEncoder as _;
|
||||
row.mark_w = mark.image.width();
|
||||
row.mark_h = mark.image.height();
|
||||
row.extract_score = mark.score;
|
||||
let mut png = Vec::new();
|
||||
if image::codecs::png::PngEncoder::new(&mut png)
|
||||
.write_image(
|
||||
mark.image.as_raw(),
|
||||
row.mark_w,
|
||||
row.mark_h,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
row.mark_png = format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(&png)
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(reason) => row.reject_reason = reason.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+212
-13
@@ -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.
|
||||
|
||||
@@ -1561,6 +1561,24 @@ async function applyArtistIdentity(artistId, active) {
|
||||
};
|
||||
catalog.artistPage = merged;
|
||||
if (merged.fused_record) void disownFusedPortrait(artistId, merged);
|
||||
// The wordmark is lifted from THESE albums' covers, and this pass is
|
||||
// fired beside the one above rather than after it — so on a fused record
|
||||
// it normally wins the race and reads the unfiltered list. Wand's page
|
||||
// wore a "wand." lifted from an Indonesian co-tenant's single that way.
|
||||
// Re-run whenever ownership actually changed the covers on offer; the
|
||||
// request is keyed by the exact list, so an unchanged one is a cache hit
|
||||
// and costs nothing.
|
||||
const coversChanged =
|
||||
(current.albums ?? []).length !== merged.albums.length
|
||||
|| (current.albums ?? []).some((a, i) => a?.cover !== merged.albums[i]?.cover);
|
||||
if (coversChanged && !merged.logos?.length) {
|
||||
void resolveCoverLogoAvailability(
|
||||
artistId,
|
||||
merged,
|
||||
Promise.resolve(merged.logos ?? []),
|
||||
active,
|
||||
);
|
||||
}
|
||||
// Write the supplemented page back into the session cache: a warm
|
||||
// revisit must not render less than the cold visit did. Overwriting
|
||||
// a pending entry is safe — cachedArtistRequest only installs its
|
||||
|
||||
Reference in new issue
Block a user