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);