Nicht wie hell das Bild ist, sondern wie viel davon schwarz

Gemessen wurde der Mittelwert über das ganze Bild, und daran scheiterte
die Abspannsuche wiederholt: weisse Schrift hebt den Mittelwert. Derselbe
Abspann misst 19, wenn wenig Schrift darin steht, und 37, wenn viel darin
steht — die Schwelle musste zwischen diesen Wert und die Handlung passen,
und der Abstand schrumpfte mit jeder Textzeile. Zweimal nachgezogen,
zweimal wieder getroffen.

Jellyfin zählt stattdessen Pixel, und das ist kein besserer Schwellenwert,
sondern ein besseres Merkmal. Nachgemessen an denselben erzeugten
Abspännen, durch dieselbe Kette wie im Betrieb:

                      Mittelwert   Anteil dunkler Pixel
    Handlung                 128                    0 %
    Abspann, schriftreich     37                   84 %
    Abspann, schlank          19                   87 %
    dunkle Nachtszene         28                   37 %

Schrift belegt ein bis zehn Prozent der Fläche; der Rest bleibt schwarz.
Der Anteil hängt deshalb nicht an der Schriftmenge, und der Abstand zur
Handlung ist nach beiden Seiten riesig.

`signalstats` weicht `blackframe=amount=0:threshold=24` — die Schwelle
für das einzelne Pixel über eine Reihe von Werten eingemessen: darunter
(16) fällt der Abspann selbst auf zehn Prozent ab, darüber (32) fängt die
Handlung an mitzuzählen. Die Ausgabe kommt über denselben
`metadata=print`-Weg wie vorher, nur unter einem anderen Schlüssel.

**Und die relative Schwelle fällt ersatzlos weg.** Sie war nötig, solange
der Mittelwert gemessen wurde: wie hell ein Abspann ist, hing am Film,
also musste sich die Schwelle am Film ausrichten. Der Pixelanteil eicht
sich selbst — `Abspannkante.Schwelle` und `Abspannregeln.Anteil` sind
gestrichen, und mit ihnen die Rechnerei um den Grundton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 15:14:52 +02:00
co-authored by Claude Opus 5
parent 121ee62be9
commit 791c00dacc
5 changed files with 170 additions and 199 deletions
+6 -6
View File
@@ -510,17 +510,17 @@ public sealed class Markenerkennung(
// weiterbauen lohnt — und das sieht man nur an vielen Dateien.
log.LogInformation(
"„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), "
+ "Schwelle {Schwelle:0.#}, letzte Probe {Letzte:0.#}. {Kapitel}",
name, grund, proben.Count, Abspannkante.Schwelle(proben, regeln),
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN,
Kapitellage(kapitel));
+ "letzte Probe {Letzte:0.#} % dunkel (nötig: {Noetig} %). {Kapitel}",
name, grund, proben.Count,
proben.Count > 0 ? proben[^1].Dunkelanteil : double.NaN,
regeln.Mindestanteil, Kapitellage(kapitel));
return false;
}
var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab;
var kante = await Abspannkante.VerfeinernAsync(
hell, stelle, Abspannkante.Schwelle(proben, regeln),
wo => abtaster.HelligkeitAsync(pfad, wo, ct));
hell, stelle, regeln.Mindestanteil,
wo => abtaster.DunkelanteilAsync(pfad, wo, ct));
log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(kante));
+43 -72
View File
@@ -1,10 +1,26 @@
namespace Flaro.Core.Media;
/// <summary>Ein Messpunkt aus dem Bild: wann, und wie hell.</summary>
/// <summary>Ein Messpunkt aus dem Bild: wann, und wie schwarz.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anteil, nicht der Mittelwert</b> — und das ist der Unterschied
/// zwischen „funktioniert manchmal" und „funktioniert". Ein Mittelwert
/// über das ganze Bild steigt mit jeder Textzeile: derselbe Abspann misst
/// 19, wenn wenig Schrift darin steht, und 37, wenn viel darin steht. Die
/// Schwelle musste zwischen diesen Wert und die Handlung passen, und der
/// Abstand schrumpfte mit jedem Namen.
/// </para>
/// <para>
/// Der Anteil dunkler Pixel tut das nicht: Schrift belegt ein bis zehn
/// Prozent der Fläche, der Rest bleibt schwarz. Gemessen an denselben
/// zwei Abspännen — 84 % und 87 % gegen 0 % in der Handlung.
/// </para>
/// </remarks>
///
/// <param name="Sekunde">Die Stelle im Film.</param>
/// <param name="Helligkeit">Mittlere Leuchtdichte, 0 bis 255.</param>
public readonly record struct Bildprobe(double Sekunde, double Helligkeit);
/// <param name="Dunkelanteil">Wie viel Prozent des Bildes dunkel sind, 0 bis 100.</param>
public readonly record struct Bildprobe(double Sekunde, double Dunkelanteil);
/// <summary>Die Schwellen, nach denen eine dunkle Strecke ein Abspann ist.</summary>
///
@@ -15,43 +31,29 @@ public readonly record struct Bildprobe(double Sekunde, double Helligkeit);
/// </remarks>
public readonly record struct Abspannregeln
{
/// <summary>Die unterste Schwelle, unter der immer „dunkel" gilt (0–255).</summary>
///
/// <remarks>
/// <b>Nur noch der Boden, nicht mehr die Schwelle.</b> Sie stand lange
/// allein da, und das war falsch gemessen: das Prüfbild hatte 0,8
/// Prozent weisse Fläche und kam auf 18. Ein echter Abspann hat mehr
/// Schrift. Nachgemessen an 1080p mit zwei Spalten Namen: bei
/// realistischer Schriftdichte 32, bei einem dichten Abspann 47. Die
/// Fünfunddreissig lag also mitten zwischen den Fällen und schnitt
/// sie auseinander.
/// </para>
/// <para>
/// <b>Und seitdem im vollen Wertebereich.</b> Der Abtaster rechnet
/// jede Datei auf 0–255 um, gleich ob sie acht oder zehn Bit hat und
/// gleich ob sie den Fernsehbereich 16–235 benutzt. Dieselben
/// gemessenen Abspänne liegen darin bei 19 und 36 statt bei 32 und 47;
/// der Boden wandert entsprechend von 35 auf 22.
/// </remarks>
public double Dunkelschwelle { get; init; }
/// <summary>Wie dunkel eine Strecke gegenüber dem Film sein muss.</summary>
/// <summary>Wie viel Prozent des Bildes dunkel sein müssen (0–100).</summary>
///
/// <remarks>
/// <para>
/// <b>Dunkel ist keine absolute Grösse.</b> Entscheidend ist nicht, wie
/// dunkel der Abspann ist, sondern dass er viel dunkler ist als das,
/// was davor lief — und das misst sich am Film selbst. Ein heller Film
/// (Grundton 126) erkennt damit auch einen schriftreichen Abspann bei
/// 47; ein durchweg dunkler Film rutscht nicht in Fehlalarme, weil
/// die Schwelle nie unter <see cref="Dunkelschwelle"/> fällt.
/// <b>Sechzig, und der Abstand ist nach beiden Seiten gross.</b>
/// Gemessen an erzeugtem 1080p-Material mit zwei Spalten Namen, durch
/// dieselbe ffmpeg-Kette wie im Betrieb:
/// </para>
/// <list type="table">
/// <item><description>Abspann, schriftreich: <b>84 %</b></description></item>
/// <item><description>Abspann, schlank: <b>87 %</b></description></item>
/// <item><description>dunkle Nachtszene: <b>37 %</b></description></item>
/// <item><description>Handlung: <b>0 %</b></description></item>
/// </list>
/// <para>
/// 0,55 heisst: knapp unter die Hälfte. Bei 126 ergibt das 69 — weit
/// über beiden gemessenen Abspannwerten und weit unter dem Inhalt.
/// <b>Und damit entfällt die relative Schwelle.</b> Sie war nötig,
/// solange der Mittelwert gemessen wurde: wie hell ein Abspann ist,
/// hing am Film, also musste sich die Schwelle am Film ausrichten. Der
/// Anteil dunkler Pixel eicht sich selbst — ein heller Film und ein
/// dunkler ergeben in der Handlung beide nahezu null.
/// </para>
/// </remarks>
public double Anteil { get; init; }
public double Mindestanteil { get; init; }
/// <summary>Wie viele Sekunden hell eine dunkle Strecke nicht brechen.</summary>
///
@@ -90,8 +92,7 @@ public readonly record struct Abspannregeln
public static Abspannregeln Vorgabe => new()
{
Dunkelschwelle = 22,
Anteil = 0.55,
Mindestanteil = 60,
Nachsicht = 10,
Schlusstafel = 20,
Mindestlänge = 45,
@@ -129,33 +130,6 @@ public static class Abspannkante
/// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen
/// ungleichmässig liegen: Schlüsselbilder tun das.
/// </param>
/// <summary>Ab welcher Helligkeit eine Probe in diesem Film dunkel ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Das obere Viertel als Grundton.</b> Nicht der Mittelwert: der
/// zöge den Abspann selbst mit hinein. Nicht der Höchstwert: ein
/// einzelner Blitz verschöbe alles. Drei Viertel ist robust, solange
/// der Abspann nicht das ganze Fenster füllt — und das kann er nicht,
/// <see cref="Abspannregeln.Höchstlänge"/> lässt ihm höchstens gut die
/// Hälfte davon.
/// </para>
/// <para>
/// Öffentlich, weil das Nachmessen dieselbe Schwelle braucht. Zweimal
/// gerechnet wären es zwei Schwellen, und die Kante läge woanders als
/// der Fund.
/// </para>
/// </remarks>
public static double Schwelle(IReadOnlyList<Bildprobe> proben, Abspannregeln regeln)
{
if (proben.Count == 0) return regeln.Dunkelschwelle;
var sortiert = proben.Select(p => p.Helligkeit).OrderBy(h => h).ToArray();
var grundton = sortiert[Math.Min(sortiert.Length - 1, (int)(sortiert.Length * 0.75))];
return Math.Max(regeln.Dunkelschwelle, grundton * regeln.Anteil);
}
public static double? Suchen(IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) =>
Suchen(proben, regeln, out _);
@@ -175,8 +149,6 @@ public static class Abspannkante
return null;
}
var schwelle = Schwelle(proben, regeln);
// **Von hinten.** Der Abspann endet am Dateiende; alles, was davor
// dunkel war und wieder hell wurde, war eine Schwarzblende.
//
@@ -187,7 +159,7 @@ public static class Abspannkante
// einziges weisses Schlussbild macht den ganzen Abspann unsichtbar.
var letzte = proben.Count - 1;
var ende = proben[letzte].Sekunde;
while (letzte > 0 && proben[letzte].Helligkeit >= schwelle)
while (letzte > 0 && proben[letzte].Dunkelanteil < regeln.Mindestanteil)
{
if (ende - proben[letzte].Sekunde >= regeln.Schlusstafel)
{
@@ -196,7 +168,7 @@ public static class Abspannkante
}
letzte--;
}
if (proben[letzte].Helligkeit >= schwelle)
if (proben[letzte].Dunkelanteil < regeln.Mindestanteil)
{
grund = "das Ende ist hell";
return null;
@@ -208,7 +180,7 @@ public static class Abspannkante
var anfang = letzte;
for (var i = letzte - 1; i >= 0; i--)
{
if (proben[i].Helligkeit < schwelle) { anfang = i; continue; }
if (proben[i].Dunkelanteil >= regeln.Mindestanteil) { anfang = i; continue; }
if (proben[anfang].Sekunde - proben[i].Sekunde > regeln.Nachsicht) break;
}
@@ -235,12 +207,11 @@ public static class Abspannkante
public static double? HellDavor(
IReadOnlyList<Bildprobe> proben, double dunkelAb, Abspannregeln regeln)
{
var schwelle = Schwelle(proben, regeln);
double? letzteHelle = null;
foreach (var p in proben)
{
if (p.Sekunde >= dunkelAb) break;
if (p.Helligkeit >= schwelle) letzteHelle = p.Sekunde;
if (p.Dunkelanteil < regeln.Mindestanteil) letzteHelle = p.Sekunde;
}
return letzteHelle;
}
@@ -263,7 +234,7 @@ public static class Abspannkante
/// </para>
/// </remarks>
public static async Task<double> VerfeinernAsync(
double hell, double dunkel, double schwelle,
double hell, double dunkel, double mindestanteil,
Func<double, Task<double?>> messen, double genauigkeit = 4)
{
if (genauigkeit <= 0) genauigkeit = 1;
@@ -272,9 +243,9 @@ public static class Abspannkante
{
var mitte = hell + (dunkel - hell) / 2;
var wert = await messen(mitte);
if (wert is not { } helligkeit) return dunkel;
if (wert is not { } anteil) return dunkel;
if (helligkeit < schwelle) dunkel = mitte;
if (anteil >= mindestanteil) dunkel = mitte;
else hell = mitte;
}
+33 -18
View File
@@ -34,7 +34,26 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// </remarks>
private const string Breite = "160";
/// <summary>Die Helligkeit an den Schlüsselbildern eines Abschnitts.</summary>
/// <summary>Unter welchem Lumawert ein einzelnes Pixel dunkel heisst.</summary>
///
/// <remarks>
/// <para>
/// <b>Nicht der Mittelwert des Bildes, sondern jedes Pixel für sich.</b>
/// <c>blackframe</c> zählt, wie viel Prozent eines Bildes darunter
/// liegen — und das ist ein ganz anderes Merkmal als der Mittelwert:
/// weisse Schrift hebt den Mittelwert, belegt aber nur ein paar
/// Prozent der Fläche.
/// </para>
/// <para>
/// Vierundzwanzig, gemessen über eine Reihe von Schwellen. Darunter
/// (16) fällt der Abspann selbst auf zehn Prozent ab, darüber (32)
/// fängt die Handlung an mitzuzählen. Bei 24: Abspann 84 bis 87
/// Prozent, Handlung 0, eine dunkle Nachtszene 37.
/// </para>
/// </remarks>
private const string Dunkelschwelle = "24";
/// <summary>Der Dunkelanteil an den Schlüsselbildern eines Abschnitts.</summary>
///
/// <remarks>
/// <b>Nur Schlüsselbilder, und das ist der ganze Trick.</b> Gemessen an
@@ -42,7 +61,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// Sekunden, nur die Schlüsselbilder 0,24. Dafür liegen die Messpunkte
/// fünf bis zehn Sekunden auseinander — genug, um eine dunkle Strecke
/// zu finden, zu wenig für ihre Kante. Die holt
/// <see cref="HelligkeitAsync"/> nach.
/// <see cref="DunkelanteilAsync"/> nach.
/// </remarks>
public async Task<IReadOnlyList<Bildprobe>> UeberblickAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
@@ -53,7 +72,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
return Lesen(ausgabe, ab);
}
/// <summary>Die mittlere Helligkeit einer einzelnen Sekunde.</summary>
/// <summary>Der mittlere Dunkelanteil einer einzelnen Sekunde.</summary>
///
/// <remarks>
/// Alle Bilder, nicht nur Schlüsselbilder: eine Sekunde enthält sonst
@@ -61,7 +80,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
/// an dekodieren muss, ist der Preis — bei zehn Sekunden Abstand sind
/// das zehn Sekunden Bildmaterial, also Millisekunden.
/// </remarks>
public async Task<double?> HelligkeitAsync(
public async Task<double?> DunkelanteilAsync(
string pfad, double bei, CancellationToken ct = default)
{
var ausgabe = await LaufenAsync(
@@ -70,7 +89,7 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
var proben = Lesen(ausgabe, 0);
if (proben.Count == 0) return null;
return proben.Average(p => p.Helligkeit);
return proben.Average(p => p.Dunkelanteil);
}
private async Task<string> LaufenAsync(
@@ -110,19 +129,15 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
start.ArgumentList.Add("-sn");
start.ArgumentList.Add("-vf");
// **`format=gray` vor `signalstats`, und das ist nicht kosmetisch.**
//
// `signalstats` rechnet in der Skala des Bildformats: bei acht Bit
// 0–255, bei zehn Bit 0–1023. Gemessen am selben erzeugten Bild
// einmal als 8-Bit und einmal als 10-Bit: YAVG 127 gegen YAVG 500.
// Alle Schwellen hier sind an acht Bit gemessen; bei einer
// 10-Bit-Datei bedeutete `Dunkelschwelle = 35` plötzlich ein
// Viertel davon, und im Protokoll standen Zahlen, die sich mit
// denen anderer Dateien nicht vergleichen liessen — „Schwelle
// 247,6" war der Hinweis, dem das hier zu verdanken ist.
// **`format=gray`, weil die Skala sonst an der Bittiefe hängt.**
// Gemessen am selben erzeugten Bild einmal als 8-Bit und einmal
// als 10-Bit: 127 gegen 500. Ohne diese Umrechnung bedeutete
// dieselbe Schwelle bei der einen Datei etwas anderes als bei der
// nächsten — „Schwelle 247,6" im Protokoll war der Hinweis darauf.
start.ArgumentList.Add(
$"scale={Breite}:-2,format=gray,signalstats,"
+ "metadata=print:key=lavfi.signalstats.YAVG:file=-");
$"scale={Breite}:-2,format=gray,"
+ $"blackframe=amount=0:threshold={Dunkelschwelle},"
+ "metadata=print:key=lavfi.blackframe.pblack:file=-");
start.ArgumentList.Add("-f");
start.ArgumentList.Add("null");
start.ArgumentList.Add("-");
@@ -199,6 +214,6 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
[GeneratedRegex(@"pts_time:([\d.]+)")]
private static partial Regex Zeitzeile();
[GeneratedRegex(@"lavfi\.signalstats\.YAVG=([\d.]+)")]
[GeneratedRegex(@"lavfi\.blackframe\.pblack=([\d.]+)")]
private static partial Regex Wertzeile();
}