diff --git a/Titano/Analysis/ColorScience.cs b/Titano/Analysis/ColorScience.cs
index b6127f5..1d52734 100644
--- a/Titano/Analysis/ColorScience.cs
+++ b/Titano/Analysis/ColorScience.cs
@@ -34,20 +34,46 @@ public static class ColorScience
///
/// 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
- /// visto da lì determina la temperatura. Vale con buona precisione fra 2000 e 25000 K,
- /// che copre tutto ciò che si incontra fra una candela e un cielo di mezzogiorno.
+ /// visto da lì determina la temperatura.
+ ///
+ /// 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.
///
public static double CorrelatedColorTemperature(double r, double g, double 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;
- 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 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;
}
+ ///
+ /// 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.
+ ///
+ public static bool IsNearPlanckianLocus(double x, double y)
+ => x is >= 0.20 and <= 0.60 && y is >= 0.18 and <= 0.55;
+
///
/// 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
@@ -56,6 +82,8 @@ public static class ColorScience
public static double GreenTintStops(double log2R, double log2G, double log2B)
=> log2G - (log2R + log2B) * 0.5;
+ private static double Log2Safe(double value) => Math.Log2(Math.Max(value, 1.0 / 65536.0));
+
/// Descrizione compatta di una temperatura, per l'interfaccia e la diagnostica.
public static string Describe(double kelvin)
=> double.IsNaN(kelvin) ? "—" : $"{kelvin:0} K";
diff --git a/Titano/Analysis/HolyGrailEngine.cs b/Titano/Analysis/HolyGrailEngine.cs
index 160171d..f1b8a36 100644
--- a/Titano/Analysis/HolyGrailEngine.cs
+++ b/Titano/Analysis/HolyGrailEngine.cs
@@ -62,6 +62,14 @@ public sealed class HolyGrailAnalysis
public double LargestStepStops { get; init; }
public int StepCount => StepFrames.Length;
+ ///
+ /// 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.
+ ///
+ public int UnobservedSteps { get; init; }
+
public static HolyGrailAnalysis Empty(int count) => new()
{
ExposureValue = Filled(count, double.NaN),
@@ -149,8 +157,9 @@ public static class HolyGrailEngine
double threshold = Math.Max(0.02, settings.StepThresholdStops);
bool metadataUsable = settings.UseMetadata && usable > n / 2;
+ int unobserved = 0;
var jumps = metadataUsable
- ? StepsFromMetadata(exposureValue, threshold)
+ ? GateByObservability(StepsFromMetadata(exposureValue, threshold), stats, out unobserved)
: StepsFromLuminance(stats, threshold);
var staircase = new double[n];
@@ -199,9 +208,62 @@ public static class HolyGrailEngine
TintStops = tint,
MetadataUsable = metadataUsable,
LargestStepStops = largest,
+ UnobservedSteps = unobserved,
};
}
+ ///
+ /// 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.
+ ///
+ private static double[] GateByObservability(double[] jumps, IReadOnlyList 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 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 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];
+ }
+
///
/// 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
diff --git a/Titano/Analysis/RegionSegmenter.cs b/Titano/Analysis/RegionSegmenter.cs
index 7690b0f..93cc59e 100644
--- a/Titano/Analysis/RegionSegmenter.cs
+++ b/Titano/Analysis/RegionSegmenter.cs
@@ -134,10 +134,6 @@ public static class RegionSegmenter
double log2High = weightHigh > 1e-6 ? sumHigh / weightHigh : 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
{
Width = width,
@@ -146,10 +142,48 @@ public static class RegionSegmenter
Coverage = coverage,
Log2High = log2High,
Log2Low = log2Low,
- Description = description,
+ Description = Describe(settings.Mode, coverage, log2High, log2Low),
};
}
+ ///
+ /// 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.
+ ///
+ 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
private static List CollectSamples(IReadOnlyList paths, int orientation, int width,
@@ -182,13 +216,20 @@ public static class RegionSegmenter
var plane = new GrayPlane(width, height);
var data = buffer.Data;
+ int saturated = 0;
+
for (int p = 0; p < plane.Data.Length; p++)
{
int s = p * ImageBuffer.Channels;
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));
}
- 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)
{
@@ -285,40 +326,57 @@ public static class RegionSegmenter
}
///
- /// Maschera per linea d'orizzonte. La soglia dà una prima ipotesi colonna per colonna;
- /// 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.
- /// Il profilo risultante viene poi lisciato: un orizzonte reale non fa scalini.
+ /// Maschera per linea d'orizzonte.
+ ///
+ /// L'ipotesi iniziale viene dal profilo di luminanza per riga: la linea d'orizzonte è il
+ /// 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.
///
private static float[] BuildFromHorizon(float[] median, int width, int height, double threshold,
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];
int band = Math.Max(2, height / 8);
for (int x = 0; x < width; x++)
{
- // Prima riga, dall'alto, che apre una serie stabile di pixel sotto soglia.
- int guess = height;
- 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);
+ int from = Math.Max(1, horizon - band);
+ int to = Math.Min(height - 2, horizon + band);
double bestGradient = -1;
- int bestRow = guess;
+ int bestRow = horizon;
for (int y = from; y <= to; y++)
{
diff --git a/Titano/Core/LocalRegression.cs b/Titano/Core/LocalRegression.cs
index 1b86128..a8b14cd 100644
--- a/Titano/Core/LocalRegression.cs
+++ b/Titano/Core/LocalRegression.cs
@@ -56,43 +56,71 @@ public static class LocalRegression
int n = y.Length;
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 to = Math.Min(n - 1, i + radius);
+ int from = Math.Max(0, centre - 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++)
{
- double t = j - i;
- double w = Math.Exp(-(t * t) / twoSigmaSq) * robust[j];
- if (w <= 1e-9) continue;
- sw += w;
- swt += w * t;
- swt2 += w * t * t;
- swy += w * y[j];
- swty += w * t * y[j];
+ double t = j - centre;
+ double plain = Math.Exp(-(t * t) / twoSigmaSq);
+ if (plain <= 1e-12) continue;
+
+ pw += plain; pwt += plain * t; pwt2 += plain * t * t;
+ pwy += plain * y[j]; pwty += plain * 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)
- {
- output[i] = y[i];
- continue;
- }
+ if (pw <= 1e-9) return y[centre];
- double det = sw * swt2 - swt * swt;
- if (Math.Abs(det) < 1e-12)
- {
- output[i] = swy / sw; // finestra degenere: media pesata
- continue;
- }
+ double simple = Solve(pw, pwt, pwt2, pwy, pwty, y[centre]);
+ double survival = rw / pw;
+ if (survival <= 1e-6) return simple;
- double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0
- output[i] = a;
+ 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;
}
}
+ /// Intercetta della retta pesata, ossia il valore stimato nel centro della finestra.
+ 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;
+ if (Math.Abs(det) < 1e-12) return swy / sw; // finestra degenere: media pesata
+
+ return (swt2 * swy - swt * swty) / det;
+ }
+
private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust)
{
int n = y.Length;
diff --git a/Titano/Diagnostics/AdvancedModuleTests.cs b/Titano/Diagnostics/AdvancedModuleTests.cs
index 3acb303..308e105 100644
--- a/Titano/Diagnostics/AdvancedModuleTests.cs
+++ b/Titano/Diagnostics/AdvancedModuleTests.cs
@@ -25,6 +25,7 @@ internal static class AdvancedModuleTests
{
Stabilization(add);
PhotometricRegions(workingDirectory, add, output);
+ Smoothing(add);
ColourDrift(add);
Easing(add);
Geometry(add);
@@ -295,6 +296,59 @@ internal static class AdvancedModuleTests
double temperature = ColorScience.CorrelatedColorTemperature(1.0, 1.0, 1.0);
add("Temperatura di colore — bianco di riferimento",
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)}");
+ }
+
+ ///
+ /// 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.
+ ///
+ 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
diff --git a/Titano/Diagnostics/SequenceDiagnostics.cs b/Titano/Diagnostics/SequenceDiagnostics.cs
index 2aaf5bb..11440c6 100644
--- a/Titano/Diagnostics/SequenceDiagnostics.cs
+++ b/Titano/Diagnostics/SequenceDiagnostics.cs
@@ -14,9 +14,24 @@ namespace Titano.Diagnostics;
///
public static class SequenceDiagnostics
{
+ /// Moduli opzionali da accendere durante la diagnosi di una cartella reale.
+ [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,
+
+ /// Divide per luminanza invece che per linea d'orizzonte.
+ RegionsByLuminance = 16,
+ }
+
public static int Run(string directory, TextWriter output, int sampleCount = 6,
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))
{
@@ -147,18 +162,262 @@ public static class SequenceDiagnostics
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}"))}");
+ if (advanced != AdvancedModules.None)
+ RunAdvancedAnalysis(all2, advanced, analysisFrames, output);
+
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;
}
+ ///
+ /// 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.
+ ///
+ private static void RunAdvancedAnalysis(List 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();
+ }
+
///
/// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero
/// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file.
///
private static void RunTrialRender(List 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("RENDER DI PROVA");
@@ -178,6 +437,7 @@ public static class SequenceDiagnostics
project.Export.BitrateMbps = 80;
project.Export.Codec = codec;
project.General.WorkingWidth = forcedWidth;
+ ConfigureAdvanced(project, advanced);
var (width, height) = project.ResolveWorkingSize();
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
@@ -191,6 +451,14 @@ public static class SequenceDiagnostics
: string.Empty));
output.WriteLine($" memoria per buffer {perFrame / (1024.0 * 1024.0):0.0} MiB");
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 stopwatch = Stopwatch.StartNew();
@@ -213,6 +481,8 @@ public static class SequenceDiagnostics
var playback = Mp4Playback.Read(result.OutputPath);
output.WriteLine($" rilettura {playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati"}");
+
+ ReportStackingEvidence(advanced, playback, output);
}
catch (Exception ex)
{
@@ -220,6 +490,48 @@ public static class SequenceDiagnostics
}
}
+ ///
+ /// 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.
+ ///
+ 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 PickSamples(List files, int count)
{
if (files.Count <= count) return files;
diff --git a/Titano/Motion/PhaseCorrelator.cs b/Titano/Motion/PhaseCorrelator.cs
index 513cb9d..21a49a4 100644
--- a/Titano/Motion/PhaseCorrelator.cs
+++ b/Titano/Motion/PhaseCorrelator.cs
@@ -118,10 +118,23 @@ public sealed class PhaseCorrelator
/// Misura lo spostamento del contenuto del riquadro fra e
/// : il risultato è il vettore d per cui b(p) ≈ a(p − d).
///
+ ///
+ /// 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.
+ ///
+ private const float TextureFloor = 0.002f;
+
public PhaseShift Correlate(GrayImage a, GrayImage b, int originX, int originY)
{
- Load(a, originX, originY, _aRe, _aIm);
- Load(b, originX, originY, _bRe, _bIm);
+ float deviationA = Load(a, originX, originY, _aRe, _aIm);
+ 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(_bRe, _bIm, _size, false);
@@ -273,10 +286,15 @@ public sealed class PhaseCorrelator
return MathF.Abs(offset) > 0.5f ? 0 : offset;
}
- /// Estrae il riquadro, ne toglie la media e vi applica la finestra.
- 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.
+ ///
+ private float Load(GrayImage image, int originX, int originY, float[] re, float[] im)
{
double sum = 0;
+ double sumSquares = 0;
+
for (int y = 0; y < _size; y++)
{
int rowBase = y * _size;
@@ -285,14 +303,20 @@ public sealed class PhaseCorrelator
float value = image.At(originX + x, originY + y);
re[rowBase + x] = 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++)
{
re[i] = (re[i] - mean) * _window[i];
im[i] = 0f;
}
+
+ return (float)Math.Sqrt(variance);
}
}
diff --git a/Titano/Program.cs b/Titano/Program.cs
index f93d478..bc74ead 100644
--- a/Titano/Program.cs
+++ b/Titano/Program.cs
@@ -38,12 +38,39 @@ internal static class Program
{
EnsureConsole();
Console.WriteLine();
- return SequenceDiagnostics.Run(args[1], Console.Out,
- renderPath: args.Length > 2 ? args[2] : null,
- renderFrames: args.Length > 3 && int.TryParse(args[3], out int n) ? n : 24,
- renderWidth: args.Length > 4 && int.TryParse(args[4], out int w) ? w : 0,
- renderCodec: args.Length > 5 && args[5].Equals("hevc", StringComparison.OrdinalIgnoreCase)
- ? Video.VideoCodec.Hevc : Video.VideoCodec.H264);
+
+ // Le parole chiave possono comparire in qualunque posizione: gli argomenti
+ // posizionali erano ormai troppi perché ricordarne l'ordine avesse senso.
+ var modules = SequenceDiagnostics.AdvancedModules.None;
+ int analysisFrames = 0;
+ var positional = new List();
+
+ 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")
diff --git a/Titano/README.md b/Titano/README.md
index 6a040cc..b86422b 100644
--- a/Titano/README.md
+++ b/Titano/README.md
@@ -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
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
-— la mediana toglie di mezzo proprio ciò che passa e non resta — e la linea d'orizzonte viene
-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 mediana toglie di mezzo proprio ciò che passa e non resta.
+
+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
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
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
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
@@ -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
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
```
@@ -209,7 +282,7 @@ Titano.exe --selftest [cartella]
```
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
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.
@@ -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
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
-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
esercitare il parcheggio su disco, riletto poi dal lettore di sistema.
```
Titano.exe --diagnose [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
@@ -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
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 [cartella-sequenza] [scheda] [avanzato]
```