Files
Titano/Titano/Pipeline/TitanoProject.cs
T
Alby96andClaude Opus 5 2e5a441938 Navigazione verticale e strumenti di misura nell'interfaccia
Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che
governa insieme il contenuto principale e la colonna delle impostazioni: una
sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da
tenere allineate a mano; adesso ce n'e' una sola, in verticale ci sta il nome
per esteso, e la sezione scelta resta leggibile mentre si lavora - cosa che in
una fila di schede in cima si perde appena l'occhio scende sul contenuto.
Accanto a ogni voce compare il numero di avvisi che la riguardano.

L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto
sempre, perche' in un programma che tratta immagini l'immagine si guarda
mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile
per giudicare e non solo per guardare. Zoom e trascinamento, perche' una
stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per
stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono
invece di essere interpolati. Confronto a tendina fra originale e corretto,
perche' l'unico modo di capire cosa una correzione stia facendo e' vedere
accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore
calcola comunque e che spiega perche' la sfocatura viene come viene,
soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso
la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una
panoramica virtuale.

Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini
e pannello degli avvisi sulla sequenza, istogramma e forma d'onda
sull'esposizione, percorso ricostruito della stabilizzazione sul movimento,
piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e
che finivano in due numeri in fondo a una riga di stato, cioe' invisibili.

Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu':
un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo
passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello
che il menu dell'orientamento usava da solo, esteso a larghezza di analisi,
finestra del deflicker, lunghezza della transizione, finestra della
stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del
deflicker viene da quattro periodi dello sfarfallio misurati
sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende
liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi.
Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce
meno decisioni e lascia il cursore dov'e'.

Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e'
l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non
toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un
ostacolo. Gli annunci si leggono da una cartella locale con un listino in
formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun
identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito
pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze
esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta
montando un time-lapse. Le campagne si aggiornano copiando file; a listino
vuoto compaiono note interne; il riquadro si spegne dalle preferenze.

Aggiunge anche le preferenze dell'applicazione, distinte da quelle del
progetto, con persistenza in un file di testo scritto a mano nello stesso
spirito del resto: una riga per voce, correggibile con un editor. Il progetto
descrive come trattare questi fotogrammi, le preferenze come si comporta il
programma.

Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche'
trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e
anteprima dei fotogrammi mentre vengono codificati, che non migliora il
risultato di un pixel ma cambia molto un'attesa di mezz'ora.

Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le
sezioni ispezionate a video sulla scena di prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:58:23 +02:00

324 lines
13 KiB
C#

using Titano.Analysis;
using Titano.Core;
using Titano.Motion;
using Titano.Video;
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 delle passate
/// di analisi: tutte grandezze che migliorano il risultato e costano tempo.
/// </summary>
public enum QualityProfile
{
Bozza,
Standard,
Massima,
}
/// <summary>Impostazioni della sezione "Generale".</summary>
public sealed class GeneralSettings
{
/// <summary>Larghezza massima di lavoro; 0 = risoluzione nativa del primo fotogramma.</summary>
public int WorkingWidth { get; set; }
/// <summary>Larghezza usata nella passata di analisi fotometrica: incide solo sulla velocità.</summary>
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>
public int DecodeParallelism { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 2, 8);
/// <summary>Tolleranza sulla cadenza oltre la quale un intervallo è segnalato come anomalo.</summary>
public double CadenceTolerance { get; set; } = 0.35;
public GeneralSettings Clone() => (GeneralSettings)MemberwiseClone();
}
/// <summary>
/// Stato completo di un progetto: sequenza caricata e tutti i parametri dei pannelli di
/// configurazione. È l'unico oggetto che l'interfaccia scambia con il motore.
/// </summary>
public sealed class TitanoProject
{
public GeneralSettings General { get; set; } = new();
public DeflickerSettings Deflicker { get; set; } = new();
public RegionSettings Regions { get; set; } = new();
public HolyGrailSettings HolyGrail { get; set; } = new();
public MotionBlurSettings MotionBlur { get; set; } = new();
public OpticalFlowSettings Flow { get; set; } = new();
public StabilizationSettings Stabilization { get; set; } = new();
public VirtualCameraSettings Camera { get; set; } = new();
public TimeRampSettings TimeRamp { get; set; } = new();
public StackingSettings Stacking { get; set; } = new();
public FrameCacheSettings Cache { 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);
}
/// <summary>
/// Parametri di cui l'utente ha preso il controllo: il direttore non li tocca più.
/// Basta muovere un cursore per finirci dentro, e il pulsante «auto» per uscirne.
/// </summary>
public HashSet<AutoKey> ManualParameters { get; } = [];
/// <summary>Ultime decisioni automatiche, con il motivo di ciascuna.</summary>
public IReadOnlyDictionary<AutoKey, AutoDecision> AutoDecisions { get; set; }
= new Dictionary<AutoKey, AutoDecision>();
public TimelapseSequence? Sequence { get; set; }
/// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary>
public DeflickerCurve? Curve { get; set; }
/// <summary>Statistiche fotometriche per fotogramma dell'ultima analisi.</summary>
public IReadOnlyList<LuminanceStats>? Stats { get; set; }
/// <summary>Maschera delle regioni, calcolata una volta sola per sequenza.</summary>
public RegionMask? Mask { get; set; }
/// <summary>Analisi delle transizioni giorno-notte.</summary>
public HolyGrailAnalysis? Transitions { get; set; }
/// <summary>Percorso della camera e correzione della stabilizzazione.</summary>
public StabilizationPath? Motion { get; set; }
/// <summary>
/// Spostamenti misurati fra fotogrammi adiacenti, conservati così come escono dalla
/// correlazione di fase. Da questi il percorso si ricostruisce in un istante: regolare
/// quanto la stabilizzazione è decisa non deve costringere a rileggere mille file.
/// </summary>
public SimilarityTransform[]? MotionRelative { get; set; }
public double[]? MotionConfidence { get; set; }
/// <summary>Ricalcola la correzione dai soli spostamenti già misurati.</summary>
public void RebuildStabilizationPath()
{
Motion = Stabilization.Enabled && MotionRelative is not null && MotionConfidence is not null
? Stabilizer.BuildPath(MotionRelative, MotionConfidence, Stabilization)
: null;
}
public bool HasSequence => Sequence is { Count: > 0 };
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>Vero se serve lo stadio geometrico finale, quindi un ricampionamento in più.</summary>
public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled;
/// <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>Azzera tutto ciò che dipende dai pixel: va fatto quando cambia come vengono letti.</summary>
public void InvalidateAnalysis()
{
Curve = null;
Stats = null;
Mask = null;
Transitions = null;
Motion = null;
MotionRelative = null;
MotionConfidence = null;
}
/// <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;
Stabilization.AnalysisWidth = 640;
Stabilization.PatchSize = 64;
Stabilization.Grid = 2;
Regions.AnalysisWidth = 192;
Regions.SampleFrames = 8;
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;
Stabilization.AnalysisWidth = 960;
Stabilization.PatchSize = 128;
Stabilization.Grid = 3;
Regions.AnalysisWidth = 256;
Regions.SampleFrames = 12;
break;
default:
// Il campo di movimento si infittisce e la scia guadagna campioni: sono le
// due voci che si vedono davvero nel fotogramma finale. I riquadri della
// correlazione crescono perché un riquadro più largo contiene più tessitura
// e dà un picco più stretto, quindi una misura più precisa.
General.AnalysisWidth = 2048;
Flow.AnalysisWidth = 1440;
Flow.CellSize = 6;
Flow.PyramidLevels = 5;
Flow.WindowRadius = 7;
Flow.Iterations = 8;
MotionBlur.MaxSamples = 49;
Stabilization.AnalysisWidth = 1440;
Stabilization.PatchSize = 256;
Stabilization.Grid = 3;
Regions.AnalysisWidth = 384;
Regions.SampleFrames = 20;
break;
}
}
// ------------------------------------------------------------------ risoluzioni
/// <summary>Dimensioni native del primo fotogramma, già nell'orientamento finale.</summary>
public (int Width, int Height) ResolveNativeSize()
{
if (Sequence is not { Count: > 0 }) return (0, 0);
var first = Sequence.Frames[0].Metadata;
int orientation = EffectiveOrientation;
int width = first.PixelWidth;
int height = first.PixelHeight;
if (width <= 0 || height <= 0)
{
(width, height) = Imaging.ImageDecoder.ProbeDisplaySize(first.FilePath, orientation);
}
else if (Imaging.ImageDecoder.SwapsAxes(orientation))
{
(width, height) = (height, width);
}
return (width, height);
}
/// <summary>
/// Risoluzione a cui i fotogrammi vengono decodificati.
///
/// Senza stadio geometrico coincide con quella d'uscita, e la catena di decodifica scala
/// direttamente al valore finale: è il percorso più corto e il più nitido. Con una
/// panoramica virtuale o una stabilizzazione attive serve invece la risoluzione nativa,
/// perché il ritaglio deve avere pixel da cui attingere — è tutto il senso del metodo.
/// </summary>
public (int Width, int Height) ResolveSourceSize()
{
if (!NeedsGeometry) return ResolveWorkingSize();
var (nativeWidth, nativeHeight) = ResolveNativeSize();
if (nativeWidth <= 0 || nativeHeight <= 0) return (0, 0);
int width = General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, nativeWidth) : nativeWidth;
int height = (int)Math.Round(width * nativeHeight / (double)nativeWidth);
return (Math.Max(2, width & ~1), Math.Max(2, height & ~1));
}
/// <summary>
/// Risoluzione del video finale: parte dal primo fotogramma, applica l'eventuale limite
/// dell'utente e arrotonda a valori pari, richiesti dal sottocampionamento cromatico 4:2:0.
/// </summary>
public (int Width, int Height) ResolveWorkingSize()
{
var (sourceWidth, sourceHeight) = ResolveNativeSize();
if (sourceWidth <= 0 || sourceHeight <= 0) return (0, 0);
int targetWidth = Export.Width > 0 ? Export.Width
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
: sourceWidth;
double aspect = sourceHeight / (double)sourceWidth;
int targetHeight = Export.Height > 0 ? Export.Height : (int)Math.Round(targetWidth * aspect);
targetWidth = Math.Max(2, targetWidth & ~1);
targetHeight = Math.Max(2, targetHeight & ~1);
// Ultimo vincolo, non negoziabile: un fotogramma oltre i limiti del codec darebbe un
// file che nessun lettore comune apre. Meglio un video leggermente più piccolo che uno
// inutilizzabile; la riduzione viene riportata in interfaccia.
return Video.CodecLimits.Clamp(Export.Codec, targetWidth, targetHeight);
}
/// <summary>
/// Risoluzione che si otterrebbe senza il vincolo di conformità del codec.
/// Serve solo a segnalare all'utente che è stata applicata una riduzione.
/// </summary>
public (int Width, int Height) ResolveRequestedSize()
{
var (width, height) = ResolveWorkingSize();
var (sourceWidth, sourceHeight) = ResolveNativeSize();
if (sourceWidth <= 0 || sourceHeight <= 0) return (width, height);
int requestedWidth = Export.Width > 0 ? Export.Width
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
: sourceWidth;
int requestedHeight = Export.Height > 0
? Export.Height
: (int)Math.Round(requestedWidth * (sourceHeight / (double)sourceWidth));
return (Math.Max(2, requestedWidth & ~1), Math.Max(2, requestedHeight & ~1));
}
/// <summary>
/// Ingrandimento aggiuntivo imposto dalla stabilizzazione: senza di esso i bordi scoperti
/// dalla correzione entrerebbero nell'inquadratura.
/// </summary>
public double StabilizationZoom
{
get
{
if (!Stabilization.Enabled || Motion is null) return 1.0;
var (width, height) = ResolveSourceSize();
if (width <= 0 || height <= 0) return 1.0;
return Motion.RequiredZoom(height / (double)width);
}
}
/// <summary>Inquadratura effettiva a un dato punto della sequenza, vincoli compresi.</summary>
public CameraFraming FramingAt(double normalizedTime)
{
var framing = Camera.Enabled ? VirtualCamera.Resolve(Camera, normalizedTime) : CameraFraming.Full;
return VirtualCamera.Constrain(framing, Camera.KeepInsideFrame, StabilizationZoom);
}
}