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