I sei moduli passavano cinquantuno controlli su scene sintetiche. Le scene sintetiche pero' sono costruite perche' la risposta sia nota, e per questo non possono sorprendere. Provati su quattro sequenze vere - da 554 a 1005 scatti, riprese notturne di comete e aurore con una GoPro - hanno mostrato cinque difetti, nessuno dei quali visibile prima. Uno sta nel nucleo del deflicker e c'era da molto. La curva obiettivo poteva essere piu' a scatti del segnale che lisciava. La robustezza di Tukey presuppone anomalie sparse; dove invece un tratto contiguo si discosta - il crollo di luce del crepuscolo - azzera l'intera finestra, e la stima commutava fra l'usare i soli sopravvissuti e l'usare tutto. Nel punto di commutazione si apriva uno scalino di 2,3 stop, piu' grande di qualunque salto presente nel segnale che doveva lisciare. Il rimedio non e' scegliere meglio fra le due stime ma non scegliere affatto: ora si mescolano con continuita' secondo quanta finestra e' sopravvissuta, e dove sopravvive per intero il risultato resta identico a prima. Sulla ripresa di sedici ore la riduzione dello sfarfallio passa dal 17% al 67% e il salto massimo da 1,73 a 0,21 stop. La correlazione di fase inseguiva spostamenti inventati. Una ripresa attraversa il giorno con pose da trenta secondi, quindi esce bruciata: su un riquadro uniforme la normalizzazione al modulo unitario amplifica il solo rumore numerico e l'antitrasformata da' un picco qualunque. Con un controllo di tessitura il tremolio misurato scende da 9,8 a 0,07 px e il ritaglio richiesto dal 15% allo 0,1%. La ricerca dell'orizzonte presupponeva un cielo chiaro e sgombro sopra la testa. Sotto un pergolato agganciava il bordo del tetto e chiamava cielo le travi. Ora la linea e' il gradino piu' marcato del profilo di luminanza per riga, che del verso non si cura: cielo dal 29% al 91% dell'inquadratura, con 1,9 EV di separazione dove prima erano zero, e sfarfallio trasmesso al paesaggio ridotto del 79% invece che del 57%. I gradini dichiarati nei metadati non sempre si vedono. Se il fotogramma e' gia' saturo dimezzare la sensibilita' non lo scurisce, e se l'esposizione automatica insegue l'alba il salto e' compensato dalla scena. In entrambi i casi sottrarlo introduceva il gradino invece di toglierlo. Ora ogni cambio viene ridotto alla quota che la luminanza ha davvero recepito. La temperatura di colore inventava numeri. Su un cielo stellato il colore medio non somiglia a nessun corpo nero, McCamy diverge, e uscivano decine di migliaia di kelvin troncate a un estremo. Ora fuori dall'intorno del luogo di Planck la misura si dichiara inapplicabile - e il controllo che lo verifica ha trovato subito un buco nel primo filtro, perche' un riquadro sulle coordinate cromatiche sa dire in quale zona si e' ma non quanto si e' vicini a una curva. La diagnostica impara a fare queste domande: --diagnose accetta ora parole chiave che accendono i moduli e riporta cosa ciascuno ha trovato sul materiale vero. Le scie stellari si verificano sull'uscita con la proprieta' che le definisce - la luminanza non puo' calare, perche' ogni pixel trattiene il valore piu' alto incontrato - e su 150 fotogrammi risulta non decrescente sul 99,3% dei passi. Verifica: da 51 a 54 controlli. I tre nuovi coprono proprio cio' che era sfuggito, a partire dalla garanzia che una curva lisciata non sia mai piu' a scatti dell'originale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
14 KiB
C#
348 lines
14 KiB
C#
using Titano.Core;
|
||
using Titano.Metadata;
|
||
|
||
namespace Titano.Analysis;
|
||
|
||
/// <summary>Parametri delle transizioni giorno-notte.</summary>
|
||
public sealed class HolyGrailSettings
|
||
{
|
||
public bool Enabled { get; set; }
|
||
|
||
/// <summary>
|
||
/// Ricava i cambi di impostazione dai metadati invece che dalla sola luminanza misurata.
|
||
/// Quando tempo, diaframma e ISO sono leggibili, il salto è noto in modo esatto.
|
||
/// </summary>
|
||
public bool UseMetadata { get; set; } = true;
|
||
|
||
/// <summary>Variazione di esposizione oltre la quale si parla di cambio di impostazione, in stop.</summary>
|
||
public double StepThresholdStops { get; set; } = 0.15;
|
||
|
||
/// <summary>Su quanti fotogrammi viene distribuito un salto.</summary>
|
||
public int TransitionFrames { get; set; } = 48;
|
||
|
||
/// <summary>Livella anche la deriva del bilanciamento del bianco.</summary>
|
||
public bool SmoothColor { get; set; } = true;
|
||
|
||
/// <summary>Finestra della lisciatura cromatica: più lunga della finestra del deflicker.</summary>
|
||
public int ColorWindowFrames { get; set; } = 61;
|
||
|
||
public double ColorStrength { get; set; } = 0.8;
|
||
|
||
/// <summary>Limite dello spostamento cromatico per canale, in stop.</summary>
|
||
public double MaxColorShiftStops { get; set; } = 0.6;
|
||
|
||
public HolyGrailSettings Clone() => (HolyGrailSettings)MemberwiseClone();
|
||
}
|
||
|
||
/// <summary>Esito dell'analisi delle transizioni: gradini individuati e correzione che li ammorbidisce.</summary>
|
||
public sealed class HolyGrailAnalysis
|
||
{
|
||
/// <summary>Valore di esposizione da metadati; NaN dove non ricostruibile.</summary>
|
||
public required double[] ExposureValue { get; init; }
|
||
|
||
/// <summary>Scalinata grezza presente nella luminanza misurata, in stop cumulativi.</summary>
|
||
public required double[] Staircase { get; init; }
|
||
|
||
/// <summary>La stessa scalinata con ogni gradino spalmato sulla transizione.</summary>
|
||
public required double[] SmoothStaircase { get; init; }
|
||
|
||
/// <summary>Correzione da aggiungere: porta la scalinata grezza in quella morbida.</summary>
|
||
public required double[] StepGainStops { get; init; }
|
||
|
||
/// <summary>Indici dei fotogrammi in cui l'impostazione è cambiata.</summary>
|
||
public required int[] StepFrames { get; init; }
|
||
|
||
/// <summary>Temperatura di colore misurata su ciascun fotogramma.</summary>
|
||
public required double[] TemperatureKelvin { get; init; }
|
||
|
||
/// <summary>Deviazione verde-magenta misurata, in stop.</summary>
|
||
public required double[] TintStops { get; init; }
|
||
|
||
public bool MetadataUsable { get; init; }
|
||
public double LargestStepStops { get; init; }
|
||
public int StepCount => StepFrames.Length;
|
||
|
||
/// <summary>
|
||
/// Cambi dichiarati dai metadati che la luminanza non ha recepito, e che sono stati
|
||
/// quindi lasciati stare. Le cause sono due, entrambe legittime: il fotogramma era già
|
||
/// saturo e non poteva scurirsi oltre, oppure la scena è cambiata di altrettanto nel
|
||
/// verso opposto — l'esposizione automatica che insegue l'alba — e il salto non si vede.
|
||
/// </summary>
|
||
public int UnobservedSteps { get; init; }
|
||
|
||
public static HolyGrailAnalysis Empty(int count) => new()
|
||
{
|
||
ExposureValue = Filled(count, double.NaN),
|
||
Staircase = new double[count],
|
||
SmoothStaircase = new double[count],
|
||
StepGainStops = new double[count],
|
||
StepFrames = [],
|
||
TemperatureKelvin = Filled(count, double.NaN),
|
||
TintStops = new double[count],
|
||
};
|
||
|
||
private static double[] Filled(int count, double value)
|
||
{
|
||
var array = new double[count];
|
||
Array.Fill(array, value);
|
||
return array;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gestione delle transizioni giorno-notte.
|
||
///
|
||
/// In una ripresa che attraversa il tramonto la luce cala di una quindicina di stop, molto
|
||
/// più di quanto una singola esposizione possa coprire: la macchina deve cambiare tempo, ISO
|
||
/// o diaframma lungo la strada. Ogni cambio è quantizzato — un terzo di stop, spesso uno
|
||
/// intero — e nel filmato finito si vede come uno scalino netto della luminosità, che stona
|
||
/// tanto più quanto il resto della sequenza è liscio.
|
||
///
|
||
/// Il deflicker ordinario non basta: un gradino non è rumore da mediare, è un evento reale,
|
||
/// e la finestra mobile lo trasforma in una rampa lunga quanto la finestra, con due spigoli
|
||
/// alle estremità. La correzione giusta è un'altra: riconoscere il gradino, misurarne
|
||
/// l'ampiezza esatta e ridistribuirlo su una transizione lunga a piacere, con una curva a
|
||
/// derivata nulla agli estremi che non lascia spigoli da nessuna parte.
|
||
///
|
||
/// L'ampiezza si legge nei metadati, dove il salto è noto senza incertezza: il valore di
|
||
/// esposizione vale log2(N²/t) − log2(ISO/100), e la sua variazione fra due scatti è
|
||
/// esattamente ciò che la macchina ha deciso di cambiare. Quando i metadati non bastano si
|
||
/// ripiega sulla luminanza misurata, cercando i salti che si staccano dal rumore locale.
|
||
/// </summary>
|
||
public static class HolyGrailEngine
|
||
{
|
||
public static HolyGrailAnalysis Analyze(IReadOnlyList<LuminanceStats> stats,
|
||
IReadOnlyList<FrameMetadata> metadata,
|
||
HolyGrailSettings settings)
|
||
{
|
||
int n = stats.Count;
|
||
if (n == 0) return HolyGrailAnalysis.Empty(0);
|
||
|
||
var exposureValue = new double[n];
|
||
int usable = 0;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
exposureValue[i] = i < metadata.Count ? ExposureValue(metadata[i]) : double.NaN;
|
||
if (!double.IsNaN(exposureValue[i])) usable++;
|
||
}
|
||
|
||
var temperature = new double[n];
|
||
var tint = new double[n];
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
double r = Math.Pow(2, stats[i].Log2AverageR);
|
||
double g = Math.Pow(2, stats[i].Log2AverageG);
|
||
double b = Math.Pow(2, stats[i].Log2AverageB);
|
||
temperature[i] = ColorScience.CorrelatedColorTemperature(r, g, b);
|
||
tint[i] = ColorScience.GreenTintStops(stats[i].Log2AverageR, stats[i].Log2AverageG,
|
||
stats[i].Log2AverageB);
|
||
}
|
||
|
||
if (!settings.Enabled || n < 3)
|
||
{
|
||
var idle = HolyGrailAnalysis.Empty(n);
|
||
return new HolyGrailAnalysis
|
||
{
|
||
ExposureValue = exposureValue,
|
||
Staircase = idle.Staircase,
|
||
SmoothStaircase = idle.SmoothStaircase,
|
||
StepGainStops = idle.StepGainStops,
|
||
StepFrames = [],
|
||
TemperatureKelvin = temperature,
|
||
TintStops = tint,
|
||
MetadataUsable = usable > n / 2,
|
||
};
|
||
}
|
||
|
||
double threshold = Math.Max(0.02, settings.StepThresholdStops);
|
||
bool metadataUsable = settings.UseMetadata && usable > n / 2;
|
||
|
||
int unobserved = 0;
|
||
var jumps = metadataUsable
|
||
? GateByObservability(StepsFromMetadata(exposureValue, threshold), stats, out unobserved)
|
||
: StepsFromLuminance(stats, threshold);
|
||
|
||
var staircase = new double[n];
|
||
var smooth = new double[n];
|
||
var stepGain = new double[n];
|
||
var stepFrames = new List<int>();
|
||
double largest = 0;
|
||
|
||
double running = 0;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
running += jumps[i];
|
||
staircase[i] = running;
|
||
if (Math.Abs(jumps[i]) > 1e-9)
|
||
{
|
||
stepFrames.Add(i);
|
||
largest = Math.Max(largest, Math.Abs(jumps[i]));
|
||
}
|
||
}
|
||
|
||
// Ogni gradino viene sostituito dalla propria transizione, centrata sull'istante in
|
||
// cui è avvenuto: la somma delle transizioni ricostruisce la stessa scalinata, ma
|
||
// percorsa senza scatti.
|
||
double transition = Math.Max(2, settings.TransitionFrames);
|
||
foreach (int step in stepFrames)
|
||
{
|
||
double amplitude = jumps[step];
|
||
double centre = step - 0.5;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
double t = (i - centre + transition * 0.5) / transition;
|
||
smooth[i] += amplitude * Spline.SmoothStep(Math.Clamp(t, 0, 1));
|
||
}
|
||
}
|
||
|
||
for (int i = 0; i < n; i++) stepGain[i] = smooth[i] - staircase[i];
|
||
|
||
return new HolyGrailAnalysis
|
||
{
|
||
ExposureValue = exposureValue,
|
||
Staircase = staircase,
|
||
SmoothStaircase = smooth,
|
||
StepGainStops = stepGain,
|
||
StepFrames = [.. stepFrames],
|
||
TemperatureKelvin = temperature,
|
||
TintStops = tint,
|
||
MetadataUsable = metadataUsable,
|
||
LargestStepStops = largest,
|
||
UnobservedSteps = unobserved,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Riduce ogni gradino alla quota che la luminanza ha davvero recepito.
|
||
///
|
||
/// I metadati dicono cosa la macchina ha cambiato, non cosa si vede: se il fotogramma è
|
||
/// già saturo, dimezzare la sensibilità non lo scurisce affatto, perché sopra il bianco
|
||
/// non c'è niente da togliere. Sottrarre comunque il gradino dichiarato introdurrebbe nel
|
||
/// segnale un salto che nell'immagine non esisteva — e la correzione, invece di
|
||
/// ammorbidire uno scalino, ne creerebbe uno. Il confronto è fatto su mediane di pochi
|
||
/// fotogrammi per lato, per non farsi ingannare dal rumore del singolo scatto.
|
||
/// </summary>
|
||
private static double[] GateByObservability(double[] jumps, IReadOnlyList<LuminanceStats> stats,
|
||
out int unobserved)
|
||
{
|
||
const int span = 3;
|
||
int n = jumps.Length;
|
||
var gated = new double[n];
|
||
unobserved = 0;
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
double predicted = jumps[i];
|
||
if (Math.Abs(predicted) < 1e-9) continue;
|
||
|
||
double before = MedianLuminance(stats, i - span, i - 1);
|
||
double after = MedianLuminance(stats, i, i + span - 1);
|
||
|
||
// Quota recepita: 1 se la luminanza si è mossa quanto previsto, 0 se non si è
|
||
// mossa affatto o si è mossa nel verso opposto.
|
||
double share = Math.Clamp((after - before) / predicted, 0, 1);
|
||
gated[i] = predicted * share;
|
||
if (share < 0.25) unobserved++;
|
||
}
|
||
|
||
return gated;
|
||
}
|
||
|
||
private static double MedianLuminance(IReadOnlyList<LuminanceStats> stats, int from, int to)
|
||
{
|
||
int count = stats.Count;
|
||
from = Math.Clamp(from, 0, count - 1);
|
||
to = Math.Clamp(to, 0, count - 1);
|
||
if (to < from) (from, to) = (to, from);
|
||
|
||
Span<double> values = stackalloc double[8];
|
||
int n = 0;
|
||
for (int i = from; i <= to && n < values.Length; i++) values[n++] = stats[i].Log2Average;
|
||
|
||
var slice = values[..n];
|
||
slice.Sort();
|
||
return slice[n / 2];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Valore di esposizione dello scatto. Diaframma e sensibilità mancanti si considerano
|
||
/// costanti — quasi sempre lo sono in un time-lapse — mentre senza il tempo di posa il
|
||
/// dato non è ricostruibile e va dichiarato ignoto.
|
||
/// </summary>
|
||
public static double ExposureValue(FrameMetadata metadata)
|
||
{
|
||
if (metadata.ExposureSeconds is not { } exposure || exposure <= 0) return double.NaN;
|
||
|
||
double ev = -Math.Log2(exposure);
|
||
if (metadata.FNumber is { } aperture && aperture > 0) ev += 2 * Math.Log2(aperture);
|
||
if (metadata.Iso is { } iso && iso > 0) ev -= Math.Log2(iso / 100.0);
|
||
return ev;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Salti dedotti dai metadati. Un aumento del valore di esposizione scurisce il
|
||
/// fotogramma, quindi il gradino che compare nella luminanza misurata ha segno opposto.
|
||
/// </summary>
|
||
private static double[] StepsFromMetadata(double[] exposureValue, double threshold)
|
||
{
|
||
int n = exposureValue.Length;
|
||
var jumps = new double[n];
|
||
double previous = double.NaN;
|
||
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
double current = exposureValue[i];
|
||
if (double.IsNaN(current)) continue;
|
||
if (!double.IsNaN(previous))
|
||
{
|
||
double delta = current - previous;
|
||
if (Math.Abs(delta) > threshold) jumps[i] = -delta;
|
||
}
|
||
previous = current;
|
||
}
|
||
return jumps;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Salti dedotti dalla sola luminanza, quando i metadati non aiutano.
|
||
///
|
||
/// Il criterio non può essere la sola ampiezza, altrimenti una rampa ripida di tramonto
|
||
/// verrebbe scambiata per una serie di gradini. Si confronta invece ogni differenza con
|
||
/// la mediana delle differenze vicine: un cambio di impostazione si stacca dal fondo,
|
||
/// una rampa no, perché lì tutte le differenze si somigliano.
|
||
/// </summary>
|
||
private static double[] StepsFromLuminance(IReadOnlyList<LuminanceStats> stats, double threshold)
|
||
{
|
||
int n = stats.Count;
|
||
var jumps = new double[n];
|
||
if (n < 5) return jumps;
|
||
|
||
var delta = new double[n];
|
||
for (int i = 1; i < n; i++) delta[i] = stats[i].Log2Average - stats[i - 1].Log2Average;
|
||
|
||
const int radius = 6;
|
||
Span<double> window = stackalloc double[2 * radius + 1];
|
||
|
||
for (int i = 1; i < n; i++)
|
||
{
|
||
if (Math.Abs(delta[i]) <= threshold) continue;
|
||
|
||
int count = 0;
|
||
for (int j = i - radius; j <= i + radius; j++)
|
||
{
|
||
if (j <= 0 || j >= n || j == i) continue;
|
||
window[count++] = Math.Abs(delta[j]);
|
||
}
|
||
if (count == 0) continue;
|
||
|
||
var slice = window[..count];
|
||
slice.Sort();
|
||
double background = slice[count / 2];
|
||
|
||
if (Math.Abs(delta[i]) > Math.Max(threshold, 3.0 * background)) jumps[i] = delta[i];
|
||
}
|
||
|
||
return jumps;
|
||
}
|
||
}
|