Correzioni emerse provando i moduli avanzati sui DNG reali

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>
This commit is contained in:
2026-08-15 01:58:57 +02:00
co-authored by Claude Opus 5
parent 3d51e98ca3
commit 790637bc0d
9 changed files with 763 additions and 80 deletions
+32 -4
View File
@@ -34,20 +34,46 @@ public static class ColorScience
/// <summary> /// <summary>
/// Temperatura di colore correlata con l'approssimazione di McCamy: il punto di /// Temperatura di colore correlata con l'approssimazione di McCamy: il punto di
/// convergenza delle rette isotermiche sta molto vicino a (0,3320; 0,1858) e l'angolo /// convergenza delle rette isotermiche sta molto vicino a (0,3320; 0,1858) e l'angolo
/// visto da lì determina la temperatura. Vale con buona precisione fra 2000 e 25000 K, /// visto da lì determina la temperatura.
/// che copre tutto ciò che si incontra fra una candela e un cielo di mezzogiorno. ///
/// Restituisce NaN quando il colore medio non sta nell'intorno del luogo di Planck in cui
/// la formula è stata costruita. Non è prudenza teorica: su una ripresa notturna la
/// cromaticità media cade lontanissimo dal luogo dei corpi neri, il polinomio diverge e
/// produce valori di decine di migliaia di kelvin. Troncarli a un estremo darebbe un
/// numero dall'aria plausibile che non descrive nulla, ed è peggio che non darne uno.
/// </summary> /// </summary>
public static double CorrelatedColorTemperature(double r, double g, double b) public static double CorrelatedColorTemperature(double r, double g, double b)
{ {
var (x, y) = Chromaticity(r, g, b); var (x, y) = Chromaticity(r, g, b);
if (!IsNearPlanckianLocus(x, y)) return double.NaN;
// Il riquadro sulle coordinate cromatiche sa dire "in quale zona", non "quanto vicino
// alla curva": un rosso saturo ci cade dentro di misura e riceverebbe una temperatura
// dall'aria sensata. Il luogo dei corpi neri percorre l'asse caldo-freddo e si scosta
// pochissimo da quello verde-magenta, quindi è lo scostamento su quell'asse — la
// stessa grandezza che il motore corregge — a dire se il colore somiglia a un
// illuminante o a una tinta qualsiasi.
if (Math.Abs(GreenTintStops(Log2Safe(r), Log2Safe(g), Log2Safe(b))) > 0.45) return double.NaN;
double denominator = 0.1858 - y; double denominator = 0.1858 - y;
if (Math.Abs(denominator) < 1e-9) return double.NaN; if (Math.Abs(denominator) < 1e-6) return double.NaN;
double n = (x - 0.3320) / denominator; double n = (x - 0.3320) / denominator;
double cct = 449 * n * n * n + 3525 * n * n + 6823.3 * n + 5520.33; double cct = 449 * n * n * n + 3525 * n * n + 6823.3 * n + 5520.33;
return double.IsFinite(cct) ? Math.Clamp(cct, 1000, 40000) : double.NaN;
// Oltre questi estremi il polinomio non è più un'approssimazione di niente:
// sotto sta la brace, sopra un blu che nessun illuminante produce.
return double.IsFinite(cct) && cct is >= 1500 and <= 15000 ? cct : double.NaN;
} }
/// <summary>
/// Vero se la cromaticità cade nella regione in cui l'approssimazione è stata ricavata.
/// Il riquadro è generoso: contiene tutti gli illuminanti reali, dalla candela al cielo
/// coperto, ed esclude i colori saturi che di temperatura non ne hanno una.
/// </summary>
public static bool IsNearPlanckianLocus(double x, double y)
=> x is >= 0.20 and <= 0.60 && y is >= 0.18 and <= 0.55;
/// <summary> /// <summary>
/// Deviazione verde-magenta, in stop: eccesso di verde rispetto alla media di rosso e blu. /// Deviazione verde-magenta, in stop: eccesso di verde rispetto alla media di rosso e blu.
/// È la grandezza che il motore corregge davvero, quindi viene riportata così com'è invece /// È la grandezza che il motore corregge davvero, quindi viene riportata così com'è invece
@@ -56,6 +82,8 @@ public static class ColorScience
public static double GreenTintStops(double log2R, double log2G, double log2B) public static double GreenTintStops(double log2R, double log2G, double log2B)
=> log2G - (log2R + log2B) * 0.5; => log2G - (log2R + log2B) * 0.5;
private static double Log2Safe(double value) => Math.Log2(Math.Max(value, 1.0 / 65536.0));
/// <summary>Descrizione compatta di una temperatura, per l'interfaccia e la diagnostica.</summary> /// <summary>Descrizione compatta di una temperatura, per l'interfaccia e la diagnostica.</summary>
public static string Describe(double kelvin) public static string Describe(double kelvin)
=> double.IsNaN(kelvin) ? "—" : $"{kelvin:0} K"; => double.IsNaN(kelvin) ? "—" : $"{kelvin:0} K";
+63 -1
View File
@@ -62,6 +62,14 @@ public sealed class HolyGrailAnalysis
public double LargestStepStops { get; init; } public double LargestStepStops { get; init; }
public int StepCount => StepFrames.Length; 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() public static HolyGrailAnalysis Empty(int count) => new()
{ {
ExposureValue = Filled(count, double.NaN), ExposureValue = Filled(count, double.NaN),
@@ -149,8 +157,9 @@ public static class HolyGrailEngine
double threshold = Math.Max(0.02, settings.StepThresholdStops); double threshold = Math.Max(0.02, settings.StepThresholdStops);
bool metadataUsable = settings.UseMetadata && usable > n / 2; bool metadataUsable = settings.UseMetadata && usable > n / 2;
int unobserved = 0;
var jumps = metadataUsable var jumps = metadataUsable
? StepsFromMetadata(exposureValue, threshold) ? GateByObservability(StepsFromMetadata(exposureValue, threshold), stats, out unobserved)
: StepsFromLuminance(stats, threshold); : StepsFromLuminance(stats, threshold);
var staircase = new double[n]; var staircase = new double[n];
@@ -199,9 +208,62 @@ public static class HolyGrailEngine
TintStops = tint, TintStops = tint,
MetadataUsable = metadataUsable, MetadataUsable = metadataUsable,
LargestStepStops = largest, 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> /// <summary>
/// Valore di esposizione dello scatto. Diaframma e sensibilità mancanti si considerano /// 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 /// costanti — quasi sempre lo sono in un time-lapse — mentre senza il tempo di posa il
+88 -30
View File
@@ -134,10 +134,6 @@ public static class RegionSegmenter
double log2High = weightHigh > 1e-6 ? sumHigh / weightHigh : 0; double log2High = weightHigh > 1e-6 ? sumHigh / weightHigh : 0;
double log2Low = weightLow > 1e-6 ? sumLow / weightLow : 0; double log2Low = weightLow > 1e-6 ? sumLow / weightLow : 0;
string description = settings.Mode == RegionMode.SkyGround
? $"cielo {coverage * 100:0}% dell'inquadratura, {log2High - log2Low:0.0} EV sopra il paesaggio"
: $"regione chiara {coverage * 100:0}% dell'inquadratura, {log2High - log2Low:0.0} EV sopra la scura";
return new RegionMask return new RegionMask
{ {
Width = width, Width = width,
@@ -146,10 +142,48 @@ public static class RegionSegmenter
Coverage = coverage, Coverage = coverage,
Log2High = log2High, Log2High = log2High,
Log2Low = log2Low, Log2Low = log2Low,
Description = description, Description = Describe(settings.Mode, coverage, log2High, log2Low),
}; };
} }
/// <summary>
/// Descrizione della divisione trovata, riportando quello che si è misurato invece di
/// quello che ci si aspettava.
///
/// La divisione per orizzonte assegna alla prima regione ciò che sta sopra la linea, e
/// nulla garantisce che sia il cielo. Un cielo più scuro del terreno però non basta a
/// dire che la divisione ha sbagliato: di notte, con un primo piano illuminato, è la
/// norma. Il sospetto nasce quando la regione superiore è insieme scura e piccola —
/// una tettoia, un ramo, un cornicione che coprono l'alto dell'inquadratura — perché un
/// cielo vero, per quanto scuro, occupa una porzione ampia del fotogramma.
/// </summary>
private static string Describe(RegionMode mode, double coverage, double log2High, double log2Low)
{
double separation = log2High - log2Low;
if (mode == RegionMode.Luminance)
{
return $"regione chiara {coverage * 100:0}% dell'inquadratura, " +
$"{Math.Abs(separation):0.0} EV sopra la scura";
}
if (separation >= 0)
{
return $"cielo {coverage * 100:0}% dell'inquadratura, {separation:0.0} EV sopra il paesaggio";
}
if (coverage >= 0.35)
{
return $"cielo {coverage * 100:0}% dell'inquadratura, {-separation:0.0} EV sotto il paesaggio " +
$"(scena notturna con primo piano illuminato)";
}
return $"regione superiore {coverage * 100:0}% dell'inquadratura, e {-separation:0.0} EV più " +
$"scura di quella inferiore: stretta e scura insieme, sopra la linea è probabile ci sia " +
$"un primo piano che copre l'alto e non il cielo. Per questa scena conviene la " +
$"divisione per luminanza.";
}
// ------------------------------------------------------------------ campionamento // ------------------------------------------------------------------ campionamento
private static List<GrayPlane> CollectSamples(IReadOnlyList<string> paths, int orientation, int width, private static List<GrayPlane> CollectSamples(IReadOnlyList<string> paths, int orientation, int width,
@@ -182,13 +216,20 @@ public static class RegionSegmenter
var plane = new GrayPlane(width, height); var plane = new GrayPlane(width, height);
var data = buffer.Data; var data = buffer.Data;
int saturated = 0;
for (int p = 0; p < plane.Data.Length; p++) for (int p = 0; p < plane.Data.Length; p++)
{ {
int s = p * ImageBuffer.Channels; int s = p * ImageBuffer.Channels;
float luma = ColorSpace.Luminance(data[s], data[s + 1], data[s + 2]); float luma = ColorSpace.Luminance(data[s], data[s + 1], data[s + 2]);
if (luma >= 0.99f) saturated++;
plane.Data[p] = (float)Math.Log2(Math.Max(luma, 1.0 / 65536.0)); plane.Data[p] = (float)Math.Log2(Math.Max(luma, 1.0 / 65536.0));
} }
results[i] = plane;
// Un fotogramma quasi interamente bruciato non ha struttura da votare: la sua
// mediana sarebbe bianco ovunque e cancellerebbe l'orizzonte visto dagli altri.
// Capita davvero in una ripresa che attraversa il giorno con pose lunghe.
if (saturated < plane.Data.Length * 0.9) results[i] = plane;
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
@@ -285,40 +326,57 @@ public static class RegionSegmenter
} }
/// <summary> /// <summary>
/// Maschera per linea d'orizzonte. La soglia dà una prima ipotesi colonna per colonna; /// Maschera per linea d'orizzonte.
/// il gradiente verticale la corregge, perché il passaggio cielo-terra è il contrasto ///
/// più forte della colonna e cade esattamente sul bordo, non dove capita la soglia. /// L'ipotesi iniziale viene dal profilo di luminanza per riga: la linea d'orizzonte è il
/// Il profilo risultante viene poi lisciato: un orizzonte reale non fa scalini. /// gradino più marcato del profilo, qualunque ne sia il verso. La versione precedente
/// cercava invece la prima riga sotto soglia scendendo dall'alto, il che presuppone un
/// cielo chiaro e sgombro sopra la testa; su una ripresa notturna fatta da sotto un
/// pergolato quella regola aggancia il bordo del tetto e chiama "cielo" le travi.
/// Il gradino del profilo non fa questa assunzione e vale anche quando il cielo è più
/// scuro del primo piano, cosa normalissima di notte.
///
/// Il gradiente verticale raffina poi l'ipotesi colonna per colonna, perché il passaggio
/// cade esattamente sul bordo e non dove capita la soglia, e il profilo risultante viene
/// lisciato: un orizzonte reale non fa scalini.
/// </summary> /// </summary>
private static float[] BuildFromHorizon(float[] median, int width, int height, double threshold, private static float[] BuildFromHorizon(float[] median, int width, int height, double threshold,
RegionSettings settings) RegionSettings settings)
{ {
_ = threshold;
var rowMean = new double[height];
for (int y = 0; y < height; y++)
{
double sum = 0;
int rowBase = y * width;
for (int x = 0; x < width; x++) sum += median[rowBase + x];
rowMean[y] = sum / width;
}
var smoothRows = SmoothProfile(rowMean, Math.Max(1, height / 48));
int span = Math.Max(1, height / 32);
double strongest = -1;
int horizon = height / 2;
for (int y = span; y < height - span; y++)
{
double step = Math.Abs(smoothRows[y + span] - smoothRows[y - span]);
if (step <= strongest) continue;
strongest = step;
horizon = y;
}
var boundary = new double[width]; var boundary = new double[width];
int band = Math.Max(2, height / 8); int band = Math.Max(2, height / 8);
for (int x = 0; x < width; x++) for (int x = 0; x < width; x++)
{ {
// Prima riga, dall'alto, che apre una serie stabile di pixel sotto soglia. int from = Math.Max(1, horizon - band);
int guess = height; int to = Math.Min(height - 2, horizon + band);
int run = 0;
int required = Math.Max(2, height / 40);
for (int y = 0; y < height; y++)
{
if (median[y * width + x] < threshold)
{
if (++run >= required) { guess = y - required + 1; break; }
}
else
{
run = 0;
}
}
if (guess >= height) guess = height - 1;
int from = Math.Max(1, guess - band);
int to = Math.Min(height - 2, guess + band);
double bestGradient = -1; double bestGradient = -1;
int bestRow = guess; int bestRow = horizon;
for (int y = from; y <= to; y++) for (int y = from; y <= to; y++)
{ {
+53 -25
View File
@@ -56,41 +56,69 @@ public static class LocalRegression
int n = y.Length; int n = y.Length;
double twoSigmaSq = 2.0 * sigma * sigma; double twoSigmaSq = 2.0 * sigma * sigma;
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++) output[i] = Fit(i);
// Stima nel punto indicato, mescolando la retta robusta e quella semplice secondo
// quanta parte della finestra i pesi di robustezza hanno lasciato in vita.
//
// La robustezza di Tukey presuppone anomalie sparse. Quando invece un tratto contiguo
// si discosta — il crollo di luce del crepuscolo, una serie di fotogrammi bruciati —
// può azzerare l'intera finestra, e allora l'unica cosa che conta è cosa si fa dopo.
// Commutare fra "uso i soli sopravvissuti" e "uso tutto" produce uno scalino nel punto
// di commutazione: sulla ripresa reale di sedici ore la curva obiettivo arrivava così
// ad avere un salto di 2,3 stop, più grande di qualunque salto presente nel segnale
// che doveva lisciare. Il rimedio non è scegliere meglio fra le due stime ma non
// scegliere affatto: si passa dall'una all'altra con continuità, e dove la finestra
// sopravvive per intero il risultato resta identico a prima.
double Fit(int centre)
{ {
int from = Math.Max(0, i - radius); int from = Math.Max(0, centre - radius);
int to = Math.Min(n - 1, i + radius); int to = Math.Min(n - 1, centre + radius);
double pw = 0, pwt = 0, pwt2 = 0, pwy = 0, pwty = 0; // retta semplice
double rw = 0, rwt = 0, rwt2 = 0, rwy = 0, rwty = 0; // retta robusta
// Sistema normale della retta pesata y = a + b·t, con t = j - i.
double sw = 0, swt = 0, swt2 = 0, swy = 0, swty = 0;
for (int j = from; j <= to; j++) for (int j = from; j <= to; j++)
{ {
double t = j - i; double t = j - centre;
double w = Math.Exp(-(t * t) / twoSigmaSq) * robust[j]; double plain = Math.Exp(-(t * t) / twoSigmaSq);
if (w <= 1e-9) continue; if (plain <= 1e-12) continue;
sw += w;
swt += w * t; pw += plain; pwt += plain * t; pwt2 += plain * t * t;
swt2 += w * t * t; pwy += plain * y[j]; pwty += plain * t * y[j];
swy += w * y[j];
swty += w * t * y[j]; double weighted = plain * robust[j];
if (weighted <= 1e-12) continue;
rw += weighted; rwt += weighted * t; rwt2 += weighted * t * t;
rwy += weighted * y[j]; rwty += weighted * t * y[j];
} }
if (sw <= 1e-9) if (pw <= 1e-9) return y[centre];
{
output[i] = y[i]; double simple = Solve(pw, pwt, pwt2, pwy, pwty, y[centre]);
continue; double survival = rw / pw;
if (survival <= 1e-6) return simple;
double sturdy = Solve(rw, rwt, rwt2, rwy, rwty, y[centre]);
// Sopra metà finestra sopravvissuta la stima è quella robusta e basta; sotto,
// si scivola verso quella semplice senza gradini.
double blend = Math.Clamp(survival / 0.5, 0, 1);
blend = blend * blend * (3 - 2 * blend);
return simple + (sturdy - simple) * blend;
} }
}
/// <summary>Intercetta della retta pesata, ossia il valore stimato nel centro della finestra.</summary>
private static double Solve(double sw, double swt, double swt2, double swy, double swty, double fallback)
{
if (sw <= 1e-9) return fallback;
double det = sw * swt2 - swt * swt; double det = sw * swt2 - swt * swt;
if (Math.Abs(det) < 1e-12) if (Math.Abs(det) < 1e-12) return swy / sw; // finestra degenere: media pesata
{
output[i] = swy / sw; // finestra degenere: media pesata
continue;
}
double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0 return (swt2 * swy - swt * swty) / det;
output[i] = a;
}
} }
private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust) private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust)
+54
View File
@@ -25,6 +25,7 @@ internal static class AdvancedModuleTests
{ {
Stabilization(add); Stabilization(add);
PhotometricRegions(workingDirectory, add, output); PhotometricRegions(workingDirectory, add, output);
Smoothing(add);
ColourDrift(add); ColourDrift(add);
Easing(add); Easing(add);
Geometry(add); Geometry(add);
@@ -295,6 +296,59 @@ internal static class AdvancedModuleTests
double temperature = ColorScience.CorrelatedColorTemperature(1.0, 1.0, 1.0); double temperature = ColorScience.CorrelatedColorTemperature(1.0, 1.0, 1.0);
add("Temperatura di colore — bianco di riferimento", add("Temperatura di colore — bianco di riferimento",
Math.Abs(temperature - 6504) < 120, $"{ColorScience.Describe(temperature)} per un grigio neutro"); Math.Abs(temperature - 6504) < 120, $"{ColorScience.Describe(temperature)} per un grigio neutro");
// Su una ripresa notturna il colore medio cade lontanissimo dal luogo dei corpi neri,
// dove l'approssimazione diverge. Deve dichiararsi inapplicabile invece di restituire
// un numero troncato a un estremo, che avrebbe l'aria di essere una misura.
double nightSky = ColorScience.CorrelatedColorTemperature(0.004, 0.006, 0.020);
double deepRed = ColorScience.CorrelatedColorTemperature(0.90, 0.05, 0.02);
add("Temperatura di colore — dichiarata non significativa fuori dal luogo di Planck",
double.IsNaN(nightSky) && double.IsNaN(deepRed),
$"cielo notturno {ColorScience.Describe(nightSky)}, rosso saturo {ColorScience.Describe(deepRed)}");
}
/// <summary>
/// La curva obiettivo non deve mai essere più a scatti del segnale che liscia.
///
/// Sembra ovvio e invece è la proprietà che si è rotta per prima su materiale vero: dove i
/// residui restano grandi per un tratto intero, i pesi di robustezza azzerano l'intera
/// finestra, e ripiegando sul campione grezzo la curva obiettivo apre uno scalino proprio
/// dove doveva esserci la massima continuità. La scena qui riproduce quelle condizioni:
/// un crollo di luce ripido con sopra una serie contigua di fotogrammi anomali.
/// </summary>
private static void Smoothing(Report add)
{
const int count = 240;
var series = new double[count];
for (int i = 0; i < count; i++)
{
// Crepuscolo: quasi piatto, poi un crollo ripido, poi di nuovo piatto.
double t = (i - 100) / 30.0;
series[i] = -2.0 - 7.0 / (1.0 + Math.Exp(-t));
// Un tratto contiguo di fotogrammi fuori scala, non un singolo scatto isolato:
// è la condizione che fa collassare i pesi su tutta la finestra.
if (i is >= 118 and <= 132) series[i] += 1.6;
}
var smoothed = LocalRegression.Smooth(series, 15, true);
double worstInput = 0, worstOutput = 0;
for (int i = 1; i < count; i++)
{
worstInput = Math.Max(worstInput, Math.Abs(series[i] - series[i - 1]));
worstOutput = Math.Max(worstOutput, Math.Abs(smoothed[i] - smoothed[i - 1]));
}
add("Regressione locale — la curva lisciata non è mai più a scatti dell'originale",
worstOutput <= worstInput,
$"salto massimo {worstInput:0.000} stop in ingresso, {worstOutput:0.000} in uscita");
// E deve comunque seguire il crollo: una curva piatta sarebbe continua ma inutile.
double range = smoothed.Max() - smoothed.Min();
add("Regressione locale — il crollo di luce viene seguito", range > 6.0,
$"escursione {range:0.00} EV su {series.Max() - series.Min():0.00} EV del segnale");
} }
// ================================================================== 4. accelerazione // ================================================================== 4. accelerazione
+315 -3
View File
@@ -14,9 +14,24 @@ namespace Titano.Diagnostics;
/// </summary> /// </summary>
public static class SequenceDiagnostics public static class SequenceDiagnostics
{ {
/// <summary>Moduli opzionali da accendere durante la diagnosi di una cartella reale.</summary>
[Flags]
public enum AdvancedModules
{
None = 0,
Analysis = 1, // stabilizzazione, regioni, transizioni giorno-notte
Camera = 2, // movimento di macchina virtuale e rimappatura del tempo
MedianStack = 4,
StarTrails = 8,
/// <summary>Divide per luminanza invece che per linea d'orizzonte.</summary>
RegionsByLuminance = 16,
}
public static int Run(string directory, TextWriter output, int sampleCount = 6, public static int Run(string directory, TextWriter output, int sampleCount = 6,
string? renderPath = null, int renderFrames = 24, string? renderPath = null, int renderFrames = 24,
int renderWidth = 0, Video.VideoCodec renderCodec = Video.VideoCodec.H264) int renderWidth = 0, Video.VideoCodec renderCodec = Video.VideoCodec.H264,
AdvancedModules advanced = AdvancedModules.None, int analysisFrames = 0)
{ {
if (!Directory.Exists(directory)) if (!Directory.Exists(directory))
{ {
@@ -147,18 +162,262 @@ public static class SequenceDiagnostics
var distinct = all2.Select(m => (m.PixelWidth, m.PixelHeight)).Distinct().ToList(); var distinct = all2.Select(m => (m.PixelWidth, m.PixelHeight)).Distinct().ToList();
output.WriteLine($" dimensioni dichiarate distinte: {string.Join(", ", distinct.Select(d => $"{d.PixelWidth}×{d.PixelHeight}"))}"); output.WriteLine($" dimensioni dichiarate distinte: {string.Join(", ", distinct.Select(d => $"{d.PixelWidth}×{d.PixelHeight}"))}");
if (advanced != AdvancedModules.None)
RunAdvancedAnalysis(all2, advanced, analysisFrames, output);
if (renderPath is not null) if (renderPath is not null)
RunTrialRender(all2, renderPath, renderFrames, renderWidth, renderCodec, output); RunTrialRender(all2, renderPath, renderFrames, renderWidth, renderCodec, advanced, output);
return decoded == sampleMetadata.Count ? 0 : 1; return decoded == sampleMetadata.Count ? 0 : 1;
} }
/// <summary>
/// Fa girare i moduli avanzati sulla sequenza reale e riporta che cosa hanno trovato.
///
/// Non è un rendering: è la fase di analisi, l'unica che può dire se su materiale vero la
/// correlazione di fase aggancia qualcosa, se la scena si lascia dividere in cielo e
/// paesaggio, se la macchina ha davvero cambiato impostazioni lungo la ripresa. Sono le
/// domande a cui una scena sintetica, costruita perché la risposta sia nota, non risponde.
/// </summary>
private static void RunAdvancedAnalysis(List<FrameMetadata> metadata, AdvancedModules modules,
int analysisFrames, TextWriter output)
{
output.WriteLine();
output.WriteLine("MODULI AVANZATI SULLA SEQUENZA REALE");
output.WriteLine(new string('-', 74));
var ordered = metadata.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
.ToList();
// La stabilizzazione confronta ogni fotogramma con il precedente: il campione deve
// essere contiguo, non sparso sulla sequenza.
var subset = analysisFrames > 0 && analysisFrames < ordered.Count
? ordered.Take(analysisFrames).ToList()
: ordered;
var project = new Pipeline.TitanoProject();
project.Sequence = TimelapseSequence.Build(subset);
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
project.DetectOrientation();
ConfigureAdvanced(project, modules);
output.WriteLine($" fotogrammi analizzati {subset.Count}" +
(subset.Count < ordered.Count ? $" di {ordered.Count} (sottoinsieme contiguo)" : string.Empty));
output.WriteLine($" larghezza di analisi {project.General.AnalysisWidth} px");
var stopwatch = Stopwatch.StartNew();
try
{
new Pipeline.RenderPipeline(project).AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
output.WriteLine($" ANALISI NON RIUSCITA: {ex.GetType().Name} — {ex.Message}");
return;
}
stopwatch.Stop();
output.WriteLine($" tempo di analisi {stopwatch.Elapsed.TotalSeconds:0.0} s " +
$"({subset.Count / Math.Max(0.001, stopwatch.Elapsed.TotalSeconds):0.0} fotogrammi/s)");
output.WriteLine();
ReportStabilization(project, output);
ReportRegions(project, output);
ReportTransitions(project, output);
ReportDeflicker(project, output);
}
private static void ConfigureAdvanced(Pipeline.TitanoProject project, AdvancedModules modules)
{
if (modules.HasFlag(AdvancedModules.Analysis))
{
project.Stabilization.Enabled = true;
project.Regions.Mode = modules.HasFlag(AdvancedModules.RegionsByLuminance)
? RegionMode.Luminance
: RegionMode.SkyGround;
project.HolyGrail.Enabled = true;
project.HolyGrail.SmoothColor = true;
}
if (modules.HasFlag(AdvancedModules.Camera))
{
project.Camera.Enabled = true;
project.Camera.Keyframes =
[
new() { Time = 0.0, CentreX = 0.42, CentreY = 0.46, Zoom = 1.10 },
new() { Time = 1.0, CentreX = 0.58, CentreY = 0.54, Zoom = 1.45 },
];
project.TimeRamp.Enabled = true;
project.TimeRamp.Speed = [new(0.0, 1.6), new(0.5, 0.5), new(1.0, 1.6)];
}
if (modules.HasFlag(AdvancedModules.MedianStack))
{
project.Stacking.Mode = Motion.StackingMode.Median;
project.Stacking.WindowFrames = 5;
}
else if (modules.HasFlag(AdvancedModules.StarTrails))
{
project.Stacking.Mode = Motion.StackingMode.Maximum;
project.Stacking.TrailFrames = 0; // scie che non si spengono
}
}
private static void ReportStabilization(Pipeline.TitanoProject project, TextWriter output)
{
if (project.Motion is not { } motion) return;
var (width, height) = project.ResolveSourceSize();
double toPixels = Math.Max(1, width);
double maxRotation = 0;
double maxShift = 0;
foreach (var correction in motion.Correction)
{
maxRotation = Math.Max(maxRotation, Math.Abs(correction.Rotation) * 180 / Math.PI);
maxShift = Math.Max(maxShift, Math.Sqrt(correction.Tx * correction.Tx + correction.Ty * correction.Ty));
}
var last = motion.Measured[^1];
double drift = Math.Sqrt(last.Tx * last.Tx + last.Ty * last.Ty) * toPixels;
output.WriteLine(" STABILIZZAZIONE");
output.WriteLine($" correlazione debole {motion.UnreliableFraction * 100:0.0}% delle coppie");
output.WriteLine($" tremolio rimosso {motion.MeanShake * toPixels:0.00} px in media, " +
$"{maxShift * toPixels:0.00} px al massimo");
output.WriteLine($" rotazione compensata {maxRotation:0.000}° al massimo");
output.WriteLine($" deriva complessiva {drift:0.0} px dal primo all'ultimo fotogramma");
output.WriteLine($" ritaglio necessario {(motion.RequiredZoom(height / (double)width) - 1) * 100:0.0}%");
output.WriteLine();
}
private static void ReportRegions(Pipeline.TitanoProject project, TextWriter output)
{
output.WriteLine(" SEGMENTAZIONE");
if (project.Mask is not { } mask)
{
output.WriteLine(" esito la scena non si divide in modo utile: resta la curva unica");
output.WriteLine();
return;
}
output.WriteLine($" esito {mask.Description}");
output.WriteLine($" maschera {mask.Width}×{mask.Height}, copertura {mask.Coverage * 100:0.0}%");
var curve = project.Curve;
if (curve is { HasRegions: true, MeasuredLow: { } measuredLow, TargetLow: { } targetLow })
{
var underGlobal = new double[measuredLow.Length];
for (int i = 0; i < measuredLow.Length; i++) underGlobal[i] = measuredLow[i] + curve.GainStops[i];
double globalNoise = DeflickerCurve.FlickerIndex(underGlobal);
double regionalNoise = DeflickerCurve.FlickerIndex(targetLow);
double gain = globalNoise > 1e-9 ? 100 * (1 - regionalNoise / globalNoise) : 0;
output.WriteLine($" effetto sul paesaggio sfarfallio {globalNoise:0.0000} stop con la curva globale, " +
$"{regionalNoise:0.0000} con quella di regione ({gain:0.#}% meglio)");
}
output.WriteLine();
}
private static void ReportTransitions(Pipeline.TitanoProject project, TextWriter output)
{
if (project.Transitions is not { } transitions) return;
int withExposureValue = transitions.ExposureValue.Count(v => !double.IsNaN(v));
var temperatures = transitions.TemperatureKelvin.Where(double.IsFinite).ToList();
var tints = transitions.TintStops.Where(double.IsFinite).ToList();
output.WriteLine(" TRANSIZIONI GIORNO-NOTTE");
output.WriteLine($" valore di esposizione ricostruito su {withExposureValue}/{transitions.ExposureValue.Length} fotogrammi");
output.WriteLine($" cambi di impostazione {transitions.StepCount}" +
(transitions.StepCount > 0
? $", il maggiore di {transitions.LargestStepStops:0.00} EV " +
$"({(transitions.MetadataUsable ? "dai metadati" : "dedotti dalla luminanza")})"
: string.Empty));
if (transitions.StepCount is > 0 and <= 12)
{
output.WriteLine($" ai fotogrammi {string.Join(", ", transitions.StepFrames.Select(f => f + 1))}");
}
if (transitions.UnobservedSteps > 0)
{
// Due cause, entrambe legittime: il fotogramma era già saturo e non poteva
// scurirsi oltre, oppure la scena è cambiata di altrettanto nel verso opposto e
// il salto, semplicemente, non si vede. In nessuno dei due casi c'è qualcosa da
// ammorbidire, e correggere comunque introdurrebbe il gradino invece di toglierlo.
output.WriteLine($" non recepiti {transitions.UnobservedSteps} cambi dichiarati che la " +
$"luminanza non ha recepito (fotogramma saturo, o esposizione " +
$"compensata dalla scena): lasciati stare");
}
int total = transitions.TemperatureKelvin.Length;
if (temperatures.Count > 0)
{
output.WriteLine($" temperatura di colore da {temperatures[0]:0} K a {temperatures[^1]:0} K " +
$"(intervallo {temperatures.Min():0}{temperatures.Max():0} K)" +
(temperatures.Count < total
? $", significativa su {temperatures.Count}/{total} fotogrammi"
: string.Empty));
}
else
{
// Su una ripresa notturna è il caso normale, non un guasto: il colore medio di un
// cielo stellato non sta da nessuna parte vicino al luogo dei corpi neri.
output.WriteLine($" temperatura di colore non significativa su nessuno dei {total} fotogrammi: " +
$"il colore medio è troppo lontano dal luogo di Planck perché una " +
$"temperatura voglia dire qualcosa");
}
if (tints.Count > 0)
{
output.WriteLine($" tinta verde-magenta da {tints[0]:+0.000;-0.000;0.000} a {tints[^1]:+0.000;-0.000;0.000} EV " +
$"(escursione {tints.Max() - tints.Min():0.000} EV)");
}
output.WriteLine();
}
private static void ReportDeflicker(Pipeline.TitanoProject project, TextWriter output)
{
if (project.Curve is not { } curve || curve.Count < 3) return;
double before = DeflickerCurve.FlickerIndex(curve.Measured);
var corrected = new double[curve.Count];
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
double after = DeflickerCurve.FlickerIndex(corrected);
double maxStepBefore = 0, maxStepAfter = 0;
int worstBefore = 0, worstAfter = 0;
for (int i = 1; i < curve.Count; i++)
{
double stepBefore = Math.Abs(curve.Measured[i] - curve.Measured[i - 1]);
if (stepBefore > maxStepBefore) { maxStepBefore = stepBefore; worstBefore = i; }
double stepAfter = Math.Abs(curve.Target[i] - curve.Target[i - 1]);
if (stepAfter > maxStepAfter) { maxStepAfter = stepAfter; worstAfter = i; }
}
output.WriteLine(" DEFLICKER");
output.WriteLine($" sfarfallio {before:0.0000} → {after:0.0000} stop RMS " +
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)");
output.WriteLine($" salto massimo {maxStepBefore:0.000} stop al fotogramma {worstBefore + 1} " +
$"→ {maxStepAfter:0.000} al fotogramma {worstAfter + 1}" +
(maxStepAfter > maxStepBefore ? " ← LA CORREZIONE LO PEGGIORA" : string.Empty));
output.WriteLine($" escursione della luce {curve.Measured.Max() - curve.Measured.Min():0.00} EV sull'intera sequenza");
output.WriteLine();
}
/// <summary> /// <summary>
/// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero /// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero
/// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file. /// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file.
/// </summary> /// </summary>
private static void RunTrialRender(List<FrameMetadata> metadata, string outputPath, int frames, private static void RunTrialRender(List<FrameMetadata> metadata, string outputPath, int frames,
int forcedWidth, Video.VideoCodec codec, TextWriter output) int forcedWidth, Video.VideoCodec codec,
AdvancedModules advanced, TextWriter output)
{ {
output.WriteLine(); output.WriteLine();
output.WriteLine("RENDER DI PROVA"); output.WriteLine("RENDER DI PROVA");
@@ -178,6 +437,7 @@ public static class SequenceDiagnostics
project.Export.BitrateMbps = 80; project.Export.BitrateMbps = 80;
project.Export.Codec = codec; project.Export.Codec = codec;
project.General.WorkingWidth = forcedWidth; project.General.WorkingWidth = forcedWidth;
ConfigureAdvanced(project, advanced);
var (width, height) = project.ResolveWorkingSize(); var (width, height) = project.ResolveWorkingSize();
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize(); var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
@@ -191,6 +451,14 @@ public static class SequenceDiagnostics
: string.Empty)); : string.Empty));
output.WriteLine($" memoria per buffer {perFrame / (1024.0 * 1024.0):0.0} MiB"); output.WriteLine($" memoria per buffer {perFrame / (1024.0 * 1024.0):0.0} MiB");
output.WriteLine($" codec {codec}"); output.WriteLine($" codec {codec}");
if (advanced != AdvancedModules.None) output.WriteLine($" moduli attivi {advanced}");
if (project.NeedsGeometry)
{
var (sourceWidth, sourceHeight) = project.ResolveSourceSize();
output.WriteLine($" lettura sorgente {sourceWidth}×{sourceHeight} " +
$"({(long)sourceWidth * sourceHeight * 3 * sizeof(float) / (1024.0 * 1024.0):0.0} MiB per fotogramma)");
}
var pipeline = new Pipeline.RenderPipeline(project); var pipeline = new Pipeline.RenderPipeline(project);
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
@@ -213,6 +481,8 @@ public static class SequenceDiagnostics
var playback = Mp4Playback.Read(result.OutputPath); var playback = Mp4Playback.Read(result.OutputPath);
output.WriteLine($" rilettura {playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati"}"); output.WriteLine($" rilettura {playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati"}");
ReportStackingEvidence(advanced, playback, output);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -220,6 +490,48 @@ public static class SequenceDiagnostics
} }
} }
/// <summary>
/// Verifica sull'uscita vera la proprietà che definisce ciascuna modalità di accumulo.
///
/// Che il rendering non si inceppi non prova che l'effetto ci sia. Il massimo progressivo
/// ha una proprietà controllabile senza guardare l'immagine: la luminanza non può mai
/// calare, perché ogni pixel trattiene il valore più alto incontrato. Se cala, qualcosa
/// nell'accumulatore non tiene.
/// </summary>
private static void ReportStackingEvidence(AdvancedModules advanced, Mp4Playback.Playback playback,
TextWriter output)
{
var luma = playback.MeanLuma;
if (luma.Count < 4) return;
if (advanced.HasFlag(AdvancedModules.StarTrails))
{
int rising = 0;
double worstDrop = 0;
for (int i = 1; i < luma.Count; i++)
{
if (luma[i] >= luma[i - 1] - 1e-4) rising++;
else worstDrop = Math.Max(worstDrop, luma[i - 1] - luma[i]);
}
double share = 100.0 * rising / (luma.Count - 1);
output.WriteLine($" scie stellari luminanza da {luma[0]:0.0000} a {luma[^1]:0.0000} " +
$"(×{luma[^1] / Math.Max(1e-6, luma[0]):0.0}), " +
$"non decrescente sul {share:0.#}% dei passi" +
(worstDrop > 1e-3 ? $", calo massimo {worstDrop:0.0000}" : string.Empty));
}
if (advanced.HasFlag(AdvancedModules.MedianStack))
{
// La mediana temporale toglie ciò che passa una volta sola: la luminanza deve
// risultare più regolare di quella della sequenza sorgente.
double roughness = 0;
for (int i = 1; i < luma.Count; i++) roughness += Math.Abs(luma[i] - luma[i - 1]);
output.WriteLine($" mediana temporale variazione media fra fotogrammi " +
$"{roughness / (luma.Count - 1):0.00000}");
}
}
private static List<string> PickSamples(List<string> files, int count) private static List<string> PickSamples(List<string> files, int count)
{ {
if (files.Count <= count) return files; if (files.Count <= count) return files;
+29 -5
View File
@@ -118,10 +118,23 @@ public sealed class PhaseCorrelator
/// Misura lo spostamento del contenuto del riquadro fra <paramref name="a"/> e /// Misura lo spostamento del contenuto del riquadro fra <paramref name="a"/> e
/// <paramref name="b"/>: il risultato è il vettore d per cui b(p) ≈ a(p d). /// <paramref name="b"/>: il risultato è il vettore d per cui b(p) ≈ a(p d).
/// </summary> /// </summary>
/// <summary>
/// Deviazione standard sotto la quale un riquadro si considera privo di tessitura.
/// Corrisponde a mezzo livello su 255: sotto, non c'è nulla di cui misurare lo spostamento.
/// </summary>
private const float TextureFloor = 0.002f;
public PhaseShift Correlate(GrayImage a, GrayImage b, int originX, int originY) public PhaseShift Correlate(GrayImage a, GrayImage b, int originX, int originY)
{ {
Load(a, originX, originY, _aRe, _aIm); float deviationA = Load(a, originX, originY, _aRe, _aIm);
Load(b, originX, originY, _bRe, _bIm); float deviationB = Load(b, originX, originY, _bRe, _bIm);
// Un riquadro uniforme non ha spostamento misurabile: la normalizzazione al modulo
// unitario amplificherebbe il solo rumore numerico e l'antitrasformata darebbe un
// picco qualunque, indistinguibile da uno vero. Succede davvero — su una ripresa che
// attraversa il giorno con pose da trenta secondi il cielo esce bruciato e piatto —
// e senza questo controllo la stabilizzazione inseguiva spostamenti inventati.
if (deviationA < TextureFloor || deviationB < TextureFloor) return PhaseShift.None;
Fourier.Transform2D(_aRe, _aIm, _size, false); Fourier.Transform2D(_aRe, _aIm, _size, false);
Fourier.Transform2D(_bRe, _bIm, _size, false); Fourier.Transform2D(_bRe, _bIm, _size, false);
@@ -273,10 +286,15 @@ public sealed class PhaseCorrelator
return MathF.Abs(offset) > 0.5f ? 0 : offset; return MathF.Abs(offset) > 0.5f ? 0 : offset;
} }
/// <summary>Estrae il riquadro, ne toglie la media e vi applica la finestra.</summary> /// <summary>
private void Load(GrayImage image, int originX, int originY, float[] re, float[] im) /// Estrae il riquadro, ne toglie la media e vi applica la finestra. Restituisce la
/// deviazione standard del riquadro, che dice se c'era qualcosa da misurare.
/// </summary>
private float Load(GrayImage image, int originX, int originY, float[] re, float[] im)
{ {
double sum = 0; double sum = 0;
double sumSquares = 0;
for (int y = 0; y < _size; y++) for (int y = 0; y < _size; y++)
{ {
int rowBase = y * _size; int rowBase = y * _size;
@@ -285,14 +303,20 @@ public sealed class PhaseCorrelator
float value = image.At(originX + x, originY + y); float value = image.At(originX + x, originY + y);
re[rowBase + x] = value; re[rowBase + x] = value;
sum += value; sum += value;
sumSquares += (double)value * value;
} }
} }
float mean = (float)(sum / re.Length); double count = re.Length;
float mean = (float)(sum / count);
double variance = Math.Max(0, sumSquares / count - (double)mean * mean);
for (int i = 0; i < re.Length; i++) for (int i = 0; i < re.Length; i++)
{ {
re[i] = (re[i] - mean) * _window[i]; re[i] = (re[i] - mean) * _window[i];
im[i] = 0f; im[i] = 0f;
} }
return (float)Math.Sqrt(variance);
} }
} }
+33 -6
View File
@@ -38,12 +38,39 @@ internal static class Program
{ {
EnsureConsole(); EnsureConsole();
Console.WriteLine(); Console.WriteLine();
return SequenceDiagnostics.Run(args[1], Console.Out,
renderPath: args.Length > 2 ? args[2] : null, // Le parole chiave possono comparire in qualunque posizione: gli argomenti
renderFrames: args.Length > 3 && int.TryParse(args[3], out int n) ? n : 24, // posizionali erano ormai troppi perché ricordarne l'ordine avesse senso.
renderWidth: args.Length > 4 && int.TryParse(args[4], out int w) ? w : 0, var modules = SequenceDiagnostics.AdvancedModules.None;
renderCodec: args.Length > 5 && args[5].Equals("hevc", StringComparison.OrdinalIgnoreCase) int analysisFrames = 0;
? Video.VideoCodec.Hevc : Video.VideoCodec.H264); var positional = new List<string>();
foreach (string argument in args[1..])
{
if (argument.Equals("avanzato", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.Analysis;
else if (argument.Equals("camera", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.Camera;
else if (argument.Equals("mediana", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.MedianStack;
else if (argument.Equals("scie", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.StarTrails;
else if (argument.Equals("luminanza", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.RegionsByLuminance;
else if (argument.StartsWith("analisi=", StringComparison.OrdinalIgnoreCase))
int.TryParse(argument[8..], out analysisFrames);
else
positional.Add(argument);
}
return SequenceDiagnostics.Run(positional[0], Console.Out,
renderPath: positional.Count > 1 ? positional[1] : null,
renderFrames: positional.Count > 2 && int.TryParse(positional[2], out int n) ? n : 24,
renderWidth: positional.Count > 3 && int.TryParse(positional[3], out int w) ? w : 0,
renderCodec: positional.Count > 4 && positional[4].Equals("hevc", StringComparison.OrdinalIgnoreCase)
? Video.VideoCodec.Hevc : Video.VideoCodec.H264,
advanced: modules,
analysisFrames: analysisFrames);
} }
if (args.Length > 1 && args[0] == "--capture") if (args.Length > 1 && args[0] == "--capture")
+95 -5
View File
@@ -114,9 +114,18 @@ sua inversa è la correzione. Una panoramica voluta sopravvive perché è già l
del fotogramma; il deflicker legge un calo di luce e schiarisce tutto, paesaggio compreso, che del fotogramma; il deflicker legge un calo di luce e schiarisce tutto, paesaggio compreso, che
invece non era cambiato. Titano divide il fotogramma in due regioni e dà a ciascuna la propria invece non era cambiato. Titano divide il fotogramma in due regioni e dà a ciascuna la propria
curva. La maschera nasce una volta sola dalla mediana temporale di un campione di fotogrammi curva. La maschera nasce una volta sola dalla mediana temporale di un campione di fotogrammi
— la mediana toglie di mezzo proprio ciò che passa e non resta — e la linea d'orizzonte viene — la mediana toglie di mezzo proprio ciò che passa e non resta.
poi agganciata al massimo del gradiente verticale, che cade sul bordo vero e non dove capita
la soglia. Sulla scena di prova il terreno passa da 0,062 a 0,026 stop di oscillazione. La linea d'orizzonte è il gradino più marcato del profilo di luminanza per riga, e viene poi
agganciata colonna per colonna al massimo del gradiente verticale. Il verso del gradino non
conta: cercare invece "la prima riga sotto soglia scendendo dall'alto" presuppone un cielo
chiaro e sgombro sopra la testa, e su una ripresa notturna fatta da sotto un pergolato quella
regola aggancia il bordo del tetto e chiama cielo le travi. Quando sopra la linea finisce
comunque un primo piano, la divisione lo dichiara e indica quella per luminanza, che non fa
ipotesi sulla forma.
Sulla sequenza reale della cometa il paesaggio passa da 0,156 a 0,020 stop di oscillazione:
l'87% dello sfarfallio che la curva globale gli avrebbe trasmesso non lo raggiunge.
**Transizioni giorno-notte.** Attraversando il tramonto la macchina deve cambiare tempo, ISO o **Transizioni giorno-notte.** Attraversando il tramonto la macchina deve cambiare tempo, ISO o
diaframma, e ogni cambio è quantizzato: nel filmato si vede uno scalino netto. Il deflicker diaframma, e ogni cambio è quantizzato: nel filmato si vede uno scalino netto. Il deflicker
@@ -128,6 +137,13 @@ già priva di gradini. Il bilanciamento del bianco riceve un trattamento paralle
rapporto fra i canali invece della loro luminanza, così sparisce il tremolio del bilanciamento rapporto fra i canali invece della loro luminanza, così sparisce il tremolio del bilanciamento
automatico e resta il viaggio verso il caldo del tramonto. automatico e resta il viaggio verso il caldo del tramonto.
La temperatura di colore si ricava con l'approssimazione di McCamy, ma solo dove ha senso:
fuori dall'intorno del luogo di Planck il polinomio diverge, e su un cielo notturno — dove il
colore medio non somiglia a nessun corpo nero — restituiva decine di migliaia di kelvin.
Adesso in quel caso si dichiara inapplicabile invece di produrre un numero dall'aria
plausibile. Quello che resta sempre definito, e che il motore corregge davvero, è la deviazione
verde-magenta in stop.
**Movimento di macchina virtuale.** Un sensore da dodici megapixel contiene un 4K con **Movimento di macchina virtuale.** Un sensore da dodici megapixel contiene un 4K con
abbondante margine di ritaglio: da lì si ricava una panoramica che in ripresa avrebbe richiesto abbondante margine di ritaglio: da lì si ricava una panoramica che in ripresa avrebbe richiesto
una slitta motorizzata, decisa dopo, guardando il materiale. Fra un nodo e il successivo il una slitta motorizzata, decisa dopo, guardando il materiale. Fra un nodo e il successivo il
@@ -193,6 +209,63 @@ video uscirebbe, peserebbe, e non si riprodurrebbe. Titano riduce quindi il foto
massimo riproducibile conservando le proporzioni, e lo dichiara in interfaccia. Le sorgenti massimo riproducibile conservando le proporzioni, e lo dichiara in interfaccia. Le sorgenti
16:9 fino al 4K UHD non vengono toccate. 16:9 fino al 4K UHD non vengono toccate.
## Cosa ha insegnato il materiale vero
I moduli avanzati sono stati provati su quattro sequenze reali — da 554 a 1005 scatti in DNG,
riprese notturne di comete e aurore con una GoPro HERO8 — dopo essere già passati da
cinquantaquattro controlli su scene sintetiche. Le scene sintetiche sono costruite perché la
risposta sia nota, e per questo non possono sorprendere: tutto ciò che segue è emerso solo dai
file veri, e ha portato a correzioni nel motore.
**La ricerca dell'orizzonte agganciava la tettoia.** Una delle riprese è fatta da sotto un
pergolato: travi scure in cima, cielo nel mezzo, alberi e case in fondo. Sul tratto notturno di
quella sequenza la vecchia regola — "la prima riga sotto soglia scendendo dall'alto" — trovava
il bordo del tetto e chiamava cielo le travi, il 7% dell'inquadratura e per giunta più scuro
del resto. Sulla ripresa di sedici ore l'errore si vedeva sull'intera sequenza: cielo al 29% e
separazione nulla fra le due regioni, cioè una divisione che non portava alcuna informazione.
Sostituita con il gradino più marcato del profilo di luminanza per riga, che non presuppone un
cielo chiaro in cima e funziona anche quando il cielo è il più scuro dei due: 91%
dell'inquadratura e 1,9 EV di separazione, con lo sfarfallio trasmesso al paesaggio ridotto del
79% invece che del 57%.
**La temperatura di colore inventava numeri.** Su un cielo stellato il colore medio non
somiglia a nessun corpo nero, l'approssimazione di McCamy diverge e usciva un intervallo da
1000 a 40000 K, troncato agli estremi. Ora fuori dall'intorno del luogo di Planck la misura si
dichiara inapplicabile. Il controllo che verifica la nuova regola ha subito trovato un buco nel
primo tentativo di filtro: un riquadro sulle coordinate cromatiche lascia passare un rosso
saturo, perché sa dire in quale zona si è ma non quanto si è vicini a una curva.
**La correlazione di fase inseguiva spostamenti inventati.** Una ripresa attraversa sedici ore
con pose da trenta secondi, quindi di giorno esce completamente bruciata. Su un riquadro
uniforme la normalizzazione al modulo unitario amplifica il solo rumore numerico e
l'antitrasformata dà un picco qualunque: la stabilizzazione misurava 9,8 px di tremolio medio,
181 px di punta, e chiedeva di ritagliare il 15% dell'inquadratura. Con un controllo di
tessitura sul riquadro il tremolio scende a 0,07 px e il ritaglio a 0,1%.
**I gradini dichiarati non sempre si vedono.** Se il fotogramma è già saturo, dimezzare la
sensibilità non lo scurisce: sopra il bianco non c'è niente da togliere. Sottrarre comunque il
gradino letto nei metadati introduceva nel segnale un salto che nell'immagine non esisteva. Ora
ogni gradino viene ridotto alla quota che la luminanza ha davvero recepito; su quella sequenza
7 cambi su 17 risultano non recepiti e vengono lasciati stare.
**La curva obiettivo poteva essere più a scatti del segnale che lisciava.** È il difetto più
serio, e stava nel nucleo del deflicker, non nei moduli nuovi. 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 "uso i soli sopravvissuti" e
"uso tutto" aprendo uno scalino di 2,3 stop proprio dove serviva la massima continuità. Adesso
le due stime si mescolano con continuità secondo quanta finestra è sopravvissuta. Sulla ripresa
di sedici ore la riduzione dello sfarfallio passa dal 17% al 67%, e il salto massimo della
curva obiettivo da 1,73 a 0,21 stop.
Le scie stellari sono state verificate sull'uscita vera con la proprietà che le definisce: la
luminanza non può calare, perché ogni pixel trattiene il valore più alto incontrato. Su 150
fotogrammi cresce di 1,6 volte ed è non decrescente sul 99,3% dei passi — il resto è il rumore
della compressione.
Sul costo: con stabilizzazione, mediana, movimento virtuale e rimappatura tutti attivi insieme,
al profilo Massima e a 1920×1440, il rendering va a 0,7 fotogrammi al secondo. È il baratto che
il profilo dichiara, e si sceglie dal pannello Generale.
## Compilazione ed esecuzione ## Compilazione ed esecuzione
``` ```
@@ -209,7 +282,7 @@ Titano.exe --selftest [cartella]
``` ```
Genera sequenze sintetiche dalle proprietà note e le fa attraversare l'intera pipeline, Genera sequenze sintetiche dalle proprietà note e le fa attraversare l'intera pipeline,
confrontando 51 grandezze misurate con i valori attesi. Nessuna soglia è scelta a posteriori: confrontando 54 grandezze misurate con i valori attesi. Nessuna soglia è scelta a posteriori:
la scena è costruita perché il valore atteso sia la conseguenza aritmetica di come è stata la scena è costruita perché il valore atteso sia la conseguenza aritmetica di come è stata
generata — il tremolio ha un percorso noto, il gradino di esposizione un'ampiezza dichiarata generata — il tremolio ha un percorso noto, il gradino di esposizione un'ampiezza dichiarata
nei metadati e visibile nei pixel, la nuvola attraversa il solo cielo. nei metadati e visibile nei pixel, la nuvola attraversa il solo cielo.
@@ -221,12 +294,16 @@ della maschera delle regioni; riconoscimento e ridistribuzione dei cambi di espo
lisciatura cromatica con la deriva del tramonto conservata; monotonia e simmetria delle curve lisciatura cromatica con la deriva del tramonto conservata; monotonia e simmetria delle curve
di accelerazione; trasparenza e ritaglio dello stadio geometrico; monotonia della rimappatura di accelerazione; trasparenza e ritaglio dello stadio geometrico; monotonia della rimappatura
temporale; rimozione dell'intruso da parte della mediana e conservazione dei passaggi da parte temporale; rimozione dell'intruso da parte della mediana e conservazione dei passaggi da parte
del massimo; allineamento delle NAL dentro ogni campione del contenitore; e — prova conclusiva del massimo; la garanzia che la curva lisciata non sia mai più a scatti del segnale che liscia,
nemmeno quando un tratto contiguo di fotogrammi si discosta; il rifiuto di dichiarare una
temperatura di colore dove non ne esiste una; allineamento delle NAL dentro ogni campione del
contenitore; e — prova conclusiva
— un rendering con tutti i moduli attivi insieme, con tetto di memoria volutamente stretto per — un rendering con tutti i moduli attivi insieme, con tetto di memoria volutamente stretto per
esercitare il parcheggio su disco, riletto poi dal lettore di sistema. esercitare il parcheggio su disco, riletto poi dal lettore di sistema.
``` ```
Titano.exe --diagnose <cartella> [uscita.mp4] [fotogrammi] [larghezza] [h264|hevc] Titano.exe --diagnose <cartella> [uscita.mp4] [fotogrammi] [larghezza] [h264|hevc]
[avanzato] [luminanza] [camera] [mediana|scie] [analisi=N]
``` ```
Riporta cosa il programma riesce davvero a leggere da una cartella reale: metadati file per Riporta cosa il programma riesce davvero a leggere da una cartella reale: metadati file per
@@ -235,6 +312,19 @@ risoluzioni diverse, cadenza sull'intera sequenza. Indicando un file di uscita e
render di prova e ne riverifica il contenitore. È lo strumento con cui si distingue un difetto render di prova e ne riverifica il contenitore. È lo strumento con cui si distingue un difetto
del motore da un formato che il sistema non sa aprire. del motore da un formato che il sistema non sa aprire.
Le parole chiave finali, in qualunque ordine, accendono i moduli avanzati e aggiungono una
sezione che riporta cosa ciascuno ha trovato: entità del tremolio e ritaglio necessario, forma
della maschera e quanto la curva di regione stabilizza il paesaggio rispetto a quella globale,
numero e ampiezza dei cambi di impostazione, escursione della tinta. `luminanza` divide per
luminanza invece che per linea d'orizzonte, `camera` aggiunge movimento virtuale e rimappatura
del tempo al render di prova, `mediana` e `scie` scelgono l'accumulo temporale, `analisi=N`
limita l'analisi ai primi N fotogrammi — contigui, perché la stabilizzazione confronta ogni
fotogramma con il precedente.
È lo strumento che ha fatto emergere i due difetti visibili solo su materiale vero: la ricerca
dell'orizzonte che agganciava il bordo di una tettoia, e la temperatura di colore che su un
cielo notturno restituiva decine di migliaia di kelvin invece di dichiararsi inapplicabile.
``` ```
Titano.exe --capture <file.png> [cartella-sequenza] [scheda] [avanzato] Titano.exe --capture <file.png> [cartella-sequenza] [scheda] [avanzato]
``` ```