Files
Titano/Titano/Analysis/RegionSegmenter.cs
T
Alby96andClaude Opus 5 a815a91006 Sei moduli avanzati per la produzione cinematografica
Estende il motore con stabilizzazione sub-pixel, deflicker per regioni,
transizioni giorno-notte, movimento di macchina virtuale, rimappatura non
lineare del tempo e accumulo temporale. Tutto in-house, nessuna dipendenza
aggiunta: il progetto continua a non contenere un solo PackageReference.

Perche' questa forma. Il rendering non percorre piu' la sequenza sorgente ma
un piano di fotogrammi d'uscita, ognuno con posizione anche frazionaria e
durata propria. Le quattro modalita' temporali producono tutte quella stessa
forma, quindi il ciclo di rendering e' uno solo e non ha un ramo per ciascun
caso; da li' discende anche la sfocatura, perche' un fotogramma che copre v
scatti ha angolo di otturatore diviso v.

Gli spostamenti si misurano con la correlazione di fase, che ignora per
costruzione le differenze di luminosita' fra scatti - in un time-lapse ci sono
sempre - e reagisce alla sola geometria. Il picco intero non basta: la
superficie di correlazione viene ricostruita a passo fine valutando la somma
di Fourier sulle posizioni intermedie invece di interpolare con una parabola
tre campioni di una cresta che parabola non e'. L'errore misurato scende da
0,14 a 0,08 px.

I gradini di esposizione non sono rumore da mediare: l'ampiezza si legge
esatta nei metadati e viene ridistribuita su una transizione a derivata nulla
agli estremi. Il deflicker lavora poi su una serie gia' priva di gradini,
invece di trasformare lo scalino in una rampa con due spigoli.

La maschera delle regioni nasce dalla mediana temporale di un campione di
fotogrammi, che toglie di mezzo proprio le nuvole di passaggio, e la linea
d'orizzonte viene agganciata al massimo del gradiente verticale. Sulla scena
di prova il terreno passa da 0,062 a 0,026 stop di oscillazione.

Ritaglio virtuale e correzione di stabilizzazione sono entrambi affini e
vengono composti in una sola trasformazione: due ricampionamenti in fila
costerebbero il doppio di nitidezza senza dare nulla in cambio.

Sulla memoria: i moduli avanzati hanno rotto l'assunto che bastassero due
fotogrammi vivi alla volta, quindi il disco entra ora in gioco - come annotato
nel commit precedente, e' questa la porta che si apriva. La finestra attiva
resta sempre in memoria perche' la mediana ha bisogno di tutti i suoi
fotogrammi insieme; solo la lettura in anticipo viene parcheggiata su disco
oltre il tetto, e ripresa una volta sola. Non esiste un caso in cui lo stesso
fotogramma vada e torni piu' volte. Il file di parcheggio si cancella da se'.

Verifica: da 26 a 51 controlli. Nessuna soglia scelta a posteriori - il
tremolio ha un percorso noto, il gradino un'ampiezza dichiarata nei metadati e
visibile nei pixel, la nuvola attraversa il solo cielo. Il controllo
conclusivo rende una sequenza con tutti i moduli attivi insieme e tetto di
memoria volutamente stretto, poi la rilegge con il lettore di sistema.
Verificato anche sui DNG GoPro reali: 580 file, render di prova conforme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:55:27 +02:00

427 lines
16 KiB
C#

using Titano.Imaging;
namespace Titano.Analysis;
/// <summary>Criterio con cui il fotogramma viene diviso in regioni indipendenti.</summary>
public enum RegionMode
{
/// <summary>Una sola regione: il deflicker lavora sull'intero fotogramma.</summary>
Off,
/// <summary>Ricerca della linea d'orizzonte: separa il cielo dal paesaggio.</summary>
SkyGround,
/// <summary>Separazione per sola luminanza, senza vincolo di forma.</summary>
Luminance,
}
/// <summary>Parametri della segmentazione e del deflicker per regioni.</summary>
public sealed class RegionSettings
{
public RegionMode Mode { get; set; } = RegionMode.Off;
/// <summary>Fotogrammi campionati per costruire la maschera, distribuiti su tutta la sequenza.</summary>
public int SampleFrames { get; set; } = 16;
/// <summary>Larghezza a cui viene costruita la maschera: la forma delle regioni è grossolana per natura.</summary>
public int AnalysisWidth { get; set; } = 320;
/// <summary>Ampiezza della sfumatura fra le due regioni, in frazione dell'altezza.</summary>
public double Feather { get; set; } = 0.05;
/// <summary>
/// Quanto le due curve restano indipendenti: a 0 coincidono con la curva globale,
/// a 1 ciascuna regione insegue solo la propria luminanza.
/// </summary>
public double Independence { get; set; } = 0.75;
public RegionSettings Clone() => (RegionSettings)MemberwiseClone();
}
/// <summary>
/// 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.
/// </summary>
public sealed class RegionMask
{
public required int Width { get; init; }
public required int Height { get; init; }
public required float[] Weight { get; init; }
/// <summary>Frazione dell'immagine attribuita alla prima regione.</summary>
public double Coverage { get; init; }
/// <summary>Luminanza media delle due regioni, in log2: serve a descriverle in interfaccia.</summary>
public double Log2High { get; init; }
public double Log2Low { get; init; }
public required string Description { get; init; }
/// <summary>Peso della prima regione in coordinate normalizzate 0..1.</summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public static class RegionSegmenter
{
/// <summary>
/// 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.
/// </summary>
public static RegionMask? Build(IReadOnlyList<string> 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<GrayPlane> CollectSamples(IReadOnlyList<string> 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<GrayPlane>(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];
}
/// <summary>Mediana temporale pixel per pixel: elimina ciò che passa e non appartiene alla scena.</summary>
private static float[] TemporalMedian(List<GrayPlane> samples, int width, int height)
{
var median = new float[width * height];
int count = samples.Count;
Parallel.For(0, height, y =>
{
Span<float> 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
/// <summary>
/// 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.
/// </summary>
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<int> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Maschera per sola luminanza, poi sfumata: nessun vincolo sulla forma delle regioni.</summary>
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;
}
/// <summary>Media mobile su una serie monodimensionale, con estensione dei bordi.</summary>
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;
}
/// <summary>Sfocatura a media d'area separabile: due passate approssimano già bene una gaussiana.</summary>
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;
}
});
}
}