Remapping a mark whose color is part of its identity - a red-outline wordmark with white fill, a colored badge - fixes legibility but changes the logo's character, which reads worse on hero surfaces than the partial invisibility it cures. The light tone class now splits on measured colored-pixel fraction: light-dominant marks with incidental color (<=15% saturated pixels, lightAccented) always remap, while lightMixed marks remap only when the new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false on the network, offline, and TV spotlight logo paths; the Live TV guide's channel cells keep remapping mixed marks. The policy joins the image-cache key. The threshold comes from a real clear-logo set: remap-friendly marks measured at <=0.11 colored, identity-colored marks at >=0.28.
134 lines
5.6 KiB
Dart
134 lines
5.6 KiB
Dart
import 'dart:typed_data';
|
|
|
|
/// Classification of a channel-logo frame for theme-adaptive rendering.
|
|
///
|
|
/// Broadcast channel logos (Gracenote and most Jellyfin/Emby lineups) are
|
|
/// designed for dark UIs: white or near-white marks on transparency. On the
|
|
/// light theme's white cards they are invisible (issue #2197). The tone drives
|
|
/// whether [remapLightNeutral] may recolor a logo for a light surface.
|
|
enum LogoTone {
|
|
/// Too few opaque pixels to judge — leave untouched.
|
|
unknown,
|
|
|
|
/// Near-white, low-saturation mark (CBS/FOX-style wordmarks).
|
|
lightMonochrome,
|
|
|
|
/// Light-dominant mark with only incidental color (≤15% saturated pixels):
|
|
/// a white wordmark with a small colored accent. Safe to remap everywhere —
|
|
/// the accent keeps its pixels and the mark stays recognizable.
|
|
lightAccented,
|
|
|
|
/// Light content beside *significant* color (an NBC peacock, a red-outline
|
|
/// wordmark with white fill). Remapping is legibility-correct but changes
|
|
/// the mark's character, so hero surfaces leave these untouched while the
|
|
/// guide's tiny channel cells still remap them.
|
|
lightMixed,
|
|
|
|
/// Dark or self-backed artwork (abc's black disc, PBS KIDS' blue chip):
|
|
/// legible on light surfaces as-is. Also the gate that keeps
|
|
/// [remapLightNeutral] away from light text sitting inside an opaque dark
|
|
/// shape, which a per-pixel remap alone would erase.
|
|
dark,
|
|
}
|
|
|
|
/// Single-pass tone analysis over a **straight-alpha** RGBA frame
|
|
/// (`ImageByteFormat.rawStraightRgba`). Premultiplied input reads antialiased
|
|
/// and semi-transparent pixels darker than they are and must not be used.
|
|
///
|
|
/// [stride] samples every Nth pixel on both axes; 1 visits every pixel.
|
|
/// Cost is O(w·h / stride²) with no allocation.
|
|
LogoTone analyzeLogoTone(ByteData rgba, int width, int height, {int stride = 1}) {
|
|
final bytes = rgba.buffer.asUint8List(rgba.offsetInBytes, rgba.lengthInBytes);
|
|
var opaque = 0;
|
|
var light = 0;
|
|
var lightLowSat = 0;
|
|
var colored = 0;
|
|
|
|
for (var y = 0; y < height; y += stride) {
|
|
var i = y * width * 4;
|
|
final rowEnd = i + width * 4;
|
|
for (; i < rowEnd; i += 4 * stride) {
|
|
final a = bytes[i + 3];
|
|
if (a < 32) continue;
|
|
opaque++;
|
|
final r = bytes[i];
|
|
final g = bytes[i + 1];
|
|
final b = bytes[i + 2];
|
|
final maxC = r > g ? (r > b ? r : b) : (g > b ? g : b);
|
|
final minC = r < g ? (r < b ? r : b) : (g < b ? g : b);
|
|
// Relative saturation ≥0.35: the same boundary at which
|
|
// [remapLightNeutral]'s weight reaches zero.
|
|
if (maxC > 0 && 255 * (maxC - minC) ~/ maxC >= 89) colored++;
|
|
// Rec.601 integer luma.
|
|
final luma = (r * 77 + g * 150 + b * 29) >> 8;
|
|
if (luma < 176) continue;
|
|
light++;
|
|
if (maxC - minC <= 48) lightLowSat++;
|
|
}
|
|
}
|
|
|
|
final sampled = ((height + stride - 1) ~/ stride) * ((width + stride - 1) ~/ stride);
|
|
if (sampled == 0 || opaque * 50 < sampled) return LogoTone.unknown; // <2% coverage
|
|
final lightFrac = light / opaque;
|
|
if (lightFrac >= 0.85 && lightLowSat / opaque >= 0.80) return LogoTone.lightMonochrome;
|
|
if (lightFrac >= 0.30) {
|
|
// Measured on real clear-logo sets: remap-friendly marks (white wordmark,
|
|
// small accent) sit at ≤0.11 colored, identity-colored marks at ≥0.28 —
|
|
// 0.15 splits them with margin on both sides.
|
|
return colored * 100 <= opaque * 15 ? LogoTone.lightAccented : LogoTone.lightMixed;
|
|
}
|
|
return LogoTone.dark;
|
|
}
|
|
|
|
/// Bakes a light-surface-adapted copy of a light-toned logo frame.
|
|
///
|
|
/// Input is **straight-alpha** RGBA (`ImageByteFormat.rawStraightRgba`);
|
|
/// output is **premultiplied** RGBA, ready for
|
|
/// `ImageDescriptor.raw(..., PixelFormat.rgba8888)`. Reading the widely used
|
|
/// `rawRgba` instead silently premultiplies, which drags antialiased glyph
|
|
/// edges below the luma ramp — they escape the remap and render as a light
|
|
/// fringe around the recolored mark.
|
|
///
|
|
/// Lerps each pixel toward [targetArgb] weighted by how light *and* neutral it
|
|
/// is: full weight above ~0.85 luma at ~zero saturation, zero weight below
|
|
/// ~0.6 luma or above ~0.35 saturation, smooth ramp between. Colored content
|
|
/// (an NBC peacock) keeps its pixels; white wordmarks become the target;
|
|
/// antialiased boundary pixels land proportionally in between. Alpha is
|
|
/// preserved. Only call for [LogoTone.lightMonochrome] /
|
|
/// [LogoTone.lightMixed]; the classification is the gate that protects
|
|
/// self-backed logos (white text inside an opaque dark disc).
|
|
Uint8List remapLightNeutral(ByteData rgba, {required int targetArgb}) {
|
|
final src = rgba.buffer.asUint8List(rgba.offsetInBytes, rgba.lengthInBytes);
|
|
final out = Uint8List.fromList(src);
|
|
final tr = (targetArgb >> 16) & 0xFF, tg = (targetArgb >> 8) & 0xFF, tb = targetArgb & 0xFF;
|
|
|
|
for (var i = 0; i < out.length; i += 4) {
|
|
final a = out[i + 3];
|
|
if (a == 0) {
|
|
out[i] = 0;
|
|
out[i + 1] = 0;
|
|
out[i + 2] = 0;
|
|
continue;
|
|
}
|
|
var r = out[i], g = out[i + 1], b = out[i + 2];
|
|
final luma = (r * 77 + g * 150 + b * 29) >> 8;
|
|
final maxC = r > g ? (r > b ? r : b) : (g > b ? g : b);
|
|
final minC = r < g ? (r < b ? r : b) : (g < b ? g : b);
|
|
final sat = maxC == 0 ? 0 : 255 * (maxC - minC) ~/ maxC;
|
|
// Luma ramp 153..217 (0.60..0.85), saturation ramp 89..38 (0.35..0.15).
|
|
final lw = ((luma - 153) * 4).clamp(0, 255);
|
|
final sw = ((89 - sat) * 5).clamp(0, 255);
|
|
final w = lw * sw ~/ 255;
|
|
if (w != 0) {
|
|
r += (tr - r) * w ~/ 255;
|
|
g += (tg - g) * w ~/ 255;
|
|
b += (tb - b) * w ~/ 255;
|
|
}
|
|
// Premultiply for PixelFormat.rgba8888 consumption.
|
|
out[i] = r * a ~/ 255;
|
|
out[i + 1] = g * a ~/ 255;
|
|
out[i + 2] = b * a ~/ 255;
|
|
}
|
|
return out;
|
|
}
|