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;
}