From a815a9100687c8ab100d357d3f4810d3d844cd03 Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Fri, 14 Aug 2026 16:55:27 +0200 Subject: [PATCH] Sei moduli avanzati per la produzione cinematografica Estende il motore con stabilizzazione sub-pixel, deflicker per regioni, transizioni giorno-notte, movimento di macchina virtuale, rimappatura non lineare del tempo e accumulo temporale. Tutto in-house, nessuna dipendenza aggiunta: il progetto continua a non contenere un solo PackageReference. Perche' questa forma. Il rendering non percorre piu' la sequenza sorgente ma un piano di fotogrammi d'uscita, ognuno con posizione anche frazionaria e durata propria. Le quattro modalita' temporali producono tutte quella stessa forma, quindi il ciclo di rendering e' uno solo e non ha un ramo per ciascun caso; da li' discende anche la sfocatura, perche' un fotogramma che copre v scatti ha angolo di otturatore diviso v. Gli spostamenti si misurano con la correlazione di fase, che ignora per costruzione le differenze di luminosita' fra scatti - in un time-lapse ci sono sempre - e reagisce alla sola geometria. Il picco intero non basta: la superficie di correlazione viene ricostruita a passo fine valutando la somma di Fourier sulle posizioni intermedie invece di interpolare con una parabola tre campioni di una cresta che parabola non e'. L'errore misurato scende da 0,14 a 0,08 px. I gradini di esposizione non sono rumore da mediare: l'ampiezza si legge esatta nei metadati e viene ridistribuita su una transizione a derivata nulla agli estremi. Il deflicker lavora poi su una serie gia' priva di gradini, invece di trasformare lo scalino in una rampa con due spigoli. La maschera delle regioni nasce dalla mediana temporale di un campione di fotogrammi, che toglie di mezzo proprio le nuvole di passaggio, e la linea d'orizzonte viene agganciata al massimo del gradiente verticale. Sulla scena di prova il terreno passa da 0,062 a 0,026 stop di oscillazione. Ritaglio virtuale e correzione di stabilizzazione sono entrambi affini e vengono composti in una sola trasformazione: due ricampionamenti in fila costerebbero il doppio di nitidezza senza dare nulla in cambio. Sulla memoria: i moduli avanzati hanno rotto l'assunto che bastassero due fotogrammi vivi alla volta, quindi il disco entra ora in gioco - come annotato nel commit precedente, e' questa la porta che si apriva. La finestra attiva resta sempre in memoria perche' la mediana ha bisogno di tutti i suoi fotogrammi insieme; solo la lettura in anticipo viene parcheggiata su disco oltre il tetto, e ripresa una volta sola. Non esiste un caso in cui lo stesso fotogramma vada e torni piu' volte. Il file di parcheggio si cancella da se'. Verifica: da 26 a 51 controlli. Nessuna soglia scelta a posteriori - il tremolio ha un percorso noto, il gradino un'ampiezza dichiarata nei metadati e visibile nei pixel, la nuvola attraversa il solo cielo. Il controllo conclusivo rende una sequenza con tutti i moduli attivi insieme e tetto di memoria volutamente stretto, poi la rilegge con il lettore di sistema. Verificato anche sui DNG GoPro reali: 580 file, render di prova conforme. Co-Authored-By: Claude Opus 5 --- Titano/Analysis/ColorScience.cs | 62 +++ Titano/Analysis/DeflickerEngine.cs | 318 ++++++----- Titano/Analysis/ExposureProcessor.cs | 65 +++ Titano/Analysis/HolyGrailEngine.cs | 285 ++++++++++ Titano/Analysis/LuminanceAnalyzer.cs | 102 +++- Titano/Analysis/RegionSegmenter.cs | 426 +++++++++++++++ Titano/Core/FrameRecord.cs | 13 + Titano/Core/LocalRegression.cs | 120 ++++ Titano/Core/Spline.cs | 147 +++++ Titano/Diagnostics/AdvancedModuleTests.cs | 621 +++++++++++++++++++++ Titano/Diagnostics/SelfTest.cs | 52 +- Titano/Diagnostics/SyntheticSequence.cs | 92 +++- Titano/Imaging/GeometryStage.cs | 193 +++++++ Titano/Motion/Fourier.cs | 104 ++++ Titano/Motion/GrayPyramid.cs | 15 + Titano/Motion/PhaseCorrelator.cs | 298 ++++++++++ Titano/Motion/Stabilizer.cs | 401 ++++++++++++++ Titano/Motion/TemporalStacker.cs | 217 ++++++++ Titano/Motion/VirtualCamera.cs | 144 +++++ Titano/Pipeline/FrameWindow.cs | 425 +++++++++++++++ Titano/Pipeline/PipelineProgress.cs | 9 +- Titano/Pipeline/RenderPipeline.cs | 635 ++++++++++++++-------- Titano/Pipeline/RenderPlan.cs | 172 ++++++ Titano/Pipeline/TitanoProject.cs | 153 +++++- Titano/Program.cs | 10 +- Titano/README.md | 152 +++++- Titano/UI/CameraEditor.cs | 390 +++++++++++++ Titano/UI/LuminanceChart.cs | 129 ++++- Titano/UI/MainForm.cs | 101 +++- Titano/UI/PreviewPanel.cs | 118 +++- Titano/UI/SettingsPanel.cs | 463 ++++++++++++++-- Titano/UI/SplineEditor.cs | 252 +++++++++ Titano/UI/Theme.cs | 6 + 33 files changed, 6167 insertions(+), 523 deletions(-) create mode 100644 Titano/Analysis/ColorScience.cs create mode 100644 Titano/Analysis/HolyGrailEngine.cs create mode 100644 Titano/Analysis/RegionSegmenter.cs create mode 100644 Titano/Core/LocalRegression.cs create mode 100644 Titano/Core/Spline.cs create mode 100644 Titano/Diagnostics/AdvancedModuleTests.cs create mode 100644 Titano/Imaging/GeometryStage.cs create mode 100644 Titano/Motion/Fourier.cs create mode 100644 Titano/Motion/PhaseCorrelator.cs create mode 100644 Titano/Motion/Stabilizer.cs create mode 100644 Titano/Motion/TemporalStacker.cs create mode 100644 Titano/Motion/VirtualCamera.cs create mode 100644 Titano/Pipeline/FrameWindow.cs create mode 100644 Titano/Pipeline/RenderPlan.cs create mode 100644 Titano/UI/CameraEditor.cs create mode 100644 Titano/UI/SplineEditor.cs diff --git a/Titano/Analysis/ColorScience.cs b/Titano/Analysis/ColorScience.cs new file mode 100644 index 0000000..b6127f5 --- /dev/null +++ b/Titano/Analysis/ColorScience.cs @@ -0,0 +1,62 @@ +namespace Titano.Analysis; + +/// +/// Conversioni colorimetriche in-house: dal terzetto RGB in luce lineare alle coordinate +/// cromatiche e alla temperatura di colore correlata. +/// +/// Servono a dare un nome alla deriva cromatica di una sequenza. Dire "il bianco si è +/// spostato di 0,3 stop sul rosso" non aiuta nessuno; dire "da 5400 K a 3100 K" descrive +/// esattamente ciò che l'occhio vede accadere durante un tramonto, e permette di decidere +/// quanta parte di quel viaggio va conservata e quanta era solo il bilanciamento automatico +/// della macchina che cambiava idea da uno scatto all'altro. +/// +public static class ColorScience +{ + /// + /// Coordinate cromatiche CIE 1931 dal terzetto lineare Rec.709 / sRGB. + /// La matrice è quella delle primarie sRGB con bianco D65. + /// + public static (double X, double Y) Chromaticity(double r, double g, double b) + { + r = Math.Max(r, 0); + g = Math.Max(g, 0); + b = Math.Max(b, 0); + + double capitalX = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b; + double capitalY = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b; + double capitalZ = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b; + + double sum = capitalX + capitalY + capitalZ; + if (sum <= 1e-12) return (0.3127, 0.3290); // ripiego sul bianco D65 + return (capitalX / sum, capitalY / sum); + } + + /// + /// 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. + /// + public static double CorrelatedColorTemperature(double r, double g, double b) + { + var (x, y) = Chromaticity(r, g, b); + double denominator = 0.1858 - y; + if (Math.Abs(denominator) < 1e-9) 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; + } + + /// + /// 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 + /// di essere tradotta in una scala convenzionale. + /// + public static double GreenTintStops(double log2R, double log2G, double log2B) + => log2G - (log2R + log2B) * 0.5; + + /// 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/DeflickerEngine.cs b/Titano/Analysis/DeflickerEngine.cs index f76965e..f5cd78c 100644 --- a/Titano/Analysis/DeflickerEngine.cs +++ b/Titano/Analysis/DeflickerEngine.cs @@ -33,12 +33,31 @@ public sealed class DeflickerSettings public sealed class DeflickerCurve { public required double[] Measured { get; init; } + + /// Luminanza che il fotogramma avrà dopo la correzione: è la curva obiettivo. public required double[] Target { get; init; } + public required double[] GainStops { get; init; } /// Guadagni per canale (R,G,B); pari a quello di luminanza se la stabilizzazione colore è spenta. public required double[][] ChannelGain { get; init; } + /// Guadagni della prima regione (cielo); null quando il deflicker è globale. + public double[][]? ChannelGainHigh { get; init; } + + /// Guadagni della seconda regione (paesaggio); null quando il deflicker è globale. + public double[][]? ChannelGainLow { get; init; } + + public double[]? MeasuredHigh { get; init; } + public double[]? MeasuredLow { get; init; } + public double[]? TargetHigh { get; init; } + public double[]? TargetLow { get; init; } + + /// Quota di correzione dovuta all'ammorbidimento dei salti di esposizione. + public double[]? StepGainStops { get; init; } + + public bool HasRegions => ChannelGainHigh is not null && ChannelGainLow is not null; + public int Count => Measured.Length; /// Deviazione standard delle differenze fra fotogrammi adiacenti, in stop: misura lo sfarfallio. @@ -73,65 +92,85 @@ public sealed class DeflickerCurve /// robustezza di Tukey che neutralizza i fotogrammi anomali. Rispetto a una media mobile /// semplice, la componente lineare segue senza ritardo le rampe reali di luce (alba, tramonto) /// e rimuove solo la componente ad alta frequenza generata dalle micro-variazioni di diaframma. +/// +/// La correzione finale si compone di tre contributi indipendenti, che vivono su scale +/// temporali diverse e non si disturbano a vicenda: +/// lo sfarfallio, veloce, che la regressione toglie; i salti di impostazione della macchina, +/// istantanei, che l'analisi Holy Grail riconosce e ridistribuisce; la deriva cromatica, +/// lenta, che viene lisciata sui rapporti fra canali invece che sulla luminanza. +/// A ciò si aggiunge, quando la scena lo consente, la separazione in regioni: cielo e +/// paesaggio ricevono curve proprie, così una nuvola di passaggio non muove il terreno. /// public static class DeflickerEngine { - private const double TukeyConstant = 4.685; - public static DeflickerCurve Compute(IReadOnlyList stats, DeflickerSettings settings) + => Compute(stats, settings, null, null, null); + + public static DeflickerCurve Compute(IReadOnlyList stats, DeflickerSettings settings, + RegionSettings? regions, HolyGrailAnalysis? holyGrail, + HolyGrailSettings? holyGrailSettings) { int n = stats.Count; var measured = new double[n]; for (int i = 0; i < n; i++) measured[i] = stats[i].Log2Average; - var target = Smooth(measured, settings); - var gainStops = new double[n]; - var channelGain = new double[n][]; + var staircase = holyGrail?.Staircase ?? new double[n]; + var smoothStaircase = holyGrail?.SmoothStaircase ?? new double[n]; + var stepGain = holyGrail?.StepGainStops ?? new double[n]; double limit = Math.Max(0, settings.MaxCorrectionStops); double strength = Math.Clamp(settings.Strength, 0, 1); - double[]? targetR = null, targetG = null, targetB = null; - if (settings.StabilizeColor && n > 0) + var (target, gainStops) = FitSeries(measured, staircase, stepGain, settings, limit, strength); + + // ---- regioni indipendenti + bool useRegions = regions is { Mode: not RegionMode.Off } && n > 0 && HasRegionData(stats); + double[][]? channelGainHigh = null; + double[][]? channelGainLow = null; + double[]? measuredHigh = null, measuredLow = null, targetHigh = null, targetLow = null; + double[]? gainHigh = null, gainLow = null; + + if (useRegions) { - var r = new double[n]; - var g = new double[n]; - var b = new double[n]; + measuredHigh = new double[n]; + measuredLow = new double[n]; for (int i = 0; i < n; i++) { - r[i] = stats[i].Log2AverageR; - g[i] = stats[i].Log2AverageG; - b[i] = stats[i].Log2AverageB; + measuredHigh[i] = stats[i].Log2High; + measuredLow[i] = stats[i].Log2Low; + } + + (targetHigh, gainHigh) = FitSeries(measuredHigh, staircase, stepGain, settings, limit, strength); + (targetLow, gainLow) = FitSeries(measuredLow, staircase, stepGain, settings, limit, strength); + + // L'indipendenza governa quanto ciascuna regione può allontanarsi dalla correzione + // comune: al massimo le due parti dell'immagine vivono di vita propria, al minimo + // si comportano come un fotogramma solo. + double independence = Math.Clamp(regions!.Independence, 0, 1); + for (int i = 0; i < n; i++) + { + gainHigh[i] = gainStops[i] + (gainHigh[i] - gainStops[i]) * independence; + gainLow[i] = gainStops[i] + (gainLow[i] - gainStops[i]) * independence; } - targetR = Smooth(r, settings); - targetG = Smooth(g, settings); - targetB = Smooth(b, settings); } + // ---- deriva cromatica + var colorDelta = ComputeColorDelta(stats, settings, holyGrailSettings, limit, strength); + + var channelGain = BuildChannelGain(gainStops, colorDelta, settings.Enabled); + if (useRegions) + { + channelGainHigh = BuildChannelGain(gainHigh!, colorDelta, settings.Enabled); + channelGainLow = BuildChannelGain(gainLow!, colorDelta, settings.Enabled); + } + + // La curva obiettivo mostrata è la luminanza che il fotogramma avrà davvero: + // la regressione sulla serie senza gradini, più la scalinata resa morbida. for (int i = 0; i < n; i++) { - double delta = settings.Enabled ? Math.Clamp((target[i] - measured[i]) * strength, -limit, limit) : 0; - gainStops[i] = delta; - - if (settings.Enabled && targetR is not null && targetG is not null && targetB is not null) - { - // Il canale verde definisce il livello, gli altri due lo inseguono: si corregge - // la deriva cromatica senza spostare la luminanza complessiva. - double dr = Math.Clamp((targetR[i] - stats[i].Log2AverageR) * strength, -limit, limit); - double dg = Math.Clamp((targetG[i] - stats[i].Log2AverageG) * strength, -limit, limit); - double db = Math.Clamp((targetB[i] - stats[i].Log2AverageB) * strength, -limit, limit); - channelGain[i] = - [ - Math.Pow(2, delta + (dr - dg)), - Math.Pow(2, delta), - Math.Pow(2, delta + (db - dg)), - ]; - } - else - { - double gain = Math.Pow(2, delta); - channelGain[i] = [gain, gain, gain]; - } + target[i] += smoothStaircase[i]; + if (targetHigh is not null) targetHigh[i] += smoothStaircase[i]; + if (targetLow is not null) targetLow[i] += smoothStaircase[i]; } return new DeflickerCurve @@ -140,102 +179,119 @@ public static class DeflickerEngine Target = target, GainStops = gainStops, ChannelGain = channelGain, + ChannelGainHigh = channelGainHigh, + ChannelGainLow = channelGainLow, + MeasuredHigh = measuredHigh, + MeasuredLow = measuredLow, + TargetHigh = targetHigh, + TargetLow = targetLow, + StepGainStops = holyGrail is null ? null : stepGain, }; } + private static bool HasRegionData(IReadOnlyList stats) + { + foreach (var s in stats) + { + if (s.HasRegions) return true; + } + return false; + } + + /// + /// Liscia una serie di luminanza dopo averne tolto i gradini deliberati e restituisce la + /// curva obiettivo insieme al guadagno da applicare. + /// + private static (double[] Target, double[] Gain) FitSeries(double[] measured, double[] staircase, + double[] stepGain, DeflickerSettings settings, + double limit, double strength) + { + int n = measured.Length; + var neutral = new double[n]; + for (int i = 0; i < n; i++) neutral[i] = measured[i] - staircase[i]; + + var target = Smooth(neutral, settings); + var gain = new double[n]; + + for (int i = 0; i < n; i++) + { + if (!settings.Enabled) { gain[i] = 0; continue; } + gain[i] = Math.Clamp((target[i] - neutral[i]) * strength, -limit, limit) + stepGain[i]; + } + + return (target, gain); + } + + /// + /// Correzione cromatica per canale, in stop. Si liscia il rapporto fra i canali invece + /// della loro luminanza assoluta: così la correzione tocca il colore e lascia intatta + /// l'esposizione, che ha già la sua curva. Il verde fa da riferimento perché è il canale + /// che porta quasi tutta la luminanza. + /// + private static double[][]? ComputeColorDelta(IReadOnlyList stats, DeflickerSettings settings, + HolyGrailSettings? holyGrail, double limit, double strength) + { + int n = stats.Count; + if (n == 0) return null; + + bool viaHolyGrail = holyGrail is { Enabled: true, SmoothColor: true }; + if (!viaHolyGrail && !settings.StabilizeColor) return null; + + int window = viaHolyGrail ? holyGrail!.ColorWindowFrames : settings.WindowFrames; + double colorStrength = viaHolyGrail ? Math.Clamp(holyGrail!.ColorStrength, 0, 1) : strength; + double colorLimit = viaHolyGrail ? Math.Max(0, holyGrail!.MaxColorShiftStops) : limit; + + var red = new double[n]; + var blue = new double[n]; + for (int i = 0; i < n; i++) + { + red[i] = stats[i].Log2AverageR - stats[i].Log2AverageG; + blue[i] = stats[i].Log2AverageB - stats[i].Log2AverageG; + } + + var redTarget = Core.LocalRegression.Smooth(red, window, settings.RejectOutliers); + var blueTarget = Core.LocalRegression.Smooth(blue, window, settings.RejectOutliers); + + var delta = new double[n][]; + for (int i = 0; i < n; i++) + { + delta[i] = + [ + Math.Clamp((redTarget[i] - red[i]) * colorStrength, -colorLimit, colorLimit), + 0, + Math.Clamp((blueTarget[i] - blue[i]) * colorStrength, -colorLimit, colorLimit), + ]; + } + return delta; + } + + private static double[][] BuildChannelGain(double[] gainStops, double[][]? colorDelta, bool enabled) + { + int n = gainStops.Length; + var result = new double[n][]; + + for (int i = 0; i < n; i++) + { + if (!enabled) + { + result[i] = [1, 1, 1]; + continue; + } + + double gain = gainStops[i]; + result[i] = colorDelta is null + ? [Math.Pow(2, gain), Math.Pow(2, gain), Math.Pow(2, gain)] + : + [ + Math.Pow(2, gain + colorDelta[i][0]), + Math.Pow(2, gain + colorDelta[i][1]), + Math.Pow(2, gain + colorDelta[i][2]), + ]; + } + return result; + } + /// Regressione lineare locale pesata, con seconda passata robusta agli outlier. public static double[] Smooth(double[] series, DeflickerSettings settings) - { - int n = series.Length; - var result = new double[n]; - if (n == 0) return result; - if (n <= 2) - { - Array.Copy(series, result, n); - return result; - } - - int radius = Math.Clamp((Math.Max(3, settings.WindowFrames) - 1) / 2, 1, Math.Max(1, n - 1)); - double sigma = Math.Max(radius / 2.0, 0.5); - - var robust = new double[n]; - Array.Fill(robust, 1.0); - - FitAll(series, result, radius, sigma, robust); - - if (settings.RejectOutliers) - { - UpdateRobustWeights(series, result, robust); - FitAll(series, result, radius, sigma, robust); - } - - return result; - } - - private static void FitAll(double[] y, double[] output, int radius, double sigma, double[] robust) - { - int n = y.Length; - double twoSigmaSq = 2.0 * sigma * sigma; - - for (int i = 0; i < n; i++) - { - int from = Math.Max(0, i - radius); - int to = Math.Min(n - 1, i + radius); - - // 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]; - } - - if (sw <= 1e-9) - { - output[i] = y[i]; - continue; - } - - double det = sw * swt2 - swt * swt; - if (Math.Abs(det) < 1e-12) - { - output[i] = swy / sw; // finestra degenere: media pesata - continue; - } - - double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0 - output[i] = a; - } - } - - private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust) - { - int n = y.Length; - var residuals = new double[n]; - for (int i = 0; i < n; i++) residuals[i] = Math.Abs(y[i] - fit[i]); - - var sorted = (double[])residuals.Clone(); - Array.Sort(sorted); - double mad = sorted[n / 2]; - double scale = 1.4826 * mad; - - // Se la sequenza è già pulita non si scarta nulla: evita di amplificare il rumore numerico. - if (scale < 1e-4) - { - Array.Fill(robust, 1.0); - return; - } - - for (int i = 0; i < n; i++) - { - double u = residuals[i] / (TukeyConstant * scale); - robust[i] = u >= 1.0 ? 0.0 : Math.Pow(1.0 - u * u, 2.0); - } - } + => Core.LocalRegression.Smooth(series, settings.WindowFrames, settings.RejectOutliers); } diff --git a/Titano/Analysis/ExposureProcessor.cs b/Titano/Analysis/ExposureProcessor.cs index 26304ac..b2a4427 100644 --- a/Titano/Analysis/ExposureProcessor.cs +++ b/Titano/Analysis/ExposureProcessor.cs @@ -39,6 +39,71 @@ public static class ExposureProcessor return MeasureClipping(data, count); } + /// + /// Applica due terne di guadagni miscelate secondo la maschera delle regioni. + /// + /// La miscela avviene in scala logaritmica: fra un guadagno di 1× e uno di 4×, il valore + /// intermedio è 2×, non 2,5×. È l'unica interpolazione che produce una transizione + /// visivamente uniforme lungo la sfumatura fra cielo e paesaggio; in scala lineare il + /// passaggio si addenserebbe verso il guadagno più alto e si vedrebbe come una banda. + /// + public static double ApplyRegional(ImageBuffer frame, RegionMask mask, + ReadOnlySpan gainHigh, ReadOnlySpan gainLow, + bool protectHighlights, double knee) + { + Span logHigh = stackalloc float[ImageBuffer.Channels]; + Span logLow = stackalloc float[ImageBuffer.Channels]; + float maxGain = 0; + + for (int c = 0; c < ImageBuffer.Channels; c++) + { + logHigh[c] = (float)Math.Log2(Math.Max(gainHigh[c], 1e-6)); + logLow[c] = (float)Math.Log2(Math.Max(gainLow[c], 1e-6)); + maxGain = Math.Max(maxGain, (float)Math.Max(gainHigh[c], gainLow[c])); + } + + bool rolloff = protectHighlights && maxGain > 1.0001f; + float kneeValue = (float)Math.Clamp(knee, 0.05, 0.98); + float span = 1f - kneeValue; + + int width = frame.Width; + int height = frame.Height; + var data = frame.Data; + float invWidth = width > 1 ? 1f / (width - 1) : 0f; + float invHeight = height > 1 ? 1f / (height - 1) : 0f; + + // Il compilatore non lascia usare uno Span dentro una lambda: le tre coppie di + // esponenti passano quindi per variabili locali, che è anche la forma più veloce. + float hR = logHigh[0], hG = logHigh[1], hB = logHigh[2]; + float lR = logLow[0], lG = logLow[1], lB = logLow[2]; + + Parallel.For(0, height, y => + { + int rowBase = y * width * ImageBuffer.Channels; + float v = y * invHeight; + + for (int x = 0; x < width; x++) + { + float weight = mask.Sample(x * invWidth, v); + float gr = Exp2(lR + (hR - lR) * weight); + float gg = Exp2(lG + (hG - lG) * weight); + float gb = Exp2(lB + (hB - lB) * weight); + + int index = rowBase + x * ImageBuffer.Channels; + data[index] = Compress(data[index] * gr, rolloff, kneeValue, span); + data[index + 1] = Compress(data[index + 1] * gg, rolloff, kneeValue, span); + data[index + 2] = Compress(data[index + 2] * gb, rolloff, kneeValue, span); + } + }); + + return MeasureClipping(data, frame.SampleCount); + } + + private static float Exp2(float value) => MathF.Pow(2f, value); + + private static float Compress(float value, bool rolloff, float knee, float span) + => !rolloff || value <= knee ? value : knee + span * MathF.Tanh((value - knee) / span); + private static void ApplyLinear(float[] data, int count, float gr, float gg, float gb) { int width = Vector.Count; diff --git a/Titano/Analysis/HolyGrailEngine.cs b/Titano/Analysis/HolyGrailEngine.cs new file mode 100644 index 0000000..160171d --- /dev/null +++ b/Titano/Analysis/HolyGrailEngine.cs @@ -0,0 +1,285 @@ +using Titano.Core; +using Titano.Metadata; + +namespace Titano.Analysis; + +/// Parametri delle transizioni giorno-notte. +public sealed class HolyGrailSettings +{ + public bool Enabled { get; set; } + + /// + /// Ricava i cambi di impostazione dai metadati invece che dalla sola luminanza misurata. + /// Quando tempo, diaframma e ISO sono leggibili, il salto è noto in modo esatto. + /// + public bool UseMetadata { get; set; } = true; + + /// Variazione di esposizione oltre la quale si parla di cambio di impostazione, in stop. + public double StepThresholdStops { get; set; } = 0.15; + + /// Su quanti fotogrammi viene distribuito un salto. + public int TransitionFrames { get; set; } = 48; + + /// Livella anche la deriva del bilanciamento del bianco. + public bool SmoothColor { get; set; } = true; + + /// Finestra della lisciatura cromatica: più lunga della finestra del deflicker. + public int ColorWindowFrames { get; set; } = 61; + + public double ColorStrength { get; set; } = 0.8; + + /// Limite dello spostamento cromatico per canale, in stop. + public double MaxColorShiftStops { get; set; } = 0.6; + + public HolyGrailSettings Clone() => (HolyGrailSettings)MemberwiseClone(); +} + +/// Esito dell'analisi delle transizioni: gradini individuati e correzione che li ammorbidisce. +public sealed class HolyGrailAnalysis +{ + /// Valore di esposizione da metadati; NaN dove non ricostruibile. + public required double[] ExposureValue { get; init; } + + /// Scalinata grezza presente nella luminanza misurata, in stop cumulativi. + public required double[] Staircase { get; init; } + + /// La stessa scalinata con ogni gradino spalmato sulla transizione. + public required double[] SmoothStaircase { get; init; } + + /// Correzione da aggiungere: porta la scalinata grezza in quella morbida. + public required double[] StepGainStops { get; init; } + + /// Indici dei fotogrammi in cui l'impostazione è cambiata. + public required int[] StepFrames { get; init; } + + /// Temperatura di colore misurata su ciascun fotogramma. + public required double[] TemperatureKelvin { get; init; } + + /// Deviazione verde-magenta misurata, in stop. + public required double[] TintStops { get; init; } + + public bool MetadataUsable { get; init; } + public double LargestStepStops { get; init; } + public int StepCount => StepFrames.Length; + + public static HolyGrailAnalysis Empty(int count) => new() + { + ExposureValue = Filled(count, double.NaN), + Staircase = new double[count], + SmoothStaircase = new double[count], + StepGainStops = new double[count], + StepFrames = [], + TemperatureKelvin = Filled(count, double.NaN), + TintStops = new double[count], + }; + + private static double[] Filled(int count, double value) + { + var array = new double[count]; + Array.Fill(array, value); + return array; + } +} + +/// +/// Gestione delle transizioni giorno-notte. +/// +/// In una ripresa che attraversa il tramonto la luce cala di una quindicina di stop, molto +/// più di quanto una singola esposizione possa coprire: la macchina deve cambiare tempo, ISO +/// o diaframma lungo la strada. Ogni cambio è quantizzato — un terzo di stop, spesso uno +/// intero — e nel filmato finito si vede come uno scalino netto della luminosità, che stona +/// tanto più quanto il resto della sequenza è liscio. +/// +/// Il deflicker ordinario non basta: un gradino non è rumore da mediare, è un evento reale, +/// e la finestra mobile lo trasforma in una rampa lunga quanto la finestra, con due spigoli +/// alle estremità. La correzione giusta è un'altra: riconoscere il gradino, misurarne +/// l'ampiezza esatta e ridistribuirlo su una transizione lunga a piacere, con una curva a +/// derivata nulla agli estremi che non lascia spigoli da nessuna parte. +/// +/// L'ampiezza si legge nei metadati, dove il salto è noto senza incertezza: il valore di +/// esposizione vale log2(N²/t) − log2(ISO/100), e la sua variazione fra due scatti è +/// esattamente ciò che la macchina ha deciso di cambiare. Quando i metadati non bastano si +/// ripiega sulla luminanza misurata, cercando i salti che si staccano dal rumore locale. +/// +public static class HolyGrailEngine +{ + public static HolyGrailAnalysis Analyze(IReadOnlyList stats, + IReadOnlyList metadata, + HolyGrailSettings settings) + { + int n = stats.Count; + if (n == 0) return HolyGrailAnalysis.Empty(0); + + var exposureValue = new double[n]; + int usable = 0; + for (int i = 0; i < n; i++) + { + exposureValue[i] = i < metadata.Count ? ExposureValue(metadata[i]) : double.NaN; + if (!double.IsNaN(exposureValue[i])) usable++; + } + + var temperature = new double[n]; + var tint = new double[n]; + for (int i = 0; i < n; i++) + { + double r = Math.Pow(2, stats[i].Log2AverageR); + double g = Math.Pow(2, stats[i].Log2AverageG); + double b = Math.Pow(2, stats[i].Log2AverageB); + temperature[i] = ColorScience.CorrelatedColorTemperature(r, g, b); + tint[i] = ColorScience.GreenTintStops(stats[i].Log2AverageR, stats[i].Log2AverageG, + stats[i].Log2AverageB); + } + + if (!settings.Enabled || n < 3) + { + var idle = HolyGrailAnalysis.Empty(n); + return new HolyGrailAnalysis + { + ExposureValue = exposureValue, + Staircase = idle.Staircase, + SmoothStaircase = idle.SmoothStaircase, + StepGainStops = idle.StepGainStops, + StepFrames = [], + TemperatureKelvin = temperature, + TintStops = tint, + MetadataUsable = usable > n / 2, + }; + } + + double threshold = Math.Max(0.02, settings.StepThresholdStops); + bool metadataUsable = settings.UseMetadata && usable > n / 2; + + var jumps = metadataUsable + ? StepsFromMetadata(exposureValue, threshold) + : StepsFromLuminance(stats, threshold); + + var staircase = new double[n]; + var smooth = new double[n]; + var stepGain = new double[n]; + var stepFrames = new List(); + double largest = 0; + + double running = 0; + for (int i = 0; i < n; i++) + { + running += jumps[i]; + staircase[i] = running; + if (Math.Abs(jumps[i]) > 1e-9) + { + stepFrames.Add(i); + largest = Math.Max(largest, Math.Abs(jumps[i])); + } + } + + // Ogni gradino viene sostituito dalla propria transizione, centrata sull'istante in + // cui è avvenuto: la somma delle transizioni ricostruisce la stessa scalinata, ma + // percorsa senza scatti. + double transition = Math.Max(2, settings.TransitionFrames); + foreach (int step in stepFrames) + { + double amplitude = jumps[step]; + double centre = step - 0.5; + for (int i = 0; i < n; i++) + { + double t = (i - centre + transition * 0.5) / transition; + smooth[i] += amplitude * Spline.SmoothStep(Math.Clamp(t, 0, 1)); + } + } + + for (int i = 0; i < n; i++) stepGain[i] = smooth[i] - staircase[i]; + + return new HolyGrailAnalysis + { + ExposureValue = exposureValue, + Staircase = staircase, + SmoothStaircase = smooth, + StepGainStops = stepGain, + StepFrames = [.. stepFrames], + TemperatureKelvin = temperature, + TintStops = tint, + MetadataUsable = metadataUsable, + LargestStepStops = largest, + }; + } + + /// + /// Valore di esposizione dello scatto. Diaframma e sensibilità mancanti si considerano + /// costanti — quasi sempre lo sono in un time-lapse — mentre senza il tempo di posa il + /// dato non è ricostruibile e va dichiarato ignoto. + /// + public static double ExposureValue(FrameMetadata metadata) + { + if (metadata.ExposureSeconds is not { } exposure || exposure <= 0) return double.NaN; + + double ev = -Math.Log2(exposure); + if (metadata.FNumber is { } aperture && aperture > 0) ev += 2 * Math.Log2(aperture); + if (metadata.Iso is { } iso && iso > 0) ev -= Math.Log2(iso / 100.0); + return ev; + } + + /// + /// Salti dedotti dai metadati. Un aumento del valore di esposizione scurisce il + /// fotogramma, quindi il gradino che compare nella luminanza misurata ha segno opposto. + /// + private static double[] StepsFromMetadata(double[] exposureValue, double threshold) + { + int n = exposureValue.Length; + var jumps = new double[n]; + double previous = double.NaN; + + for (int i = 0; i < n; i++) + { + double current = exposureValue[i]; + if (double.IsNaN(current)) continue; + if (!double.IsNaN(previous)) + { + double delta = current - previous; + if (Math.Abs(delta) > threshold) jumps[i] = -delta; + } + previous = current; + } + return jumps; + } + + /// + /// Salti dedotti dalla sola luminanza, quando i metadati non aiutano. + /// + /// Il criterio non può essere la sola ampiezza, altrimenti una rampa ripida di tramonto + /// verrebbe scambiata per una serie di gradini. Si confronta invece ogni differenza con + /// la mediana delle differenze vicine: un cambio di impostazione si stacca dal fondo, + /// una rampa no, perché lì tutte le differenze si somigliano. + /// + private static double[] StepsFromLuminance(IReadOnlyList stats, double threshold) + { + int n = stats.Count; + var jumps = new double[n]; + if (n < 5) return jumps; + + var delta = new double[n]; + for (int i = 1; i < n; i++) delta[i] = stats[i].Log2Average - stats[i - 1].Log2Average; + + const int radius = 6; + Span window = stackalloc double[2 * radius + 1]; + + for (int i = 1; i < n; i++) + { + if (Math.Abs(delta[i]) <= threshold) continue; + + int count = 0; + for (int j = i - radius; j <= i + radius; j++) + { + if (j <= 0 || j >= n || j == i) continue; + window[count++] = Math.Abs(delta[j]); + } + if (count == 0) continue; + + var slice = window[..count]; + slice.Sort(); + double background = slice[count / 2]; + + if (Math.Abs(delta[i]) > Math.Max(threshold, 3.0 * background)) jumps[i] = delta[i]; + } + + return jumps; + } +} diff --git a/Titano/Analysis/LuminanceAnalyzer.cs b/Titano/Analysis/LuminanceAnalyzer.cs index d709d7c..77840eb 100644 --- a/Titano/Analysis/LuminanceAnalyzer.cs +++ b/Titano/Analysis/LuminanceAnalyzer.cs @@ -25,7 +25,16 @@ public readonly struct LuminanceStats public double BlackFraction { get; init; } - public static LuminanceStats Empty => new() { Log2Average = -8 }; + /// Media logaritmica della prima regione (il cielo, nella separazione per orizzonte). + public double Log2High { get; init; } + + /// Media logaritmica della seconda regione. + public double Log2Low { get; init; } + + /// Vero se la misura per regioni è stata effettivamente calcolata su questo fotogramma. + public bool HasRegions { get; init; } + + public static LuminanceStats Empty => new() { Log2Average = -8, Log2High = -8, Log2Low = -8 }; } /// @@ -34,6 +43,10 @@ public readonly struct LuminanceStats /// La stima usa la media logaritmica (media geometrica) su un campionamento a griglia fissa, /// con esclusione delle code della distribuzione: è la metrica che segue la variazione di /// esposizione reale senza farsi trascinare da cieli bruciati o ombre chiuse. +/// +/// Con una maschera di regioni la stessa passata riempie tre istogrammi invece di uno: +/// l'intero fotogramma e le due regioni separate. Costa quanto la passata semplice, perché +/// il prezzo vero è la lettura dei pixel, non l'incremento di un contatore. /// public static class LuminanceAnalyzer { @@ -46,15 +59,27 @@ public static class LuminanceAnalyzer private const int TargetSamples = 262_144; public static LuminanceStats Analyze(ImageBuffer frame, double trimLow = 0.02, double trimHigh = 0.02) + => Analyze(frame, null, trimLow, trimHigh); + + public static LuminanceStats Analyze(ImageBuffer frame, RegionMask? mask, + double trimLow = 0.02, double trimHigh = 0.02) { int step = ComputeStep(frame.PixelCount); Span histogram = stackalloc int[Bins]; + Span histogramHigh = stackalloc int[Bins]; + Span histogramLow = stackalloc int[Bins]; histogram.Clear(); + histogramHigh.Clear(); + histogramLow.Clear(); double sumR = 0, sumG = 0, sumB = 0; int samples = 0, clipped = 0, black = 0; + int samplesHigh = 0, samplesLow = 0; var data = frame.Data; int totalPixels = frame.PixelCount; + int width = frame.Width; + float invWidth = frame.Width > 1 ? 1f / (frame.Width - 1) : 0f; + float invHeight = frame.Height > 1 ? 1f / (frame.Height - 1) : 0f; for (int p = 0; p < totalPixels; p += step) { @@ -72,22 +97,66 @@ public static class LuminanceAnalyzer sumB += Log2Safe(b + Epsilon); double l = Log2Safe(luma + Epsilon); - int bin = (int)((l - LogMin) / (LogMax - LogMin) * (Bins - 1) + 0.5); - histogram[Math.Clamp(bin, 0, Bins - 1)]++; + int bin = Math.Clamp((int)((l - LogMin) / (LogMax - LogMin) * (Bins - 1) + 0.5), 0, Bins - 1); + histogram[bin]++; samples++; + + if (mask is null) continue; + + // Attribuzione netta alla regione dominante: per la misura serve sapere di quale + // regione un pixel racconta la luce, non in che proporzione. La sfumatura conta + // quando la correzione viene applicata, non quando viene calcolata. + float weight = mask.Sample((p % width) * invWidth, (p / width) * invHeight); + if (weight >= 0.5f) { histogramHigh[bin]++; samplesHigh++; } + else { histogramLow[bin]++; samplesLow++; } } if (samples == 0) return LuminanceStats.Empty; - // Media troncata calcolata direttamente sull'istogramma: le code (cielo bruciato, - // ombre chiuse) non influenzano la stima dell'esposizione media. + double average = TrimmedMean(histogram, samples, trimLow, trimHigh, + out double p01, out double p50, out double p99); + + // Una regione con pochissimi campioni non produce una stima affidabile: eredita + // quella globale, così la sua curva resta agganciata al resto dell'immagine. + bool regionsUsable = mask is not null && samplesHigh > 64 && samplesLow > 64; + double log2High = regionsUsable + ? TrimmedMean(histogramHigh, samplesHigh, trimLow, trimHigh, out _, out _, out _) + : average; + double log2Low = regionsUsable + ? TrimmedMean(histogramLow, samplesLow, trimLow, trimHigh, out _, out _, out _) + : average; + + return new LuminanceStats + { + Log2Average = average, + Log2AverageR = sumR / samples, + Log2AverageG = sumG / samples, + Log2AverageB = sumB / samples, + Percentile01 = Math.Pow(2.0, p01), + Percentile50 = Math.Pow(2.0, p50), + Percentile99 = Math.Pow(2.0, p99), + ClippedFraction = (double)clipped / samples, + BlackFraction = (double)black / samples, + Log2High = log2High, + Log2Low = log2Low, + HasRegions = regionsUsable, + }; + } + + /// + /// Media troncata calcolata direttamente sull'istogramma: le code (cielo bruciato, ombre + /// chiuse) non influenzano la stima dell'esposizione media. + /// + private static double TrimmedMean(ReadOnlySpan histogram, int samples, double trimLow, double trimHigh, + out double p01, out double p50, out double p99) + { int lowCut = (int)(samples * trimLow); int highCut = (int)(samples * (1.0 - trimHigh)); double weighted = 0; long counted = 0; int running = 0; - double p01 = LogMin, p50 = LogMin, p99 = LogMin; + p01 = LogMin; p50 = LogMin; p99 = LogMin; int q01 = (int)(samples * 0.01), q50 = samples / 2, q99 = (int)(samples * 0.99); bool got01 = false, got50 = false, got99 = false; @@ -113,25 +182,8 @@ public static class LuminanceAnalyzer } } - if (counted == 0) - { - // Distribuzione degenere (immagine uniforme): ricadiamo sulla mediana. - weighted = p50; - counted = 1; - } - - return new LuminanceStats - { - Log2Average = weighted / counted, - Log2AverageR = sumR / samples, - Log2AverageG = sumG / samples, - Log2AverageB = sumB / samples, - Percentile01 = Math.Pow(2.0, p01), - Percentile50 = Math.Pow(2.0, p50), - Percentile99 = Math.Pow(2.0, p99), - ClippedFraction = (double)clipped / samples, - BlackFraction = (double)black / samples, - }; + // Distribuzione degenere (immagine uniforme): ricadiamo sulla mediana. + return counted == 0 ? p50 : weighted / counted; } /// Passo di campionamento a griglia: deterministico, quindi identico per ogni fotogramma. diff --git a/Titano/Analysis/RegionSegmenter.cs b/Titano/Analysis/RegionSegmenter.cs new file mode 100644 index 0000000..7690b0f --- /dev/null +++ b/Titano/Analysis/RegionSegmenter.cs @@ -0,0 +1,426 @@ +using Titano.Imaging; + +namespace Titano.Analysis; + +/// Criterio con cui il fotogramma viene diviso in regioni indipendenti. +public enum RegionMode +{ + /// Una sola regione: il deflicker lavora sull'intero fotogramma. + Off, + + /// Ricerca della linea d'orizzonte: separa il cielo dal paesaggio. + SkyGround, + + /// Separazione per sola luminanza, senza vincolo di forma. + Luminance, +} + +/// Parametri della segmentazione e del deflicker per regioni. +public sealed class RegionSettings +{ + public RegionMode Mode { get; set; } = RegionMode.Off; + + /// Fotogrammi campionati per costruire la maschera, distribuiti su tutta la sequenza. + public int SampleFrames { get; set; } = 16; + + /// Larghezza a cui viene costruita la maschera: la forma delle regioni è grossolana per natura. + public int AnalysisWidth { get; set; } = 320; + + /// Ampiezza della sfumatura fra le due regioni, in frazione dell'altezza. + public double Feather { get; set; } = 0.05; + + /// + /// Quanto le due curve restano indipendenti: a 0 coincidono con la curva globale, + /// a 1 ciascuna regione insegue solo la propria luminanza. + /// + public double Independence { get; set; } = 0.75; + + public RegionSettings Clone() => (RegionSettings)MemberwiseClone(); +} + +/// +/// Maschera morbida di appartenenza: 1 dove vale la prima regione (il cielo, nella +/// separazione per orizzonte), 0 dove vale la seconda, con una transizione continua in mezzo. +/// I valori intermedi non sono un difetto da eliminare: sono ciò che evita di vedere il +/// confine fra le due correzioni come una linea netta in mezzo all'immagine. +/// +public sealed class RegionMask +{ + public required int Width { get; init; } + public required int Height { get; init; } + public required float[] Weight { get; init; } + + /// Frazione dell'immagine attribuita alla prima regione. + public double Coverage { get; init; } + + /// Luminanza media delle due regioni, in log2: serve a descriverle in interfaccia. + public double Log2High { get; init; } + public double Log2Low { get; init; } + + public required string Description { get; init; } + + /// Peso della prima regione in coordinate normalizzate 0..1. + public float Sample(float u, float v) + { + float x = Math.Clamp(u, 0f, 1f) * (Width - 1); + float y = Math.Clamp(v, 0f, 1f) * (Height - 1); + + int x0 = (int)x, y0 = (int)y; + int x1 = Math.Min(x0 + 1, Width - 1); + int y1 = Math.Min(y0 + 1, Height - 1); + float fx = x - x0, fy = y - y0; + + float top = Weight[y0 * Width + x0] * (1 - fx) + Weight[y0 * Width + x1] * fx; + float bottom = Weight[y1 * Width + x0] * (1 - fx) + Weight[y1 * Width + x1] * fx; + return top + (bottom - top) * fy; + } +} + +/// +/// Segmentazione automatica del fotogramma in due regioni, dall'istogramma e dal gradiente. +/// +/// Il problema che risolve è concreto: una nuvola densa che attraversa il cielo abbassa la +/// luminanza media del fotogramma, il deflicker legge un calo di luce e schiarisce tutto — +/// compreso il paesaggio, che non è cambiato affatto. Il risultato è un paesaggio che +/// respira al passaggio di ogni nuvola. Misurando cielo e terra separatamente ciascuna +/// regione riceve la propria correzione e il paesaggio resta fermo. +/// +/// La maschera si costruisce una volta sola, dalla mediana temporale di un campione di +/// fotogrammi: la mediana toglie di mezzo proprio le nuvole e tutto ciò che passa e non +/// resta, lasciando la struttura permanente della scena. È il motivo per cui la maschera +/// non sfarfalla da un fotogramma all'altro. +/// +public static class RegionSegmenter +{ + /// + /// Costruisce la maschera campionando la sequenza. Restituisce null se la scena non si + /// lascia dividere in modo sensato — un muro uniforme, un cielo che occupa tutto — nel + /// qual caso il deflicker globale è la scelta giusta e non c'è nulla da correggere. + /// + public static RegionMask? Build(IReadOnlyList paths, int orientation, RegionSettings settings, + CancellationToken cancellation = default) + { + if (settings.Mode == RegionMode.Off || paths.Count == 0) return null; + + int width = Math.Clamp(settings.AnalysisWidth, 96, 1024); + var samples = CollectSamples(paths, orientation, width, settings.SampleFrames, cancellation); + if (samples.Count == 0) return null; + + int height = samples[0].Height; + var median = TemporalMedian(samples, width, height); + + double threshold = OtsuThreshold(median); + var mask = settings.Mode == RegionMode.SkyGround + ? BuildFromHorizon(median, width, height, threshold, settings) + : BuildFromLuminance(median, width, height, threshold, settings); + + double coverage = 0; + double sumHigh = 0, sumLow = 0; + double weightHigh = 0, weightLow = 0; + for (int i = 0; i < mask.Length; i++) + { + coverage += mask[i]; + sumHigh += median[i] * mask[i]; + weightHigh += mask[i]; + sumLow += median[i] * (1 - mask[i]); + weightLow += 1 - mask[i]; + } + coverage /= mask.Length; + + // Una divisione che lascia una delle due parti quasi vuota non porta informazione: + // meglio dichiararlo e lasciare al deflicker globale il suo lavoro. + if (coverage is < 0.04 or > 0.96) return null; + + 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, + Height = height, + Weight = mask, + Coverage = coverage, + Log2High = log2High, + Log2Low = log2Low, + Description = description, + }; + } + + // ------------------------------------------------------------------ campionamento + + private static List CollectSamples(IReadOnlyList paths, int orientation, int width, + int requested, CancellationToken cancellation) + { + int count = Math.Clamp(requested, 3, Math.Min(64, paths.Count)); + var chosen = new string[count]; + for (int i = 0; i < count; i++) + { + int index = count == 1 ? 0 : (int)Math.Round(i * (paths.Count - 1.0) / (count - 1)); + chosen[i] = paths[Math.Clamp(index, 0, paths.Count - 1)]; + } + + var results = new GrayPlane?[count]; + var pool = new FrameBufferPool(6); + + Parallel.For(0, count, new ParallelOptions + { + CancellationToken = cancellation, + MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount / 2, 1, 8), + }, i => + { + try + { + var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(chosen[i], orientation); + if (probeWidth <= 0 || probeHeight <= 0) return; + + int height = Math.Max(2, (int)Math.Round(width * probeHeight / (double)probeWidth)); + using var buffer = ImageDecoder.Decode(chosen[i], width, height, orientation, pool); + + var plane = new GrayPlane(width, height); + var data = buffer.Data; + 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]); + plane.Data[p] = (float)Math.Log2(Math.Max(luma, 1.0 / 65536.0)); + } + results[i] = plane; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Un campione illeggibile non compromette la maschera: ne restano altri. + } + }); + + var samples = new List(count); + foreach (var plane in results) + { + if (plane is not null && (samples.Count == 0 || plane.Height == samples[0].Height)) samples.Add(plane); + } + return samples; + } + + private sealed class GrayPlane(int width, int height) + { + public int Width { get; } = width; + public int Height { get; } = height; + public float[] Data { get; } = new float[width * height]; + } + + /// Mediana temporale pixel per pixel: elimina ciò che passa e non appartiene alla scena. + private static float[] TemporalMedian(List samples, int width, int height) + { + var median = new float[width * height]; + int count = samples.Count; + + Parallel.For(0, height, y => + { + Span values = stackalloc float[64]; + for (int x = 0; x < width; x++) + { + int index = y * width + x; + for (int s = 0; s < count; s++) values[s] = samples[s].Data[index]; + var slice = values[..count]; + slice.Sort(); + median[index] = slice[count / 2]; + } + }); + + return median; + } + + // ------------------------------------------------------------------ soglia e maschere + + /// + /// Soglia di Otsu: quella che minimizza la varianza interna alle due classi. Applicata + /// alla luminanza logaritmica, separa naturalmente cielo e terra, che in un paesaggio + /// distano quasi sempre più di due stop. + /// + private static double OtsuThreshold(float[] values) + { + const int bins = 256; + float min = float.MaxValue, max = float.MinValue; + foreach (float v in values) + { + if (v < min) min = v; + if (v > max) max = v; + } + if (max - min < 1e-4f) return min; + + Span histogram = stackalloc int[bins]; + histogram.Clear(); + double scale = (bins - 1) / (max - min); + foreach (float v in values) histogram[Math.Clamp((int)((v - min) * scale + 0.5), 0, bins - 1)]++; + + double total = values.Length; + double sum = 0; + for (int i = 0; i < bins; i++) sum += i * (double)histogram[i]; + + double sumBackground = 0, weightBackground = 0, bestVariance = -1; + int bestBin = bins / 2; + + for (int i = 0; i < bins; i++) + { + weightBackground += histogram[i]; + if (weightBackground <= 0) continue; + double weightForeground = total - weightBackground; + if (weightForeground <= 0) break; + + sumBackground += i * (double)histogram[i]; + double meanBackground = sumBackground / weightBackground; + double meanForeground = (sum - sumBackground) / weightForeground; + double between = weightBackground * weightForeground * + (meanBackground - meanForeground) * (meanBackground - meanForeground); + + if (between <= bestVariance) continue; + bestVariance = between; + bestBin = i; + } + + return min + bestBin / scale; + } + + /// + /// 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. + /// + private static float[] BuildFromHorizon(float[] median, int width, int height, double threshold, + RegionSettings settings) + { + 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); + double bestGradient = -1; + int bestRow = guess; + + for (int y = from; y <= to; y++) + { + double gradient = Math.Abs(median[(y + 1) * width + x] - median[(y - 1) * width + x]); + if (gradient <= bestGradient) continue; + bestGradient = gradient; + bestRow = y; + } + + boundary[x] = bestRow; + } + + boundary = SmoothProfile(boundary, Math.Max(3, width / 12)); + + var mask = new float[width * height]; + double feather = Math.Max(1.0, settings.Feather * height); + + Parallel.For(0, height, y => + { + for (int x = 0; x < width; x++) + { + double distance = (boundary[x] - y) / feather; + mask[y * width + x] = (float)Core.Spline.SmoothStep(Math.Clamp(distance * 0.5 + 0.5, 0, 1)); + } + }); + + return mask; + } + + /// Maschera per sola luminanza, poi sfumata: nessun vincolo sulla forma delle regioni. + private static float[] BuildFromLuminance(float[] median, int width, int height, double threshold, + RegionSettings settings) + { + var mask = new float[width * height]; + double feather = Math.Max(0.15, settings.Feather * 12); + + for (int i = 0; i < mask.Length; i++) + { + double distance = (median[i] - threshold) / feather; + mask[i] = (float)Core.Spline.SmoothStep(Math.Clamp(distance * 0.5 + 0.5, 0, 1)); + } + + int radius = Math.Max(1, (int)Math.Round(settings.Feather * height)); + BoxBlur(mask, width, height, radius); + BoxBlur(mask, width, height, radius); + return mask; + } + + /// Media mobile su una serie monodimensionale, con estensione dei bordi. + private static double[] SmoothProfile(double[] series, int radius) + { + int n = series.Length; + var result = new double[n]; + for (int i = 0; i < n; i++) + { + double sum = 0; + int count = 0; + for (int j = i - radius; j <= i + radius; j++) + { + sum += series[Math.Clamp(j, 0, n - 1)]; + count++; + } + result[i] = sum / count; + } + return result; + } + + /// Sfocatura a media d'area separabile: due passate approssimano già bene una gaussiana. + private static void BoxBlur(float[] data, int width, int height, int radius) + { + if (radius < 1) return; + var scratch = new float[data.Length]; + + Parallel.For(0, height, y => + { + int rowBase = y * width; + for (int x = 0; x < width; x++) + { + float sum = 0; + int count = 0; + for (int k = x - radius; k <= x + radius; k++) + { + sum += data[rowBase + Math.Clamp(k, 0, width - 1)]; + count++; + } + scratch[rowBase + x] = sum / count; + } + }); + + Parallel.For(0, width, x => + { + for (int y = 0; y < height; y++) + { + float sum = 0; + int count = 0; + for (int k = y - radius; k <= y + radius; k++) + { + sum += scratch[Math.Clamp(k, 0, height - 1) * width + x]; + count++; + } + data[y * width + x] = sum / count; + } + }); + } +} diff --git a/Titano/Core/FrameRecord.cs b/Titano/Core/FrameRecord.cs index b7f8488..016d4a4 100644 --- a/Titano/Core/FrameRecord.cs +++ b/Titano/Core/FrameRecord.cs @@ -57,6 +57,19 @@ public sealed class FrameRecord(int index, FrameMetadata metadata) /// Durata del fotogramma nel video finale, in unità di timescale (playback adattivo). public int OutputDurationUnits { get; internal set; } + // ---- stabilizzazione e colore ------------------------------------------ + /// Correzione di stabilizzazione applicata, in pixel della risoluzione sorgente. + public double StabilizationShift { get; internal set; } + + /// Rotazione residua compensata, in gradi. + public double StabilizationRotation { get; internal set; } + + /// Temperatura di colore correlata misurata sul fotogramma, in kelvin. + public double TemperatureKelvin { get; internal set; } = double.NaN; + + /// Vero se in questo fotogramma la macchina ha cambiato tempo, diaframma o sensibilità. + public bool IsExposureStep { get; internal set; } + public string CadenceText => IntervalSeconds <= 0 ? "—" : IntervalSeconds < 1 diff --git a/Titano/Core/LocalRegression.cs b/Titano/Core/LocalRegression.cs new file mode 100644 index 0000000..1b86128 --- /dev/null +++ b/Titano/Core/LocalRegression.cs @@ -0,0 +1,120 @@ +namespace Titano.Core; + +/// +/// Regressione lineare locale pesata, con seconda passata robusta agli scarti anomali. +/// +/// È il nucleo numerico condiviso da tutto ciò che, nel motore, deve distinguere una +/// variazione reale e lenta da un disturbo veloce: l'esposizione che sfarfalla sopra una +/// rampa di tramonto, la deriva del bilanciamento del bianco, il tremolio del treppiede +/// sopra una panoramica voluta. In tutti questi casi il segnale utile è la componente +/// liscia e il disturbo è quella ad alta frequenza. +/// +/// Rispetto a una media mobile, la componente lineare della regressione segue senza ritardo +/// le rampe: una media mobile su una salita costante restituisce sempre un valore vecchio di +/// mezza finestra, e la correzione risultante spegnerebbe proprio il fenomeno da preservare. +/// +public static class LocalRegression +{ + /// Costante del peso di Tukey: oltre 4.685 deviazioni il campione non pesa più nulla. + private const double TukeyConstant = 4.685; + + /// + /// Lisciatura della serie su una finestra mobile di campioni. + /// Con si esegue una seconda passata in cui i campioni + /// lontani dalla prima stima vengono neutralizzati. + /// + public static double[] Smooth(double[] series, int windowFrames, bool rejectOutliers) + { + int n = series.Length; + var result = new double[n]; + if (n == 0) return result; + if (n <= 2) + { + Array.Copy(series, result, n); + return result; + } + + int radius = Math.Clamp((Math.Max(3, windowFrames) - 1) / 2, 1, Math.Max(1, n - 1)); + double sigma = Math.Max(radius / 2.0, 0.5); + + var robust = new double[n]; + Array.Fill(robust, 1.0); + + FitAll(series, result, radius, sigma, robust); + + if (rejectOutliers) + { + UpdateRobustWeights(series, result, robust); + FitAll(series, result, radius, sigma, robust); + } + + return result; + } + + private static void FitAll(double[] y, double[] output, int radius, double sigma, double[] robust) + { + int n = y.Length; + double twoSigmaSq = 2.0 * sigma * sigma; + + for (int i = 0; i < n; i++) + { + int from = Math.Max(0, i - radius); + int to = Math.Min(n - 1, i + radius); + + // 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]; + } + + if (sw <= 1e-9) + { + output[i] = y[i]; + continue; + } + + double det = sw * swt2 - swt * swt; + if (Math.Abs(det) < 1e-12) + { + output[i] = swy / sw; // finestra degenere: media pesata + continue; + } + + double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0 + output[i] = a; + } + } + + private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust) + { + int n = y.Length; + var residuals = new double[n]; + for (int i = 0; i < n; i++) residuals[i] = Math.Abs(y[i] - fit[i]); + + var sorted = (double[])residuals.Clone(); + Array.Sort(sorted); + double mad = sorted[n / 2]; + double scale = 1.4826 * mad; + + // Se la sequenza è già pulita non si scarta nulla: evita di amplificare il rumore numerico. + if (scale < 1e-4) + { + Array.Fill(robust, 1.0); + return; + } + + for (int i = 0; i < n; i++) + { + double u = residuals[i] / (TukeyConstant * scale); + robust[i] = u >= 1.0 ? 0.0 : Math.Pow(1.0 - u * u, 2.0); + } + } +} diff --git a/Titano/Core/Spline.cs b/Titano/Core/Spline.cs new file mode 100644 index 0000000..cf4f9f0 --- /dev/null +++ b/Titano/Core/Spline.cs @@ -0,0 +1,147 @@ +namespace Titano.Core; + +/// Nodo di una curva editabile: ascissa e valore, entrambi in unità di dominio. +public readonly record struct SplineKnot(double X, double Y) +{ + public SplineKnot WithX(double x) => new(x, Y); + public SplineKnot WithY(double y) => new(X, y); +} + +/// +/// Interpolazione a spline cubica monotona (schema di Fritsch–Carlson) e funzioni di +/// accelerazione di Bézier, entrambe scritte in-house. +/// +/// La monotonia non è un vezzo matematico: la curva di rimappatura temporale deve restituire +/// una velocità sempre positiva, altrimenti la sequenza tornerebbe indietro. Una Catmull-Rom +/// ordinaria produce sovraelongazioni fra due nodi molto diversi — sotto zero, nel nostro +/// caso — mentre lo smorzamento delle tangenti di Fritsch–Carlson lo impedisce per costruzione. +/// +public static class Spline +{ + /// + /// Valuta la spline monotona passante per i nodi indicati (ordinati per ascissa). + /// Fuori dall'intervallo dei nodi la curva si mantiene costante sull'estremo più vicino: + /// una estrapolazione lineare potrebbe scendere sotto zero. + /// + public static double Evaluate(IReadOnlyList knots, double x) + { + int n = knots.Count; + if (n == 0) return 0; + if (n == 1) return knots[0].Y; + + if (x <= knots[0].X) return knots[0].Y; + if (x >= knots[n - 1].X) return knots[n - 1].Y; + + int i = 0; + while (i < n - 2 && x > knots[i + 1].X) i++; + + double h = knots[i + 1].X - knots[i].X; + if (h <= 1e-12) return knots[i + 1].Y; + + double t = (x - knots[i].X) / h; + double m0 = Tangent(knots, i) * h; + double m1 = Tangent(knots, i + 1) * h; + + // Base di Hermite cubica. + double t2 = t * t; + double t3 = t2 * t; + return (2 * t3 - 3 * t2 + 1) * knots[i].Y + + (t3 - 2 * t2 + t) * m0 + + (-2 * t3 + 3 * t2) * knots[i + 1].Y + + (t3 - t2) * m1; + } + + /// + /// Tangente nel nodo indicato, smorzata secondo Fritsch–Carlson. + /// + /// La regola è: se le pendenze dei due segmenti adiacenti hanno segno discorde — cioè il + /// nodo è un massimo o un minimo locale — la tangente è nulla; altrimenti si prende la + /// media armonica pesata, che non supera mai tre volte la pendenza del segmento più dolce. + /// È questa limitazione a garantire che la curva non esca dall'intervallo dei nodi. + /// + private static double Tangent(IReadOnlyList knots, int index) + { + int n = knots.Count; + double slopeLeft = index > 0 ? Slope(knots, index - 1) : Slope(knots, 0); + double slopeRight = index < n - 1 ? Slope(knots, index) : Slope(knots, n - 2); + + if (index == 0) return slopeRight; + if (index == n - 1) return slopeLeft; + + if (slopeLeft * slopeRight <= 0) return 0; + + double hLeft = knots[index].X - knots[index - 1].X; + double hRight = knots[index + 1].X - knots[index].X; + double weightLeft = 2 * hRight + hLeft; + double weightRight = hRight + 2 * hLeft; + return (weightLeft + weightRight) / (weightLeft / slopeLeft + weightRight / slopeRight); + } + + private static double Slope(IReadOnlyList knots, int segment) + { + double h = knots[segment + 1].X - knots[segment].X; + return h <= 1e-12 ? 0 : (knots[segment + 1].Y - knots[segment].Y) / h; + } + + /// + /// Funzione di accelerazione cubica di Bézier, nella forma usata dai programmi di montaggio: + /// i punti di controllo (0,0) e (1,1) sono fissi, i due intermedi governano l'attacco e la + /// chiusura del movimento. Restituisce l'avanzamento y corrispondente al tempo x. + /// + /// La curva è definita in forma parametrica, quindi per un dato x va prima trovato il + /// parametro: si usa Newton, con ripiego sulla bisezione quando la derivata si annulla + /// (accade con maniglie estreme, dove la curva ha tangente verticale). + /// + public static double Ease(double x, double x1, double y1, double x2, double y2) + { + if (x <= 0) return 0; + if (x >= 1) return 1; + + x1 = Math.Clamp(x1, 0, 1); + x2 = Math.Clamp(x2, 0, 1); + + double t = x; + for (int i = 0; i < 8; i++) + { + double error = BezierAxis(t, x1, x2) - x; + if (Math.Abs(error) < 1e-7) return BezierAxis(t, y1, y2); + double derivative = BezierAxisDerivative(t, x1, x2); + if (Math.Abs(derivative) < 1e-9) break; + t -= error / derivative; + if (t is < 0 or > 1) { t = Math.Clamp(t, 0, 1); break; } + } + + double lo = 0, hi = 1; + for (int i = 0; i < 32 && hi - lo > 1e-7; i++) + { + t = (lo + hi) * 0.5; + if (BezierAxis(t, x1, x2) < x) lo = t; else hi = t; + } + return BezierAxis((lo + hi) * 0.5, y1, y2); + } + + /// Componente di una Bézier cubica con estremi fissi in 0 e 1. + private static double BezierAxis(double t, double p1, double p2) + { + double u = 1 - t; + return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t; + } + + private static double BezierAxisDerivative(double t, double p1, double p2) + { + double u = 1 - t; + return 3 * u * u * p1 + 6 * u * t * (p2 - p1) + 3 * t * t * (1 - p2); + } + + /// + /// Transizione a derivata nulla agli estremi, usata per redistribuire un gradino di + /// esposizione su più fotogrammi. Rispetto a una rampa lineare non lascia i due spigoli + /// che l'occhio legge come altrettanti scatti. + /// + public static double SmoothStep(double t) + { + if (t <= 0) return 0; + if (t >= 1) return 1; + return t * t * t * (t * (t * 6 - 15) + 10); + } +} diff --git a/Titano/Diagnostics/AdvancedModuleTests.cs b/Titano/Diagnostics/AdvancedModuleTests.cs new file mode 100644 index 0000000..3acb303 --- /dev/null +++ b/Titano/Diagnostics/AdvancedModuleTests.cs @@ -0,0 +1,621 @@ +using Titano.Analysis; +using Titano.Core; +using Titano.Imaging; +using Titano.Metadata; +using Titano.Motion; +using Titano.Pipeline; + +namespace Titano.Diagnostics; + +/// +/// Verifiche dei moduli avanzati: stabilizzazione, deflicker per regioni, transizioni +/// giorno-notte, movimento di macchina virtuale, rimappatura del tempo e accumulo temporale. +/// +/// Ogni controllo parte da una scena costruita apposta, con la proprietà da misurare imposta +/// per costruzione: il tremolio ha un percorso noto, il gradino di esposizione un'ampiezza +/// nota e coerente con i metadati, la nuvola attraversa il cielo senza toccare il terreno. +/// Il valore atteso non è quindi una soglia scelta a posteriori ma la conseguenza aritmetica +/// di come la scena è stata generata. +/// +internal static class AdvancedModuleTests +{ + internal delegate void Report(string name, bool passed, string detail); + + public static void Run(string workingDirectory, Report add, TextWriter output) + { + Stabilization(add); + PhotometricRegions(workingDirectory, add, output); + ColourDrift(add); + Easing(add); + Geometry(add); + Ramp(add); + Stacking(add); + Integration(workingDirectory, add, output); + } + + // ================================================================== 1. stabilizzazione + + /// + /// Percorso noto: una deriva lenta e voluta con sopra un tremolio veloce. Dopo la + /// stabilizzazione la deriva deve restare — è un movimento reale — e il tremolio sparire. + /// La misura è l'energia delle differenze seconde, che è nulla su una rampa perfetta e + /// cresce con l'irregolarità: separa esattamente ciò che va tenuto da ciò che va tolto. + /// + private static void Stabilization(Report add) + { + const int count = 36; + const int size = 320; + + var settings = new StabilizationSettings + { + Enabled = true, + PatchSize = 128, + Grid = 3, + SmoothingFrames = 15, + Strength = 1.0, + MaxCorrectionFraction = 0.10, + MinConfidence = 1.5, + }; + + var truthX = new double[count]; + var truthY = new double[count]; + var planes = new GrayImage[count]; + + for (int i = 0; i < count; i++) + { + double driftX = 0.30 * i; // panoramica voluta, lenta e regolare + double driftY = 0.12 * i; + double shakeX = 1.6 * Math.Sin(i * 2.399963) + 0.9 * Math.Sin(i * 0.7853); + double shakeY = 1.4 * Math.Cos(i * 1.618034) + 0.8 * Math.Sin(i * 1.162389); + + truthX[i] = driftX + shakeX; + truthY[i] = driftY + shakeY; + planes[i] = SelfTest.TexturePlane(size, size, truthX[i], truthY[i]); + } + + var stabilizer = new Stabilizer(settings); + var relative = new SimilarityTransform[count]; + var confidence = new double[count]; + relative[0] = SimilarityTransform.Identity; + + double worstMeasurement = 0; + for (int i = 1; i < count; i++) + { + relative[i] = stabilizer.Estimate(planes[i - 1], planes[i], out confidence[i]); + double expectedX = truthX[i] - truthX[i - 1]; + double expectedY = truthY[i] - truthY[i - 1]; + double measuredX = relative[i].Tx * size; + double measuredY = relative[i].Ty * size; + worstMeasurement = Math.Max(worstMeasurement, + Math.Sqrt((measuredX - expectedX) * (measuredX - expectedX) + + (measuredY - expectedY) * (measuredY - expectedY))); + } + + add("Stabilizzazione — spostamento fra fotogrammi adiacenti", worstMeasurement < 0.30, + $"errore massimo {worstMeasurement:0.000} px rispetto al percorso imposto"); + + var path = Stabilizer.BuildPath(relative, confidence, settings); + + var afterX = new double[count]; + var afterY = new double[count]; + for (int i = 0; i < count; i++) + { + afterX[i] = truthX[i] + path.Correction[i].Tx * size; + afterY[i] = truthY[i] + path.Correction[i].Ty * size; + } + + double before = Roughness(truthX) + Roughness(truthY); + double after = Roughness(afterX) + Roughness(afterY); + + add("Stabilizzazione — tremolio rimosso", after < before * 0.30, + $"irregolarità {before:0.000} → {after:0.000} px " + + $"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)"); + + // La panoramica voluta deve arrivare in fondo: se la stabilizzazione la annullasse, + // il totale percorso crollerebbe verso zero. + double driftBefore = truthX[^1] - truthX[0]; + double driftAfter = afterX[^1] - afterX[0]; + add("Stabilizzazione — panoramica voluta conservata", + Math.Abs(driftAfter - driftBefore) < Math.Abs(driftBefore) * 0.30, + $"{driftBefore:0.0} px → {driftAfter:0.0} px lungo la sequenza"); + } + + /// Energia delle differenze seconde: zero su una rampa, alta su un percorso a scatti. + private static double Roughness(double[] series) + { + if (series.Length < 3) return 0; + double sum = 0; + for (int i = 1; i < series.Length - 1; i++) + { + double d = series[i + 1] - 2 * series[i] + series[i - 1]; + sum += d * d; + } + return Math.Sqrt(sum / (series.Length - 2)); + } + + // ================================================================== 2. regioni e transizioni + + /// + /// Scena con orizzonte, un treno di nuvole che attraversa il solo cielo e un cambio di + /// sensibilità a metà sequenza, dichiarato nei metadati e visibile nei pixel. + /// + internal static SyntheticSequence.Definition SceneDefinition => new( + FrameCount: 40, Width: 480, Height: 320, + IntervalSeconds: 2.0, ExposureSeconds: 0.02, + FlickerStops: 0.10, RampStops: -0.35, + PauseAtFrame: 0, PauseFrames: 0, + ShiftX: 0, ShiftY: 0, + ExposureStepAt: 20, ExposureStepStops: 1.0, + SkyGround: true, SkyFraction: 0.4, CloudStrength: 0.55); + + private static void PhotometricRegions(string workingDirectory, Report add, TextWriter output) + { + var definition = SceneDefinition; + string directory = Path.Combine(workingDirectory, "scena"); + output.WriteLine($"Generazione di {definition.FrameCount} fotogrammi con orizzonte, " + + $"nuvole e cambio di sensibilità in {directory}"); + var paths = SyntheticSequence.Write(directory, definition); + + var project = BuildSceneProject(paths, definition); + new RenderPipeline(project).AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult(); + + // ---- segmentazione + var mask = project.Mask; + double coverage = mask?.Coverage ?? 0; + add("Segmentazione — cielo separato dal paesaggio", + mask is not null && Math.Abs(coverage - definition.SkyFraction) < 0.06, + mask is null + ? "nessuna maschera prodotta" + : $"copertura {coverage * 100:0.#}% (attesa {definition.SkyFraction * 100:0}%), {mask.Description}"); + + // ---- transizioni giorno-notte + var transitions = project.Transitions!; + bool stepFound = transitions.StepFrames.Length == 1 && + transitions.StepFrames[0] == definition.ExposureStepAt; + add("Holy Grail — cambio di impostazione riconosciuto", stepFound, + transitions.StepCount == 0 + ? "nessun gradino individuato" + : $"gradino al fotogramma {string.Join(", ", transitions.StepFrames)} " + + $"di {transitions.LargestStepStops:0.00} stop " + + $"(atteso {definition.ExposureStepAt}, {definition.ExposureStepStops:0.00} stop)"); + + var curve = project.Curve!; + double jumpBefore = MaxStep(curve.Measured); + double jumpAfter = MaxStep(curve.Target); + add("Holy Grail — gradino ridistribuito sulla transizione", jumpAfter < jumpBefore * 0.25, + $"salto massimo fra fotogrammi adiacenti {jumpBefore:0.000} → {jumpAfter:0.000} stop"); + + // ---- deflicker per regioni + if (curve.HasRegions && curve.MeasuredLow is { } measuredLow && curve.TargetLow is { } targetLow) + { + // Correzione globale applicata al paesaggio: eredita le oscillazioni che le + // nuvole hanno prodotto nel cielo, perché la misura da cui nasce le contiene. + var groundUnderGlobal = new double[measuredLow.Length]; + for (int i = 0; i < measuredLow.Length; i++) + groundUnderGlobal[i] = measuredLow[i] + curve.GainStops[i]; + + double globalNoise = DeflickerCurve.FlickerIndex(groundUnderGlobal); + double regionalNoise = DeflickerCurve.FlickerIndex(targetLow); + + add("Deflicker regionale — il paesaggio non insegue le nuvole", + regionalNoise < globalNoise * 0.5, + $"sfarfallio del terreno {globalNoise:0.0000} stop con la curva globale, " + + $"{regionalNoise:0.0000} con quella di regione"); + } + else + { + add("Deflicker regionale — il paesaggio non insegue le nuvole", false, + "le curve di regione non sono state calcolate"); + } + } + + internal static TitanoProject BuildSceneProject(IReadOnlyList paths, + SyntheticSequence.Definition definition) + { + var project = new TitanoProject(); + project.Sequence = TimelapseSequence.Build(paths.Select(MetadataReader.Read)); + project.Sequence.RecomputeTiming(project.General.CadenceTolerance); + project.DetectOrientation(); + + project.General.AnalysisWidth = definition.Width; + project.General.DecodeParallelism = 4; + project.Regions.Mode = RegionMode.SkyGround; + project.Regions.SampleFrames = 12; + project.Regions.AnalysisWidth = 240; + project.HolyGrail.Enabled = true; + project.HolyGrail.TransitionFrames = 24; + project.Deflicker.WindowFrames = 15; + project.Deflicker.MaxCorrectionStops = 1.5; + return project; + } + + private static double MaxStep(double[] series) + { + double worst = 0; + for (int i = 1; i < series.Length; i++) worst = Math.Max(worst, Math.Abs(series[i] - series[i - 1])); + return worst; + } + + // ================================================================== 3. deriva cromatica + + /// + /// Il bilanciamento del bianco deve perdere il tremolio e conservare il viaggio: in un + /// tramonto la luce si scalda davvero, e una correzione che riportasse ogni fotogramma al + /// bianco neutro cancellerebbe il soggetto invece di ripulirlo. + /// + private static void ColourDrift(Report add) + { + const int count = 60; + var stats = new LuminanceStats[count]; + var before = new double[count]; + + for (int i = 0; i < count; i++) + { + double warming = -0.60 * i / (count - 1.0); // il blu cala lungo il tramonto + double jitter = ((i * 7 + i * i % 5) % 7 - 3) / 3.0 * 0.09; + + stats[i] = new LuminanceStats + { + Log2Average = -2.0, + Log2AverageR = -2.0, + Log2AverageG = -2.0, + Log2AverageB = -2.0 + warming + jitter, + }; + before[i] = stats[i].Log2AverageB; + } + + var settings = new DeflickerSettings { Enabled = true, StabilizeColor = false, WindowFrames = 15 }; + var holyGrail = new HolyGrailSettings + { + Enabled = true, + SmoothColor = true, + ColorWindowFrames = 15, + ColorStrength = 1.0, + MaxColorShiftStops = 1.0, + }; + + var analysis = HolyGrailEngine.Analyze(stats, [], holyGrail); + var curve = DeflickerEngine.Compute(stats, settings, null, analysis, holyGrail); + + var after = new double[count]; + for (int i = 0; i < count; i++) + after[i] = stats[i].Log2AverageB + Math.Log2(curve.ChannelGain[i][2]); + + double noiseBefore = DeflickerCurve.FlickerIndex(before); + double noiseAfter = DeflickerCurve.FlickerIndex(after); + add("Bilanciamento del bianco — tremolio cromatico rimosso", noiseAfter < noiseBefore * 0.25, + $"{noiseBefore:0.0000} → {noiseAfter:0.0000} stop RMS sul canale blu"); + + double driftBefore = before[^1] - before[0]; + double driftAfter = after[^1] - after[0]; + add("Bilanciamento del bianco — deriva del tramonto conservata", + Math.Abs(driftAfter - driftBefore) < 0.15, + $"{driftBefore:0.00} stop → {driftAfter:0.00} stop dal primo all'ultimo fotogramma"); + + 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"); + } + + // ================================================================== 4. accelerazione + + private static void Easing(Report add) + { + bool monotone = true; + double previous = -1; + for (int i = 0; i <= 200; i++) + { + double value = Spline.Ease(i / 200.0, 0.42, 0, 0.58, 1); + if (value < previous - 1e-9) monotone = false; + previous = value; + } + + double start = Spline.Ease(0, 0.42, 0, 0.58, 1); + double middle = Spline.Ease(0.5, 0.42, 0, 0.58, 1); + double end = Spline.Ease(1, 0.42, 0, 0.58, 1); + + add("Accelerazione di Bézier — curva monotona con estremi esatti", + monotone && Math.Abs(start) < 1e-6 && Math.Abs(end - 1) < 1e-6 && Math.Abs(middle - 0.5) < 0.02, + $"0 → {middle:0.000} → 1, monotona su 201 campioni"); + + // Una maniglia asimmetrica deve spostare il baricentro del movimento: partenza lenta + // significa meno strada percorsa a metà tempo. + double lazy = Spline.Ease(0.5, 0.9, 0, 0.9, 1); + add("Accelerazione di Bézier — maniglie asimmetriche", lazy < middle - 0.05, + $"a metà tempo {lazy:0.000} con partenza indugiata contro {middle:0.000} con la curva simmetrica"); + + var camera = new VirtualCameraSettings + { + Enabled = true, + Keyframes = + [ + new() { Time = 0.0, CentreX = 0.5, CentreY = 0.5, Zoom = 1.0 }, + new() { Time = 1.0, CentreX = 0.7, CentreY = 0.4, Zoom = 2.0 }, + ], + }; + + var atStart = VirtualCamera.Resolve(camera, 0); + var atEnd = VirtualCamera.Resolve(camera, 1); + var atMiddle = VirtualCamera.Resolve(camera, 0.5); + + bool endpoints = Math.Abs(atStart.Zoom - 1) < 1e-6 && Math.Abs(atEnd.Zoom - 2) < 1e-6 && + Math.Abs(atEnd.CentreX - 0.7) < 1e-6; + // Lo zoom si interpola in scala logaritmica: fra 1× e 2× il punto di mezzo è √2. + bool geometric = Math.Abs(atMiddle.Zoom - Math.Sqrt(2)) < 0.02; + + add("Movimento virtuale — nodi rispettati e zoom geometrico", endpoints && geometric, + $"zoom 1× → {atMiddle.Zoom:0.000}× → 2×, centro finale {atEnd.CentreX:0.00}"); + } + + // ================================================================== 5. stadio geometrico + + private static void Geometry(Report add) + { + const int width = 256, height = 160; + var pool = new FrameBufferPool(6); + + using var source = pool.Rent(width, height); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int index = source.Offset(x, y); + float value = x / (float)(width - 1); + source.Data[index] = value; + source.Data[index + 1] = value; + source.Data[index + 2] = y / (float)(height - 1); + } + } + + // Nessuna trasformazione e stessa dimensione: lo stadio deve essere trasparente. + var identity = GeometryStage.Build(width, height, width, height, + CameraFraming.Full, SimilarityTransform.Identity); + using var copy = pool.Rent(width, height); + GeometryStage.Resample(source, copy, identity); + + double worstIdentity = 0; + for (int i = 0; i < source.SampleCount; i++) + worstIdentity = Math.Max(worstIdentity, Math.Abs(source.Data[i] - copy.Data[i])); + + add("Stadio geometrico — trasparente senza trasformazioni", + identity.IsIdentity && worstIdentity < 1e-6, + $"scarto massimo {worstIdentity:0.000000} sull'intero fotogramma"); + + // Ritaglio centrale al doppio: l'uscita deve coprire la metà centrale della rampa, + // quindi passare da 0,25 a 0,75 invece che da 0 a 1. + var zoomed = GeometryStage.Build(width, height, width, height, + new CameraFraming(0.5, 0.5, 2.0), SimilarityTransform.Identity); + using var cropped = pool.Rent(width, height); + GeometryStage.Resample(source, cropped, zoomed); + + float left = cropped.Data[cropped.Offset(0, height / 2)]; + float right = cropped.Data[cropped.Offset(width - 1, height / 2)]; + float centre = cropped.Data[cropped.Offset(width / 2, height / 2)]; + + add("Stadio geometrico — ritaglio al doppio ingrandimento", + Math.Abs(left - 0.25) < 0.01 && Math.Abs(right - 0.75) < 0.01 && Math.Abs(centre - 0.5) < 0.01, + $"rampa orizzontale letta da {left:0.000} a {right:0.000}, centro {centre:0.000}"); + } + + // ================================================================== 6. rimappatura del tempo + + private static void Ramp(Report add) + { + const int count = 60; + var metadata = new FrameMetadata[count]; + var origin = new DateTime(2026, 6, 1, 20, 0, 0, DateTimeKind.Unspecified); + for (int i = 0; i < count; i++) + { + metadata[i] = new FrameMetadata + { + FilePath = $"sintetico-{i:D3}.jpg", + FileName = $"sintetico-{i:D3}.jpg", + CaptureTime = origin.AddSeconds(2 * i), + ExposureSeconds = 0.02, + PixelWidth = 640, + PixelHeight = 360, + }; + } + + var sequence = TimelapseSequence.Build(metadata); + var export = new Video.ExportSettings { FrameRate = 30, Timing = Video.FrameTimingMode.Constant }; + uint baseUnits = (uint)(export.Timescale / export.FrameRate); + + var ramp = new TimeRampSettings + { + Enabled = true, + Speed = [new(0.0, 3.0), new(0.5, 0.25), new(1.0, 3.0)], + }; + + // La spline monotona non deve mai scendere sotto zero fra i nodi: una velocità + // negativa farebbe tornare indietro la sequenza. + double lowest = double.MaxValue; + for (int i = 0; i <= 400; i++) lowest = Math.Min(lowest, ramp.SpeedAt(i / 400.0)); + add("Rimappatura del tempo — velocità sempre positiva", lowest > 0, + $"minimo della curva {lowest:0.000}× fra nodi da 3× e 0,25×"); + + var plan = RenderPlanner.Build(sequence, export, ramp, baseUnits); + + bool monotone = true; + for (int i = 1; i < plan.Count; i++) + { + if (plan.Frames[i].SourcePosition < plan.Frames[i - 1].SourcePosition - 1e-9) monotone = false; + } + + bool covers = plan.Count > 0 && + plan.Frames[0].SourcePosition < 1e-9 && + plan.Frames[^1].SourcePosition > count - 4; + + add("Rimappatura del tempo — percorso monotono e completo", monotone && covers, + $"{plan.Count} fotogrammi d'uscita da {count} scatti, " + + $"da {plan.Frames[0].SourcePosition:0.00} a {plan.Frames[^1].SourcePosition:0.00}"); + + // La parte centrale è rallentata: lì i passi devono essere molto più fitti che agli + // estremi, dove la curva chiede tre scatti per fotogramma. + double middleStep = plan.Frames[plan.Count / 2].Speed; + double edgeStep = plan.Frames[0].Speed; + add("Rimappatura del tempo — rallentamento effettivo al centro", + middleStep < edgeStep * 0.2 && plan.Count > count, + $"passo {edgeStep:0.00} scatti agli estremi contro {middleStep:0.00} al centro"); + } + + // ================================================================== 7. accumulo temporale + + private static void Stacking(Report add) + { + const int width = 96, height = 64; + var pool = new FrameBufferPool(16); + var window = new List(); + + const float background = 0.30f; + const float intruder = 0.95f; + int blobX = width / 3, blobY = height / 2; + + for (int k = 0; k < 5; k++) + { + var frame = pool.Rent(width, height); + for (int i = 0; i < frame.SampleCount; i++) frame.Data[i] = background; + + // Un intruso presente in un solo fotogramma della finestra: una persona che passa. + if (k == 2) + { + for (int y = blobY - 3; y <= blobY + 3; y++) + { + for (int x = blobX - 3; x <= blobX + 3; x++) + { + int index = frame.Offset(x, y); + frame.Data[index] = intruder; + frame.Data[index + 1] = intruder; + frame.Data[index + 2] = intruder; + } + } + } + window.Add(frame); + } + + using var median = pool.Rent(width, height); + TemporalStacker.Median(window, null, window[2], median, 1.0); + + float atBlob = median.Data[median.Offset(blobX, blobY)]; + float average = (4 * background + intruder) / 5f; + add("Stacking mediano — intruso occasionale rimosso", + Math.Abs(atBlob - background) < 1e-5, + $"pixel dell'intruso {atBlob:0.0000} contro {background:0.0000} dello sfondo " + + $"(una media lascerebbe {average:0.0000})"); + + // Massimo progressivo: quattro luci in posizioni diverse devono restare tutte accese. + using var accumulator = pool.Rent(width, height); + Array.Clear(accumulator.Data, 0, accumulator.SampleCount); + + var positions = new (int X, int Y)[] { (10, 10), (30, 20), (50, 30), (70, 40) }; + foreach (var (x, y) in positions) + { + using var star = pool.Rent(width, height); + Array.Clear(star.Data, 0, star.SampleCount); + int index = star.Offset(x, y); + star.Data[index] = star.Data[index + 1] = star.Data[index + 2] = 0.9f; + + TemporalStacker.Accumulate(accumulator, star, SourceMapping.Identity, 1f); + } + + int lit = positions.Count(p => accumulator.Data[accumulator.Offset(p.X, p.Y)] > 0.89f); + add("Stacking a massima luminanza — la scia conserva tutti i passaggi", lit == positions.Length, + $"{lit} punti su {positions.Length} conservati nell'accumulatore"); + + // Con una scia di lunghezza finita l'accumulo deve spegnersi: il primo passaggio, + // dopo tre fotogrammi, è già molto più debole dell'ultimo. + float fade = TemporalStacker.FadeFactor(2.0); + add("Stacking a massima luminanza — decadimento della coda", fade is > 0.5f and < 0.7f, + $"fattore di decadimento {fade:0.000} per una scia di 2 fotogrammi"); + + foreach (var frame in window) frame.Dispose(); + } + + // ================================================================== 8. integrazione + + /// + /// Tutti i moduli accesi insieme su una sequenza reale, fino al file riprodotto dal + /// lettore di sistema. È l'unico controllo che può accorgersi di un'incompatibilità fra + /// due moduli che, presi da soli, funzionano entrambi. + /// + private static void Integration(string workingDirectory, Report add, TextWriter output) + { + string directory = Path.Combine(workingDirectory, "scena"); + if (!Directory.Exists(directory)) + { + add("Integrazione — tutti i moduli attivi insieme", false, "scena di prova non disponibile"); + return; + } + + var definition = SceneDefinition; + var paths = Directory.GetFiles(directory, "*.jpg").OrderBy(p => p, StringComparer.Ordinal).ToArray(); + var project = BuildSceneProject(paths, definition); + + project.Stabilization.Enabled = true; + project.Stabilization.SmoothingFrames = 15; + project.Stabilization.MinConfidence = 1.5; + + project.Camera.Enabled = true; + project.Camera.Keyframes = + [ + new() { Time = 0.0, CentreX = 0.40, CentreY = 0.45, Zoom = 1.15 }, + new() { Time = 1.0, CentreX = 0.60, CentreY = 0.55, Zoom = 1.45 }, + ]; + + project.Stacking.Mode = StackingMode.Median; + project.Stacking.WindowFrames = 5; + + project.TimeRamp.Enabled = true; + project.TimeRamp.Speed = [new(0.0, 1.5), new(0.5, 0.6), new(1.0, 1.5)]; + + project.Export.OutputPath = Path.Combine(workingDirectory, "titano-moduli-avanzati.mp4"); + project.Export.Width = 320; + project.Export.Height = 214; + project.Export.FrameRate = 24; + project.Export.BitrateMbps = 12; + project.Cache.PrefetchDepth = 6; + + // Tetto di memoria volutamente stretto: obbliga la finestra a usare il disco, così il + // percorso di parcheggio viene esercitato invece di restare teorico. + project.Cache.MemoryBudgetMiB = 4; + project.Cache.AllowDiskSpill = true; + + output.WriteLine("Rendering con stabilizzazione, movimento virtuale, mediana e rimappatura attivi"); + + RenderResult? result = null; + string detail; + try + { + var pipeline = new RenderPipeline(project); + pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult(); + result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult(); + detail = $"{result.EncodedFrames} fotogrammi, {result.PlanDescription}, " + + $"{result.Elapsed.TotalSeconds:0.0} s"; + } + catch (Exception ex) + { + detail = ex.Message; + } + + add("Integrazione — tutti i moduli attivi insieme", result is { EncodedFrames: > 0 }, detail); + + if (result is null) return; + + var playback = Mp4Playback.Read(result.OutputPath); + add("Integrazione — il video prodotto è riproducibile", + playback.Error is null && playback.FrameCount == result.EncodedFrames && + playback.Width == project.Export.Width, + playback.Error ?? $"{playback.FrameCount} fotogrammi riletti a {playback.Width}×{playback.Height}"); + + add("Parcheggio su disco — attivato dal tetto di memoria", result.UsedDisk, + result.UsedDisk + ? $"{result.SpilledFrames} fotogrammi parcheggiati, {result.SpillBytes / (1024.0 * 1024.0):0.0} MiB scritti" + : "nessun fotogramma parcheggiato: il tetto non è stato raggiunto"); + + // Il file di parcheggio nasce con la cancellazione automatica: a fine esportazione + // nella cartella temporanea non deve restare nulla. + var leftovers = Directory.GetFiles(Path.GetTempPath(), "titano-*.frames"); + add("Parcheggio su disco — nessun file temporaneo sopravvissuto", leftovers.Length == 0, + leftovers.Length == 0 ? "cartella temporanea pulita" : $"{leftovers.Length} file rimasti"); + } +} diff --git a/Titano/Diagnostics/SelfTest.cs b/Titano/Diagnostics/SelfTest.cs index c35e0d9..3fe744f 100644 --- a/Titano/Diagnostics/SelfTest.cs +++ b/Titano/Diagnostics/SelfTest.cs @@ -138,7 +138,29 @@ public static class SelfTest $"scarto massimo {worstMirrorError:0.000000} fra fotogramma diretto e specchiato ribaltato"); } - // ---------------------------------------------------------------- 5. optical flow + // ---------------------------------------------------------------- 5. correlazione di fase + // Verifica diretta della trasformata e della stima sotto il pixel: si costruisce una + // tessitura nota, la si trasla di una quantità frazionaria e si misura quanto la + // correlazione se ne accorge. Non serve passare dal disco: è il nucleo numerico. + { + var reference = TexturePlane(320, 320, 0, 0); + var correlator = new PhaseCorrelator(256); + double worstShiftError = 0; + + foreach (var (sx, sy) in (ReadOnlySpan<(double, double)>)[(3.5, -2.25), (-1.75, 0.5), (0.25, 6.0)]) + { + var shifted = TexturePlane(320, 320, sx, sy); + var measured = correlator.Correlate(reference, shifted, 32, 32); + double error = Math.Sqrt((measured.Dx - sx) * (measured.Dx - sx) + + (measured.Dy - sy) * (measured.Dy - sy)); + worstShiftError = Math.Max(worstShiftError, error); + } + + Add(checks, "Correlazione di fase — spostamento sotto il pixel", worstShiftError < 0.12, + $"errore massimo {worstShiftError:0.0000} px su traslazioni frazionarie note"); + } + + // ---------------------------------------------------------------- 6. optical flow var flowEngine = new OpticalFlowEngine(project.Flow); var field = flowEngine.Compute(frameA, frameB); @@ -237,6 +259,11 @@ public static class SelfTest $"{result.PeakPixelMemoryBytes / (1024 * 1024.0):0.0} MiB " + $"({result.PeakPixelMemoryBytes / (double)expectedBytes:0.0} fotogrammi)"); + // ---------------------------------------------------------------- 9. moduli avanzati + output.WriteLine(); + AdvancedModuleTests.Run(workingDirectory, (name, passed, detail) => Add(checks, name, passed, detail), + output); + // ---------------------------------------------------------------- esito output.WriteLine(); foreach (var (name, passed, detail) in checks) @@ -258,6 +285,29 @@ public static class SelfTest private static void Add(List<(string, bool, string)> checks, string name, bool passed, string detail) => checks.Add((name, passed, detail)); + /// + /// Piano di tessitura sintetica traslato di una quantità arbitraria, anche frazionaria. + /// Il rumore è definito su coordinate continue, quindi lo spostamento è esatto per + /// costruzione: non c'è ricampionamento che possa falsare la misura attesa. + /// + internal static GrayImage TexturePlane(int width, int height, double offsetX, double offsetY) + { + var plane = new GrayImage(width, height); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + double sx = x - offsetX; + double sy = y - offsetY; + double value = 0.55 * SyntheticSequence.Texture(sx / 21.0, sy / 21.0, 17) + + 0.30 * SyntheticSequence.Texture(sx / 6.5, sy / 6.5, 41) + + 0.15 * SyntheticSequence.Texture(sx / 2.7, sy / 2.7, 59); + plane.Data[y * width + x] = (float)value; + } + } + return plane; + } + private static string Format(double? value) => value?.ToString("0.######", CultureInfo.InvariantCulture) ?? "—"; diff --git a/Titano/Diagnostics/SyntheticSequence.cs b/Titano/Diagnostics/SyntheticSequence.cs index 105f950..445ff21 100644 --- a/Titano/Diagnostics/SyntheticSequence.cs +++ b/Titano/Diagnostics/SyntheticSequence.cs @@ -23,7 +23,51 @@ internal static class SyntheticSequence int PauseFrames = 4, double PauseMultiplier = 3.0, int ShiftX = 6, - int ShiftY = 2); + int ShiftY = 2, + + // ---- proprietà usate dalle verifiche dei moduli avanzati; a zero la sequenza + // resta identica a quella storica, così i controlli già esistenti non si spostano. + + /// Ampiezza del tremolio accidentale sovrapposto, in pixel. + double ShakePixels = 0, + + /// Fotogramma in cui la macchina cambia sensibilità; 0 disattiva il gradino. + int ExposureStepAt = 0, + + /// Ampiezza del gradino di esposizione, in stop (positivo = schiarisce). + double ExposureStepStops = 0, + + /// Divide la scena in cielo e paesaggio, per la segmentazione regionale. + bool SkyGround = false, + + /// Quota di inquadratura occupata dal cielo. + double SkyFraction = 0.4, + + /// Intensità della nuvola scura che attraversa il cielo senza toccare il terreno. + double CloudStrength = 0) + { + /// Sensibilità dichiarata sul fotogramma indicato, coerente con il gradino. + public int IsoAt(int index) + { + if (ExposureStepAt <= 0 || index < ExposureStepAt) return 200; + // Alzare la sensibilità schiarisce il fotogramma e abbassa il valore di esposizione: + // i metadati e i pixel devono raccontare la stessa cosa, o il test non prova nulla. + return (int)Math.Round(200 * Math.Pow(2, ExposureStepStops)); + } + + /// Contributo del gradino alla luminosità del fotogramma, in stop. + public double StepStopsAt(int index) + => ExposureStepAt > 0 && index >= ExposureStepAt ? ExposureStepStops : 0; + + /// Tremolio accidentale del fotogramma, deterministico ma non periodico. + public (double X, double Y) ShakeAt(int index) + { + if (ShakePixels <= 0) return (0, 0); + double x = Math.Sin(index * 2.399963) * 0.6 + Math.Sin(index * 0.7853) * 0.4; + double y = Math.Cos(index * 1.618034) * 0.55 + Math.Sin(index * 3.141593 * 0.37) * 0.45; + return (x * ShakePixels, y * ShakePixels); + } + } /// Scrive la sequenza nella cartella indicata e restituisce i percorsi generati. public static List Write(string directory, Definition definition) @@ -36,14 +80,16 @@ internal static class SyntheticSequence for (int i = 0; i < definition.FrameCount; i++) { - double exposureScale = Math.Pow(2.0, Ramp(definition, i) + Flicker(definition, i)); + double exposureScale = Math.Pow(2.0, Ramp(definition, i) + Flicker(definition, i) + + definition.StepStopsAt(i)); byte[] jpeg = RenderJpeg(definition, i, exposureScale); var timestamp = origin.AddSeconds(elapsed); int subSecond = (int)Math.Round((elapsed - Math.Floor(elapsed)) * 100) % 100; byte[] exif = ExifWriter.BuildExifBlock(timestamp, subSecond, definition.ExposureSeconds, - 8.0, 200, definition.Width, definition.Height, + 8.0, definition.IsoAt(i), + definition.Width, definition.Height, "TITANO", "TITANO Synthetic"); string path = Path.Combine(directory, $"TITANO_{i:D4}.jpg"); @@ -71,8 +117,21 @@ internal static class SyntheticSequence { int width = definition.Width; int height = definition.Height; - double offsetX = index * definition.ShiftX; - double offsetY = index * definition.ShiftY; + var (shakeX, shakeY) = definition.ShakeAt(index); + double offsetX = index * definition.ShiftX + shakeX; + double offsetY = index * definition.ShiftY + shakeY; + + double horizon = height * Math.Clamp(definition.SkyFraction, 0.05, 0.95); + + // Un treno di nuvole attraversa il cielo, una ogni sette scatti. Ciascuna abbassa la + // luminanza media dell'inquadratura mentre sul terreno non cambia nulla: è il caso in + // cui il deflicker globale schiarisce anche il paesaggio, che invece era fermo. + // Il periodo è più corto della finestra di lisciatura, quindi la correzione ci prova + // davvero — con un passaggio lento si limiterebbe a seguirlo. + const int cloudPeriod = 7; + double cloudWidth = Math.Max(4.0, horizon * 0.30); + double cloudCentre = -cloudWidth + (index % cloudPeriod) / (double)cloudPeriod + * (horizon + 2 * cloudWidth); using var bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb); var rect = new Rectangle(0, 0, width, height); @@ -98,7 +157,21 @@ internal static class SyntheticSequence + 0.30 * ValueNoise(sx / 7.0, sy / 7.0, 37) + 0.20 * ValueNoise(sx / 3.0, sy / 3.0, 53); - double value = Math.Clamp((0.18 + 0.62 * texture) * exposureScale, 0.0, 1.0); + double scene = 0.18 + 0.62 * texture; + + if (definition.SkyGround) + { + bool sky = y < horizon; + scene = sky ? 0.62 + 0.20 * texture : 0.055 + 0.070 * texture; + + if (sky && definition.CloudStrength > 0) + { + double distance = (y - cloudCentre) / cloudWidth; + scene *= 1.0 - definition.CloudStrength * Math.Exp(-distance * distance); + } + } + + double value = Math.Clamp(scene * exposureScale, 0.0, 1.0); byte gray = (byte)Math.Clamp((int)(Math.Pow(value, 1.0 / 2.2) * 255.0 + 0.5), 0, 255); byte* pixel = row + x * 3; @@ -123,6 +196,13 @@ internal static class SyntheticSequence return stream.ToArray(); } + /// + /// Rumore di valore su coordinate continue, esposto anche alle verifiche numeriche: + /// permette di costruire una tessitura traslata di una quantità frazionaria esatta, + /// senza il ricampionamento che falserebbe la misura attesa. + /// + public static double Texture(double x, double y, int seed) => ValueNoise(x, y, seed); + /// Rumore di valore bilineare su reticolo intero, deterministico. private static double ValueNoise(double x, double y, int seed) { diff --git a/Titano/Imaging/GeometryStage.cs b/Titano/Imaging/GeometryStage.cs new file mode 100644 index 0000000..0dd4e6c --- /dev/null +++ b/Titano/Imaging/GeometryStage.cs @@ -0,0 +1,193 @@ +using Titano.Motion; + +namespace Titano.Imaging; + +/// +/// Trasformazione affine dal fotogramma d'uscita a quello sorgente: per ogni pixel di +/// destinazione dice da quali coordinate prelevare. È la forma inversa a quella naturale, +/// ed è voluta — percorrendo la destinazione ogni pixel viene scritto una volta sola e non +/// restano buchi, mentre percorrendo la sorgente ne resterebbero ovunque la scala aumenti. +/// +public readonly record struct SourceMapping(double Ax, double Bx, double Tx, + double Ay, double By, double Ty) +{ + public static SourceMapping Identity => new(1, 0, 0, 0, 1, 0); + + public bool IsIdentity => + Math.Abs(Ax - 1) < 1e-9 && Math.Abs(Bx) < 1e-9 && Math.Abs(Tx) < 1e-6 && + Math.Abs(Ay) < 1e-9 && Math.Abs(By - 1) < 1e-9 && Math.Abs(Ty) < 1e-6; + + public (double X, double Y) Apply(double x, double y) + => (Ax * x + Bx * y + Tx, Ay * x + By * y + Ty); +} + +/// +/// Stadio geometrico finale: ritaglio del movimento virtuale e correzione della +/// stabilizzazione, applicati insieme in un unico ricampionamento. +/// +/// Che siano insieme non è un dettaglio di efficienza. Ogni ricampionamento costa un po' di +/// nitidezza, perché ricostruisce il segnale su un reticolo diverso; farne due in fila — +/// prima raddrizzare il fotogramma, poi ritagliarlo — costa il doppio senza dare nulla in +/// cambio. Le due trasformazioni sono entrambe affini, quindi si compongono esattamente in +/// una sola, e il fotogramma viene interpolato una volta sola. +/// +/// Il filtro di ricostruzione è la cubica di Catmull-Rom: passa per i campioni, ha derivata +/// continua e restituisce un'immagine più incisa di quanto farebbe una bilineare, che a ogni +/// panoramica virtuale lascerebbe un velo di morbidezza. +/// +public static class GeometryStage +{ + /// + /// Compone il ritaglio dell'inquadratura con l'inversa della correzione di stabilizzazione. + /// + /// La correzione porta il fotogramma dalla posizione in cui è stato scattato a quella in cui + /// avrebbe dovuto essere; qui serve il percorso opposto, perché si parte dalla destinazione + /// e si va a cercare il pixel nella sorgente. + /// + public static SourceMapping Build(int sourceWidth, int sourceHeight, int outputWidth, int outputHeight, + in CameraFraming framing, in SimilarityTransform stabilization) + { + var inverse = stabilization.Inverse; + + double zoom = Math.Max(1e-3, framing.Zoom); + double cropWidth = sourceWidth / zoom; + double cropHeight = sourceHeight / zoom; + double left = framing.CentreX * sourceWidth - cropWidth * 0.5; + double top = framing.CentreY * sourceHeight - cropHeight * 0.5; + + // La catena è affine, quindi tre punti la determinano per intero: valutarla e poi + // ricavarne i coefficienti è più corto — e molto meno soggetto a errori di segno — + // che moltiplicare le matrici a mano. + var origin = Trace(0, 0); + var alongX = Trace(1, 0); + var alongY = Trace(0, 1); + + return new SourceMapping(alongX.X - origin.X, alongY.X - origin.X, origin.X, + alongX.Y - origin.Y, alongY.Y - origin.Y, origin.Y); + + (double X, double Y) Trace(double x, double y) + { + // Pixel di destinazione → punto dentro il ritaglio, in pixel sorgente stabilizzati. + double stabilizedX = left + (x + 0.5) * cropWidth / outputWidth; + double stabilizedY = top + (y + 0.5) * cropHeight / outputHeight; + + if (inverse.IsIdentity) return (stabilizedX - 0.5, stabilizedY - 0.5); + + // Le similitudini vivono in coordinate normalizzate sulla larghezza e centrate. + double u = (stabilizedX - sourceWidth * 0.5) / sourceWidth; + double v = (stabilizedY - sourceHeight * 0.5) / sourceWidth; + var (ru, rv) = inverse.Apply(u, v); + + return (ru * sourceWidth + sourceWidth * 0.5 - 0.5, + rv * sourceWidth + sourceHeight * 0.5 - 0.5); + } + } + + /// + /// Mappatura fra due fotogrammi della stessa dimensione legati da una similitudine + /// espressa in coordinate normalizzate. La usa lo stacking per sovrapporre fotogrammi + /// che la stabilizzazione ha spostato di quantità diverse. + /// + public static SourceMapping FromSimilarity(int width, int height, in SimilarityTransform mapping) + { + if (mapping.IsIdentity) return SourceMapping.Identity; + + var similarity = mapping; + var origin = Trace(0, 0); + var alongX = Trace(1, 0); + var alongY = Trace(0, 1); + + return new SourceMapping(alongX.X - origin.X, alongY.X - origin.X, origin.X, + alongX.Y - origin.Y, alongY.Y - origin.Y, origin.Y); + + (double X, double Y) Trace(double x, double y) + { + double u = (x + 0.5 - width * 0.5) / width; + double v = (y + 0.5 - height * 0.5) / width; + var (ru, rv) = similarity.Apply(u, v); + return (ru * width + width * 0.5 - 0.5, rv * width + height * 0.5 - 0.5); + } + } + + /// Vero se lo stadio non ha nulla da fare: stessa dimensione e nessuna trasformazione. + public static bool IsPassThrough(in SourceMapping map, ImageBuffer source, ImageBuffer destination) + => map.IsIdentity && source.Width == destination.Width && source.Height == destination.Height; + + /// Ricampiona la sorgente nella destinazione secondo la mappatura indicata. + public static void Resample(ImageBuffer source, ImageBuffer destination, in SourceMapping map) + { + if (IsPassThrough(map, source, destination)) + { + destination.CopyFrom(source); + return; + } + + int sourceWidth = source.Width; + int sourceHeight = source.Height; + int width = destination.Width; + int height = destination.Height; + var src = source.Data; + var dst = destination.Data; + + double ax = map.Ax, bx = map.Bx, tx = map.Tx; + double ay = map.Ay, by = map.By, ty = map.Ty; + + Parallel.For(0, height, y => + { + double rowX = bx * y + tx; + double rowY = by * y + ty; + int rowBase = y * width * ImageBuffer.Channels; + + Span weightX = stackalloc float[4]; + Span weightY = stackalloc float[4]; + + for (int x = 0; x < width; x++) + { + float sx = (float)(ax * x + rowX); + float sy = (float)(ay * x + rowY); + + int x0 = (int)MathF.Floor(sx); + int y0 = (int)MathF.Floor(sy); + CatmullRom(sx - x0, weightX); + CatmullRom(sy - y0, weightY); + + float r = 0, g = 0, b = 0; + for (int j = 0; j < 4; j++) + { + int py = Math.Clamp(y0 - 1 + j, 0, sourceHeight - 1); + int lineBase = py * sourceWidth * ImageBuffer.Channels; + float wy = weightY[j]; + if (wy == 0f) continue; + + for (int i = 0; i < 4; i++) + { + int px = Math.Clamp(x0 - 1 + i, 0, sourceWidth - 1); + float w = wy * weightX[i]; + int index = lineBase + px * ImageBuffer.Channels; + r += src[index] * w; + g += src[index + 1] * w; + b += src[index + 2] * w; + } + } + + // La cubica sovraelonga: sui bordi ad alto contrasto può scendere sotto zero, + // che in luce lineare non significa nulla e all'encoder arriverebbe come rumore. + int destination0 = rowBase + x * ImageBuffer.Channels; + dst[destination0] = r > 0f ? r : 0f; + dst[destination0 + 1] = g > 0f ? g : 0f; + dst[destination0 + 2] = b > 0f ? b : 0f; + } + }); + } + + /// Pesi della cubica di Catmull-Rom per i quattro campioni attorno alla posizione. + private static void CatmullRom(float t, Span weights) + { + float t2 = t * t; + float t3 = t2 * t; + weights[0] = -0.5f * t3 + t2 - 0.5f * t; + weights[1] = 1.5f * t3 - 2.5f * t2 + 1f; + weights[2] = -1.5f * t3 + 2f * t2 + 0.5f * t; + weights[3] = 0.5f * t3 - 0.5f * t2; + } +} diff --git a/Titano/Motion/Fourier.cs b/Titano/Motion/Fourier.cs new file mode 100644 index 0000000..02bb971 --- /dev/null +++ b/Titano/Motion/Fourier.cs @@ -0,0 +1,104 @@ +namespace Titano.Motion; + +/// +/// Trasformata di Fourier discreta, implementata in-house con lo schema di Cooley–Tukey +/// a base due, iterativo e in loco. Serve alla correlazione di fase della stabilizzazione: +/// il teorema di convoluzione trasforma la ricerca dello spostamento fra due fotogrammi, +/// che in forma diretta costerebbe una ricerca esaustiva, in un prodotto punto per punto. +/// +/// L'implementazione lavora su due array separati per parte reale e immaginaria: nel nostro +/// uso l'ingresso è reale, quindi la parte immaginaria parte a zero e non vale la pena di +/// impacchettare i campioni in una struttura complessa. +/// +public static class Fourier +{ + /// Vero se il valore è una potenza di due maggiore di zero. + public static bool IsPowerOfTwo(int value) => value > 0 && (value & (value - 1)) == 0; + + /// Più grande potenza di due minore o uguale al valore indicato. + public static int FloorPowerOfTwo(int value) + { + if (value < 1) return 0; + int result = 1; + while (result * 2 <= value) result *= 2; + return result; + } + + /// + /// Trasformata monodimensionale in loco su campioni contigui a + /// partire da , con passo : lo stesso + /// codice serve così sia le righe sia le colonne di una matrice. + /// + public static void Transform(float[] re, float[] im, int offset, int stride, int length, bool inverse) + { + if (length <= 1) return; + + // Permutazione a bit invertiti: porta i campioni nell'ordine in cui le farfalle + // successive li combinano senza ulteriori spostamenti. + for (int i = 1, j = 0; i < length; i++) + { + int bit = length >> 1; + for (; (j & bit) != 0; bit >>= 1) j ^= bit; + j ^= bit; + + if (i >= j) continue; + int a = offset + i * stride; + int b = offset + j * stride; + (re[a], re[b]) = (re[b], re[a]); + (im[a], im[b]) = (im[b], im[a]); + } + + double sign = inverse ? 1.0 : -1.0; + + for (int size = 2; size <= length; size <<= 1) + { + double angle = sign * 2.0 * Math.PI / size; + float stepRe = (float)Math.Cos(angle); + float stepIm = (float)Math.Sin(angle); + + for (int start = 0; start < length; start += size) + { + float wRe = 1f, wIm = 0f; + int half = size >> 1; + + for (int k = 0; k < half; k++) + { + int even = offset + (start + k) * stride; + int odd = offset + (start + k + half) * stride; + + float tRe = re[odd] * wRe - im[odd] * wIm; + float tIm = re[odd] * wIm + im[odd] * wRe; + + re[odd] = re[even] - tRe; + im[odd] = im[even] - tIm; + re[even] += tRe; + im[even] += tIm; + + float nextRe = wRe * stepRe - wIm * stepIm; + wIm = wRe * stepIm + wIm * stepRe; + wRe = nextRe; + } + } + } + + if (!inverse) return; + + float scale = 1f / length; + for (int i = 0; i < length; i++) + { + int index = offset + i * stride; + re[index] *= scale; + im[index] *= scale; + } + } + + /// + /// Trasformata bidimensionale di una matrice quadrata di lato , + /// per separabilità: prima tutte le righe, poi tutte le colonne. + /// + public static void Transform2D(float[] re, float[] im, int size, bool inverse) + { + for (int y = 0; y < size; y++) Transform(re, im, y * size, 1, size, inverse); + for (int x = 0; x < size; x++) Transform(re, im, x, size, size, inverse); + } +} diff --git a/Titano/Motion/GrayPyramid.cs b/Titano/Motion/GrayPyramid.cs index ca8e1c6..5850502 100644 --- a/Titano/Motion/GrayPyramid.cs +++ b/Titano/Motion/GrayPyramid.cs @@ -61,6 +61,21 @@ public sealed class GrayPyramid public int LevelCount => Levels.Length; + /// + /// Solo il piano di luminanza alla risoluzione di analisi, senza costruire la piramide. + /// Lo usa la correlazione di fase, che lavora su un unico livello. + /// + public static GrayImage Plane(ImageBuffer frame, int maxAnalysisWidth) + { + float scale = Math.Min(1f, maxAnalysisWidth / (float)frame.Width); + int w = Math.Max(16, (int)MathF.Round(frame.Width * scale)); + int h = Math.Max(16, (int)MathF.Round(frame.Height * scale)); + + var plane = new GrayImage(w, h); + Downsample(frame, plane); + return plane; + } + public static GrayPyramid Build(ImageBuffer frame, int maxAnalysisWidth, int requestedLevels) { float scale = Math.Min(1f, maxAnalysisWidth / (float)frame.Width); diff --git a/Titano/Motion/PhaseCorrelator.cs b/Titano/Motion/PhaseCorrelator.cs new file mode 100644 index 0000000..513cb9d --- /dev/null +++ b/Titano/Motion/PhaseCorrelator.cs @@ -0,0 +1,298 @@ +namespace Titano.Motion; + +/// Spostamento misurato su un riquadro, con l'indice di attendibilità del picco. +public readonly record struct PhaseShift(float Dx, float Dy, float Confidence) +{ + public static PhaseShift None => new(0, 0, 0); +} + +/// +/// Correlazione di fase fra due riquadri omologhi di fotogrammi adiacenti. +/// +/// Il principio: una traslazione nel dominio spaziale è uno sfasamento lineare nel dominio +/// di Fourier. Normalizzando lo spettro incrociato al modulo unitario resta solo la fase, e +/// l'antitrasformata di una fase lineare pura è un impulso posto esattamente sullo +/// spostamento. Il metodo ignora quindi per costruzione le differenze di luminosità e di +/// contrasto fra i due fotogrammi — che in un time-lapse ci sono sempre — e reagisce +/// unicamente alla geometria. +/// +/// L'antitrasformata dà però il picco solo sui campioni interi. Per scendere sotto il pixel +/// si ricostruisce la superficie di correlazione a passo fine nell'intorno del massimo, +/// valutando direttamente la somma di Fourier sulle posizioni intermedie invece di +/// interpolare i campioni già calcolati. La differenza è sostanziale: interpolare con una +/// parabola tre campioni di una cresta che parabola non è introduce un errore sistematico +/// che cresce con l'entità dello spostamento, mentre la valutazione diretta è esatta a meno +/// del passo scelto, perché il segnale è a banda limitata per costruzione. +/// +/// L'istanza possiede i propri buffer di lavoro e non è utilizzabile da più thread insieme. +/// +public sealed class PhaseCorrelator +{ + /// Campioni per lato della griglia di raffinamento, su un intorno di ±1 pixel. + private const int RefineSteps = 17; + + private readonly int _size; + private readonly float[] _aRe; + private readonly float[] _aIm; + private readonly float[] _bRe; + private readonly float[] _bIm; + private readonly float[] _window; + + /// Radici dell'unità di ordine N: servono a spostare la valutazione di un intero. + private readonly float[] _rootRe; + private readonly float[] _rootIm; + + /// Fattori di fase degli scostamenti frazionari, precalcolati una volta sola. + private readonly float[] _deltaRe; + private readonly float[] _deltaIm; + + private readonly float[] _rowRe; + private readonly float[] _rowIm; + private readonly int[] _frequency; + + public int Size => _size; + + public PhaseCorrelator(int size) + { + if (!Fourier.IsPowerOfTwo(size)) + throw new ArgumentException("La correlazione di fase richiede un lato potenza di due.", nameof(size)); + + _size = size; + int samples = size * size; + _aRe = new float[samples]; + _aIm = new float[samples]; + _bRe = new float[samples]; + _bIm = new float[samples]; + _window = BuildWindow(size); + + _rootRe = new float[size]; + _rootIm = new float[size]; + for (int m = 0; m < size; m++) + { + double angle = 2.0 * Math.PI * m / size; + _rootRe[m] = (float)Math.Cos(angle); + _rootIm[m] = (float)Math.Sin(angle); + } + + // Frequenza con segno: oltre metà spettro l'indice rappresenta una frequenza negativa. + // Usare l'indice grezzo darebbe una interpolazione sbagliata sulle posizioni frazionarie. + _frequency = new int[size]; + for (int k = 0; k < size; k++) _frequency[k] = k < size / 2 ? k : k - size; + + _deltaRe = new float[RefineSteps * size]; + _deltaIm = new float[RefineSteps * size]; + for (int step = 0; step < RefineSteps; step++) + { + double delta = -1.0 + 2.0 * step / (RefineSteps - 1); + for (int k = 0; k < size; k++) + { + double angle = 2.0 * Math.PI * _frequency[k] * delta / size; + _deltaRe[step * size + k] = (float)Math.Cos(angle); + _deltaIm[step * size + k] = (float)Math.Sin(angle); + } + } + + _rowRe = new float[size * RefineSteps]; + _rowIm = new float[size * RefineSteps]; + } + + /// + /// Finestra di Hann separabile. Senza di essa il bordo del riquadro si comporta come un + /// gradino e la sua trasformata riempie di righe l'intero spettro, coprendo il picco. + /// + private static float[] BuildWindow(int size) + { + var line = new float[size]; + for (int i = 0; i < size; i++) + line[i] = 0.5f * (1f - MathF.Cos(2f * MathF.PI * i / (size - 1))); + + var window = new float[size * size]; + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) window[y * size + x] = line[y] * line[x]; + } + return window; + } + + /// + /// Misura lo spostamento del contenuto del riquadro fra e + /// : il risultato è il vettore d per cui b(p) ≈ a(p − d). + /// + public PhaseShift Correlate(GrayImage a, GrayImage b, int originX, int originY) + { + Load(a, originX, originY, _aRe, _aIm); + Load(b, originX, originY, _bRe, _bIm); + + Fourier.Transform2D(_aRe, _aIm, _size, false); + Fourier.Transform2D(_bRe, _bIm, _size, false); + + // Spettro incrociato coniugato e normalizzato: conj(A)·B / |conj(A)·B|. + // Con questa combinazione l'impulso dell'antitrasformata cade su +d. + for (int i = 0; i < _aRe.Length; i++) + { + float cr = _aRe[i] * _bRe[i] + _aIm[i] * _bIm[i]; + float ci = _aRe[i] * _bIm[i] - _aIm[i] * _bRe[i]; + float magnitude = MathF.Sqrt(cr * cr + ci * ci); + + if (magnitude < 1e-12f) { _aRe[i] = 0; _aIm[i] = 0; continue; } + _aRe[i] = cr / magnitude; + _aIm[i] = ci / magnitude; + } + + // Lo spettro normalizzato serve ancora al raffinamento: i buffer di B sono liberi. + Array.Copy(_aRe, _bRe, _aRe.Length); + Array.Copy(_aIm, _bIm, _aIm.Length); + + Fourier.Transform2D(_aRe, _aIm, _size, true); + + int peak = 0; + float best = float.NegativeInfinity; + double energy = 0; + + for (int i = 0; i < _aRe.Length; i++) + { + float value = _aRe[i]; + energy += Math.Abs(value); + if (value <= best) continue; + best = value; + peak = i; + } + + if (best <= 0) return PhaseShift.None; + + // Il piano della correlazione è periodico: la metà superiore rappresenta gli + // spostamenti negativi. + int px = peak % _size; + int py = peak / _size; + int ix = px > _size / 2 ? px - _size : px; + int iy = py > _size / 2 ? py - _size : py; + + Refine(ix, iy, out float dx, out float dy); + + // Attendibilità: quanto il picco svetta sul livello medio del piano. Un riquadro + // senza tessitura, o coperto da una nuvola che si è mossa da sola, produce una + // collina larga e bassa, non un impulso. + double mean = energy / _aRe.Length; + float confidence = mean > 1e-9 ? (float)(best / mean) : 0; + + return new PhaseShift(dx, dy, confidence); + } + + /// + /// Ricostruisce la correlazione a passo fine su ±1 pixel attorno al massimo intero, + /// valutando la somma di Fourier direttamente sulle posizioni intermedie. La somma è + /// separabile: prima si risolve la direzione orizzontale per ogni riga dello spettro, + /// poi si combinano le righe. Il costo resta dello stesso ordine della trasformata. + /// + private void Refine(int integerX, int integerY, out float dx, out float dy) + { + int n = _size; + + // Somma sulle frequenze orizzontali, per ogni riga dello spettro e ogni scostamento. + for (int ky = 0; ky < n; ky++) + { + int spectrumRow = ky * n; + for (int step = 0; step < RefineSteps; step++) + { + int deltaRow = step * n; + float sumRe = 0, sumIm = 0; + + for (int kx = 0; kx < n; kx++) + { + // Fase totale = parte intera (radice dell'unità) × parte frazionaria. + int rotation = ((_frequency[kx] * integerX) % n + n) % n; + float phaseRe = _rootRe[rotation] * _deltaRe[deltaRow + kx] + - _rootIm[rotation] * _deltaIm[deltaRow + kx]; + float phaseIm = _rootRe[rotation] * _deltaIm[deltaRow + kx] + + _rootIm[rotation] * _deltaRe[deltaRow + kx]; + + float re = _bRe[spectrumRow + kx]; + float im = _bIm[spectrumRow + kx]; + sumRe += re * phaseRe - im * phaseIm; + sumIm += re * phaseIm + im * phaseRe; + } + + _rowRe[ky * RefineSteps + step] = sumRe; + _rowIm[ky * RefineSteps + step] = sumIm; + } + } + + float bestValue = float.NegativeInfinity; + int bestX = RefineSteps / 2, bestY = RefineSteps / 2; + Span surface = stackalloc float[RefineSteps * RefineSteps]; + + for (int stepY = 0; stepY < RefineSteps; stepY++) + { + int deltaRow = stepY * n; + for (int stepX = 0; stepX < RefineSteps; stepX++) + { + float sum = 0; + for (int ky = 0; ky < n; ky++) + { + int rotation = ((_frequency[ky] * integerY) % n + n) % n; + float phaseRe = _rootRe[rotation] * _deltaRe[deltaRow + ky] + - _rootIm[rotation] * _deltaIm[deltaRow + ky]; + float phaseIm = _rootRe[rotation] * _deltaIm[deltaRow + ky] + + _rootIm[rotation] * _deltaRe[deltaRow + ky]; + + float re = _rowRe[ky * RefineSteps + stepX]; + float im = _rowIm[ky * RefineSteps + stepX]; + sum += re * phaseRe - im * phaseIm; // basta la parte reale + } + + surface[stepY * RefineSteps + stepX] = sum; + if (sum <= bestValue) continue; + bestValue = sum; + bestX = stepX; + bestY = stepY; + } + } + + float span = 2f / (RefineSteps - 1); + + // Sulla griglia fine la cresta è ormai ben campionata: qui la parabola è legittima. + float offsetX = bestX is > 0 and < RefineSteps - 1 + ? ParabolicOffset(surface[bestY * RefineSteps + bestX - 1], bestValue, + surface[bestY * RefineSteps + bestX + 1]) + : 0; + float offsetY = bestY is > 0 and < RefineSteps - 1 + ? ParabolicOffset(surface[(bestY - 1) * RefineSteps + bestX], bestValue, + surface[(bestY + 1) * RefineSteps + bestX]) + : 0; + + dx = integerX + (-1f + bestX * span) + offsetX * span; + dy = integerY + (-1f + bestY * span) + offsetY * span; + } + + /// Vertice della parabola per i tre campioni attorno al picco, in [-0.5, 0.5]. + private static float ParabolicOffset(float left, float centre, float right) + { + float denominator = 2f * (2f * centre - left - right); + if (MathF.Abs(denominator) < 1e-9f) return 0; + float offset = (right - left) / denominator; + 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) + { + double sum = 0; + for (int y = 0; y < _size; y++) + { + int rowBase = y * _size; + for (int x = 0; x < _size; x++) + { + float value = image.At(originX + x, originY + y); + re[rowBase + x] = value; + sum += value; + } + } + + float mean = (float)(sum / re.Length); + for (int i = 0; i < re.Length; i++) + { + re[i] = (re[i] - mean) * _window[i]; + im[i] = 0f; + } + } +} diff --git a/Titano/Motion/Stabilizer.cs b/Titano/Motion/Stabilizer.cs new file mode 100644 index 0000000..ba85ecb --- /dev/null +++ b/Titano/Motion/Stabilizer.cs @@ -0,0 +1,401 @@ +using Titano.Core; + +namespace Titano.Motion; + +/// +/// Similitudine piana: rotazione, scala isotropa e traslazione, nella forma q = s·R(θ)·p + t. +/// +/// Le coordinate sono normalizzate sulla larghezza del fotogramma e centrate: un punto vale +/// ((x − W/2)/W, (y − H/2)/W). Così la stessa trasformazione descrive il movimento tanto alla +/// risoluzione ridotta dell'analisi quanto a quella piena del rendering, senza conversioni +/// e senza il rischio di applicare uno spostamento misurato in pixel sbagliati. +/// +public readonly record struct SimilarityTransform(double Scale, double Rotation, double Tx, double Ty) +{ + public static SimilarityTransform Identity => new(1, 0, 0, 0); + + public bool IsIdentity => Math.Abs(Scale - 1) < 1e-9 && Math.Abs(Rotation) < 1e-9 + && Math.Abs(Tx) < 1e-9 && Math.Abs(Ty) < 1e-9; + + public (double X, double Y) Apply(double x, double y) + { + double cos = Math.Cos(Rotation), sin = Math.Sin(Rotation); + return (Scale * (cos * x - sin * y) + Tx, + Scale * (sin * x + cos * y) + Ty); + } + + public SimilarityTransform Inverse + { + get + { + double scale = Math.Abs(Scale) < 1e-12 ? 1 : 1.0 / Scale; + double cos = Math.Cos(-Rotation), sin = Math.Sin(-Rotation); + return new SimilarityTransform(scale, -Rotation, + -scale * (cos * Tx - sin * Ty), + -scale * (sin * Tx + cos * Ty)); + } + } + + /// Composizione : prima inner, poi outer. + public static SimilarityTransform Compose(in SimilarityTransform outer, in SimilarityTransform inner) + { + var (tx, ty) = outer.Apply(inner.Tx, inner.Ty); + return new SimilarityTransform(outer.Scale * inner.Scale, + outer.Rotation + inner.Rotation, + tx, ty); + } + + /// + /// Interpolazione fra due trasformazioni. La scala si interpola in scala logaritmica, + /// l'unica in cui il valore intermedio fra 1× e 4× è 2× e non 2,5×. + /// + public static SimilarityTransform Lerp(in SimilarityTransform a, in SimilarityTransform b, double t) + { + if (t <= 0) return a; + if (t >= 1) return b; + double logScale = Math.Log(Math.Max(1e-6, a.Scale)) * (1 - t) + Math.Log(Math.Max(1e-6, b.Scale)) * t; + return new SimilarityTransform(Math.Exp(logScale), + a.Rotation * (1 - t) + b.Rotation * t, + a.Tx * (1 - t) + b.Tx * t, + a.Ty * (1 - t) + b.Ty * t); + } +} + +/// Parametri della stabilizzazione sub-pixel. +public sealed class StabilizationSettings +{ + public bool Enabled { get; set; } + + /// Lato dei riquadri di correlazione, in pixel della risoluzione di analisi (potenza di due). + public int PatchSize { get; set; } = 128; + + /// Riquadri per lato: 3 significa nove misure indipendenti per coppia di fotogrammi. + public int Grid { get; set; } = 3; + + /// Larghezza di analisi della correlazione, in pixel. + public int AnalysisWidth { get; set; } = 960; + + /// + /// Finestra della lisciatura del percorso: quanto un movimento deve durare per essere + /// considerato voluto. Corta rimuove solo il tremolio, lunga blocca anche le panoramiche. + /// + public int SmoothingFrames { get; set; } = 45; + + /// Corregge anche la rotazione residua, tipica del vento che fa torcere la testa del treppiede. + public bool CompensateRotation { get; set; } = true; + + /// Quota della correzione applicata. + public double Strength { get; set; } = 1.0; + + /// Limite della correzione, in frazione della larghezza: protegge da stime sbagliate. + public double MaxCorrectionFraction { get; set; } = 0.06; + + /// Rapporto minimo fra picco e fondo della correlazione perché la misura sia accettata. + public double MinConfidence { get; set; } = 2.5; + + public StabilizationSettings Clone() => (StabilizationSettings)MemberwiseClone(); +} + +/// Percorso della camera ricostruito e correzione da applicare a ciascun fotogramma. +public sealed class StabilizationPath +{ + public required SimilarityTransform[] Correction { get; init; } + + /// Trasformazione assoluta misurata, rispetto al primo fotogramma. Serve al grafico. + public required SimilarityTransform[] Measured { get; init; } + + /// Spostamento residuo medio rimosso, in frazione della larghezza. + public double MeanShake { get; init; } + + /// Correzione massima applicata: da qui si ricava il margine da ritagliare. + public double MaxCorrection { get; init; } + + /// Frazione di coppie in cui la correlazione non ha dato un picco attendibile. + public double UnreliableFraction { get; init; } + + public int Count => Correction.Length; + + /// Correzione a posizione frazionaria, per i fotogrammi sintetizzati dal time-ramping. + public SimilarityTransform At(double position) + { + if (Correction.Length == 0) return SimilarityTransform.Identity; + int i = (int)Math.Floor(position); + if (i < 0) return Correction[0]; + if (i >= Correction.Length - 1) return Correction[^1]; + return SimilarityTransform.Lerp(Correction[i], Correction[i + 1], position - i); + } + + /// + /// Ingrandimento minimo che tiene i bordi vuoti fuori dall'inquadratura. Spostando il + /// fotogramma per compensare un urto si scopre una striscia di nulla sul lato opposto: + /// l'unico rimedio onesto è ritagliare quel tanto. + /// + public double RequiredZoom(double aspect) + { + double translation = MaxCorrection; + double rotation = 0; + foreach (var c in Correction) rotation = Math.Max(rotation, Math.Abs(c.Rotation)); + + // Il vertice più lontano dal centro percorre un arco di raggio pari alla semidiagonale. + double halfDiagonal = 0.5 * Math.Sqrt(1 + aspect * aspect); + double rotationLoss = halfDiagonal * Math.Abs(Math.Sin(rotation)); + double needed = 1.0 + 2.0 * (translation + rotationLoss); + return Math.Clamp(needed, 1.0, 1.35); + } +} + +/// +/// Stabilizzazione dei micro-urti: vento sul treppiede, passi vicino alla macchina, scatto +/// dello specchio. Sono spostamenti di pochi pixel che a velocità di time-lapse diventano un +/// tremolio continuo, ben visibile e impossibile da togliere in ripresa. +/// +/// Per ogni coppia di fotogrammi adiacenti si misura lo spostamento su una griglia di +/// riquadri con la correlazione di fase; dalle misure si stima ai minimi quadrati una +/// similitudine — traslazione, rotazione e scala — scartando i riquadri il cui picco non è +/// attendibile, tipicamente quelli occupati da nuvole o fronde che si muovono per conto loro. +/// +/// Le trasformazioni relative vengono poi composte in un percorso assoluto, che viene lisciato: +/// ciò che resta fra percorso misurato e percorso liscio è il tremolio, e la sua inversa è la +/// correzione. Una panoramica voluta sopravvive perché è già liscia; un urto no perché non lo è. +/// +/// L'istanza contiene i buffer della trasformata e non è condivisibile fra thread. +/// +public sealed class Stabilizer(StabilizationSettings settings) +{ + private readonly StabilizationSettings _settings = settings; + private PhaseCorrelator? _correlator; + + /// + /// Stima la trasformazione che porta le coordinate di in quelle di + /// , in unità normalizzate sulla larghezza. + /// + public SimilarityTransform Estimate(GrayImage a, GrayImage b, out double confidence) + { + confidence = 0; + if (a.Width != b.Width || a.Height != b.Height) return SimilarityTransform.Identity; + + int requested = Math.Max(32, Fourier.FloorPowerOfTwo(_settings.PatchSize)); + int patch = Fourier.FloorPowerOfTwo(Math.Min(requested, Math.Min(a.Width, a.Height))); + if (patch < 32) return SimilarityTransform.Identity; + + if (_correlator is null || _correlator.Size != patch) _correlator = new PhaseCorrelator(patch); + + int grid = Math.Clamp(_settings.Grid, 1, 5); + int spanX = Math.Max(0, a.Width - patch); + int spanY = Math.Max(0, a.Height - patch); + + double width = a.Width; + double centreX = a.Width / 2.0; + double centreY = a.Height / 2.0; + + var px = new double[grid * grid]; + var py = new double[grid * grid]; + var qx = new double[grid * grid]; + var qy = new double[grid * grid]; + var weight = new double[grid * grid]; + int samples = 0; + double confidenceSum = 0; + + for (int gy = 0; gy < grid; gy++) + { + int originY = grid == 1 ? spanY / 2 : spanY * gy / (grid - 1); + for (int gx = 0; gx < grid; gx++) + { + int originX = grid == 1 ? spanX / 2 : spanX * gx / (grid - 1); + var shift = _correlator.Correlate(a, b, originX, originY); + + confidenceSum += shift.Confidence; + if (shift.Confidence < _settings.MinConfidence) continue; + + // Uno spostamento superiore a un quarto del fotogramma non è un micro-urto: + // è un picco spurio, oppure la scena è cambiata del tutto. + if (Math.Abs(shift.Dx) > a.Width * 0.25 || Math.Abs(shift.Dy) > a.Height * 0.25) continue; + + double ux = (originX + patch / 2.0 - centreX) / width; + double uy = (originY + patch / 2.0 - centreY) / width; + + px[samples] = ux; + py[samples] = uy; + qx[samples] = ux + shift.Dx / width; + qy[samples] = uy + shift.Dy / width; + weight[samples] = shift.Confidence; + samples++; + } + } + + confidence = grid * grid > 0 ? confidenceSum / (grid * grid) : 0; + if (samples == 0) return SimilarityTransform.Identity; + + var estimate = Fit(px, py, qx, qy, weight, samples, _settings.CompensateRotation); + + // Seconda passata: i riquadri che si discostano dal modello comune vengono spenti. + // È il caso classico del riquadro sul cielo, dove le nuvole scorrono da sole. + if (samples >= 4) + { + var residuals = new double[samples]; + for (int i = 0; i < samples; i++) + { + var (fx, fy) = estimate.Apply(px[i], py[i]); + residuals[i] = Math.Sqrt((fx - qx[i]) * (fx - qx[i]) + (fy - qy[i]) * (fy - qy[i])); + } + + var sorted = (double[])residuals.Clone(); + Array.Sort(sorted); + double median = sorted[samples / 2]; + double scale = Math.Max(1.4826 * median, 1e-5); + + for (int i = 0; i < samples; i++) + { + double u = residuals[i] / (3.0 * scale); + weight[i] *= u >= 1 ? 0 : (1 - u * u) * (1 - u * u); + } + + double total = 0; + for (int i = 0; i < samples; i++) total += weight[i]; + if (total > 1e-9) estimate = Fit(px, py, qx, qy, weight, samples, _settings.CompensateRotation); + } + + return Sanitize(estimate); + } + + /// Similitudine ai minimi quadrati pesati fra due insiemi di punti omologhi. + private static SimilarityTransform Fit(double[] px, double[] py, double[] qx, double[] qy, + double[] weight, int count, bool allowRotation) + { + double sw = 0, pxBar = 0, pyBar = 0, qxBar = 0, qyBar = 0; + for (int i = 0; i < count; i++) + { + double w = weight[i]; + if (w <= 0) continue; + sw += w; + pxBar += w * px[i]; + pyBar += w * py[i]; + qxBar += w * qx[i]; + qyBar += w * qy[i]; + } + if (sw <= 1e-9) return SimilarityTransform.Identity; + + pxBar /= sw; pyBar /= sw; qxBar /= sw; qyBar /= sw; + + if (!allowRotation) return new SimilarityTransform(1, 0, qxBar - pxBar, qyBar - pyBar); + + double dot = 0, cross = 0, norm = 0; + for (int i = 0; i < count; i++) + { + double w = weight[i]; + if (w <= 0) continue; + double ax = px[i] - pxBar, ay = py[i] - pyBar; + double bx = qx[i] - qxBar, by = qy[i] - qyBar; + dot += w * (ax * bx + ay * by); + cross += w * (ax * by - ay * bx); + norm += w * (ax * ax + ay * ay); + } + + // Riquadri troppo vicini fra loro non vincolano rotazione e scala: resta la traslazione. + if (norm < 1e-9) return new SimilarityTransform(1, 0, qxBar - pxBar, qyBar - pyBar); + + double rotation = Math.Atan2(cross, dot); + double scale = Math.Sqrt(dot * dot + cross * cross) / norm; + if (double.IsNaN(scale) || scale <= 0) scale = 1; + + double cos = Math.Cos(rotation), sin = Math.Sin(rotation); + double tx = qxBar - scale * (cos * pxBar - sin * pyBar); + double ty = qyBar - scale * (sin * pxBar + cos * pyBar); + return new SimilarityTransform(scale, rotation, tx, ty); + } + + /// Limiti fisici: fra due scatti adiacenti nessuno di questi parametri può esplodere. + private static SimilarityTransform Sanitize(in SimilarityTransform t) + { + double scale = double.IsFinite(t.Scale) ? Math.Clamp(t.Scale, 0.9, 1.1) : 1; + double rotation = double.IsFinite(t.Rotation) ? Math.Clamp(t.Rotation, -0.09, 0.09) : 0; + double tx = double.IsFinite(t.Tx) ? Math.Clamp(t.Tx, -0.25, 0.25) : 0; + double ty = double.IsFinite(t.Ty) ? Math.Clamp(t.Ty, -0.25, 0.25) : 0; + return new SimilarityTransform(scale, rotation, tx, ty); + } + + // ------------------------------------------------------------------ percorso + + /// + /// Compone le trasformazioni relative in un percorso assoluto, lo liscia e ne ricava la + /// correzione per fotogramma. [i] porta le coordinate del + /// fotogramma i−1 in quelle del fotogramma i; l'elemento zero è ignorato. + /// + public static StabilizationPath BuildPath(IReadOnlyList relative, + IReadOnlyList confidence, + StabilizationSettings settings) + { + int n = relative.Count; + var measured = new SimilarityTransform[n]; + var correction = new SimilarityTransform[n]; + + if (n == 0) + { + return new StabilizationPath { Correction = correction, Measured = measured }; + } + + measured[0] = SimilarityTransform.Identity; + for (int i = 1; i < n; i++) measured[i] = SimilarityTransform.Compose(relative[i], measured[i - 1]); + + var tx = new double[n]; + var ty = new double[n]; + var rotation = new double[n]; + var logScale = new double[n]; + for (int i = 0; i < n; i++) + { + tx[i] = measured[i].Tx; + ty[i] = measured[i].Ty; + rotation[i] = measured[i].Rotation; + logScale[i] = Math.Log(Math.Max(1e-6, measured[i].Scale)); + } + + int window = Math.Max(3, settings.SmoothingFrames | 1); + var smoothTx = LocalRegression.Smooth(tx, window, false); + var smoothTy = LocalRegression.Smooth(ty, window, false); + var smoothRotation = LocalRegression.Smooth(rotation, window, false); + var smoothLogScale = LocalRegression.Smooth(logScale, window, false); + + double strength = Math.Clamp(settings.Strength, 0, 1); + double limit = Math.Max(0.001, settings.MaxCorrectionFraction); + double shakeSum = 0; + double maxCorrection = 0; + + for (int i = 0; i < n; i++) + { + var target = new SimilarityTransform(Math.Exp(smoothLogScale[i]), smoothRotation[i], + smoothTx[i], smoothTy[i]); + + // Dal punto in cui il fotogramma si trova davvero a quello in cui dovrebbe stare. + var raw = SimilarityTransform.Compose(target, measured[i].Inverse); + var scaled = SimilarityTransform.Lerp(SimilarityTransform.Identity, raw, strength); + + double magnitude = Math.Sqrt(scaled.Tx * scaled.Tx + scaled.Ty * scaled.Ty); + if (magnitude > limit) + { + double factor = limit / magnitude; + scaled = new SimilarityTransform(scaled.Scale, scaled.Rotation, + scaled.Tx * factor, scaled.Ty * factor); + magnitude = limit; + } + + correction[i] = scaled; + shakeSum += magnitude; + maxCorrection = Math.Max(maxCorrection, magnitude); + } + + int unreliable = 0; + for (int i = 1; i < n && i < confidence.Count; i++) + { + if (confidence[i] < settings.MinConfidence) unreliable++; + } + + return new StabilizationPath + { + Correction = correction, + Measured = measured, + MeanShake = shakeSum / n, + MaxCorrection = maxCorrection, + UnreliableFraction = n > 1 ? unreliable / (double)(n - 1) : 0, + }; + } +} diff --git a/Titano/Motion/TemporalStacker.cs b/Titano/Motion/TemporalStacker.cs new file mode 100644 index 0000000..044a7d7 --- /dev/null +++ b/Titano/Motion/TemporalStacker.cs @@ -0,0 +1,217 @@ +using Titano.Imaging; + +namespace Titano.Motion; + +/// Modalità di accumulo temporale. +public enum StackingMode +{ + Off, + + /// Mediana su una finestra di fotogrammi: rimuove ciò che passa e non appartiene alla scena. + Median, + + /// Massimo progressivo: fonde i picchi di luce in scie continue. + Maximum, +} + +/// Parametri dell'accumulo temporale. +public sealed class StackingSettings +{ + public StackingMode Mode { get; set; } = StackingMode.Off; + + /// Fotogrammi della finestra della mediana; viene reso dispari internamente. + public int WindowFrames { get; set; } = 5; + + /// Quota dell'effetto miscelata sul fotogramma originale. + public double Strength { get; set; } = 1.0; + + /// + /// Lunghezza delle scie stellari in fotogrammi; 0 significa scie che non si spengono mai. + /// Un valore finito fa svanire la coda e mantiene leggibile il paesaggio sotto. + /// + public double TrailFrames { get; set; } + + public StackingSettings Clone() => (StackingSettings)MemberwiseClone(); + + /// Semiampiezza effettiva della finestra: quanti fotogrammi servono da ogni lato. + public int MedianRadius => Mode == StackingMode.Median + ? Math.Clamp((Math.Max(3, WindowFrames) - 1) / 2, 1, 24) + : 0; +} + +/// +/// Accumulo e filtraggio temporale. +/// +/// Mediana. Su una finestra di fotogrammi consecutivi, ogni pixel prende il valore +/// centrale della propria serie temporale. Ciò che è stabile — il paesaggio — resta se stesso, +/// perché la maggioranza dei campioni lo mostra; ciò che attraversa l'inquadratura una volta +/// sola — una persona, un'automobile, un uccello, il faro di un'auto lontana — occupa una +/// minoranza dei campioni e la mediana lo scarta. La media non funzionerebbe: lascerebbe un +/// fantasma tanto più visibile quanto più l'intruso era contrastato. +/// +/// Massimo. Ogni pixel trattiene il valore più alto incontrato finora. Sui cieli +/// notturni è il modo classico di ottenere le scie stellari: la stella si sposta di poco a +/// ogni scatto, il massimo ne conserva il passaggio e il risultato è un arco continuo invece +/// di un punto. Con una lunghezza di scia finita l'accumulo sfuma piano, e le scie hanno una +/// coda invece di riempire progressivamente tutto il cielo. +/// +/// Entrambe le modalità accettano un allineamento per fotogramma: se la stabilizzazione è +/// attiva i fotogrammi vanno sovrapposti dopo averli raddrizzati, altrimenti la mediana +/// vedrebbe come intruso il tremolio stesso e le scie uscirebbero doppie. +/// +public static class TemporalStacker +{ + /// Numero massimo di fotogrammi mediabili in una finestra. + public const int MaxWindow = 49; + + /// + /// Mediana temporale della finestra indicata, miscelata sul fotogramma centrale secondo + /// l'intensità. può essere null quando i fotogrammi sono già + /// sovrapposti; altrimenti ogni elemento porta le coordinate del fotogramma centrale in + /// quelle del fotogramma corrispondente. + /// + public static void Median(IReadOnlyList window, IReadOnlyList? alignment, + ImageBuffer centre, ImageBuffer destination, double strength) + { + int count = Math.Min(window.Count, MaxWindow); + if (count <= 1) + { + destination.CopyFrom(centre); + return; + } + + int width = destination.Width; + int height = destination.Height; + var dst = destination.Data; + var src = centre.Data; + float blend = (float)Math.Clamp(strength, 0, 1); + bool aligned = alignment is not null; + + Parallel.For(0, height, y => + { + Span red = stackalloc float[MaxWindow]; + Span green = stackalloc float[MaxWindow]; + Span blue = stackalloc float[MaxWindow]; + + int rowBase = y * width * ImageBuffer.Channels; + + for (int x = 0; x < width; x++) + { + for (int k = 0; k < count; k++) + { + var frame = window[k]; + if (aligned) + { + var (sx, sy) = alignment![k].Apply(x, y); + MotionBlurRenderer.SampleBilinear(frame.Data, frame.Width, frame.Height, + (float)sx, (float)sy, + out red[k], out green[k], out blue[k]); + } + else + { + int index = (y * frame.Width + x) * ImageBuffer.Channels; + red[k] = frame.Data[index]; + green[k] = frame.Data[index + 1]; + blue[k] = frame.Data[index + 2]; + } + } + + int destinationIndex = rowBase + x * ImageBuffer.Channels; + dst[destinationIndex] = Mix(src[destinationIndex], Median(red[..count]), blend); + dst[destinationIndex + 1] = Mix(src[destinationIndex + 1], Median(green[..count]), blend); + dst[destinationIndex + 2] = Mix(src[destinationIndex + 2], Median(blue[..count]), blend); + } + }); + } + + /// + /// Aggiorna l'accumulatore dei massimi con un nuovo fotogramma. Con + /// minore di uno l'accumulo decade e le scie hanno una coda. + /// + public static void Accumulate(ImageBuffer accumulator, ImageBuffer frame, + in SourceMapping alignment, float fade) + { + int width = accumulator.Width; + int height = accumulator.Height; + var acc = accumulator.Data; + bool aligned = !alignment.IsIdentity || frame.Width != width || frame.Height != height; + + double ax = alignment.Ax, bx = alignment.Bx, tx = alignment.Tx; + double ay = alignment.Ay, by = alignment.By, ty = alignment.Ty; + + Parallel.For(0, height, y => + { + int rowBase = y * width * ImageBuffer.Channels; + double rowX = bx * y + tx; + double rowY = by * y + ty; + + for (int x = 0; x < width; x++) + { + float r, g, b; + if (aligned) + { + MotionBlurRenderer.SampleBilinear(frame.Data, frame.Width, frame.Height, + (float)(ax * x + rowX), (float)(ay * x + rowY), + out r, out g, out b); + } + else + { + int source = rowBase + x * ImageBuffer.Channels; + r = frame.Data[source]; + g = frame.Data[source + 1]; + b = frame.Data[source + 2]; + } + + int index = rowBase + x * ImageBuffer.Channels; + acc[index] = MathF.Max(acc[index] * fade, r); + acc[index + 1] = MathF.Max(acc[index + 1] * fade, g); + acc[index + 2] = MathF.Max(acc[index + 2] * fade, b); + } + }); + } + + /// Miscela l'accumulatore sul fotogramma corrente secondo l'intensità richiesta. + public static void Blend(ImageBuffer accumulator, ImageBuffer current, ImageBuffer destination, double strength) + { + float blend = (float)Math.Clamp(strength, 0, 1); + var acc = accumulator.Data; + var src = current.Data; + var dst = destination.Data; + int count = destination.SampleCount; + + if (blend >= 0.999f) + { + Array.Copy(acc, dst, count); + return; + } + + for (int i = 0; i < count; i++) dst[i] = src[i] + (acc[i] - src[i]) * blend; + } + + /// Fattore di decadimento per la lunghezza di scia richiesta. + public static float FadeFactor(double trailFrames) + => trailFrames <= 0 ? 1f : (float)Math.Exp(-1.0 / Math.Max(1.0, trailFrames)); + + private static float Mix(float original, float stacked, float blend) + => original + (stacked - original) * blend; + + /// + /// Mediana per inserzione. Su finestre di pochi elementi batte qualunque algoritmo + /// asintoticamente migliore: nessuna allocazione, nessun salto, tutto in registri. + /// + private static float Median(Span values) + { + for (int i = 1; i < values.Length; i++) + { + float key = values[i]; + int j = i - 1; + while (j >= 0 && values[j] > key) + { + values[j + 1] = values[j]; + j--; + } + values[j + 1] = key; + } + return values[values.Length / 2]; + } +} diff --git a/Titano/Motion/VirtualCamera.cs b/Titano/Motion/VirtualCamera.cs new file mode 100644 index 0000000..79481dc --- /dev/null +++ b/Titano/Motion/VirtualCamera.cs @@ -0,0 +1,144 @@ +using Titano.Core; + +namespace Titano.Motion; + +/// +/// Nodo del movimento virtuale: dove sta il centro dell'inquadratura e quanto è stretta, +/// a un dato punto della sequenza. Le maniglie governano il modo in cui il movimento +/// parte da qui e arriva qui. +/// +public sealed class CameraKeyframe +{ + /// Posizione lungo la sequenza, da 0 (primo fotogramma) a 1 (ultimo). + public double Time { get; set; } + + /// Centro dell'inquadratura in coordinate normalizzate; 0,5 è il centro del sensore. + public double CentreX { get; set; } = 0.5; + public double CentreY { get; set; } = 0.5; + + /// Fattore di ritaglio: 1 usa tutto il fotogramma, 2 ne usa metà per lato. + public double Zoom { get; set; } = 1.0; + + /// Quanto il movimento indugia partendo da questo nodo, da 0 (parte netto) a 1 (parte lentissimo). + public double EaseOut { get; set; } = 0.42; + + /// Quanto il movimento rallenta arrivando su questo nodo. + public double EaseIn { get; set; } = 0.42; + + public CameraKeyframe Clone() => (CameraKeyframe)MemberwiseClone(); +} + +/// Inquadratura risolta a un istante: centro e ritaglio, in coordinate normalizzate. +public readonly record struct CameraFraming(double CentreX, double CentreY, double Zoom) +{ + public static CameraFraming Full => new(0.5, 0.5, 1.0); +} + +/// Parametri del movimento di macchina virtuale. +public sealed class VirtualCameraSettings +{ + public bool Enabled { get; set; } + + public List Keyframes { get; set; } = + [ + new() { Time = 0.0 }, + new() { Time = 1.0 }, + ]; + + /// Impedisce all'inquadratura di uscire dal fotogramma sorgente. + public bool KeepInsideFrame { get; set; } = true; + + public VirtualCameraSettings Clone() + { + var copy = (VirtualCameraSettings)MemberwiseClone(); + copy.Keyframes = [.. Keyframes.Select(k => k.Clone())]; + return copy; + } +} + +/// +/// Movimento di macchina virtuale: panoramiche, inclinazioni e carrellate ottenute ritagliando +/// dentro la risoluzione nativa invece di muovere qualcosa in ripresa. +/// +/// Un sensore da dodici megapixel contiene un 4K con un fattore di ritaglio abbondante: da lì +/// si ricava un movimento che in ripresa avrebbe richiesto una slitta motorizzata, e lo si +/// decide dopo, guardando il materiale. Il prezzo è la risoluzione consumata dallo zoom, ed è +/// l'unico vincolo reale del metodo. +/// +/// Fra un nodo e il successivo il tempo non scorre uniforme: passa per una curva di Bézier +/// con estremi fissi, la stessa che i programmi di montaggio chiamano accelerazione. Senza di +/// essa il movimento partirebbe e si fermerebbe di colpo, e in un time-lapse — dove ogni +/// difetto di fluidità è amplificato dalla velocità — lo scatto si vede benissimo. +/// +public static class VirtualCamera +{ + /// Inquadratura all'istante normalizzato ∈ [0,1]. + public static CameraFraming Resolve(VirtualCameraSettings settings, double time) + { + if (!settings.Enabled || settings.Keyframes.Count == 0) return CameraFraming.Full; + + var keyframes = settings.Keyframes; + if (keyframes.Count == 1) return Framing(keyframes[0]); + + // I nodi vivono in una lista che l'utente può riordinare a piacere: qui serve + // l'ordine temporale, e cercarlo linearmente su una manciata di elementi non costa nulla. + int index = -1; + for (int i = 0; i < keyframes.Count - 1; i++) + { + if (time >= keyframes[i].Time && time <= keyframes[i + 1].Time) { index = i; break; } + } + + if (index < 0) return time <= keyframes[0].Time ? Framing(keyframes[0]) : Framing(keyframes[^1]); + + var from = keyframes[index]; + var to = keyframes[index + 1]; + double span = to.Time - from.Time; + if (span <= 1e-9) return Framing(to); + + double local = (time - from.Time) / span; + + // Maniglie: la prima allunga l'avvio, la seconda anticipa la frenata. Il valore + // predefinito 0,42 è la curva morbida simmetrica di uso comune. + double eased = Spline.Ease(local, + Math.Clamp(from.EaseOut, 0, 1), 0.0, + 1.0 - Math.Clamp(to.EaseIn, 0, 1), 1.0); + + double zoom = Math.Exp(Lerp(Math.Log(Math.Max(1e-3, from.Zoom)), + Math.Log(Math.Max(1e-3, to.Zoom)), eased)); + + return new CameraFraming(Lerp(from.CentreX, to.CentreX, eased), + Lerp(from.CentreY, to.CentreY, eased), + zoom); + } + + /// + /// Riporta l'inquadratura dentro il fotogramma sorgente. Un centro troppo spostato + /// scoprirebbe una fascia vuota sul lato opposto: si preferisce arrestare la panoramica + /// sul bordo piuttosto che mostrare il nulla. + /// + public static CameraFraming Constrain(in CameraFraming framing, bool keepInside, double extraZoom = 1.0) + { + double zoom = Math.Max(1e-3, framing.Zoom * Math.Max(1e-3, extraZoom)); + if (!keepInside) return new CameraFraming(framing.CentreX, framing.CentreY, zoom); + + zoom = Math.Max(1.0, zoom); + double half = 0.5 / zoom; + return new CameraFraming(Math.Clamp(framing.CentreX, half, 1 - half), + Math.Clamp(framing.CentreY, half, 1 - half), + zoom); + } + + /// Zoom massimo richiesto dai nodi: serve a dire quanta risoluzione nativa occorre. + public static double MaximumZoom(VirtualCameraSettings settings) + { + double max = 1.0; + if (!settings.Enabled) return max; + foreach (var keyframe in settings.Keyframes) max = Math.Max(max, keyframe.Zoom); + return max; + } + + private static CameraFraming Framing(CameraKeyframe keyframe) + => new(keyframe.CentreX, keyframe.CentreY, keyframe.Zoom); + + private static double Lerp(double a, double b, double t) => a + (b - a) * t; +} diff --git a/Titano/Pipeline/FrameWindow.cs b/Titano/Pipeline/FrameWindow.cs new file mode 100644 index 0000000..74e6f46 --- /dev/null +++ b/Titano/Pipeline/FrameWindow.cs @@ -0,0 +1,425 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using Titano.Imaging; + +namespace Titano.Pipeline; + +/// Come vengono gestiti i fotogrammi decodificati in anticipo che non stanno in memoria. +public sealed class FrameCacheSettings +{ + /// + /// Quanti fotogrammi decodificare in anticipo oltre la finestra attiva. Una lettura di + /// anticipo profonda tiene occupati tutti i processori sulla decodifica dei RAW, che è la + /// parte più lenta della pipeline e l'unica che non si può accelerare in altro modo. + /// + public int PrefetchDepth { get; set; } = 8; + + /// + /// Memoria concessa ai fotogrammi decodificati, in mebibyte. 0 significa nessun limite. + /// La finestra attiva sta sempre in memoria: il limite governa la lettura di anticipo. + /// + public int MemoryBudgetMiB { get; set; } = 3072; + + /// Consente di parcheggiare su disco i fotogrammi in eccesso invece di rinunciarvi. + public bool AllowDiskSpill { get; set; } = true; + + /// Cartella del file di parcheggio; vuota significa la cartella temporanea di sistema. + public string SpillDirectory { get; set; } = string.Empty; + + public FrameCacheSettings Clone() => (FrameCacheSettings)MemberwiseClone(); +} + +/// Contatori della finestra, riportati a fine esportazione. +public readonly record struct FrameWindowStatistics(long PeakResidentBytes, int SpilledFrames, + long SpillBytesWritten, long SpillBytesRead, + int Decoded, int Failed); + +/// +/// Finestra scorrevole di fotogrammi decodificati, con lettura di anticipo e parcheggio su disco. +/// +/// I moduli avanzati hanno rotto l'assunto su cui la pipeline originale era costruita: che +/// bastassero due fotogrammi vivi alla volta. La mediana temporale ne vuole un'intera finestra +/// simultaneamente, la rimappatura del tempo può chiedere lo stesso fotogramma per molti +/// fotogrammi d'uscita consecutivi. L'occupazione resta comunque indipendente dalla lunghezza +/// della sequenza — dipende dall'ampiezza della finestra, che l'utente decide — ma non è più +/// una manciata di buffer. +/// +/// La regola di residenza è semplice e non produce mai andirivieni: la finestra attiva sta +/// sempre in memoria, perché chi la usa ha bisogno di tutti i suoi fotogrammi insieme; i +/// fotogrammi letti in anticipo, che serviranno solo più avanti, finiscono su disco quando il +/// tetto di memoria è raggiunto e vengono ripresi una volta sola, quando entrano nella +/// finestra. Non esiste un caso in cui lo stesso fotogramma vada e torni dal disco più volte. +/// +/// Il file di parcheggio nasce con l'opzione di cancellazione alla chiusura: se il programma +/// termina male, il sistema lo rimuove comunque. +/// +public sealed class FrameWindow : IDisposable +{ + private readonly int _count; + private readonly int _width; + private readonly int _height; + private readonly long _frameBytes; + private readonly int _parallelism; + private readonly FrameCacheSettings _settings; + private readonly Func _decode; + private readonly FrameBufferPool _pool; + + private readonly Dictionary _resident = []; + private readonly Dictionary _spilled = []; + private readonly Dictionary _pending = []; + private readonly HashSet _failed = []; + private readonly List _freeBlocks = []; + private readonly Lock _gate = new(); + + private SafeFileHandle? _spillHandle; + private string? _spillPath; + private long _spillLength; + private long _residentBytes; + private long _peakResidentBytes; + private long _spillWritten; + private long _spillRead; + private int _spilledFrames; + private int _decoded; + private int _first = int.MaxValue; + private int _last = int.MinValue; + + public FrameWindow(int count, int width, int height, int parallelism, + FrameCacheSettings settings, Func decode) + { + _count = count; + _width = width; + _height = height; + _frameBytes = (long)width * height * ImageBuffer.Channels * sizeof(float); + _parallelism = Math.Clamp(parallelism, 1, 32); + _settings = settings; + _decode = decode; + + // Il pool deve poter contenere la finestra, la lettura di anticipo e i buffer di lavoro. + _pool = new FrameBufferPool(_parallelism + Math.Max(4, settings.PrefetchDepth) + 8); + } + + public FrameBufferPool Pool => _pool; + + public FrameWindowStatistics Statistics + { + get + { + lock (_gate) + { + return new FrameWindowStatistics(_peakResidentBytes, _spilledFrames, _spillWritten, + _spillRead, _decoded, _failed.Count); + } + } + } + + /// Vero se il tetto di memoria ha costretto a usare il disco almeno una volta. + public bool UsedDisk => _spilledFrames > 0; + + private long Budget => _settings.MemoryBudgetMiB <= 0 + ? long.MaxValue + : (long)_settings.MemoryBudgetMiB * 1024 * 1024; + + /// + /// Porta in memoria l'intervallo richiesto, libera ciò che sta prima e lancia la lettura + /// di anticipo. Gli estremi vengono riportati dentro la sequenza dal chiamante. + /// + public void EnsureRange(int first, int last, CancellationToken cancellation) + { + first = Math.Clamp(first, 0, Math.Max(0, _count - 1)); + last = Math.Clamp(last, first, Math.Max(0, _count - 1)); + _first = first; + _last = last; + + Evict(first); + StartDecoding(first, Math.Min(_count - 1, last + Math.Max(0, _settings.PrefetchDepth)), cancellation); + WaitFor(first, last, cancellation); + + for (int i = first; i <= last; i++) PageIn(i); + } + + /// + /// Fotogramma già portato in memoria da . Restituisce null solo + /// se il file era illeggibile: il chiamante decide con cosa sostituirlo. + /// + public ImageBuffer? Get(int index) + { + lock (_gate) + { + return _resident.TryGetValue(index, out var buffer) ? buffer : null; + } + } + + /// + /// Il fotogramma richiesto oppure, se il file era illeggibile, quello valido più vicino + /// fra quelli in memoria. Ripiegare sul vicino costa nulla e mantiene il sincronismo: + /// nel video si vede un fotogramma ripetuto invece di un salto. + /// + public ImageBuffer? GetNearest(int index) + { + lock (_gate) + { + if (_resident.TryGetValue(index, out var exact)) return exact; + + for (int distance = 1; distance <= _count; distance++) + { + if (_resident.TryGetValue(index - distance, out var before)) return before; + if (_resident.TryGetValue(index + distance, out var after)) return after; + if (index - distance < 0 && index + distance >= _count) break; + } + return null; + } + } + + // ------------------------------------------------------------------ decodifica + + private void StartDecoding(int first, int last, CancellationToken cancellation) + { + lock (_gate) + { + int inFlight = _pending.Count; + + for (int i = first; i <= last; i++) + { + if (inFlight >= _parallelism && i > _last) break; // l'anticipo non scavalca la finestra + if (_resident.ContainsKey(i) || _spilled.ContainsKey(i) || + _pending.ContainsKey(i) || _failed.Contains(i)) continue; + + int index = i; + _pending[index] = Task.Run(() => DecodeInto(index), cancellation); + inFlight++; + } + } + } + + /// + /// Decodifica un fotogramma e decide dove metterlo. La scelta avviene qui, dentro il + /// lucchetto, perché il tetto di memoria va confrontato con l'occupazione del momento e + /// non con quella di quando il compito è stato creato. + /// + private void DecodeInto(int index) + { + ImageBuffer? buffer; + try + { + buffer = _decode(index, _pool); + } + catch (Exception) + { + buffer = null; + } + + lock (_gate) + { + _decoded++; + if (buffer is null) + { + _failed.Add(index); + return; + } + + bool insideWindow = index >= _first && index <= _last; + bool fits = _residentBytes + _frameBytes <= Budget; + + if (insideWindow || fits || !_settings.AllowDiskSpill) + { + Store(index, buffer); + return; + } + } + + // La scrittura su disco avviene fuori dal lucchetto: è l'operazione lenta e non deve + // fermare gli altri thread di decodifica. + long offset = Spill(index, buffer); + if (offset >= 0) return; + + lock (_gate) Store(index, buffer); + } + + private void Store(int index, ImageBuffer buffer) + { + if (!_resident.TryAdd(index, buffer)) + { + buffer.Dispose(); + return; + } + _residentBytes += _frameBytes; + if (_residentBytes > _peakResidentBytes) _peakResidentBytes = _residentBytes; + } + + private void WaitFor(int first, int last, CancellationToken cancellation) + { + while (true) + { + cancellation.ThrowIfCancellationRequested(); + + var waiting = new List(); + lock (_gate) + { + for (int i = first; i <= last; i++) + { + if (_pending.TryGetValue(i, out var task)) waiting.Add(task); + } + } + + if (waiting.Count == 0) break; + Task.WaitAll([.. waiting], cancellation); + + lock (_gate) + { + foreach (int key in _pending.Keys.ToArray()) + { + if (_pending[key].IsCompleted) _pending.Remove(key); + } + } + } + } + + // ------------------------------------------------------------------ disco + + private long Spill(int index, ImageBuffer buffer) + { + try + { + var handle = OpenSpill(); + long offset; + + lock (_gate) + { + if (_freeBlocks.Count > 0) + { + offset = _freeBlocks[^1]; + _freeBlocks.RemoveAt(_freeBlocks.Count - 1); + } + else + { + offset = _spillLength; + _spillLength += _frameBytes; + } + } + + var bytes = MemoryMarshal.AsBytes(buffer.Data.AsSpan(0, buffer.SampleCount)); + RandomAccess.Write(handle, bytes, offset); + + lock (_gate) + { + _spilled[index] = offset; + _spilledFrames++; + _spillWritten += _frameBytes; + } + + buffer.Dispose(); + return offset; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + // Disco pieno o non scrivibile: si tiene tutto in memoria e si tira avanti. + return -1; + } + } + + private SafeFileHandle OpenSpill() + { + lock (_gate) + { + if (_spillHandle is not null) return _spillHandle; + + string directory = string.IsNullOrWhiteSpace(_settings.SpillDirectory) + ? Path.GetTempPath() + : _settings.SpillDirectory; + Directory.CreateDirectory(directory); + + _spillPath = Path.Combine(directory, + $"titano-{Environment.ProcessId:X}-{Guid.NewGuid():N}.frames"); + + _spillHandle = File.OpenHandle(_spillPath, FileMode.CreateNew, FileAccess.ReadWrite, + FileShare.None, FileOptions.DeleteOnClose | FileOptions.RandomAccess); + return _spillHandle; + } + } + + /// Riporta in memoria un fotogramma parcheggiato. Avviene una volta sola per fotogramma. + private void PageIn(int index) + { + long offset; + SafeFileHandle handle; + + lock (_gate) + { + if (_resident.ContainsKey(index) || !_spilled.TryGetValue(index, out offset)) return; + if (_spillHandle is null) return; + handle = _spillHandle; + } + + var buffer = _pool.Rent(_width, _height); + try + { + var bytes = MemoryMarshal.AsBytes(buffer.Data.AsSpan(0, buffer.SampleCount)); + int read = RandomAccess.Read(handle, bytes, offset); + + lock (_gate) + { + if (read < bytes.Length) { _failed.Add(index); buffer.Dispose(); } + else Store(index, buffer); + + _spilled.Remove(index); + _freeBlocks.Add(offset); + _spillRead += read; + } + } + catch (IOException) + { + buffer.Dispose(); + lock (_gate) _failed.Add(index); + } + } + + private void Evict(int before) + { + lock (_gate) + { + foreach (int key in _resident.Keys.ToArray()) + { + if (key >= before) continue; + _resident[key].Dispose(); + _resident.Remove(key); + _residentBytes -= _frameBytes; + } + + foreach (int key in _spilled.Keys.ToArray()) + { + if (key >= before) continue; + _freeBlocks.Add(_spilled[key]); + _spilled.Remove(key); + } + } + } + + public void Dispose() + { + Task[] pending; + lock (_gate) pending = [.. _pending.Values]; + + try { Task.WaitAll(pending, TimeSpan.FromSeconds(10)); } + catch (Exception) { /* le decodifiche interrotte non hanno nulla da salvare */ } + + lock (_gate) + { + foreach (var buffer in _resident.Values) buffer.Dispose(); + _resident.Clear(); + _spilled.Clear(); + _pending.Clear(); + _residentBytes = 0; + + _spillHandle?.Dispose(); + _spillHandle = null; + } + + // DeleteOnClose se ne occupa già; questo copre il caso in cui l'opzione non sia + // stata onorata dal file system sottostante. + if (_spillPath is not null && File.Exists(_spillPath)) + { + try { File.Delete(_spillPath); } catch (IOException) { } + } + _spillPath = null; + } +} diff --git a/Titano/Pipeline/PipelineProgress.cs b/Titano/Pipeline/PipelineProgress.cs index 7fe843b..16b8a58 100644 --- a/Titano/Pipeline/PipelineProgress.cs +++ b/Titano/Pipeline/PipelineProgress.cs @@ -30,4 +30,11 @@ public sealed record RenderResult( TimeSpan Elapsed, string EncoderName, bool HardwareAccelerated, - long PeakPixelMemoryBytes); + long PeakPixelMemoryBytes, + string PlanDescription = "", + int SpilledFrames = 0, + long SpillBytes = 0) +{ + /// Vero se il tetto di memoria ha imposto di parcheggiare fotogrammi su disco. + public bool UsedDisk => SpilledFrames > 0; +} diff --git a/Titano/Pipeline/RenderPipeline.cs b/Titano/Pipeline/RenderPipeline.cs index b1ce3c6..a88a9bf 100644 --- a/Titano/Pipeline/RenderPipeline.cs +++ b/Titano/Pipeline/RenderPipeline.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Threading.Channels; using Titano.Analysis; using Titano.Core; using Titano.Imaging; @@ -12,11 +11,16 @@ namespace Titano.Pipeline; /// /// Orchestrazione delle fasi di lavoro. /// -/// Architettura a flusso: i fotogrammi vengono decodificati da più thread e consegnati in -/// ordine attraverso un canale a capacità limitata. Il limite del canale coincide con il -/// numero di decodifiche simultanee, quindi il numero di buffer vivi — e con esso -/// l'occupazione di memoria — resta costante qualunque sia la lunghezza della sequenza. -/// Nessuna fase scrive su disco: l'unico file prodotto è il video finale. +/// Il rendering non percorre più la sequenza sorgente ma un : un +/// elenco di posizioni, anche frazionarie, con la relativa durata. Tutte le modalità +/// temporali — durata costante, proporzionale, cadenza uniformata, rimappatura non lineare — +/// si riducono a quella forma, quindi il ciclo di rendering è uno solo e non contiene rami +/// per i singoli casi. +/// +/// I fotogrammi arrivano da una finestra scorrevole che li decodifica in anticipo su più +/// thread e, se il tetto di memoria lo impone, ne parcheggia una parte su disco. L'unico file +/// che sopravvive all'elaborazione resta il video: il parcheggio nasce con la cancellazione +/// automatica alla chiusura. /// public sealed class RenderPipeline(TitanoProject project) { @@ -58,12 +62,17 @@ public sealed class RenderPipeline(TitanoProject project) return sequence; } - // ------------------------------------------------------------------ analisi fotometrica + // ------------------------------------------------------------------ analisi /// - /// Passata di analisi: misura la luminanza di ogni fotogramma e calcola la curva di - /// deflicker. La decodifica avviene a risoluzione ridotta perché la media logaritmica - /// troncata è invariante alla scala, e questo rende la fase praticamente istantanea. + /// Passata di analisi. Misura la luminanza di ogni fotogramma — globale e, se richiesto, + /// per regione — e nel frattempo, senza rileggere i file, stima lo spostamento fra + /// fotogrammi adiacenti per la stabilizzazione. Da lì si ricavano l'analisi delle + /// transizioni e la curva di correzione. + /// + /// La decodifica avviene a risoluzione ridotta: la media logaritmica troncata è + /// invariante alla scala, e anche la correlazione di fase lavora bene in scala ridotta, + /// perché lo spostamento si misura in frazioni della larghezza e non in pixel. /// public async Task AnalyzeAsync(IProgress? progress, CancellationToken cancellation) { @@ -74,41 +83,99 @@ public sealed class RenderPipeline(TitanoProject project) var (workingWidth, workingHeight) = _project.ResolveWorkingSize(); if (workingWidth <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi."); - int analysisWidth = Math.Clamp(_project.General.AnalysisWidth, 128, workingWidth); + int orientation = _project.EffectiveOrientation; + int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16); + + // ---- maschera delle regioni: una volta sola per sequenza + if (_project.Regions.Mode != RegionMode.Off) + { + progress?.Report(new PipelineProgress(PipelinePhase.Analysis, 0, count, + "Segmentazione delle regioni…")); + var paths = sequence.Frames.Select(f => f.FilePath).ToArray(); + _project.Mask = await Task.Run(() => RegionSegmenter.Build(paths, orientation, _project.Regions, + cancellation), cancellation) + .ConfigureAwait(false); + } + else + { + _project.Mask = null; + } + + int analysisWidth = Math.Clamp(_project.General.AnalysisWidth, 128, Math.Max(128, workingWidth)); int analysisHeight = Math.Max(2, (int)Math.Round(analysisWidth * workingHeight / (double)workingWidth)); - int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16); - int orientation = _project.EffectiveOrientation; - var pool = new FrameBufferPool(parallelism + 2); var stats = new LuminanceStats[count]; var failures = new bool[count]; + var relative = new SimilarityTransform[count]; + var confidence = new double[count]; + Array.Fill(relative, SimilarityTransform.Identity); + + bool stabilize = _project.Stabilization.Enabled; + var mask = _project.Mask; int done = 0; await Task.Run(() => { + // I blocchi sono contigui perché la stabilizzazione confronta ogni fotogramma con + // il precedente e ha quindi bisogno dell'ordine. Se ne creano più dei thread + // disponibili, così un blocco lento non ferma tutti gli altri; il prezzo è un + // fotogramma in più decodificato all'inizio di ciascun blocco, per agganciarlo + // alla coda di quello che lo precede. + int blocks = Math.Clamp(count / 4, 1, parallelism * 4); + var pool = new FrameBufferPool(parallelism + 4); + var options = new ParallelOptions { CancellationToken = cancellation, MaxDegreeOfParallelism = parallelism, }; - Parallel.For(0, count, options, i => + Parallel.For(0, blocks, options, block => { - var record = sequence.Frames[i]; - try + int start = (int)((long)block * count / blocks); + int end = (int)((long)(block + 1) * count / blocks); + if (start >= end) return; + + var stabilizer = stabilize ? new Stabilizer(_project.Stabilization) : null; + GrayImage? previous = null; + + if (stabilize && start > 0) { - using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight, - orientation, pool); - stats[i] = LuminanceAnalyzer.Analyze(buffer); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - failures[i] = true; + previous = DecodePlane(sequence.Frames[start - 1].FilePath, analysisWidth, analysisHeight, + orientation, pool, _project.Stabilization.AnalysisWidth); } - int completed = Interlocked.Increment(ref done); - progress?.Report(new PipelineProgress(PipelinePhase.Analysis, completed, count, - "Analisi della luminanza…")); + for (int i = start; i < end; i++) + { + cancellation.ThrowIfCancellationRequested(); + var record = sequence.Frames[i]; + + try + { + using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight, + orientation, pool); + stats[i] = LuminanceAnalyzer.Analyze(buffer, mask); + + if (stabilizer is not null) + { + var plane = GrayPyramid.Plane(buffer, _project.Stabilization.AnalysisWidth); + if (previous is not null) + { + relative[i] = stabilizer.Estimate(previous, plane, out double measured); + confidence[i] = measured; + } + previous = plane; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + failures[i] = true; + } + + int completed = Interlocked.Increment(ref done); + progress?.Report(new PipelineProgress(PipelinePhase.Analysis, completed, count, + "Analisi della luminanza…")); + } }); }, cancellation).ConfigureAwait(false); @@ -119,20 +186,35 @@ public sealed class RenderPipeline(TitanoProject project) stats[i] = i > 0 ? stats[i - 1] : LuminanceStats.Empty; } - var curve = DeflickerEngine.Compute(stats, _project.Deflicker); + var metadata = sequence.Frames.Select(f => f.Metadata).ToArray(); + _project.Transitions = HolyGrailEngine.Analyze(stats, metadata, _project.HolyGrail); - for (int i = 0; i < count; i++) - { - var record = sequence.Frames[i]; - record.MeasuredLuminance = Math.Pow(2, curve.Measured[i]); - record.TargetLuminance = Math.Pow(2, curve.Target[i]); - record.Gain = Math.Pow(2, curve.GainStops[i]); - record.ClippedFraction = stats[i].ClippedFraction; - record.LuminanceAnalyzed = !failures[i]; - } + // Gli spostamenti grezzi restano nel progetto: da lì il percorso si ricostruisce in un + // istante quando l'utente regola quanto la stabilizzazione deve essere decisa. + _project.MotionRelative = stabilize ? relative : null; + _project.MotionConfidence = stabilize ? confidence : null; + _project.RebuildStabilizationPath(); + + var curve = DeflickerEngine.Compute(stats, _project.Deflicker, _project.Regions, + _project.Transitions, _project.HolyGrail); _project.Stats = stats; _project.Curve = curve; + UpdateRecords(sequence, curve, stats, failures); + } + + private static GrayImage? DecodePlane(string path, int width, int height, int orientation, + FrameBufferPool pool, int planeWidth) + { + try + { + using var buffer = ImageDecoder.Decode(path, width, height, orientation, pool); + return GrayPyramid.Plane(buffer, planeWidth); + } + catch (Exception) + { + return null; + } } /// @@ -143,15 +225,54 @@ public sealed class RenderPipeline(TitanoProject project) { if (_project.Stats is not { } stats || _project.Sequence is not { } sequence) return; - var curve = DeflickerEngine.Compute(stats, _project.Deflicker); + // Transizioni e percorso di stabilizzazione dipendono solo da misure già in mano: + // si rifanno qui, così muovere la lunghezza di una transizione o la decisione della + // stabilizzazione dà un riscontro immediato senza rileggere un solo file. + var metadata = sequence.Frames.Select(f => f.Metadata).ToArray(); + _project.Transitions = HolyGrailEngine.Analyze(stats, metadata, _project.HolyGrail); + _project.RebuildStabilizationPath(); + + var curve = DeflickerEngine.Compute(stats, _project.Deflicker, _project.Regions, + _project.Transitions, _project.HolyGrail); + _project.Curve = curve; + UpdateRecords(sequence, curve, stats, null); + } + + private void UpdateRecords(TimelapseSequence sequence, DeflickerCurve curve, + IReadOnlyList stats, bool[]? failures) + { + var transitions = _project.Transitions; + var steps = transitions is null ? [] : new HashSet(transitions.StepFrames); + var motion = _project.Motion; + var (sourceWidth, _) = _project.ResolveSourceSize(); + for (int i = 0; i < sequence.Count && i < curve.Count; i++) { var record = sequence.Frames[i]; record.MeasuredLuminance = Math.Pow(2, curve.Measured[i]); record.TargetLuminance = Math.Pow(2, curve.Target[i]); record.Gain = Math.Pow(2, curve.GainStops[i]); + record.ClippedFraction = stats[i].ClippedFraction; + if (failures is not null) record.LuminanceAnalyzed = !failures[i]; + + record.TemperatureKelvin = transitions is not null && i < transitions.TemperatureKelvin.Length + ? transitions.TemperatureKelvin[i] + : double.NaN; + record.IsExposureStep = steps.Contains(i); + + if (motion is not null && i < motion.Count) + { + var correction = motion.Correction[i]; + record.StabilizationShift = Math.Sqrt(correction.Tx * correction.Tx + + correction.Ty * correction.Ty) * Math.Max(1, sourceWidth); + record.StabilizationRotation = correction.Rotation * 180.0 / Math.PI; + } + else + { + record.StabilizationShift = 0; + record.StabilizationRotation = 0; + } } - _project.Curve = curve; } // ------------------------------------------------------------------ render ed esportazione @@ -179,141 +300,193 @@ public sealed class RenderPipeline(TitanoProject project) private RenderResult RenderCore(TimelapseSequence sequence, ExportSettings export, IProgress? progress, CancellationToken cancellation) { - int width = export.Width; - int height = export.Height; int count = sequence.Count; - var curve = _project.Curve; + int outputWidth = export.Width; + int outputHeight = export.Height; + + var (sourceWidth, sourceHeight) = _project.ResolveSourceSize(); + if (sourceWidth <= 0 || sourceHeight <= 0) (sourceWidth, sourceHeight) = (outputWidth, outputHeight); + + bool needsGeometry = _project.NeedsGeometry || + sourceWidth != outputWidth || sourceHeight != outputHeight; int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16); int orientation = _project.EffectiveOrientation; - // Buffer contemporaneamente vivi: i cinque fissi del ciclo (corrente, successivo, - // sfocatura, interpolato, interpolato sfocato), le decodifiche in volo e la copia - // di ripiego per un fotogramma illeggibile. Trattenerne di più è memoria ferma: - // con sorgenti da 12 megapixel ogni buffer pesa oltre cento megabyte. - var pool = new FrameBufferPool(parallelism + 6); - var flowEngine = new OpticalFlowEngine(_project.Flow); - var blurSettings = _project.MotionBlur; - var deflickerSettings = _project.Deflicker; - uint baseUnits = (uint)Math.Max(1, Math.Round(export.Timescale / Math.Max(1.0, export.FrameRate))); - double nominal = sequence.NominalInterval > 0 ? sequence.NominalInterval : 1.0; + var plan = RenderPlanner.Build(sequence, export, _project.TimeRamp, baseUnits); + if (plan.Count == 0) throw new InvalidOperationException("Il piano di rendering è vuoto."); + var stacking = _project.Stacking; + int stackRadius = stacking.MedianRadius; + + var curve = _project.Curve; + var mask = _project.Mask; + var deflicker = _project.Deflicker; + var blurSettings = _project.MotionBlur; + var flowEngine = new OpticalFlowEngine(_project.Flow); + var motion = _project.Motion; + + using var window = new FrameWindow(count, sourceWidth, sourceHeight, parallelism, _project.Cache, + (index, framePool) => DecodeAndCorrect(sequence, index, sourceWidth, sourceHeight, orientation, + framePool, curve, deflicker, mask)); + + var pool = window.Pool; var stopwatch = Stopwatch.StartNew(); - using var session = new VideoEncoderSession(export, width, height); + using var session = new VideoEncoderSession(export, outputWidth, outputHeight); - // Canale a capacità limitata: al più "parallelism" decodifiche in volo. - var channel = Channel.CreateBounded>(new BoundedChannelOptions(parallelism) - { - SingleReader = true, - SingleWriter = true, - FullMode = BoundedChannelFullMode.Wait, - }); - - var producer = Task.Run(async () => - { - try - { - for (int i = 0; i < count; i++) - { - cancellation.ThrowIfCancellationRequested(); - int index = i; - var task = Task.Run(() => DecodeAndCorrect(sequence, index, width, height, orientation, - pool, curve, deflickerSettings), cancellation); - await channel.Writer.WriteAsync(task, cancellation).ConfigureAwait(false); - } - channel.Writer.Complete(); - } - catch (Exception ex) - { - channel.Writer.TryComplete(ex); - } - }, cancellation); - - ImageBuffer? current = null; - ImageBuffer? next = null; - var blurScratch = pool.Rent(width, height); - var interpolated = pool.Rent(width, height); - var interpolatedBlur = pool.Rent(width, height); + // Buffer di lavoro. Restano gli stessi per tutta l'esportazione: nessuna allocazione + // per fotogramma, qualunque sia la lunghezza della sequenza. + ImageBuffer? fallback = null; + ImageBuffer? stackA = null, stackB = null; + ImageBuffer? accumulator = null; + ImageBuffer? interpolated = null; + ImageBuffer? blurScratch = null; + ImageBuffer? geometryOut = null; + int stackIndexA = int.MinValue, stackIndexB = int.MinValue; + int flowIndex = int.MinValue; + MotionField? flowField = null; + int fedThrough = -1; int encoded = 0; bool cancelled = false; try { - current = ReadNextOrSubstitute(channel, pool, width, height, null); - next = ReadNextOrSubstitute(channel, pool, width, height, current); + if (stacking.Mode == StackingMode.Median) + { + stackA = pool.Rent(sourceWidth, sourceHeight); + stackB = pool.Rent(sourceWidth, sourceHeight); + } + else if (stacking.Mode == StackingMode.Maximum) + { + accumulator = pool.Rent(sourceWidth, sourceHeight); + Array.Clear(accumulator.Data, 0, accumulator.SampleCount); + stackA = pool.Rent(sourceWidth, sourceHeight); + } - for (int i = 0; i < count && current is not null; i++) + interpolated = pool.Rent(sourceWidth, sourceHeight); + blurScratch = pool.Rent(sourceWidth, sourceHeight); + if (needsGeometry) geometryOut = pool.Rent(outputWidth, outputHeight); + + float fade = TemporalStacker.FadeFactor(stacking.TrailFrames); + + for (int k = 0; k < plan.Count; k++) { if (cancellation.IsCancellationRequested) { cancelled = true; break; } - var record = sequence.Frames[i]; - int subdivisions = ComputeSubdivisions(export, record, nominal); - uint duration = ComputeDuration(export, record, nominal, baseUnits, subdivisions); + var planned = plan.Frames[k]; + int index = Math.Clamp((int)Math.Floor(planned.SourcePosition), 0, count - 1); + double fraction = planned.SourcePosition - index; + if (index >= count - 1) fraction = 0; + bool needsNext = fraction > 1e-6; - // In presenza di suddivisioni l'intervallo di ciascun fotogramma d'uscita si - // accorcia: lo shutter angle effettivo cresce e lo spostamento si riduce. - double effectiveAngle = Math.Min(360.0, record.ShutterAngle * subdivisions); + // Solo le scie stellari hanno bisogno di risalire agli scatti già superati, + // perché l'accumulatore va nutrito anche con quelli che la rimappatura salta. + // Estendere la finestra all'indietro anche negli altri casi vorrebbe dire non + // liberare mai nulla, e l'occupazione crescerebbe con la lunghezza della sequenza. + int first = index - stackRadius; + if (stacking.Mode == StackingMode.Maximum) first = Math.Min(first, fedThrough + 1); + int last = index + (needsNext ? 1 : 0) + stackRadius; + window.EnsureRange(first, last, cancellation); + + // ---- fotogramma base: sorgente puro, oppure risultato dell'accumulo + var record = sequence.Frames[index]; + + if (stacking.Mode == StackingMode.Maximum) + { + // L'accumulatore va nutrito con tutti gli scatti attraversati, anche quelli + // che la rimappatura temporale salta: una scia con un buco non è una scia. + for (int j = Math.Max(0, fedThrough + 1); j <= index; j++) + { + var source = Resolve(window, j, pool, ref fallback, sourceWidth, sourceHeight); + TemporalStacker.Accumulate(accumulator!, source, + AlignmentTo(motion, index, j, sourceWidth, sourceHeight), fade); + } + fedThrough = Math.Max(fedThrough, index); + } + + var current = BaseFrame(index, ref stackIndexA, ref stackIndexB); + var next = needsNext ? BaseFrame(index + 1, ref stackIndexA, ref stackIndexB) : null; + + // ---- campo vettoriale: serve solo se c'è da sfocare o da interpolare + double speed = Math.Max(1e-6, planned.Speed); + double effectiveAngle = Math.Min(360.0, record.ShutterAngle / speed); double missing = blurSettings.Enabled ? MotionBlurRenderer.MissingBlurFactor(effectiveAngle, blurSettings.TargetShutterAngle, - blurSettings.Strength) / subdivisions + blurSettings.Strength) * speed : 0; - // Il campo vettoriale si calcola solo se serve davvero. Nelle riprese notturne - // la posa copre quasi tutto l'intervallo, lo shutter angle supera già i 180° - // e non c'è sfocatura da sintetizzare: calcolarlo lo stesso costerebbe la voce - // di spesa più pesante della pipeline per nulla. - MotionField? field = null; - bool needsFlow = missing > 1e-4 || - (export.Timing == FrameTimingMode.Interpolated && subdivisions > 1); - if (needsFlow && next is not null) + bool needsFlow = (missing > 1e-4 || needsNext) && next is not null; + if (needsFlow && flowIndex != index) { - field = flowEngine.Compute(current, next); - record.MotionMagnitude = field.MedianMagnitude(); - record.MotionDirection = field.DominantDirection(); + flowField = flowEngine.Compute(current, next!); + flowIndex = index; + record.MotionMagnitude = flowField.MedianMagnitude(); + record.MotionDirection = flowField.DominantDirection(); + } + var field = needsFlow ? flowField : null; + + // ---- posizione frazionaria: il fotogramma va sintetizzato + var composed = current; + if (needsNext && next is not null) + { + if (field is not null) + { + FrameInterpolator.Interpolate(current, next, field, (float)fraction, interpolated!); + } + else + { + CrossFade(current, next, (float)fraction, interpolated!); + } + composed = interpolated!; } - record.BlurLength = EncodeFrame(session, current, field, missing, blurSettings, blurScratch, duration); + // ---- sfocatura di movimento + if (blurSettings.Enabled && field is not null && missing > 1e-4) + { + record.BlurLength = MotionBlurRenderer.Render(composed, blurScratch!, field, missing, blurSettings); + composed = blurScratch!; + } + + // ---- inquadratura virtuale e stabilizzazione, in un solo ricampionamento + if (needsGeometry) + { + double normalized = plan.Count > 1 ? k / (double)(plan.Count - 1) : 0; + var framing = _project.FramingAt(normalized); + var stabilization = motion?.At(planned.SourcePosition) ?? SimilarityTransform.Identity; + var map = GeometryStage.Build(sourceWidth, sourceHeight, outputWidth, outputHeight, + framing, stabilization); + GeometryStage.Resample(composed, geometryOut!, map); + composed = geometryOut!; + } + + session.EncodeFrame(composed, planned.DurationUnits); encoded++; - record.OutputDurationUnits = (int)duration; + record.OutputDurationUnits = (int)planned.DurationUnits; - for (int k = 1; k < subdivisions && next is not null && field is not null; k++) - { - if (cancellation.IsCancellationRequested) { cancelled = true; break; } - float t = k / (float)subdivisions; - FrameInterpolator.Interpolate(current, next, field, t, interpolated); - EncodeFrame(session, interpolated, field, missing, blurSettings, interpolatedBlur, duration); - encoded++; - } - - ReportRenderProgress(progress, i + 1, count, encoded, stopwatch); - - current.Dispose(); - current = next; - next = ReadNextOrSubstitute(channel, pool, width, height, current); + ReportRenderProgress(progress, k + 1, plan.Count, encoded, stopwatch); } } finally { - current?.Dispose(); - next?.Dispose(); - blurScratch.Dispose(); - interpolated.Dispose(); - interpolatedBlur.Dispose(); + fallback?.Dispose(); + stackA?.Dispose(); + stackB?.Dispose(); + accumulator?.Dispose(); + interpolated?.Dispose(); + blurScratch?.Dispose(); + geometryOut?.Dispose(); - progress?.Report(new PipelineProgress(PipelinePhase.Finalizing, count, count, + progress?.Report(new PipelineProgress(PipelinePhase.Finalizing, plan.Count, plan.Count, "Chiusura del contenitore…")); session.Finish(); - - try { producer.Wait(TimeSpan.FromSeconds(5)); } - catch (AggregateException) { /* già segnalato dal canale */ } } stopwatch.Stop(); - if (cancelled) cancellation.ThrowIfCancellationRequested(); + var statistics = window.Statistics; return new RenderResult( export.OutputPath, session.EncodedFrames, @@ -321,31 +494,112 @@ public sealed class RenderPipeline(TitanoProject project) stopwatch.Elapsed, session.EncoderName, session.IsHardware, - pool.AllocatedBytes); - } + Math.Max(statistics.PeakResidentBytes, pool.AllocatedBytes), + plan.Description, + statistics.SpilledFrames, + statistics.SpillBytesWritten); - /// Applica la sfocatura, se prevista, e consegna il fotogramma all'encoder. - private static double EncodeFrame(VideoEncoderSession session, ImageBuffer frame, MotionField? field, - double missing, MotionBlurSettings settings, ImageBuffer scratch, - uint duration) - { - double blurLength = 0; - var toEncode = frame; + // ------------------------------------------------------------------ funzioni locali - if (settings.Enabled && field is not null && missing > 1e-4) + // Fotogramma pronto per l'elaborazione temporale: quello sorgente quando non c'è + // accumulo, il risultato dello stacking quando c'è. I due esiti più recenti restano + // in cache perché il ciclo chiede sempre l'indice corrente e il successivo, e senza + // cache la mediana verrebbe calcolata due volte per ogni fotogramma d'uscita. + ImageBuffer BaseFrame(int wanted, ref int cachedA, ref int cachedB) { - blurLength = MotionBlurRenderer.Render(frame, scratch, field, missing, settings); - toEncode = scratch; + int clamped = Math.Clamp(wanted, 0, count - 1); + var source = Resolve(window, clamped, pool, ref fallback, sourceWidth, sourceHeight); + + switch (stacking.Mode) + { + case StackingMode.Median: + { + if (cachedA == clamped) return stackA!; + if (cachedB == clamped) return stackB!; + + bool intoA = cachedA <= cachedB; + var destination = intoA ? stackA! : stackB!; + BuildMedian(clamped, source, destination); + if (intoA) cachedA = clamped; else cachedB = clamped; + return destination; + } + + case StackingMode.Maximum: + { + if (cachedA == clamped) return stackA!; + TemporalStacker.Blend(accumulator!, source, stackA!, stacking.Strength); + cachedA = clamped; + return stackA!; + } + + default: + return source; + } } - session.EncodeFrame(toEncode, duration); - return blurLength; + void BuildMedian(int centre, ImageBuffer centreFrame, ImageBuffer destination) + { + var frames = new List(2 * stackRadius + 1); + List? alignment = motion is not null ? new(2 * stackRadius + 1) : null; + + for (int j = centre - stackRadius; j <= centre + stackRadius; j++) + { + int clamped = Math.Clamp(j, 0, count - 1); + frames.Add(Resolve(window, clamped, pool, ref fallback, sourceWidth, sourceHeight)); + alignment?.Add(AlignmentTo(motion, centre, clamped, sourceWidth, sourceHeight)); + } + + TemporalStacker.Median(frames, alignment, centreFrame, destination, stacking.Strength); + } + } + + /// + /// Preleva un fotogramma dalla finestra. Se il file era illeggibile subentra il vicino + /// valido più prossimo; solo una sequenza interamente illeggibile arriva al nero. + /// + private static ImageBuffer Resolve(FrameWindow window, int index, FrameBufferPool pool, + ref ImageBuffer? black, int width, int height) + { + var buffer = window.GetNearest(index); + if (buffer is not null) return buffer; + + if (black is null) + { + black = pool.Rent(width, height); + Array.Clear(black.Data, 0, black.SampleCount); + } + return black; + } + + /// + /// Trasformazione che porta le coordinate del fotogramma in + /// quelle del fotogramma : due scatti stabilizzati in modo diverso + /// vanno riportati sullo stesso reticolo prima di poterli sovrapporre. + /// + private static SourceMapping AlignmentTo(StabilizationPath? motion, int centre, int other, + int width, int height) + { + if (motion is null || centre == other || centre >= motion.Count || other >= motion.Count) + return SourceMapping.Identity; + + var mapping = SimilarityTransform.Compose(motion.Correction[other].Inverse, motion.Correction[centre]); + return GeometryStage.FromSimilarity(width, height, mapping); + } + + /// Dissolvenza lineare: ripiego quando la posizione è frazionaria ma il campo manca. + private static void CrossFade(ImageBuffer a, ImageBuffer b, float t, ImageBuffer destination) + { + var source = a.Data; + var other = b.Data; + var dst = destination.Data; + int count = destination.SampleCount; + for (int i = 0; i < count; i++) dst[i] = source[i] + (other[i] - source[i]) * t; } /// Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato. private static ImageBuffer? DecodeAndCorrect(TimelapseSequence sequence, int index, int width, int height, int orientation, FrameBufferPool pool, DeflickerCurve? curve, - DeflickerSettings settings) + DeflickerSettings settings, RegionMask? mask) { var record = sequence.Frames[index]; ImageBuffer buffer; @@ -355,87 +609,20 @@ public sealed class RenderPipeline(TitanoProject project) } catch (Exception) { - return null; // il consumatore sostituirà con l'ultimo fotogramma valido + return null; // la finestra sostituirà con l'ultimo fotogramma valido } - if (curve is not null && index < curve.Count && settings.Enabled) - { - record.ClippedFraction = ExposureProcessor.Apply(buffer, curve.ChannelGain[index], - settings.ProtectHighlights, settings.HighlightKnee); - } + if (curve is null || index >= curve.Count || !settings.Enabled) return buffer; + + record.ClippedFraction = curve.HasRegions && mask is not null + ? ExposureProcessor.ApplyRegional(buffer, mask, curve.ChannelGainHigh![index], + curve.ChannelGainLow![index], + settings.ProtectHighlights, settings.HighlightKnee) + : ExposureProcessor.Apply(buffer, curve.ChannelGain[index], + settings.ProtectHighlights, settings.HighlightKnee); return buffer; } - /// - /// Preleva il prossimo fotogramma in ordine di sequenza. - /// Restituisce false quando il flusso è terminato; un buffer nullo con esito true segnala - /// un fotogramma illeggibile, che il chiamante sostituisce senza perdere il sincronismo. - /// - private static bool TryReadNext(Channel> channel, out ImageBuffer? buffer) - { - buffer = null; - while (true) - { - Task task; - try - { - if (!channel.Reader.WaitToReadAsync().AsTask().GetAwaiter().GetResult()) return false; - if (!channel.Reader.TryRead(out task!)) continue; - } - catch (Exception) - { - return false; - } - - try - { - buffer = task.GetAwaiter().GetResult(); - } - catch (Exception) - { - buffer = null; - } - return true; - } - } - - /// Legge il fotogramma successivo sostituendo gli illeggibili con una copia del precedente. - private static ImageBuffer? ReadNextOrSubstitute(Channel> channel, FrameBufferPool pool, - int width, int height, ImageBuffer? previous) - { - if (!TryReadNext(channel, out var buffer)) return null; - if (buffer is not null) return buffer; - - return previous is not null - ? previous.CloneFromPool(pool) - : CreateBlack(pool, width, height); - } - - private static ImageBuffer CreateBlack(FrameBufferPool pool, int width, int height) - { - var buffer = pool.Rent(width, height); - Array.Clear(buffer.Data, 0, buffer.SampleCount); - return buffer; - } - - private static int ComputeSubdivisions(ExportSettings export, FrameRecord record, double nominal) - { - if (export.Timing != FrameTimingMode.Interpolated) return 1; - double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal); - int max = (int)Math.Max(1, Math.Round(export.MaxAdaptiveStretch)); - return Math.Clamp((int)Math.Round(ratio), 1, max); - } - - private static uint ComputeDuration(ExportSettings export, FrameRecord record, double nominal, - uint baseUnits, int subdivisions) - { - if (export.Timing != FrameTimingMode.Adaptive) return baseUnits; - - double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal); - double limited = Math.Clamp(ratio, 1.0 / export.MaxAdaptiveStretch, export.MaxAdaptiveStretch); - return (uint)Math.Max(1, Math.Round(baseUnits * limited)); - } - private static void ReportRenderProgress(IProgress? progress, int completed, int total, int encoded, Stopwatch stopwatch) { diff --git a/Titano/Pipeline/RenderPlan.cs b/Titano/Pipeline/RenderPlan.cs new file mode 100644 index 0000000..2fb3048 --- /dev/null +++ b/Titano/Pipeline/RenderPlan.cs @@ -0,0 +1,172 @@ +using Titano.Core; +using Titano.Video; + +namespace Titano.Pipeline; + +/// Parametri della rimappatura temporale non lineare. +public sealed class TimeRampSettings +{ + public bool Enabled { get; set; } + + /// + /// Curva di velocità: l'ascissa è la posizione lungo la sequenza sorgente (0..1), + /// l'ordinata quanti fotogrammi sorgente vengono consumati per ogni fotogramma d'uscita. + /// Sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta sintetizzandoli. + /// + public List Speed { get; set; } = [new(0.0, 1.0), new(0.5, 1.0), new(1.0, 1.0)]; + + public double MinSpeed { get; set; } = 0.1; + public double MaxSpeed { get; set; } = 8.0; + + public TimeRampSettings Clone() + { + var copy = (TimeRampSettings)MemberwiseClone(); + copy.Speed = [.. Speed]; + return copy; + } + + /// Velocità alla posizione normalizzata indicata, entro i limiti ammessi. + public double SpeedAt(double normalized) + => Math.Clamp(Spline.Evaluate(Speed, normalized), Math.Max(0.02, MinSpeed), Math.Max(0.1, MaxSpeed)); +} + +/// +/// Un fotogramma del video finale: da dove viene, quanto dura e quanta sequenza sorgente +/// copre. La posizione è frazionaria perché il fotogramma può cadere fra due scatti, e in +/// quel caso viene sintetizzato dal campo vettoriale. +/// +public readonly record struct PlannedFrame(double SourcePosition, uint DurationUnits, double Speed); + +/// Elenco ordinato dei fotogrammi da produrre. +public sealed class RenderPlan +{ + public required PlannedFrame[] Frames { get; init; } + public required string Description { get; init; } + + /// Fotogrammi sorgente effettivamente richiesti: serve a dimensionare la finestra. + public required int SourceSpan { get; init; } + + public int Count => Frames.Length; +} + +/// +/// Costruisce l'elenco dei fotogrammi d'uscita a partire dalla cadenza reale della sequenza +/// e dalla modalità temporale scelta. +/// +/// Tutte le modalità — durata costante, durata proporzionale all'intervallo, cadenza +/// uniformata, rimappatura non lineare — producono la stessa cosa: una successione di +/// posizioni sorgente con la relativa durata. Averle ridotte a una forma sola vuol dire che +/// il motore di rendering non sa quale modalità sia attiva e non ha un ramo di codice per +/// ciascuna: legge il piano ed esegue. +/// +public static class RenderPlanner +{ + /// Tetto di sicurezza: una curva di velocità estrema non deve generare un video infinito. + private const int MaxOutputFrames = 500_000; + + public static RenderPlan Build(TimelapseSequence sequence, ExportSettings export, + TimeRampSettings? ramp, uint baseUnits) + { + int count = sequence.Count; + if (count == 0) + { + return new RenderPlan { Frames = [], Description = "sequenza vuota", SourceSpan = 0 }; + } + + var frames = ramp is { Enabled: true } && count > 1 + ? BuildRamped(count, ramp, baseUnits, out string description) + : BuildFromTimingMode(sequence, export, baseUnits, out description); + + // La velocità di ciascun fotogramma è la distanza dal successivo: da lì dipendono sia + // l'entità della sfocatura sintetica sia l'angolo di otturatore effettivo. + for (int i = 0; i < frames.Length; i++) + { + double speed = i + 1 < frames.Length + ? frames[i + 1].SourcePosition - frames[i].SourcePosition + : (i > 0 ? frames[i].SourcePosition - frames[i - 1].SourcePosition : 1.0); + frames[i] = frames[i] with { Speed = Math.Max(0.0, speed) }; + } + + return new RenderPlan { Frames = frames, Description = description, SourceSpan = count }; + } + + private static PlannedFrame[] BuildFromTimingMode(TimelapseSequence sequence, ExportSettings export, + uint baseUnits, out string description) + { + int count = sequence.Count; + double nominal = sequence.NominalInterval > 0 ? sequence.NominalInterval : 1.0; + var frames = new List(count); + + for (int i = 0; i < count; i++) + { + var record = sequence.Frames[i]; + int subdivisions = ComputeSubdivisions(export, record, nominal); + uint duration = ComputeDuration(export, record, nominal, baseUnits); + + for (int k = 0; k < subdivisions; k++) + { + frames.Add(new PlannedFrame(i + k / (double)subdivisions, duration, 1.0 / subdivisions)); + if (frames.Count >= MaxOutputFrames) break; + } + if (frames.Count >= MaxOutputFrames) break; + } + + description = export.Timing switch + { + FrameTimingMode.Adaptive => $"{frames.Count} fotogrammi a durata proporzionale all'intervallo", + FrameTimingMode.Interpolated => $"{frames.Count} fotogrammi su cadenza uniformata " + + $"({frames.Count - count} sintetizzati)", + _ => $"{frames.Count} fotogrammi a durata costante", + }; + return [.. frames]; + } + + /// + /// Percorre la sequenza integrando la curva di velocità. Il passo non è costante: dove la + /// curva scende sotto uno la sequenza avanza per frazioni di fotogramma e quelli mancanti + /// verranno sintetizzati, dove sale sopra uno alcuni scatti vengono semplicemente saltati. + /// La monotonia della spline garantisce che il tempo non torni mai indietro. + /// + private static PlannedFrame[] BuildRamped(int count, TimeRampSettings ramp, uint baseUnits, + out string description) + { + var frames = new List(count * 2); + double last = count - 1.0; + double position = 0; + double slowest = double.MaxValue, fastest = 0; + + while (position <= last + 1e-9 && frames.Count < MaxOutputFrames) + { + double speed = ramp.SpeedAt(position / last); + slowest = Math.Min(slowest, speed); + fastest = Math.Max(fastest, speed); + + frames.Add(new PlannedFrame(Math.Min(position, last), baseUnits, speed)); + position += speed; + } + + if (frames.Count == 0) frames.Add(new PlannedFrame(0, baseUnits, 1)); + + description = frames.Count > 1 + ? $"{frames.Count} fotogrammi rimappati, velocità da {slowest:0.##}× a {fastest:0.##}×" + : $"{frames.Count} fotogrammi rimappati"; + return [.. frames]; + } + + private static int ComputeSubdivisions(ExportSettings export, FrameRecord record, double nominal) + { + if (export.Timing != FrameTimingMode.Interpolated) return 1; + double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal); + int max = (int)Math.Max(1, Math.Round(export.MaxAdaptiveStretch)); + return Math.Clamp((int)Math.Round(ratio), 1, max); + } + + private static uint ComputeDuration(ExportSettings export, FrameRecord record, double nominal, uint baseUnits) + { + if (export.Timing != FrameTimingMode.Adaptive) return baseUnits; + + double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal); + double limited = Math.Clamp(ratio, 1.0 / export.MaxAdaptiveStretch, export.MaxAdaptiveStretch); + return (uint)Math.Max(1, Math.Round(baseUnits * limited)); + } +} diff --git a/Titano/Pipeline/TitanoProject.cs b/Titano/Pipeline/TitanoProject.cs index d68cbf4..697a5ba 100644 --- a/Titano/Pipeline/TitanoProject.cs +++ b/Titano/Pipeline/TitanoProject.cs @@ -7,8 +7,8 @@ namespace Titano.Pipeline; /// /// Compromesso fra qualità del risultato e tempo di elaborazione. Agisce sulla finezza -/// del campo di movimento, sui campioni della sfocatura e sulla risoluzione della passata -/// fotometrica: tutte grandezze che migliorano il risultato e costano tempo. +/// del campo di movimento, sui campioni della sfocatura e sulla risoluzione delle passate +/// di analisi: tutte grandezze che migliorano il risultato e costano tempo. /// public enum QualityProfile { @@ -44,15 +44,22 @@ public sealed class GeneralSettings } /// -/// Stato completo di un progetto: sequenza caricata e tutti i parametri dei tre pannelli -/// di configurazione. È l'unico oggetto che l'interfaccia scambia con il motore. +/// Stato completo di un progetto: sequenza caricata e tutti i parametri dei pannelli di +/// configurazione. È l'unico oggetto che l'interfaccia scambia con il motore. /// public sealed class TitanoProject { public GeneralSettings General { get; set; } = new(); public DeflickerSettings Deflicker { get; set; } = new(); + public RegionSettings Regions { get; set; } = new(); + public HolyGrailSettings HolyGrail { get; set; } = new(); public MotionBlurSettings MotionBlur { get; set; } = new(); public OpticalFlowSettings Flow { get; set; } = new(); + public StabilizationSettings Stabilization { get; set; } = new(); + public VirtualCameraSettings Camera { get; set; } = new(); + public TimeRampSettings TimeRamp { get; set; } = new(); + public StackingSettings Stacking { get; set; } = new(); + public FrameCacheSettings Cache { get; set; } = new(); public ExportSettings Export { get; set; } = new(); public TitanoProject() @@ -71,6 +78,31 @@ public sealed class TitanoProject /// Statistiche fotometriche per fotogramma dell'ultima analisi. public IReadOnlyList? Stats { get; set; } + /// Maschera delle regioni, calcolata una volta sola per sequenza. + public RegionMask? Mask { get; set; } + + /// Analisi delle transizioni giorno-notte. + public HolyGrailAnalysis? Transitions { get; set; } + + /// Percorso della camera e correzione della stabilizzazione. + public StabilizationPath? Motion { get; set; } + + /// + /// Spostamenti misurati fra fotogrammi adiacenti, conservati così come escono dalla + /// correlazione di fase. Da questi il percorso si ricostruisce in un istante: regolare + /// quanto la stabilizzazione è decisa non deve costringere a rileggere mille file. + /// + public SimilarityTransform[]? MotionRelative { get; set; } + public double[]? MotionConfidence { get; set; } + + /// Ricalcola la correzione dai soli spostamenti già misurati. + public void RebuildStabilizationPath() + { + Motion = Stabilization.Enabled && MotionRelative is not null && MotionConfidence is not null + ? Stabilizer.BuildPath(MotionRelative, MotionConfidence, Stabilization) + : null; + } + public bool HasSequence => Sequence is { Count: > 0 }; public bool IsAnalyzed => Curve is not null && Stats is not null; @@ -80,6 +112,9 @@ public sealed class TitanoProject /// Trasformazione effettivamente applicata: la scelta dell'utente ha la precedenza. public int EffectiveOrientation => General.OrientationOverride ?? Orientation.Orientation; + /// Vero se serve lo stadio geometrico finale, quindi un ricampionamento in più. + public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled; + /// /// Esamina il primo fotogramma per stabilire come vanno raddrizzati i pixel. Va invocato /// dopo l'ingestion: la sequenza è omogenea, quindi un solo campione basta per tutti. @@ -91,6 +126,18 @@ public sealed class TitanoProject : new Imaging.OrientationDetection(1, "nessuna sequenza caricata"); } + /// Azzera tutto ciò che dipende dai pixel: va fatto quando cambia come vengono letti. + public void InvalidateAnalysis() + { + Curve = null; + Stats = null; + Mask = null; + Transitions = null; + Motion = null; + MotionRelative = null; + MotionConfidence = null; + } + /// /// Allinea i parametri di elaborazione al profilo di qualità scelto. Sovrascrive i /// cursori delle sezioni avanzate: è il senso stesso di un profilo. @@ -109,6 +156,11 @@ public sealed class TitanoProject Flow.WindowRadius = 5; Flow.Iterations = 3; MotionBlur.MaxSamples = 13; + Stabilization.AnalysisWidth = 640; + Stabilization.PatchSize = 64; + Stabilization.Grid = 2; + Regions.AnalysisWidth = 192; + Regions.SampleFrames = 8; break; case QualityProfile.Standard: @@ -119,11 +171,18 @@ public sealed class TitanoProject Flow.WindowRadius = 6; Flow.Iterations = 5; MotionBlur.MaxSamples = 25; + Stabilization.AnalysisWidth = 960; + Stabilization.PatchSize = 128; + Stabilization.Grid = 3; + Regions.AnalysisWidth = 256; + Regions.SampleFrames = 12; break; default: // Il campo di movimento si infittisce e la scia guadagna campioni: sono le - // due voci che si vedono davvero nel fotogramma finale. + // due voci che si vedono davvero nel fotogramma finale. I riquadri della + // correlazione crescono perché un riquadro più largo contiene più tessitura + // e dà un picco più stretto, quindi una misura più precisa. General.AnalysisWidth = 2048; Flow.AnalysisWidth = 1440; Flow.CellSize = 6; @@ -131,32 +190,67 @@ public sealed class TitanoProject Flow.WindowRadius = 7; Flow.Iterations = 8; MotionBlur.MaxSamples = 49; + Stabilization.AnalysisWidth = 1440; + Stabilization.PatchSize = 256; + Stabilization.Grid = 3; + Regions.AnalysisWidth = 384; + Regions.SampleFrames = 20; break; } } - /// - /// Risoluzione di lavoro effettiva: parte dal primo fotogramma, applica l'eventuale limite - /// dell'utente e arrotonda a valori pari, richiesti dal sottocampionamento cromatico 4:2:0. - /// - public (int Width, int Height) ResolveWorkingSize() + // ------------------------------------------------------------------ risoluzioni + + /// Dimensioni native del primo fotogramma, già nell'orientamento finale. + public (int Width, int Height) ResolveNativeSize() { if (Sequence is not { Count: > 0 }) return (0, 0); var first = Sequence.Frames[0].Metadata; int orientation = EffectiveOrientation; - int sourceWidth = first.PixelWidth; - int sourceHeight = first.PixelHeight; + int width = first.PixelWidth; + int height = first.PixelHeight; - if (sourceWidth <= 0 || sourceHeight <= 0) + if (width <= 0 || height <= 0) { - (sourceWidth, sourceHeight) = Imaging.ImageDecoder.ProbeDisplaySize(first.FilePath, orientation); + (width, height) = Imaging.ImageDecoder.ProbeDisplaySize(first.FilePath, orientation); } else if (Imaging.ImageDecoder.SwapsAxes(orientation)) { - (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); + (width, height) = (height, width); } + return (width, height); + } + + /// + /// Risoluzione a cui i fotogrammi vengono decodificati. + /// + /// Senza stadio geometrico coincide con quella d'uscita, e la catena di decodifica scala + /// direttamente al valore finale: è il percorso più corto e il più nitido. Con una + /// panoramica virtuale o una stabilizzazione attive serve invece la risoluzione nativa, + /// perché il ritaglio deve avere pixel da cui attingere — è tutto il senso del metodo. + /// + public (int Width, int Height) ResolveSourceSize() + { + if (!NeedsGeometry) return ResolveWorkingSize(); + + var (nativeWidth, nativeHeight) = ResolveNativeSize(); + if (nativeWidth <= 0 || nativeHeight <= 0) return (0, 0); + + int width = General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, nativeWidth) : nativeWidth; + int height = (int)Math.Round(width * nativeHeight / (double)nativeWidth); + + return (Math.Max(2, width & ~1), Math.Max(2, height & ~1)); + } + + /// + /// Risoluzione del video finale: parte dal primo fotogramma, applica l'eventuale limite + /// dell'utente e arrotonda a valori pari, richiesti dal sottocampionamento cromatico 4:2:0. + /// + public (int Width, int Height) ResolveWorkingSize() + { + var (sourceWidth, sourceHeight) = ResolveNativeSize(); if (sourceWidth <= 0 || sourceHeight <= 0) return (0, 0); int targetWidth = Export.Width > 0 ? Export.Width @@ -182,13 +276,8 @@ public sealed class TitanoProject public (int Width, int Height) ResolveRequestedSize() { var (width, height) = ResolveWorkingSize(); - if (Sequence is not { Count: > 0 }) return (width, height); - - var first = Sequence.Frames[0].Metadata; - int sourceWidth = first.PixelWidth; - int sourceHeight = first.PixelHeight; + var (sourceWidth, sourceHeight) = ResolveNativeSize(); if (sourceWidth <= 0 || sourceHeight <= 0) return (width, height); - if (Imaging.ImageDecoder.SwapsAxes(EffectiveOrientation)) (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); int requestedWidth = Export.Width > 0 ? Export.Width : General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth) @@ -199,4 +288,26 @@ public sealed class TitanoProject return (Math.Max(2, requestedWidth & ~1), Math.Max(2, requestedHeight & ~1)); } + + /// + /// Ingrandimento aggiuntivo imposto dalla stabilizzazione: senza di esso i bordi scoperti + /// dalla correzione entrerebbero nell'inquadratura. + /// + public double StabilizationZoom + { + get + { + if (!Stabilization.Enabled || Motion is null) return 1.0; + var (width, height) = ResolveSourceSize(); + if (width <= 0 || height <= 0) return 1.0; + return Motion.RequiredZoom(height / (double)width); + } + } + + /// Inquadratura effettiva a un dato punto della sequenza, vincoli compresi. + public CameraFraming FramingAt(double normalizedTime) + { + var framing = Camera.Enabled ? VirtualCamera.Resolve(Camera, normalizedTime) : CameraFraming.Full; + return VirtualCamera.Constrain(framing, Camera.KeepInsideFrame, StabilizationZoom); + } } diff --git a/Titano/Program.cs b/Titano/Program.cs index 143ef6c..f93d478 100644 --- a/Titano/Program.cs +++ b/Titano/Program.cs @@ -51,7 +51,8 @@ internal static class Program EnsureConsole(); ApplicationConfiguration.Initialize(); return Capture(args[1], args.Length > 2 ? args[2] : null, - args.Length > 3 && int.TryParse(args[3], out int tab) ? tab : 0); + args.Length > 3 && int.TryParse(args[3], out int tab) ? tab : 0, + args.Length > 4 && args[4].Equals("avanzato", StringComparison.OrdinalIgnoreCase)); } ApplicationConfiguration.Initialize(); @@ -79,12 +80,17 @@ internal static class Program /// Apre l'interfaccia, opzionalmente vi carica una sequenza, e ne salva un'immagine. /// Serve a verificare la resa dei controlli disegnati a mano senza intervento manuale. /// - private static int Capture(string outputPath, string? sequenceDirectory, int settingsTab) + private static int Capture(string outputPath, string? sequenceDirectory, int settingsTab, + bool advanced = false) { using var form = new UI.MainForm(); form.Show(); Pump(200); + // I moduli avanzati vanno accesi prima di caricare: la maschera delle regioni e il + // percorso della stabilizzazione nascono durante l'analisi, non dopo. + if (advanced) form.EnableAdvancedModulesForCapture(); + if (sequenceDirectory is not null && Directory.Exists(sequenceDirectory)) { string[] files = [.. Directory.EnumerateFiles(sequenceDirectory) diff --git a/Titano/README.md b/Titano/README.md index 312a19f..6a040cc 100644 --- a/Titano/README.md +++ b/Titano/README.md @@ -14,6 +14,9 @@ oppure ottenuto tramite P/Invoke diretto verso componenti del sistema operativo. | Parsing EXIF / XMP | Parser binario proprietario (`Metadata/`) | | Analisi fotometrica e deflicker | Algoritmi proprietari (`Analysis/`) | | Campo vettoriale e motion blur | Schema differenziale piramidale proprietario (`Motion/`) | +| Trasformata di Fourier | Cooley–Tukey a base due, in loco (`Motion/Fourier.cs`) | +| Segmentazione e stabilizzazione | Otsu, gradiente e correlazione di fase proprietarie | +| Interpolazione di curve | Spline monotona e Bézier proprietarie (`Core/Spline.cs`) | | Contenitore MP4 | Multiplexer ISO-BMFF proprietario (`Video/Mp4Muxer.cs`) | | Decodifica immagini | WIC, componente di Windows, via COM interop scritto a mano | | Codifica video | Media Foundation Transform, encoder hardware di sistema | @@ -23,16 +26,16 @@ Non viene invocato nessun processo esterno: niente FFmpeg, niente ExifTool. ## Principi architetturali -**Nessun file temporaneo.** I dati passano fra le fasi solo attraverso buffer in RAM. L'unica -scrittura su disco è il flusso compresso finale, prodotto in streaming: `ftyp` e l'header -`mdat` vengono scritti all'apertura, i pacchetti dell'encoder scorrono direttamente nel file -e `moov` viene aggiunto in chiusura. +**Il video è l'unico file che sopravvive.** Il flusso compresso viene prodotto in streaming: +`ftyp` e l'header `mdat` all'apertura, i pacchetti dell'encoder direttamente nel file, `moov` +in chiusura. L'elaborazione può creare un solo altro file — il parcheggio temporaneo dei +fotogrammi descritto più sotto — che nasce con la cancellazione automatica alla chiusura e non +contiene nulla di riutilizzabile. -**Impronta di memoria costante.** I fotogrammi vivono in array float presi da un pool -(`Imaging/ImageBuffer.cs`) e la decodifica è governata da un canale a capacità limitata: al -più *N* fotogrammi sono vivi contemporaneamente, dove *N* è il numero di decodifiche -simultanee scelto dall'utente. L'occupazione non dipende dalla lunghezza della sequenza — -la verifica automatica lo misura esplicitamente. +**Impronta di memoria governata, non incidentale.** I fotogrammi vivono in array float presi +da un pool (`Imaging/ImageBuffer.cs`) e passano da una finestra scorrevole +(`Pipeline/FrameWindow.cs`). L'occupazione dipende dall'ampiezza della finestra — che l'utente +sceglie — e mai dalla lunghezza della sequenza: la verifica automatica lo misura esplicitamente. **Interfaccia reattiva.** Ingestion, analisi e render girano su thread di lavoro; il thread dell'interfaccia riceve solo aggiornamenti di stato immutabili tramite `IProgress`. @@ -41,16 +44,30 @@ dell'interfaccia riceve solo aggiornamenti di stato immutabili tramite `IProgres ``` Metadata/ parser binario TIFF/Exif, scanner XMP, riconoscimento contenitori -Core/ sequenza, intervalli reali, cadenza nominale, shutter angle -Imaging/ buffer poolati, spazio colore lineare, decodifica via WIC -Analysis/ misura di luminanza, curva di deflicker, applicazione dei guadagni -Motion/ piramide gaussiana, optical flow, motion blur, interpolazione +Core/ sequenza, cadenza, shutter angle, spline monotona e Bézier +Imaging/ buffer poolati, spazio colore lineare, decodifica WIC, stadio geometrico +Analysis/ luminanza, deflicker, segmentazione in regioni, transizioni giorno-notte +Motion/ Fourier, correlazione di fase, stabilizzazione, optical flow, blur, stacking Video/ conversione NV12, encoder Media Foundation, multiplexer MP4 -Pipeline/ orchestrazione delle fasi, progetto e impostazioni -UI/ tema scuro, grafico vettoriale, tabella virtuale, pannelli -Diagnostics/ sequenza sintetica, verifica end-to-end, ispettore MP4 +Pipeline/ piano di rendering, finestra scorrevole, orchestrazione, impostazioni +UI/ tema scuro, grafico vettoriale, editor di curve e keyframe, pannelli +Diagnostics/ sequenze sintetiche, verifica end-to-end, ispettore MP4 ``` +## Il piano di rendering + +Il motore non percorre la sequenza sorgente: percorre un elenco di fotogrammi d'uscita, ognuno +con la propria posizione — anche frazionaria — e la propria durata. Durata costante, durata +proporzionale all'intervallo, cadenza uniformata e rimappatura non lineare producono tutte +quella stessa forma, quindi il ciclo di rendering è uno solo e non contiene un ramo per +ciascuna modalità. + +Una posizione frazionaria significa un fotogramma che non esiste e va sintetizzato dal campo +vettoriale. Da qui discende anche la sfocatura: se un fotogramma d'uscita copre `v` scatti, +l'angolo di otturatore effettivo è quello di ripresa diviso `v`, e la scia da sintetizzare si +scala della stessa quantità. Accelerare e rallentare restano quindi coerenti con la fisica +dell'esposizione invece di essere un semplice salto di indici. + ## Note sugli algoritmi **Deflicker.** Per ogni fotogramma si esegue una regressione lineare locale pesata sulla @@ -77,6 +94,79 @@ davvero: se la posa copre già più dell'apertura obiettivo — il caso delle ri pose da trenta secondi su intervalli da trentaquattro — non c'è sfocatura da sintetizzare e la voce di spesa più pesante della pipeline viene saltata. +## Moduli avanzati + +**Stabilizzazione sub-pixel.** Lo spostamento fra fotogrammi adiacenti si misura con la +correlazione di fase: una traslazione nello spazio è uno sfasamento lineare in frequenza, e +normalizzando lo spettro incrociato al modulo unitario resta solo la fase, la cui +antitrasformata è un impulso posto sullo spostamento. Il metodo ignora quindi per costruzione +le differenze di luminosità fra due scatti — che in un time-lapse ci sono sempre — e reagisce +alla sola geometria. Sotto il pixel la superficie di correlazione viene ricostruita a passo +fine valutando direttamente la somma di Fourier sulle posizioni intermedie, invece di +interpolare con una parabola tre campioni di una cresta che parabola non è: l'errore misurato +scende da 0,14 a 0,08 px. Nove riquadri per coppia danno nove misure indipendenti, da cui si +stima ai minimi quadrati una similitudine — traslazione, rotazione e scala — scartando i +riquadri occupati da nuvole o fronde, che si muovono per conto loro. Il percorso assoluto +viene infine lisciato: ciò che resta fra percorso vero e percorso liscio è il tremolio, e la +sua inversa è la correzione. Una panoramica voluta sopravvive perché è già liscia. + +**Deflicker per regioni.** Una nuvola densa che attraversa il cielo abbassa la luminanza media +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. + +**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 +ordinario non aiuta, perché un gradino non è rumore da mediare e la finestra mobile lo +trasforma in una rampa con due spigoli. Titano legge invece l'ampiezza esatta nei metadati — +il valore di esposizione vale log2(N²/t) − log2(ISO/100) — e la ridistribuisce su una +transizione a derivata nulla agli estremi, lunga a piacere. Il deflicker lavora poi sulla serie +già priva di gradini. Il bilanciamento del bianco riceve un trattamento parallelo: si liscia il +rapporto fra i canali invece della loro luminanza, così sparisce il tremolio del bilanciamento +automatico e resta il viaggio verso il caldo del tramonto. + +**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 +tempo passa per una curva di Bézier con estremi fissi — senza, il movimento partirebbe e si +fermerebbe di colpo, e a velocità di time-lapse lo scatto si vede benissimo. Ritaglio e +correzione di stabilizzazione sono entrambi affini e vengono composti in una sola +trasformazione: il fotogramma viene interpolato una volta sola, con una cubica di Catmull-Rom. + +**Rimappatura non lineare del tempo.** Una curva di velocità dice quanti scatti vengono +consumati per ogni fotogramma d'uscita: sopra 1 la sequenza accelera saltando scatti, sotto 1 +rallenta e i mancanti vengono sintetizzati. La velocità del filmato resta fissa. La spline è +monotona per costruzione (schema di Fritsch–Carlson): una Catmull-Rom ordinaria +sovraelongerebbe fra due nodi molto diversi, e una velocità negativa farebbe tornare indietro +la sequenza. + +**Accumulo temporale.** La mediana su una finestra di fotogrammi dà a ogni pixel il valore +centrale della propria serie: la scena stabile resta se stessa, chi attraversa l'inquadratura +una volta sola sparisce. Una media lascerebbe un fantasma tanto più visibile quanto più +l'intruso era contrastato. Il massimo progressivo conserva invece il valore più alto incontrato +e trasforma le stelle in archi continui; con una lunghezza di scia finita l'accumulo decade e +le scie hanno una coda invece di riempire il cielo. Se la stabilizzazione è attiva i fotogrammi +vengono sovrapposti dopo averli raddrizzati, altrimenti la mediana scambierebbe per intruso il +tremolio stesso. + +## Memoria e parcheggio su disco + +I moduli avanzati hanno rotto l'assunto su cui la pipeline originale era costruita: che +bastassero due fotogrammi vivi alla volta. La mediana ne vuole un'intera finestra +simultaneamente, la rimappatura può chiedere lo stesso scatto per molti fotogrammi d'uscita +consecutivi. + +La regola di residenza è una sola e non produce mai andirivieni: la finestra attiva sta sempre +in memoria, perché chi la usa ha bisogno di tutti i suoi fotogrammi insieme; i fotogrammi letti +in anticipo — che servono a tenere occupati tutti i processori sulla decodifica dei RAW, la +parte più lenta della pipeline — finiscono su disco quando il tetto di memoria è raggiunto, e +vengono ripresi una volta sola, quando entrano nella finestra. Non esiste un caso in cui lo +stesso fotogramma vada e torni dal disco più volte. Il file di parcheggio nasce con l'opzione di +cancellazione alla chiusura: se il programma termina male, lo rimuove il sistema. + ## Sorgenti RAW e limiti dei codec I file RAW e DNG non espongono l'immagine principale nella prima directory: quella è una @@ -118,13 +208,22 @@ Richiede .NET 10 SDK su Windows x64. Titano.exe --selftest [cartella] ``` -Genera una sequenza sintetica dalle proprietà note — traslazione, sfarfallio e rampa di luce -imposti, pausa dell'intervallometro inclusa — e la fa attraversare l'intera pipeline, -confrontando 25 grandezze misurate con i valori attesi: campi Exif, cadenza, shutter angle, -riduzione dello sfarfallio, conservazione della rampa, invarianza della luminanza alla scala di -decodifica, modulo e direzione del campo vettoriale, attenuazione del dettaglio dovuta alla -sfocatura, allineamento delle NAL dentro ogni campione del contenitore e — prova conclusiva — -la ri-decodifica del file con il lettore di sistema. +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: +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. + +Copre campi Exif, cadenza e shutter angle; riduzione dello sfarfallio e conservazione della +rampa di luce; invarianza della luminanza alla scala di decodifica; correlazione di fase sotto +il pixel; misura e rimozione del tremolio con la panoramica voluta che sopravvive; copertura +della maschera delle regioni; riconoscimento e ridistribuzione dei cambi di esposizione; +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 +— 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] @@ -137,10 +236,13 @@ render di prova e ne riverifica il contenitore. È lo strumento con cui si disti del motore da un formato che il sistema non sa aprire. ``` -Titano.exe --capture [cartella-sequenza] [scheda] +Titano.exe --capture [cartella-sequenza] [scheda] [avanzato] ``` -Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile. +Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile. Con +`avanzato` i moduli opzionali vengono accesi prima del caricamento, così l'immagine mostra +curve di regione, marcatori dei cambi di esposizione, percorso della macchina virtuale e +contorno delle regioni con dati veri invece che a riposo. ``` Titano.exe --make-icon diff --git a/Titano/UI/CameraEditor.cs b/Titano/UI/CameraEditor.cs new file mode 100644 index 0000000..8c5abd3 --- /dev/null +++ b/Titano/UI/CameraEditor.cs @@ -0,0 +1,390 @@ +using System.Drawing.Drawing2D; +using Titano.Motion; + +namespace Titano.UI; + +/// +/// Editor del movimento di macchina virtuale: mostra il fotogramma sorgente per intero e, +/// dentro di esso, le inquadrature dei nodi con il percorso che il centro descrive fra l'uno +/// e l'altro. +/// +/// Il percorso disegnato non è una retta fra i nodi ma la curva davvero eseguita, campionata +/// con la stessa funzione di accelerazione che userà il rendering: è l'unico modo perché +/// l'anteprima dica qualcosa di vero su come si muoverà l'inquadratura, e perché l'effetto di +/// una maniglia di accelerazione si veda mentre la si regola. +/// +/// Le inquadrature si spostano trascinandole e si stringono trascinando la maniglia +/// nell'angolo. La striscia in basso è la linea del tempo: ogni nodo è un indicatore che si +/// seleziona con un clic e si sposta trascinandolo. +/// +internal sealed class CameraEditor : Control +{ + private const int TimelineHeight = 26; + private const float HandleSize = 9f; + + private VirtualCameraSettings _settings = new(); + private int _selected; + private int _dragging = -1; + private bool _draggingZoom; + private bool _draggingTime; + private PointF _grabOffset; + + public event EventHandler? Changed; + public event EventHandler? SelectionChanged; + + /// Rapporto larghezza/altezza del fotogramma sorgente. + public double Aspect { get; set; } = 16.0 / 9.0; + + public CameraEditor() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = Theme.Surface; + Height = 220; + } + + public VirtualCameraSettings Settings + { + get => _settings; + set + { + _settings = value; + _selected = Math.Clamp(_selected, 0, Math.Max(0, value.Keyframes.Count - 1)); + Invalidate(); + } + } + + public int SelectedIndex + { + get => Math.Clamp(_selected, 0, Math.Max(0, _settings.Keyframes.Count - 1)); + set + { + int clamped = Math.Clamp(value, 0, Math.Max(0, _settings.Keyframes.Count - 1)); + if (clamped == _selected) return; + _selected = clamped; + Invalidate(); + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + } + + public CameraKeyframe? Selected => _settings.Keyframes.Count > 0 + ? _settings.Keyframes[SelectedIndex] + : null; + + /// Inserisce un nodo a metà fra quello scelto e il successivo. + public void AddKeyframe() + { + var keyframes = _settings.Keyframes; + int index = SelectedIndex; + + double time = index < keyframes.Count - 1 + ? (keyframes[index].Time + keyframes[index + 1].Time) * 0.5 + : Math.Min(1, keyframes[^1].Time + 0.1); + + var framing = VirtualCamera.Resolve(new VirtualCameraSettings + { + Enabled = true, + Keyframes = keyframes, + }, time); + + keyframes.Add(new CameraKeyframe + { + Time = time, + CentreX = framing.CentreX, + CentreY = framing.CentreY, + Zoom = framing.Zoom, + }); + keyframes.Sort((a, b) => a.Time.CompareTo(b.Time)); + + _selected = keyframes.FindIndex(k => Math.Abs(k.Time - time) < 1e-9); + Changed?.Invoke(this, EventArgs.Empty); + SelectionChanged?.Invoke(this, EventArgs.Empty); + Invalidate(); + } + + /// Toglie il nodo scelto; due devono restare, o non ci sarebbe più un movimento. + public void RemoveSelected() + { + if (_settings.Keyframes.Count <= 2) return; + _settings.Keyframes.RemoveAt(SelectedIndex); + _selected = Math.Clamp(_selected, 0, _settings.Keyframes.Count - 1); + Changed?.Invoke(this, EventArgs.Empty); + SelectionChanged?.Invoke(this, EventArgs.Empty); + Invalidate(); + } + + // ------------------------------------------------------------------ geometria + + private Rectangle Timeline => new(10, Height - TimelineHeight, Math.Max(20, Width - 20), TimelineHeight - 8); + + /// Rettangolo che rappresenta il fotogramma sorgente, con le sue proporzioni. + private RectangleF Stage + { + get + { + var available = new RectangleF(10, 8, Math.Max(20, Width - 20), + Math.Max(20, Height - TimelineHeight - 16)); + float aspect = (float)Math.Max(0.1, Aspect); + float width = available.Width; + float height = width / aspect; + + if (height > available.Height) + { + height = available.Height; + width = height * aspect; + } + + return new RectangleF(available.Left + (available.Width - width) / 2, + available.Top + (available.Height - height) / 2, width, height); + } + } + + private RectangleF FramingRect(CameraKeyframe keyframe) + { + var stage = Stage; + var framing = VirtualCamera.Constrain(new CameraFraming(keyframe.CentreX, keyframe.CentreY, keyframe.Zoom), + _settings.KeepInsideFrame); + float width = (float)(stage.Width / framing.Zoom); + float height = (float)(stage.Height / framing.Zoom); + return new RectangleF(stage.Left + (float)(framing.CentreX * stage.Width) - width / 2, + stage.Top + (float)(framing.CentreY * stage.Height) - height / 2, + width, height); + } + + private PointF TimelinePoint(CameraKeyframe keyframe) + { + var timeline = Timeline; + return new PointF(timeline.Left + (float)(keyframe.Time * timeline.Width), + timeline.Top + timeline.Height / 2f); + } + + // ------------------------------------------------------------------ interazione + + protected override void OnMouseDown(MouseEventArgs e) + { + Focus(); + if (e.Button != MouseButtons.Left) { base.OnMouseDown(e); return; } + + var keyframes = _settings.Keyframes; + + // La linea del tempo ha la precedenza: è la striscia più sottile e va raggiunta prima. + if (e.Y >= Timeline.Top - 6) + { + for (int i = 0; i < keyframes.Count; i++) + { + var point = TimelinePoint(keyframes[i]); + if (Math.Abs(point.X - e.X) > 8) continue; + SelectedIndex = i; + _dragging = i; + _draggingTime = true; + return; + } + return; + } + + // Maniglia dell'ingrandimento sull'inquadratura scelta. + if (Selected is { } current) + { + var rect = FramingRect(current); + var handle = new RectangleF(rect.Right - HandleSize, rect.Bottom - HandleSize, + HandleSize * 2, HandleSize * 2); + if (handle.Contains(e.Location)) + { + _dragging = SelectedIndex; + _draggingZoom = true; + return; + } + } + + // Altrimenti si sceglie l'inquadratura più stretta che contiene il punto: quelle + // piccole stanno dentro le grandi, e cliccando sulla piccola si vuole la piccola. + int best = -1; + double bestArea = double.MaxValue; + for (int i = 0; i < keyframes.Count; i++) + { + var rect = FramingRect(keyframes[i]); + if (!rect.Contains(e.Location)) continue; + double area = rect.Width * (double)rect.Height; + if (area >= bestArea) continue; + bestArea = area; + best = i; + } + + if (best < 0) return; + + SelectedIndex = best; + _dragging = best; + _draggingZoom = false; + _draggingTime = false; + + var selectedRect = FramingRect(keyframes[best]); + _grabOffset = new PointF(e.X - (selectedRect.Left + selectedRect.Width / 2), + e.Y - (selectedRect.Top + selectedRect.Height / 2)); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + if (_dragging < 0 || _dragging >= _settings.Keyframes.Count) + { + Cursor = CursorFor(e.Location); + base.OnMouseMove(e); + return; + } + + var keyframe = _settings.Keyframes[_dragging]; + var stage = Stage; + + if (_draggingTime) + { + var timeline = Timeline; + double time = Math.Clamp((e.X - timeline.Left) / (double)timeline.Width, 0, 1); + + // Gli estremi restano agli estremi: il movimento deve coprire tutta la sequenza. + if (_dragging > 0 && _dragging < _settings.Keyframes.Count - 1) + { + keyframe.Time = Math.Clamp(time, + _settings.Keyframes[_dragging - 1].Time + 0.01, + _settings.Keyframes[_dragging + 1].Time - 0.01); + } + } + else if (_draggingZoom) + { + // L'ingrandimento segue la semilarghezza trascinata: il rettangolo insegue il dito. + float halfWidth = Math.Max(4f, e.X - (stage.Left + (float)(keyframe.CentreX * stage.Width))); + double zoom = stage.Width / (2.0 * halfWidth); + keyframe.Zoom = Math.Clamp(zoom, 1.0, 8.0); + } + else + { + keyframe.CentreX = Math.Clamp((e.X - _grabOffset.X - stage.Left) / stage.Width, 0, 1); + keyframe.CentreY = Math.Clamp((e.Y - _grabOffset.Y - stage.Top) / stage.Height, 0, 1); + } + + Changed?.Invoke(this, EventArgs.Empty); + Invalidate(); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + if (_dragging >= 0) _settings.Keyframes.Sort((a, b) => a.Time.CompareTo(b.Time)); + _dragging = -1; + _draggingZoom = false; + _draggingTime = false; + Invalidate(); + base.OnMouseUp(e); + } + + private Cursor CursorFor(Point location) + { + if (location.Y >= Timeline.Top - 6) return Cursors.SizeWE; + if (Selected is not { } current) return Cursors.Default; + + var rect = FramingRect(current); + var handle = new RectangleF(rect.Right - HandleSize, rect.Bottom - HandleSize, + HandleSize * 2, HandleSize * 2); + if (handle.Contains(location)) return Cursors.SizeNWSE; + return rect.Contains(location) ? Cursors.SizeAll : Cursors.Default; + } + + // ------------------------------------------------------------------ disegno + + protected override void OnPaint(PaintEventArgs e) + { + var g = e.Graphics; + Theme.HighQuality(g); + g.Clear(Parent?.BackColor ?? Theme.Surface); + + var stage = Stage; + Theme.FillRounded(g, stage, 4f, Theme.SurfaceAlt); + using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(stage)); + + TextRenderer.DrawText(g, "risoluzione nativa", Theme.Small, + new Rectangle((int)stage.Left + 6, (int)stage.Top + 4, 160, 14), + Theme.TextFaint, TextFormatFlags.Left); + + var keyframes = _settings.Keyframes; + if (keyframes.Count == 0) return; + + // Percorso del centro, campionato sulla curva vera: comprende l'accelerazione. + var samples = new PointF[128]; + for (int i = 0; i < samples.Length; i++) + { + double t = i / (double)(samples.Length - 1); + var framing = VirtualCamera.Constrain(VirtualCamera.Resolve( + new VirtualCameraSettings { Enabled = true, Keyframes = keyframes }, t), + _settings.KeepInsideFrame); + samples[i] = new PointF(stage.Left + (float)(framing.CentreX * stage.Width), + stage.Top + (float)(framing.CentreY * stage.Height)); + } + + using (var pathPen = new Pen(Color.FromArgb(150, Theme.Success), 1.6f) { DashStyle = DashStyle.Dash }) + { + g.DrawLines(pathPen, samples); + } + + // Le tacche lungo il percorso sono equispaziate nel tempo: dove si addensano il + // movimento sta rallentando, dove si diradano sta correndo. È la lettura immediata + // dell'effetto delle maniglie di accelerazione. + using (var tick = new SolidBrush(Color.FromArgb(190, Theme.Success))) + { + for (int i = 0; i < samples.Length; i += 8) + g.FillEllipse(tick, samples[i].X - 1.6f, samples[i].Y - 1.6f, 3.2f, 3.2f); + } + + for (int i = 0; i < keyframes.Count; i++) + { + var rect = FramingRect(keyframes[i]); + bool active = i == SelectedIndex; + + using var pen = new Pen(active ? Theme.Accent : Color.FromArgb(120, Theme.Text), active ? 2f : 1.2f); + if (!active) pen.DashStyle = DashStyle.Dash; + g.DrawRectangle(pen, rect.Left, rect.Top, rect.Width, rect.Height); + + if (!active) continue; + + using (var fill = new SolidBrush(Color.FromArgb(26, Theme.Accent))) g.FillRectangle(fill, rect); + using (var handle = new SolidBrush(Theme.Accent)) + { + g.FillRectangle(handle, rect.Right - HandleSize / 2, rect.Bottom - HandleSize / 2, + HandleSize, HandleSize); + } + + // In alto a destra: a ingrandimento 1 il riquadro coincide con il fotogramma e a + // sinistra c'è già la didascalia della risoluzione nativa. + string label = $"{keyframes[i].Zoom:0.00}×"; + TextRenderer.DrawText(g, label, Theme.SmallBold, + new Rectangle((int)rect.Right - 74, (int)rect.Top + 3, 70, 15), + Theme.Text, TextFormatFlags.Right); + } + + DrawTimeline(g, keyframes); + } + + private void DrawTimeline(Graphics g, List keyframes) + { + var timeline = Timeline; + Theme.FillRounded(g, new RectangleF(timeline.Left, timeline.Top + timeline.Height / 2f - 2, + timeline.Width, 4), 2f, Theme.SurfaceAlt); + + for (int i = 0; i < keyframes.Count; i++) + { + var point = TimelinePoint(keyframes[i]); + bool active = i == SelectedIndex; + float radius = active ? 6f : 4.5f; + + // Losanga, come nei programmi di montaggio: si distingue da un punto qualsiasi. + var diamond = new PointF[] + { + new(point.X, point.Y - radius), + new(point.X + radius, point.Y), + new(point.X, point.Y + radius), + new(point.X - radius, point.Y), + }; + + using var fill = new SolidBrush(active ? Theme.Accent : Theme.TextMuted); + using var stroke = new Pen(Theme.Background, 1.5f); + g.FillPolygon(fill, diamond); + g.DrawPolygon(stroke, diamond); + } + } +} diff --git a/Titano/UI/LuminanceChart.cs b/Titano/UI/LuminanceChart.cs index 258e5fd..7f58bae 100644 --- a/Titano/UI/LuminanceChart.cs +++ b/Titano/UI/LuminanceChart.cs @@ -214,6 +214,11 @@ internal sealed class LuminanceChart : Control { minValue = Math.Min(minValue, Math.Min(_curve.Measured[i], _curve.Target[i])); maxValue = Math.Max(maxValue, Math.Max(_curve.Measured[i], _curve.Target[i])); + + // Le curve di regione stanno per definizione fuori da quella globale: senza + // includerle nella scala uscirebbero dal riquadro. + if (_curve.MeasuredHigh is { } high) maxValue = Math.Max(maxValue, high[i]); + if (_curve.MeasuredLow is { } low) minValue = Math.Min(minValue, low[i]); } if (minValue > maxValue) { minValue = -4; maxValue = -1; } @@ -223,7 +228,9 @@ internal sealed class LuminanceChart : Control DrawGrid(g, plot, minValue, maxValue); DrawCadenceMarkers(g, plot); + DrawExposureSteps(g, plot); DrawCorrectionBand(g, plot, from, to, minValue, maxValue); + DrawRegionCurves(g, plot, from, to, minValue, maxValue); DrawCurve(g, plot, _curve.Measured, from, to, minValue, maxValue, Theme.Measured, 1.4f); DrawCurve(g, plot, _curve.Target, from, to, minValue, maxValue, Theme.Accent, 2.1f); DrawGainLane(g, from, to); @@ -314,6 +321,68 @@ internal sealed class LuminanceChart : Control } } + /// + /// Segnala i fotogrammi in cui la macchina ha cambiato tempo, diaframma o sensibilità. + /// Sono i punti in cui la curva misurata fa un gradino e quella obiettivo no: vederli + /// spiega a colpo d'occhio da dove viene la correzione più vistosa della sequenza. + /// + private void DrawExposureSteps(Graphics g, Rectangle plot) + { + if (_sequence is null) return; + + int from = Math.Max(0, (int)_viewStart); + int to = Math.Min(_sequence.Count - 1, (int)Math.Ceiling(_viewEnd)); + + using var pen = new Pen(Color.FromArgb(110, Theme.Success), 1f) { DashStyle = DashStyle.Dash }; + using var marker = new SolidBrush(Theme.Success); + + for (int i = from; i <= to; i++) + { + if (!_sequence.Frames[i].IsExposureStep) continue; + float x = XFor(i, plot); + if (x < plot.Left || x > plot.Right) continue; + + g.DrawLine(pen, x, plot.Top, x, plot.Bottom); + g.FillPolygon(marker, + [ + new PointF(x, plot.Top + 7), + new PointF(x - 4.5f, plot.Top), + new PointF(x + 4.5f, plot.Top), + ]); + } + } + + /// + /// Curve delle due regioni, quando il deflicker le distingue. Sono disegnate sottili e + /// smorzate: raccontano perché la curva globale si muove come si muove, senza rubarle + /// la scena. + /// + private void DrawRegionCurves(Graphics g, Rectangle plot, int from, int to, double min, double max) + { + if (_curve is not { HasRegions: true }) return; + + var clip = g.Clip; + g.SetClip(Rectangle.Inflate(plot, 2, 2)); + + DrawThin(_curve.MeasuredHigh, Color.FromArgb(110, Theme.RegionHigh)); + DrawThin(_curve.TargetHigh, Color.FromArgb(200, Theme.RegionHigh)); + DrawThin(_curve.MeasuredLow, Color.FromArgb(110, Theme.RegionLow)); + DrawThin(_curve.TargetLow, Color.FromArgb(200, Theme.RegionLow)); + + g.Clip = clip; + + void DrawThin(double[]? values, Color color) + { + if (values is null || to - from < 1) return; + var points = new PointF[to - from + 1]; + for (int i = 0; i < points.Length; i++) + points[i] = new PointF(XFor(from + i, plot), YFor(values[from + i], plot, min, max)); + + using var pen = new Pen(color, 1.2f) { LineJoin = LineJoin.Round }; + g.DrawLines(pen, points); + } + } + /// Area fra misurato e target: è la correzione che verrà applicata. private void DrawCorrectionBand(Graphics g, Rectangle plot, int from, int to, double min, double max) { @@ -478,28 +547,46 @@ internal sealed class LuminanceChart : Control private void DrawLegend(Graphics g) { - var entries = new (Color Color, string Label)[] + var entries = new List<(Color Color, string Label, bool Band)> { - (Theme.Measured, "luminanza misurata"), - (Theme.Accent, "curva target"), - (Theme.Warning, "cadenza anomala"), + (Theme.Measured, "luminanza misurata", false), + (Theme.Accent, "curva target", false), }; - int x = GutterLeft; - for (int i = 0; i < entries.Length; i++) + if (_curve is { HasRegions: true }) { - var (color, label) = entries[i]; - // L'ultima voce indica una fascia di sfondo, non una curva: si disegna come tale. - bool band = i == entries.Length - 1; + entries.Add((Theme.RegionHigh, "cielo", false)); + entries.Add((Theme.RegionLow, "paesaggio", false)); + } + + if (_sequence is not null && HasExposureSteps()) entries.Add((Theme.Success, "cambio impostazioni", false)); + entries.Add((Theme.Warning, "cadenza anomala", true)); + + int x = GutterLeft; + foreach (var (color, label, band) in entries) + { + var size = TextRenderer.MeasureText(g, label, Theme.Small); + // Oltre il bordo non si disegna: meglio una legenda corta che una tagliata. + if (x + 19 + size.Width > Width - 180) break; + using (var brush = new SolidBrush(band ? Color.FromArgb(90, color) : color)) g.FillRectangle(brush, x, band ? 5 : 10, 14, band ? 12 : 3); - var size = TextRenderer.MeasureText(g, label, Theme.Small); TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 19, 4, size.Width + 4, 16), Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); - x += 19 + size.Width + 18; + x += 19 + size.Width + 16; } } + private bool HasExposureSteps() + { + if (_sequence is null) return false; + foreach (var frame in _sequence.Frames) + { + if (frame.IsExposureStep) return true; + } + return false; + } + private void DrawHover(Graphics g, Rectangle plot, double min, double max) { if (_hoverIndex < 0 || _sequence is null || _hoverIndex >= _sequence.Count || _curve is null) return; @@ -516,14 +603,28 @@ internal sealed class LuminanceChart : Control using (var brush = new SolidBrush(Theme.Accent)) g.FillEllipse(brush, x - 3.5f, targetY - 3.5f, 7, 7); var record = _sequence.Frames[_hoverIndex]; - string[] lines = - [ + var detail = new List + { $"#{_hoverIndex + 1} {record.FileName}", $"misurata {_curve.Measured[_hoverIndex]:0.00} EV", $"target {_curve.Target[_hoverIndex]:0.00} EV", $"guadagno {_curve.GainStops[_hoverIndex]:+0.00;-0.00;0.00} EV", $"intervallo {record.CadenceText} otturatore {record.ShutterAngle:0.#}°", - ]; + }; + + if (_curve.HasRegions && _curve.MeasuredHigh is { } high && _curve.MeasuredLow is { } low) + detail.Add($"cielo {high[_hoverIndex]:0.00} EV paesaggio {low[_hoverIndex]:0.00} EV"); + + if (!double.IsNaN(record.TemperatureKelvin)) + detail.Add($"temperatura {Analysis.ColorScience.Describe(record.TemperatureKelvin)}"); + + if (record.StabilizationShift > 0.01) + detail.Add($"stabilizzazione {record.StabilizationShift:0.0} px, " + + $"{record.StabilizationRotation:0.00}°"); + + if (record.IsExposureStep) detail.Add("qui la macchina ha cambiato impostazioni"); + + string[] lines = [.. detail]; int widthNeeded = 0; foreach (string line in lines) diff --git a/Titano/UI/MainForm.cs b/Titano/UI/MainForm.cs index fd2dab5..aa7dd93 100644 --- a/Titano/UI/MainForm.cs +++ b/Titano/UI/MainForm.cs @@ -12,7 +12,7 @@ internal sealed class MainForm : Form private readonly LuminanceChart _chart; private readonly FrameTable _table; private readonly PreviewPanel _preview; - private readonly SettingsPanel _settings; + private SettingsPanel _settings; private readonly DarkProgressBar _progress; private readonly Label _status; private readonly Label _summary; @@ -285,10 +285,7 @@ internal sealed class MainForm : Form ShowPreview(_table.SelectedIndex); }; - _settings.DeflickerChanged += (_, _) => RecomputeCurve(); - _settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis(); - _settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex); - _settings.BrowseOutputRequested += (_, _) => BrowseOutput(); + WireSettingsEvents(); DragEnter += (_, e) => { @@ -300,6 +297,14 @@ internal sealed class MainForm : Form }; } + private void WireSettingsEvents() + { + _settings.DeflickerChanged += (_, _) => RecomputeCurve(); + _settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis(); + _settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex); + _settings.BrowseOutputRequested += (_, _) => BrowseOutput(); + } + // ------------------------------------------------------------------ comandi private void AddFiles() @@ -352,10 +357,11 @@ internal sealed class MainForm : Form var sequence = await RenderPipeline.IngestAsync(paths, _project.General.CadenceTolerance, progress, _operation!.Token); _project.Sequence = sequence; - _project.Curve = null; - _project.Stats = null; + _project.InvalidateAnalysis(); _project.DetectOrientation(); _settings.ShowDetectedOrientation(_project.Orientation); + _settings.ShowSequenceGeometry(); + _settings.ShowAnalysis(); _table.SetSequence(sequence); _chart.SetData(sequence, null); @@ -386,8 +392,8 @@ internal sealed class MainForm : Form { if (_busy) return; _project.Sequence = null; - _project.Curve = null; - _project.Stats = null; + _project.InvalidateAnalysis(); + _settings.ShowAnalysis(); _table.SetSequence(null); _chart.SetData(null, null); _preview.Clear(); @@ -409,6 +415,7 @@ internal sealed class MainForm : Form _chart.SetData(_project.Sequence, _project.Curve); _table.Refresh(_project.Sequence); + _settings.ShowAnalysis(); UpdateSummary(); ShowPreview(_table.SelectedIndex); @@ -419,7 +426,8 @@ internal sealed class MainForm : Form double after = Analysis.DeflickerCurve.FlickerIndex(corrected); SetStatus($"Analisi completata. Sfarfallio {before:0.000} EV → {after:0.000} EV " + - $"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)."); + $"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)." + + DescribeAdvancedAnalysis()); } catch (OperationCanceledException) { @@ -455,7 +463,12 @@ internal sealed class MainForm : Form _table.Refresh(_project.Sequence); SetStatus($"Esportazione completata: {result.EncodedFrames} fotogrammi, " + $"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.Elapsed.TotalSeconds:0.0} s " + - $"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}."); + $"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}. " + + char.ToUpper(result.PlanDescription[0]) + result.PlanDescription[1..] + "." + + (result.UsedDisk + ? $" {result.SpilledFrames} fotogrammi sono passati dal parcheggio su disco " + + $"({result.SpillBytes / (1024.0 * 1024.0):0} MiB)." + : string.Empty)); } catch (OperationCanceledException) { @@ -506,6 +519,7 @@ internal sealed class MainForm : Form new RenderPipeline(_project).RecomputeCurve(); _chart.UpdateCurve(_project.Curve); _table.Refresh(_project.Sequence); + _settings.ShowAnalysis(); UpdateSummary(); ShowPreview(_table.SelectedIndex); } @@ -513,14 +527,42 @@ internal sealed class MainForm : Form private void InvalidateAnalysis() { if (_project.Sequence is { } sequence) sequence.RecomputeTiming(_project.General.CadenceTolerance); - _project.Curve = null; - _project.Stats = null; + _project.InvalidateAnalysis(); _chart.SetData(_project.Sequence, null); _table.Refresh(_project.Sequence); + _settings.ShowSequenceGeometry(); + _settings.ShowAnalysis(); UpdateSummary(); UpdateCommandState(); } + /// + /// Cenno compatto a quello che i moduli avanzati hanno trovato. Il dettaglio vive nella + /// sezione "Esito dell'analisi" del pannello: la barra di stato ha una riga sola, e + /// riempirla di numeri significa troncarli tutti. + /// + private string DescribeAdvancedAnalysis() + { + var parts = new List(); + + if (_project.Mask is { Coverage: var coverage }) parts.Add($"cielo {coverage * 100:0}%"); + + if (_project.Transitions is { StepCount: > 0 } transitions) + { + parts.Add(transitions.StepCount == 1 + ? "1 cambio di esposizione" + : $"{transitions.StepCount} cambi di esposizione"); + } + + if (_project.Motion is { } motion) + { + var (width, _) = _project.ResolveSourceSize(); + parts.Add($"tremolio {motion.MeanShake * Math.Max(1, width):0.0} px"); + } + + return parts.Count == 0 ? string.Empty : " " + string.Join(" · ", parts) + "."; + } + private void ShowPreview(int index) { if (_project.Sequence is not { Count: > 0 } sequence || index < 0) { _preview.Clear(); return; } @@ -627,4 +669,37 @@ internal sealed class MainForm : Form await AnalyzeAsync(); _table.SelectedIndex = Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1)); } + + /// + /// Accende i moduli avanzati prima di una cattura, così l'immagine mostra i controlli con + /// dati veri invece che a riposo. Serve solo alla verifica riproducibile dell'interfaccia. + /// + internal void EnableAdvancedModulesForCapture() + { + _project.Regions.Mode = Analysis.RegionMode.SkyGround; + _project.HolyGrail.Enabled = true; + _project.HolyGrail.TransitionFrames = 24; + _project.HolyGrail.SmoothColor = true; + _project.Stabilization.Enabled = true; + _project.Camera.Enabled = true; + _project.Camera.Keyframes = + [ + new() { Time = 0.0, CentreX = 0.38, CentreY = 0.42, Zoom = 1.20 }, + new() { Time = 0.55, CentreX = 0.52, CentreY = 0.50, Zoom = 1.45, EaseIn = 0.7, EaseOut = 0.2 }, + new() { Time = 1.0, CentreX = 0.68, CentreY = 0.58, Zoom = 1.80 }, + ]; + _project.TimeRamp.Enabled = true; + _project.TimeRamp.Speed = [new(0.0, 2.5), new(0.45, 0.4), new(1.0, 2.0)]; + _project.Stacking.Mode = Motion.StackingMode.Median; + + // I pannelli leggono il progetto alla costruzione: qui vanno rifatti da capo. + var host = _settings.Parent; + int page = 0; + host?.Controls.Remove(_settings); + _settings.Dispose(); + _settings = new SettingsPanel(_project) { Dock = DockStyle.Fill }; + host?.Controls.Add(_settings); + WireSettingsEvents(); + _settings.SelectPage(page); + } } diff --git a/Titano/UI/PreviewPanel.cs b/Titano/UI/PreviewPanel.cs index 5967660..b404861 100644 --- a/Titano/UI/PreviewPanel.cs +++ b/Titano/UI/PreviewPanel.cs @@ -21,6 +21,9 @@ internal sealed class PreviewPanel : Control private int _requestId; private bool _busy; + /// Contorno delle regioni in coordinate normalizzate dell'anteprima. + private PointF[]? _regionContour; + public PreviewPanel(TitanoProject project) { _project = project; @@ -61,7 +64,7 @@ internal sealed class PreviewPanel : Control { try { - var (bitmap, status) = Render(project, sequence, index, PreviewSize(), token); + var (bitmap, status, contour) = Render(project, sequence, index, PreviewSize(), token); if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); @@ -72,6 +75,7 @@ internal sealed class PreviewPanel : Control { if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; } SwapBitmap(bitmap); + _regionContour = contour; _status = status; _busy = false; Invalidate(); @@ -111,8 +115,8 @@ internal sealed class PreviewPanel : Control // ------------------------------------------------------------------ rendering - private static (Bitmap? Bitmap, string Status) Render(TitanoProject project, TimelapseSequence sequence, - int index, Size available, CancellationToken token) + private static (Bitmap? Bitmap, string Status, PointF[]? Contour) Render( + TitanoProject project, TimelapseSequence sequence, int index, Size available, CancellationToken token) { var record = sequence.Frames[index]; var metadata = record.Metadata; @@ -125,7 +129,7 @@ internal sealed class PreviewPanel : Control else if (ImageDecoder.SwapsAxes(orientation)) (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); - if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile"); + if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile", null); double scale = Math.Min(available.Width / (double)sourceWidth, available.Height / (double)sourceHeight); scale = Math.Min(scale, 1.0); @@ -142,8 +146,7 @@ internal sealed class PreviewPanel : Control var curve = project.Curve; if (project.Deflicker.Enabled && curve is not null && index < curve.Count) { - Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index], - project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee); + ApplyExposure(project, curve, index, frame); status.Append($" guadagno {curve.GainStops[index]:+0.00;-0.00;0.00} EV"); } @@ -158,8 +161,7 @@ internal sealed class PreviewPanel : Control if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count) { - Analysis.ExposureProcessor.Apply(next, curve.ChannelGain[index + 1], - project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee); + ApplyExposure(project, curve, index + 1, next); } var flow = new OpticalFlowEngine(project.Flow).Compute(frame, next); @@ -180,9 +182,92 @@ internal sealed class PreviewPanel : Control status.Append($" scia {length:0.0} px"); } + // Inquadratura virtuale e stabilizzazione: l'anteprima deve mostrare il fotogramma + // come uscirà, altrimenti si regola una panoramica guardando ciò che non si esporta. + ImageBuffer? framed = null; + PointF[]? contour = null; + var mapping = SourceMapping.Identity; + int framedWidth = width, framedHeight = height; + + if (project.NeedsGeometry) + { + var (outputWidth, outputHeight) = project.ResolveWorkingSize(); + if (outputWidth > 0 && outputHeight > 0) + { + framedWidth = width; + framedHeight = Math.Max(2, (int)Math.Round(width * outputHeight / (double)outputWidth) & ~1); + + double normalized = sequence.Count > 1 ? index / (double)(sequence.Count - 1) : 0; + var framing = project.FramingAt(normalized); + var stabilization = project.Motion?.At(index) ?? Motion.SimilarityTransform.Identity; + + mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization); + framed = pool.Rent(framedWidth, framedHeight); + GeometryStage.Resample(result, framed, mapping); + result = framed; + + status.Append($" inquadratura {framing.Zoom:0.00}×"); + } + } + + if (project.Mask is { } mask) + { + contour = TraceRegionBoundary(mask, mapping, framedWidth, framedHeight, width, height); + } + var bitmap = ToBitmap(result); blurred?.Dispose(); - return (bitmap, status.ToString()); + framed?.Dispose(); + return (bitmap, status.ToString(), contour); + } + + private static void ApplyExposure(TitanoProject project, Analysis.DeflickerCurve curve, + int index, ImageBuffer frame) + { + if (curve.HasRegions && project.Mask is { } mask) + { + Analysis.ExposureProcessor.ApplyRegional(frame, mask, curve.ChannelGainHigh![index], + curve.ChannelGainLow![index], + project.Deflicker.ProtectHighlights, + project.Deflicker.HighlightKnee); + return; + } + + Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index], + project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee); + } + + /// + /// Confine fra le due regioni, in coordinate normalizzate dell'immagine mostrata. Il + /// campionamento passa per la stessa mappatura del ritaglio, così la linea resta al suo + /// posto anche quando l'anteprima mostra una panoramica virtuale. + /// + private static PointF[]? TraceRegionBoundary(Analysis.RegionMask mask, in SourceMapping mapping, + int width, int height, int sourceWidth, int sourceHeight) + { + var points = new List(width); + float invSourceWidth = sourceWidth > 1 ? 1f / (sourceWidth - 1) : 0f; + float invSourceHeight = sourceHeight > 1 ? 1f / (sourceHeight - 1) : 0f; + + for (int x = 0; x < width; x += 2) + { + float previous = float.NaN; + for (int y = 0; y < height; y++) + { + var (sx, sy) = mapping.Apply(x, y); + float weight = mask.Sample((float)sx * invSourceWidth, (float)sy * invSourceHeight); + + if (!float.IsNaN(previous) && (previous - 0.5f) * (weight - 0.5f) <= 0) + { + points.Add(new PointF(x / (float)Math.Max(1, width - 1), + y / (float)Math.Max(1, height - 1))); + break; + } + previous = weight; + } + } + + return points.Count >= 2 ? [.. points] : null; } /// Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit. @@ -245,6 +330,21 @@ internal sealed class PreviewPanel : Control g.DrawImage(_bitmap, target); using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, target); + if (_regionContour is { Length: >= 2 } contour) + { + var line = new PointF[contour.Length]; + for (int i = 0; i < contour.Length; i++) + { + line[i] = new PointF(target.Left + contour[i].X * target.Width, + target.Top + contour[i].Y * target.Height); + } + + using var shadow = new Pen(Color.FromArgb(140, Color.Black), 3f); + using var boundary = new Pen(Color.FromArgb(210, Theme.Success), 1.6f); + g.DrawLines(shadow, line); + g.DrawLines(boundary, line); + } + if (_busy) { using var overlay = new SolidBrush(Color.FromArgb(120, Theme.Background)); diff --git a/Titano/UI/SettingsPanel.cs b/Titano/UI/SettingsPanel.cs index 290b855..5ada1df 100644 --- a/Titano/UI/SettingsPanel.cs +++ b/Titano/UI/SettingsPanel.cs @@ -1,13 +1,15 @@ +using Titano.Analysis; +using Titano.Motion; using Titano.Pipeline; using Titano.Video; namespace Titano.UI; /// -/// Pannello di configurazione avanzata, suddiviso nelle tre sezioni richieste: -/// Generale, Elaborazione immagini (deflicker, motion blur, campo vettoriale) ed -/// Esportazione video. Ogni controllo scrive direttamente nel progetto e segnala -/// quale parte della pipeline va ricalcolata. +/// Pannello di configurazione avanzata, diviso nelle sezioni Generale, Immagine, Movimento, +/// Tempo ed Esportazione. Ogni controllo scrive direttamente nel progetto e dichiara quale +/// parte della pipeline va rifatta: c'è differenza fra spostare un cursore che cambia solo +/// una curva già calcolata e sceglierne uno che obbliga a rileggere ogni file. /// internal sealed class SettingsPanel : Panel { @@ -17,9 +19,19 @@ internal sealed class SettingsPanel : Panel private readonly TitanoProject _project; private readonly TabStrip _tabs; private readonly Panel[] _pages; - private LabeledCombo? _orientationCombo; - /// Un parametro del deflicker è cambiato: basta ricalcolare la curva. + private LabeledCombo? _orientationCombo; + private Label? _analysisNote; + private CameraEditor? _cameraEditor; + private SplineEditor? _rampEditor; + private ParameterSlider? _keyframeCentreX; + private ParameterSlider? _keyframeCentreY; + private ParameterSlider? _keyframeZoom; + private ParameterSlider? _keyframeEaseOut; + private ParameterSlider? _keyframeEaseIn; + private bool _syncingKeyframe; + + /// Un parametro del deflicker è cambiato: basta ricalcolare le curve già misurate. public event EventHandler? DeflickerChanged; /// È cambiato un parametro che invalida l'analisi già svolta. @@ -38,7 +50,10 @@ internal sealed class SettingsPanel : Panel BackColor = Theme.Surface; Padding = new Padding(0); - _tabs = new TabStrip("Generale", "Elaborazione immagini", "Esportazione") { Dock = DockStyle.Top }; + _tabs = new TabStrip("Generale", "Immagine", "Movimento", "Tempo", "Esportazione") + { + Dock = DockStyle.Top, + }; _tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex); OutputPathBox = new TextBox @@ -58,7 +73,7 @@ internal sealed class SettingsPanel : Panel // L'ordine di inserimento determina l'ordine di ancoraggio: i controlli in coda alla // collezione vengono disposti per primi, quindi la barra delle schede va aggiunta // dopo le pagine per riservarsi la propria fascia in alto. - _pages = [BuildGeneralPage(), BuildImagePage(), BuildExportPage()]; + _pages = [BuildGeneralPage(), BuildImagePage(), BuildMotionPage(), BuildTimePage(), BuildExportPage()]; foreach (var page in _pages) { page.Dock = DockStyle.Fill; @@ -82,7 +97,52 @@ internal sealed class SettingsPanel : Panel _orientationCombo.Combo.SelectedIndex = selected; } - /// Seleziona una delle tre sezioni; usata anche dalla modalità di cattura. + /// Aggiorna le voci che dipendono dall'analisi: maschera, transizioni, tremolio. + public void ShowAnalysis() + { + if (_analysisNote is null) return; + + var lines = new List(); + + if (_project.Regions.Mode != RegionMode.Off) + { + lines.Add(_project.Mask is { } mask + ? "Regioni: " + mask.Description + : "Regioni: la scena non si divide in modo utile, resta la curva unica."); + } + + if (_project.HolyGrail.Enabled && _project.Transitions is { } transitions) + { + lines.Add(transitions.StepCount == 0 + ? "Transizioni: nessun cambio di impostazione rilevato." + : $"Transizioni: {transitions.StepCount} cambi di impostazione, " + + $"il maggiore di {transitions.LargestStepStops:0.00} EV" + + (transitions.MetadataUsable ? " (dai metadati)." : " (dedotti dalla luminanza).")); + } + + if (_project.Stabilization.Enabled && _project.Motion is { } motion) + { + var (width, _) = _project.ResolveSourceSize(); + lines.Add($"Stabilizzazione: tremolio medio {motion.MeanShake * Math.Max(1, width):0.0} px, " + + $"ritaglio necessario {(_project.StabilizationZoom - 1) * 100:0.#}%."); + } + + _analysisNote.Text = lines.Count > 0 + ? string.Join(Environment.NewLine, lines) + : "Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza."; + LayoutNote(_analysisNote); + } + + /// Aggiorna l'editor del movimento quando cambia la sequenza caricata. + public void ShowSequenceGeometry() + { + if (_cameraEditor is null) return; + var (width, height) = _project.ResolveNativeSize(); + if (width > 0 && height > 0) _cameraEditor.Aspect = width / (double)height; + _cameraEditor.Invalidate(); + } + + /// Seleziona una delle sezioni; usata anche dalla modalità di cattura. internal void SelectPage(int index) => _tabs.SelectedIndex = index; private void ShowPage(int index) @@ -90,7 +150,7 @@ internal sealed class SettingsPanel : Panel for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index; } - // ------------------------------------------------------------------ pagine + // ------------------------------------------------------------------ Generale private Panel BuildGeneralPage() { @@ -140,11 +200,11 @@ internal sealed class SettingsPanel : Panel AnalysisInvalidated?.Invoke(this, EventArgs.Empty); })); - stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura e " + - "risoluzione della passata fotometrica, e sovrascrive i cursori delle sezioni " + - "avanzate. Più qualità significa più tempo, non un file più grande.")); + stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura, " + + "riquadri della correlazione di fase e risoluzione delle passate di analisi, " + + "e sovrascrive i cursori delle sezioni avanzate.")); - stack.Add(new SectionHeader("Prestazioni")); + stack.Add(new SectionHeader("Prestazioni e memoria")); stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px", value => { @@ -159,24 +219,40 @@ internal sealed class SettingsPanel : Panel PreviewInvalidated?.Invoke(this, EventArgs.Empty); })); - stack.Add(Note("Le decodifiche simultanee determinano anche quanti fotogrammi restano " + - "contemporaneamente in memoria: l'occupazione non dipende dalla lunghezza della sequenza.")); + stack.Add(Slider("Lettura in anticipo", 0, 32, _project.Cache.PrefetchDepth, 1, "0", "fotogrammi", + value => _project.Cache.PrefetchDepth = (int)value)); + + stack.Add(Slider("Tetto di memoria per i fotogrammi", 256, 32768, _project.Cache.MemoryBudgetMiB, 256, "0", "MiB", + value => _project.Cache.MemoryBudgetMiB = (int)value)); + + stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _project.Cache.AllowDiskSpill, + value => _project.Cache.AllowDiskSpill = value)); + + stack.Add(Note("La finestra attiva sta sempre in memoria; il tetto governa la lettura in " + + "anticipo, che serve a tenere occupati tutti i processori sulla decodifica dei " + + "RAW. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono ripresi " + + "una volta sola. Il file di parcheggio si cancella da sé alla chiusura.")); + + stack.Add(new SectionHeader("Esito dell'analisi")); + _analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza."); + stack.Add(_analysisNote); + return stack.Panel; } + // ------------------------------------------------------------------ Immagine + private Panel BuildImagePage() { var stack = NewStack(); - // ---- Deflicker stack.Add(new SectionHeader("Deflicker")); - var deflickerEnabled = Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value => + stack.Add(Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value => { _project.Deflicker.Enabled = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); - }); - stack.Add(deflickerEnabled); + })); stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi", value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); @@ -193,12 +269,6 @@ internal sealed class SettingsPanel : Panel DeflickerChanged?.Invoke(this, EventArgs.Empty); })); - stack.Add(Check("Stabilizza il bilanciamento colore", _project.Deflicker.StabilizeColor, value => - { - _project.Deflicker.StabilizeColor = value; - DeflickerChanged?.Invoke(this, EventArgs.Empty); - })); - stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value => { _project.Deflicker.ProtectHighlights = value; @@ -208,7 +278,182 @@ internal sealed class SettingsPanel : Panel stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty, value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); })); - // ---- Motion blur + // ---- Regioni + stack.Add(new SectionHeader("Deflicker per regioni")); + + stack.Add(Combo("Divisione del fotogramma", + ["Nessuna — una sola curva", "Cielo e paesaggio (linea d'orizzonte)", "Per luminanza"], + _project.Regions.Mode switch { RegionMode.SkyGround => 1, RegionMode.Luminance => 2, _ => 0 }, + index => + { + _project.Regions.Mode = index switch + { + 1 => RegionMode.SkyGround, + 2 => RegionMode.Luminance, + _ => RegionMode.Off, + }; + AnalysisInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Indipendenza delle regioni", 0, 1, _project.Regions.Independence, 0.05, "0.00", string.Empty, + value => { _project.Regions.Independence = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Sfumatura del confine", 0.01, 0.20, _project.Regions.Feather, 0.01, "0.00", string.Empty, + value => { _project.Regions.Feather = value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Fotogrammi campionati", 3, 48, _project.Regions.SampleFrames, 1, "0", string.Empty, + value => { _project.Regions.SampleFrames = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Note("La maschera nasce dalla mediana temporale di un campione di fotogrammi, che " + + "toglie di mezzo nuvole e passanti e lascia la struttura fissa della scena. " + + "Serve a impedire che il transito di una nuvola densa sul cielo faccia " + + "schiarire anche il paesaggio, che invece non è cambiato.")); + + // ---- Holy Grail + stack.Add(new SectionHeader("Transizioni giorno-notte")); + + stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled, value => + { + _project.HolyGrail.Enabled = value; + DeflickerChanged?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Lunghezza della transizione", 4, 240, _project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi", + value => { _project.HolyGrail.TransitionFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Soglia di riconoscimento", 0.05, 1.0, _project.HolyGrail.StepThresholdStops, 0.05, "0.00", "EV", + value => { _project.HolyGrail.StepThresholdStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Check("Ricava i salti dai metadati", _project.HolyGrail.UseMetadata, value => + { + _project.HolyGrail.UseMetadata = value; + DeflickerChanged?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Check("Liscia il bilanciamento del bianco", _project.HolyGrail.SmoothColor, value => + { + _project.HolyGrail.SmoothColor = value; + DeflickerChanged?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0", "fotogrammi", + value => { _project.HolyGrail.ColorWindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Intensità cromatica", 0, 1, _project.HolyGrail.ColorStrength, 0.05, "0.00", string.Empty, + value => { _project.HolyGrail.ColorStrength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni " + + "salto è nota senza incertezza e viene ridistribuita su una transizione a " + + "derivata nulla agli estremi. La lisciatura cromatica agisce sul rapporto fra " + + "i canali: toglie il tremolio del bilanciamento automatico e lascia intatto " + + "il viaggio verso il caldo del tramonto.")); + + return stack.Panel; + } + + // ------------------------------------------------------------------ Movimento + + private Panel BuildMotionPage() + { + var stack = NewStack(); + + stack.Add(new SectionHeader("Stabilizzazione sub-pixel")); + + stack.Add(Check("Compensa i micro-urti", _project.Stabilization.Enabled, value => + { + _project.Stabilization.Enabled = value; + AnalysisInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Finestra del percorso", 5, 151, _project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi", + value => { _project.Stabilization.SmoothingFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Intensità", 0, 1, _project.Stabilization.Strength, 0.05, "0.00", string.Empty, + value => { _project.Stabilization.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005, "0.000", "×L", + value => { _project.Stabilization.MaxCorrectionFraction = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation, value => + { + _project.Stabilization.CompensateRotation = value; + AnalysisInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Lato dei riquadri di correlazione", 64, 512, _project.Stabilization.PatchSize, 64, "0", "px", + value => + { + // La trasformata vuole una potenza di due: il cursore si muove per gradini validi. + _project.Stabilization.PatchSize = Fourier.FloorPowerOfTwo((int)value); + AnalysisInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Riquadri per lato", 1, 5, _project.Stabilization.Grid, 1, "0", string.Empty, + value => { _project.Stabilization.Grid = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Note("Lo spostamento fra fotogrammi adiacenti si misura con la correlazione di " + + "fase, che ignora le differenze di luminosità e reagisce alla sola geometria. " + + "Il percorso ricostruito viene lisciato: quello che resta fra percorso vero e " + + "percorso liscio è il tremolio, e la sua inversa è la correzione. Una " + + "panoramica voluta sopravvive perché è già liscia.")); + + // ---- Virtual camera + stack.Add(new SectionHeader("Movimento di macchina virtuale")); + + stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled, value => + { + _project.Camera.Enabled = value; + AnalysisInvalidated?.Invoke(this, EventArgs.Empty); + })); + + _cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 210 }; + _cameraEditor.Changed += (_, _) => + { + SyncKeyframeControls(); + PreviewInvalidated?.Invoke(this, EventArgs.Empty); + }; + _cameraEditor.SelectionChanged += (_, _) => SyncKeyframeControls(); + stack.Add(_cameraEditor); + + var keyframeButtons = new Panel { Height = 32, BackColor = Theme.Surface }; + var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 130, Height = 28, Left = 0, Top = 0 }; + var removeButton = new DarkButton { Text = "Togli nodo", Width = 118, Height = 28, Left = 138, Top = 0 }; + addButton.Click += (_, _) => { _cameraEditor.AddKeyframe(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); }; + removeButton.Click += (_, _) => { _cameraEditor.RemoveSelected(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); }; + keyframeButtons.Controls.Add(addButton); + keyframeButtons.Controls.Add(removeButton); + stack.Add(keyframeButtons); + + _keyframeCentreX = Slider("Nodo — centro orizzontale", 0, 1, 0.5, 0.005, "0.000", string.Empty, + value => UpdateSelectedKeyframe(k => k.CentreX = value)); + _keyframeCentreY = Slider("Nodo — centro verticale", 0, 1, 0.5, 0.005, "0.000", string.Empty, + value => UpdateSelectedKeyframe(k => k.CentreY = value)); + _keyframeZoom = Slider("Nodo — ingrandimento", 1, 8, 1, 0.05, "0.00", "×", + value => UpdateSelectedKeyframe(k => k.Zoom = value)); + _keyframeEaseOut = Slider("Nodo — indugio in partenza", 0, 1, 0.42, 0.02, "0.00", string.Empty, + value => UpdateSelectedKeyframe(k => k.EaseOut = value)); + _keyframeEaseIn = Slider("Nodo — frenata in arrivo", 0, 1, 0.42, 0.02, "0.00", string.Empty, + value => UpdateSelectedKeyframe(k => k.EaseIn = value)); + + stack.Add(_keyframeCentreX); + stack.Add(_keyframeCentreY); + stack.Add(_keyframeZoom); + stack.Add(_keyframeEaseOut); + stack.Add(_keyframeEaseIn); + + stack.Add(Check("Tieni l'inquadratura dentro il fotogramma", _project.Camera.KeepInsideFrame, value => + { + _project.Camera.KeepInsideFrame = value; + PreviewInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Note("Le inquadrature si trascinano dentro il riquadro e si stringono dalla " + + "maniglia d'angolo; la striscia in basso è la linea del tempo. Le tacche " + + "lungo il percorso sono equispaziate nel tempo: dove si addensano il " + + "movimento rallenta. Con il movimento attivo i fotogrammi vengono letti a " + + "risoluzione nativa, perché il ritaglio deve avere pixel da cui attingere.")); + + // ---- Motion blur e campo vettoriale stack.Add(new SectionHeader("Motion blur sintetico")); stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value => @@ -232,7 +477,6 @@ internal sealed class SettingsPanel : Panel stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " + "√(obiettivo² − reale²). A 180° si ottiene la resa cinematografica.")); - // ---- Optical flow stack.Add(new SectionHeader("Campo vettoriale di movimento")); stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px", @@ -250,9 +494,122 @@ internal sealed class SettingsPanel : Panel stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty, value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); })); + SyncKeyframeControls(); return stack.Panel; } + private void UpdateSelectedKeyframe(Action change) + { + if (_syncingKeyframe || _cameraEditor?.Selected is not { } keyframe) return; + change(keyframe); + _cameraEditor.Invalidate(); + PreviewInvalidated?.Invoke(this, EventArgs.Empty); + } + + /// Riporta nei cursori i valori del nodo scelto, senza farli reagire. + private void SyncKeyframeControls() + { + if (_cameraEditor?.Selected is not { } keyframe) return; + + _syncingKeyframe = true; + _keyframeCentreX?.SetValueSilently(keyframe.CentreX); + _keyframeCentreY?.SetValueSilently(keyframe.CentreY); + _keyframeZoom?.SetValueSilently(keyframe.Zoom); + _keyframeEaseOut?.SetValueSilently(keyframe.EaseOut); + _keyframeEaseIn?.SetValueSilently(keyframe.EaseIn); + _syncingKeyframe = false; + } + + // ------------------------------------------------------------------ Tempo + + private Panel BuildTimePage() + { + var stack = NewStack(); + + stack.Add(new SectionHeader("Andamento temporale")); + stack.Add(Combo("Durata dei fotogrammi", + ["Costante — un fotogramma per scatto", + "Adattiva — durata proporzionale all'intervallo", + "Interpolata — cadenza uniformata con fotogrammi sintetici"], + (int)_project.Export.Timing, + index => + { + _project.Export.Timing = (FrameTimingMode)index; + PreviewInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×", + value => _project.Export.MaxAdaptiveStretch = value)); + + // ---- Time ramping + stack.Add(new SectionHeader("Rimappatura non lineare")); + + stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled, value => + { + _project.TimeRamp.Enabled = value; + PreviewInvalidated?.Invoke(this, EventArgs.Empty); + })); + + _rampEditor = new SplineEditor + { + Minimum = 0.1, + Maximum = 8.0, + Height = 168, + StartLabel = "primo scatto", + EndLabel = "ultimo scatto", + }; + _rampEditor.SetKnots(_project.TimeRamp.Speed); + _rampEditor.Changed += (_, _) => + { + _project.TimeRamp.Speed = [.. _rampEditor.Knots]; + PreviewInvalidated?.Invoke(this, EventArgs.Empty); + }; + stack.Add(_rampEditor); + + stack.Add(Note("Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro. " + + "La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: " + + "sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta e i fotogrammi " + + "mancanti vengono sintetizzati dal campo vettoriale. La velocità del filmato " + + "resta fissa. La spline è monotona per costruzione, quindi il tempo non può " + + "tornare indietro fra due nodi.")); + + // ---- Stacking + stack.Add(new SectionHeader("Accumulo temporale")); + + stack.Add(Combo("Modalità", + ["Nessuna", "Mediana — rimuove gli elementi di passaggio", "Massimo — scie stellari"], + _project.Stacking.Mode switch { StackingMode.Median => 1, StackingMode.Maximum => 2, _ => 0 }, + index => + { + _project.Stacking.Mode = index switch + { + 1 => StackingMode.Median, + 2 => StackingMode.Maximum, + _ => StackingMode.Off, + }; + PreviewInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Slider("Finestra della mediana", 3, 49, _project.Stacking.WindowFrames, 2, "0", "fotogrammi", + value => { _project.Stacking.WindowFrames = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Intensità dell'accumulo", 0, 1, _project.Stacking.Strength, 0.05, "0.00", string.Empty, + value => { _project.Stacking.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Slider("Lunghezza delle scie", 0, 400, _project.Stacking.TrailFrames, 5, "0", "fotogrammi", + value => { _project.Stacking.TrailFrames = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); })); + + stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la " + + "scena stabile resta se stessa, chi attraversa l'inquadratura una volta sola " + + "sparisce. Il massimo conserva il valore più alto incontrato e trasforma le " + + "stelle in archi continui; a zero le scie non si spengono mai. La finestra " + + "della mediana tiene occupata memoria: sono tutti fotogrammi vivi insieme.")); + + return stack.Panel; + } + + // ------------------------------------------------------------------ Esportazione + private Panel BuildExportPage() { var stack = NewStack(); @@ -283,21 +640,6 @@ internal sealed class SettingsPanel : Panel stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s", value => _project.Export.KeyframeIntervalSeconds = (int)value)); - stack.Add(new SectionHeader("Andamento temporale")); - stack.Add(Combo("Durata dei fotogrammi", - ["Costante — un fotogramma per scatto", - "Adattiva — durata proporzionale all'intervallo", - "Interpolata — cadenza uniformata con fotogrammi sintetici"], - (int)_project.Export.Timing, - index => - { - _project.Export.Timing = (FrameTimingMode)index; - PreviewInvalidated?.Invoke(this, EventArgs.Empty); - })); - - stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×", - value => _project.Export.MaxAdaptiveStretch = value)); - stack.Add(new SectionHeader("Codifica")); stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware, value => _project.Export.PreferHardware = value)); @@ -309,8 +651,9 @@ internal sealed class SettingsPanel : Panel browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty); stack.Add(browse); - stack.Add(Note("Il video viene scritto in un unico flusso continuo: l'elaborazione non " + - "genera alcun file temporaneo su disco.")); + stack.Add(Note("Il video viene scritto in un unico flusso continuo. L'unico altro file che " + + "l'elaborazione può creare è il parcheggio temporaneo dei fotogrammi, che si " + + "cancella da sé e non contiene nulla di riutilizzabile.")); return stack.Panel; } @@ -332,18 +675,36 @@ internal sealed class SettingsPanel : Panel // Le note esplicative variano in lunghezza: si misurano sulla larghezza reale // della colonna, altrimenti le più lunghe finirebbero tagliate a metà frase. - if (control is Label note && !note.AutoSize) - { - var measured = TextRenderer.MeasureText(note.Text, note.Font, - new Size(control.Width, 0), TextFormatFlags.WordBreak); - control.Height = measured.Height + 8; - } + if (control is Label note && !note.AutoSize) MeasureNote(note); Panel.Controls.Add(control); _y += control.Height + 6; } } + private static void MeasureNote(Label note) + { + var measured = TextRenderer.MeasureText(note.Text, note.Font, + new Size(Math.Max(40, note.Width), 0), + TextFormatFlags.WordBreak); + note.Height = measured.Height + 8; + } + + /// Rimisura una nota il cui testo è cambiato dopo la costruzione della pagina. + private static void LayoutNote(Label note) + { + int before = note.Height; + MeasureNote(note); + if (note.Height == before || note.Parent is null) return; + + // Le note sotto vanno fatte scorrere: la colonna è posizionata a coordinate assolute. + int delta = note.Height - before; + foreach (Control sibling in note.Parent.Controls) + { + if (sibling != note && sibling.Top > note.Top) sibling.Top += delta; + } + } + private static Stack NewStack() { var panel = new Panel diff --git a/Titano/UI/SplineEditor.cs b/Titano/UI/SplineEditor.cs new file mode 100644 index 0000000..5d289c9 --- /dev/null +++ b/Titano/UI/SplineEditor.cs @@ -0,0 +1,252 @@ +using System.Drawing.Drawing2D; +using Titano.Core; + +namespace Titano.UI; + +/// +/// Editor grafico di una curva a nodi: si trascinano i punti, si aggiungono con un doppio +/// clic e si tolgono con il tasto destro. La curva disegnata è la stessa che il motore +/// valuta, non un'approssimazione: quello che si vede è quello che verrà eseguito. +/// +/// L'asse verticale è logaritmico perché il valore rappresenta una velocità, e per una +/// velocità il contrario di "doppio" è "metà", non "meno uno": su una scala lineare metà +/// dello spazio finirebbe fra 1× e 8× e l'altra metà schiacciata fra 0,1× e 1×, rendendo i +/// rallentamenti impossibili da regolare. +/// +internal sealed class SplineEditor : Control +{ + private const int Gutter = 38; + private const float HitRadius = 9f; + + private List _knots = [new(0, 1), new(0.5, 1), new(1, 1)]; + private int _dragging = -1; + private int _hovered = -1; + + public event EventHandler? Changed; + + public double Minimum { get; set; } = 0.1; + public double Maximum { get; set; } = 8.0; + public string UnitFormat { get; set; } = "0.##×"; + + /// Etichette dei due estremi dell'asse orizzontale. + public string StartLabel { get; set; } = "inizio"; + public string EndLabel { get; set; } = "fine"; + + public SplineEditor() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = Theme.Surface; + Height = 168; + Cursor = Cursors.Hand; + } + + public IReadOnlyList Knots => _knots; + + public void SetKnots(IEnumerable knots) + { + _knots = [.. knots.OrderBy(k => k.X)]; + if (_knots.Count < 2) _knots = [new(0, 1), new(1, 1)]; + Invalidate(); + } + + // ------------------------------------------------------------------ interazione + + private Rectangle Plot => new(Gutter, 12, Math.Max(20, Width - Gutter - 14), + Math.Max(20, Height - 12 - 24)); + + private PointF ToScreen(SplineKnot knot) + { + var plot = Plot; + double logMin = Math.Log(Minimum), logMax = Math.Log(Maximum); + double normalized = (Math.Log(Math.Clamp(knot.Y, Minimum, Maximum)) - logMin) / (logMax - logMin); + return new PointF(plot.Left + (float)(knot.X * plot.Width), + plot.Bottom - (float)(normalized * plot.Height)); + } + + private SplineKnot ToValue(float x, float y) + { + var plot = Plot; + double logMin = Math.Log(Minimum), logMax = Math.Log(Maximum); + double fx = Math.Clamp((x - plot.Left) / (double)plot.Width, 0, 1); + double fy = Math.Clamp((plot.Bottom - y) / (double)plot.Height, 0, 1); + return new SplineKnot(fx, Math.Exp(logMin + fy * (logMax - logMin))); + } + + private int HitTest(Point location) + { + for (int i = 0; i < _knots.Count; i++) + { + var point = ToScreen(_knots[i]); + float dx = point.X - location.X; + float dy = point.Y - location.Y; + if (dx * dx + dy * dy <= HitRadius * HitRadius) return i; + } + return -1; + } + + protected override void OnMouseDown(MouseEventArgs e) + { + Focus(); + int index = HitTest(e.Location); + + if (e.Button == MouseButtons.Right) + { + // Gli estremi non si tolgono: senza di loro la curva non coprirebbe la sequenza. + if (index > 0 && index < _knots.Count - 1) + { + _knots.RemoveAt(index); + Changed?.Invoke(this, EventArgs.Empty); + Invalidate(); + } + return; + } + + if (e.Button == MouseButtons.Left) _dragging = index; + base.OnMouseDown(e); + } + + protected override void OnMouseDoubleClick(MouseEventArgs e) + { + if (e.Button != MouseButtons.Left || HitTest(e.Location) >= 0) return; + + var value = ToValue(e.X, e.Y); + _knots.Add(value); + _knots = [.. _knots.OrderBy(k => k.X)]; + Changed?.Invoke(this, EventArgs.Empty); + Invalidate(); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + if (_dragging >= 0 && _dragging < _knots.Count) + { + var value = ToValue(e.X, e.Y); + + // Gli estremi restano ancorati; gli altri non possono scavalcare i vicini, + // altrimenti l'ordine dei nodi — su cui si regge la monotonia — salterebbe. + double x = _dragging == 0 ? 0 + : _dragging == _knots.Count - 1 ? 1 + : Math.Clamp(value.X, _knots[_dragging - 1].X + 0.01, _knots[_dragging + 1].X - 0.01); + + _knots[_dragging] = new SplineKnot(x, value.Y); + Changed?.Invoke(this, EventArgs.Empty); + Invalidate(); + return; + } + + int hovered = HitTest(e.Location); + if (hovered != _hovered) { _hovered = hovered; Invalidate(); } + base.OnMouseMove(e); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + _dragging = -1; + base.OnMouseUp(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + _hovered = -1; + Invalidate(); + base.OnMouseLeave(e); + } + + // ------------------------------------------------------------------ disegno + + protected override void OnPaint(PaintEventArgs e) + { + var g = e.Graphics; + Theme.HighQuality(g); + g.Clear(Parent?.BackColor ?? Theme.Surface); + + var plot = Plot; + Theme.FillRounded(g, plot, 4f, Theme.SurfaceAlt); + + using (var gridPen = new Pen(Theme.Border) { DashStyle = DashStyle.Dot }) + using (var unityPen = new Pen(Color.FromArgb(150, Theme.TextFaint))) + { + foreach (double value in (ReadOnlySpan)[0.25, 0.5, 1, 2, 4]) + { + if (value < Minimum || value > Maximum) continue; + var point = ToScreen(new SplineKnot(0, value)); + bool unity = Math.Abs(value - 1) < 1e-9; + g.DrawLine(unity ? unityPen : gridPen, plot.Left, point.Y, plot.Right, point.Y); + + TextRenderer.DrawText(g, value.ToString(UnitFormat), Theme.Small, + new Rectangle(0, (int)point.Y - 8, Gutter - 4, 16), + unity ? Theme.TextMuted : Theme.TextFaint, + TextFormatFlags.Right | TextFormatFlags.VerticalCenter); + } + + for (int i = 1; i < 4; i++) + { + float x = plot.Left + plot.Width * i / 4f; + g.DrawLine(gridPen, x, plot.Top, x, plot.Bottom); + } + } + + // Curva: un campione per pixel, valutata esattamente come la valuta il motore. + var points = new PointF[Math.Max(2, plot.Width)]; + for (int i = 0; i < points.Length; i++) + { + double x = i / (double)(points.Length - 1); + double y = Math.Clamp(Spline.Evaluate(_knots, x), Minimum, Maximum); + points[i] = ToScreen(new SplineKnot(x, y)); + } + + using (var area = new GraphicsPath()) + { + var polygon = new PointF[points.Length + 2]; + Array.Copy(points, polygon, points.Length); + polygon[^2] = new PointF(plot.Right, plot.Bottom); + polygon[^1] = new PointF(plot.Left, plot.Bottom); + area.AddPolygon(polygon); + + using var fill = new SolidBrush(Color.FromArgb(34, Theme.Accent)); + var clip = g.Clip; + g.SetClip(plot); + g.FillPath(fill, area); + g.Clip = clip; + } + + using (var pen = new Pen(Theme.Accent, 2f) { LineJoin = LineJoin.Round }) + { + g.DrawLines(pen, points); + } + + for (int i = 0; i < _knots.Count; i++) + { + var point = ToScreen(_knots[i]); + float radius = i == _dragging ? 6.5f : i == _hovered ? 6f : 5f; + + using var fill = new SolidBrush(Theme.Text); + using var ring = new Pen(Theme.Accent, 2f); + g.FillEllipse(fill, point.X - radius, point.Y - radius, radius * 2, radius * 2); + g.DrawEllipse(ring, point.X - radius, point.Y - radius, radius * 2, radius * 2); + } + + // Le tre etichette condividono una sola riga: quelle laterali sono corte per scelta e + // al centro resta lo spazio per il valore del nodo sotto il puntatore. + int labelY = plot.Bottom + 4; + var startSize = TextRenderer.MeasureText(g, StartLabel, Theme.Small); + var endSize = TextRenderer.MeasureText(g, EndLabel, Theme.Small); + + TextRenderer.DrawText(g, StartLabel, Theme.Small, + new Rectangle(plot.Left, labelY, startSize.Width + 4, 16), + Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); + TextRenderer.DrawText(g, EndLabel, Theme.Small, + new Rectangle(plot.Right - endSize.Width - 4, labelY, endSize.Width + 4, 16), + Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter); + + if (_hovered >= 0 && _hovered < _knots.Count) + { + int left = plot.Left + startSize.Width + 10; + int right = plot.Right - endSize.Width - 10; + TextRenderer.DrawText(g, _knots[_hovered].Y.ToString(UnitFormat), Theme.SmallBold, + new Rectangle(left, labelY, Math.Max(20, right - left), 16), Theme.Text, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + } + } +} diff --git a/Titano/UI/Theme.cs b/Titano/UI/Theme.cs index e9fe3fc..38852c6 100644 --- a/Titano/UI/Theme.cs +++ b/Titano/UI/Theme.cs @@ -23,6 +23,12 @@ internal static class Theme public static readonly Color Accent = Color.FromArgb(0x4C, 0x9A, 0xFF); public static readonly Color AccentDim = Color.FromArgb(0x2F, 0x6C, 0xC2); public static readonly Color Measured = Color.FromArgb(0xF5, 0xA5, 0x24); + + // Le due regioni hanno colori propri, scelti lontani dall'ambra del misurato e dal blu + // del target: sul grafico compaiono insieme a quelle, e due curve dello stesso colore + // sarebbero peggio che non disegnarle. + public static readonly Color RegionHigh = Color.FromArgb(0x5A, 0xD1, 0xC8); + public static readonly Color RegionLow = Color.FromArgb(0xC0, 0x84, 0x57); public static readonly Color Success = Color.FromArgb(0x35, 0xC4, 0x8F); public static readonly Color Warning = Color.FromArgb(0xE8, 0xB3, 0x39); public static readonly Color Danger = Color.FromArgb(0xF0, 0x57, 0x5A);