diff --git a/Titano/Diagnostics/IconGenerator.cs b/Titano/Diagnostics/IconGenerator.cs new file mode 100644 index 0000000..2212072 --- /dev/null +++ b/Titano/Diagnostics/IconGenerator.cs @@ -0,0 +1,274 @@ +using System.Buffers.Binary; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; + +namespace Titano.Diagnostics; + +/// +/// Generatore dell'icona dell'applicazione. +/// +/// Il soggetto sono le scie stellari: in una posa lunga le stelle lasciano archi concentrici +/// attorno al polo celeste, ed è l'immagine in cui il tempo diventa visibile. Dice insieme +/// "time-lapse" e "cielo notturno" senza ricorrere a orologi o otturatori. +/// +/// L'icona non è un file d'arte importato: viene disegnata qui e il contenitore ICO è scritto +/// a mano, coerentemente con il resto del progetto. +/// +public static class IconGenerator +{ + private static readonly int[] Sizes = [16, 24, 32, 48, 64, 128, 256]; + + public static void Write(string path) + { + var images = new List<(int Size, byte[] Data, bool Png)>(); + + foreach (int size in Sizes) + { + using var bitmap = Render(size); + // Le dimensioni piccole restano in DIB, la più grande in PNG: è la combinazione + // che ogni versione di Windows sa leggere senza sorprese. + images.Add(size >= 256 + ? (size, EncodePng(bitmap), true) + : (size, EncodeDib(bitmap), false)); + } + + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + Span header = stackalloc byte[6]; + BinaryPrimitives.WriteUInt16LittleEndian(header, 0); // riservato + BinaryPrimitives.WriteUInt16LittleEndian(header[2..], 1); // tipo: icona + BinaryPrimitives.WriteUInt16LittleEndian(header[4..], (ushort)images.Count); + stream.Write(header); + + int offset = 6 + 16 * images.Count; + Span entry = stackalloc byte[16]; + + foreach (var (size, data, _) in images) + { + entry.Clear(); + entry[0] = (byte)(size >= 256 ? 0 : size); // 0 significa 256 + entry[1] = (byte)(size >= 256 ? 0 : size); + entry[2] = 0; // colori della tavolozza + entry[3] = 0; // riservato + BinaryPrimitives.WriteUInt16LittleEndian(entry[4..], 1); // piani + BinaryPrimitives.WriteUInt16LittleEndian(entry[6..], 32); // bit per pixel + BinaryPrimitives.WriteUInt32LittleEndian(entry[8..], (uint)data.Length); + BinaryPrimitives.WriteUInt32LittleEndian(entry[12..], (uint)offset); + stream.Write(entry); + offset += data.Length; + } + + foreach (var (_, data, _) in images) stream.Write(data); + } + + // ------------------------------------------------------------------ disegno + + private static Bitmap Render(int size) + { + var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(bitmap); + + g.SmoothingMode = SmoothingMode.AntiAlias; + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.Clear(Color.Transparent); + + float s = size; + var bounds = new RectangleF(s * 0.015f, s * 0.015f, s * 0.97f, s * 0.97f); + + // Cielo: dal blu notte dell'ora blu al nero dello zenit. + using (var path = RoundedRectangle(bounds, s * 0.22f)) + { + using var sky = new LinearGradientBrush( + new PointF(0, bounds.Top), new PointF(0, bounds.Bottom), + Color.FromArgb(0x0E, 0x16, 0x2C), Color.FromArgb(0x04, 0x06, 0x0D)); + g.FillPath(sky, path); + g.SetClip(path); + } + + var pole = new PointF(s * 0.50f, s * 0.44f); + + // Sotto i venti pixel non c'è spazio per sette archi sottili: diventano una macchia. + // Le dimensioni piccole ricevono quindi meno scie, più spesse e più distanti fra loro. + // È la stessa immagine, detta con meno parole. + bool tiny = size <= 20; + bool small = size <= 36; + + float trailWidth = Math.Max(1.4f, s * (tiny ? 0.10f : small ? 0.072f : 0.042f)); + + Color coolWhite = Color.FromArgb(0xD8, 0xEA, 0xFF); + Color iceBlue = Color.FromArgb(0x8F, 0xC8, 0xFF); + Color warm = Color.FromArgb(0xFF, 0xDA, 0xA8); + + // Archi concentrici: raggio crescente, apertura crescente verso l'esterno, come + // accade davvero — le stelle lontane dal polo percorrono più cielo. + (float Radius, float Start, float Sweep, Color Tint)[] trails = tiny + ? + [ + (0.23f, 165f, 150f, coolWhite), + (0.40f, 335f, 135f, warm), + ] + : small + ? + [ + (0.17f, 190f, 130f, warm), + (0.30f, 10f, 150f, coolWhite), + (0.43f, 150f, 165f, iceBlue), + ] + : + [ + (0.10f, 200f, 120f, Color.FromArgb(0xFF, 0xE6, 0xC8)), + (0.19f, 20f, 140f, Color.FromArgb(0xCF, 0xE4, 0xFF)), + (0.19f, 215f, 95f, Color.FromArgb(0xFF, 0xD3, 0x9C)), + (0.29f, 165f, 160f, Color.FromArgb(0x9C, 0xD4, 0xFF)), + (0.29f, 355f, 85f, Color.FromArgb(0xFF, 0xFF, 0xFF)), + (0.39f, 130f, 175f, Color.FromArgb(0x7F, 0xC4, 0xFF)), + (0.39f, 330f, 100f, Color.FromArgb(0xFF, 0xE1, 0xB4)), + ]; + + foreach (var (radiusFactor, start, sweep, tint) in trails) + { + float radius = s * radiusFactor; + var box = new RectangleF(pole.X - radius, pole.Y - radius, radius * 2, radius * 2); + + // Un tratto continuo tenue sotto, e sopra la sfumatura a segmenti: senza la base + // i segmenti si leggerebbero come una collana di perle invece che come una scia. + using (var basePen = new Pen(Color.FromArgb(26, tint), trailWidth) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round, + }) + { + g.DrawArc(basePen, box, start, sweep); + } + + int steps = Math.Max(24, (int)(sweep / 1.5f)); + for (int i = 0; i < steps; i++) + { + float t = i / (float)(steps - 1); + // La coda sfuma, la testa resta accesa: è così che si legge il verso del moto. + int alpha = (int)(10 + 245 * t * t); + float a0 = start + sweep * i / steps; + float a1 = start + sweep * (i + 1) / steps; + + using var pen = new Pen(Color.FromArgb(alpha, tint), trailWidth) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round, + }; + // I segmenti si sovrappongono: le estremità arrotondate si fondono fra loro. + g.DrawArc(pen, box, a0, a1 - a0 + (sweep / steps) * 0.9f); + } + + // Stella in testa alla scia. + double head = (start + sweep) * Math.PI / 180.0; + var tip = new PointF(pole.X + radius * (float)Math.Cos(head), pole.Y + radius * (float)Math.Sin(head)); + float star = trailWidth * 0.85f; + using var glow = new SolidBrush(Color.FromArgb(235, Color.White)); + g.FillEllipse(glow, tip.X - star, tip.Y - star, star * 2, star * 2); + } + + // Polo celeste: il punto fermo attorno a cui gira tutto. + float core = Math.Max(1f, s * (tiny ? 0.055f : 0.035f)); + float haloFactor = tiny ? 1.7f : 2.4f; + using (var halo = new SolidBrush(Color.FromArgb(tiny ? 50 : 70, 0xBF, 0xDC, 0xFF))) + g.FillEllipse(halo, pole.X - core * haloFactor, pole.Y - core * haloFactor, + core * haloFactor * 2, core * haloFactor * 2); + using (var center = new SolidBrush(Color.White)) + g.FillEllipse(center, pole.X - core, pole.Y - core, core * 2, core * 2); + + // Orizzonte: una collina scura che ancora la scena alla terra. Sotto i venti pixel + // occuperebbe righe che servono alle scie, e sparisce. + if (!tiny) + { + using var horizon = new GraphicsPath(); + horizon.AddBezier( + new PointF(bounds.Left, s * 0.86f), + new PointF(s * 0.30f, s * 0.74f), + new PointF(s * 0.66f, s * 0.90f), + new PointF(bounds.Right, s * 0.79f)); + horizon.AddLine(bounds.Right, bounds.Bottom, bounds.Left, bounds.Bottom); + horizon.CloseFigure(); + + using var ground = new SolidBrush(Color.FromArgb(0xFF, 0x02, 0x03, 0x07)); + g.FillPath(ground, horizon); + } + + g.ResetClip(); + + // Bordo appena percettibile: stacca l'icona dagli sfondi chiari. + using (var path = RoundedRectangle(RectangleF.Inflate(bounds, -0.5f, -0.5f), s * 0.22f)) + using (var edge = new Pen(Color.FromArgb(60, 0x8A, 0xB4, 0xE8), Math.Max(1f, s * 0.012f))) + { + g.DrawPath(edge, path); + } + + return bitmap; + } + + private static GraphicsPath RoundedRectangle(RectangleF bounds, float radius) + { + var path = new GraphicsPath(); + float diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height)); + var arc = new RectangleF(bounds.X, bounds.Y, diameter, diameter); + + path.AddArc(arc, 180, 90); + arc.X = bounds.Right - diameter; + path.AddArc(arc, 270, 90); + arc.Y = bounds.Bottom - diameter; + path.AddArc(arc, 0, 90); + arc.X = bounds.X; + path.AddArc(arc, 90, 90); + path.CloseFigure(); + return path; + } + + // ------------------------------------------------------------------ codifica + + private static byte[] EncodePng(Bitmap bitmap) + { + using var stream = new MemoryStream(); + bitmap.Save(stream, ImageFormat.Png); + return stream.ToArray(); + } + + /// + /// DIB a 32 bit come lo vuole il formato ICO: intestazione con altezza doppia, pixel + /// dal basso verso l'alto e una maschera AND che resta comunque presente, anche se con + /// il canale alfa non serve più a nulla. + /// + private static byte[] EncodeDib(Bitmap bitmap) + { + int width = bitmap.Width; + int height = bitmap.Height; + int maskStride = (width + 31) / 32 * 4; + + var output = new byte[40 + width * height * 4 + maskStride * height]; + Span header = output.AsSpan(0, 40); + + BinaryPrimitives.WriteUInt32LittleEndian(header, 40); + BinaryPrimitives.WriteInt32LittleEndian(header[4..], width); + BinaryPrimitives.WriteInt32LittleEndian(header[8..], height * 2); + BinaryPrimitives.WriteUInt16LittleEndian(header[12..], 1); + BinaryPrimitives.WriteUInt16LittleEndian(header[14..], 32); + BinaryPrimitives.WriteUInt32LittleEndian(header[20..], (uint)(width * height * 4)); + + var locked = bitmap.LockBits(new Rectangle(0, 0, width, height), + ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + try + { + for (int y = 0; y < height; y++) + { + IntPtr row = locked.Scan0 + y * locked.Stride; + int destination = 40 + (height - 1 - y) * width * 4; + System.Runtime.InteropServices.Marshal.Copy(row, output, destination, width * 4); + } + } + finally + { + bitmap.UnlockBits(locked); + } + + return output; + } +} diff --git a/Titano/Diagnostics/SelfTest.cs b/Titano/Diagnostics/SelfTest.cs index 0561577..c35e0d9 100644 --- a/Titano/Diagnostics/SelfTest.cs +++ b/Titano/Diagnostics/SelfTest.cs @@ -119,6 +119,25 @@ public static class SelfTest 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); diff --git a/Titano/Diagnostics/SequenceDiagnostics.cs b/Titano/Diagnostics/SequenceDiagnostics.cs index 429da53..2aaf5bb 100644 --- a/Titano/Diagnostics/SequenceDiagnostics.cs +++ b/Titano/Diagnostics/SequenceDiagnostics.cs @@ -172,6 +172,7 @@ public static class SequenceDiagnostics 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; @@ -182,6 +183,8 @@ public static class SequenceDiagnostics 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)" diff --git a/Titano/Imaging/ImageDecoder.cs b/Titano/Imaging/ImageDecoder.cs index 69f7f5d..b759670 100644 --- a/Titano/Imaging/ImageDecoder.cs +++ b/Titano/Imaging/ImageDecoder.cs @@ -165,6 +165,20 @@ public static class ImageDecoder public static bool SwapsAxes(int orientation) => orientation is 5 or 6 or 7 or 8; + /// Descrizione leggibile di una trasformazione Exif. + public static string DescribeOrientation(int orientation) => orientation switch + { + 1 => "nessuna trasformazione", + 2 => "specchiata orizzontalmente", + 3 => "ruotata di 180°", + 4 => "specchiata verticalmente", + 5 => "trasposta", + 6 => "ruotata di 90° in senso orario", + 7 => "trasversa", + 8 => "ruotata di 90° in senso antiorario", + _ => "sconosciuta", + }; + // ------------------------------------------------------------------ conversione + orientamento private static unsafe void Convert32(byte[] scratch, int stride, int width, int height, diff --git a/Titano/Imaging/OrientationDetector.cs b/Titano/Imaging/OrientationDetector.cs new file mode 100644 index 0000000..27a5641 --- /dev/null +++ b/Titano/Imaging/OrientationDetector.cs @@ -0,0 +1,62 @@ +using Titano.Metadata; + +namespace Titano.Imaging; + +/// Esito del rilevamento automatico dell'orientamento. +public sealed record OrientationDetection(int Orientation, string Reason) +{ + public string Description => ImageDecoder.DescribeOrientation(Orientation); +} + +/// +/// Determina quale trasformazione applicare ai pixel dopo la decodifica. +/// +/// Il tag Exif dice come va raddrizzata l'immagine, ma non basta leggerlo: alcuni +/// decodificatori di sistema lo applicano già per conto proprio e altri no, e la stessa +/// installazione di Windows si comporta diversamente a seconda del formato. Applicare la +/// trasformazione una seconda volta produce fotogrammi ribaltati; non applicarla mai +/// produce sequenze coricate. Qui si confronta ciò che il decodificatore restituisce con +/// ciò che il file dichiara di contenere, e si decide di conseguenza. +/// +public static class OrientationDetector +{ + public static OrientationDetection Detect(FrameMetadata metadata) + { + int declared = metadata.Orientation; + if (declared is < 1 or > 8) + return new OrientationDetection(1, "il file non dichiara un orientamento valido"); + + if (declared == 1) + return new OrientationDetection(1, "il file non richiede trasformazioni"); + + // Le dimensioni memorizzate provengono dalla directory dell'immagine principale, + // quindi descrivono i pixel così come stanno nel file, prima di ogni rotazione. + int storedWidth = metadata.PixelWidth; + int storedHeight = metadata.PixelHeight; + if (storedWidth <= 0 || storedHeight <= 0) + return new OrientationDetection(declared, "dimensioni dichiarate assenti, si applica il tag Exif"); + + int decodedWidth, decodedHeight; + try + { + (decodedWidth, decodedHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, 1); + } + catch (Exception) + { + return new OrientationDetection(declared, "sonda non riuscita, si applica il tag Exif"); + } + + if (decodedWidth <= 0 || decodedHeight <= 0) + return new OrientationDetection(declared, "sonda senza esito, si applica il tag Exif"); + + // Se la trasformazione scambia gli assi e il decodificatore restituisce già le + // dimensioni scambiate, l'ha applicata lui: riapplicarla ribalterebbe il fotogramma. + if (ImageDecoder.SwapsAxes(declared) && + decodedWidth == storedHeight && decodedHeight == storedWidth) + { + return new OrientationDetection(1, "già raddrizzata dal decodificatore di sistema"); + } + + return new OrientationDetection(declared, "dal tag Exif del file"); + } +} diff --git a/Titano/Pipeline/RenderPipeline.cs b/Titano/Pipeline/RenderPipeline.cs index f989f1d..b1ce3c6 100644 --- a/Titano/Pipeline/RenderPipeline.cs +++ b/Titano/Pipeline/RenderPipeline.cs @@ -78,6 +78,7 @@ public sealed class RenderPipeline(TitanoProject project) int analysisHeight = Math.Max(2, (int)Math.Round(analysisWidth * workingHeight / (double)workingWidth)); int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16); + int orientation = _project.EffectiveOrientation; var pool = new FrameBufferPool(parallelism + 2); var stats = new LuminanceStats[count]; var failures = new bool[count]; @@ -97,7 +98,7 @@ public sealed class RenderPipeline(TitanoProject project) try { using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight, - record.Metadata.Orientation, pool); + orientation, pool); stats[i] = LuminanceAnalyzer.Analyze(buffer); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -184,6 +185,7 @@ public sealed class RenderPipeline(TitanoProject project) var curve = _project.Curve; int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16); + int orientation = _project.EffectiveOrientation; // Buffer contemporaneamente vivi: i cinque fissi del ciclo (corrente, successivo, // sfocatura, interpolato, interpolato sfocato), le decodifiche in volo e la copia @@ -216,8 +218,8 @@ public sealed class RenderPipeline(TitanoProject project) { cancellation.ThrowIfCancellationRequested(); int index = i; - var task = Task.Run(() => DecodeAndCorrect(sequence, index, width, height, pool, - curve, deflickerSettings), cancellation); + var task = Task.Run(() => DecodeAndCorrect(sequence, index, width, height, orientation, + pool, curve, deflickerSettings), cancellation); await channel.Writer.WriteAsync(task, cancellation).ConfigureAwait(false); } channel.Writer.Complete(); @@ -342,14 +344,14 @@ public sealed class RenderPipeline(TitanoProject project) /// Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato. private static ImageBuffer? DecodeAndCorrect(TimelapseSequence sequence, int index, int width, int height, - FrameBufferPool pool, DeflickerCurve? curve, + int orientation, FrameBufferPool pool, DeflickerCurve? curve, DeflickerSettings settings) { var record = sequence.Frames[index]; ImageBuffer buffer; try { - buffer = ImageDecoder.Decode(record.FilePath, width, height, record.Metadata.Orientation, pool); + buffer = ImageDecoder.Decode(record.FilePath, width, height, orientation, pool); } catch (Exception) { diff --git a/Titano/Pipeline/TitanoProject.cs b/Titano/Pipeline/TitanoProject.cs index bcc3f64..d68cbf4 100644 --- a/Titano/Pipeline/TitanoProject.cs +++ b/Titano/Pipeline/TitanoProject.cs @@ -5,6 +5,18 @@ using Titano.Video; namespace Titano.Pipeline; +/// +/// Compromesso fra qualità del risultato e tempo di elaborazione. Agisce sulla finezza +/// del campo di movimento, sui campioni della sfocatura e sulla risoluzione della passata +/// fotometrica: tutte grandezze che migliorano il risultato e costano tempo. +/// +public enum QualityProfile +{ + Bozza, + Standard, + Massima, +} + /// Impostazioni della sezione "Generale". public sealed class GeneralSettings { @@ -12,7 +24,15 @@ public sealed class GeneralSettings public int WorkingWidth { get; set; } /// Larghezza usata nella passata di analisi fotometrica: incide solo sulla velocità. - public int AnalysisWidth { get; set; } = 1024; + public int AnalysisWidth { get; set; } = 2048; + + /// + /// Trasformazione da applicare ai pixel decodificati; null lascia decidere al rilevamento + /// automatico. Serve quando i metadati del file sono sbagliati e l'occhio dice altro. + /// + public int? OrientationOverride { get; set; } + + public QualityProfile Quality { get; set; } = QualityProfile.Massima; /// Decodifiche simultanee. Limita anche i buffer in volo, quindi la memoria occupata. public int DecodeParallelism { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 2, 8); @@ -35,6 +55,14 @@ public sealed class TitanoProject public OpticalFlowSettings Flow { get; set; } = new(); public ExportSettings Export { get; set; } = new(); + public TitanoProject() + { + // I valori predefiniti delle singole sezioni devono corrispondere al profilo + // dichiarato, altrimenti l'interfaccia mostrerebbe "Massima" con i parametri di + // "Standard" sotto i cursori. + ApplyQualityProfile(General.Quality); + } + public TimelapseSequence? Sequence { get; set; } /// Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione. @@ -46,6 +74,67 @@ public sealed class TitanoProject public bool HasSequence => Sequence is { Count: > 0 }; public bool IsAnalyzed => Curve is not null && Stats is not null; + /// Orientamento dedotto dal primo fotogramma della sequenza. + public Imaging.OrientationDetection Orientation { get; private set; } = new(1, "nessuna sequenza caricata"); + + /// Trasformazione effettivamente applicata: la scelta dell'utente ha la precedenza. + public int EffectiveOrientation => General.OrientationOverride ?? Orientation.Orientation; + + /// + /// Esamina il primo fotogramma per stabilire come vanno raddrizzati i pixel. Va invocato + /// dopo l'ingestion: la sequenza è omogenea, quindi un solo campione basta per tutti. + /// + public void DetectOrientation() + { + Orientation = Sequence is { Count: > 0 } + ? Imaging.OrientationDetector.Detect(Sequence.Frames[0].Metadata) + : new Imaging.OrientationDetection(1, "nessuna sequenza caricata"); + } + + /// + /// Allinea i parametri di elaborazione al profilo di qualità scelto. Sovrascrive i + /// cursori delle sezioni avanzate: è il senso stesso di un profilo. + /// + public void ApplyQualityProfile(QualityProfile profile) + { + General.Quality = profile; + + switch (profile) + { + case QualityProfile.Bozza: + General.AnalysisWidth = 768; + Flow.AnalysisWidth = 640; + Flow.CellSize = 12; + Flow.PyramidLevels = 3; + Flow.WindowRadius = 5; + Flow.Iterations = 3; + MotionBlur.MaxSamples = 13; + break; + + case QualityProfile.Standard: + General.AnalysisWidth = 1024; + Flow.AnalysisWidth = 960; + Flow.CellSize = 8; + Flow.PyramidLevels = 4; + Flow.WindowRadius = 6; + Flow.Iterations = 5; + MotionBlur.MaxSamples = 25; + break; + + default: + // Il campo di movimento si infittisce e la scia guadagna campioni: sono le + // due voci che si vedono davvero nel fotogramma finale. + General.AnalysisWidth = 2048; + Flow.AnalysisWidth = 1440; + Flow.CellSize = 6; + Flow.PyramidLevels = 5; + Flow.WindowRadius = 7; + Flow.Iterations = 8; + MotionBlur.MaxSamples = 49; + break; + } + } + /// /// Risoluzione di lavoro effettiva: parte dal primo fotogramma, applica l'eventuale limite /// dell'utente e arrotonda a valori pari, richiesti dal sottocampionamento cromatico 4:2:0. @@ -55,14 +144,15 @@ public sealed class TitanoProject if (Sequence is not { Count: > 0 }) return (0, 0); var first = Sequence.Frames[0].Metadata; + int orientation = EffectiveOrientation; int sourceWidth = first.PixelWidth; int sourceHeight = first.PixelHeight; if (sourceWidth <= 0 || sourceHeight <= 0) { - (sourceWidth, sourceHeight) = Imaging.ImageDecoder.ProbeDisplaySize(first.FilePath, first.Orientation); + (sourceWidth, sourceHeight) = Imaging.ImageDecoder.ProbeDisplaySize(first.FilePath, orientation); } - else if (Imaging.ImageDecoder.SwapsAxes(first.Orientation)) + else if (Imaging.ImageDecoder.SwapsAxes(orientation)) { (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); } @@ -98,7 +188,7 @@ public sealed class TitanoProject int sourceWidth = first.PixelWidth; int sourceHeight = first.PixelHeight; if (sourceWidth <= 0 || sourceHeight <= 0) return (width, height); - if (Imaging.ImageDecoder.SwapsAxes(first.Orientation)) (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); + if (Imaging.ImageDecoder.SwapsAxes(EffectiveOrientation)) (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); int requestedWidth = Export.Width > 0 ? Export.Width : General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth) diff --git a/Titano/Program.cs b/Titano/Program.cs index 2774098..143ef6c 100644 --- a/Titano/Program.cs +++ b/Titano/Program.cs @@ -26,6 +26,14 @@ internal static class Program return SelfTest.Run(directory, Console.Out); } + if (args.Length > 1 && args[0] == "--make-icon") + { + EnsureConsole(); + IconGenerator.Write(args[1]); + Console.WriteLine($"Icona generata in {args[1]}."); + return 0; + } + if (args.Length > 1 && args[0] == "--diagnose") { EnsureConsole(); diff --git a/Titano/README.md b/Titano/README.md index a952f32..312a19f 100644 --- a/Titano/README.md +++ b/Titano/README.md @@ -84,6 +84,18 @@ miniatura, e le dimensioni vere vivono in una SubIFD marcata come non ridotta. T la directory dell'immagine principale invece di fidarsi della prima, e legge i metadati con una finestra che si estende su richiesta: di un DNG da 15 MiB ne tocca 128 KiB. +**Orientamento.** Il tag Exif dice come raddrizzare l'immagine, ma non basta leggerlo: +alcuni decodificatori di sistema lo applicano già per conto proprio e altri no. Applicarlo +una seconda volta ribalta il fotogramma, non applicarlo mai lo lascia coricato. In modalità +automatica Titano confronta le dimensioni che il decodificatore restituisce con quelle che il +file dichiara di contenere e decide di conseguenza; la sezione Generale mostra cosa ha dedotto +e permette di imporre una trasformazione diversa, con riscontro immediato nell'anteprima. + +**Qualità.** Il profilo di elaborazione governa finezza della griglia del campo vettoriale, +livelli della piramide, iterazioni per livello, campioni per pixel della scia e risoluzione +della passata fotometrica. Il valore predefinito è "Massima": costa circa tre volte il tempo +di "Standard" e si vede nei bordi dei soggetti in movimento. + La risoluzione di lavoro viene infine ricondotta ai limiti del codec. Un encoder hardware accetta volentieri un fotogramma 4000×3000 e dichiara un livello alto: il flusso è formalmente valido ma supera il Livello 5.2 di H.264, e i decodificatori comuni si rifiutano di aprirlo. Il @@ -108,7 +120,7 @@ Titano.exe --selftest [cartella] Genera una sequenza sintetica dalle proprietà note — traslazione, sfarfallio e rampa di luce imposti, pausa dell'intervallometro inclusa — e la fa attraversare l'intera pipeline, -confrontando 24 grandezze misurate con i valori attesi: campi Exif, cadenza, shutter angle, +confrontando 25 grandezze misurate con i valori attesi: campi Exif, cadenza, shutter angle, riduzione dello sfarfallio, conservazione della rampa, invarianza della luminanza alla scala di decodifica, modulo e direzione del campo vettoriale, attenuazione del dettaglio dovuta alla sfocatura, allineamento delle NAL dentro ogni campione del contenitore e — prova conclusiva — @@ -129,3 +141,12 @@ Titano.exe --capture [cartella-sequenza] [scheda] ``` Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile. + +``` +Titano.exe --make-icon +``` + +Ridisegna l'icona dell'applicazione — scie stellari attorno al polo celeste, l'immagine in cui +il tempo diventa visibile — e ne scrive il contenitore ICO a mano. I livelli sotto i venti +pixel ricevono meno archi e più spessi: alla dimensione della barra delle applicazioni sette +scie sottili diventerebbero una macchia. diff --git a/Titano/Titano.csproj b/Titano/Titano.csproj index ac297ec..806def8 100644 --- a/Titano/Titano.csproj +++ b/Titano/Titano.csproj @@ -20,6 +20,7 @@ x64 x64 Titano + Titano.ico Titano PerMonitorV2 Segoe UI, 9pt diff --git a/Titano/Titano.ico b/Titano/Titano.ico new file mode 100644 index 0000000..6c7366c Binary files /dev/null and b/Titano/Titano.ico differ diff --git a/Titano/UI/MainForm.cs b/Titano/UI/MainForm.cs index 96440bf..fd2dab5 100644 --- a/Titano/UI/MainForm.cs +++ b/Titano/UI/MainForm.cs @@ -70,11 +70,28 @@ internal sealed class MainForm : Form _exportButton = new DarkButton { Text = "Esporta video", Width = 140, Primary = true }; _cancelButton = new DarkButton { Text = "Annulla", Width = 96, Danger = true, Visible = false }; + LoadApplicationIcon(); BuildLayout(); WireEvents(); UpdateCommandState(); } + /// + /// Riprende l'icona dalle risorse dell'eseguibile: è la stessa che Esplora risorse + /// mostra sul file, quindi finestra, barra delle applicazioni e cartella restano coerenti. + /// + private void LoadApplicationIcon() + { + try + { + if (Environment.ProcessPath is { } executable) Icon = Icon.ExtractAssociatedIcon(executable); + } + catch (Exception) + { + // Senza icona la finestra usa quella predefinita: nulla di grave. + } + } + protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); @@ -337,6 +354,8 @@ internal sealed class MainForm : Form _project.Sequence = sequence; _project.Curve = null; _project.Stats = null; + _project.DetectOrientation(); + _settings.ShowDetectedOrientation(_project.Orientation); _table.SetSequence(sequence); _chart.SetData(sequence, null); @@ -345,7 +364,9 @@ internal sealed class MainForm : Form ShowPreview(0); SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " + - $"{sequence.CadenceAnomalies} intervalli anomali." + DescribeResolutionClamp()); + $"{sequence.CadenceAnomalies} intervalli anomali. " + + $"Orientamento {_project.Orientation.Description} ({_project.Orientation.Reason})." + + DescribeResolutionClamp()); } catch (OperationCanceledException) { diff --git a/Titano/UI/PreviewPanel.cs b/Titano/UI/PreviewPanel.cs index b4de450..5967660 100644 --- a/Titano/UI/PreviewPanel.cs +++ b/Titano/UI/PreviewPanel.cs @@ -116,12 +116,13 @@ internal sealed class PreviewPanel : Control { var record = sequence.Frames[index]; var metadata = record.Metadata; + int orientation = project.EffectiveOrientation; int sourceWidth = metadata.PixelWidth; int sourceHeight = metadata.PixelHeight; if (sourceWidth <= 0 || sourceHeight <= 0) - (sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation); - else if (ImageDecoder.SwapsAxes(metadata.Orientation)) + (sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, orientation); + else if (ImageDecoder.SwapsAxes(orientation)) (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile"); @@ -132,7 +133,7 @@ internal sealed class PreviewPanel : Control int height = Math.Max(16, (int)(sourceHeight * scale) & ~1); var pool = new FrameBufferPool(4); - using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, metadata.Orientation, pool); + using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, orientation, pool); token.ThrowIfCancellationRequested(); var status = new System.Text.StringBuilder(); @@ -152,7 +153,7 @@ internal sealed class PreviewPanel : Control if (project.MotionBlur.Enabled && index + 1 < sequence.Count) { var nextMetadata = sequence.Frames[index + 1].Metadata; - using var next = ImageDecoder.Decode(nextMetadata.FilePath, width, height, nextMetadata.Orientation, pool); + using var next = ImageDecoder.Decode(nextMetadata.FilePath, width, height, orientation, pool); token.ThrowIfCancellationRequested(); if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count) diff --git a/Titano/UI/SettingsPanel.cs b/Titano/UI/SettingsPanel.cs index a3c3177..290b855 100644 --- a/Titano/UI/SettingsPanel.cs +++ b/Titano/UI/SettingsPanel.cs @@ -11,9 +11,13 @@ namespace Titano.UI; /// internal sealed class SettingsPanel : Panel { + /// Valori Exif corrispondenti alle voci del menu dell'orientamento; null = automatico. + private static readonly int?[] OrientationValues = [null, 1, 2, 4, 6, 3, 8]; + private readonly TitanoProject _project; private readonly TabStrip _tabs; private readonly Panel[] _pages; + private LabeledCombo? _orientationCombo; /// Un parametro del deflicker è cambiato: basta ricalcolare la curva. public event EventHandler? DeflickerChanged; @@ -66,6 +70,18 @@ internal sealed class SettingsPanel : Panel ShowPage(0); } + /// + /// Riporta nel menu l'esito del rilevamento, così la voce "Automatico" dice cosa ha deciso. + /// + public void ShowDetectedOrientation(Imaging.OrientationDetection detection) + { + if (_orientationCombo is null || _orientationCombo.Combo.Items.Count == 0) return; + + int selected = _orientationCombo.Combo.SelectedIndex; + _orientationCombo.Combo.Items[0] = $"Automatico — {detection.Description}"; + _orientationCombo.Combo.SelectedIndex = selected; + } + /// Seleziona una delle tre sezioni; usata anche dalla modalità di cattura. internal void SelectPage(int index) => _tabs.SelectedIndex = index; @@ -97,6 +113,37 @@ internal sealed class SettingsPanel : Panel AnalysisInvalidated?.Invoke(this, EventArgs.Empty); })); + _orientationCombo = Combo("Orientamento dei fotogrammi", + ["Automatico", "Nessuna rotazione", "Specchia orizzontalmente", "Specchia verticalmente", + "Ruota 90° in senso orario", "Ruota 180°", "Ruota 90° in senso antiorario"], + Array.IndexOf(OrientationValues, _project.General.OrientationOverride), + index => + { + _project.General.OrientationOverride = OrientationValues[Math.Clamp(index, 0, OrientationValues.Length - 1)]; + AnalysisInvalidated?.Invoke(this, EventArgs.Empty); + }); + stack.Add(_orientationCombo); + + stack.Add(Note("In automatico l'orientamento viene dedotto dai metadati del primo scatto e " + + "confrontato con ciò che il decodificatore di sistema restituisce, così la " + + "trasformazione non viene applicata due volte. Se il risultato non convince, " + + "l'anteprima mostra subito l'effetto di una scelta manuale.")); + + stack.Add(new SectionHeader("Qualità di elaborazione")); + + stack.Add(Combo("Profilo", + ["Bozza — veloce", "Standard — equilibrio", "Massima — resa migliore"], + (int)_project.General.Quality, + index => + { + _project.ApplyQualityProfile((QualityProfile)Math.Clamp(index, 0, 2)); + AnalysisInvalidated?.Invoke(this, EventArgs.Empty); + })); + + stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura e " + + "risoluzione della passata fotometrica, e sovrascrive i cursori delle sezioni " + + "avanzate. Più qualità significa più tempo, non un file più grande.")); + stack.Add(new SectionHeader("Prestazioni")); stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px", value => @@ -282,6 +329,16 @@ internal sealed class SettingsPanel : Panel control.Top = _y; control.Width = Panel.ClientSize.Width - 34; control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right; + + // Le note esplicative variano in lunghezza: si misurano sulla larghezza reale + // della colonna, altrimenti le più lunghe finirebbero tagliate a metà frase. + if (control is Label note && !note.AutoSize) + { + var measured = TextRenderer.MeasureText(note.Text, note.Font, + new Size(control.Width, 0), TextFormatFlags.WordBreak); + control.Height = measured.Height + 8; + } + Panel.Controls.Add(control); _y += control.Height + 6; }