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>
218 lines
8.8 KiB
C#
218 lines
8.8 KiB
C#
using Titano.Imaging;
|
|
|
|
namespace Titano.Motion;
|
|
|
|
/// <summary>Modalità di accumulo temporale.</summary>
|
|
public enum StackingMode
|
|
{
|
|
Off,
|
|
|
|
/// <summary>Mediana su una finestra di fotogrammi: rimuove ciò che passa e non appartiene alla scena.</summary>
|
|
Median,
|
|
|
|
/// <summary>Massimo progressivo: fonde i picchi di luce in scie continue.</summary>
|
|
Maximum,
|
|
}
|
|
|
|
/// <summary>Parametri dell'accumulo temporale.</summary>
|
|
public sealed class StackingSettings
|
|
{
|
|
public StackingMode Mode { get; set; } = StackingMode.Off;
|
|
|
|
/// <summary>Fotogrammi della finestra della mediana; viene reso dispari internamente.</summary>
|
|
public int WindowFrames { get; set; } = 5;
|
|
|
|
/// <summary>Quota dell'effetto miscelata sul fotogramma originale.</summary>
|
|
public double Strength { get; set; } = 1.0;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public double TrailFrames { get; set; }
|
|
|
|
public StackingSettings Clone() => (StackingSettings)MemberwiseClone();
|
|
|
|
/// <summary>Semiampiezza effettiva della finestra: quanti fotogrammi servono da ogni lato.</summary>
|
|
public int MedianRadius => Mode == StackingMode.Median
|
|
? Math.Clamp((Math.Max(3, WindowFrames) - 1) / 2, 1, 24)
|
|
: 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accumulo e filtraggio temporale.
|
|
///
|
|
/// <b>Mediana.</b> 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.
|
|
///
|
|
/// <b>Massimo.</b> 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.
|
|
/// </summary>
|
|
public static class TemporalStacker
|
|
{
|
|
/// <summary>Numero massimo di fotogrammi mediabili in una finestra.</summary>
|
|
public const int MaxWindow = 49;
|
|
|
|
/// <summary>
|
|
/// Mediana temporale della finestra indicata, miscelata sul fotogramma centrale secondo
|
|
/// l'intensità. <paramref name="alignment"/> può essere null quando i fotogrammi sono già
|
|
/// sovrapposti; altrimenti ogni elemento porta le coordinate del fotogramma centrale in
|
|
/// quelle del fotogramma corrispondente.
|
|
/// </summary>
|
|
public static void Median(IReadOnlyList<ImageBuffer> window, IReadOnlyList<SourceMapping>? 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<float> red = stackalloc float[MaxWindow];
|
|
Span<float> green = stackalloc float[MaxWindow];
|
|
Span<float> 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Aggiorna l'accumulatore dei massimi con un nuovo fotogramma. Con
|
|
/// <paramref name="fade"/> minore di uno l'accumulo decade e le scie hanno una coda.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>Miscela l'accumulatore sul fotogramma corrente secondo l'intensità richiesta.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Fattore di decadimento per la lunghezza di scia richiesta.</summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Mediana per inserzione. Su finestre di pochi elementi batte qualunque algoritmo
|
|
/// asintoticamente migliore: nessuna allocazione, nessun salto, tutto in registri.
|
|
/// </summary>
|
|
private static float Median(Span<float> 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];
|
|
}
|
|
}
|