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>
This commit is contained in:
2026-08-14 15:53:10 +02:00
co-authored by Claude Opus 5
parent e476ebb648
commit 332f02d17d
14 changed files with 588 additions and 15 deletions
+274
View File
@@ -0,0 +1,274 @@
using System.Buffers.Binary;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
namespace Titano.Diagnostics;
/// <summary>
/// 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.
/// </summary>
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<byte> 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<byte> 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();
}
/// <summary>
/// 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.
/// </summary>
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<byte> 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;
}
}
+19
View File
@@ -119,6 +119,25 @@ public static class SelfTest
Add(checks, "Decodifica — luminanza invariante alla scala", worstScaleError < 0.12, Add(checks, "Decodifica — luminanza invariante alla scala", worstScaleError < 0.12,
$"scarto massimo {worstScaleError:0.000} EV fra piena risoluzione e versioni ridotte"); $"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 // ---------------------------------------------------------------- 5. optical flow
var flowEngine = new OpticalFlowEngine(project.Flow); var flowEngine = new OpticalFlowEngine(project.Flow);
@@ -172,6 +172,7 @@ public static class SequenceDiagnostics
var project = new Pipeline.TitanoProject(); var project = new Pipeline.TitanoProject();
project.Sequence = TimelapseSequence.Build(subset); project.Sequence = TimelapseSequence.Build(subset);
project.Sequence.RecomputeTiming(); project.Sequence.RecomputeTiming();
project.DetectOrientation();
project.Export.OutputPath = outputPath; project.Export.OutputPath = outputPath;
project.Export.FrameRate = 24; project.Export.FrameRate = 24;
project.Export.BitrateMbps = 80; project.Export.BitrateMbps = 80;
@@ -182,6 +183,8 @@ public static class SequenceDiagnostics
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize(); var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
long perFrame = (long)width * height * 3 * sizeof(float); long perFrame = (long)width * height * 3 * sizeof(float);
output.WriteLine($" fotogrammi {subset.Count}"); 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}" + output.WriteLine($" risoluzione {width}×{height}" +
(width != requestedWidth || height != requestedHeight (width != requestedWidth || height != requestedHeight
? $" (ridotta da {requestedWidth}×{requestedHeight} per il limite del codec)" ? $" (ridotta da {requestedWidth}×{requestedHeight} per il limite del codec)"
+14
View File
@@ -165,6 +165,20 @@ public static class ImageDecoder
public static bool SwapsAxes(int orientation) => orientation is 5 or 6 or 7 or 8; public static bool SwapsAxes(int orientation) => orientation is 5 or 6 or 7 or 8;
/// <summary>Descrizione leggibile di una trasformazione Exif.</summary>
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 // ------------------------------------------------------------------ conversione + orientamento
private static unsafe void Convert32(byte[] scratch, int stride, int width, int height, private static unsafe void Convert32(byte[] scratch, int stride, int width, int height,
+62
View File
@@ -0,0 +1,62 @@
using Titano.Metadata;
namespace Titano.Imaging;
/// <summary>Esito del rilevamento automatico dell'orientamento.</summary>
public sealed record OrientationDetection(int Orientation, string Reason)
{
public string Description => ImageDecoder.DescribeOrientation(Orientation);
}
/// <summary>
/// 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.
/// </summary>
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");
}
}
+7 -5
View File
@@ -78,6 +78,7 @@ public sealed class RenderPipeline(TitanoProject project)
int analysisHeight = Math.Max(2, (int)Math.Round(analysisWidth * workingHeight / (double)workingWidth)); int analysisHeight = Math.Max(2, (int)Math.Round(analysisWidth * workingHeight / (double)workingWidth));
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16); int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
int orientation = _project.EffectiveOrientation;
var pool = new FrameBufferPool(parallelism + 2); var pool = new FrameBufferPool(parallelism + 2);
var stats = new LuminanceStats[count]; var stats = new LuminanceStats[count];
var failures = new bool[count]; var failures = new bool[count];
@@ -97,7 +98,7 @@ public sealed class RenderPipeline(TitanoProject project)
try try
{ {
using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight, using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight,
record.Metadata.Orientation, pool); orientation, pool);
stats[i] = LuminanceAnalyzer.Analyze(buffer); stats[i] = LuminanceAnalyzer.Analyze(buffer);
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
@@ -184,6 +185,7 @@ public sealed class RenderPipeline(TitanoProject project)
var curve = _project.Curve; var curve = _project.Curve;
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16); int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
int orientation = _project.EffectiveOrientation;
// Buffer contemporaneamente vivi: i cinque fissi del ciclo (corrente, successivo, // Buffer contemporaneamente vivi: i cinque fissi del ciclo (corrente, successivo,
// sfocatura, interpolato, interpolato sfocato), le decodifiche in volo e la copia // sfocatura, interpolato, interpolato sfocato), le decodifiche in volo e la copia
@@ -216,8 +218,8 @@ public sealed class RenderPipeline(TitanoProject project)
{ {
cancellation.ThrowIfCancellationRequested(); cancellation.ThrowIfCancellationRequested();
int index = i; int index = i;
var task = Task.Run(() => DecodeAndCorrect(sequence, index, width, height, pool, var task = Task.Run(() => DecodeAndCorrect(sequence, index, width, height, orientation,
curve, deflickerSettings), cancellation); pool, curve, deflickerSettings), cancellation);
await channel.Writer.WriteAsync(task, cancellation).ConfigureAwait(false); await channel.Writer.WriteAsync(task, cancellation).ConfigureAwait(false);
} }
channel.Writer.Complete(); channel.Writer.Complete();
@@ -342,14 +344,14 @@ public sealed class RenderPipeline(TitanoProject project)
/// <summary>Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato.</summary> /// <summary>Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato.</summary>
private static ImageBuffer? DecodeAndCorrect(TimelapseSequence sequence, int index, int width, int height, private static ImageBuffer? DecodeAndCorrect(TimelapseSequence sequence, int index, int width, int height,
FrameBufferPool pool, DeflickerCurve? curve, int orientation, FrameBufferPool pool, DeflickerCurve? curve,
DeflickerSettings settings) DeflickerSettings settings)
{ {
var record = sequence.Frames[index]; var record = sequence.Frames[index];
ImageBuffer buffer; ImageBuffer buffer;
try try
{ {
buffer = ImageDecoder.Decode(record.FilePath, width, height, record.Metadata.Orientation, pool); buffer = ImageDecoder.Decode(record.FilePath, width, height, orientation, pool);
} }
catch (Exception) catch (Exception)
{ {
+94 -4
View File
@@ -5,6 +5,18 @@ using Titano.Video;
namespace Titano.Pipeline; namespace Titano.Pipeline;
/// <summary>
/// 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.
/// </summary>
public enum QualityProfile
{
Bozza,
Standard,
Massima,
}
/// <summary>Impostazioni della sezione "Generale".</summary> /// <summary>Impostazioni della sezione "Generale".</summary>
public sealed class GeneralSettings public sealed class GeneralSettings
{ {
@@ -12,7 +24,15 @@ public sealed class GeneralSettings
public int WorkingWidth { get; set; } public int WorkingWidth { get; set; }
/// <summary>Larghezza usata nella passata di analisi fotometrica: incide solo sulla velocità.</summary> /// <summary>Larghezza usata nella passata di analisi fotometrica: incide solo sulla velocità.</summary>
public int AnalysisWidth { get; set; } = 1024; public int AnalysisWidth { get; set; } = 2048;
/// <summary>
/// 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.
/// </summary>
public int? OrientationOverride { get; set; }
public QualityProfile Quality { get; set; } = QualityProfile.Massima;
/// <summary>Decodifiche simultanee. Limita anche i buffer in volo, quindi la memoria occupata.</summary> /// <summary>Decodifiche simultanee. Limita anche i buffer in volo, quindi la memoria occupata.</summary>
public int DecodeParallelism { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 2, 8); 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 OpticalFlowSettings Flow { get; set; } = new();
public ExportSettings Export { 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; } public TimelapseSequence? Sequence { get; set; }
/// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary> /// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary>
@@ -46,6 +74,67 @@ public sealed class TitanoProject
public bool HasSequence => Sequence is { Count: > 0 }; public bool HasSequence => Sequence is { Count: > 0 };
public bool IsAnalyzed => Curve is not null && Stats is not null; public bool IsAnalyzed => Curve is not null && Stats is not null;
/// <summary>Orientamento dedotto dal primo fotogramma della sequenza.</summary>
public Imaging.OrientationDetection Orientation { get; private set; } = new(1, "nessuna sequenza caricata");
/// <summary>Trasformazione effettivamente applicata: la scelta dell'utente ha la precedenza.</summary>
public int EffectiveOrientation => General.OrientationOverride ?? Orientation.Orientation;
/// <summary>
/// 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.
/// </summary>
public void DetectOrientation()
{
Orientation = Sequence is { Count: > 0 }
? Imaging.OrientationDetector.Detect(Sequence.Frames[0].Metadata)
: new Imaging.OrientationDetection(1, "nessuna sequenza caricata");
}
/// <summary>
/// Allinea i parametri di elaborazione al profilo di qualità scelto. Sovrascrive i
/// cursori delle sezioni avanzate: è il senso stesso di un profilo.
/// </summary>
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;
}
}
/// <summary> /// <summary>
/// Risoluzione di lavoro effettiva: parte dal primo fotogramma, applica l'eventuale limite /// 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. /// 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); if (Sequence is not { Count: > 0 }) return (0, 0);
var first = Sequence.Frames[0].Metadata; var first = Sequence.Frames[0].Metadata;
int orientation = EffectiveOrientation;
int sourceWidth = first.PixelWidth; int sourceWidth = first.PixelWidth;
int sourceHeight = first.PixelHeight; int sourceHeight = first.PixelHeight;
if (sourceWidth <= 0 || sourceHeight <= 0) 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); (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
} }
@@ -98,7 +188,7 @@ public sealed class TitanoProject
int sourceWidth = first.PixelWidth; int sourceWidth = first.PixelWidth;
int sourceHeight = first.PixelHeight; int sourceHeight = first.PixelHeight;
if (sourceWidth <= 0 || sourceHeight <= 0) return (width, height); 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 int requestedWidth = Export.Width > 0 ? Export.Width
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth) : General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
+8
View File
@@ -26,6 +26,14 @@ internal static class Program
return SelfTest.Run(directory, Console.Out); 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") if (args.Length > 1 && args[0] == "--diagnose")
{ {
EnsureConsole(); EnsureConsole();
+22 -1
View File
@@ -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 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. 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 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 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 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 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, 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 riduzione dello sfarfallio, conservazione della rampa, invarianza della luminanza alla scala di
decodifica, modulo e direzione del campo vettoriale, attenuazione del dettaglio dovuta alla decodifica, modulo e direzione del campo vettoriale, attenuazione del dettaglio dovuta alla
sfocatura, allineamento delle NAL dentro ogni campione del contenitore e — prova conclusiva — sfocatura, allineamento delle NAL dentro ogni campione del contenitore e — prova conclusiva —
@@ -129,3 +141,12 @@ Titano.exe --capture <file.png> [cartella-sequenza] [scheda]
``` ```
Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile. Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile.
```
Titano.exe --make-icon <file.ico>
```
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.
+1
View File
@@ -20,6 +20,7 @@
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<AssemblyName>Titano</AssemblyName> <AssemblyName>Titano</AssemblyName>
<ApplicationIcon>Titano.ico</ApplicationIcon>
<RootNamespace>Titano</RootNamespace> <RootNamespace>Titano</RootNamespace>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<ApplicationDefaultFont>Segoe UI, 9pt</ApplicationDefaultFont> <ApplicationDefaultFont>Segoe UI, 9pt</ApplicationDefaultFont>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+22 -1
View File
@@ -70,11 +70,28 @@ internal sealed class MainForm : Form
_exportButton = new DarkButton { Text = "Esporta video", Width = 140, Primary = true }; _exportButton = new DarkButton { Text = "Esporta video", Width = 140, Primary = true };
_cancelButton = new DarkButton { Text = "Annulla", Width = 96, Danger = true, Visible = false }; _cancelButton = new DarkButton { Text = "Annulla", Width = 96, Danger = true, Visible = false };
LoadApplicationIcon();
BuildLayout(); BuildLayout();
WireEvents(); WireEvents();
UpdateCommandState(); UpdateCommandState();
} }
/// <summary>
/// Riprende l'icona dalle risorse dell'eseguibile: è la stessa che Esplora risorse
/// mostra sul file, quindi finestra, barra delle applicazioni e cartella restano coerenti.
/// </summary>
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) protected override void OnHandleCreated(EventArgs e)
{ {
base.OnHandleCreated(e); base.OnHandleCreated(e);
@@ -337,6 +354,8 @@ internal sealed class MainForm : Form
_project.Sequence = sequence; _project.Sequence = sequence;
_project.Curve = null; _project.Curve = null;
_project.Stats = null; _project.Stats = null;
_project.DetectOrientation();
_settings.ShowDetectedOrientation(_project.Orientation);
_table.SetSequence(sequence); _table.SetSequence(sequence);
_chart.SetData(sequence, null); _chart.SetData(sequence, null);
@@ -345,7 +364,9 @@ internal sealed class MainForm : Form
ShowPreview(0); ShowPreview(0);
SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " + 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) catch (OperationCanceledException)
{ {
+5 -4
View File
@@ -116,12 +116,13 @@ internal sealed class PreviewPanel : Control
{ {
var record = sequence.Frames[index]; var record = sequence.Frames[index];
var metadata = record.Metadata; var metadata = record.Metadata;
int orientation = project.EffectiveOrientation;
int sourceWidth = metadata.PixelWidth; int sourceWidth = metadata.PixelWidth;
int sourceHeight = metadata.PixelHeight; int sourceHeight = metadata.PixelHeight;
if (sourceWidth <= 0 || sourceHeight <= 0) if (sourceWidth <= 0 || sourceHeight <= 0)
(sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation); (sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, orientation);
else if (ImageDecoder.SwapsAxes(metadata.Orientation)) else if (ImageDecoder.SwapsAxes(orientation))
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth); (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile"); 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); int height = Math.Max(16, (int)(sourceHeight * scale) & ~1);
var pool = new FrameBufferPool(4); 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(); token.ThrowIfCancellationRequested();
var status = new System.Text.StringBuilder(); var status = new System.Text.StringBuilder();
@@ -152,7 +153,7 @@ internal sealed class PreviewPanel : Control
if (project.MotionBlur.Enabled && index + 1 < sequence.Count) if (project.MotionBlur.Enabled && index + 1 < sequence.Count)
{ {
var nextMetadata = sequence.Frames[index + 1].Metadata; 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(); token.ThrowIfCancellationRequested();
if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count) if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count)
+57
View File
@@ -11,9 +11,13 @@ namespace Titano.UI;
/// </summary> /// </summary>
internal sealed class SettingsPanel : Panel internal sealed class SettingsPanel : Panel
{ {
/// <summary>Valori Exif corrispondenti alle voci del menu dell'orientamento; null = automatico.</summary>
private static readonly int?[] OrientationValues = [null, 1, 2, 4, 6, 3, 8];
private readonly TitanoProject _project; private readonly TitanoProject _project;
private readonly TabStrip _tabs; private readonly TabStrip _tabs;
private readonly Panel[] _pages; private readonly Panel[] _pages;
private LabeledCombo? _orientationCombo;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</summary> /// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</summary>
public event EventHandler? DeflickerChanged; public event EventHandler? DeflickerChanged;
@@ -66,6 +70,18 @@ internal sealed class SettingsPanel : Panel
ShowPage(0); ShowPage(0);
} }
/// <summary>
/// Riporta nel menu l'esito del rilevamento, così la voce "Automatico" dice cosa ha deciso.
/// </summary>
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;
}
/// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary> /// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index; internal void SelectPage(int index) => _tabs.SelectedIndex = index;
@@ -97,6 +113,37 @@ internal sealed class SettingsPanel : Panel
AnalysisInvalidated?.Invoke(this, EventArgs.Empty); 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(new SectionHeader("Prestazioni"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px", stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
value => value =>
@@ -282,6 +329,16 @@ internal sealed class SettingsPanel : Panel
control.Top = _y; control.Top = _y;
control.Width = Panel.ClientSize.Width - 34; control.Width = Panel.ClientSize.Width - 34;
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right; 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); Panel.Controls.Add(control);
_y += control.Height + 6; _y += control.Height + 6;
} }