Files
Titano/Titano/Diagnostics/AdvancedModuleTests.cs
T
Alby96andClaude Opus 5 db21a31e56 Scheda Archivio, ottimizzazione per sezione e spiegazioni a comparsa
Aggiunge l'importazione da scheda di memoria e riordina l'interfaccia attorno
a quello che ciascuna sezione serve a fare.

L'importazione riconosce i supporti collegati, cerca nelle sottocartelle e
prima di toccare il disco mostra il piano: quali cartelle nasceranno e che nome
avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e
guardare prima costa un istante. Fa poi tre cose che a mano si sbagliano
sempre: separa le sessioni sulla pausa fra due scatti, invece di lasciare piu'
riprese mescolate in una cartella sola; costruisce i nomi da modelli con
segnaposto, perche' un archivio si consulta anni dopo e il nome e' l'unica cosa
leggibile senza aprire nulla; e rilegge quello che ha scritto confrontandone
l'impronta, perche' una scheda che si scollega a meta' copia produce file della
dimensione giusta e del contenuto troncato, e il danno si scopre mesi dopo.

Sulla conversione in DNG va detto con precisione cosa si puo' e non si puo'
fare. Un DNG vero contiene i valori del sensore prima dell'interpolazione
cromatica; ottenerlo da un formato proprietario richiede la libreria del
produttore, che il vincolo sulle dipendenze esclude. Quello che si ottiene
in-house e' un DNG lineare - la specifica lo prevede, i pixel sono gia'
interpolati, il file e' valido e apribile ovunque ma non restituisce la
liberta' del grezzo. La copia resta quindi la scelta predefinita, e l'interfaccia
lo dice invece di lasciarlo intuire. Il contenitore TIFF/DNG e' scritto a mano
come il multiplexer MP4, e la verifica lo rilegge con il parser di questo stesso
programma: due implementazioni indipendenti dello stesso formato, e al primo
confronto e' saltato fuori un errore di dodici byte per voce nel calcolo degli
scostamenti - la directory Exif finiva oltre il puntatore che la indicava.

Le spiegazioni passano dalle note stampate ai suggerimenti a comparsa. Una nota
sotto un cursore occupa spazio a chi la conosce gia', quindi deve restare corta;
un suggerimento che appare solo quando serve non ha quel vincolo e puo' dire
l'unica cosa che conta - perche' quel parametro esiste e cosa succede a
spostarlo nel verso sbagliato.

Ogni sezione guadagna un comando Ottimizza che rileva le impostazioni migliori
per quella sola parte e riferisce cosa ha cambiato e perche'. Si distingue dal
pilota automatico, che lavora di continuo sui parametri deducibili senza
ambiguita': l'ottimizzazione si chiede a mano perche' accende e spegne interi
moduli, e sostituire quelle scelte in silenzio sarebbe peggio che lasciarle
sbagliate. Dove il dato manca non tira a indovinare: lo dice.

L'uscita guadagna rapporto e ritaglio. Cambiare rapporto non deforma piu'
l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il fotogramma,
il che ha anche corretto un difetto latente dello stadio geometrico, che con
rapporti diversi fra sorgente e uscita stirava invece di tagliare. La zona da
tenere si sceglie trascinandola.

I comandi seguono la sezione: importazione e analisi compaiono solo dove
servono. Un pulsante che non ha senso dove ci si trova non va disabilitato ma
tolto, perche' disabilitato resta un ingombro che chiede perche' non funziona.
Le preferenze dell'applicazione non mostrano piu' anteprima, riepilogo della
sequenza ne' riga di stato che ne parli: non riguardano la sequenza caricata, e
tenerle accanto confondeva due piani diversi.

Via anche le descrizioni inutili: la finestra si chiama Titano e basta.

Due difetti trovati durante la verifica a video. La barra di navigazione nasceva
sulla prima voce, che ora e' Archivio, quindi il selettore usciva subito perche'
l'indice coincideva e la sezione non veniva mai mostrata: barra su una voce,
contenuto su un'altra. E il posizionamento dei pulsanti leggeva Visible, che in
WinForms resta falso finche' la finestra non e' stata mostrata perche' riporta
la visibilita' dell'intera catena: alla costruzione nessun pulsante veniva
collocato e restavano tutti impilati sull'angolo.

Verifica: da 54 a 60 controlli. I nuovi coprono i modelli di percorso, la
sostituzione dei caratteri illegali, il rifiuto dei segnaposto inventati, il
riconoscimento delle sessioni e la rilettura del DNG scritto - sia dal parser
interno sia dal decodificatore di sistema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:09:54 +02:00

802 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Titano.Analysis;
using Titano.Core;
using Titano.Imaging;
using Titano.Metadata;
using Titano.Motion;
using Titano.Pipeline;
namespace Titano.Diagnostics;
/// <summary>
/// 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.
/// </summary>
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);
Smoothing(add);
ColourDrift(add);
Easing(add);
Geometry(add);
Ramp(add);
Stacking(add);
Archive(workingDirectory, add);
Integration(workingDirectory, add, output);
}
// ================================================================== 1. stabilizzazione
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>Energia delle differenze seconde: zero su una rampa, alta su un percorso a scatti.</summary>
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
/// <summary>
/// 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.
/// </summary>
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<string> 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
/// <summary>
/// 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.
/// </summary>
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");
// Su una ripresa notturna il colore medio cade lontanissimo dal luogo dei corpi neri,
// dove l'approssimazione diverge. Deve dichiararsi inapplicabile invece di restituire
// un numero troncato a un estremo, che avrebbe l'aria di essere una misura.
double nightSky = ColorScience.CorrelatedColorTemperature(0.004, 0.006, 0.020);
double deepRed = ColorScience.CorrelatedColorTemperature(0.90, 0.05, 0.02);
add("Temperatura di colore — dichiarata non significativa fuori dal luogo di Planck",
double.IsNaN(nightSky) && double.IsNaN(deepRed),
$"cielo notturno {ColorScience.Describe(nightSky)}, rosso saturo {ColorScience.Describe(deepRed)}");
}
/// <summary>
/// La curva obiettivo non deve mai essere più a scatti del segnale che liscia.
///
/// Sembra ovvio e invece è la proprietà che si è rotta per prima su materiale vero: dove i
/// residui restano grandi per un tratto intero, i pesi di robustezza azzerano l'intera
/// finestra, e ripiegando sul campione grezzo la curva obiettivo apre uno scalino proprio
/// dove doveva esserci la massima continuità. La scena qui riproduce quelle condizioni:
/// un crollo di luce ripido con sopra una serie contigua di fotogrammi anomali.
/// </summary>
private static void Smoothing(Report add)
{
const int count = 240;
var series = new double[count];
for (int i = 0; i < count; i++)
{
// Crepuscolo: quasi piatto, poi un crollo ripido, poi di nuovo piatto.
double t = (i - 100) / 30.0;
series[i] = -2.0 - 7.0 / (1.0 + Math.Exp(-t));
// Un tratto contiguo di fotogrammi fuori scala, non un singolo scatto isolato:
// è la condizione che fa collassare i pesi su tutta la finestra.
if (i is >= 118 and <= 132) series[i] += 1.6;
}
var smoothed = LocalRegression.Smooth(series, 15, true);
double worstInput = 0, worstOutput = 0;
for (int i = 1; i < count; i++)
{
worstInput = Math.Max(worstInput, Math.Abs(series[i] - series[i - 1]));
worstOutput = Math.Max(worstOutput, Math.Abs(smoothed[i] - smoothed[i - 1]));
}
add("Regressione locale — la curva lisciata non è mai più a scatti dell'originale",
worstOutput <= worstInput,
$"salto massimo {worstInput:0.000} stop in ingresso, {worstOutput:0.000} in uscita");
// E deve comunque seguire il crollo: una curva piatta sarebbe continua ma inutile.
double range = smoothed.Max() - smoothed.Min();
add("Regressione locale — il crollo di luce viene seguito", range > 6.0,
$"escursione {range:0.00} EV su {series.Max() - series.Min():0.00} EV del segnale");
}
// ================================================================== 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<ImageBuffer>();
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();
}
// ================================================================== 7-bis. archivio
/// <summary>
/// Verifiche dell'importazione: i nomi che i modelli producono, il riconoscimento delle
/// sessioni, e la prova che il contenitore scritto dallo scrittore di immagini viene
/// riletto dal parser di questo stesso programma. Quest'ultima è la più severa: sono due
/// implementazioni indipendenti dello stesso formato, e se una delle due sbaglia si vede.
/// </summary>
private static void Archive(string workingDirectory, Report add)
{
var capture = new DateTime(2026, 8, 15, 21, 30, 45, DateTimeKind.Unspecified);
var metadata = new FrameMetadata
{
FilePath = @"K:\DCIM@GOPRO\G0011051.dng",
FileName = "G0011051.dng",
CaptureTime = capture,
ExposureSeconds = 10,
FNumber = 2.8,
Iso = 800,
PixelWidth = 4000,
PixelHeight = 3000,
Camera = "GoPro HERO8 Black",
};
var context = new NamingContext(metadata, 41, 2, capture.AddMinutes(-20), 7);
string folder = PathTemplate.Expand("{anno}/{data} {fotocamera}", context, allowSeparators: true);
string file = PathTemplate.Expand("{data}_{ora}_{n:0000}", context, allowSeparators: false);
string expectedFolder = Path.Combine("2026", "2026-08-15 GoPro HERO8 Black");
add("Modelli di percorso — cartella e nome file",
folder == expectedFolder && file == "2026-08-15_21-30-45_0042",
$"«{folder}» e «{file}»");
// Un tempo di posa contiene una barra: dentro un nome file non deve diventare un livello.
var fast = Variant(metadata, metadata.FileName, capture, 1.0 / 125);
string safe = PathTemplate.Expand("{posa}", new NamingContext(fast, 0, 0, capture, 0), false);
add("Modelli di percorso — caratteri illegali sostituiti",
safe.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && safe.Length > 0,
$"posa «{safe}»");
add("Modelli di percorso — segnaposto sconosciuto respinto",
PathTemplate.Validate("{anno}/{inesistente}", true) is not null &&
PathTemplate.Validate("{anno}/{data}", true) is null,
"il modello valido passa, quello con un segnaposto inventato no");
// ---- sessioni: due gruppi separati da una pausa lunga
var settings = new ImportSettings { GroupIntoSessions = true, SessionGapMinutes = 45, MinimumSessionFrames = 3 };
var files = new List<string>();
var stamps = new List<DateTime>();
for (int i = 0; i < 12; i++) stamps.Add(capture.AddMinutes(i));
for (int i = 0; i < 12; i++) stamps.Add(capture.AddMinutes(200 + i));
var candidates = new List<ImportCandidate>();
for (int i = 0; i < stamps.Count; i++)
{
candidates.Add(new ImportCandidate($"s{i}.dng",
Variant(metadata, $"s{i}.dng", stamps[i], metadata.ExposureSeconds), 1024, 0));
}
var grouped = MediaImporter.Regroup(candidates, settings);
int sessions = grouped.Select(c => c.SessionIndex).Distinct().Count();
add("Importazione — sessioni riconosciute sulle pause", sessions == 2,
$"{sessions} sessioni da 24 scatti con una pausa di tre ore nel mezzo");
_ = files;
// ---- scrittura e rilettura del contenitore
string path = Path.Combine(workingDirectory, "prova-lineare.dng");
var pool = new FrameBufferPool(4);
using (var frame = pool.Rent(64, 48))
{
for (int y = 0; y < 48; y++)
{
for (int x = 0; x < 64; x++)
{
int i = frame.Offset(x, y);
frame.Data[i] = x / 63f;
frame.Data[i + 1] = y / 47f;
frame.Data[i + 2] = 0.25f;
}
}
RasterWriter.Write(frame, path, metadata, RasterFormat.LinearDng, 92);
}
var reread = MetadataReader.Read(path);
bool sizeOk = reread.PixelWidth == 64 && reread.PixelHeight == 48;
bool exifOk = reread.Iso == 800 && reread.FNumber is { } f && Math.Abs(f - 2.8) < 0.01 &&
reread.CaptureTime == capture;
add("DNG lineare — riletto dal parser di Titano", sizeOk && exifOk,
$"{reread.PixelWidth}×{reread.PixelHeight}, ISO {reread.IsoText}, {reread.ApertureText}, " +
$"scatto {reread.CaptureTime:HH:mm:ss}");
// E il decodificatore di sistema deve saperlo aprire: è la prova che il file non è
// valido soltanto secondo chi lo ha scritto.
try
{
var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(path, 1);
add("DNG lineare — aperto dal decodificatore di sistema",
probeWidth == 64 && probeHeight == 48, $"{probeWidth}×{probeHeight}");
}
catch (Exception ex)
{
add("DNG lineare — aperto dal decodificatore di sistema", false, ex.Message);
}
}
/// <summary>
/// Copia di metadati con qualche campo cambiato. FrameMetadata è una classe con proprietà
/// di sola inizializzazione, quindi non ha la copia con modifica dei record.
/// </summary>
private static FrameMetadata Variant(FrameMetadata source, string fileName, DateTime capture,
double? exposure) => new()
{
FilePath = source.FilePath,
FileName = fileName,
CaptureTime = capture,
ExposureSeconds = exposure,
FNumber = source.FNumber,
Iso = source.Iso,
PixelWidth = source.PixelWidth,
PixelHeight = source.PixelHeight,
Camera = source.Camera,
};
// ================================================================== 8. integrazione
/// <summary>
/// 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.
/// </summary>
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");
}
}