Files
Titano/Titano/Diagnostics/SelfTest.cs
T
Alby96andClaude Opus 5 332f02d17d Orientamento regolabile, profilo di qualita' e icona dell'applicazione
ORIENTAMENTO
Il tag Exif dice come raddrizzare l'immagine, ma non basta leggerlo: alcuni
decodificatori di sistema lo applicano gia' per conto proprio e altri no, e la
stessa installazione di Windows si comporta diversamente a seconda del formato.
Applicare la trasformazione due volte ribalta il fotogramma, non applicarla mai
lo lascia coricato.

In modalita' automatica Titano confronta ora le dimensioni che il decodificatore
restituisce con quelle che il file dichiara di contenere: se la trasformazione
scambia gli assi ed e' gia' stata applicata, non la riapplica. La sezione
Generale mostra cosa ha dedotto e con quale motivo, e permette di imporre una
delle sei trasformazioni Exif quando i metadati sono sbagliati. L'anteprima
mostra subito l'effetto della scelta.

Sui DNG GoPro il rilevamento riporta "specchiata orizzontalmente (dal tag Exif
del file)": e' quanto dichiara il file e quanto applicano i lettori conformi
alla specifica DNG. Se all'occhio risulta sbagliato, ora si cambia dal menu.

QUALITA' PRIMA DELLA VELOCITA'
Nuovo profilo di elaborazione con tre livelli, predefinito su Massima. Governa
la finezza della griglia del campo vettoriale, i livelli della piramide, le
iterazioni per livello, i campioni per pixel della scia e la risoluzione della
passata fotometrica. Costa circa tre volte il tempo del profilo Standard e si
vede nei bordi dei soggetti in movimento.

Non e' stato introdotto alcun uso del disco come memoria d'appoggio: nessuna
fase della pipeline supera cio' che la RAM regge, e i file temporanei sono
esclusi per scelta architetturale. Se in futuro servira' tenere in vita molti
fotogrammi a piena risoluzione, quella e' la porta da cui entrare.

ICONA
Scie stellari attorno al polo celeste: l'immagine in cui il tempo diventa
visibile, e che dice insieme time-lapse e cielo notturno senza ricorrere a
orologi o otturatori. Disegnata in-house con GDI+, contenitore ICO scritto a
mano con sette livelli. Sotto i venti pixel il disegno si semplifica a due archi
piu' spessi: alla dimensione della barra delle applicazioni sette scie sottili
diventerebbero una macchia.

La verifica del motore sale a 25 controlli: si aggiunge che lo specchiamento
richiesto sposti davvero i pixel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:53:10 +02:00

306 lines
16 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 System.Globalization;
using System.Text;
using Titano.Analysis;
using Titano.Imaging;
using Titano.Metadata;
using Titano.Motion;
using Titano.Pipeline;
using Titano.Video;
namespace Titano.Diagnostics;
/// <summary>
/// Verifica end-to-end del motore su una sequenza sintetica dalle proprietà note:
/// parser Exif, analisi di cadenza, deflicker, optical flow, motion blur, encoder e
/// multiplexer vengono esercitati nell'ordine in cui lavorano in produzione.
/// </summary>
public static class SelfTest
{
public static int Run(string workingDirectory, TextWriter output)
{
var checks = new List<(string Name, bool Passed, string Detail)>();
var definition = new SyntheticSequence.Definition();
output.WriteLine("Titano — verifica del motore");
output.WriteLine(new string('-', 74));
string sequenceDirectory = Path.Combine(workingDirectory, "sequenza");
output.WriteLine($"Generazione di {definition.FrameCount} fotogrammi sintetici in {sequenceDirectory}");
var paths = SyntheticSequence.Write(sequenceDirectory, definition);
// ---------------------------------------------------------------- 1. metadati
var metadata = paths.Select(MetadataReader.Read).ToList();
int withSubSecond = metadata.Count(m => m.CaptureSource == TimestampSource.ExifSubSecond);
Add(checks, "Parser Exif — timestamp con frazione di secondo",
withSubSecond == metadata.Count, $"{withSubSecond}/{metadata.Count}");
bool exposureOk = metadata.All(m => m.ExposureSeconds is { } e && Math.Abs(e - definition.ExposureSeconds) < 1e-6);
Add(checks, "Parser Exif — tempo di posa", exposureOk,
Format(metadata[0].ExposureSeconds) + " s");
bool apertureOk = metadata.All(m => m.FNumber is { } f && Math.Abs(f - 8.0) < 1e-6);
bool isoOk = metadata.All(m => m.Iso == 200);
Add(checks, "Parser Exif — apertura e ISO", apertureOk && isoOk,
$"f/{Format(metadata[0].FNumber)}, ISO {metadata[0].Iso}");
bool offsetOk = metadata.All(m => m.UtcOffset == TimeSpan.FromHours(2));
Add(checks, "Parser Exif — fuso orario", offsetOk, metadata[0].UtcOffset?.ToString() ?? "assente");
bool sizeOk = metadata.All(m => m.PixelWidth == definition.Width && m.PixelHeight == definition.Height);
Add(checks, "Parser Exif — dimensioni dichiarate", sizeOk,
$"{metadata[0].PixelWidth}×{metadata[0].PixelHeight}");
// ---------------------------------------------------------------- 2. cadenza
var project = new TitanoProject();
project.Sequence = Core.TimelapseSequence.Build(metadata);
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
double expectedInterval = definition.IntervalSeconds + 0.37;
bool cadenceOk = Math.Abs(project.Sequence.NominalInterval - expectedInterval) < 0.05;
Add(checks, "Cadenza nominale rilevata", cadenceOk,
$"{project.Sequence.NominalInterval:0.###} s (attesa {expectedInterval:0.###} s)");
bool pauseOk = project.Sequence.CadenceAnomalies >= definition.PauseFrames;
Add(checks, "Pause dell'intervallometro individuate", pauseOk,
$"{project.Sequence.CadenceAnomalies} intervalli anomali");
double expectedAngle = 360.0 * definition.ExposureSeconds / expectedInterval;
double measuredAngle = project.Sequence.Frames[0].ShutterAngle;
bool angleOk = Math.Abs(measuredAngle - expectedAngle) < 0.2;
Add(checks, "Shutter angle reale", angleOk,
$"{measuredAngle:0.00}° (atteso {expectedAngle:0.00}°)");
// ---------------------------------------------------------------- 3. deflicker
project.Export.OutputPath = Path.Combine(workingDirectory, "titano-selftest.mp4");
project.Export.FrameRate = 24;
project.Export.BitrateMbps = 20;
project.General.DecodeParallelism = 4;
var pipeline = new RenderPipeline(project);
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
var curve = project.Curve!;
double flickerBefore = 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 flickerAfter = DeflickerCurve.FlickerIndex(corrected);
Add(checks, "Sfarfallio misurato prima della correzione", flickerBefore > 0.04,
$"{flickerBefore:0.0000} stop RMS");
Add(checks, "Sfarfallio residuo dopo la correzione", flickerAfter < flickerBefore * 0.35,
$"{flickerAfter:0.0000} stop RMS ({100 * (1 - flickerAfter / flickerBefore):0.#}% di riduzione)");
// La rampa di luce deve sopravvivere: è un cambio reale, non sfarfallio.
double rampBefore = curve.Measured[^1] - curve.Measured[0];
double rampAfter = corrected[^1] - corrected[0];
bool rampOk = Math.Abs(rampAfter - rampBefore) < 0.25 && Math.Abs(rampAfter) > 0.4;
Add(checks, "Rampa di luce preservata", rampOk,
$"{rampBefore:0.00} stop → {rampAfter:0.00} stop");
// ---------------------------------------------------------------- 4. decodifica
var pool = new FrameBufferPool(6);
using var frameA = ImageDecoder.Decode(paths[10], definition.Width, definition.Height, 1, pool);
using var frameB = ImageDecoder.Decode(paths[11], definition.Width, definition.Height, 1, pool);
// La media logaritmica della luminanza è invariante alla scala: decodificare la
// stessa immagine a risoluzioni diverse deve dare lo stesso valore. È il controllo
// che smaschera una catena di decodifica mal composta, dove il ridimensionamento
// restituisce righe disallineate senza segnalare alcun errore.
double nativeLuma = LuminanceAnalyzer.Analyze(frameA).Log2Average;
double worstScaleError = 0;
foreach (int scaled in (ReadOnlySpan<int>)[320, 200])
{
int scaledHeight = Math.Max(2, (int)Math.Round(scaled * definition.Height / (double)definition.Width) & ~1);
using var small = ImageDecoder.Decode(paths[10], scaled, scaledHeight, 1, pool);
double delta = LuminanceAnalyzer.Analyze(small).Log2Average - nativeLuma;
worstScaleError = Math.Max(worstScaleError, Math.Abs(delta));
}
Add(checks, "Decodifica — luminanza invariante alla scala", worstScaleError < 0.12,
$"scarto massimo {worstScaleError:0.000} EV fra piena risoluzione e versioni ridotte");
// L'orientamento richiesto deve tradursi in pixel spostati davvero: si decodifica lo
// stesso file chiedendo lo specchiamento orizzontale e si confronta colonna per colonna.
using (var mirrored = ImageDecoder.Decode(paths[10], definition.Width, definition.Height, 2, pool))
{
double worstMirrorError = 0;
for (int y = 4; y < definition.Height - 4; y += 7)
{
for (int x = 4; x < definition.Width - 4; x += 7)
{
int direct = frameA.Offset(x, y);
int flipped = mirrored.Offset(definition.Width - 1 - x, y);
worstMirrorError = Math.Max(worstMirrorError,
Math.Abs(frameA.Data[direct] - mirrored.Data[flipped]));
}
}
Add(checks, "Orientamento — lo specchiamento sposta i pixel", worstMirrorError < 1e-5,
$"scarto massimo {worstMirrorError:0.000000} fra fotogramma diretto e specchiato ribaltato");
}
// ---------------------------------------------------------------- 5. optical flow
var flowEngine = new OpticalFlowEngine(project.Flow);
var field = flowEngine.Compute(frameA, frameB);
double expectedMagnitude = Math.Sqrt(definition.ShiftX * definition.ShiftX +
definition.ShiftY * definition.ShiftY);
double measuredMagnitude = field.MedianMagnitude();
bool flowOk = Math.Abs(measuredMagnitude - expectedMagnitude) < expectedMagnitude * 0.25;
Add(checks, "Campo vettoriale — modulo del movimento", flowOk,
$"{measuredMagnitude:0.00} px (atteso {expectedMagnitude:0.00} px)");
// Il generatore avanza le coordinate di campionamento della tessitura, quindi il
// contenuto visibile scorre nel verso opposto: il flusso atteso è l'opposto dello shift.
double expectedDirection = Math.Atan2(-definition.ShiftY, -definition.ShiftX) * 180 / Math.PI;
double measuredDirection = field.DominantDirection();
double directionError = Math.Abs(NormalizeAngle(measuredDirection - expectedDirection));
Add(checks, "Campo vettoriale — direzione dominante", directionError < 12,
$"{measuredDirection:0.0}° (atteso {expectedDirection:0.0}°)");
// ---------------------------------------------------------------- 6. motion blur
double missing = MotionBlurRenderer.MissingBlurFactor(measuredAngle, 180.0, 1.0);
using var blurred = pool.Rent(definition.Width, definition.Height);
double blurLength = MotionBlurRenderer.Render(frameA, blurred, field, missing, project.MotionBlur);
bool blurOk = blurLength > 1.5 && blurLength < 8.0;
Add(checks, "Motion blur sintetico — lunghezza della scia", blurOk,
$"{blurLength:0.00} px con fattore mancante {missing:0.000}");
double detailBefore = MeasureDetail(frameA);
double detailAfter = MeasureDetail(blurred);
Add(checks, "Motion blur sintetico — dettaglio ridotto lungo il moto",
detailAfter < detailBefore * 0.85,
$"{detailBefore:0.0000} → {detailAfter:0.0000} " +
$"({100 * (1 - detailAfter / detailBefore):0.#}% di attenuazione)");
// ---------------------------------------------------------------- 7. encoder + muxer
RenderResult? result = null;
string encodeDetail;
try
{
result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
encodeDetail = $"{result.EncodedFrames} fotogrammi, {result.OutputBytes / 1024} KiB, " +
$"{result.Elapsed.TotalSeconds:0.0} s, {result.EncoderName}" +
(result.HardwareAccelerated ? " (hardware)" : " (software)");
}
catch (Exception ex)
{
encodeDetail = ex.Message;
}
Add(checks, "Codifica video tramite encoder di sistema",
result is { EncodedFrames: > 0 }, encodeDetail);
if (result is not null && File.Exists(result.OutputPath))
{
var report = Mp4Inspector.Inspect(result.OutputPath);
Add(checks, "Contenitore MP4 — struttura dei box", report.Valid,
$"{report.Summary}; box radice: {string.Join(", ", report.TopLevelBoxes)}");
Add(checks, "Contenitore MP4 — numero di campioni",
report.SampleCount == result.EncodedFrames,
$"{report.SampleCount} campioni per {result.EncodedFrames} fotogrammi codificati");
Add(checks, "Contenitore MP4 — risoluzione dichiarata",
report.Width == definition.Width && report.Height == definition.Height,
$"{report.Width}×{report.Height}");
double declaredSeconds = report.Timescale > 0 ? report.MediaDuration / (double)report.Timescale : 0;
double expectedSeconds = result.EncodedFrames / project.Export.FrameRate;
Add(checks, "Contenitore MP4 — durata dichiarata",
Math.Abs(declaredSeconds - expectedSeconds) < 0.2,
$"{declaredSeconds:0.00} s (attesa {expectedSeconds:0.00} s)");
// Riproduzione effettiva con il lettore di sistema: prova che il contenitore
// scritto in-house è leggibile da un decoder indipendente.
var playback = Mp4Playback.Read(result.OutputPath);
Add(checks, "Riproduzione con il lettore di sistema",
playback.Error is null && playback.FrameCount == result.EncodedFrames,
playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati a {playback.Width}×{playback.Height}");
if (playback.MeanLuma.Count > 4)
{
// La luminanza riletta dal file deve essere già stabilizzata: il deflicker
// sopravvive a codifica e contenitore.
var log2 = playback.MeanLuma.Select(v => Math.Log2(Math.Max(v, 1e-4))).ToList();
double residual = DeflickerCurve.FlickerIndex(log2);
Add(checks, "Sfarfallio residuo nel video finale", residual < flickerBefore * 0.5,
$"{residual:0.0000} stop RMS contro {flickerBefore:0.0000} in origine");
}
}
// ---------------------------------------------------------------- 8. impronta di memoria
long expectedBytes = (long)definition.Width * definition.Height * 3 * sizeof(float);
bool memoryOk = result is null || result.PeakPixelMemoryBytes < expectedBytes * 24;
Add(checks, "Impronta di memoria del pool",
memoryOk,
result is null ? "non misurata" :
$"{result.PeakPixelMemoryBytes / (1024 * 1024.0):0.0} MiB " +
$"({result.PeakPixelMemoryBytes / (double)expectedBytes:0.0} fotogrammi)");
// ---------------------------------------------------------------- esito
output.WriteLine();
foreach (var (name, passed, detail) in checks)
{
output.WriteLine($" [{(passed ? "OK " : "FALLITO")}] {name}");
output.WriteLine($" {detail}");
}
int failed = checks.Count(c => !c.Passed);
output.WriteLine();
output.WriteLine(new string('-', 74));
output.WriteLine(failed == 0
? $"Tutte le {checks.Count} verifiche superate."
: $"{failed} verifiche fallite su {checks.Count}.");
return failed == 0 ? 0 : 1;
}
private static void Add(List<(string, bool, string)> checks, string name, bool passed, string detail)
=> checks.Add((name, passed, detail));
private static string Format(double? value)
=> value?.ToString("0.######", CultureInfo.InvariantCulture) ?? "—";
private static double NormalizeAngle(double degrees)
{
while (degrees > 180) degrees -= 360;
while (degrees < -180) degrees += 360;
return degrees;
}
/// <summary>
/// Dettaglio fine orizzontale: differenza fra pixel adiacenti sul canale verde.
/// Si usa la differenza a distanza 1 e non quella centrata, perché quest'ultima ha uno
/// zero esatto alla frequenza di Nyquist, proprio dove la sfocatura agisce di più.
/// </summary>
private static double MeasureDetail(ImageBuffer frame)
{
double sum = 0;
int count = 0;
var data = frame.Data;
for (int y = 4; y < frame.Height - 4; y += 3)
{
int rowBase = y * frame.Width * ImageBuffer.Channels;
for (int x = 4; x < frame.Width - 4; x += 3)
{
int i = rowBase + x * ImageBuffer.Channels;
sum += Math.Abs(data[i + ImageBuffer.Channels + 1] - data[i + 1]);
count++;
}
}
return count == 0 ? 0 : sum / count;
}
/// <summary>Riepilogo testuale usato anche dal pannello diagnostica dell'interfaccia.</summary>
public static string DescribeEnvironment()
{
var builder = new StringBuilder();
builder.AppendLine($"Sistema: {Environment.OSVersion.VersionString} ({(Environment.Is64BitProcess ? "x64" : "x86")})");
builder.AppendLine($"Processori logici: {Environment.ProcessorCount}");
builder.AppendLine($"Runtime: {Environment.Version}");
builder.AppendLine($"Vettori SIMD: {System.Numerics.Vector<float>.Count} float per registro");
return builder.ToString();
}
}