I sei moduli passavano cinquantuno controlli su scene sintetiche. Le scene sintetiche pero' sono costruite perche' la risposta sia nota, e per questo non possono sorprendere. Provati su quattro sequenze vere - da 554 a 1005 scatti, riprese notturne di comete e aurore con una GoPro - hanno mostrato cinque difetti, nessuno dei quali visibile prima. Uno sta nel nucleo del deflicker e c'era da molto. La curva obiettivo poteva essere piu' a scatti del segnale che lisciava. La robustezza di Tukey presuppone anomalie sparse; dove invece un tratto contiguo si discosta - il crollo di luce del crepuscolo - azzera l'intera finestra, e la stima commutava fra l'usare i soli sopravvissuti e l'usare tutto. Nel punto di commutazione si apriva uno scalino di 2,3 stop, piu' grande di qualunque salto presente nel segnale che doveva lisciare. Il rimedio non e' scegliere meglio fra le due stime ma non scegliere affatto: ora si mescolano con continuita' secondo quanta finestra e' sopravvissuta, e dove sopravvive per intero il risultato resta identico a prima. Sulla ripresa di sedici ore la riduzione dello sfarfallio passa dal 17% al 67% e il salto massimo da 1,73 a 0,21 stop. La correlazione di fase inseguiva spostamenti inventati. Una ripresa attraversa il giorno con pose da trenta secondi, quindi esce bruciata: su un riquadro uniforme la normalizzazione al modulo unitario amplifica il solo rumore numerico e l'antitrasformata da' un picco qualunque. Con un controllo di tessitura il tremolio misurato scende da 9,8 a 0,07 px e il ritaglio richiesto dal 15% allo 0,1%. La ricerca dell'orizzonte presupponeva un cielo chiaro e sgombro sopra la testa. Sotto un pergolato agganciava il bordo del tetto e chiamava cielo le travi. Ora la linea e' il gradino piu' marcato del profilo di luminanza per riga, che del verso non si cura: cielo dal 29% al 91% dell'inquadratura, con 1,9 EV di separazione dove prima erano zero, e sfarfallio trasmesso al paesaggio ridotto del 79% invece che del 57%. I gradini dichiarati nei metadati non sempre si vedono. Se il fotogramma e' gia' saturo dimezzare la sensibilita' non lo scurisce, e se l'esposizione automatica insegue l'alba il salto e' compensato dalla scena. In entrambi i casi sottrarlo introduceva il gradino invece di toglierlo. Ora ogni cambio viene ridotto alla quota che la luminanza ha davvero recepito. La temperatura di colore inventava numeri. Su un cielo stellato il colore medio non somiglia a nessun corpo nero, McCamy diverge, e uscivano decine di migliaia di kelvin troncate a un estremo. Ora fuori dall'intorno del luogo di Planck la misura si dichiara inapplicabile - e il controllo che lo verifica ha trovato subito un buco nel primo filtro, perche' un riquadro sulle coordinate cromatiche sa dire in quale zona si e' ma non quanto si e' vicini a una curva. La diagnostica impara a fare queste domande: --diagnose accetta ora parole chiave che accendono i moduli e riporta cosa ciascuno ha trovato sul materiale vero. Le scie stellari si verificano sull'uscita con la proprieta' che le definisce - la luminanza non puo' calare, perche' ogni pixel trattiene il valore piu' alto incontrato - e su 150 fotogrammi risulta non decrescente sul 99,3% dei passi. Verifica: da 51 a 54 controlli. I tre nuovi coprono proprio cio' che era sfuggito, a partire dalla garanzia che una curva lisciata non sia mai piu' a scatti dell'originale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
549 lines
27 KiB
C#
549 lines
27 KiB
C#
using System.Diagnostics;
|
||
using System.Globalization;
|
||
using Titano.Analysis;
|
||
using Titano.Core;
|
||
using Titano.Imaging;
|
||
using Titano.Metadata;
|
||
|
||
namespace Titano.Diagnostics;
|
||
|
||
/// <summary>
|
||
/// Analisi di una cartella reale: riporta ciò che il programma riesce davvero a leggere e
|
||
/// decodificare, file per file. Serve a distinguere un difetto del motore da un formato
|
||
/// sorgente che il sistema non sa aprire.
|
||
/// </summary>
|
||
public static class SequenceDiagnostics
|
||
{
|
||
/// <summary>Moduli opzionali da accendere durante la diagnosi di una cartella reale.</summary>
|
||
[Flags]
|
||
public enum AdvancedModules
|
||
{
|
||
None = 0,
|
||
Analysis = 1, // stabilizzazione, regioni, transizioni giorno-notte
|
||
Camera = 2, // movimento di macchina virtuale e rimappatura del tempo
|
||
MedianStack = 4,
|
||
StarTrails = 8,
|
||
|
||
/// <summary>Divide per luminanza invece che per linea d'orizzonte.</summary>
|
||
RegionsByLuminance = 16,
|
||
}
|
||
|
||
public static int Run(string directory, TextWriter output, int sampleCount = 6,
|
||
string? renderPath = null, int renderFrames = 24,
|
||
int renderWidth = 0, Video.VideoCodec renderCodec = Video.VideoCodec.H264,
|
||
AdvancedModules advanced = AdvancedModules.None, int analysisFrames = 0)
|
||
{
|
||
if (!Directory.Exists(directory))
|
||
{
|
||
output.WriteLine($"Cartella non trovata: {directory}");
|
||
return 1;
|
||
}
|
||
|
||
var files = Directory.EnumerateFiles(directory)
|
||
.Where(MetadataReader.IsSupported)
|
||
.OrderBy(path => Path.GetFileName(path), NaturalFileNameComparer.Instance)
|
||
.ToList();
|
||
|
||
output.WriteLine($"Cartella: {directory}");
|
||
output.WriteLine($"File riconosciuti: {files.Count}");
|
||
if (files.Count == 0)
|
||
{
|
||
var all = Directory.EnumerateFiles(directory).Take(5).Select(Path.GetExtension).Distinct();
|
||
output.WriteLine("Estensioni presenti ma non supportate: " + string.Join(", ", all));
|
||
return 1;
|
||
}
|
||
output.WriteLine();
|
||
|
||
// ------------------------------------------------------------------ metadati
|
||
output.WriteLine("METADATI");
|
||
output.WriteLine(new string('-', 74));
|
||
|
||
var samples = PickSamples(files, sampleCount);
|
||
var sampleMetadata = new List<FrameMetadata>();
|
||
|
||
foreach (string path in samples)
|
||
{
|
||
var stopwatch = Stopwatch.StartNew();
|
||
var metadata = MetadataReader.Read(path);
|
||
stopwatch.Stop();
|
||
sampleMetadata.Add(metadata);
|
||
|
||
output.WriteLine($" {metadata.FileName} ({metadata.FileSize / 1024 / 1024.0:0.0} MiB, letti in {stopwatch.ElapsedMilliseconds} ms)");
|
||
output.WriteLine($" scatto {Describe(metadata.CaptureTime)} [{metadata.CaptureSource}]" +
|
||
(metadata.UtcOffset is { } o ? $" fuso {o}" : string.Empty));
|
||
output.WriteLine($" posa {metadata.ExposureText} diaframma {metadata.ApertureText} ISO {metadata.IsoText}");
|
||
output.WriteLine($" dimensioni {metadata.PixelWidth}×{metadata.PixelHeight} orientamento {metadata.Orientation}");
|
||
output.WriteLine($" fotocamera {metadata.Camera ?? "—"} obiettivo {metadata.Lens ?? "—"}");
|
||
if (metadata.Warning is { } warning) output.WriteLine($" ATTENZIONE {warning}");
|
||
}
|
||
output.WriteLine();
|
||
|
||
// ------------------------------------------------------------------ decodifica
|
||
output.WriteLine("DECODIFICA (WIC, codec di sistema)");
|
||
output.WriteLine(new string('-', 74));
|
||
|
||
var pool = new FrameBufferPool(4);
|
||
int decoded = 0;
|
||
|
||
foreach (var metadata in sampleMetadata)
|
||
{
|
||
try
|
||
{
|
||
var stopwatch = Stopwatch.StartNew();
|
||
var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation);
|
||
long probeMs = stopwatch.ElapsedMilliseconds;
|
||
|
||
int width = Math.Max(2, probeWidth & ~1);
|
||
int height = Math.Max(2, probeHeight & ~1);
|
||
|
||
stopwatch.Restart();
|
||
using var buffer = ImageDecoder.Decode(metadata.FilePath, width, height, metadata.Orientation, pool);
|
||
long decodeMs = stopwatch.ElapsedMilliseconds;
|
||
|
||
var stats = LuminanceAnalyzer.Analyze(buffer);
|
||
decoded++;
|
||
|
||
output.WriteLine($" {metadata.FileName}");
|
||
output.WriteLine($" dimensione reale del pixel {probeWidth}×{probeHeight} (sonda {probeMs} ms)");
|
||
output.WriteLine($" dichiarata nei metadati {metadata.PixelWidth}×{metadata.PixelHeight}" +
|
||
(probeWidth != metadata.PixelWidth || probeHeight != metadata.PixelHeight
|
||
? " ← NON COINCIDE"
|
||
: string.Empty));
|
||
output.WriteLine($" decodifica {decodeMs} ms");
|
||
output.WriteLine($" luminanza media {stats.Log2Average:0.00} EV " +
|
||
$"percentili {stats.Percentile01:0.0000} / {stats.Percentile50:0.0000} / {stats.Percentile99:0.0000}");
|
||
output.WriteLine($" saturati {stats.ClippedFraction * 100:0.00}% neri {stats.BlackFraction * 100:0.00}%");
|
||
|
||
// Stessa immagine a scale diverse: la luminanza media è invariante alla scala,
|
||
// quindi qualunque scostamento denuncia una decodifica sbagliata.
|
||
foreach (int scaled in (ReadOnlySpan<int>)[2048, 1024, 512])
|
||
{
|
||
if (scaled >= width) continue;
|
||
int h2 = Math.Max(2, (int)Math.Round(scaled * height / (double)width) & ~1);
|
||
using var small = ImageDecoder.Decode(metadata.FilePath, scaled, h2, metadata.Orientation, pool);
|
||
var s2 = LuminanceAnalyzer.Analyze(small);
|
||
double delta = s2.Log2Average - stats.Log2Average;
|
||
output.WriteLine($" a {scaled,4}×{h2,-4} {s2.Log2Average,7:0.00} EV " +
|
||
$"scarto {delta,6:+0.00;-0.00;0.00} EV" +
|
||
(Math.Abs(delta) > 0.25 ? " ← DECODIFICA INCOERENTE" : string.Empty));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
output.WriteLine($" {metadata.FileName}");
|
||
output.WriteLine($" DECODIFICA NON RIUSCITA: {ex.GetType().Name} — {ex.Message}");
|
||
}
|
||
}
|
||
output.WriteLine();
|
||
|
||
// ------------------------------------------------------------------ cadenza
|
||
output.WriteLine("CADENZA SULL'INTERA SEQUENZA");
|
||
output.WriteLine(new string('-', 74));
|
||
|
||
var timer = Stopwatch.StartNew();
|
||
var all2 = files.AsParallel().Select(MetadataReader.Read).ToList();
|
||
timer.Stop();
|
||
|
||
var sequence = TimelapseSequence.Build(all2);
|
||
sequence.RecomputeTiming();
|
||
|
||
int withExif = all2.Count(m => m.CaptureSource is TimestampSource.Exif or TimestampSource.ExifSubSecond);
|
||
int withSubSecond = all2.Count(m => m.CaptureSource == TimestampSource.ExifSubSecond);
|
||
int withExposure = all2.Count(m => m.ExposureSeconds is not null);
|
||
|
||
output.WriteLine($" lettura di {files.Count} file in {timer.Elapsed.TotalSeconds:0.0} s");
|
||
output.WriteLine($" timestamp da Exif {withExif}/{all2.Count} di cui al sotto-secondo {withSubSecond}");
|
||
output.WriteLine($" tempo di posa noto {withExposure}/{all2.Count}");
|
||
output.WriteLine($" cadenza nominale {sequence.NominalInterval:0.###} s");
|
||
output.WriteLine($" intervalli anomali {sequence.CadenceAnomalies}");
|
||
output.WriteLine($" durata della ripresa {sequence.TotalDuration}");
|
||
output.WriteLine($" shutter angle {sequence.Frames[0].ShutterAngle:0.0}° sul primo fotogramma");
|
||
|
||
var distinct = all2.Select(m => (m.PixelWidth, m.PixelHeight)).Distinct().ToList();
|
||
output.WriteLine($" dimensioni dichiarate distinte: {string.Join(", ", distinct.Select(d => $"{d.PixelWidth}×{d.PixelHeight}"))}");
|
||
|
||
if (advanced != AdvancedModules.None)
|
||
RunAdvancedAnalysis(all2, advanced, analysisFrames, output);
|
||
|
||
if (renderPath is not null)
|
||
RunTrialRender(all2, renderPath, renderFrames, renderWidth, renderCodec, advanced, output);
|
||
|
||
return decoded == sampleMetadata.Count ? 0 : 1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fa girare i moduli avanzati sulla sequenza reale e riporta che cosa hanno trovato.
|
||
///
|
||
/// Non è un rendering: è la fase di analisi, l'unica che può dire se su materiale vero la
|
||
/// correlazione di fase aggancia qualcosa, se la scena si lascia dividere in cielo e
|
||
/// paesaggio, se la macchina ha davvero cambiato impostazioni lungo la ripresa. Sono le
|
||
/// domande a cui una scena sintetica, costruita perché la risposta sia nota, non risponde.
|
||
/// </summary>
|
||
private static void RunAdvancedAnalysis(List<FrameMetadata> metadata, AdvancedModules modules,
|
||
int analysisFrames, TextWriter output)
|
||
{
|
||
output.WriteLine();
|
||
output.WriteLine("MODULI AVANZATI SULLA SEQUENZA REALE");
|
||
output.WriteLine(new string('-', 74));
|
||
|
||
var ordered = metadata.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
|
||
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
|
||
.ToList();
|
||
|
||
// La stabilizzazione confronta ogni fotogramma con il precedente: il campione deve
|
||
// essere contiguo, non sparso sulla sequenza.
|
||
var subset = analysisFrames > 0 && analysisFrames < ordered.Count
|
||
? ordered.Take(analysisFrames).ToList()
|
||
: ordered;
|
||
|
||
var project = new Pipeline.TitanoProject();
|
||
project.Sequence = TimelapseSequence.Build(subset);
|
||
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
|
||
project.DetectOrientation();
|
||
ConfigureAdvanced(project, modules);
|
||
|
||
output.WriteLine($" fotogrammi analizzati {subset.Count}" +
|
||
(subset.Count < ordered.Count ? $" di {ordered.Count} (sottoinsieme contiguo)" : string.Empty));
|
||
output.WriteLine($" larghezza di analisi {project.General.AnalysisWidth} px");
|
||
|
||
var stopwatch = Stopwatch.StartNew();
|
||
try
|
||
{
|
||
new Pipeline.RenderPipeline(project).AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
output.WriteLine($" ANALISI NON RIUSCITA: {ex.GetType().Name} — {ex.Message}");
|
||
return;
|
||
}
|
||
stopwatch.Stop();
|
||
|
||
output.WriteLine($" tempo di analisi {stopwatch.Elapsed.TotalSeconds:0.0} s " +
|
||
$"({subset.Count / Math.Max(0.001, stopwatch.Elapsed.TotalSeconds):0.0} fotogrammi/s)");
|
||
output.WriteLine();
|
||
|
||
ReportStabilization(project, output);
|
||
ReportRegions(project, output);
|
||
ReportTransitions(project, output);
|
||
ReportDeflicker(project, output);
|
||
}
|
||
|
||
private static void ConfigureAdvanced(Pipeline.TitanoProject project, AdvancedModules modules)
|
||
{
|
||
if (modules.HasFlag(AdvancedModules.Analysis))
|
||
{
|
||
project.Stabilization.Enabled = true;
|
||
project.Regions.Mode = modules.HasFlag(AdvancedModules.RegionsByLuminance)
|
||
? RegionMode.Luminance
|
||
: RegionMode.SkyGround;
|
||
project.HolyGrail.Enabled = true;
|
||
project.HolyGrail.SmoothColor = true;
|
||
}
|
||
|
||
if (modules.HasFlag(AdvancedModules.Camera))
|
||
{
|
||
project.Camera.Enabled = true;
|
||
project.Camera.Keyframes =
|
||
[
|
||
new() { Time = 0.0, CentreX = 0.42, CentreY = 0.46, Zoom = 1.10 },
|
||
new() { Time = 1.0, CentreX = 0.58, CentreY = 0.54, Zoom = 1.45 },
|
||
];
|
||
project.TimeRamp.Enabled = true;
|
||
project.TimeRamp.Speed = [new(0.0, 1.6), new(0.5, 0.5), new(1.0, 1.6)];
|
||
}
|
||
|
||
if (modules.HasFlag(AdvancedModules.MedianStack))
|
||
{
|
||
project.Stacking.Mode = Motion.StackingMode.Median;
|
||
project.Stacking.WindowFrames = 5;
|
||
}
|
||
else if (modules.HasFlag(AdvancedModules.StarTrails))
|
||
{
|
||
project.Stacking.Mode = Motion.StackingMode.Maximum;
|
||
project.Stacking.TrailFrames = 0; // scie che non si spengono
|
||
}
|
||
}
|
||
|
||
private static void ReportStabilization(Pipeline.TitanoProject project, TextWriter output)
|
||
{
|
||
if (project.Motion is not { } motion) return;
|
||
|
||
var (width, height) = project.ResolveSourceSize();
|
||
double toPixels = Math.Max(1, width);
|
||
|
||
double maxRotation = 0;
|
||
double maxShift = 0;
|
||
foreach (var correction in motion.Correction)
|
||
{
|
||
maxRotation = Math.Max(maxRotation, Math.Abs(correction.Rotation) * 180 / Math.PI);
|
||
maxShift = Math.Max(maxShift, Math.Sqrt(correction.Tx * correction.Tx + correction.Ty * correction.Ty));
|
||
}
|
||
|
||
var last = motion.Measured[^1];
|
||
double drift = Math.Sqrt(last.Tx * last.Tx + last.Ty * last.Ty) * toPixels;
|
||
|
||
output.WriteLine(" STABILIZZAZIONE");
|
||
output.WriteLine($" correlazione debole {motion.UnreliableFraction * 100:0.0}% delle coppie");
|
||
output.WriteLine($" tremolio rimosso {motion.MeanShake * toPixels:0.00} px in media, " +
|
||
$"{maxShift * toPixels:0.00} px al massimo");
|
||
output.WriteLine($" rotazione compensata {maxRotation:0.000}° al massimo");
|
||
output.WriteLine($" deriva complessiva {drift:0.0} px dal primo all'ultimo fotogramma");
|
||
output.WriteLine($" ritaglio necessario {(motion.RequiredZoom(height / (double)width) - 1) * 100:0.0}%");
|
||
output.WriteLine();
|
||
}
|
||
|
||
private static void ReportRegions(Pipeline.TitanoProject project, TextWriter output)
|
||
{
|
||
output.WriteLine(" SEGMENTAZIONE");
|
||
|
||
if (project.Mask is not { } mask)
|
||
{
|
||
output.WriteLine(" esito la scena non si divide in modo utile: resta la curva unica");
|
||
output.WriteLine();
|
||
return;
|
||
}
|
||
|
||
output.WriteLine($" esito {mask.Description}");
|
||
output.WriteLine($" maschera {mask.Width}×{mask.Height}, copertura {mask.Coverage * 100:0.0}%");
|
||
|
||
var curve = project.Curve;
|
||
if (curve is { HasRegions: true, MeasuredLow: { } measuredLow, TargetLow: { } targetLow })
|
||
{
|
||
var underGlobal = new double[measuredLow.Length];
|
||
for (int i = 0; i < measuredLow.Length; i++) underGlobal[i] = measuredLow[i] + curve.GainStops[i];
|
||
|
||
double globalNoise = DeflickerCurve.FlickerIndex(underGlobal);
|
||
double regionalNoise = DeflickerCurve.FlickerIndex(targetLow);
|
||
double gain = globalNoise > 1e-9 ? 100 * (1 - regionalNoise / globalNoise) : 0;
|
||
|
||
output.WriteLine($" effetto sul paesaggio sfarfallio {globalNoise:0.0000} stop con la curva globale, " +
|
||
$"{regionalNoise:0.0000} con quella di regione ({gain:0.#}% meglio)");
|
||
}
|
||
output.WriteLine();
|
||
}
|
||
|
||
private static void ReportTransitions(Pipeline.TitanoProject project, TextWriter output)
|
||
{
|
||
if (project.Transitions is not { } transitions) return;
|
||
|
||
int withExposureValue = transitions.ExposureValue.Count(v => !double.IsNaN(v));
|
||
var temperatures = transitions.TemperatureKelvin.Where(double.IsFinite).ToList();
|
||
var tints = transitions.TintStops.Where(double.IsFinite).ToList();
|
||
|
||
output.WriteLine(" TRANSIZIONI GIORNO-NOTTE");
|
||
output.WriteLine($" valore di esposizione ricostruito su {withExposureValue}/{transitions.ExposureValue.Length} fotogrammi");
|
||
output.WriteLine($" cambi di impostazione {transitions.StepCount}" +
|
||
(transitions.StepCount > 0
|
||
? $", il maggiore di {transitions.LargestStepStops:0.00} EV " +
|
||
$"({(transitions.MetadataUsable ? "dai metadati" : "dedotti dalla luminanza")})"
|
||
: string.Empty));
|
||
|
||
if (transitions.StepCount is > 0 and <= 12)
|
||
{
|
||
output.WriteLine($" ai fotogrammi {string.Join(", ", transitions.StepFrames.Select(f => f + 1))}");
|
||
}
|
||
|
||
if (transitions.UnobservedSteps > 0)
|
||
{
|
||
// Due cause, entrambe legittime: il fotogramma era già saturo e non poteva
|
||
// scurirsi oltre, oppure la scena è cambiata di altrettanto nel verso opposto e
|
||
// il salto, semplicemente, non si vede. In nessuno dei due casi c'è qualcosa da
|
||
// ammorbidire, e correggere comunque introdurrebbe il gradino invece di toglierlo.
|
||
output.WriteLine($" non recepiti {transitions.UnobservedSteps} cambi dichiarati che la " +
|
||
$"luminanza non ha recepito (fotogramma saturo, o esposizione " +
|
||
$"compensata dalla scena): lasciati stare");
|
||
}
|
||
|
||
int total = transitions.TemperatureKelvin.Length;
|
||
if (temperatures.Count > 0)
|
||
{
|
||
output.WriteLine($" temperatura di colore da {temperatures[0]:0} K a {temperatures[^1]:0} K " +
|
||
$"(intervallo {temperatures.Min():0}–{temperatures.Max():0} K)" +
|
||
(temperatures.Count < total
|
||
? $", significativa su {temperatures.Count}/{total} fotogrammi"
|
||
: string.Empty));
|
||
}
|
||
else
|
||
{
|
||
// Su una ripresa notturna è il caso normale, non un guasto: il colore medio di un
|
||
// cielo stellato non sta da nessuna parte vicino al luogo dei corpi neri.
|
||
output.WriteLine($" temperatura di colore non significativa su nessuno dei {total} fotogrammi: " +
|
||
$"il colore medio è troppo lontano dal luogo di Planck perché una " +
|
||
$"temperatura voglia dire qualcosa");
|
||
}
|
||
|
||
if (tints.Count > 0)
|
||
{
|
||
output.WriteLine($" tinta verde-magenta da {tints[0]:+0.000;-0.000;0.000} a {tints[^1]:+0.000;-0.000;0.000} EV " +
|
||
$"(escursione {tints.Max() - tints.Min():0.000} EV)");
|
||
}
|
||
output.WriteLine();
|
||
}
|
||
|
||
private static void ReportDeflicker(Pipeline.TitanoProject project, TextWriter output)
|
||
{
|
||
if (project.Curve is not { } curve || curve.Count < 3) return;
|
||
|
||
double before = DeflickerCurve.FlickerIndex(curve.Measured);
|
||
var corrected = new double[curve.Count];
|
||
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
|
||
double after = DeflickerCurve.FlickerIndex(corrected);
|
||
|
||
double maxStepBefore = 0, maxStepAfter = 0;
|
||
int worstBefore = 0, worstAfter = 0;
|
||
|
||
for (int i = 1; i < curve.Count; i++)
|
||
{
|
||
double stepBefore = Math.Abs(curve.Measured[i] - curve.Measured[i - 1]);
|
||
if (stepBefore > maxStepBefore) { maxStepBefore = stepBefore; worstBefore = i; }
|
||
|
||
double stepAfter = Math.Abs(curve.Target[i] - curve.Target[i - 1]);
|
||
if (stepAfter > maxStepAfter) { maxStepAfter = stepAfter; worstAfter = i; }
|
||
}
|
||
|
||
output.WriteLine(" DEFLICKER");
|
||
output.WriteLine($" sfarfallio {before:0.0000} → {after:0.0000} stop RMS " +
|
||
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)");
|
||
output.WriteLine($" salto massimo {maxStepBefore:0.000} stop al fotogramma {worstBefore + 1} " +
|
||
$"→ {maxStepAfter:0.000} al fotogramma {worstAfter + 1}" +
|
||
(maxStepAfter > maxStepBefore ? " ← LA CORREZIONE LO PEGGIORA" : string.Empty));
|
||
output.WriteLine($" escursione della luce {curve.Measured.Max() - curve.Measured.Min():0.00} EV sull'intera sequenza");
|
||
output.WriteLine();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero
|
||
/// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file.
|
||
/// </summary>
|
||
private static void RunTrialRender(List<FrameMetadata> metadata, string outputPath, int frames,
|
||
int forcedWidth, Video.VideoCodec codec,
|
||
AdvancedModules advanced, TextWriter output)
|
||
{
|
||
output.WriteLine();
|
||
output.WriteLine("RENDER DI PROVA");
|
||
output.WriteLine(new string('-', 74));
|
||
|
||
var subset = metadata.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
|
||
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
|
||
.Take(Math.Max(2, frames))
|
||
.ToList();
|
||
|
||
var project = new Pipeline.TitanoProject();
|
||
project.Sequence = TimelapseSequence.Build(subset);
|
||
project.Sequence.RecomputeTiming();
|
||
project.DetectOrientation();
|
||
project.Export.OutputPath = outputPath;
|
||
project.Export.FrameRate = 24;
|
||
project.Export.BitrateMbps = 80;
|
||
project.Export.Codec = codec;
|
||
project.General.WorkingWidth = forcedWidth;
|
||
ConfigureAdvanced(project, advanced);
|
||
|
||
var (width, height) = project.ResolveWorkingSize();
|
||
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
|
||
long perFrame = (long)width * height * 3 * sizeof(float);
|
||
output.WriteLine($" fotogrammi {subset.Count}");
|
||
output.WriteLine($" orientamento {project.Orientation.Description} ({project.Orientation.Reason})");
|
||
output.WriteLine($" profilo di qualità {project.General.Quality}");
|
||
output.WriteLine($" risoluzione {width}×{height}" +
|
||
(width != requestedWidth || height != requestedHeight
|
||
? $" (ridotta da {requestedWidth}×{requestedHeight} per il limite del codec)"
|
||
: string.Empty));
|
||
output.WriteLine($" memoria per buffer {perFrame / (1024.0 * 1024.0):0.0} MiB");
|
||
output.WriteLine($" codec {codec}");
|
||
if (advanced != AdvancedModules.None) output.WriteLine($" moduli attivi {advanced}");
|
||
|
||
if (project.NeedsGeometry)
|
||
{
|
||
var (sourceWidth, sourceHeight) = project.ResolveSourceSize();
|
||
output.WriteLine($" lettura sorgente {sourceWidth}×{sourceHeight} " +
|
||
$"({(long)sourceWidth * sourceHeight * 3 * sizeof(float) / (1024.0 * 1024.0):0.0} MiB per fotogramma)");
|
||
}
|
||
|
||
var pipeline = new Pipeline.RenderPipeline(project);
|
||
var stopwatch = Stopwatch.StartNew();
|
||
|
||
try
|
||
{
|
||
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
|
||
output.WriteLine($" analisi completata in {stopwatch.Elapsed.TotalSeconds:0.0} s");
|
||
|
||
var result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
|
||
output.WriteLine($" codifica {result.EncodedFrames} fotogrammi in {result.Elapsed.TotalSeconds:0.0} s " +
|
||
$"({result.EncodedFrames / Math.Max(0.001, result.Elapsed.TotalSeconds):0.00} fps)");
|
||
output.WriteLine($" encoder {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}");
|
||
output.WriteLine($" file prodotto {result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.OutputPath}");
|
||
output.WriteLine($" memoria pixel {result.PeakPixelMemoryBytes / (1024.0 * 1024.0):0.0} MiB " +
|
||
$"({result.PeakPixelMemoryBytes / (double)perFrame:0.0} fotogrammi)");
|
||
|
||
var report = Mp4Inspector.Inspect(result.OutputPath);
|
||
output.WriteLine($" contenitore {report.Summary}, {report.SampleCount} campioni, {report.Width}×{report.Height}");
|
||
|
||
var playback = Mp4Playback.Read(result.OutputPath);
|
||
output.WriteLine($" rilettura {playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati"}");
|
||
|
||
ReportStackingEvidence(advanced, playback, output);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
output.WriteLine($" RENDER NON RIUSCITO: {ex.GetType().Name} — {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Verifica sull'uscita vera la proprietà che definisce ciascuna modalità di accumulo.
|
||
///
|
||
/// Che il rendering non si inceppi non prova che l'effetto ci sia. Il massimo progressivo
|
||
/// ha una proprietà controllabile senza guardare l'immagine: la luminanza non può mai
|
||
/// calare, perché ogni pixel trattiene il valore più alto incontrato. Se cala, qualcosa
|
||
/// nell'accumulatore non tiene.
|
||
/// </summary>
|
||
private static void ReportStackingEvidence(AdvancedModules advanced, Mp4Playback.Playback playback,
|
||
TextWriter output)
|
||
{
|
||
var luma = playback.MeanLuma;
|
||
if (luma.Count < 4) return;
|
||
|
||
if (advanced.HasFlag(AdvancedModules.StarTrails))
|
||
{
|
||
int rising = 0;
|
||
double worstDrop = 0;
|
||
for (int i = 1; i < luma.Count; i++)
|
||
{
|
||
if (luma[i] >= luma[i - 1] - 1e-4) rising++;
|
||
else worstDrop = Math.Max(worstDrop, luma[i - 1] - luma[i]);
|
||
}
|
||
|
||
double share = 100.0 * rising / (luma.Count - 1);
|
||
output.WriteLine($" scie stellari luminanza da {luma[0]:0.0000} a {luma[^1]:0.0000} " +
|
||
$"(×{luma[^1] / Math.Max(1e-6, luma[0]):0.0}), " +
|
||
$"non decrescente sul {share:0.#}% dei passi" +
|
||
(worstDrop > 1e-3 ? $", calo massimo {worstDrop:0.0000}" : string.Empty));
|
||
}
|
||
|
||
if (advanced.HasFlag(AdvancedModules.MedianStack))
|
||
{
|
||
// La mediana temporale toglie ciò che passa una volta sola: la luminanza deve
|
||
// risultare più regolare di quella della sequenza sorgente.
|
||
double roughness = 0;
|
||
for (int i = 1; i < luma.Count; i++) roughness += Math.Abs(luma[i] - luma[i - 1]);
|
||
output.WriteLine($" mediana temporale variazione media fra fotogrammi " +
|
||
$"{roughness / (luma.Count - 1):0.00000}");
|
||
}
|
||
}
|
||
|
||
private static List<string> PickSamples(List<string> files, int count)
|
||
{
|
||
if (files.Count <= count) return files;
|
||
var picked = new List<string>(count);
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
picked.Add(files[(int)((long)i * (files.Count - 1) / Math.Max(1, count - 1))]);
|
||
}
|
||
return picked;
|
||
}
|
||
|
||
private static string Describe(DateTime? value)
|
||
=> value?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "assente";
|
||
}
|