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>
641 lines
29 KiB
C#
641 lines
29 KiB
C#
using System.Diagnostics;
|
|
using Titano.Analysis;
|
|
using Titano.Core;
|
|
using Titano.Imaging;
|
|
using Titano.Metadata;
|
|
using Titano.Motion;
|
|
using Titano.Video;
|
|
|
|
namespace Titano.Pipeline;
|
|
|
|
/// <summary>
|
|
/// Orchestrazione delle fasi di lavoro.
|
|
///
|
|
/// Il rendering non percorre più la sequenza sorgente ma un <see cref="RenderPlan"/>: 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.
|
|
/// </summary>
|
|
public sealed class RenderPipeline(TitanoProject project)
|
|
{
|
|
private readonly TitanoProject _project = project;
|
|
|
|
// ------------------------------------------------------------------ ingestion
|
|
|
|
/// <summary>Legge i metadati dei file indicati e costruisce la sequenza ordinata.</summary>
|
|
public static async Task<TimelapseSequence> IngestAsync(IReadOnlyList<string> paths,
|
|
double cadenceTolerance,
|
|
IProgress<PipelineProgress>? progress,
|
|
CancellationToken cancellation)
|
|
{
|
|
var metadata = new FrameMetadata[paths.Count];
|
|
int done = 0;
|
|
|
|
await Task.Run(() =>
|
|
{
|
|
var options = new ParallelOptions
|
|
{
|
|
CancellationToken = cancellation,
|
|
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 1, 16),
|
|
};
|
|
|
|
Parallel.For(0, paths.Count, options, i =>
|
|
{
|
|
metadata[i] = MetadataReader.Read(paths[i]);
|
|
int completed = Interlocked.Increment(ref done);
|
|
if (completed % 16 == 0 || completed == paths.Count)
|
|
{
|
|
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, completed, paths.Count,
|
|
"Lettura metadati…"));
|
|
}
|
|
});
|
|
}, cancellation).ConfigureAwait(false);
|
|
|
|
var sequence = TimelapseSequence.Build(metadata);
|
|
sequence.RecomputeTiming(cadenceTolerance);
|
|
return sequence;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ analisi
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task AnalyzeAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
|
|
{
|
|
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
|
|
int count = sequence.Count;
|
|
if (count == 0) return;
|
|
|
|
var (workingWidth, workingHeight) = _project.ResolveWorkingSize();
|
|
if (workingWidth <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
|
|
|
|
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));
|
|
|
|
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, blocks, options, block =>
|
|
{
|
|
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)
|
|
{
|
|
previous = DecodePlane(sequence.Frames[start - 1].FilePath, analysisWidth, analysisHeight,
|
|
orientation, pool, _project.Stabilization.AnalysisWidth);
|
|
}
|
|
|
|
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);
|
|
|
|
// I fotogrammi illeggibili ereditano la misura del vicino: la curva resta continua.
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (!failures[i]) continue;
|
|
stats[i] = i > 0 ? stats[i - 1] : LuminanceStats.Empty;
|
|
}
|
|
|
|
var metadata = sequence.Frames.Select(f => f.Metadata).ToArray();
|
|
_project.Transitions = HolyGrailEngine.Analyze(stats, metadata, _project.HolyGrail);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ricalcola solo la curva a partire dalle statistiche già misurate: permette di muovere
|
|
/// i cursori del deflicker con riscontro immediato, senza rileggere i file.
|
|
/// </summary>
|
|
public void RecomputeCurve()
|
|
{
|
|
if (_project.Stats is not { } stats || _project.Sequence is not { } sequence) return;
|
|
|
|
// 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<LuminanceStats> stats, bool[]? failures)
|
|
{
|
|
var transitions = _project.Transitions;
|
|
var steps = transitions is null ? [] : new HashSet<int>(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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ render ed esportazione
|
|
|
|
public async Task<RenderResult> RenderAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
|
|
{
|
|
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
|
|
if (sequence.Count == 0) throw new InvalidOperationException("La sequenza è vuota.");
|
|
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
|
|
throw new InvalidOperationException("Percorso di destinazione non impostato.");
|
|
|
|
if (!_project.IsAnalyzed) await AnalyzeAsync(progress, cancellation).ConfigureAwait(false);
|
|
|
|
var (width, height) = _project.ResolveWorkingSize();
|
|
if (width <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
|
|
|
|
var export = _project.Export.Clone();
|
|
export.Width = width;
|
|
export.Height = height;
|
|
|
|
return await Task.Run(() => RenderCore(sequence, export, progress, cancellation), cancellation)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private RenderResult RenderCore(TimelapseSequence sequence, ExportSettings export,
|
|
IProgress<PipelineProgress>? progress, CancellationToken cancellation)
|
|
{
|
|
int count = sequence.Count;
|
|
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;
|
|
|
|
uint baseUnits = (uint)Math.Max(1, Math.Round(export.Timescale / Math.Max(1.0, export.FrameRate)));
|
|
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, outputWidth, outputHeight);
|
|
|
|
// 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
|
|
{
|
|
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);
|
|
}
|
|
|
|
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 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;
|
|
|
|
// 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) * speed
|
|
: 0;
|
|
|
|
bool needsFlow = (missing > 1e-4 || needsNext) && next is not null;
|
|
if (needsFlow && flowIndex != index)
|
|
{
|
|
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!;
|
|
}
|
|
|
|
// ---- 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)planned.DurationUnits;
|
|
|
|
ReportRenderProgress(progress, k + 1, plan.Count, encoded, stopwatch);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
fallback?.Dispose();
|
|
stackA?.Dispose();
|
|
stackB?.Dispose();
|
|
accumulator?.Dispose();
|
|
interpolated?.Dispose();
|
|
blurScratch?.Dispose();
|
|
geometryOut?.Dispose();
|
|
|
|
progress?.Report(new PipelineProgress(PipelinePhase.Finalizing, plan.Count, plan.Count,
|
|
"Chiusura del contenitore…"));
|
|
session.Finish();
|
|
}
|
|
|
|
stopwatch.Stop();
|
|
if (cancelled) cancellation.ThrowIfCancellationRequested();
|
|
|
|
var statistics = window.Statistics;
|
|
return new RenderResult(
|
|
export.OutputPath,
|
|
session.EncodedFrames,
|
|
session.OutputBytes,
|
|
stopwatch.Elapsed,
|
|
session.EncoderName,
|
|
session.IsHardware,
|
|
Math.Max(statistics.PeakResidentBytes, pool.AllocatedBytes),
|
|
plan.Description,
|
|
statistics.SpilledFrames,
|
|
statistics.SpillBytesWritten);
|
|
|
|
// ------------------------------------------------------------------ funzioni locali
|
|
|
|
// 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)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
void BuildMedian(int centre, ImageBuffer centreFrame, ImageBuffer destination)
|
|
{
|
|
var frames = new List<ImageBuffer>(2 * stackRadius + 1);
|
|
List<SourceMapping>? 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Preleva un fotogramma dalla finestra. Se il file era illeggibile subentra il vicino
|
|
/// valido più prossimo; solo una sequenza interamente illeggibile arriva al nero.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trasformazione che porta le coordinate del fotogramma <paramref name="centre"/> in
|
|
/// quelle del fotogramma <paramref name="other"/>: due scatti stabilizzati in modo diverso
|
|
/// vanno riportati sullo stesso reticolo prima di poterli sovrapporre.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Dissolvenza lineare: ripiego quando la posizione è frazionaria ma il campo manca.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato.</summary>
|
|
private static ImageBuffer? DecodeAndCorrect(TimelapseSequence sequence, int index, int width, int height,
|
|
int orientation, FrameBufferPool pool, DeflickerCurve? curve,
|
|
DeflickerSettings settings, RegionMask? mask)
|
|
{
|
|
var record = sequence.Frames[index];
|
|
ImageBuffer buffer;
|
|
try
|
|
{
|
|
buffer = ImageDecoder.Decode(record.FilePath, width, height, orientation, pool);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return null; // la finestra sostituirà con l'ultimo fotogramma valido
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static void ReportRenderProgress(IProgress<PipelineProgress>? progress, int completed, int total,
|
|
int encoded, Stopwatch stopwatch)
|
|
{
|
|
if (progress is null) return;
|
|
|
|
double seconds = stopwatch.Elapsed.TotalSeconds;
|
|
double fps = seconds > 0.001 ? encoded / seconds : 0;
|
|
var remaining = fps > 0.01
|
|
? TimeSpan.FromSeconds((total - completed) / fps)
|
|
: TimeSpan.Zero;
|
|
|
|
progress.Report(new PipelineProgress(PipelinePhase.Rendering, completed, total,
|
|
"Elaborazione e codifica…", fps, remaining));
|
|
}
|
|
}
|