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>
306 lines
16 KiB
C#
306 lines
16 KiB
C#
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();
|
||
}
|
||
}
|