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] ```