Compare commits

...
6 Commits
Author SHA1 Message Date
Alby96 3d51e98ca3 Rimozione del progetto TimeLapseTransfer e dei file di configurazione associati 2026-08-14 17:11:43 +02:00
Alby96andClaude Opus 5 a815a91006 Sei moduli avanzati per la produzione cinematografica
Estende il motore con stabilizzazione sub-pixel, deflicker per regioni,
transizioni giorno-notte, movimento di macchina virtuale, rimappatura non
lineare del tempo e accumulo temporale. Tutto in-house, nessuna dipendenza
aggiunta: il progetto continua a non contenere un solo PackageReference.

Perche' questa forma. Il rendering non percorre piu' la sequenza sorgente ma
un piano di fotogrammi d'uscita, ognuno con posizione anche frazionaria e
durata propria. Le quattro modalita' temporali producono tutte quella stessa
forma, quindi il ciclo di rendering e' uno solo e non ha un ramo per ciascun
caso; da li' discende anche la sfocatura, perche' un fotogramma che copre v
scatti ha angolo di otturatore diviso v.

Gli spostamenti si misurano con la correlazione di fase, che ignora per
costruzione le differenze di luminosita' fra scatti - in un time-lapse ci sono
sempre - e reagisce alla sola geometria. Il picco intero non basta: la
superficie di correlazione viene ricostruita a passo fine valutando la somma
di Fourier sulle posizioni intermedie invece di interpolare con una parabola
tre campioni di una cresta che parabola non e'. L'errore misurato scende da
0,14 a 0,08 px.

I gradini di esposizione non sono rumore da mediare: l'ampiezza si legge
esatta nei metadati e viene ridistribuita su una transizione a derivata nulla
agli estremi. Il deflicker lavora poi su una serie gia' priva di gradini,
invece di trasformare lo scalino in una rampa con due spigoli.

La maschera delle regioni nasce dalla mediana temporale di un campione di
fotogrammi, che toglie di mezzo proprio le nuvole di passaggio, e la linea
d'orizzonte viene agganciata al massimo del gradiente verticale. Sulla scena
di prova il terreno passa da 0,062 a 0,026 stop di oscillazione.

Ritaglio virtuale e correzione di stabilizzazione sono entrambi affini e
vengono composti in una sola trasformazione: due ricampionamenti in fila
costerebbero il doppio di nitidezza senza dare nulla in cambio.

Sulla memoria: i moduli avanzati hanno rotto l'assunto che bastassero due
fotogrammi vivi alla volta, quindi il disco entra ora in gioco - come annotato
nel commit precedente, e' questa la porta che si apriva. La finestra attiva
resta sempre in memoria perche' la mediana ha bisogno di tutti i suoi
fotogrammi insieme; solo la lettura in anticipo viene parcheggiata su disco
oltre il tetto, e ripresa una volta sola. Non esiste un caso in cui lo stesso
fotogramma vada e torni piu' volte. Il file di parcheggio si cancella da se'.

Verifica: da 26 a 51 controlli. Nessuna soglia scelta a posteriori - il
tremolio ha un percorso noto, il gradino un'ampiezza dichiarata nei metadati e
visibile nei pixel, la nuvola attraversa il solo cielo. Il controllo
conclusivo rende una sequenza con tutti i moduli attivi insieme e tetto di
memoria volutamente stretto, poi la rilegge con il lettore di sistema.
Verificato anche sui DNG GoPro reali: 580 file, render di prova conforme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:55:27 +02:00
Alby96andClaude Opus 5 332f02d17d 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>
2026-08-14 15:53:10 +02:00
Alby96andClaude Opus 5 e476ebb648 Correzione dei difetti emersi sui DNG GoPro reali
Provato il programma sulle quattro sequenze in K:\2024 (oltre 3000 scatti DNG
convertiti da GPR con le applicazioni Adobe). Ne sono usciti quattro difetti,
tutti latenti perché la verifica sintetica usa JPEG piccoli.

1. Risoluzione letta dalla miniatura. Nei DNG la IFD0 descrive un'anteprima
   256x192 marcata NewSubfileType=1 e l'immagine vera vive in una SubIFD:
   leggendo ImageWidth dalla prima directory il programma credeva di lavorare
   su 256x192 e avrebbe esportato un video di quelle dimensioni. Ora si sceglie
   la directory dell'immagine principale, preferendo quelle non marcate come
   versione ridotta.

2. Lettura dei metadati lentissima. Si leggevano 4 MiB per file a prescindere:
   179 s per una cartella da 1005 scatti. La finestra sul file ora si estende
   solo quando un offset punta oltre quanto gia' caricato, e di un DNG da 15 MiB
   ne bastano 128 KiB. Stessa cartella: 7,4 s.

3. Decodifica corrotta quando interveniva il ridimensionamento. Il convertitore
   a 48bppRGB precedeva lo scaler; con i codec RAW quell'ordine restituisce
   righe disallineate, immagini a strisce e luminanza sbagliata di due stop,
   senza segnalare alcun errore. Lo scaler ora precede il convertitore, come
   indica Microsoft. I 16 bit per canale restano.

4. Multiplexer che corrompeva il primo fotogramma chiave. Il buffer di
   conversione Annex-B veniva riallocato senza copiare il contenuto: le NAL gia'
   scritte per quel campione diventavano zeri e il campione usciva con un
   prefisso di lunghezza nullo in testa. Il file restava formalmente valido ma
   il lettore di sistema si fermava dopo sedici fotogrammi su quaranta. Con i
   JPEG del test il campione non superava mai la capacita' iniziale, per questo
   non era mai emerso; ora il buffer parte piccolo, cosi' il percorso di crescita
   viene esercitato da qualunque sequenza.

Aggiunto inoltre il vincolo di conformita' dei codec. Una sorgente 4:3 da
4000x3000 supera il Livello 5.2 di H.264: l'encoder hardware la accetta e
dichiara il Livello 6.0, ma i decodificatori comuni non aprono il file. La
risoluzione viene ora ricondotta al massimo riproducibile conservando le
proporzioni, e la riduzione e' dichiarata nella barra di stato invece di
avvenire in silenzio. Le sorgenti 16:9 fino al 4K UHD non sono toccate.

Il campo di movimento non viene piu' calcolato quando non serve: con pose da
30 s su intervalli da 34 s lo shutter angle e' gia' 317 gradi e non c'e'
sfocatura da sintetizzare. Il render della sequenza aurora passa da 1,3 a
8,5 fotogrammi al secondo.

La verifica del motore sale a 24 controlli: si aggiungono l'invarianza della
luminanza alla scala di decodifica e l'allineamento delle NAL dentro ogni
campione, i due invarianti che avrebbero intercettato i difetti 3 e 4. Nuovo
comando --diagnose per esaminare una cartella reale.

Verificato sui file dell'utente: 40 fotogrammi a risoluzione nativa, H.264 e
HEVC, entrambi riletti per intero dal lettore di sistema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:01:35 +02:00
Alby96andClaude Opus 5 5d8798f1f7 Avvio in debug da VS Code e correzione dell'output a riga di comando
Aggiunta la configurazione .vscode allineata a quella di Mimante ed Encelado:
F5 apre la finestra di Titano, una seconda configurazione entra con i breakpoint
nella verifica del motore (--selftest), che è un punto d'ingresso separato e non
raggiungibile dall'interfaccia. Nei task: build, verifica, cattura interfaccia
e pulisci.

La verifica usciva con codice 0 ma senza stampare nulla quando lanciata con
"dotnet run" o con l'output su file. La causa è AttachConsole: l'eseguibile è
un'applicazione a finestre e senza aggancio non si farebbe leggere da un
terminale, ma se lo standard output è già dirottato l'aggancio lo sostituisce e
i messaggi finiscono nella console invece che nella pipe. Ora l'aggancio avviene
solo quando l'output non è già dirottato. Verificato nei tre casi: eseguibile
diretto, dotnet run e redirezione su file, 55 righe in tutti e tre.

Svuotato Modifiche.txt: il file è dell'utente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:51:39 +02:00
Alby96andClaude Opus 5 cc1d040ac0 Titano: motore e interfaccia per time-lapse, senza dipendenze di terze parti
Applicazione desktop completa per creazione e ottimizzazione di time-lapse.
Il vincolo che ne definisce l'architettura è l'assenza totale di componenti
di terze parti: il progetto non ha alcun PackageReference e non invoca processi
esterni. Oltre alla libreria standard di .NET si usano solo API native di
Windows (WIC, Media Foundation, GDI+, DWM) richiamate via P/Invoke scritto a mano.

Sono in-house tutte le parti che di norma si delegherebbero a una libreria:
il parser binario EXIF/XMP, la misura di luminanza e la curva di deflicker,
il calcolo del campo vettoriale di movimento con il motion blur sintetico,
il multiplexer MP4 e ogni controllo dell'interfaccia.

Scelte algoritmiche che meritano una nota:
- il deflicker usa una regressione lineare locale pesata con seconda passata
  robusta, così le rampe reali di luce (alba, tramonto) sopravvivono mentre
  lo sfarfallio del diaframma viene rimosso; una media mobile semplice le
  appiattirebbe entrambe;
- la sfocatura mancante si compone in quadratura con quella già incisa nello
  scatto, perché sommarla linearmente renderebbe l'immagine troppo morbida;
- la luminanza si misura come media logaritmica troncata, invariante alla
  scala e insensibile a cieli bruciati e ombre chiuse.

L'elaborazione non produce file temporanei e mantiene un'occupazione di memoria
stazionaria: buffer poolati e canale a capacità limitata rendono i fotogrammi
vivi indipendenti dalla lunghezza della sequenza.

Verificato con "Titano.exe --selftest": 23 controlli su una sequenza sintetica
dalle proprietà note, incluse la struttura del contenitore prodotto e la sua
ri-decodifica con il lettore di sistema. Tutti superati.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:40:26 +02:00
82 changed files with 16437 additions and 717 deletions
-25
View File
@@ -1,25 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35312.102
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TimeLapseTransfer", "TimeLapseTransfer\TimeLapseTransfer.csproj", "{35512498-B08F-4177-A4C4-72ECFD2C356B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{35512498-B08F-4177-A4C4-72ECFD2C356B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{35512498-B08F-4177-A4C4-72ECFD2C356B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35512498-B08F-4177-A4C4-72ECFD2C356B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35512498-B08F-4177-A4C4-72ECFD2C356B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B354D1A3-AD35-48BC-BD7C-37EEFD1E4995}
EndGlobalSection
EndGlobal
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8.1" />
</startup>
</configuration>
-16
View File
@@ -1,16 +0,0 @@
namespace TimeLapseTransfer
{
partial class Main
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
}
}
-187
View File
@@ -1,187 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace TimeLapseTransfer
{
public partial class Main : Form
{
public Main()
{
InitializeComponent();
}
private void InitializeComponent()
{
this.sourceTextBox = new System.Windows.Forms.TextBox();
this.destinationTextBox = new System.Windows.Forms.TextBox();
this.sourceButton = new System.Windows.Forms.Button();
this.destinationButton = new System.Windows.Forms.Button();
this.startButton = new System.Windows.Forms.Button();
this.progressBar = new System.Windows.Forms.ProgressBar();
this.sourceLabel = new System.Windows.Forms.Label();
this.destinationLabel = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// sourceTextBox
//
this.sourceTextBox.Location = new System.Drawing.Point(89, 12);
this.sourceTextBox.Name = "sourceTextBox";
this.sourceTextBox.Size = new System.Drawing.Size(411, 20);
this.sourceTextBox.TabIndex = 0;
//
// destinationTextBox
//
this.destinationTextBox.Location = new System.Drawing.Point(89, 38);
this.destinationTextBox.Name = "destinationTextBox";
this.destinationTextBox.Size = new System.Drawing.Size(411, 20);
this.destinationTextBox.TabIndex = 1;
//
// sourceButton
//
this.sourceButton.Location = new System.Drawing.Point(506, 10);
this.sourceButton.Name = "sourceButton";
this.sourceButton.Size = new System.Drawing.Size(75, 23);
this.sourceButton.TabIndex = 2;
this.sourceButton.Text = "Sfoglia...";
this.sourceButton.UseVisualStyleBackColor = true;
this.sourceButton.Click += new System.EventHandler(this.SourceButton_Click);
//
// destinationButton
//
this.destinationButton.Location = new System.Drawing.Point(506, 36);
this.destinationButton.Name = "destinationButton";
this.destinationButton.Size = new System.Drawing.Size(75, 23);
this.destinationButton.TabIndex = 3;
this.destinationButton.Text = "Sfoglia...";
this.destinationButton.UseVisualStyleBackColor = true;
this.destinationButton.Click += new System.EventHandler(this.DestinationButton_Click);
//
// startButton
//
this.startButton.Location = new System.Drawing.Point(506, 65);
this.startButton.Name = "startButton";
this.startButton.Size = new System.Drawing.Size(75, 23);
this.startButton.TabIndex = 4;
this.startButton.Text = "Avvia";
this.startButton.UseVisualStyleBackColor = true;
this.startButton.Click += new System.EventHandler(this.StartButton_Click);
//
// progressBar
//
this.progressBar.Location = new System.Drawing.Point(89, 65);
this.progressBar.Name = "progressBar";
this.progressBar.Size = new System.Drawing.Size(411, 23);
this.progressBar.TabIndex = 5;
//
// sourceLabel
//
this.sourceLabel.AutoSize = true;
this.sourceLabel.Location = new System.Drawing.Point(40, 15);
this.sourceLabel.Name = "sourceLabel";
this.sourceLabel.Size = new System.Drawing.Size(43, 13);
this.sourceLabel.TabIndex = 6;
this.sourceLabel.Text = "Origine:";
//
// destinationLabel
//
this.destinationLabel.AutoSize = true;
this.destinationLabel.Location = new System.Drawing.Point(12, 41);
this.destinationLabel.Name = "destinationLabel";
this.destinationLabel.Size = new System.Drawing.Size(71, 13);
this.destinationLabel.TabIndex = 7;
this.destinationLabel.Text = "Destinazione:";
//
// Main
//
this.ClientSize = new System.Drawing.Size(600, 100);
this.Controls.Add(this.destinationLabel);
this.Controls.Add(this.sourceLabel);
this.Controls.Add(this.progressBar);
this.Controls.Add(this.startButton);
this.Controls.Add(this.destinationButton);
this.Controls.Add(this.sourceButton);
this.Controls.Add(this.destinationTextBox);
this.Controls.Add(this.sourceTextBox);
this.Name = "Main";
this.Text = "Seleziona Percorsi";
this.ResumeLayout(false);
this.PerformLayout();
}
private void SourceButton_Click(object sender, EventArgs e)
{
using (OpenFileDialog openFileDialog = new OpenFileDialog())
{
if (openFileDialog.ShowDialog() == DialogResult.OK)
{
sourceTextBox.Text = openFileDialog.FileName;
}
}
}
private void DestinationButton_Click(object sender, EventArgs e)
{
using (FolderBrowserDialog folderBrowserDialog = new FolderBrowserDialog())
{
if (folderBrowserDialog.ShowDialog() == DialogResult.OK)
{
destinationTextBox.Text = folderBrowserDialog.SelectedPath;
}
}
}
private void StartButton_Click(object sender, EventArgs e)
{
string sourcePath = sourceTextBox.Text;
string destinationPath = destinationTextBox.Text;
if (string.IsNullOrEmpty(sourcePath) || string.IsNullOrEmpty(destinationPath))
{
MessageBox.Show("Per favore, seleziona sia il percorso di origine che quello di destinazione.");
return;
}
try
{
string[] files = Directory.GetFiles(sourcePath, "*.*")
.Where(file => file.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
file.EndsWith(".gpr", StringComparison.OrdinalIgnoreCase))
.ToArray();
progressBar.Maximum = files.Length;
progressBar.Value = 0;
foreach (string file in files)
{
string fileName = Path.GetFileName(file);
string destFile = Path.Combine(destinationPath, fileName);
File.Move(file, destFile);
progressBar.Value += 1;
}
MessageBox.Show("Trasferimento completato!");
}
catch (Exception ex)
{
MessageBox.Show($"Errore durante il trasferimento: {ex.Message}");
}
}
private System.Windows.Forms.TextBox sourceTextBox;
private System.Windows.Forms.TextBox destinationTextBox;
private System.Windows.Forms.Button sourceButton;
private System.Windows.Forms.Button destinationButton;
private System.Windows.Forms.Button startButton;
private System.Windows.Forms.ProgressBar progressBar;
private System.Windows.Forms.Label sourceLabel;
private System.Windows.Forms.Label destinationLabel;
}
}
@@ -1,120 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
@@ -1,22 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace TimeLapseTransfer
{
internal static class Program
{
/// <summary>
/// Punto di ingresso principale dell'applicazione.
/// </summary>
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Main());
}
}
}
@@ -1,33 +0,0 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// Le informazioni generali relative a un assembly sono controllate dal seguente
// set di attributi. Modificare i valori di questi attributi per modificare le informazioni
// associate a un assembly.
[assembly: AssemblyTitle("TimeLapseTransfer")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("TimeLapseTransfer")]
[assembly: AssemblyCopyright("Copyright © 2024")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Se si imposta ComVisible su false, i tipi in questo assembly non saranno visibili
// ai componenti COM. Se è necessario accedere a un tipo in questo assembly da
// COM, impostare su true l'attributo ComVisible per tale tipo.
[assembly: ComVisible(false)]
// Se il progetto viene esposto a COM, il GUID seguente verrà utilizzato come ID della libreria dei tipi
[assembly: Guid("35512498-b08f-4177-a4c4-72ecfd2c356b")]
// Le informazioni sulla versione di un assembly sono costituite dai seguenti quattro valori:
//
// Versione principale
// Versione secondaria
// Numero di build
// Revisione
//
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
@@ -1,71 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// Codice generato da uno strumento.
// Versione runtime:4.0.30319.42000
//
// Le modifiche apportate a questo file possono causare un comportamento non corretto e andranno perse se
// il codice viene rigenerato.
// </auto-generated>
//------------------------------------------------------------------------------
namespace TimeLapseTransfer.Properties
{
/// <summary>
/// Classe di risorse fortemente tipizzata per la ricerca di stringhe localizzate e così via.
/// </summary>
// Questa classe è stata generata automaticamente dalla classe StronglyTypedResourceBuilder
// tramite uno strumento quale ResGen o Visual Studio.
// Per aggiungere o rimuovere un membro, modificare il file .ResX, quindi eseguire di nuovo ResGen
// con l'opzione /str oppure ricompilare il progetto VS.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources
{
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources()
{
}
/// <summary>
/// Restituisce l'istanza di ResourceManager memorizzata nella cache e usata da questa classe.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager
{
get
{
if ((resourceMan == null))
{
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("TimeLapseTransfer.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Esegue l'override della proprietà CurrentUICulture del thread corrente per tutte
/// le ricerche di risorse che utilizzano questa classe di risorse fortemente tipizzata.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture
{
get
{
return resourceCulture;
}
set
{
resourceCulture = value;
}
}
}
}
@@ -1,117 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
@@ -1,30 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace TimeLapseTransfer.Properties
{
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
{
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default
{
get
{
return defaultInstance;
}
}
}
}
@@ -1,7 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings />
</SettingsFile>
@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{35512498-B08F-4177-A4C4-72ECFD2C356B}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>TimeLapseTransfer</RootNamespace>
<AssemblyName>TimeLapseTransfer</AssemblyName>
<TargetFrameworkVersion>v4.8.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Deployment" />
<Reference Include="System.Drawing" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Main.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Main.Designer.cs">
<DependentUpon>Main.cs</DependentUpon>
</Compile>
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<EmbeddedResource Include="Main.resx">
<DependentUpon>Main.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<SubType>Designer</SubType>
</EmbeddedResource>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<None Include="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
<Compile Include="Properties\Settings.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
<DesignTimeSharedInput>True</DesignTimeSharedInput>
</Compile>
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
// Fornisce il debugger "coreclr" richiesto da launch.json.
"ms-dotnettools.csharp",
"ms-dotnettools.csdevkit"
]
}
+43
View File
@@ -0,0 +1,43 @@
{
// Titano è un'applicazione desktop: F5 apre la stessa finestra che si ottiene
// facendo doppio clic su Titano.exe. La seconda configurazione esiste perché la
// verifica del motore NON è raggiungibile da quella finestra — è un punto
// d'ingresso a sé (--selftest), e volerci mettere un breakpoint dentro è la
// ragione più frequente per aprire il debugger su questo progetto.
"version": "0.2.0",
"configurations": [
{
"name": "Titano",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/bin/Debug/net10.0-windows/Titano.exe",
"args": [],
"cwd": "${workspaceFolder}/bin/Debug/net10.0-windows",
"console": "internalConsole",
"stopAtEntry": false,
"enableStepFiltering": true
},
{
// Terminale integrato e non console interna: --selftest scrive con
// AttachConsole sulla console del processo padre, che nella console interna
// non esiste. Nel terminale l'esito delle 23 verifiche si legge davvero.
"name": "Titano — verifica del motore",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/bin/Debug/net10.0-windows/Titano.exe",
"args": ["--selftest"],
"cwd": "${workspaceFolder}/bin/Debug/net10.0-windows",
"console": "integratedTerminal",
"stopAtEntry": false,
"enableStepFiltering": true
},
{
"name": "Attach a Titano (processo già avviato)",
"type": "coreclr",
"request": "attach",
"processName": "Titano.exe"
}
]
}
+95
View File
@@ -0,0 +1,95 @@
{
// Stessa impostazione di Mimante ed Encelado: attività "process" che chiamano
// dotnet, nomi in italiano, $msCompile per portare gli errori nel pannello
// Problemi. Titano non ha una catena MSBuild dedicata perché non ne ha bisogno:
// è un solo progetto, senza pacchetti da ripristinare e senza installatore.
"version": "2.0.0",
"tasks": [
{
"label": "build",
"detail": "Compilazione di debug, per F5 e per il controllo rapido degli errori.",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Titano.slnx",
"-c",
"Debug",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": { "kind": "build", "isDefault": true },
"problemMatcher": "$msCompile"
},
{
"label": "verifica",
"detail": "Genera una sequenza sintetica e la fa attraversare tutta la pipeline: 23 controlli, encoder e contenitore compresi. Da eseguire dopo ogni modifica.",
// In Release e non in Debug: le stesse verifiche passano da ~47 s a ~6 s,
// perché i cicli in virgola mobile di flow e blur non sono ottimizzati in Debug.
"type": "process",
"command": "dotnet",
"args": [
"run",
"--project",
"${workspaceFolder}/Titano.csproj",
"-c",
"Release",
"--",
"--selftest"
],
"group": { "kind": "test", "isDefault": true },
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": "$msCompile"
},
{
"label": "cattura interfaccia",
"detail": "Salva un'immagine della finestra in bin/interfaccia.png. Serve a controllare la resa dei controlli disegnati a mano senza doverli guardare a occhio.",
"type": "process",
"command": "dotnet",
"args": [
"run",
"--project",
"${workspaceFolder}/Titano.csproj",
"-c",
"Release",
"--",
"--capture",
"${workspaceFolder}/bin/interfaccia.png",
"${input:sequenza}",
"${input:scheda}"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": []
},
{
"label": "pulisci",
"detail": "Rimuove bin e obj di entrambe le configurazioni.",
"type": "process",
"command": "dotnet",
"args": ["clean", "${workspaceFolder}/Titano.slnx", "-v", "m"],
"problemMatcher": []
}
],
"inputs": [
{
"id": "sequenza",
"type": "promptString",
// Il valore predefinito è la sequenza lasciata sul disco dal task "verifica":
// eseguito quello, la cattura ha già qualcosa di sensato da mostrare.
// Lasciando il campo vuoto si cattura la finestra senza sequenza caricata.
"description": "Cartella della sequenza da caricare (vuoto = finestra vuota)",
"default": "${env:TEMP}\\Titano.SelfTest\\sequenza"
},
{
"id": "scheda",
"type": "pickString",
"description": "Quale sezione del pannello di configurazione mostrare",
"options": [
{ "label": "Generale", "value": "0" },
{ "label": "Elaborazione immagini", "value": "1" },
{ "label": "Esportazione", "value": "2" }
],
"default": "0"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
namespace Titano.Analysis;
/// <summary>
/// Conversioni colorimetriche in-house: dal terzetto RGB in luce lineare alle coordinate
/// cromatiche e alla temperatura di colore correlata.
///
/// Servono a dare un nome alla deriva cromatica di una sequenza. Dire "il bianco si è
/// spostato di 0,3 stop sul rosso" non aiuta nessuno; dire "da 5400 K a 3100 K" descrive
/// esattamente ciò che l'occhio vede accadere durante un tramonto, e permette di decidere
/// quanta parte di quel viaggio va conservata e quanta era solo il bilanciamento automatico
/// della macchina che cambiava idea da uno scatto all'altro.
/// </summary>
public static class ColorScience
{
/// <summary>
/// Coordinate cromatiche CIE 1931 dal terzetto lineare Rec.709 / sRGB.
/// La matrice è quella delle primarie sRGB con bianco D65.
/// </summary>
public static (double X, double Y) Chromaticity(double r, double g, double b)
{
r = Math.Max(r, 0);
g = Math.Max(g, 0);
b = Math.Max(b, 0);
double capitalX = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
double capitalY = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
double capitalZ = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b;
double sum = capitalX + capitalY + capitalZ;
if (sum <= 1e-12) return (0.3127, 0.3290); // ripiego sul bianco D65
return (capitalX / sum, capitalY / sum);
}
/// <summary>
/// Temperatura di colore correlata con l'approssimazione di McCamy: il punto di
/// convergenza delle rette isotermiche sta molto vicino a (0,3320; 0,1858) e l'angolo
/// visto da lì determina la temperatura. Vale con buona precisione fra 2000 e 25000 K,
/// che copre tutto ciò che si incontra fra una candela e un cielo di mezzogiorno.
/// </summary>
public static double CorrelatedColorTemperature(double r, double g, double b)
{
var (x, y) = Chromaticity(r, g, b);
double denominator = 0.1858 - y;
if (Math.Abs(denominator) < 1e-9) return double.NaN;
double n = (x - 0.3320) / denominator;
double cct = 449 * n * n * n + 3525 * n * n + 6823.3 * n + 5520.33;
return double.IsFinite(cct) ? Math.Clamp(cct, 1000, 40000) : double.NaN;
}
/// <summary>
/// Deviazione verde-magenta, in stop: eccesso di verde rispetto alla media di rosso e blu.
/// È la grandezza che il motore corregge davvero, quindi viene riportata così com'è invece
/// di essere tradotta in una scala convenzionale.
/// </summary>
public static double GreenTintStops(double log2R, double log2G, double log2B)
=> log2G - (log2R + log2B) * 0.5;
/// <summary>Descrizione compatta di una temperatura, per l'interfaccia e la diagnostica.</summary>
public static string Describe(double kelvin)
=> double.IsNaN(kelvin) ? "—" : $"{kelvin:0} K";
}
+297
View File
@@ -0,0 +1,297 @@
namespace Titano.Analysis;
/// <summary>Parametri del motore di deflicker, esposti nel pannello "Elaborazione immagini".</summary>
public sealed class DeflickerSettings
{
public bool Enabled { get; set; } = true;
/// <summary>Ampiezza della finestra mobile in fotogrammi (viene resa dispari internamente).</summary>
public int WindowFrames { get; set; } = 15;
/// <summary>Quota della correzione applicata: 1 = curva target piena, 0 = nessuna correzione.</summary>
public double Strength { get; set; } = 1.0;
/// <summary>Limite di sicurezza della correzione, in stop.</summary>
public double MaxCorrectionStops { get; set; } = 1.5;
/// <summary>Scarta i fotogrammi anomali (lampi, passaggi di persone) dal calcolo della curva.</summary>
public bool RejectOutliers { get; set; } = true;
/// <summary>Applica lo stesso smoothing ai singoli canali per stabilizzare il bilanciamento colore.</summary>
public bool StabilizeColor { get; set; }
/// <summary>Comprime dolcemente le alte luci quando il guadagno è maggiore di 1.</summary>
public bool ProtectHighlights { get; set; } = true;
/// <summary>Punto d'innesco della compressione, in luce lineare.</summary>
public double HighlightKnee { get; set; } = 0.75;
public DeflickerSettings Clone() => (DeflickerSettings)MemberwiseClone();
}
/// <summary>Risultato del calcolo della curva: valori per fotogramma, in log2.</summary>
public sealed class DeflickerCurve
{
public required double[] Measured { get; init; }
/// <summary>Luminanza che il fotogramma avrà dopo la correzione: è la curva obiettivo.</summary>
public required double[] Target { get; init; }
public required double[] GainStops { get; init; }
/// <summary>Guadagni per canale (R,G,B); pari a quello di luminanza se la stabilizzazione colore è spenta.</summary>
public required double[][] ChannelGain { get; init; }
/// <summary>Guadagni della prima regione (cielo); null quando il deflicker è globale.</summary>
public double[][]? ChannelGainHigh { get; init; }
/// <summary>Guadagni della seconda regione (paesaggio); null quando il deflicker è globale.</summary>
public double[][]? ChannelGainLow { get; init; }
public double[]? MeasuredHigh { get; init; }
public double[]? MeasuredLow { get; init; }
public double[]? TargetHigh { get; init; }
public double[]? TargetLow { get; init; }
/// <summary>Quota di correzione dovuta all'ammorbidimento dei salti di esposizione.</summary>
public double[]? StepGainStops { get; init; }
public bool HasRegions => ChannelGainHigh is not null && ChannelGainLow is not null;
public int Count => Measured.Length;
/// <summary>Deviazione standard delle differenze fra fotogrammi adiacenti, in stop: misura lo sfarfallio.</summary>
public static double FlickerIndex(IReadOnlyList<double> log2Series)
{
if (log2Series.Count < 3) return 0;
double mean = 0;
int n = log2Series.Count - 1;
var deltas = new double[n];
for (int i = 0; i < n; i++)
{
deltas[i] = log2Series[i + 1] - log2Series[i];
mean += deltas[i];
}
mean /= n;
double variance = 0;
for (int i = 0; i < n; i++)
{
double d = deltas[i] - mean;
variance += d * d;
}
return Math.Sqrt(variance / n);
}
}
/// <summary>
/// Algoritmo proprietario di smoothing temporale dell'esposizione.
///
/// Per ogni fotogramma si esegue una regressione lineare locale pesata sulla finestra mobile:
/// i pesi combinano una gaussiana sulla distanza temporale e, in seconda passata, un peso di
/// robustezza di Tukey che neutralizza i fotogrammi anomali. Rispetto a una media mobile
/// semplice, la componente lineare segue senza ritardo le rampe reali di luce (alba, tramonto)
/// e rimuove solo la componente ad alta frequenza generata dalle micro-variazioni di diaframma.
///
/// La correzione finale si compone di tre contributi indipendenti, che vivono su scale
/// temporali diverse e non si disturbano a vicenda:
/// lo sfarfallio, veloce, che la regressione toglie; i salti di impostazione della macchina,
/// istantanei, che l'analisi Holy Grail riconosce e ridistribuisce; la deriva cromatica,
/// lenta, che viene lisciata sui rapporti fra canali invece che sulla luminanza.
/// A ciò si aggiunge, quando la scena lo consente, la separazione in regioni: cielo e
/// paesaggio ricevono curve proprie, così una nuvola di passaggio non muove il terreno.
/// </summary>
public static class DeflickerEngine
{
public static DeflickerCurve Compute(IReadOnlyList<LuminanceStats> stats, DeflickerSettings settings)
=> Compute(stats, settings, null, null, null);
public static DeflickerCurve Compute(IReadOnlyList<LuminanceStats> stats, DeflickerSettings settings,
RegionSettings? regions, HolyGrailAnalysis? holyGrail,
HolyGrailSettings? holyGrailSettings)
{
int n = stats.Count;
var measured = new double[n];
for (int i = 0; i < n; i++) measured[i] = stats[i].Log2Average;
var staircase = holyGrail?.Staircase ?? new double[n];
var smoothStaircase = holyGrail?.SmoothStaircase ?? new double[n];
var stepGain = holyGrail?.StepGainStops ?? new double[n];
double limit = Math.Max(0, settings.MaxCorrectionStops);
double strength = Math.Clamp(settings.Strength, 0, 1);
var (target, gainStops) = FitSeries(measured, staircase, stepGain, settings, limit, strength);
// ---- regioni indipendenti
bool useRegions = regions is { Mode: not RegionMode.Off } && n > 0 && HasRegionData(stats);
double[][]? channelGainHigh = null;
double[][]? channelGainLow = null;
double[]? measuredHigh = null, measuredLow = null, targetHigh = null, targetLow = null;
double[]? gainHigh = null, gainLow = null;
if (useRegions)
{
measuredHigh = new double[n];
measuredLow = new double[n];
for (int i = 0; i < n; i++)
{
measuredHigh[i] = stats[i].Log2High;
measuredLow[i] = stats[i].Log2Low;
}
(targetHigh, gainHigh) = FitSeries(measuredHigh, staircase, stepGain, settings, limit, strength);
(targetLow, gainLow) = FitSeries(measuredLow, staircase, stepGain, settings, limit, strength);
// L'indipendenza governa quanto ciascuna regione può allontanarsi dalla correzione
// comune: al massimo le due parti dell'immagine vivono di vita propria, al minimo
// si comportano come un fotogramma solo.
double independence = Math.Clamp(regions!.Independence, 0, 1);
for (int i = 0; i < n; i++)
{
gainHigh[i] = gainStops[i] + (gainHigh[i] - gainStops[i]) * independence;
gainLow[i] = gainStops[i] + (gainLow[i] - gainStops[i]) * independence;
}
}
// ---- deriva cromatica
var colorDelta = ComputeColorDelta(stats, settings, holyGrailSettings, limit, strength);
var channelGain = BuildChannelGain(gainStops, colorDelta, settings.Enabled);
if (useRegions)
{
channelGainHigh = BuildChannelGain(gainHigh!, colorDelta, settings.Enabled);
channelGainLow = BuildChannelGain(gainLow!, colorDelta, settings.Enabled);
}
// La curva obiettivo mostrata è la luminanza che il fotogramma avrà davvero:
// la regressione sulla serie senza gradini, più la scalinata resa morbida.
for (int i = 0; i < n; i++)
{
target[i] += smoothStaircase[i];
if (targetHigh is not null) targetHigh[i] += smoothStaircase[i];
if (targetLow is not null) targetLow[i] += smoothStaircase[i];
}
return new DeflickerCurve
{
Measured = measured,
Target = target,
GainStops = gainStops,
ChannelGain = channelGain,
ChannelGainHigh = channelGainHigh,
ChannelGainLow = channelGainLow,
MeasuredHigh = measuredHigh,
MeasuredLow = measuredLow,
TargetHigh = targetHigh,
TargetLow = targetLow,
StepGainStops = holyGrail is null ? null : stepGain,
};
}
private static bool HasRegionData(IReadOnlyList<LuminanceStats> stats)
{
foreach (var s in stats)
{
if (s.HasRegions) return true;
}
return false;
}
/// <summary>
/// Liscia una serie di luminanza dopo averne tolto i gradini deliberati e restituisce la
/// curva obiettivo insieme al guadagno da applicare.
/// </summary>
private static (double[] Target, double[] Gain) FitSeries(double[] measured, double[] staircase,
double[] stepGain, DeflickerSettings settings,
double limit, double strength)
{
int n = measured.Length;
var neutral = new double[n];
for (int i = 0; i < n; i++) neutral[i] = measured[i] - staircase[i];
var target = Smooth(neutral, settings);
var gain = new double[n];
for (int i = 0; i < n; i++)
{
if (!settings.Enabled) { gain[i] = 0; continue; }
gain[i] = Math.Clamp((target[i] - neutral[i]) * strength, -limit, limit) + stepGain[i];
}
return (target, gain);
}
/// <summary>
/// Correzione cromatica per canale, in stop. Si liscia il rapporto fra i canali invece
/// della loro luminanza assoluta: così la correzione tocca il colore e lascia intatta
/// l'esposizione, che ha già la sua curva. Il verde fa da riferimento perché è il canale
/// che porta quasi tutta la luminanza.
/// </summary>
private static double[][]? ComputeColorDelta(IReadOnlyList<LuminanceStats> stats, DeflickerSettings settings,
HolyGrailSettings? holyGrail, double limit, double strength)
{
int n = stats.Count;
if (n == 0) return null;
bool viaHolyGrail = holyGrail is { Enabled: true, SmoothColor: true };
if (!viaHolyGrail && !settings.StabilizeColor) return null;
int window = viaHolyGrail ? holyGrail!.ColorWindowFrames : settings.WindowFrames;
double colorStrength = viaHolyGrail ? Math.Clamp(holyGrail!.ColorStrength, 0, 1) : strength;
double colorLimit = viaHolyGrail ? Math.Max(0, holyGrail!.MaxColorShiftStops) : limit;
var red = new double[n];
var blue = new double[n];
for (int i = 0; i < n; i++)
{
red[i] = stats[i].Log2AverageR - stats[i].Log2AverageG;
blue[i] = stats[i].Log2AverageB - stats[i].Log2AverageG;
}
var redTarget = Core.LocalRegression.Smooth(red, window, settings.RejectOutliers);
var blueTarget = Core.LocalRegression.Smooth(blue, window, settings.RejectOutliers);
var delta = new double[n][];
for (int i = 0; i < n; i++)
{
delta[i] =
[
Math.Clamp((redTarget[i] - red[i]) * colorStrength, -colorLimit, colorLimit),
0,
Math.Clamp((blueTarget[i] - blue[i]) * colorStrength, -colorLimit, colorLimit),
];
}
return delta;
}
private static double[][] BuildChannelGain(double[] gainStops, double[][]? colorDelta, bool enabled)
{
int n = gainStops.Length;
var result = new double[n][];
for (int i = 0; i < n; i++)
{
if (!enabled)
{
result[i] = [1, 1, 1];
continue;
}
double gain = gainStops[i];
result[i] = colorDelta is null
? [Math.Pow(2, gain), Math.Pow(2, gain), Math.Pow(2, gain)]
:
[
Math.Pow(2, gain + colorDelta[i][0]),
Math.Pow(2, gain + colorDelta[i][1]),
Math.Pow(2, gain + colorDelta[i][2]),
];
}
return result;
}
/// <summary>Regressione lineare locale pesata, con seconda passata robusta agli outlier.</summary>
public static double[] Smooth(double[] series, DeflickerSettings settings)
=> Core.LocalRegression.Smooth(series, settings.WindowFrames, settings.RejectOutliers);
}
+177
View File
@@ -0,0 +1,177 @@
using System.Numerics;
using Titano.Imaging;
namespace Titano.Analysis;
/// <summary>
/// Applicazione dei guadagni di esposizione sui canali colore.
///
/// L'operazione avviene in luce lineare (moltiplicazione pura, fisicamente corretta) e,
/// se richiesto, con una compressione dolce delle alte luci: la parte di segnale che
/// supererebbe il bianco viene ripiegata con una tangente iperbolica invece di essere
/// troncata, evitando le classiche macchie piatte e le derive di tinta sui bordi bruciati.
/// </summary>
public static class ExposureProcessor
{
/// <summary>
/// Moltiplica il buffer per i tre guadagni indicati, in-place.
/// Restituisce la frazione stimata di campioni saturati dopo la correzione.
/// </summary>
public static double Apply(ImageBuffer frame, ReadOnlySpan<double> channelGain,
bool protectHighlights, double knee)
{
float gr = (float)channelGain[0];
float gg = (float)channelGain[1];
float gb = (float)channelGain[2];
bool identity = Math.Abs(gr - 1f) < 1e-5f && Math.Abs(gg - 1f) < 1e-5f && Math.Abs(gb - 1f) < 1e-5f;
bool rolloff = protectHighlights && Math.Max(gr, Math.Max(gg, gb)) > 1.0001f;
var data = frame.Data;
int count = frame.SampleCount;
if (!identity)
{
if (rolloff) ApplyWithRolloff(data, count, gr, gg, gb, (float)Math.Clamp(knee, 0.05, 0.98));
else ApplyLinear(data, count, gr, gg, gb);
}
return MeasureClipping(data, count);
}
/// <summary>
/// Applica due terne di guadagni miscelate secondo la maschera delle regioni.
///
/// La miscela avviene in scala logaritmica: fra un guadagno di 1× e uno di 4×, il valore
/// intermedio è 2×, non 2,5×. È l'unica interpolazione che produce una transizione
/// visivamente uniforme lungo la sfumatura fra cielo e paesaggio; in scala lineare il
/// passaggio si addenserebbe verso il guadagno più alto e si vedrebbe come una banda.
/// </summary>
public static double ApplyRegional(ImageBuffer frame, RegionMask mask,
ReadOnlySpan<double> gainHigh, ReadOnlySpan<double> gainLow,
bool protectHighlights, double knee)
{
Span<float> logHigh = stackalloc float[ImageBuffer.Channels];
Span<float> logLow = stackalloc float[ImageBuffer.Channels];
float maxGain = 0;
for (int c = 0; c < ImageBuffer.Channels; c++)
{
logHigh[c] = (float)Math.Log2(Math.Max(gainHigh[c], 1e-6));
logLow[c] = (float)Math.Log2(Math.Max(gainLow[c], 1e-6));
maxGain = Math.Max(maxGain, (float)Math.Max(gainHigh[c], gainLow[c]));
}
bool rolloff = protectHighlights && maxGain > 1.0001f;
float kneeValue = (float)Math.Clamp(knee, 0.05, 0.98);
float span = 1f - kneeValue;
int width = frame.Width;
int height = frame.Height;
var data = frame.Data;
float invWidth = width > 1 ? 1f / (width - 1) : 0f;
float invHeight = height > 1 ? 1f / (height - 1) : 0f;
// Il compilatore non lascia usare uno Span dentro una lambda: le tre coppie di
// esponenti passano quindi per variabili locali, che è anche la forma più veloce.
float hR = logHigh[0], hG = logHigh[1], hB = logHigh[2];
float lR = logLow[0], lG = logLow[1], lB = logLow[2];
Parallel.For(0, height, y =>
{
int rowBase = y * width * ImageBuffer.Channels;
float v = y * invHeight;
for (int x = 0; x < width; x++)
{
float weight = mask.Sample(x * invWidth, v);
float gr = Exp2(lR + (hR - lR) * weight);
float gg = Exp2(lG + (hG - lG) * weight);
float gb = Exp2(lB + (hB - lB) * weight);
int index = rowBase + x * ImageBuffer.Channels;
data[index] = Compress(data[index] * gr, rolloff, kneeValue, span);
data[index + 1] = Compress(data[index + 1] * gg, rolloff, kneeValue, span);
data[index + 2] = Compress(data[index + 2] * gb, rolloff, kneeValue, span);
}
});
return MeasureClipping(data, frame.SampleCount);
}
private static float Exp2(float value) => MathF.Pow(2f, value);
private static float Compress(float value, bool rolloff, float knee, float span)
=> !rolloff || value <= knee ? value : knee + span * MathF.Tanh((value - knee) / span);
private static void ApplyLinear(float[] data, int count, float gr, float gg, float gb)
{
int width = Vector<float>.Count;
if (width >= 4 && count >= width * 3)
{
// I guadagni si ripetono ogni 3 campioni: con vettori di larghezza non multipla di 3
// il pattern si richiude su 3 vettori consecutivi (lcm(3, width) / width == 3).
var phases = BuildGainPhases(gr, gg, gb, width, out int phaseCount);
int blocks = count / width;
int i = 0;
for (int block = 0; block < blocks; block++, i += width)
{
var v = new Vector<float>(data, i);
(v * phases[block % phaseCount]).CopyTo(data, i);
}
for (; i < count; i++) data[i] *= GainFor(i, gr, gg, gb);
return;
}
for (int i = 0; i < count; i++) data[i] *= GainFor(i, gr, gg, gb);
}
private static void ApplyWithRolloff(float[] data, int count, float gr, float gg, float gb, float knee)
{
float span = 1f - knee;
for (int i = 0; i < count; i++)
{
float v = data[i] * GainFor(i, gr, gg, gb);
data[i] = v <= knee ? v : knee + span * MathF.Tanh((v - knee) / span);
}
}
private static Vector<float>[] BuildGainPhases(float gr, float gg, float gb, int width, out int phaseCount)
{
phaseCount = width % 3 == 0 ? 1 : 3;
var phases = new Vector<float>[phaseCount];
var scratch = new float[width];
for (int phase = 0; phase < phaseCount; phase++)
{
for (int k = 0; k < width; k++)
{
scratch[k] = GainFor(phase * width + k, gr, gg, gb);
}
phases[phase] = new Vector<float>(scratch);
}
return phases;
}
private static float GainFor(int sampleIndex, float gr, float gg, float gb)
=> (sampleIndex % 3) switch { 0 => gr, 1 => gg, _ => gb };
/// <summary>Stima del clipping su campionamento regolare: costo indipendente dalla risoluzione.</summary>
private static double MeasureClipping(float[] data, int count)
{
if (count == 0) return 0;
int step = Math.Max(3, (count / 3 / 200_000) * 3);
int clipped = 0, samples = 0;
for (int i = 0; i + 2 < count; i += step)
{
float luma = ColorSpace.Luminance(data[i], data[i + 1], data[i + 2]);
if (luma >= 0.995f) clipped++;
samples++;
}
return samples == 0 ? 0 : (double)clipped / samples;
}
}
+285
View File
@@ -0,0 +1,285 @@
using Titano.Core;
using Titano.Metadata;
namespace Titano.Analysis;
/// <summary>Parametri delle transizioni giorno-notte.</summary>
public sealed class HolyGrailSettings
{
public bool Enabled { get; set; }
/// <summary>
/// Ricava i cambi di impostazione dai metadati invece che dalla sola luminanza misurata.
/// Quando tempo, diaframma e ISO sono leggibili, il salto è noto in modo esatto.
/// </summary>
public bool UseMetadata { get; set; } = true;
/// <summary>Variazione di esposizione oltre la quale si parla di cambio di impostazione, in stop.</summary>
public double StepThresholdStops { get; set; } = 0.15;
/// <summary>Su quanti fotogrammi viene distribuito un salto.</summary>
public int TransitionFrames { get; set; } = 48;
/// <summary>Livella anche la deriva del bilanciamento del bianco.</summary>
public bool SmoothColor { get; set; } = true;
/// <summary>Finestra della lisciatura cromatica: più lunga della finestra del deflicker.</summary>
public int ColorWindowFrames { get; set; } = 61;
public double ColorStrength { get; set; } = 0.8;
/// <summary>Limite dello spostamento cromatico per canale, in stop.</summary>
public double MaxColorShiftStops { get; set; } = 0.6;
public HolyGrailSettings Clone() => (HolyGrailSettings)MemberwiseClone();
}
/// <summary>Esito dell'analisi delle transizioni: gradini individuati e correzione che li ammorbidisce.</summary>
public sealed class HolyGrailAnalysis
{
/// <summary>Valore di esposizione da metadati; NaN dove non ricostruibile.</summary>
public required double[] ExposureValue { get; init; }
/// <summary>Scalinata grezza presente nella luminanza misurata, in stop cumulativi.</summary>
public required double[] Staircase { get; init; }
/// <summary>La stessa scalinata con ogni gradino spalmato sulla transizione.</summary>
public required double[] SmoothStaircase { get; init; }
/// <summary>Correzione da aggiungere: porta la scalinata grezza in quella morbida.</summary>
public required double[] StepGainStops { get; init; }
/// <summary>Indici dei fotogrammi in cui l'impostazione è cambiata.</summary>
public required int[] StepFrames { get; init; }
/// <summary>Temperatura di colore misurata su ciascun fotogramma.</summary>
public required double[] TemperatureKelvin { get; init; }
/// <summary>Deviazione verde-magenta misurata, in stop.</summary>
public required double[] TintStops { get; init; }
public bool MetadataUsable { get; init; }
public double LargestStepStops { get; init; }
public int StepCount => StepFrames.Length;
public static HolyGrailAnalysis Empty(int count) => new()
{
ExposureValue = Filled(count, double.NaN),
Staircase = new double[count],
SmoothStaircase = new double[count],
StepGainStops = new double[count],
StepFrames = [],
TemperatureKelvin = Filled(count, double.NaN),
TintStops = new double[count],
};
private static double[] Filled(int count, double value)
{
var array = new double[count];
Array.Fill(array, value);
return array;
}
}
/// <summary>
/// Gestione delle transizioni giorno-notte.
///
/// In una ripresa che attraversa il tramonto la luce cala di una quindicina di stop, molto
/// più di quanto una singola esposizione possa coprire: la macchina deve cambiare tempo, ISO
/// o diaframma lungo la strada. Ogni cambio è quantizzato — un terzo di stop, spesso uno
/// intero — e nel filmato finito si vede come uno scalino netto della luminosità, che stona
/// tanto più quanto il resto della sequenza è liscio.
///
/// Il deflicker ordinario non basta: un gradino non è rumore da mediare, è un evento reale,
/// e la finestra mobile lo trasforma in una rampa lunga quanto la finestra, con due spigoli
/// alle estremità. La correzione giusta è un'altra: riconoscere il gradino, misurarne
/// l'ampiezza esatta e ridistribuirlo su una transizione lunga a piacere, con una curva a
/// derivata nulla agli estremi che non lascia spigoli da nessuna parte.
///
/// L'ampiezza si legge nei metadati, dove il salto è noto senza incertezza: il valore di
/// esposizione vale log2(N²/t) log2(ISO/100), e la sua variazione fra due scatti è
/// esattamente ciò che la macchina ha deciso di cambiare. Quando i metadati non bastano si
/// ripiega sulla luminanza misurata, cercando i salti che si staccano dal rumore locale.
/// </summary>
public static class HolyGrailEngine
{
public static HolyGrailAnalysis Analyze(IReadOnlyList<LuminanceStats> stats,
IReadOnlyList<FrameMetadata> metadata,
HolyGrailSettings settings)
{
int n = stats.Count;
if (n == 0) return HolyGrailAnalysis.Empty(0);
var exposureValue = new double[n];
int usable = 0;
for (int i = 0; i < n; i++)
{
exposureValue[i] = i < metadata.Count ? ExposureValue(metadata[i]) : double.NaN;
if (!double.IsNaN(exposureValue[i])) usable++;
}
var temperature = new double[n];
var tint = new double[n];
for (int i = 0; i < n; i++)
{
double r = Math.Pow(2, stats[i].Log2AverageR);
double g = Math.Pow(2, stats[i].Log2AverageG);
double b = Math.Pow(2, stats[i].Log2AverageB);
temperature[i] = ColorScience.CorrelatedColorTemperature(r, g, b);
tint[i] = ColorScience.GreenTintStops(stats[i].Log2AverageR, stats[i].Log2AverageG,
stats[i].Log2AverageB);
}
if (!settings.Enabled || n < 3)
{
var idle = HolyGrailAnalysis.Empty(n);
return new HolyGrailAnalysis
{
ExposureValue = exposureValue,
Staircase = idle.Staircase,
SmoothStaircase = idle.SmoothStaircase,
StepGainStops = idle.StepGainStops,
StepFrames = [],
TemperatureKelvin = temperature,
TintStops = tint,
MetadataUsable = usable > n / 2,
};
}
double threshold = Math.Max(0.02, settings.StepThresholdStops);
bool metadataUsable = settings.UseMetadata && usable > n / 2;
var jumps = metadataUsable
? StepsFromMetadata(exposureValue, threshold)
: StepsFromLuminance(stats, threshold);
var staircase = new double[n];
var smooth = new double[n];
var stepGain = new double[n];
var stepFrames = new List<int>();
double largest = 0;
double running = 0;
for (int i = 0; i < n; i++)
{
running += jumps[i];
staircase[i] = running;
if (Math.Abs(jumps[i]) > 1e-9)
{
stepFrames.Add(i);
largest = Math.Max(largest, Math.Abs(jumps[i]));
}
}
// Ogni gradino viene sostituito dalla propria transizione, centrata sull'istante in
// cui è avvenuto: la somma delle transizioni ricostruisce la stessa scalinata, ma
// percorsa senza scatti.
double transition = Math.Max(2, settings.TransitionFrames);
foreach (int step in stepFrames)
{
double amplitude = jumps[step];
double centre = step - 0.5;
for (int i = 0; i < n; i++)
{
double t = (i - centre + transition * 0.5) / transition;
smooth[i] += amplitude * Spline.SmoothStep(Math.Clamp(t, 0, 1));
}
}
for (int i = 0; i < n; i++) stepGain[i] = smooth[i] - staircase[i];
return new HolyGrailAnalysis
{
ExposureValue = exposureValue,
Staircase = staircase,
SmoothStaircase = smooth,
StepGainStops = stepGain,
StepFrames = [.. stepFrames],
TemperatureKelvin = temperature,
TintStops = tint,
MetadataUsable = metadataUsable,
LargestStepStops = largest,
};
}
/// <summary>
/// Valore di esposizione dello scatto. Diaframma e sensibilità mancanti si considerano
/// costanti — quasi sempre lo sono in un time-lapse — mentre senza il tempo di posa il
/// dato non è ricostruibile e va dichiarato ignoto.
/// </summary>
public static double ExposureValue(FrameMetadata metadata)
{
if (metadata.ExposureSeconds is not { } exposure || exposure <= 0) return double.NaN;
double ev = -Math.Log2(exposure);
if (metadata.FNumber is { } aperture && aperture > 0) ev += 2 * Math.Log2(aperture);
if (metadata.Iso is { } iso && iso > 0) ev -= Math.Log2(iso / 100.0);
return ev;
}
/// <summary>
/// Salti dedotti dai metadati. Un aumento del valore di esposizione scurisce il
/// fotogramma, quindi il gradino che compare nella luminanza misurata ha segno opposto.
/// </summary>
private static double[] StepsFromMetadata(double[] exposureValue, double threshold)
{
int n = exposureValue.Length;
var jumps = new double[n];
double previous = double.NaN;
for (int i = 0; i < n; i++)
{
double current = exposureValue[i];
if (double.IsNaN(current)) continue;
if (!double.IsNaN(previous))
{
double delta = current - previous;
if (Math.Abs(delta) > threshold) jumps[i] = -delta;
}
previous = current;
}
return jumps;
}
/// <summary>
/// Salti dedotti dalla sola luminanza, quando i metadati non aiutano.
///
/// Il criterio non può essere la sola ampiezza, altrimenti una rampa ripida di tramonto
/// verrebbe scambiata per una serie di gradini. Si confronta invece ogni differenza con
/// la mediana delle differenze vicine: un cambio di impostazione si stacca dal fondo,
/// una rampa no, perché lì tutte le differenze si somigliano.
/// </summary>
private static double[] StepsFromLuminance(IReadOnlyList<LuminanceStats> stats, double threshold)
{
int n = stats.Count;
var jumps = new double[n];
if (n < 5) return jumps;
var delta = new double[n];
for (int i = 1; i < n; i++) delta[i] = stats[i].Log2Average - stats[i - 1].Log2Average;
const int radius = 6;
Span<double> window = stackalloc double[2 * radius + 1];
for (int i = 1; i < n; i++)
{
if (Math.Abs(delta[i]) <= threshold) continue;
int count = 0;
for (int j = i - radius; j <= i + radius; j++)
{
if (j <= 0 || j >= n || j == i) continue;
window[count++] = Math.Abs(delta[j]);
}
if (count == 0) continue;
var slice = window[..count];
slice.Sort();
double background = slice[count / 2];
if (Math.Abs(delta[i]) > Math.Max(threshold, 3.0 * background)) jumps[i] = delta[i];
}
return jumps;
}
}
+196
View File
@@ -0,0 +1,196 @@
using Titano.Imaging;
namespace Titano.Analysis;
/// <summary>Statistiche fotometriche di un fotogramma, calcolate in luce lineare.</summary>
public readonly struct LuminanceStats
{
/// <summary>Media logaritmica (base 2) della luminanza sui pixel non estremi.</summary>
public double Log2Average { get; init; }
/// <summary>Media geometrica in scala lineare: 2^<see cref="Log2Average"/>.</summary>
public double Linear => Math.Pow(2.0, Log2Average);
public double Log2AverageR { get; init; }
public double Log2AverageG { get; init; }
public double Log2AverageB { get; init; }
/// <summary>Percentili della distribuzione di luminanza, in scala lineare.</summary>
public double Percentile01 { get; init; }
public double Percentile50 { get; init; }
public double Percentile99 { get; init; }
/// <summary>Frazione di pixel già saturati nel fotogramma sorgente.</summary>
public double ClippedFraction { get; init; }
public double BlackFraction { get; init; }
/// <summary>Media logaritmica della prima regione (il cielo, nella separazione per orizzonte).</summary>
public double Log2High { get; init; }
/// <summary>Media logaritmica della seconda regione.</summary>
public double Log2Low { get; init; }
/// <summary>Vero se la misura per regioni è stata effettivamente calcolata su questo fotogramma.</summary>
public bool HasRegions { get; init; }
public static LuminanceStats Empty => new() { Log2Average = -8, Log2High = -8, Log2Low = -8 };
}
/// <summary>
/// Misura la luminanza dei buffer in-house, senza dipendenze esterne.
///
/// La stima usa la media logaritmica (media geometrica) su un campionamento a griglia fissa,
/// con esclusione delle code della distribuzione: è la metrica che segue la variazione di
/// esposizione reale senza farsi trascinare da cieli bruciati o ombre chiuse.
///
/// Con una maschera di regioni la stessa passata riempie tre istogrammi invece di uno:
/// l'intero fotogramma e le due regioni separate. Costa quanto la passata semplice, perché
/// il prezzo vero è la lettura dei pixel, non l'incremento di un contatore.
/// </summary>
public static class LuminanceAnalyzer
{
private const int Bins = 1024;
private const double LogMin = -16.0; // 2^-16 ≈ nero assoluto
private const double LogMax = 2.0; // headroom sopra il bianco
private const double Epsilon = 1.0 / 65536.0;
/// <summary>Numero massimo di pixel campionati per fotogramma: fissa il costo dell'analisi.</summary>
private const int TargetSamples = 262_144;
public static LuminanceStats Analyze(ImageBuffer frame, double trimLow = 0.02, double trimHigh = 0.02)
=> Analyze(frame, null, trimLow, trimHigh);
public static LuminanceStats Analyze(ImageBuffer frame, RegionMask? mask,
double trimLow = 0.02, double trimHigh = 0.02)
{
int step = ComputeStep(frame.PixelCount);
Span<int> histogram = stackalloc int[Bins];
Span<int> histogramHigh = stackalloc int[Bins];
Span<int> histogramLow = stackalloc int[Bins];
histogram.Clear();
histogramHigh.Clear();
histogramLow.Clear();
double sumR = 0, sumG = 0, sumB = 0;
int samples = 0, clipped = 0, black = 0;
int samplesHigh = 0, samplesLow = 0;
var data = frame.Data;
int totalPixels = frame.PixelCount;
int width = frame.Width;
float invWidth = frame.Width > 1 ? 1f / (frame.Width - 1) : 0f;
float invHeight = frame.Height > 1 ? 1f / (frame.Height - 1) : 0f;
for (int p = 0; p < totalPixels; p += step)
{
int i = p * ImageBuffer.Channels;
float r = data[i], g = data[i + 1], b = data[i + 2];
double luma = ColorSpace.Luminance(r, g, b);
if (double.IsNaN(luma)) continue;
if (luma >= 0.995) clipped++;
if (luma <= 0.0008) black++;
sumR += Log2Safe(r + Epsilon);
sumG += Log2Safe(g + Epsilon);
sumB += Log2Safe(b + Epsilon);
double l = Log2Safe(luma + Epsilon);
int bin = Math.Clamp((int)((l - LogMin) / (LogMax - LogMin) * (Bins - 1) + 0.5), 0, Bins - 1);
histogram[bin]++;
samples++;
if (mask is null) continue;
// Attribuzione netta alla regione dominante: per la misura serve sapere di quale
// regione un pixel racconta la luce, non in che proporzione. La sfumatura conta
// quando la correzione viene applicata, non quando viene calcolata.
float weight = mask.Sample((p % width) * invWidth, (p / width) * invHeight);
if (weight >= 0.5f) { histogramHigh[bin]++; samplesHigh++; }
else { histogramLow[bin]++; samplesLow++; }
}
if (samples == 0) return LuminanceStats.Empty;
double average = TrimmedMean(histogram, samples, trimLow, trimHigh,
out double p01, out double p50, out double p99);
// Una regione con pochissimi campioni non produce una stima affidabile: eredita
// quella globale, così la sua curva resta agganciata al resto dell'immagine.
bool regionsUsable = mask is not null && samplesHigh > 64 && samplesLow > 64;
double log2High = regionsUsable
? TrimmedMean(histogramHigh, samplesHigh, trimLow, trimHigh, out _, out _, out _)
: average;
double log2Low = regionsUsable
? TrimmedMean(histogramLow, samplesLow, trimLow, trimHigh, out _, out _, out _)
: average;
return new LuminanceStats
{
Log2Average = average,
Log2AverageR = sumR / samples,
Log2AverageG = sumG / samples,
Log2AverageB = sumB / samples,
Percentile01 = Math.Pow(2.0, p01),
Percentile50 = Math.Pow(2.0, p50),
Percentile99 = Math.Pow(2.0, p99),
ClippedFraction = (double)clipped / samples,
BlackFraction = (double)black / samples,
Log2High = log2High,
Log2Low = log2Low,
HasRegions = regionsUsable,
};
}
/// <summary>
/// Media troncata calcolata direttamente sull'istogramma: le code (cielo bruciato, ombre
/// chiuse) non influenzano la stima dell'esposizione media.
/// </summary>
private static double TrimmedMean(ReadOnlySpan<int> histogram, int samples, double trimLow, double trimHigh,
out double p01, out double p50, out double p99)
{
int lowCut = (int)(samples * trimLow);
int highCut = (int)(samples * (1.0 - trimHigh));
double weighted = 0;
long counted = 0;
int running = 0;
p01 = LogMin; p50 = LogMin; p99 = LogMin;
int q01 = (int)(samples * 0.01), q50 = samples / 2, q99 = (int)(samples * 0.99);
bool got01 = false, got50 = false, got99 = false;
for (int bin = 0; bin < Bins; bin++)
{
int count = histogram[bin];
if (count == 0) continue;
double value = LogMin + bin * (LogMax - LogMin) / (Bins - 1);
int before = running;
running += count;
if (!got01 && running >= q01) { p01 = value; got01 = true; }
if (!got50 && running >= q50) { p50 = value; got50 = true; }
if (!got99 && running >= q99) { p99 = value; got99 = true; }
int from = Math.Max(before, lowCut);
int to = Math.Min(running, highCut);
if (to > from)
{
weighted += value * (to - from);
counted += to - from;
}
}
// Distribuzione degenere (immagine uniforme): ricadiamo sulla mediana.
return counted == 0 ? p50 : weighted / counted;
}
/// <summary>Passo di campionamento a griglia: deterministico, quindi identico per ogni fotogramma.</summary>
private static int ComputeStep(int pixelCount)
=> Math.Max(1, pixelCount / TargetSamples);
/// <summary>Logaritmo base 2 con saturazione al nero: evita -infinito sui pixel spenti.</summary>
private static double Log2Safe(double value)
=> value <= 0 ? LogMin : Math.Max(LogMin, Math.Log2(value));
}
+426
View File
@@ -0,0 +1,426 @@
using Titano.Imaging;
namespace Titano.Analysis;
/// <summary>Criterio con cui il fotogramma viene diviso in regioni indipendenti.</summary>
public enum RegionMode
{
/// <summary>Una sola regione: il deflicker lavora sull'intero fotogramma.</summary>
Off,
/// <summary>Ricerca della linea d'orizzonte: separa il cielo dal paesaggio.</summary>
SkyGround,
/// <summary>Separazione per sola luminanza, senza vincolo di forma.</summary>
Luminance,
}
/// <summary>Parametri della segmentazione e del deflicker per regioni.</summary>
public sealed class RegionSettings
{
public RegionMode Mode { get; set; } = RegionMode.Off;
/// <summary>Fotogrammi campionati per costruire la maschera, distribuiti su tutta la sequenza.</summary>
public int SampleFrames { get; set; } = 16;
/// <summary>Larghezza a cui viene costruita la maschera: la forma delle regioni è grossolana per natura.</summary>
public int AnalysisWidth { get; set; } = 320;
/// <summary>Ampiezza della sfumatura fra le due regioni, in frazione dell'altezza.</summary>
public double Feather { get; set; } = 0.05;
/// <summary>
/// Quanto le due curve restano indipendenti: a 0 coincidono con la curva globale,
/// a 1 ciascuna regione insegue solo la propria luminanza.
/// </summary>
public double Independence { get; set; } = 0.75;
public RegionSettings Clone() => (RegionSettings)MemberwiseClone();
}
/// <summary>
/// Maschera morbida di appartenenza: 1 dove vale la prima regione (il cielo, nella
/// separazione per orizzonte), 0 dove vale la seconda, con una transizione continua in mezzo.
/// I valori intermedi non sono un difetto da eliminare: sono ciò che evita di vedere il
/// confine fra le due correzioni come una linea netta in mezzo all'immagine.
/// </summary>
public sealed class RegionMask
{
public required int Width { get; init; }
public required int Height { get; init; }
public required float[] Weight { get; init; }
/// <summary>Frazione dell'immagine attribuita alla prima regione.</summary>
public double Coverage { get; init; }
/// <summary>Luminanza media delle due regioni, in log2: serve a descriverle in interfaccia.</summary>
public double Log2High { get; init; }
public double Log2Low { get; init; }
public required string Description { get; init; }
/// <summary>Peso della prima regione in coordinate normalizzate 0..1.</summary>
public float Sample(float u, float v)
{
float x = Math.Clamp(u, 0f, 1f) * (Width - 1);
float y = Math.Clamp(v, 0f, 1f) * (Height - 1);
int x0 = (int)x, y0 = (int)y;
int x1 = Math.Min(x0 + 1, Width - 1);
int y1 = Math.Min(y0 + 1, Height - 1);
float fx = x - x0, fy = y - y0;
float top = Weight[y0 * Width + x0] * (1 - fx) + Weight[y0 * Width + x1] * fx;
float bottom = Weight[y1 * Width + x0] * (1 - fx) + Weight[y1 * Width + x1] * fx;
return top + (bottom - top) * fy;
}
}
/// <summary>
/// Segmentazione automatica del fotogramma in due regioni, dall'istogramma e dal gradiente.
///
/// Il problema che risolve è concreto: una nuvola densa che attraversa il cielo abbassa la
/// luminanza media del fotogramma, il deflicker legge un calo di luce e schiarisce tutto —
/// compreso il paesaggio, che non è cambiato affatto. Il risultato è un paesaggio che
/// respira al passaggio di ogni nuvola. Misurando cielo e terra separatamente ciascuna
/// regione riceve la propria correzione e il paesaggio resta fermo.
///
/// La maschera si costruisce una volta sola, dalla mediana temporale di un campione di
/// fotogrammi: la mediana toglie di mezzo proprio le nuvole e tutto ciò che passa e non
/// resta, lasciando la struttura permanente della scena. È il motivo per cui la maschera
/// non sfarfalla da un fotogramma all'altro.
/// </summary>
public static class RegionSegmenter
{
/// <summary>
/// Costruisce la maschera campionando la sequenza. Restituisce null se la scena non si
/// lascia dividere in modo sensato — un muro uniforme, un cielo che occupa tutto — nel
/// qual caso il deflicker globale è la scelta giusta e non c'è nulla da correggere.
/// </summary>
public static RegionMask? Build(IReadOnlyList<string> paths, int orientation, RegionSettings settings,
CancellationToken cancellation = default)
{
if (settings.Mode == RegionMode.Off || paths.Count == 0) return null;
int width = Math.Clamp(settings.AnalysisWidth, 96, 1024);
var samples = CollectSamples(paths, orientation, width, settings.SampleFrames, cancellation);
if (samples.Count == 0) return null;
int height = samples[0].Height;
var median = TemporalMedian(samples, width, height);
double threshold = OtsuThreshold(median);
var mask = settings.Mode == RegionMode.SkyGround
? BuildFromHorizon(median, width, height, threshold, settings)
: BuildFromLuminance(median, width, height, threshold, settings);
double coverage = 0;
double sumHigh = 0, sumLow = 0;
double weightHigh = 0, weightLow = 0;
for (int i = 0; i < mask.Length; i++)
{
coverage += mask[i];
sumHigh += median[i] * mask[i];
weightHigh += mask[i];
sumLow += median[i] * (1 - mask[i]);
weightLow += 1 - mask[i];
}
coverage /= mask.Length;
// Una divisione che lascia una delle due parti quasi vuota non porta informazione:
// meglio dichiararlo e lasciare al deflicker globale il suo lavoro.
if (coverage is < 0.04 or > 0.96) return null;
double log2High = weightHigh > 1e-6 ? sumHigh / weightHigh : 0;
double log2Low = weightLow > 1e-6 ? sumLow / weightLow : 0;
string description = settings.Mode == RegionMode.SkyGround
? $"cielo {coverage * 100:0}% dell'inquadratura, {log2High - log2Low:0.0} EV sopra il paesaggio"
: $"regione chiara {coverage * 100:0}% dell'inquadratura, {log2High - log2Low:0.0} EV sopra la scura";
return new RegionMask
{
Width = width,
Height = height,
Weight = mask,
Coverage = coverage,
Log2High = log2High,
Log2Low = log2Low,
Description = description,
};
}
// ------------------------------------------------------------------ campionamento
private static List<GrayPlane> CollectSamples(IReadOnlyList<string> paths, int orientation, int width,
int requested, CancellationToken cancellation)
{
int count = Math.Clamp(requested, 3, Math.Min(64, paths.Count));
var chosen = new string[count];
for (int i = 0; i < count; i++)
{
int index = count == 1 ? 0 : (int)Math.Round(i * (paths.Count - 1.0) / (count - 1));
chosen[i] = paths[Math.Clamp(index, 0, paths.Count - 1)];
}
var results = new GrayPlane?[count];
var pool = new FrameBufferPool(6);
Parallel.For(0, count, new ParallelOptions
{
CancellationToken = cancellation,
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount / 2, 1, 8),
}, i =>
{
try
{
var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(chosen[i], orientation);
if (probeWidth <= 0 || probeHeight <= 0) return;
int height = Math.Max(2, (int)Math.Round(width * probeHeight / (double)probeWidth));
using var buffer = ImageDecoder.Decode(chosen[i], width, height, orientation, pool);
var plane = new GrayPlane(width, height);
var data = buffer.Data;
for (int p = 0; p < plane.Data.Length; p++)
{
int s = p * ImageBuffer.Channels;
float luma = ColorSpace.Luminance(data[s], data[s + 1], data[s + 2]);
plane.Data[p] = (float)Math.Log2(Math.Max(luma, 1.0 / 65536.0));
}
results[i] = plane;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Un campione illeggibile non compromette la maschera: ne restano altri.
}
});
var samples = new List<GrayPlane>(count);
foreach (var plane in results)
{
if (plane is not null && (samples.Count == 0 || plane.Height == samples[0].Height)) samples.Add(plane);
}
return samples;
}
private sealed class GrayPlane(int width, int height)
{
public int Width { get; } = width;
public int Height { get; } = height;
public float[] Data { get; } = new float[width * height];
}
/// <summary>Mediana temporale pixel per pixel: elimina ciò che passa e non appartiene alla scena.</summary>
private static float[] TemporalMedian(List<GrayPlane> samples, int width, int height)
{
var median = new float[width * height];
int count = samples.Count;
Parallel.For(0, height, y =>
{
Span<float> values = stackalloc float[64];
for (int x = 0; x < width; x++)
{
int index = y * width + x;
for (int s = 0; s < count; s++) values[s] = samples[s].Data[index];
var slice = values[..count];
slice.Sort();
median[index] = slice[count / 2];
}
});
return median;
}
// ------------------------------------------------------------------ soglia e maschere
/// <summary>
/// Soglia di Otsu: quella che minimizza la varianza interna alle due classi. Applicata
/// alla luminanza logaritmica, separa naturalmente cielo e terra, che in un paesaggio
/// distano quasi sempre più di due stop.
/// </summary>
private static double OtsuThreshold(float[] values)
{
const int bins = 256;
float min = float.MaxValue, max = float.MinValue;
foreach (float v in values)
{
if (v < min) min = v;
if (v > max) max = v;
}
if (max - min < 1e-4f) return min;
Span<int> histogram = stackalloc int[bins];
histogram.Clear();
double scale = (bins - 1) / (max - min);
foreach (float v in values) histogram[Math.Clamp((int)((v - min) * scale + 0.5), 0, bins - 1)]++;
double total = values.Length;
double sum = 0;
for (int i = 0; i < bins; i++) sum += i * (double)histogram[i];
double sumBackground = 0, weightBackground = 0, bestVariance = -1;
int bestBin = bins / 2;
for (int i = 0; i < bins; i++)
{
weightBackground += histogram[i];
if (weightBackground <= 0) continue;
double weightForeground = total - weightBackground;
if (weightForeground <= 0) break;
sumBackground += i * (double)histogram[i];
double meanBackground = sumBackground / weightBackground;
double meanForeground = (sum - sumBackground) / weightForeground;
double between = weightBackground * weightForeground *
(meanBackground - meanForeground) * (meanBackground - meanForeground);
if (between <= bestVariance) continue;
bestVariance = between;
bestBin = i;
}
return min + bestBin / scale;
}
/// <summary>
/// Maschera per linea d'orizzonte. La soglia dà una prima ipotesi colonna per colonna;
/// il gradiente verticale la corregge, perché il passaggio cielo-terra è il contrasto
/// più forte della colonna e cade esattamente sul bordo, non dove capita la soglia.
/// Il profilo risultante viene poi lisciato: un orizzonte reale non fa scalini.
/// </summary>
private static float[] BuildFromHorizon(float[] median, int width, int height, double threshold,
RegionSettings settings)
{
var boundary = new double[width];
int band = Math.Max(2, height / 8);
for (int x = 0; x < width; x++)
{
// Prima riga, dall'alto, che apre una serie stabile di pixel sotto soglia.
int guess = height;
int run = 0;
int required = Math.Max(2, height / 40);
for (int y = 0; y < height; y++)
{
if (median[y * width + x] < threshold)
{
if (++run >= required) { guess = y - required + 1; break; }
}
else
{
run = 0;
}
}
if (guess >= height) guess = height - 1;
int from = Math.Max(1, guess - band);
int to = Math.Min(height - 2, guess + band);
double bestGradient = -1;
int bestRow = guess;
for (int y = from; y <= to; y++)
{
double gradient = Math.Abs(median[(y + 1) * width + x] - median[(y - 1) * width + x]);
if (gradient <= bestGradient) continue;
bestGradient = gradient;
bestRow = y;
}
boundary[x] = bestRow;
}
boundary = SmoothProfile(boundary, Math.Max(3, width / 12));
var mask = new float[width * height];
double feather = Math.Max(1.0, settings.Feather * height);
Parallel.For(0, height, y =>
{
for (int x = 0; x < width; x++)
{
double distance = (boundary[x] - y) / feather;
mask[y * width + x] = (float)Core.Spline.SmoothStep(Math.Clamp(distance * 0.5 + 0.5, 0, 1));
}
});
return mask;
}
/// <summary>Maschera per sola luminanza, poi sfumata: nessun vincolo sulla forma delle regioni.</summary>
private static float[] BuildFromLuminance(float[] median, int width, int height, double threshold,
RegionSettings settings)
{
var mask = new float[width * height];
double feather = Math.Max(0.15, settings.Feather * 12);
for (int i = 0; i < mask.Length; i++)
{
double distance = (median[i] - threshold) / feather;
mask[i] = (float)Core.Spline.SmoothStep(Math.Clamp(distance * 0.5 + 0.5, 0, 1));
}
int radius = Math.Max(1, (int)Math.Round(settings.Feather * height));
BoxBlur(mask, width, height, radius);
BoxBlur(mask, width, height, radius);
return mask;
}
/// <summary>Media mobile su una serie monodimensionale, con estensione dei bordi.</summary>
private static double[] SmoothProfile(double[] series, int radius)
{
int n = series.Length;
var result = new double[n];
for (int i = 0; i < n; i++)
{
double sum = 0;
int count = 0;
for (int j = i - radius; j <= i + radius; j++)
{
sum += series[Math.Clamp(j, 0, n - 1)];
count++;
}
result[i] = sum / count;
}
return result;
}
/// <summary>Sfocatura a media d'area separabile: due passate approssimano già bene una gaussiana.</summary>
private static void BoxBlur(float[] data, int width, int height, int radius)
{
if (radius < 1) return;
var scratch = new float[data.Length];
Parallel.For(0, height, y =>
{
int rowBase = y * width;
for (int x = 0; x < width; x++)
{
float sum = 0;
int count = 0;
for (int k = x - radius; k <= x + radius; k++)
{
sum += data[rowBase + Math.Clamp(k, 0, width - 1)];
count++;
}
scratch[rowBase + x] = sum / count;
}
});
Parallel.For(0, width, x =>
{
for (int y = 0; y < height; y++)
{
float sum = 0;
int count = 0;
for (int k = y - radius; k <= y + radius; k++)
{
sum += scratch[Math.Clamp(k, 0, height - 1) * width + x];
count++;
}
data[y * width + x] = sum / count;
}
});
}
}
+78
View File
@@ -0,0 +1,78 @@
using Titano.Metadata;
namespace Titano.Core;
/// <summary>
/// Stato mutabile di un fotogramma lungo tutta la pipeline. Contiene solo scalari:
/// i pixel non vivono mai qui, restano nei buffer poolati (impronta di memoria costante).
/// </summary>
public sealed class FrameRecord(int index, FrameMetadata metadata)
{
public int Index { get; } = index;
public FrameMetadata Metadata { get; } = metadata;
public string FileName => Metadata.FileName;
public string FilePath => Metadata.FilePath;
/// <summary>Secondi trascorsi dal primo scatto della sequenza.</summary>
public double ElapsedSeconds { get; internal set; }
/// <summary>Intervallo verso lo scatto successivo, in secondi (l'ultimo eredita il precedente).</summary>
public double IntervalSeconds { get; internal set; }
/// <summary>True se l'intervallo devia in modo significativo dalla cadenza nominale.</summary>
public bool IsCadenceAnomaly { get; internal set; }
/// <summary>Shutter angle reale: 360 * tempo di posa / intervallo.</summary>
public double ShutterAngle { get; internal set; }
// ---- analisi luminanza -------------------------------------------------
/// <summary>Luminanza media geometrica misurata (log-average), in scala lineare 0..1.</summary>
public double MeasuredLuminance { get; internal set; }
/// <summary>Luminanza obiettivo dopo lo smoothing temporale.</summary>
public double TargetLuminance { get; internal set; }
/// <summary>Guadagno lineare applicato ai canali colore.</summary>
public double Gain { get; internal set; } = 1.0;
/// <summary>Correzione espressa in stop (log2 del guadagno), per la lettura in UI.</summary>
public double GainStops => Gain > 0 ? Math.Log2(Gain) : 0;
/// <summary>Percentuale di pixel oltre la soglia di clipping dopo la correzione.</summary>
public double ClippedFraction { get; internal set; }
public bool LuminanceAnalyzed { get; internal set; }
// ---- motion ------------------------------------------------------------
/// <summary>Modulo mediano del vettore di movimento verso il fotogramma successivo, in pixel.</summary>
public double MotionMagnitude { get; internal set; }
/// <summary>Direzione dominante del movimento, in gradi.</summary>
public double MotionDirection { get; internal set; }
/// <summary>Lunghezza della scia di motion blur sintetico effettivamente applicata, in pixel.</summary>
public double BlurLength { get; internal set; }
/// <summary>Durata del fotogramma nel video finale, in unità di timescale (playback adattivo).</summary>
public int OutputDurationUnits { get; internal set; }
// ---- stabilizzazione e colore ------------------------------------------
/// <summary>Correzione di stabilizzazione applicata, in pixel della risoluzione sorgente.</summary>
public double StabilizationShift { get; internal set; }
/// <summary>Rotazione residua compensata, in gradi.</summary>
public double StabilizationRotation { get; internal set; }
/// <summary>Temperatura di colore correlata misurata sul fotogramma, in kelvin.</summary>
public double TemperatureKelvin { get; internal set; } = double.NaN;
/// <summary>Vero se in questo fotogramma la macchina ha cambiato tempo, diaframma o sensibilità.</summary>
public bool IsExposureStep { get; internal set; }
public string CadenceText => IntervalSeconds <= 0
? "—"
: IntervalSeconds < 1
? $"{IntervalSeconds * 1000:0} ms"
: $"{IntervalSeconds:0.###} s";
}
+120
View File
@@ -0,0 +1,120 @@
namespace Titano.Core;
/// <summary>
/// Regressione lineare locale pesata, con seconda passata robusta agli scarti anomali.
///
/// È il nucleo numerico condiviso da tutto ciò che, nel motore, deve distinguere una
/// variazione reale e lenta da un disturbo veloce: l'esposizione che sfarfalla sopra una
/// rampa di tramonto, la deriva del bilanciamento del bianco, il tremolio del treppiede
/// sopra una panoramica voluta. In tutti questi casi il segnale utile è la componente
/// liscia e il disturbo è quella ad alta frequenza.
///
/// Rispetto a una media mobile, la componente lineare della regressione segue senza ritardo
/// le rampe: una media mobile su una salita costante restituisce sempre un valore vecchio di
/// mezza finestra, e la correzione risultante spegnerebbe proprio il fenomeno da preservare.
/// </summary>
public static class LocalRegression
{
/// <summary>Costante del peso di Tukey: oltre 4.685 deviazioni il campione non pesa più nulla.</summary>
private const double TukeyConstant = 4.685;
/// <summary>
/// Lisciatura della serie su una finestra mobile di <paramref name="windowFrames"/> campioni.
/// Con <paramref name="rejectOutliers"/> si esegue una seconda passata in cui i campioni
/// lontani dalla prima stima vengono neutralizzati.
/// </summary>
public static double[] Smooth(double[] series, int windowFrames, bool rejectOutliers)
{
int n = series.Length;
var result = new double[n];
if (n == 0) return result;
if (n <= 2)
{
Array.Copy(series, result, n);
return result;
}
int radius = Math.Clamp((Math.Max(3, windowFrames) - 1) / 2, 1, Math.Max(1, n - 1));
double sigma = Math.Max(radius / 2.0, 0.5);
var robust = new double[n];
Array.Fill(robust, 1.0);
FitAll(series, result, radius, sigma, robust);
if (rejectOutliers)
{
UpdateRobustWeights(series, result, robust);
FitAll(series, result, radius, sigma, robust);
}
return result;
}
private static void FitAll(double[] y, double[] output, int radius, double sigma, double[] robust)
{
int n = y.Length;
double twoSigmaSq = 2.0 * sigma * sigma;
for (int i = 0; i < n; i++)
{
int from = Math.Max(0, i - radius);
int to = Math.Min(n - 1, i + radius);
// Sistema normale della retta pesata y = a + b·t, con t = j - i.
double sw = 0, swt = 0, swt2 = 0, swy = 0, swty = 0;
for (int j = from; j <= to; j++)
{
double t = j - i;
double w = Math.Exp(-(t * t) / twoSigmaSq) * robust[j];
if (w <= 1e-9) continue;
sw += w;
swt += w * t;
swt2 += w * t * t;
swy += w * y[j];
swty += w * t * y[j];
}
if (sw <= 1e-9)
{
output[i] = y[i];
continue;
}
double det = sw * swt2 - swt * swt;
if (Math.Abs(det) < 1e-12)
{
output[i] = swy / sw; // finestra degenere: media pesata
continue;
}
double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0
output[i] = a;
}
}
private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust)
{
int n = y.Length;
var residuals = new double[n];
for (int i = 0; i < n; i++) residuals[i] = Math.Abs(y[i] - fit[i]);
var sorted = (double[])residuals.Clone();
Array.Sort(sorted);
double mad = sorted[n / 2];
double scale = 1.4826 * mad;
// Se la sequenza è già pulita non si scarta nulla: evita di amplificare il rumore numerico.
if (scale < 1e-4)
{
Array.Fill(robust, 1.0);
return;
}
for (int i = 0; i < n; i++)
{
double u = residuals[i] / (TukeyConstant * scale);
robust[i] = u >= 1.0 ? 0.0 : Math.Pow(1.0 - u * u, 2.0);
}
}
}
+147
View File
@@ -0,0 +1,147 @@
namespace Titano.Core;
/// <summary>Nodo di una curva editabile: ascissa e valore, entrambi in unità di dominio.</summary>
public readonly record struct SplineKnot(double X, double Y)
{
public SplineKnot WithX(double x) => new(x, Y);
public SplineKnot WithY(double y) => new(X, y);
}
/// <summary>
/// Interpolazione a spline cubica monotona (schema di FritschCarlson) e funzioni di
/// accelerazione di Bézier, entrambe scritte in-house.
///
/// La monotonia non è un vezzo matematico: la curva di rimappatura temporale deve restituire
/// una velocità sempre positiva, altrimenti la sequenza tornerebbe indietro. Una Catmull-Rom
/// ordinaria produce sovraelongazioni fra due nodi molto diversi — sotto zero, nel nostro
/// caso — mentre lo smorzamento delle tangenti di FritschCarlson lo impedisce per costruzione.
/// </summary>
public static class Spline
{
/// <summary>
/// Valuta la spline monotona passante per i nodi indicati (ordinati per ascissa).
/// Fuori dall'intervallo dei nodi la curva si mantiene costante sull'estremo più vicino:
/// una estrapolazione lineare potrebbe scendere sotto zero.
/// </summary>
public static double Evaluate(IReadOnlyList<SplineKnot> knots, double x)
{
int n = knots.Count;
if (n == 0) return 0;
if (n == 1) return knots[0].Y;
if (x <= knots[0].X) return knots[0].Y;
if (x >= knots[n - 1].X) return knots[n - 1].Y;
int i = 0;
while (i < n - 2 && x > knots[i + 1].X) i++;
double h = knots[i + 1].X - knots[i].X;
if (h <= 1e-12) return knots[i + 1].Y;
double t = (x - knots[i].X) / h;
double m0 = Tangent(knots, i) * h;
double m1 = Tangent(knots, i + 1) * h;
// Base di Hermite cubica.
double t2 = t * t;
double t3 = t2 * t;
return (2 * t3 - 3 * t2 + 1) * knots[i].Y
+ (t3 - 2 * t2 + t) * m0
+ (-2 * t3 + 3 * t2) * knots[i + 1].Y
+ (t3 - t2) * m1;
}
/// <summary>
/// Tangente nel nodo indicato, smorzata secondo FritschCarlson.
///
/// La regola è: se le pendenze dei due segmenti adiacenti hanno segno discorde — cioè il
/// nodo è un massimo o un minimo locale — la tangente è nulla; altrimenti si prende la
/// media armonica pesata, che non supera mai tre volte la pendenza del segmento più dolce.
/// È questa limitazione a garantire che la curva non esca dall'intervallo dei nodi.
/// </summary>
private static double Tangent(IReadOnlyList<SplineKnot> knots, int index)
{
int n = knots.Count;
double slopeLeft = index > 0 ? Slope(knots, index - 1) : Slope(knots, 0);
double slopeRight = index < n - 1 ? Slope(knots, index) : Slope(knots, n - 2);
if (index == 0) return slopeRight;
if (index == n - 1) return slopeLeft;
if (slopeLeft * slopeRight <= 0) return 0;
double hLeft = knots[index].X - knots[index - 1].X;
double hRight = knots[index + 1].X - knots[index].X;
double weightLeft = 2 * hRight + hLeft;
double weightRight = hRight + 2 * hLeft;
return (weightLeft + weightRight) / (weightLeft / slopeLeft + weightRight / slopeRight);
}
private static double Slope(IReadOnlyList<SplineKnot> knots, int segment)
{
double h = knots[segment + 1].X - knots[segment].X;
return h <= 1e-12 ? 0 : (knots[segment + 1].Y - knots[segment].Y) / h;
}
/// <summary>
/// Funzione di accelerazione cubica di Bézier, nella forma usata dai programmi di montaggio:
/// i punti di controllo (0,0) e (1,1) sono fissi, i due intermedi governano l'attacco e la
/// chiusura del movimento. Restituisce l'avanzamento y corrispondente al tempo x.
///
/// La curva è definita in forma parametrica, quindi per un dato x va prima trovato il
/// parametro: si usa Newton, con ripiego sulla bisezione quando la derivata si annulla
/// (accade con maniglie estreme, dove la curva ha tangente verticale).
/// </summary>
public static double Ease(double x, double x1, double y1, double x2, double y2)
{
if (x <= 0) return 0;
if (x >= 1) return 1;
x1 = Math.Clamp(x1, 0, 1);
x2 = Math.Clamp(x2, 0, 1);
double t = x;
for (int i = 0; i < 8; i++)
{
double error = BezierAxis(t, x1, x2) - x;
if (Math.Abs(error) < 1e-7) return BezierAxis(t, y1, y2);
double derivative = BezierAxisDerivative(t, x1, x2);
if (Math.Abs(derivative) < 1e-9) break;
t -= error / derivative;
if (t is < 0 or > 1) { t = Math.Clamp(t, 0, 1); break; }
}
double lo = 0, hi = 1;
for (int i = 0; i < 32 && hi - lo > 1e-7; i++)
{
t = (lo + hi) * 0.5;
if (BezierAxis(t, x1, x2) < x) lo = t; else hi = t;
}
return BezierAxis((lo + hi) * 0.5, y1, y2);
}
/// <summary>Componente di una Bézier cubica con estremi fissi in 0 e 1.</summary>
private static double BezierAxis(double t, double p1, double p2)
{
double u = 1 - t;
return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t;
}
private static double BezierAxisDerivative(double t, double p1, double p2)
{
double u = 1 - t;
return 3 * u * u * p1 + 6 * u * t * (p2 - p1) + 3 * t * t * (1 - p2);
}
/// <summary>
/// Transizione a derivata nulla agli estremi, usata per redistribuire un gradino di
/// esposizione su più fotogrammi. Rispetto a una rampa lineare non lascia i due spigoli
/// che l'occhio legge come altrettanti scatti.
/// </summary>
public static double SmoothStep(double t)
{
if (t <= 0) return 0;
if (t >= 1) return 1;
return t * t * t * (t * (t * 6 - 15) + 10);
}
}
+169
View File
@@ -0,0 +1,169 @@
using Titano.Metadata;
namespace Titano.Core;
/// <summary>
/// Sequenza ordinata di fotogrammi con la relativa analisi temporale: intervalli reali,
/// cadenza nominale dell'intervallometro, pause e shutter angle per singolo scatto.
/// </summary>
public sealed class TimelapseSequence
{
private readonly List<FrameRecord> _frames = [];
public IReadOnlyList<FrameRecord> Frames => _frames;
public int Count => _frames.Count;
/// <summary>Cadenza nominale (mediana degli intervalli), in secondi.</summary>
public double NominalInterval { get; private set; }
/// <summary>Numero di intervalli che deviano oltre la tolleranza dalla cadenza nominale.</summary>
public int CadenceAnomalies { get; private set; }
public TimeSpan TotalDuration { get; private set; }
/// <summary>True se almeno un fotogramma ha un timestamp con precisione al sotto-secondo.</summary>
public bool HasSubSecondPrecision { get; private set; }
/// <summary>Costruisce la sequenza ordinando per timestamp e, a parità, per nome file naturale.</summary>
public static TimelapseSequence Build(IEnumerable<FrameMetadata> metadata)
{
var ordered = metadata
.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
.ToList();
var sequence = new TimelapseSequence();
for (int i = 0; i < ordered.Count; i++)
{
sequence._frames.Add(new FrameRecord(i, ordered[i]));
}
sequence.RecomputeTiming();
return sequence;
}
/// <summary>
/// Ricalcola intervalli reali, cadenza nominale e shutter angle.
/// Gli intervalli assenti (timestamp mancanti) ereditano la cadenza nominale.
/// </summary>
public void RecomputeTiming(double cadenceTolerance = 0.35)
{
if (_frames.Count == 0)
{
NominalInterval = 0;
TotalDuration = TimeSpan.Zero;
CadenceAnomalies = 0;
return;
}
HasSubSecondPrecision = _frames.Any(f => f.Metadata.CaptureSource == TimestampSource.ExifSubSecond);
DateTime? origin = _frames[0].Metadata.CaptureTime;
var raw = new double[_frames.Count];
for (int i = 0; i < _frames.Count; i++)
{
var current = _frames[i].Metadata.CaptureTime;
var next = i + 1 < _frames.Count ? _frames[i + 1].Metadata.CaptureTime : null;
_frames[i].ElapsedSeconds = origin is { } o && current is { } c ? (c - o).TotalSeconds : i;
raw[i] = current is { } a && next is { } b ? (b - a).TotalSeconds : double.NaN;
}
// Cadenza nominale = mediana degli intervalli validi: robusta a pause e scatti doppi.
var valid = raw.Where(v => !double.IsNaN(v) && v > 0).ToArray();
if (valid.Length > 0)
{
Array.Sort(valid);
NominalInterval = valid[valid.Length / 2];
}
else
{
NominalInterval = 1.0;
}
if (NominalInterval <= 0 || double.IsNaN(NominalInterval)) NominalInterval = 1.0;
CadenceAnomalies = 0;
for (int i = 0; i < _frames.Count; i++)
{
double interval = raw[i];
if (double.IsNaN(interval) || interval <= 0)
{
interval = i > 0 ? _frames[i - 1].IntervalSeconds : NominalInterval;
}
_frames[i].IntervalSeconds = interval;
bool anomaly = Math.Abs(interval - NominalInterval) > NominalInterval * cadenceTolerance;
_frames[i].IsCadenceAnomaly = anomaly && i < _frames.Count - 1;
if (_frames[i].IsCadenceAnomaly) CadenceAnomalies++;
_frames[i].ShutterAngle = ComputeShutterAngle(_frames[i].Metadata.ExposureSeconds, interval);
}
TotalDuration = _frames.Count > 1
? TimeSpan.FromSeconds(Math.Max(0, _frames[^1].ElapsedSeconds))
: TimeSpan.Zero;
}
/// <summary>
/// Shutter angle cinematografico: la frazione dell'intervallo effettivamente esposta,
/// espressa in gradi su un giro completo dell'otturatore rotante.
/// </summary>
public static double ComputeShutterAngle(double? exposureSeconds, double intervalSeconds)
{
if (exposureSeconds is not { } e || e <= 0 || intervalSeconds <= 0) return 0;
return Math.Min(360.0, 360.0 * e / intervalSeconds);
}
/// <summary>Sotto-insieme contiguo di indici, usato dalle anteprime e dai render parziali.</summary>
public IEnumerable<FrameRecord> Range(int start, int count)
{
int from = Math.Clamp(start, 0, Math.Max(0, _frames.Count - 1));
int to = Math.Clamp(from + count, from, _frames.Count);
for (int i = from; i < to; i++) yield return _frames[i];
}
}
/// <summary>
/// Ordinamento "naturale" dei nomi file: IMG_2.jpg precede IMG_10.jpg.
/// Necessario perché molte sequenze condividono lo stesso timestamp al secondo.
/// </summary>
public sealed class NaturalFileNameComparer : IComparer<string>
{
public static readonly NaturalFileNameComparer Instance = new();
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
int i = 0, j = 0;
while (i < x.Length && j < y.Length)
{
char cx = x[i], cy = y[j];
bool dx = cx is >= '0' and <= '9';
bool dy = cy is >= '0' and <= '9';
if (dx && dy)
{
int si = i, sj = j;
while (i < x.Length && x[i] is >= '0' and <= '9') i++;
while (j < y.Length && y[j] is >= '0' and <= '9') j++;
var nx = x.AsSpan(si, i - si).TrimStart('0');
var ny = y.AsSpan(sj, j - sj).TrimStart('0');
if (nx.Length != ny.Length) return nx.Length - ny.Length;
int cmp = nx.SequenceCompareTo(ny);
if (cmp != 0) return cmp;
}
else
{
int cmp = char.ToUpperInvariant(cx).CompareTo(char.ToUpperInvariant(cy));
if (cmp != 0) return cmp;
i++;
j++;
}
}
return (x.Length - i) - (y.Length - j);
}
}
+621
View File
@@ -0,0 +1,621 @@
using Titano.Analysis;
using Titano.Core;
using Titano.Imaging;
using Titano.Metadata;
using Titano.Motion;
using Titano.Pipeline;
namespace Titano.Diagnostics;
/// <summary>
/// Verifiche dei moduli avanzati: stabilizzazione, deflicker per regioni, transizioni
/// giorno-notte, movimento di macchina virtuale, rimappatura del tempo e accumulo temporale.
///
/// Ogni controllo parte da una scena costruita apposta, con la proprietà da misurare imposta
/// per costruzione: il tremolio ha un percorso noto, il gradino di esposizione un'ampiezza
/// nota e coerente con i metadati, la nuvola attraversa il cielo senza toccare il terreno.
/// Il valore atteso non è quindi una soglia scelta a posteriori ma la conseguenza aritmetica
/// di come la scena è stata generata.
/// </summary>
internal static class AdvancedModuleTests
{
internal delegate void Report(string name, bool passed, string detail);
public static void Run(string workingDirectory, Report add, TextWriter output)
{
Stabilization(add);
PhotometricRegions(workingDirectory, add, output);
ColourDrift(add);
Easing(add);
Geometry(add);
Ramp(add);
Stacking(add);
Integration(workingDirectory, add, output);
}
// ================================================================== 1. stabilizzazione
/// <summary>
/// Percorso noto: una deriva lenta e voluta con sopra un tremolio veloce. Dopo la
/// stabilizzazione la deriva deve restare — è un movimento reale — e il tremolio sparire.
/// La misura è l'energia delle differenze seconde, che è nulla su una rampa perfetta e
/// cresce con l'irregolarità: separa esattamente ciò che va tenuto da ciò che va tolto.
/// </summary>
private static void Stabilization(Report add)
{
const int count = 36;
const int size = 320;
var settings = new StabilizationSettings
{
Enabled = true,
PatchSize = 128,
Grid = 3,
SmoothingFrames = 15,
Strength = 1.0,
MaxCorrectionFraction = 0.10,
MinConfidence = 1.5,
};
var truthX = new double[count];
var truthY = new double[count];
var planes = new GrayImage[count];
for (int i = 0; i < count; i++)
{
double driftX = 0.30 * i; // panoramica voluta, lenta e regolare
double driftY = 0.12 * i;
double shakeX = 1.6 * Math.Sin(i * 2.399963) + 0.9 * Math.Sin(i * 0.7853);
double shakeY = 1.4 * Math.Cos(i * 1.618034) + 0.8 * Math.Sin(i * 1.162389);
truthX[i] = driftX + shakeX;
truthY[i] = driftY + shakeY;
planes[i] = SelfTest.TexturePlane(size, size, truthX[i], truthY[i]);
}
var stabilizer = new Stabilizer(settings);
var relative = new SimilarityTransform[count];
var confidence = new double[count];
relative[0] = SimilarityTransform.Identity;
double worstMeasurement = 0;
for (int i = 1; i < count; i++)
{
relative[i] = stabilizer.Estimate(planes[i - 1], planes[i], out confidence[i]);
double expectedX = truthX[i] - truthX[i - 1];
double expectedY = truthY[i] - truthY[i - 1];
double measuredX = relative[i].Tx * size;
double measuredY = relative[i].Ty * size;
worstMeasurement = Math.Max(worstMeasurement,
Math.Sqrt((measuredX - expectedX) * (measuredX - expectedX) +
(measuredY - expectedY) * (measuredY - expectedY)));
}
add("Stabilizzazione — spostamento fra fotogrammi adiacenti", worstMeasurement < 0.30,
$"errore massimo {worstMeasurement:0.000} px rispetto al percorso imposto");
var path = Stabilizer.BuildPath(relative, confidence, settings);
var afterX = new double[count];
var afterY = new double[count];
for (int i = 0; i < count; i++)
{
afterX[i] = truthX[i] + path.Correction[i].Tx * size;
afterY[i] = truthY[i] + path.Correction[i].Ty * size;
}
double before = Roughness(truthX) + Roughness(truthY);
double after = Roughness(afterX) + Roughness(afterY);
add("Stabilizzazione — tremolio rimosso", after < before * 0.30,
$"irregolarità {before:0.000} → {after:0.000} px " +
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)");
// La panoramica voluta deve arrivare in fondo: se la stabilizzazione la annullasse,
// il totale percorso crollerebbe verso zero.
double driftBefore = truthX[^1] - truthX[0];
double driftAfter = afterX[^1] - afterX[0];
add("Stabilizzazione — panoramica voluta conservata",
Math.Abs(driftAfter - driftBefore) < Math.Abs(driftBefore) * 0.30,
$"{driftBefore:0.0} px → {driftAfter:0.0} px lungo la sequenza");
}
/// <summary>Energia delle differenze seconde: zero su una rampa, alta su un percorso a scatti.</summary>
private static double Roughness(double[] series)
{
if (series.Length < 3) return 0;
double sum = 0;
for (int i = 1; i < series.Length - 1; i++)
{
double d = series[i + 1] - 2 * series[i] + series[i - 1];
sum += d * d;
}
return Math.Sqrt(sum / (series.Length - 2));
}
// ================================================================== 2. regioni e transizioni
/// <summary>
/// Scena con orizzonte, un treno di nuvole che attraversa il solo cielo e un cambio di
/// sensibilità a metà sequenza, dichiarato nei metadati e visibile nei pixel.
/// </summary>
internal static SyntheticSequence.Definition SceneDefinition => new(
FrameCount: 40, Width: 480, Height: 320,
IntervalSeconds: 2.0, ExposureSeconds: 0.02,
FlickerStops: 0.10, RampStops: -0.35,
PauseAtFrame: 0, PauseFrames: 0,
ShiftX: 0, ShiftY: 0,
ExposureStepAt: 20, ExposureStepStops: 1.0,
SkyGround: true, SkyFraction: 0.4, CloudStrength: 0.55);
private static void PhotometricRegions(string workingDirectory, Report add, TextWriter output)
{
var definition = SceneDefinition;
string directory = Path.Combine(workingDirectory, "scena");
output.WriteLine($"Generazione di {definition.FrameCount} fotogrammi con orizzonte, " +
$"nuvole e cambio di sensibilità in {directory}");
var paths = SyntheticSequence.Write(directory, definition);
var project = BuildSceneProject(paths, definition);
new RenderPipeline(project).AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
// ---- segmentazione
var mask = project.Mask;
double coverage = mask?.Coverage ?? 0;
add("Segmentazione — cielo separato dal paesaggio",
mask is not null && Math.Abs(coverage - definition.SkyFraction) < 0.06,
mask is null
? "nessuna maschera prodotta"
: $"copertura {coverage * 100:0.#}% (attesa {definition.SkyFraction * 100:0}%), {mask.Description}");
// ---- transizioni giorno-notte
var transitions = project.Transitions!;
bool stepFound = transitions.StepFrames.Length == 1 &&
transitions.StepFrames[0] == definition.ExposureStepAt;
add("Holy Grail — cambio di impostazione riconosciuto", stepFound,
transitions.StepCount == 0
? "nessun gradino individuato"
: $"gradino al fotogramma {string.Join(", ", transitions.StepFrames)} " +
$"di {transitions.LargestStepStops:0.00} stop " +
$"(atteso {definition.ExposureStepAt}, {definition.ExposureStepStops:0.00} stop)");
var curve = project.Curve!;
double jumpBefore = MaxStep(curve.Measured);
double jumpAfter = MaxStep(curve.Target);
add("Holy Grail — gradino ridistribuito sulla transizione", jumpAfter < jumpBefore * 0.25,
$"salto massimo fra fotogrammi adiacenti {jumpBefore:0.000} → {jumpAfter:0.000} stop");
// ---- deflicker per regioni
if (curve.HasRegions && curve.MeasuredLow is { } measuredLow && curve.TargetLow is { } targetLow)
{
// Correzione globale applicata al paesaggio: eredita le oscillazioni che le
// nuvole hanno prodotto nel cielo, perché la misura da cui nasce le contiene.
var groundUnderGlobal = new double[measuredLow.Length];
for (int i = 0; i < measuredLow.Length; i++)
groundUnderGlobal[i] = measuredLow[i] + curve.GainStops[i];
double globalNoise = DeflickerCurve.FlickerIndex(groundUnderGlobal);
double regionalNoise = DeflickerCurve.FlickerIndex(targetLow);
add("Deflicker regionale — il paesaggio non insegue le nuvole",
regionalNoise < globalNoise * 0.5,
$"sfarfallio del terreno {globalNoise:0.0000} stop con la curva globale, " +
$"{regionalNoise:0.0000} con quella di regione");
}
else
{
add("Deflicker regionale — il paesaggio non insegue le nuvole", false,
"le curve di regione non sono state calcolate");
}
}
internal static TitanoProject BuildSceneProject(IReadOnlyList<string> paths,
SyntheticSequence.Definition definition)
{
var project = new TitanoProject();
project.Sequence = TimelapseSequence.Build(paths.Select(MetadataReader.Read));
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
project.DetectOrientation();
project.General.AnalysisWidth = definition.Width;
project.General.DecodeParallelism = 4;
project.Regions.Mode = RegionMode.SkyGround;
project.Regions.SampleFrames = 12;
project.Regions.AnalysisWidth = 240;
project.HolyGrail.Enabled = true;
project.HolyGrail.TransitionFrames = 24;
project.Deflicker.WindowFrames = 15;
project.Deflicker.MaxCorrectionStops = 1.5;
return project;
}
private static double MaxStep(double[] series)
{
double worst = 0;
for (int i = 1; i < series.Length; i++) worst = Math.Max(worst, Math.Abs(series[i] - series[i - 1]));
return worst;
}
// ================================================================== 3. deriva cromatica
/// <summary>
/// Il bilanciamento del bianco deve perdere il tremolio e conservare il viaggio: in un
/// tramonto la luce si scalda davvero, e una correzione che riportasse ogni fotogramma al
/// bianco neutro cancellerebbe il soggetto invece di ripulirlo.
/// </summary>
private static void ColourDrift(Report add)
{
const int count = 60;
var stats = new LuminanceStats[count];
var before = new double[count];
for (int i = 0; i < count; i++)
{
double warming = -0.60 * i / (count - 1.0); // il blu cala lungo il tramonto
double jitter = ((i * 7 + i * i % 5) % 7 - 3) / 3.0 * 0.09;
stats[i] = new LuminanceStats
{
Log2Average = -2.0,
Log2AverageR = -2.0,
Log2AverageG = -2.0,
Log2AverageB = -2.0 + warming + jitter,
};
before[i] = stats[i].Log2AverageB;
}
var settings = new DeflickerSettings { Enabled = true, StabilizeColor = false, WindowFrames = 15 };
var holyGrail = new HolyGrailSettings
{
Enabled = true,
SmoothColor = true,
ColorWindowFrames = 15,
ColorStrength = 1.0,
MaxColorShiftStops = 1.0,
};
var analysis = HolyGrailEngine.Analyze(stats, [], holyGrail);
var curve = DeflickerEngine.Compute(stats, settings, null, analysis, holyGrail);
var after = new double[count];
for (int i = 0; i < count; i++)
after[i] = stats[i].Log2AverageB + Math.Log2(curve.ChannelGain[i][2]);
double noiseBefore = DeflickerCurve.FlickerIndex(before);
double noiseAfter = DeflickerCurve.FlickerIndex(after);
add("Bilanciamento del bianco — tremolio cromatico rimosso", noiseAfter < noiseBefore * 0.25,
$"{noiseBefore:0.0000} → {noiseAfter:0.0000} stop RMS sul canale blu");
double driftBefore = before[^1] - before[0];
double driftAfter = after[^1] - after[0];
add("Bilanciamento del bianco — deriva del tramonto conservata",
Math.Abs(driftAfter - driftBefore) < 0.15,
$"{driftBefore:0.00} stop → {driftAfter:0.00} stop dal primo all'ultimo fotogramma");
double temperature = ColorScience.CorrelatedColorTemperature(1.0, 1.0, 1.0);
add("Temperatura di colore — bianco di riferimento",
Math.Abs(temperature - 6504) < 120, $"{ColorScience.Describe(temperature)} per un grigio neutro");
}
// ================================================================== 4. accelerazione
private static void Easing(Report add)
{
bool monotone = true;
double previous = -1;
for (int i = 0; i <= 200; i++)
{
double value = Spline.Ease(i / 200.0, 0.42, 0, 0.58, 1);
if (value < previous - 1e-9) monotone = false;
previous = value;
}
double start = Spline.Ease(0, 0.42, 0, 0.58, 1);
double middle = Spline.Ease(0.5, 0.42, 0, 0.58, 1);
double end = Spline.Ease(1, 0.42, 0, 0.58, 1);
add("Accelerazione di Bézier — curva monotona con estremi esatti",
monotone && Math.Abs(start) < 1e-6 && Math.Abs(end - 1) < 1e-6 && Math.Abs(middle - 0.5) < 0.02,
$"0 → {middle:0.000} → 1, monotona su 201 campioni");
// Una maniglia asimmetrica deve spostare il baricentro del movimento: partenza lenta
// significa meno strada percorsa a metà tempo.
double lazy = Spline.Ease(0.5, 0.9, 0, 0.9, 1);
add("Accelerazione di Bézier — maniglie asimmetriche", lazy < middle - 0.05,
$"a metà tempo {lazy:0.000} con partenza indugiata contro {middle:0.000} con la curva simmetrica");
var camera = new VirtualCameraSettings
{
Enabled = true,
Keyframes =
[
new() { Time = 0.0, CentreX = 0.5, CentreY = 0.5, Zoom = 1.0 },
new() { Time = 1.0, CentreX = 0.7, CentreY = 0.4, Zoom = 2.0 },
],
};
var atStart = VirtualCamera.Resolve(camera, 0);
var atEnd = VirtualCamera.Resolve(camera, 1);
var atMiddle = VirtualCamera.Resolve(camera, 0.5);
bool endpoints = Math.Abs(atStart.Zoom - 1) < 1e-6 && Math.Abs(atEnd.Zoom - 2) < 1e-6 &&
Math.Abs(atEnd.CentreX - 0.7) < 1e-6;
// Lo zoom si interpola in scala logaritmica: fra 1× e 2× il punto di mezzo è √2.
bool geometric = Math.Abs(atMiddle.Zoom - Math.Sqrt(2)) < 0.02;
add("Movimento virtuale — nodi rispettati e zoom geometrico", endpoints && geometric,
$"zoom 1× → {atMiddle.Zoom:0.000}× → 2×, centro finale {atEnd.CentreX:0.00}");
}
// ================================================================== 5. stadio geometrico
private static void Geometry(Report add)
{
const int width = 256, height = 160;
var pool = new FrameBufferPool(6);
using var source = pool.Rent(width, height);
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int index = source.Offset(x, y);
float value = x / (float)(width - 1);
source.Data[index] = value;
source.Data[index + 1] = value;
source.Data[index + 2] = y / (float)(height - 1);
}
}
// Nessuna trasformazione e stessa dimensione: lo stadio deve essere trasparente.
var identity = GeometryStage.Build(width, height, width, height,
CameraFraming.Full, SimilarityTransform.Identity);
using var copy = pool.Rent(width, height);
GeometryStage.Resample(source, copy, identity);
double worstIdentity = 0;
for (int i = 0; i < source.SampleCount; i++)
worstIdentity = Math.Max(worstIdentity, Math.Abs(source.Data[i] - copy.Data[i]));
add("Stadio geometrico — trasparente senza trasformazioni",
identity.IsIdentity && worstIdentity < 1e-6,
$"scarto massimo {worstIdentity:0.000000} sull'intero fotogramma");
// Ritaglio centrale al doppio: l'uscita deve coprire la metà centrale della rampa,
// quindi passare da 0,25 a 0,75 invece che da 0 a 1.
var zoomed = GeometryStage.Build(width, height, width, height,
new CameraFraming(0.5, 0.5, 2.0), SimilarityTransform.Identity);
using var cropped = pool.Rent(width, height);
GeometryStage.Resample(source, cropped, zoomed);
float left = cropped.Data[cropped.Offset(0, height / 2)];
float right = cropped.Data[cropped.Offset(width - 1, height / 2)];
float centre = cropped.Data[cropped.Offset(width / 2, height / 2)];
add("Stadio geometrico — ritaglio al doppio ingrandimento",
Math.Abs(left - 0.25) < 0.01 && Math.Abs(right - 0.75) < 0.01 && Math.Abs(centre - 0.5) < 0.01,
$"rampa orizzontale letta da {left:0.000} a {right:0.000}, centro {centre:0.000}");
}
// ================================================================== 6. rimappatura del tempo
private static void Ramp(Report add)
{
const int count = 60;
var metadata = new FrameMetadata[count];
var origin = new DateTime(2026, 6, 1, 20, 0, 0, DateTimeKind.Unspecified);
for (int i = 0; i < count; i++)
{
metadata[i] = new FrameMetadata
{
FilePath = $"sintetico-{i:D3}.jpg",
FileName = $"sintetico-{i:D3}.jpg",
CaptureTime = origin.AddSeconds(2 * i),
ExposureSeconds = 0.02,
PixelWidth = 640,
PixelHeight = 360,
};
}
var sequence = TimelapseSequence.Build(metadata);
var export = new Video.ExportSettings { FrameRate = 30, Timing = Video.FrameTimingMode.Constant };
uint baseUnits = (uint)(export.Timescale / export.FrameRate);
var ramp = new TimeRampSettings
{
Enabled = true,
Speed = [new(0.0, 3.0), new(0.5, 0.25), new(1.0, 3.0)],
};
// La spline monotona non deve mai scendere sotto zero fra i nodi: una velocità
// negativa farebbe tornare indietro la sequenza.
double lowest = double.MaxValue;
for (int i = 0; i <= 400; i++) lowest = Math.Min(lowest, ramp.SpeedAt(i / 400.0));
add("Rimappatura del tempo — velocità sempre positiva", lowest > 0,
$"minimo della curva {lowest:0.000}× fra nodi da 3× e 0,25×");
var plan = RenderPlanner.Build(sequence, export, ramp, baseUnits);
bool monotone = true;
for (int i = 1; i < plan.Count; i++)
{
if (plan.Frames[i].SourcePosition < plan.Frames[i - 1].SourcePosition - 1e-9) monotone = false;
}
bool covers = plan.Count > 0 &&
plan.Frames[0].SourcePosition < 1e-9 &&
plan.Frames[^1].SourcePosition > count - 4;
add("Rimappatura del tempo — percorso monotono e completo", monotone && covers,
$"{plan.Count} fotogrammi d'uscita da {count} scatti, " +
$"da {plan.Frames[0].SourcePosition:0.00} a {plan.Frames[^1].SourcePosition:0.00}");
// La parte centrale è rallentata: lì i passi devono essere molto più fitti che agli
// estremi, dove la curva chiede tre scatti per fotogramma.
double middleStep = plan.Frames[plan.Count / 2].Speed;
double edgeStep = plan.Frames[0].Speed;
add("Rimappatura del tempo — rallentamento effettivo al centro",
middleStep < edgeStep * 0.2 && plan.Count > count,
$"passo {edgeStep:0.00} scatti agli estremi contro {middleStep:0.00} al centro");
}
// ================================================================== 7. accumulo temporale
private static void Stacking(Report add)
{
const int width = 96, height = 64;
var pool = new FrameBufferPool(16);
var window = new List<ImageBuffer>();
const float background = 0.30f;
const float intruder = 0.95f;
int blobX = width / 3, blobY = height / 2;
for (int k = 0; k < 5; k++)
{
var frame = pool.Rent(width, height);
for (int i = 0; i < frame.SampleCount; i++) frame.Data[i] = background;
// Un intruso presente in un solo fotogramma della finestra: una persona che passa.
if (k == 2)
{
for (int y = blobY - 3; y <= blobY + 3; y++)
{
for (int x = blobX - 3; x <= blobX + 3; x++)
{
int index = frame.Offset(x, y);
frame.Data[index] = intruder;
frame.Data[index + 1] = intruder;
frame.Data[index + 2] = intruder;
}
}
}
window.Add(frame);
}
using var median = pool.Rent(width, height);
TemporalStacker.Median(window, null, window[2], median, 1.0);
float atBlob = median.Data[median.Offset(blobX, blobY)];
float average = (4 * background + intruder) / 5f;
add("Stacking mediano — intruso occasionale rimosso",
Math.Abs(atBlob - background) < 1e-5,
$"pixel dell'intruso {atBlob:0.0000} contro {background:0.0000} dello sfondo " +
$"(una media lascerebbe {average:0.0000})");
// Massimo progressivo: quattro luci in posizioni diverse devono restare tutte accese.
using var accumulator = pool.Rent(width, height);
Array.Clear(accumulator.Data, 0, accumulator.SampleCount);
var positions = new (int X, int Y)[] { (10, 10), (30, 20), (50, 30), (70, 40) };
foreach (var (x, y) in positions)
{
using var star = pool.Rent(width, height);
Array.Clear(star.Data, 0, star.SampleCount);
int index = star.Offset(x, y);
star.Data[index] = star.Data[index + 1] = star.Data[index + 2] = 0.9f;
TemporalStacker.Accumulate(accumulator, star, SourceMapping.Identity, 1f);
}
int lit = positions.Count(p => accumulator.Data[accumulator.Offset(p.X, p.Y)] > 0.89f);
add("Stacking a massima luminanza — la scia conserva tutti i passaggi", lit == positions.Length,
$"{lit} punti su {positions.Length} conservati nell'accumulatore");
// Con una scia di lunghezza finita l'accumulo deve spegnersi: il primo passaggio,
// dopo tre fotogrammi, è già molto più debole dell'ultimo.
float fade = TemporalStacker.FadeFactor(2.0);
add("Stacking a massima luminanza — decadimento della coda", fade is > 0.5f and < 0.7f,
$"fattore di decadimento {fade:0.000} per una scia di 2 fotogrammi");
foreach (var frame in window) frame.Dispose();
}
// ================================================================== 8. integrazione
/// <summary>
/// Tutti i moduli accesi insieme su una sequenza reale, fino al file riprodotto dal
/// lettore di sistema. È l'unico controllo che può accorgersi di un'incompatibilità fra
/// due moduli che, presi da soli, funzionano entrambi.
/// </summary>
private static void Integration(string workingDirectory, Report add, TextWriter output)
{
string directory = Path.Combine(workingDirectory, "scena");
if (!Directory.Exists(directory))
{
add("Integrazione — tutti i moduli attivi insieme", false, "scena di prova non disponibile");
return;
}
var definition = SceneDefinition;
var paths = Directory.GetFiles(directory, "*.jpg").OrderBy(p => p, StringComparer.Ordinal).ToArray();
var project = BuildSceneProject(paths, definition);
project.Stabilization.Enabled = true;
project.Stabilization.SmoothingFrames = 15;
project.Stabilization.MinConfidence = 1.5;
project.Camera.Enabled = true;
project.Camera.Keyframes =
[
new() { Time = 0.0, CentreX = 0.40, CentreY = 0.45, Zoom = 1.15 },
new() { Time = 1.0, CentreX = 0.60, CentreY = 0.55, Zoom = 1.45 },
];
project.Stacking.Mode = StackingMode.Median;
project.Stacking.WindowFrames = 5;
project.TimeRamp.Enabled = true;
project.TimeRamp.Speed = [new(0.0, 1.5), new(0.5, 0.6), new(1.0, 1.5)];
project.Export.OutputPath = Path.Combine(workingDirectory, "titano-moduli-avanzati.mp4");
project.Export.Width = 320;
project.Export.Height = 214;
project.Export.FrameRate = 24;
project.Export.BitrateMbps = 12;
project.Cache.PrefetchDepth = 6;
// Tetto di memoria volutamente stretto: obbliga la finestra a usare il disco, così il
// percorso di parcheggio viene esercitato invece di restare teorico.
project.Cache.MemoryBudgetMiB = 4;
project.Cache.AllowDiskSpill = true;
output.WriteLine("Rendering con stabilizzazione, movimento virtuale, mediana e rimappatura attivi");
RenderResult? result = null;
string detail;
try
{
var pipeline = new RenderPipeline(project);
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
detail = $"{result.EncodedFrames} fotogrammi, {result.PlanDescription}, " +
$"{result.Elapsed.TotalSeconds:0.0} s";
}
catch (Exception ex)
{
detail = ex.Message;
}
add("Integrazione — tutti i moduli attivi insieme", result is { EncodedFrames: > 0 }, detail);
if (result is null) return;
var playback = Mp4Playback.Read(result.OutputPath);
add("Integrazione — il video prodotto è riproducibile",
playback.Error is null && playback.FrameCount == result.EncodedFrames &&
playback.Width == project.Export.Width,
playback.Error ?? $"{playback.FrameCount} fotogrammi riletti a {playback.Width}×{playback.Height}");
add("Parcheggio su disco — attivato dal tetto di memoria", result.UsedDisk,
result.UsedDisk
? $"{result.SpilledFrames} fotogrammi parcheggiati, {result.SpillBytes / (1024.0 * 1024.0):0.0} MiB scritti"
: "nessun fotogramma parcheggiato: il tetto non è stato raggiunto");
// Il file di parcheggio nasce con la cancellazione automatica: a fine esportazione
// nella cartella temporanea non deve restare nulla.
var leftovers = Directory.GetFiles(Path.GetTempPath(), "titano-*.frames");
add("Parcheggio su disco — nessun file temporaneo sopravvissuto", leftovers.Length == 0,
leftovers.Length == 0 ? "cartella temporanea pulita" : $"{leftovers.Length} file rimasti");
}
}
+168
View File
@@ -0,0 +1,168 @@
using System.Buffers.Binary;
using System.Text;
using Titano.Metadata;
namespace Titano.Diagnostics;
/// <summary>
/// Generatore di blocchi Exif usato dalla diagnostica interna: costruisce un APP1 completo
/// (header TIFF, IFD0, Exif IFD, area dati) e lo inserisce in un JPEG esistente.
/// Serve a verificare il parser binario su dati di forma nota.
/// </summary>
internal static class ExifWriter
{
private sealed record Entry(ushort Tag, TiffType Type, uint Count, byte[] Data);
public static byte[] BuildExifBlock(DateTime capture, int subSecond, double exposureSeconds,
double fNumber, int iso, int width, int height,
string make, string model)
{
var ifd0 = new List<Entry>
{
Ascii(TiffTags.Make, make),
Ascii(TiffTags.Model, model),
Short(TiffTags.Orientation, 1),
};
var exif = new List<Entry>
{
Rational(TiffTags.ExposureTime, exposureSeconds),
Rational(TiffTags.FNumber, fNumber),
Short(TiffTags.IsoSpeedRatings, (ushort)Math.Clamp(iso, 0, ushort.MaxValue)),
Ascii(TiffTags.DateTimeOriginal, capture.ToString("yyyy:MM:dd HH:mm:ss")),
Ascii(TiffTags.SubSecTimeOriginal, subSecond.ToString("D2")),
Ascii(TiffTags.OffsetTimeOriginal, "+02:00"),
Long(TiffTags.PixelXDimension, (uint)width),
Long(TiffTags.PixelYDimension, (uint)height),
};
// Le voci di ogni IFD devono essere ordinate per tag crescente.
ifd0.Sort((a, b) => a.Tag.CompareTo(b.Tag));
exif.Sort((a, b) => a.Tag.CompareTo(b.Tag));
const int headerSize = 8;
int ifd0Size = 2 + 12 * (ifd0.Count + 1) + 4; // + puntatore all'Exif IFD
int exifSize = 2 + 12 * exif.Count + 4;
int ifd0Offset = headerSize;
int exifOffset = ifd0Offset + ifd0Size;
int dataOffset = exifOffset + exifSize;
var data = new MemoryStream();
var body = new MemoryStream();
// Header TIFF little-endian.
WriteUInt16(body, 0x4949);
WriteUInt16(body, 42);
WriteUInt32(body, (uint)ifd0Offset);
var pointerEntry = new Entry(TiffTags.ExifIfdPointer, TiffType.Long, 1, BitConverter.GetBytes((uint)exifOffset));
var ifd0WithPointer = new List<Entry>(ifd0) { pointerEntry };
ifd0WithPointer.Sort((a, b) => a.Tag.CompareTo(b.Tag));
WriteDirectory(body, ifd0WithPointer, data, dataOffset);
WriteUInt32(body, 0); // nessuna IFD successiva
WriteDirectory(body, exif, data, dataOffset);
WriteUInt32(body, 0);
body.Write(data.GetBuffer(), 0, (int)data.Length);
return body.ToArray();
}
/// <summary>Inserisce il segmento APP1 in un JPEG, subito dopo l'eventuale APP0/JFIF.</summary>
public static byte[] InsertIntoJpeg(byte[] jpeg, byte[] exifBlock)
{
int insertAt = 2;
if (jpeg.Length > 4 && jpeg[2] == 0xFF && jpeg[3] == 0xE0)
{
int app0Length = (jpeg[4] << 8) | jpeg[5];
insertAt = 4 + app0Length;
}
byte[] signature = "Exif\0\0"u8.ToArray();
int payload = signature.Length + exifBlock.Length;
int segmentLength = payload + 2;
var result = new byte[jpeg.Length + 4 + payload];
int position = 0;
Array.Copy(jpeg, 0, result, position, insertAt);
position += insertAt;
result[position++] = 0xFF;
result[position++] = 0xE1;
result[position++] = (byte)(segmentLength >> 8);
result[position++] = (byte)(segmentLength & 0xFF);
Array.Copy(signature, 0, result, position, signature.Length);
position += signature.Length;
Array.Copy(exifBlock, 0, result, position, exifBlock.Length);
position += exifBlock.Length;
Array.Copy(jpeg, insertAt, result, position, jpeg.Length - insertAt);
return result;
}
// ------------------------------------------------------------------ scrittura IFD
private static void WriteDirectory(Stream body, List<Entry> entries, MemoryStream data, int dataOffset)
{
WriteUInt16(body, (ushort)entries.Count);
Span<byte> inline = stackalloc byte[4];
foreach (var entry in entries)
{
WriteUInt16(body, entry.Tag);
WriteUInt16(body, (ushort)entry.Type);
WriteUInt32(body, entry.Count);
if (entry.Data.Length <= 4)
{
inline.Clear();
entry.Data.CopyTo(inline);
body.Write(inline);
}
else
{
WriteUInt32(body, (uint)(dataOffset + data.Length));
data.Write(entry.Data);
if ((data.Length & 1) != 0) data.WriteByte(0); // le aree dati restano allineate
}
}
}
private static Entry Ascii(ushort tag, string value)
{
byte[] bytes = Encoding.ASCII.GetBytes(value + "\0");
return new Entry(tag, TiffType.Ascii, (uint)bytes.Length, bytes);
}
private static Entry Short(ushort tag, ushort value)
=> new(tag, TiffType.Short, 1, BitConverter.GetBytes(value));
private static Entry Long(ushort tag, uint value)
=> new(tag, TiffType.Long, 1, BitConverter.GetBytes(value));
private static Entry Rational(ushort tag, double value)
{
// Approssimazione con denominatore fisso: sufficiente e priva di ambiguità.
uint denominator = 1_000_000;
uint numerator = (uint)Math.Round(value * denominator);
var bytes = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, numerator);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), denominator);
return new Entry(tag, TiffType.Rational, 1, bytes);
}
private static void WriteUInt16(Stream stream, ushort value)
{
Span<byte> buffer = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
stream.Write(buffer);
}
private static void WriteUInt32(Stream stream, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
stream.Write(buffer);
}
}
+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;
}
}
+191
View File
@@ -0,0 +1,191 @@
using System.Buffers.Binary;
using System.Text;
namespace Titano.Diagnostics;
/// <summary>
/// Verificatore della struttura ISO-BMFF prodotta dal multiplexer: percorre l'albero dei box
/// e ne estrae i valori che devono risultare coerenti (numero di campioni, dimensioni,
/// presenza della configurazione del codec).
/// </summary>
internal static class Mp4Inspector
{
public sealed record Report(
bool Valid,
string Summary,
int SampleCount,
int Width,
int Height,
uint Timescale,
long MediaDuration,
int CodecConfigBytes,
List<string> TopLevelBoxes,
string? SampleProblem);
private static readonly string[] Containers =
["moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "avc1", "hvc1"];
public static Report Inspect(string path)
{
var boxes = new List<string>();
var sampleSizes = new List<uint>();
long firstChunkOffset = -1;
int sampleCount = 0, width = 0, height = 0, configBytes = 0;
uint timescale = 0;
long mediaDuration = 0;
var problems = new List<string>();
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
long length = stream.Length;
Walk(stream, 0, length, 0);
bool hasFtyp = boxes.Contains("ftyp");
bool hasMdat = boxes.Contains("mdat");
bool hasMoov = boxes.Contains("moov");
if (!hasFtyp) problems.Add("box ftyp assente");
if (!hasMdat) problems.Add("box mdat assente");
if (!hasMoov) problems.Add("box moov assente");
if (sampleCount == 0) problems.Add("tabella stsz vuota");
if (configBytes == 0) problems.Add("configurazione del codec assente");
string? sampleProblem = VerifySampleData(stream, sampleSizes, firstChunkOffset);
if (sampleProblem is not null) problems.Add(sampleProblem);
string summary = problems.Count == 0
? "struttura conforme"
: string.Join(", ", problems);
return new Report(problems.Count == 0, summary, sampleCount, width, height,
timescale, mediaDuration, configBytes, boxes, sampleProblem);
void Walk(FileStream file, long start, long end, int depth)
{
Span<byte> header = stackalloc byte[8];
long position = start;
while (position + 8 <= end && depth < 8)
{
file.Position = position;
if (file.Read(header) != 8) return;
long size = BinaryPrimitives.ReadUInt32BigEndian(header);
string type = Encoding.ASCII.GetString(header[4..]);
long payload = position + 8;
if (size == 1)
{
if (file.Read(header) != 8) return;
size = BinaryPrimitives.ReadInt64BigEndian(header);
payload += 8;
}
else if (size == 0)
{
size = end - position;
}
if (size < 8 || position + size > end) return;
if (depth == 0) boxes.Add(type);
switch (type)
{
case "stsz":
{
file.Position = payload + 8; // versione/flag + sample_size
sampleCount = (int)ReadUInt32(file);
for (int s = 0; s < sampleCount && s < 100_000; s++) sampleSizes.Add(ReadUInt32(file));
break;
}
case "co64":
file.Position = payload + 8; // versione/flag + entry_count
firstChunkOffset = (long)ReadUInt64(file);
break;
case "stco":
file.Position = payload + 8;
firstChunkOffset = ReadUInt32(file);
break;
case "mdhd":
file.Position = payload + 12; // versione/flag + due timestamp
timescale = ReadUInt32(file);
mediaDuration = ReadUInt32(file);
break;
case "avcC" or "hvcC":
configBytes = (int)(size - (payload - position));
break;
case "avc1" or "hvc1":
file.Position = payload + 24;
width = ReadUInt16(file);
height = ReadUInt16(file);
Walk(file, payload + 78, position + size, depth + 1);
break;
}
if (Containers.Contains(type) && type is not ("avc1" or "hvc1"))
{
long childStart = type == "stsd" ? payload + 8 : payload;
Walk(file, childStart, position + size, depth + 1);
}
else if (type == "stsd")
{
Walk(file, payload + 8, position + size, depth + 1);
}
position += size;
}
}
static ulong ReadUInt64(FileStream file)
{
Span<byte> buffer = stackalloc byte[8];
return file.Read(buffer) == 8 ? BinaryPrimitives.ReadUInt64BigEndian(buffer) : 0;
}
static uint ReadUInt32(FileStream file)
{
Span<byte> buffer = stackalloc byte[4];
return file.Read(buffer) == 4 ? BinaryPrimitives.ReadUInt32BigEndian(buffer) : 0;
}
static ushort ReadUInt16(FileStream file)
{
Span<byte> buffer = stackalloc byte[2];
return file.Read(buffer) == 2 ? BinaryPrimitives.ReadUInt16BigEndian(buffer) : (ushort)0;
}
}
/// <summary>
/// Verifica che ogni campione sia una successione esatta di NAL con lunghezza prefissata.
/// Un prefisso nullo o che sborda dal campione significa dati corrotti dal multiplexer:
/// il contenitore resterebbe formalmente valido ma il flusso sarebbe indecodificabile.
/// </summary>
private static string? VerifySampleData(FileStream stream, List<uint> sizes, long firstChunkOffset)
{
if (sizes.Count == 0 || firstChunkOffset < 0) return null;
long position = firstChunkOffset;
Span<byte> prefix = stackalloc byte[4];
for (int index = 0; index < sizes.Count; index++)
{
long end = position + sizes[index];
if (end > stream.Length) return $"campione {index + 1} oltre la fine del file";
long cursor = position;
while (cursor + 4 <= end)
{
stream.Position = cursor;
if (stream.Read(prefix) != 4) return $"campione {index + 1} troncato";
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length == 0) return $"campione {index + 1}: NAL di lunghezza nulla";
if (cursor + 4 + length > end) return $"campione {index + 1}: NAL che sborda dal campione";
cursor += 4 + length;
}
if (cursor != end) return $"campione {index + 1}: {end - cursor} byte residui non allineati";
position = end;
}
return null;
}
}
+188
View File
@@ -0,0 +1,188 @@
using System.Runtime.InteropServices;
using Titano.Video;
namespace Titano.Diagnostics;
[ComImport, Guid("70ae66f2-c809-4e4f-8915-bdcb406b7993"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFSourceReader
{
[PreserveSig] int GetStreamSelection(uint streamIndex, [MarshalAs(UnmanagedType.Bool)] out bool selected);
[PreserveSig] int SetStreamSelection(uint streamIndex, [MarshalAs(UnmanagedType.Bool)] bool selected);
[PreserveSig] int GetNativeMediaType(uint streamIndex, uint typeIndex, out IMFMediaType type);
[PreserveSig] int GetCurrentMediaType(uint streamIndex, out IMFMediaType type);
[PreserveSig] int SetCurrentMediaType(uint streamIndex, IntPtr reserved, IMFMediaType type);
[PreserveSig] int SetCurrentPosition(ref Guid timeFormat, ref PropVariant position);
[PreserveSig] int ReadSample(uint streamIndex, uint controlFlags, out uint actualStreamIndex,
out uint streamFlags, out long timestamp, out IMFSample? sample);
[PreserveSig] int Flush(uint streamIndex);
[PreserveSig] int GetServiceForStream(uint streamIndex, ref Guid service, ref Guid riid, out IntPtr instance);
[PreserveSig] int GetPresentationAttribute(uint streamIndex, ref Guid attribute, IntPtr value);
}
/// <summary>
/// Rilegge un MP4 prodotto dal multiplexer usando il lettore di sistema: è la prova che il
/// contenitore non è soltanto ben formato sulla carta, ma effettivamente riproducibile.
/// </summary>
internal static class Mp4Playback
{
private const uint FirstVideoStream = 0xFFFFFFFC;
private const uint AllStreams = 0xFFFFFFFE;
private const uint EndOfStream = 0x00000002;
private static Guid VideoFormatRgb32 = new("00000016-0000-0010-8000-00aa00389b71");
private static Guid EnableVideoProcessing = new("fb394f3d-ccf1-42ee-bbb3-f9b845d5681d");
[DllImport("mfreadwrite.dll", ExactSpelling = true)]
private static extern int MFCreateSourceReaderFromURL([MarshalAs(UnmanagedType.LPWStr)] string url,
IMFAttributes? attributes, out IMFSourceReader reader);
[DllImport("mfplat.dll", ExactSpelling = true)]
private static extern int MFCreateAttributes(out IMFAttributes attributes, uint initialSize);
public sealed record Playback(int FrameCount, int Width, int Height, List<double> MeanLuma, string? Error);
public static Playback Read(string path, int maxFrames = 4096)
{
MediaFoundationRuntime.Startup();
IMFAttributes? attributes = null;
IMFSourceReader? reader = null;
try
{
if (MFCreateAttributes(out attributes, 1) >= 0)
{
var key = EnableVideoProcessing;
attributes.SetUINT32(ref key, 1);
}
int hr = MFCreateSourceReaderFromURL(path, attributes, out reader);
if (hr < 0) return new Playback(0, 0, 0, [], $"apertura non riuscita (HRESULT 0x{hr:X8})");
reader.SetStreamSelection(AllStreams, false);
reader.SetStreamSelection(FirstVideoStream, true);
int width = 0, height = 0;
if (reader.GetNativeMediaType(FirstVideoStream, 0, out IMFMediaType native) >= 0)
{
try
{
var sizeKey = MediaFoundation.MtFrameSize;
if (native.GetUINT64(ref sizeKey, out ulong packed) >= 0)
{
width = (int)(packed >> 32);
height = (int)(packed & 0xFFFFFFFF);
}
}
finally
{
Marshal.ReleaseComObject(native);
}
}
// Si richiede RGB a 8 bit: il lettore inserisce da sé il convertitore di formato.
if (MediaFoundation.MFCreateMediaType(out IMFMediaType target) >= 0)
{
try
{
var majorKey = MediaFoundation.MtMajorType;
var majorValue = MediaFoundation.MajorTypeVideo;
target.SetGUID(ref majorKey, ref majorValue);
var subKey = MediaFoundation.MtSubtype;
target.SetGUID(ref subKey, ref VideoFormatRgb32);
reader.SetCurrentMediaType(FirstVideoStream, IntPtr.Zero, target);
}
finally
{
Marshal.ReleaseComObject(target);
}
}
var luma = new List<double>();
int frames = 0;
int emptyReads = 0;
uint flags = 0;
while (frames < maxFrames)
{
hr = reader.ReadSample(FirstVideoStream, 0, out _, out flags, out _, out IMFSample? sample);
if (hr < 0) return new Playback(frames, width, height, luma, $"lettura interrotta (HRESULT 0x{hr:X8})");
if (sample is not null)
{
emptyReads = 0;
try
{
luma.Add(MeasureMeanLuma(sample, width, height));
frames++;
}
finally
{
Marshal.ReleaseComObject(sample);
}
}
else if (++emptyReads > 64)
{
// Il lettore restituisce campioni nulli senza mai segnalare la fine:
// meglio fermarsi che girare a vuoto.
return new Playback(frames, width, height, luma,
$"{frames} fotogrammi, poi solo campioni nulli (flag 0x{flags:X})");
}
if ((flags & EndOfStream) != 0) break;
}
return new Playback(frames, width, height, luma,
frames == 0 ? $"nessun fotogramma decodificato (flag 0x{flags:X})" : null);
}
catch (Exception ex)
{
return new Playback(0, 0, 0, [], ex.Message);
}
finally
{
if (reader is not null) Marshal.ReleaseComObject(reader);
if (attributes is not null) Marshal.ReleaseComObject(attributes);
}
}
private static unsafe double MeasureMeanLuma(IMFSample sample, int width, int height)
{
sample.ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
try
{
buffer.Lock(out IntPtr pointer, out _, out uint length);
try
{
if (length == 0 || width <= 0 || height <= 0) return 0;
int stride = (int)(length / Math.Max(1, height));
if (stride < width * 4) return 0;
byte* data = (byte*)pointer;
double sum = 0;
int count = 0;
for (int y = 0; y < height; y += 4)
{
byte* row = data + (long)y * stride;
for (int x = 0; x < width; x += 4)
{
byte* pixel = row + x * 4; // BGRX
sum += 0.2126 * pixel[2] + 0.7152 * pixel[1] + 0.0722 * pixel[0];
count++;
}
}
return count == 0 ? 0 : sum / count / 255.0;
}
finally
{
buffer.Unlock();
}
}
finally
{
Marshal.ReleaseComObject(buffer);
}
}
}
+355
View File
@@ -0,0 +1,355 @@
using System.Globalization;
using System.Text;
using Titano.Analysis;
using Titano.Imaging;
using Titano.Metadata;
using Titano.Motion;
using Titano.Pipeline;
using Titano.Video;
namespace Titano.Diagnostics;
/// <summary>
/// Verifica end-to-end del motore su una sequenza sintetica dalle proprietà note:
/// parser Exif, analisi di cadenza, deflicker, optical flow, motion blur, encoder e
/// multiplexer vengono esercitati nell'ordine in cui lavorano in produzione.
/// </summary>
public static class SelfTest
{
public static int Run(string workingDirectory, TextWriter output)
{
var checks = new List<(string Name, bool Passed, string Detail)>();
var definition = new SyntheticSequence.Definition();
output.WriteLine("Titano — verifica del motore");
output.WriteLine(new string('-', 74));
string sequenceDirectory = Path.Combine(workingDirectory, "sequenza");
output.WriteLine($"Generazione di {definition.FrameCount} fotogrammi sintetici in {sequenceDirectory}");
var paths = SyntheticSequence.Write(sequenceDirectory, definition);
// ---------------------------------------------------------------- 1. metadati
var metadata = paths.Select(MetadataReader.Read).ToList();
int withSubSecond = metadata.Count(m => m.CaptureSource == TimestampSource.ExifSubSecond);
Add(checks, "Parser Exif — timestamp con frazione di secondo",
withSubSecond == metadata.Count, $"{withSubSecond}/{metadata.Count}");
bool exposureOk = metadata.All(m => m.ExposureSeconds is { } e && Math.Abs(e - definition.ExposureSeconds) < 1e-6);
Add(checks, "Parser Exif — tempo di posa", exposureOk,
Format(metadata[0].ExposureSeconds) + " s");
bool apertureOk = metadata.All(m => m.FNumber is { } f && Math.Abs(f - 8.0) < 1e-6);
bool isoOk = metadata.All(m => m.Iso == 200);
Add(checks, "Parser Exif — apertura e ISO", apertureOk && isoOk,
$"f/{Format(metadata[0].FNumber)}, ISO {metadata[0].Iso}");
bool offsetOk = metadata.All(m => m.UtcOffset == TimeSpan.FromHours(2));
Add(checks, "Parser Exif — fuso orario", offsetOk, metadata[0].UtcOffset?.ToString() ?? "assente");
bool sizeOk = metadata.All(m => m.PixelWidth == definition.Width && m.PixelHeight == definition.Height);
Add(checks, "Parser Exif — dimensioni dichiarate", sizeOk,
$"{metadata[0].PixelWidth}×{metadata[0].PixelHeight}");
// ---------------------------------------------------------------- 2. cadenza
var project = new TitanoProject();
project.Sequence = Core.TimelapseSequence.Build(metadata);
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
double expectedInterval = definition.IntervalSeconds + 0.37;
bool cadenceOk = Math.Abs(project.Sequence.NominalInterval - expectedInterval) < 0.05;
Add(checks, "Cadenza nominale rilevata", cadenceOk,
$"{project.Sequence.NominalInterval:0.###} s (attesa {expectedInterval:0.###} s)");
bool pauseOk = project.Sequence.CadenceAnomalies >= definition.PauseFrames;
Add(checks, "Pause dell'intervallometro individuate", pauseOk,
$"{project.Sequence.CadenceAnomalies} intervalli anomali");
double expectedAngle = 360.0 * definition.ExposureSeconds / expectedInterval;
double measuredAngle = project.Sequence.Frames[0].ShutterAngle;
bool angleOk = Math.Abs(measuredAngle - expectedAngle) < 0.2;
Add(checks, "Shutter angle reale", angleOk,
$"{measuredAngle:0.00}° (atteso {expectedAngle:0.00}°)");
// ---------------------------------------------------------------- 3. deflicker
project.Export.OutputPath = Path.Combine(workingDirectory, "titano-selftest.mp4");
project.Export.FrameRate = 24;
project.Export.BitrateMbps = 20;
project.General.DecodeParallelism = 4;
var pipeline = new RenderPipeline(project);
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
var curve = project.Curve!;
double flickerBefore = DeflickerCurve.FlickerIndex(curve.Measured);
var corrected = new double[curve.Count];
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
double flickerAfter = DeflickerCurve.FlickerIndex(corrected);
Add(checks, "Sfarfallio misurato prima della correzione", flickerBefore > 0.04,
$"{flickerBefore:0.0000} stop RMS");
Add(checks, "Sfarfallio residuo dopo la correzione", flickerAfter < flickerBefore * 0.35,
$"{flickerAfter:0.0000} stop RMS ({100 * (1 - flickerAfter / flickerBefore):0.#}% di riduzione)");
// La rampa di luce deve sopravvivere: è un cambio reale, non sfarfallio.
double rampBefore = curve.Measured[^1] - curve.Measured[0];
double rampAfter = corrected[^1] - corrected[0];
bool rampOk = Math.Abs(rampAfter - rampBefore) < 0.25 && Math.Abs(rampAfter) > 0.4;
Add(checks, "Rampa di luce preservata", rampOk,
$"{rampBefore:0.00} stop → {rampAfter:0.00} stop");
// ---------------------------------------------------------------- 4. decodifica
var pool = new FrameBufferPool(6);
using var frameA = ImageDecoder.Decode(paths[10], definition.Width, definition.Height, 1, pool);
using var frameB = ImageDecoder.Decode(paths[11], definition.Width, definition.Height, 1, pool);
// La media logaritmica della luminanza è invariante alla scala: decodificare la
// stessa immagine a risoluzioni diverse deve dare lo stesso valore. È il controllo
// che smaschera una catena di decodifica mal composta, dove il ridimensionamento
// restituisce righe disallineate senza segnalare alcun errore.
double nativeLuma = LuminanceAnalyzer.Analyze(frameA).Log2Average;
double worstScaleError = 0;
foreach (int scaled in (ReadOnlySpan<int>)[320, 200])
{
int scaledHeight = Math.Max(2, (int)Math.Round(scaled * definition.Height / (double)definition.Width) & ~1);
using var small = ImageDecoder.Decode(paths[10], scaled, scaledHeight, 1, pool);
double delta = LuminanceAnalyzer.Analyze(small).Log2Average - nativeLuma;
worstScaleError = Math.Max(worstScaleError, Math.Abs(delta));
}
Add(checks, "Decodifica — luminanza invariante alla scala", worstScaleError < 0.12,
$"scarto massimo {worstScaleError:0.000} EV fra piena risoluzione e versioni ridotte");
// L'orientamento richiesto deve tradursi in pixel spostati davvero: si decodifica lo
// stesso file chiedendo lo specchiamento orizzontale e si confronta colonna per colonna.
using (var mirrored = ImageDecoder.Decode(paths[10], definition.Width, definition.Height, 2, pool))
{
double worstMirrorError = 0;
for (int y = 4; y < definition.Height - 4; y += 7)
{
for (int x = 4; x < definition.Width - 4; x += 7)
{
int direct = frameA.Offset(x, y);
int flipped = mirrored.Offset(definition.Width - 1 - x, y);
worstMirrorError = Math.Max(worstMirrorError,
Math.Abs(frameA.Data[direct] - mirrored.Data[flipped]));
}
}
Add(checks, "Orientamento — lo specchiamento sposta i pixel", worstMirrorError < 1e-5,
$"scarto massimo {worstMirrorError:0.000000} fra fotogramma diretto e specchiato ribaltato");
}
// ---------------------------------------------------------------- 5. correlazione di fase
// Verifica diretta della trasformata e della stima sotto il pixel: si costruisce una
// tessitura nota, la si trasla di una quantità frazionaria e si misura quanto la
// correlazione se ne accorge. Non serve passare dal disco: è il nucleo numerico.
{
var reference = TexturePlane(320, 320, 0, 0);
var correlator = new PhaseCorrelator(256);
double worstShiftError = 0;
foreach (var (sx, sy) in (ReadOnlySpan<(double, double)>)[(3.5, -2.25), (-1.75, 0.5), (0.25, 6.0)])
{
var shifted = TexturePlane(320, 320, sx, sy);
var measured = correlator.Correlate(reference, shifted, 32, 32);
double error = Math.Sqrt((measured.Dx - sx) * (measured.Dx - sx) +
(measured.Dy - sy) * (measured.Dy - sy));
worstShiftError = Math.Max(worstShiftError, error);
}
Add(checks, "Correlazione di fase — spostamento sotto il pixel", worstShiftError < 0.12,
$"errore massimo {worstShiftError:0.0000} px su traslazioni frazionarie note");
}
// ---------------------------------------------------------------- 6. optical flow
var flowEngine = new OpticalFlowEngine(project.Flow);
var field = flowEngine.Compute(frameA, frameB);
double expectedMagnitude = Math.Sqrt(definition.ShiftX * definition.ShiftX +
definition.ShiftY * definition.ShiftY);
double measuredMagnitude = field.MedianMagnitude();
bool flowOk = Math.Abs(measuredMagnitude - expectedMagnitude) < expectedMagnitude * 0.25;
Add(checks, "Campo vettoriale — modulo del movimento", flowOk,
$"{measuredMagnitude:0.00} px (atteso {expectedMagnitude:0.00} px)");
// Il generatore avanza le coordinate di campionamento della tessitura, quindi il
// contenuto visibile scorre nel verso opposto: il flusso atteso è l'opposto dello shift.
double expectedDirection = Math.Atan2(-definition.ShiftY, -definition.ShiftX) * 180 / Math.PI;
double measuredDirection = field.DominantDirection();
double directionError = Math.Abs(NormalizeAngle(measuredDirection - expectedDirection));
Add(checks, "Campo vettoriale — direzione dominante", directionError < 12,
$"{measuredDirection:0.0}° (atteso {expectedDirection:0.0}°)");
// ---------------------------------------------------------------- 6. motion blur
double missing = MotionBlurRenderer.MissingBlurFactor(measuredAngle, 180.0, 1.0);
using var blurred = pool.Rent(definition.Width, definition.Height);
double blurLength = MotionBlurRenderer.Render(frameA, blurred, field, missing, project.MotionBlur);
bool blurOk = blurLength > 1.5 && blurLength < 8.0;
Add(checks, "Motion blur sintetico — lunghezza della scia", blurOk,
$"{blurLength:0.00} px con fattore mancante {missing:0.000}");
double detailBefore = MeasureDetail(frameA);
double detailAfter = MeasureDetail(blurred);
Add(checks, "Motion blur sintetico — dettaglio ridotto lungo il moto",
detailAfter < detailBefore * 0.85,
$"{detailBefore:0.0000} → {detailAfter:0.0000} " +
$"({100 * (1 - detailAfter / detailBefore):0.#}% di attenuazione)");
// ---------------------------------------------------------------- 7. encoder + muxer
RenderResult? result = null;
string encodeDetail;
try
{
result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
encodeDetail = $"{result.EncodedFrames} fotogrammi, {result.OutputBytes / 1024} KiB, " +
$"{result.Elapsed.TotalSeconds:0.0} s, {result.EncoderName}" +
(result.HardwareAccelerated ? " (hardware)" : " (software)");
}
catch (Exception ex)
{
encodeDetail = ex.Message;
}
Add(checks, "Codifica video tramite encoder di sistema",
result is { EncodedFrames: > 0 }, encodeDetail);
if (result is not null && File.Exists(result.OutputPath))
{
var report = Mp4Inspector.Inspect(result.OutputPath);
Add(checks, "Contenitore MP4 — struttura dei box", report.Valid,
$"{report.Summary}; box radice: {string.Join(", ", report.TopLevelBoxes)}");
Add(checks, "Contenitore MP4 — numero di campioni",
report.SampleCount == result.EncodedFrames,
$"{report.SampleCount} campioni per {result.EncodedFrames} fotogrammi codificati");
Add(checks, "Contenitore MP4 — risoluzione dichiarata",
report.Width == definition.Width && report.Height == definition.Height,
$"{report.Width}×{report.Height}");
double declaredSeconds = report.Timescale > 0 ? report.MediaDuration / (double)report.Timescale : 0;
double expectedSeconds = result.EncodedFrames / project.Export.FrameRate;
Add(checks, "Contenitore MP4 — durata dichiarata",
Math.Abs(declaredSeconds - expectedSeconds) < 0.2,
$"{declaredSeconds:0.00} s (attesa {expectedSeconds:0.00} s)");
// Riproduzione effettiva con il lettore di sistema: prova che il contenitore
// scritto in-house è leggibile da un decoder indipendente.
var playback = Mp4Playback.Read(result.OutputPath);
Add(checks, "Riproduzione con il lettore di sistema",
playback.Error is null && playback.FrameCount == result.EncodedFrames,
playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati a {playback.Width}×{playback.Height}");
if (playback.MeanLuma.Count > 4)
{
// La luminanza riletta dal file deve essere già stabilizzata: il deflicker
// sopravvive a codifica e contenitore.
var log2 = playback.MeanLuma.Select(v => Math.Log2(Math.Max(v, 1e-4))).ToList();
double residual = DeflickerCurve.FlickerIndex(log2);
Add(checks, "Sfarfallio residuo nel video finale", residual < flickerBefore * 0.5,
$"{residual:0.0000} stop RMS contro {flickerBefore:0.0000} in origine");
}
}
// ---------------------------------------------------------------- 8. impronta di memoria
long expectedBytes = (long)definition.Width * definition.Height * 3 * sizeof(float);
bool memoryOk = result is null || result.PeakPixelMemoryBytes < expectedBytes * 24;
Add(checks, "Impronta di memoria del pool",
memoryOk,
result is null ? "non misurata" :
$"{result.PeakPixelMemoryBytes / (1024 * 1024.0):0.0} MiB " +
$"({result.PeakPixelMemoryBytes / (double)expectedBytes:0.0} fotogrammi)");
// ---------------------------------------------------------------- 9. moduli avanzati
output.WriteLine();
AdvancedModuleTests.Run(workingDirectory, (name, passed, detail) => Add(checks, name, passed, detail),
output);
// ---------------------------------------------------------------- esito
output.WriteLine();
foreach (var (name, passed, detail) in checks)
{
output.WriteLine($" [{(passed ? "OK " : "FALLITO")}] {name}");
output.WriteLine($" {detail}");
}
int failed = checks.Count(c => !c.Passed);
output.WriteLine();
output.WriteLine(new string('-', 74));
output.WriteLine(failed == 0
? $"Tutte le {checks.Count} verifiche superate."
: $"{failed} verifiche fallite su {checks.Count}.");
return failed == 0 ? 0 : 1;
}
private static void Add(List<(string, bool, string)> checks, string name, bool passed, string detail)
=> checks.Add((name, passed, detail));
/// <summary>
/// Piano di tessitura sintetica traslato di una quantità arbitraria, anche frazionaria.
/// Il rumore è definito su coordinate continue, quindi lo spostamento è esatto per
/// costruzione: non c'è ricampionamento che possa falsare la misura attesa.
/// </summary>
internal static GrayImage TexturePlane(int width, int height, double offsetX, double offsetY)
{
var plane = new GrayImage(width, height);
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
double sx = x - offsetX;
double sy = y - offsetY;
double value = 0.55 * SyntheticSequence.Texture(sx / 21.0, sy / 21.0, 17)
+ 0.30 * SyntheticSequence.Texture(sx / 6.5, sy / 6.5, 41)
+ 0.15 * SyntheticSequence.Texture(sx / 2.7, sy / 2.7, 59);
plane.Data[y * width + x] = (float)value;
}
}
return plane;
}
private static string Format(double? value)
=> value?.ToString("0.######", CultureInfo.InvariantCulture) ?? "—";
private static double NormalizeAngle(double degrees)
{
while (degrees > 180) degrees -= 360;
while (degrees < -180) degrees += 360;
return degrees;
}
/// <summary>
/// Dettaglio fine orizzontale: differenza fra pixel adiacenti sul canale verde.
/// Si usa la differenza a distanza 1 e non quella centrata, perché quest'ultima ha uno
/// zero esatto alla frequenza di Nyquist, proprio dove la sfocatura agisce di più.
/// </summary>
private static double MeasureDetail(ImageBuffer frame)
{
double sum = 0;
int count = 0;
var data = frame.Data;
for (int y = 4; y < frame.Height - 4; y += 3)
{
int rowBase = y * frame.Width * ImageBuffer.Channels;
for (int x = 4; x < frame.Width - 4; x += 3)
{
int i = rowBase + x * ImageBuffer.Channels;
sum += Math.Abs(data[i + ImageBuffer.Channels + 1] - data[i + 1]);
count++;
}
}
return count == 0 ? 0 : sum / count;
}
/// <summary>Riepilogo testuale usato anche dal pannello diagnostica dell'interfaccia.</summary>
public static string DescribeEnvironment()
{
var builder = new StringBuilder();
builder.AppendLine($"Sistema: {Environment.OSVersion.VersionString} ({(Environment.Is64BitProcess ? "x64" : "x86")})");
builder.AppendLine($"Processori logici: {Environment.ProcessorCount}");
builder.AppendLine($"Runtime: {Environment.Version}");
builder.AppendLine($"Vettori SIMD: {System.Numerics.Vector<float>.Count} float per registro");
return builder.ToString();
}
}
+236
View File
@@ -0,0 +1,236 @@
using System.Diagnostics;
using System.Globalization;
using Titano.Analysis;
using Titano.Core;
using Titano.Imaging;
using Titano.Metadata;
namespace Titano.Diagnostics;
/// <summary>
/// Analisi di una cartella reale: riporta ciò che il programma riesce davvero a leggere e
/// decodificare, file per file. Serve a distinguere un difetto del motore da un formato
/// sorgente che il sistema non sa aprire.
/// </summary>
public static class SequenceDiagnostics
{
public static int Run(string directory, TextWriter output, int sampleCount = 6,
string? renderPath = null, int renderFrames = 24,
int renderWidth = 0, Video.VideoCodec renderCodec = Video.VideoCodec.H264)
{
if (!Directory.Exists(directory))
{
output.WriteLine($"Cartella non trovata: {directory}");
return 1;
}
var files = Directory.EnumerateFiles(directory)
.Where(MetadataReader.IsSupported)
.OrderBy(path => Path.GetFileName(path), NaturalFileNameComparer.Instance)
.ToList();
output.WriteLine($"Cartella: {directory}");
output.WriteLine($"File riconosciuti: {files.Count}");
if (files.Count == 0)
{
var all = Directory.EnumerateFiles(directory).Take(5).Select(Path.GetExtension).Distinct();
output.WriteLine("Estensioni presenti ma non supportate: " + string.Join(", ", all));
return 1;
}
output.WriteLine();
// ------------------------------------------------------------------ metadati
output.WriteLine("METADATI");
output.WriteLine(new string('-', 74));
var samples = PickSamples(files, sampleCount);
var sampleMetadata = new List<FrameMetadata>();
foreach (string path in samples)
{
var stopwatch = Stopwatch.StartNew();
var metadata = MetadataReader.Read(path);
stopwatch.Stop();
sampleMetadata.Add(metadata);
output.WriteLine($" {metadata.FileName} ({metadata.FileSize / 1024 / 1024.0:0.0} MiB, letti in {stopwatch.ElapsedMilliseconds} ms)");
output.WriteLine($" scatto {Describe(metadata.CaptureTime)} [{metadata.CaptureSource}]" +
(metadata.UtcOffset is { } o ? $" fuso {o}" : string.Empty));
output.WriteLine($" posa {metadata.ExposureText} diaframma {metadata.ApertureText} ISO {metadata.IsoText}");
output.WriteLine($" dimensioni {metadata.PixelWidth}×{metadata.PixelHeight} orientamento {metadata.Orientation}");
output.WriteLine($" fotocamera {metadata.Camera ?? ""} obiettivo {metadata.Lens ?? ""}");
if (metadata.Warning is { } warning) output.WriteLine($" ATTENZIONE {warning}");
}
output.WriteLine();
// ------------------------------------------------------------------ decodifica
output.WriteLine("DECODIFICA (WIC, codec di sistema)");
output.WriteLine(new string('-', 74));
var pool = new FrameBufferPool(4);
int decoded = 0;
foreach (var metadata in sampleMetadata)
{
try
{
var stopwatch = Stopwatch.StartNew();
var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation);
long probeMs = stopwatch.ElapsedMilliseconds;
int width = Math.Max(2, probeWidth & ~1);
int height = Math.Max(2, probeHeight & ~1);
stopwatch.Restart();
using var buffer = ImageDecoder.Decode(metadata.FilePath, width, height, metadata.Orientation, pool);
long decodeMs = stopwatch.ElapsedMilliseconds;
var stats = LuminanceAnalyzer.Analyze(buffer);
decoded++;
output.WriteLine($" {metadata.FileName}");
output.WriteLine($" dimensione reale del pixel {probeWidth}×{probeHeight} (sonda {probeMs} ms)");
output.WriteLine($" dichiarata nei metadati {metadata.PixelWidth}×{metadata.PixelHeight}" +
(probeWidth != metadata.PixelWidth || probeHeight != metadata.PixelHeight
? " ← NON COINCIDE"
: string.Empty));
output.WriteLine($" decodifica {decodeMs} ms");
output.WriteLine($" luminanza media {stats.Log2Average:0.00} EV " +
$"percentili {stats.Percentile01:0.0000} / {stats.Percentile50:0.0000} / {stats.Percentile99:0.0000}");
output.WriteLine($" saturati {stats.ClippedFraction * 100:0.00}% neri {stats.BlackFraction * 100:0.00}%");
// Stessa immagine a scale diverse: la luminanza media è invariante alla scala,
// quindi qualunque scostamento denuncia una decodifica sbagliata.
foreach (int scaled in (ReadOnlySpan<int>)[2048, 1024, 512])
{
if (scaled >= width) continue;
int h2 = Math.Max(2, (int)Math.Round(scaled * height / (double)width) & ~1);
using var small = ImageDecoder.Decode(metadata.FilePath, scaled, h2, metadata.Orientation, pool);
var s2 = LuminanceAnalyzer.Analyze(small);
double delta = s2.Log2Average - stats.Log2Average;
output.WriteLine($" a {scaled,4}×{h2,-4} {s2.Log2Average,7:0.00} EV " +
$"scarto {delta,6:+0.00;-0.00;0.00} EV" +
(Math.Abs(delta) > 0.25 ? " ← DECODIFICA INCOERENTE" : string.Empty));
}
}
catch (Exception ex)
{
output.WriteLine($" {metadata.FileName}");
output.WriteLine($" DECODIFICA NON RIUSCITA: {ex.GetType().Name} — {ex.Message}");
}
}
output.WriteLine();
// ------------------------------------------------------------------ cadenza
output.WriteLine("CADENZA SULL'INTERA SEQUENZA");
output.WriteLine(new string('-', 74));
var timer = Stopwatch.StartNew();
var all2 = files.AsParallel().Select(MetadataReader.Read).ToList();
timer.Stop();
var sequence = TimelapseSequence.Build(all2);
sequence.RecomputeTiming();
int withExif = all2.Count(m => m.CaptureSource is TimestampSource.Exif or TimestampSource.ExifSubSecond);
int withSubSecond = all2.Count(m => m.CaptureSource == TimestampSource.ExifSubSecond);
int withExposure = all2.Count(m => m.ExposureSeconds is not null);
output.WriteLine($" lettura di {files.Count} file in {timer.Elapsed.TotalSeconds:0.0} s");
output.WriteLine($" timestamp da Exif {withExif}/{all2.Count} di cui al sotto-secondo {withSubSecond}");
output.WriteLine($" tempo di posa noto {withExposure}/{all2.Count}");
output.WriteLine($" cadenza nominale {sequence.NominalInterval:0.###} s");
output.WriteLine($" intervalli anomali {sequence.CadenceAnomalies}");
output.WriteLine($" durata della ripresa {sequence.TotalDuration}");
output.WriteLine($" shutter angle {sequence.Frames[0].ShutterAngle:0.0}° sul primo fotogramma");
var distinct = all2.Select(m => (m.PixelWidth, m.PixelHeight)).Distinct().ToList();
output.WriteLine($" dimensioni dichiarate distinte: {string.Join(", ", distinct.Select(d => $"{d.PixelWidth}×{d.PixelHeight}"))}");
if (renderPath is not null)
RunTrialRender(all2, renderPath, renderFrames, renderWidth, renderCodec, output);
return decoded == sampleMetadata.Count ? 0 : 1;
}
/// <summary>
/// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero
/// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file.
/// </summary>
private static void RunTrialRender(List<FrameMetadata> metadata, string outputPath, int frames,
int forcedWidth, Video.VideoCodec codec, TextWriter output)
{
output.WriteLine();
output.WriteLine("RENDER DI PROVA");
output.WriteLine(new string('-', 74));
var subset = metadata.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
.Take(Math.Max(2, frames))
.ToList();
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;
project.Export.Codec = codec;
project.General.WorkingWidth = forcedWidth;
var (width, height) = project.ResolveWorkingSize();
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)"
: string.Empty));
output.WriteLine($" memoria per buffer {perFrame / (1024.0 * 1024.0):0.0} MiB");
output.WriteLine($" codec {codec}");
var pipeline = new Pipeline.RenderPipeline(project);
var stopwatch = Stopwatch.StartNew();
try
{
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
output.WriteLine($" analisi completata in {stopwatch.Elapsed.TotalSeconds:0.0} s");
var result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
output.WriteLine($" codifica {result.EncodedFrames} fotogrammi in {result.Elapsed.TotalSeconds:0.0} s " +
$"({result.EncodedFrames / Math.Max(0.001, result.Elapsed.TotalSeconds):0.00} fps)");
output.WriteLine($" encoder {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}");
output.WriteLine($" file prodotto {result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.OutputPath}");
output.WriteLine($" memoria pixel {result.PeakPixelMemoryBytes / (1024.0 * 1024.0):0.0} MiB " +
$"({result.PeakPixelMemoryBytes / (double)perFrame:0.0} fotogrammi)");
var report = Mp4Inspector.Inspect(result.OutputPath);
output.WriteLine($" contenitore {report.Summary}, {report.SampleCount} campioni, {report.Width}×{report.Height}");
var playback = Mp4Playback.Read(result.OutputPath);
output.WriteLine($" rilettura {playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati"}");
}
catch (Exception ex)
{
output.WriteLine($" RENDER NON RIUSCITO: {ex.GetType().Name} — {ex.Message}");
}
}
private static List<string> PickSamples(List<string> files, int count)
{
if (files.Count <= count) return files;
var picked = new List<string>(count);
for (int i = 0; i < count; i++)
{
picked.Add(files[(int)((long)i * (files.Count - 1) / Math.Max(1, count - 1))]);
}
return picked;
}
private static string Describe(DateTime? value)
=> value?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "assente";
}
+239
View File
@@ -0,0 +1,239 @@
using System.Drawing;
using System.Drawing.Imaging;
namespace Titano.Diagnostics;
/// <summary>
/// Generatore di sequenze sintetiche per la diagnostica: produce JPEG con Exif completo,
/// una traslazione nota del contenuto (verifica dell'optical flow), uno sfarfallio noto
/// sovrapposto a una rampa di luce (verifica del deflicker) e una pausa dell'intervallometro
/// (verifica dell'analisi di cadenza).
/// </summary>
internal static class SyntheticSequence
{
public sealed record Definition(
int FrameCount = 48,
int Width = 640,
int Height = 360,
double IntervalSeconds = 2.0,
double ExposureSeconds = 0.02,
double FlickerStops = 0.12,
double RampStops = -0.9,
int PauseAtFrame = 20,
int PauseFrames = 4,
double PauseMultiplier = 3.0,
int ShiftX = 6,
int ShiftY = 2,
// ---- proprietà usate dalle verifiche dei moduli avanzati; a zero la sequenza
// resta identica a quella storica, così i controlli già esistenti non si spostano.
/// <summary>Ampiezza del tremolio accidentale sovrapposto, in pixel.</summary>
double ShakePixels = 0,
/// <summary>Fotogramma in cui la macchina cambia sensibilità; 0 disattiva il gradino.</summary>
int ExposureStepAt = 0,
/// <summary>Ampiezza del gradino di esposizione, in stop (positivo = schiarisce).</summary>
double ExposureStepStops = 0,
/// <summary>Divide la scena in cielo e paesaggio, per la segmentazione regionale.</summary>
bool SkyGround = false,
/// <summary>Quota di inquadratura occupata dal cielo.</summary>
double SkyFraction = 0.4,
/// <summary>Intensità della nuvola scura che attraversa il cielo senza toccare il terreno.</summary>
double CloudStrength = 0)
{
/// <summary>Sensibilità dichiarata sul fotogramma indicato, coerente con il gradino.</summary>
public int IsoAt(int index)
{
if (ExposureStepAt <= 0 || index < ExposureStepAt) return 200;
// Alzare la sensibilità schiarisce il fotogramma e abbassa il valore di esposizione:
// i metadati e i pixel devono raccontare la stessa cosa, o il test non prova nulla.
return (int)Math.Round(200 * Math.Pow(2, ExposureStepStops));
}
/// <summary>Contributo del gradino alla luminosità del fotogramma, in stop.</summary>
public double StepStopsAt(int index)
=> ExposureStepAt > 0 && index >= ExposureStepAt ? ExposureStepStops : 0;
/// <summary>Tremolio accidentale del fotogramma, deterministico ma non periodico.</summary>
public (double X, double Y) ShakeAt(int index)
{
if (ShakePixels <= 0) return (0, 0);
double x = Math.Sin(index * 2.399963) * 0.6 + Math.Sin(index * 0.7853) * 0.4;
double y = Math.Cos(index * 1.618034) * 0.55 + Math.Sin(index * 3.141593 * 0.37) * 0.45;
return (x * ShakePixels, y * ShakePixels);
}
}
/// <summary>Scrive la sequenza nella cartella indicata e restituisce i percorsi generati.</summary>
public static List<string> Write(string directory, Definition definition)
{
Directory.CreateDirectory(directory);
var paths = new List<string>(definition.FrameCount);
var origin = new DateTime(2026, 6, 1, 18, 30, 0, DateTimeKind.Unspecified);
double elapsed = 0;
for (int i = 0; i < definition.FrameCount; i++)
{
double exposureScale = Math.Pow(2.0, Ramp(definition, i) + Flicker(definition, i) +
definition.StepStopsAt(i));
byte[] jpeg = RenderJpeg(definition, i, exposureScale);
var timestamp = origin.AddSeconds(elapsed);
int subSecond = (int)Math.Round((elapsed - Math.Floor(elapsed)) * 100) % 100;
byte[] exif = ExifWriter.BuildExifBlock(timestamp, subSecond, definition.ExposureSeconds,
8.0, definition.IsoAt(i),
definition.Width, definition.Height,
"TITANO", "TITANO Synthetic");
string path = Path.Combine(directory, $"TITANO_{i:D4}.jpg");
File.WriteAllBytes(path, ExifWriter.InsertIntoJpeg(jpeg, exif));
paths.Add(path);
bool inPause = i >= definition.PauseAtFrame && i < definition.PauseAtFrame + definition.PauseFrames;
elapsed += definition.IntervalSeconds * (inPause ? definition.PauseMultiplier : 1.0) + 0.37;
}
return paths;
}
public static double Ramp(Definition definition, int index)
=> definition.RampStops * index / Math.Max(1, definition.FrameCount - 1);
/// <summary>Sfarfallio deterministico ma non periodico rispetto alla finestra di smoothing.</summary>
public static double Flicker(Definition definition, int index)
{
int pattern = (index * 7 + (index * index) % 5) % 7;
return definition.FlickerStops * ((pattern - 3) / 3.0);
}
private static byte[] RenderJpeg(Definition definition, int index, double exposureScale)
{
int width = definition.Width;
int height = definition.Height;
var (shakeX, shakeY) = definition.ShakeAt(index);
double offsetX = index * definition.ShiftX + shakeX;
double offsetY = index * definition.ShiftY + shakeY;
double horizon = height * Math.Clamp(definition.SkyFraction, 0.05, 0.95);
// Un treno di nuvole attraversa il cielo, una ogni sette scatti. Ciascuna abbassa la
// luminanza media dell'inquadratura mentre sul terreno non cambia nulla: è il caso in
// cui il deflicker globale schiarisce anche il paesaggio, che invece era fermo.
// Il periodo è più corto della finestra di lisciatura, quindi la correzione ci prova
// davvero — con un passaggio lento si limiterebbe a seguirlo.
const int cloudPeriod = 7;
double cloudWidth = Math.Max(4.0, horizon * 0.30);
double cloudCentre = -cloudWidth + (index % cloudPeriod) / (double)cloudPeriod
* (horizon + 2 * cloudWidth);
using var bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb);
var rect = new Rectangle(0, 0, width, height);
var locked = bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
try
{
unsafe
{
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
for (int x = 0; x < width; x++)
{
double sx = x + offsetX;
double sy = y + offsetY;
// Rumore di valore su tre ottave: tessitura ovunque e a banda limitata,
// condizioni ideali per uno schema differenziale di optical flow.
// L'ottava più fine serve a rendere misurabile l'effetto del motion blur.
double texture = 0.50 * ValueNoise(sx / 24.0, sy / 24.0, 11)
+ 0.30 * ValueNoise(sx / 7.0, sy / 7.0, 37)
+ 0.20 * ValueNoise(sx / 3.0, sy / 3.0, 53);
double scene = 0.18 + 0.62 * texture;
if (definition.SkyGround)
{
bool sky = y < horizon;
scene = sky ? 0.62 + 0.20 * texture : 0.055 + 0.070 * texture;
if (sky && definition.CloudStrength > 0)
{
double distance = (y - cloudCentre) / cloudWidth;
scene *= 1.0 - definition.CloudStrength * Math.Exp(-distance * distance);
}
}
double value = Math.Clamp(scene * exposureScale, 0.0, 1.0);
byte gray = (byte)Math.Clamp((int)(Math.Pow(value, 1.0 / 2.2) * 255.0 + 0.5), 0, 255);
byte* pixel = row + x * 3;
pixel[0] = (byte)(gray * 0.94); // B
pixel[1] = gray; // G
pixel[2] = (byte)Math.Min(255, gray * 1.04); // R
}
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
using var stream = new MemoryStream();
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
using var parameters = new EncoderParameters(1);
using var quality = new EncoderParameter(Encoder.Quality, 92L);
parameters.Param[0] = quality;
bitmap.Save(stream, codec, parameters);
return stream.ToArray();
}
/// <summary>
/// Rumore di valore su coordinate continue, esposto anche alle verifiche numeriche:
/// permette di costruire una tessitura traslata di una quantità frazionaria esatta,
/// senza il ricampionamento che falserebbe la misura attesa.
/// </summary>
public static double Texture(double x, double y, int seed) => ValueNoise(x, y, seed);
/// <summary>Rumore di valore bilineare su reticolo intero, deterministico.</summary>
private static double ValueNoise(double x, double y, int seed)
{
int x0 = (int)Math.Floor(x);
int y0 = (int)Math.Floor(y);
double fx = x - x0;
double fy = y - y0;
// Interpolazione con curva di Hermite: derivata continua, niente discontinuità di gradiente.
double sx = fx * fx * (3 - 2 * fx);
double sy = fy * fy * (3 - 2 * fy);
double a = Hash(x0, y0, seed);
double b = Hash(x0 + 1, y0, seed);
double c = Hash(x0, y0 + 1, seed);
double d = Hash(x0 + 1, y0 + 1, seed);
double top = a + (b - a) * sx;
double bottom = c + (d - c) * sx;
return top + (bottom - top) * sy;
}
private static double Hash(int x, int y, int seed)
{
unchecked
{
uint h = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(seed * 83492791);
h ^= h >> 13;
h *= 0x85EBCA6B;
h ^= h >> 16;
return (h & 0xFFFFFF) / (double)0xFFFFFF;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Runtime.CompilerServices;
namespace Titano.Imaging;
/// <summary>
/// Conversioni colore implementate in-house: curva sRGB ↔ luce lineare e pesi di luminanza.
/// Tutta l'elaborazione (deflicker, blur, interpolazione) avviene in luce lineare, l'unico
/// spazio in cui somme e medie pesate corrispondono al comportamento fisico della luce.
/// </summary>
public static class ColorSpace
{
/// <summary>LUT sRGB→lineare a 8 bit.</summary>
private static readonly float[] SrgbToLinear8 = BuildLut(256);
/// <summary>LUT sRGB→lineare a 16 bit, campionata a 4096 punti con interpolazione lineare.</summary>
private static readonly float[] SrgbToLinear16 = BuildLut(4096);
/// <summary>LUT inversa lineare→sRGB a 12 bit, per la scrittura verso l'encoder.</summary>
private const int InverseLutSize = 4096;
private static readonly float[] LinearToSrgbLut = BuildInverseLut();
// Coefficienti di luminanza Rec.709 (spazio di lavoro sRGB/Rec.709 lineare).
public const float LumaR = 0.2126f;
public const float LumaG = 0.7152f;
public const float LumaB = 0.0722f;
private static float[] BuildLut(int size)
{
var lut = new float[size];
for (int i = 0; i < size; i++) lut[i] = SrgbToLinearExact(i / (float)(size - 1));
return lut;
}
private static float[] BuildInverseLut()
{
var lut = new float[InverseLutSize + 1];
for (int i = 0; i <= InverseLutSize; i++) lut[i] = LinearToSrgbExact(i / (float)InverseLutSize);
return lut;
}
public static float SrgbToLinearExact(float v)
{
if (v <= 0f) return 0f;
if (v >= 1f) return 1f;
return v <= 0.04045f ? v / 12.92f : MathF.Pow((v + 0.055f) / 1.055f, 2.4f);
}
public static float LinearToSrgbExact(float v)
{
if (v <= 0f) return 0f;
if (v >= 1f) return 1f;
return v <= 0.0031308f ? v * 12.92f : 1.055f * MathF.Pow(v, 1f / 2.4f) - 0.055f;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float FromByte(byte v) => SrgbToLinear8[v];
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float FromUInt16(ushort v)
{
// Interpolazione fra i due campioni adiacenti della LUT: errore < 1e-5.
int scaled = v * (SrgbToLinear16.Length - 1);
int index = scaled / 65535;
int rem = scaled - index * 65535;
if (index >= SrgbToLinear16.Length - 1) return SrgbToLinear16[^1];
float a = SrgbToLinear16[index], b = SrgbToLinear16[index + 1];
return a + (b - a) * (rem / 65535f);
}
/// <summary>Lineare → sRGB con LUT interpolata; valori fuori range vengono saturati.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float ToSrgb(float linear)
{
if (linear <= 0f) return 0f;
if (linear >= 1f) return 1f;
float pos = linear * InverseLutSize;
int i = (int)pos;
float t = pos - i;
return LinearToSrgbLut[i] + (LinearToSrgbLut[i + 1] - LinearToSrgbLut[i]) * t;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte ToSrgbByte(float linear)
=> (byte)Math.Clamp((int)(ToSrgb(linear) * 255f + 0.5f), 0, 255);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float Luminance(float r, float g, float b) => LumaR * r + LumaG * g + LumaB * b;
}
+193
View File
@@ -0,0 +1,193 @@
using Titano.Motion;
namespace Titano.Imaging;
/// <summary>
/// Trasformazione affine dal fotogramma d'uscita a quello sorgente: per ogni pixel di
/// destinazione dice da quali coordinate prelevare. È la forma inversa a quella naturale,
/// ed è voluta — percorrendo la destinazione ogni pixel viene scritto una volta sola e non
/// restano buchi, mentre percorrendo la sorgente ne resterebbero ovunque la scala aumenti.
/// </summary>
public readonly record struct SourceMapping(double Ax, double Bx, double Tx,
double Ay, double By, double Ty)
{
public static SourceMapping Identity => new(1, 0, 0, 0, 1, 0);
public bool IsIdentity =>
Math.Abs(Ax - 1) < 1e-9 && Math.Abs(Bx) < 1e-9 && Math.Abs(Tx) < 1e-6 &&
Math.Abs(Ay) < 1e-9 && Math.Abs(By - 1) < 1e-9 && Math.Abs(Ty) < 1e-6;
public (double X, double Y) Apply(double x, double y)
=> (Ax * x + Bx * y + Tx, Ay * x + By * y + Ty);
}
/// <summary>
/// Stadio geometrico finale: ritaglio del movimento virtuale e correzione della
/// stabilizzazione, applicati insieme in un unico ricampionamento.
///
/// Che siano insieme non è un dettaglio di efficienza. Ogni ricampionamento costa un po' di
/// nitidezza, perché ricostruisce il segnale su un reticolo diverso; farne due in fila —
/// prima raddrizzare il fotogramma, poi ritagliarlo — costa il doppio senza dare nulla in
/// cambio. Le due trasformazioni sono entrambe affini, quindi si compongono esattamente in
/// una sola, e il fotogramma viene interpolato una volta sola.
///
/// Il filtro di ricostruzione è la cubica di Catmull-Rom: passa per i campioni, ha derivata
/// continua e restituisce un'immagine più incisa di quanto farebbe una bilineare, che a ogni
/// panoramica virtuale lascerebbe un velo di morbidezza.
/// </summary>
public static class GeometryStage
{
/// <summary>
/// Compone il ritaglio dell'inquadratura con l'inversa della correzione di stabilizzazione.
///
/// La correzione porta il fotogramma dalla posizione in cui è stato scattato a quella in cui
/// avrebbe dovuto essere; qui serve il percorso opposto, perché si parte dalla destinazione
/// e si va a cercare il pixel nella sorgente.
/// </summary>
public static SourceMapping Build(int sourceWidth, int sourceHeight, int outputWidth, int outputHeight,
in CameraFraming framing, in SimilarityTransform stabilization)
{
var inverse = stabilization.Inverse;
double zoom = Math.Max(1e-3, framing.Zoom);
double cropWidth = sourceWidth / zoom;
double cropHeight = sourceHeight / zoom;
double left = framing.CentreX * sourceWidth - cropWidth * 0.5;
double top = framing.CentreY * sourceHeight - cropHeight * 0.5;
// La catena è affine, quindi tre punti la determinano per intero: valutarla e poi
// ricavarne i coefficienti è più corto — e molto meno soggetto a errori di segno —
// che moltiplicare le matrici a mano.
var origin = Trace(0, 0);
var alongX = Trace(1, 0);
var alongY = Trace(0, 1);
return new SourceMapping(alongX.X - origin.X, alongY.X - origin.X, origin.X,
alongX.Y - origin.Y, alongY.Y - origin.Y, origin.Y);
(double X, double Y) Trace(double x, double y)
{
// Pixel di destinazione → punto dentro il ritaglio, in pixel sorgente stabilizzati.
double stabilizedX = left + (x + 0.5) * cropWidth / outputWidth;
double stabilizedY = top + (y + 0.5) * cropHeight / outputHeight;
if (inverse.IsIdentity) return (stabilizedX - 0.5, stabilizedY - 0.5);
// Le similitudini vivono in coordinate normalizzate sulla larghezza e centrate.
double u = (stabilizedX - sourceWidth * 0.5) / sourceWidth;
double v = (stabilizedY - sourceHeight * 0.5) / sourceWidth;
var (ru, rv) = inverse.Apply(u, v);
return (ru * sourceWidth + sourceWidth * 0.5 - 0.5,
rv * sourceWidth + sourceHeight * 0.5 - 0.5);
}
}
/// <summary>
/// Mappatura fra due fotogrammi della stessa dimensione legati da una similitudine
/// espressa in coordinate normalizzate. La usa lo stacking per sovrapporre fotogrammi
/// che la stabilizzazione ha spostato di quantità diverse.
/// </summary>
public static SourceMapping FromSimilarity(int width, int height, in SimilarityTransform mapping)
{
if (mapping.IsIdentity) return SourceMapping.Identity;
var similarity = mapping;
var origin = Trace(0, 0);
var alongX = Trace(1, 0);
var alongY = Trace(0, 1);
return new SourceMapping(alongX.X - origin.X, alongY.X - origin.X, origin.X,
alongX.Y - origin.Y, alongY.Y - origin.Y, origin.Y);
(double X, double Y) Trace(double x, double y)
{
double u = (x + 0.5 - width * 0.5) / width;
double v = (y + 0.5 - height * 0.5) / width;
var (ru, rv) = similarity.Apply(u, v);
return (ru * width + width * 0.5 - 0.5, rv * width + height * 0.5 - 0.5);
}
}
/// <summary>Vero se lo stadio non ha nulla da fare: stessa dimensione e nessuna trasformazione.</summary>
public static bool IsPassThrough(in SourceMapping map, ImageBuffer source, ImageBuffer destination)
=> map.IsIdentity && source.Width == destination.Width && source.Height == destination.Height;
/// <summary>Ricampiona la sorgente nella destinazione secondo la mappatura indicata.</summary>
public static void Resample(ImageBuffer source, ImageBuffer destination, in SourceMapping map)
{
if (IsPassThrough(map, source, destination))
{
destination.CopyFrom(source);
return;
}
int sourceWidth = source.Width;
int sourceHeight = source.Height;
int width = destination.Width;
int height = destination.Height;
var src = source.Data;
var dst = destination.Data;
double ax = map.Ax, bx = map.Bx, tx = map.Tx;
double ay = map.Ay, by = map.By, ty = map.Ty;
Parallel.For(0, height, y =>
{
double rowX = bx * y + tx;
double rowY = by * y + ty;
int rowBase = y * width * ImageBuffer.Channels;
Span<float> weightX = stackalloc float[4];
Span<float> weightY = stackalloc float[4];
for (int x = 0; x < width; x++)
{
float sx = (float)(ax * x + rowX);
float sy = (float)(ay * x + rowY);
int x0 = (int)MathF.Floor(sx);
int y0 = (int)MathF.Floor(sy);
CatmullRom(sx - x0, weightX);
CatmullRom(sy - y0, weightY);
float r = 0, g = 0, b = 0;
for (int j = 0; j < 4; j++)
{
int py = Math.Clamp(y0 - 1 + j, 0, sourceHeight - 1);
int lineBase = py * sourceWidth * ImageBuffer.Channels;
float wy = weightY[j];
if (wy == 0f) continue;
for (int i = 0; i < 4; i++)
{
int px = Math.Clamp(x0 - 1 + i, 0, sourceWidth - 1);
float w = wy * weightX[i];
int index = lineBase + px * ImageBuffer.Channels;
r += src[index] * w;
g += src[index + 1] * w;
b += src[index + 2] * w;
}
}
// La cubica sovraelonga: sui bordi ad alto contrasto può scendere sotto zero,
// che in luce lineare non significa nulla e all'encoder arriverebbe come rumore.
int destination0 = rowBase + x * ImageBuffer.Channels;
dst[destination0] = r > 0f ? r : 0f;
dst[destination0 + 1] = g > 0f ? g : 0f;
dst[destination0 + 2] = b > 0f ? b : 0f;
}
});
}
/// <summary>Pesi della cubica di Catmull-Rom per i quattro campioni attorno alla posizione.</summary>
private static void CatmullRom(float t, Span<float> weights)
{
float t2 = t * t;
float t3 = t2 * t;
weights[0] = -0.5f * t3 + t2 - 0.5f * t;
weights[1] = 1.5f * t3 - 2.5f * t2 + 1f;
weights[2] = -1.5f * t3 + 2f * t2 + 0.5f * t;
weights[3] = 0.5f * t3 - 0.5f * t2;
}
}
+116
View File
@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
namespace Titano.Imaging;
/// <summary>
/// Fotogramma in memoria: RGB impacchettato, float32, luce lineare (gamma rimossa).
/// L'array proviene sempre da un <see cref="FrameBufferPool"/>: nessuna allocazione
/// per fotogramma dopo il riscaldamento, quindi impronta di memoria stazionaria.
/// </summary>
public sealed class ImageBuffer : IDisposable
{
public const int Channels = 3;
public int Width { get; private set; }
public int Height { get; private set; }
public float[] Data { get; private set; }
internal FrameBufferPool? Owner;
private int _disposed;
public int PixelCount => Width * Height;
public int SampleCount => Width * Height * Channels;
internal ImageBuffer(int width, int height, float[] data, FrameBufferPool? owner)
{
Width = width;
Height = height;
Data = data;
Owner = owner;
}
/// <summary>Indice del primo campione (canale R) del pixel indicato.</summary>
public int Offset(int x, int y) => (y * Width + x) * Channels;
public void CopyFrom(ImageBuffer other)
{
if (other.Width != Width || other.Height != Height)
throw new ArgumentException("Dimensioni non compatibili.", nameof(other));
Array.Copy(other.Data, Data, SampleCount);
}
public ImageBuffer CloneFromPool(FrameBufferPool pool)
{
var copy = pool.Rent(Width, Height);
Array.Copy(Data, copy.Data, SampleCount);
return copy;
}
/// <summary>Restituisce il buffer al pool. Idempotente.</summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
var owner = Owner;
Owner = null;
owner?.Return(Data, Width, Height);
Data = [];
Width = Height = 0;
}
}
/// <summary>
/// Pool di array float per fotogrammi di dimensione omogenea. Gli array sono trattenuti
/// per lunghezza esatta e in numero limitato: l'occupazione totale è funzione della
/// profondità della pipeline, non del numero di fotogrammi della sequenza.
/// </summary>
public sealed class FrameBufferPool(int maxRetained = 8)
{
private readonly ConcurrentDictionary<int, ConcurrentBag<float[]>> _bins = new();
private readonly int _maxRetained = Math.Max(2, maxRetained);
private int _retained;
private long _allocatedBytes;
/// <summary>Numero di array attualmente trattenuti dal pool.</summary>
public int Retained => Volatile.Read(ref _retained);
/// <summary>
/// Byte di memoria pixel effettivamente allocati dall'avvio. Poiché gli array vengono
/// riutilizzati, questo valore si stabilizza dopo i primi fotogrammi e rappresenta
/// l'impronta stazionaria della pipeline.
/// </summary>
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
public ImageBuffer Rent(int width, int height)
{
if (width <= 0 || height <= 0) throw new ArgumentOutOfRangeException(nameof(width));
int length = checked(width * height * ImageBuffer.Channels);
if (_bins.TryGetValue(length, out var bag) && bag.TryTake(out var array))
{
Interlocked.Decrement(ref _retained);
return new ImageBuffer(width, height, array, this);
}
var fresh = GC.AllocateUninitializedArray<float>(length);
Interlocked.Add(ref _allocatedBytes, (long)length * sizeof(float));
return new ImageBuffer(width, height, fresh, this);
}
internal void Return(float[] array, int width, int height)
{
if (array.Length == 0) return;
if (Volatile.Read(ref _retained) >= _maxRetained) return; // eccedenza lasciata al GC
var bag = _bins.GetOrAdd(array.Length, static _ => []);
bag.Add(array);
Interlocked.Increment(ref _retained);
}
/// <summary>Svuota il pool: usato al cambio di risoluzione di lavoro.</summary>
public void Clear()
{
_bins.Clear();
Interlocked.Exchange(ref _retained, 0);
Interlocked.Exchange(ref _allocatedBytes, 0);
}
}
+250
View File
@@ -0,0 +1,250 @@
using System.Buffers;
using System.Runtime.InteropServices;
namespace Titano.Imaging;
/// <summary>
/// Decodifica un file immagine direttamente in un <see cref="ImageBuffer"/> in luce lineare,
/// scalandolo alla risoluzione di lavoro e applicando l'orientamento Exif.
///
/// La catena WIC è pull-based: il ridimensionamento avviene a bande mentre i pixel vengono
/// prelevati, quindi il fotogramma a piena risoluzione non viene mai materializzato per intero
/// e non tocca mai il disco.
/// </summary>
public static class ImageDecoder
{
/// <summary>Dimensioni dell'immagine così come verrà mostrata (orientamento già applicato).</summary>
public static (int Width, int Height) ProbeDisplaySize(string path, int orientation)
{
IWICBitmapDecoder? decoder = null;
IWICBitmapFrameDecode? frame = null;
try
{
Wic.Factory.CreateDecoderFromFilename(path, IntPtr.Zero, Wic.GenericRead,
Wic.MetadataCacheOnDemand, out decoder);
decoder.GetFrame(0, out frame);
frame.GetSize(out uint w, out uint h);
return SwapsAxes(orientation) ? ((int)h, (int)w) : ((int)w, (int)h);
}
finally
{
Wic.Release(frame);
Wic.Release(decoder);
}
}
/// <summary>
/// Decodifica il file producendo un buffer di esattamente <paramref name="targetWidth"/> ×
/// <paramref name="targetHeight"/> pixel (misure già nell'orientamento finale).
/// </summary>
public static ImageBuffer Decode(string path, int targetWidth, int targetHeight,
int orientation, FrameBufferPool pool)
{
if (targetWidth <= 0 || targetHeight <= 0) throw new ArgumentOutOfRangeException(nameof(targetWidth));
bool swap = SwapsAxes(orientation);
uint decodeWidth = (uint)(swap ? targetHeight : targetWidth);
uint decodeHeight = (uint)(swap ? targetWidth : targetHeight);
IWICBitmapDecoder? decoder = null;
IWICBitmapFrameDecode? frame = null;
IWICFormatConverter? converter = null;
IWICBitmapScaler? scaler = null;
try
{
Wic.Factory.CreateDecoderFromFilename(path, IntPtr.Zero, Wic.GenericRead,
Wic.MetadataCacheOnDemand, out decoder);
decoder.GetFrame(0, out frame);
frame.GetSize(out uint sourceWidth, out uint sourceHeight);
if (sourceWidth == 0 || sourceHeight == 0)
throw new InvalidDataException("Il decoder di sistema ha riportato dimensioni nulle.");
bool wantDeep = PrefersHighBitDepth(path);
Guid destinationFormat = wantDeep ? Wic.PixelFormat48bppRGB : Wic.PixelFormat32bppBGRA;
IWICBitmapSource source = CreateChain(frame, ref destinationFormat, decodeWidth, decodeHeight,
sourceWidth, sourceHeight, ref converter, ref scaler);
int bytesPerPixel = destinationFormat == Wic.PixelFormat48bppRGB ? 6 : 4;
int stride = checked((int)decodeWidth * bytesPerPixel);
long total = (long)stride * decodeHeight;
if (total > int.MaxValue) throw new InvalidDataException("Fotogramma troppo grande per il buffer di trasferimento.");
byte[] scratch = ArrayPool<byte>.Shared.Rent((int)total);
try
{
var handle = GCHandle.Alloc(scratch, GCHandleType.Pinned);
try
{
source.CopyPixels(IntPtr.Zero, (uint)stride, (uint)total, handle.AddrOfPinnedObject());
}
finally
{
handle.Free();
}
var buffer = pool.Rent(targetWidth, targetHeight);
try
{
if (bytesPerPixel == 6)
Convert48(scratch, stride, (int)decodeWidth, (int)decodeHeight, buffer, orientation);
else
Convert32(scratch, stride, (int)decodeWidth, (int)decodeHeight, buffer, orientation);
}
catch
{
buffer.Dispose();
throw;
}
return buffer;
}
finally
{
ArrayPool<byte>.Shared.Return(scratch);
}
}
finally
{
Wic.Release(scaler);
Wic.Release(converter);
Wic.Release(frame);
Wic.Release(decoder);
}
}
/// <summary>
/// Costruisce la catena WIC: conversione al formato di lavoro e, se necessario, scalatura.
/// In caso di sorgenti che il codec non sa convertire a 16 bit si ripiega su 8 bit.
/// </summary>
private static IWICBitmapSource CreateChain(IWICBitmapFrameDecode frame, ref Guid destinationFormat,
uint decodeWidth, uint decodeHeight,
uint sourceWidth, uint sourceHeight,
ref IWICFormatConverter? converter, ref IWICBitmapScaler? scaler)
{
IWICBitmapSource current = (IWICBitmapSource)frame;
// Lo scaler precede il convertitore. È l'ordine indicato da Microsoft e non è un
// dettaglio: interponendo prima la conversione a 48bppRGB, lo scaler di alcuni codec
// RAW restituisce righe disallineate — l'immagine esce a strisce e la luminanza media
// sbaglia di due stop. A piena risoluzione, senza scaler, lo stesso percorso è esatto.
if (decodeWidth != sourceWidth || decodeHeight != sourceHeight)
{
Wic.Factory.CreateBitmapScaler(out scaler);
scaler.Initialize(current, decodeWidth, decodeHeight, Wic.InterpolationFant);
current = (IWICBitmapSource)scaler;
}
try
{
Wic.Factory.CreateFormatConverter(out converter);
converter.Initialize(current, ref destinationFormat, Wic.DitherTypeNone,
IntPtr.Zero, 0.0, Wic.PaletteTypeCustom);
}
catch (COMException) when (destinationFormat == Wic.PixelFormat48bppRGB)
{
Wic.Release(converter);
converter = null;
destinationFormat = Wic.PixelFormat32bppBGRA;
Wic.Factory.CreateFormatConverter(out converter);
converter.Initialize(current, ref destinationFormat, Wic.DitherTypeNone,
IntPtr.Zero, 0.0, Wic.PaletteTypeCustom);
}
return (IWICBitmapSource)converter!;
}
private static bool PrefersHighBitDepth(string path)
{
string ext = Path.GetExtension(path);
return !(ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".jpe", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".jfif", StringComparison.OrdinalIgnoreCase));
}
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
private static unsafe void Convert32(byte[] scratch, int stride, int width, int height,
ImageBuffer destination, int orientation)
{
fixed (byte* basePtr = scratch)
fixed (float* destBase = destination.Data)
{
byte* src = basePtr;
float* dst = destBase;
int destWidth = destination.Width;
for (int y = 0; y < height; y++)
{
byte* row = src + (long)y * stride;
for (int x = 0; x < width; x++)
{
byte* px = row + x * 4; // BGRA
int index = MapIndex(x, y, width, height, destWidth, orientation);
dst[index] = ColorSpace.FromByte(px[2]);
dst[index + 1] = ColorSpace.FromByte(px[1]);
dst[index + 2] = ColorSpace.FromByte(px[0]);
}
}
}
}
private static unsafe void Convert48(byte[] scratch, int stride, int width, int height,
ImageBuffer destination, int orientation)
{
fixed (byte* basePtr = scratch)
fixed (float* destBase = destination.Data)
{
float* dst = destBase;
int destWidth = destination.Width;
for (int y = 0; y < height; y++)
{
ushort* row = (ushort*)(basePtr + (long)y * stride);
for (int x = 0; x < width; x++)
{
ushort* px = row + x * 3; // RGB 16 bit
int index = MapIndex(x, y, width, height, destWidth, orientation);
dst[index] = ColorSpace.FromUInt16(px[0]);
dst[index + 1] = ColorSpace.FromUInt16(px[1]);
dst[index + 2] = ColorSpace.FromUInt16(px[2]);
}
}
}
}
/// <summary>Applica la trasformazione Exif (valori 1..8) restituendo l'offset di destinazione.</summary>
private static int MapIndex(int x, int y, int width, int height, int destWidth, int orientation)
{
int dx, dy;
switch (orientation)
{
case 2: dx = width - 1 - x; dy = y; break;
case 3: dx = width - 1 - x; dy = height - 1 - y; break;
case 4: dx = x; dy = height - 1 - y; break;
case 5: dx = y; dy = x; break;
case 6: dx = height - 1 - y; dy = x; break;
case 7: dx = height - 1 - y; dy = width - 1 - x; break;
case 8: dx = y; dy = width - 1 - x; break;
default: dx = x; dy = y; break;
}
return (dy * destWidth + dx) * ImageBuffer.Channels;
}
}
+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");
}
}
+164
View File
@@ -0,0 +1,164 @@
using System.Runtime.InteropServices;
namespace Titano.Imaging;
// ---------------------------------------------------------------------------------------
// Binding manuale verso Windows Imaging Component (windowscodecs.dll), componente di
// sistema. Nessun wrapper di terze parti: le interfacce COM sono dichiarate qui con
// l'ordine di vtable esatto e vengono usate solo le voci necessarie alla pipeline.
// ---------------------------------------------------------------------------------------
[ComImport, Guid("00000120-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapSource
{
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
}
[ComImport, Guid("3B16811B-6A43-4ec9-A813-3D930C13B940"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapFrameDecode
{
// --- IWICBitmapSource
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
// --- IWICBitmapFrameDecode
void GetMetadataQueryReader(out IntPtr reader);
void GetColorContexts(uint count, IntPtr contexts, out uint actual);
void GetThumbnail(out IWICBitmapSource thumbnail);
}
[ComImport, Guid("9EDDE9E7-8DEE-47ea-99DF-E6FAF2ED44BF"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapDecoder
{
void QueryCapability(IntPtr stream, out uint capability);
void Initialize(IntPtr stream, int cacheOptions);
void GetContainerFormat(out Guid format);
void GetDecoderInfo(out IntPtr info);
void CopyPalette(IntPtr palette);
void GetMetadataQueryReader(out IntPtr reader);
void GetPreview(out IWICBitmapSource preview);
void GetColorContexts(uint count, IntPtr contexts, out uint actual);
void GetThumbnail(out IWICBitmapSource thumbnail);
void GetFrameCount(out uint count);
void GetFrame(uint index, out IWICBitmapFrameDecode frame);
}
[ComImport, Guid("00000301-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICFormatConverter
{
// --- IWICBitmapSource
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
// --- IWICFormatConverter
void Initialize(IWICBitmapSource source, ref Guid destinationFormat, int dither,
IntPtr palette, double alphaThreshold, int paletteTranslate);
void CanConvert(ref Guid source, ref Guid destination, [MarshalAs(UnmanagedType.Bool)] out bool canConvert);
}
[ComImport, Guid("00000302-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapScaler
{
// --- IWICBitmapSource
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
// --- IWICBitmapScaler
void Initialize(IWICBitmapSource source, uint width, uint height, int interpolationMode);
}
[ComImport, Guid("EC5EC8A9-C395-4314-9C77-54D7A935FF70"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICImagingFactory
{
void CreateDecoderFromFilename([MarshalAs(UnmanagedType.LPWStr)] string filename, IntPtr vendor,
uint desiredAccess, int metadataOptions, out IWICBitmapDecoder decoder);
void CreateDecoderFromStream(IntPtr stream, IntPtr vendor, int metadataOptions, out IntPtr decoder);
void CreateDecoderFromFileHandle(UIntPtr file, IntPtr vendor, int metadataOptions, out IntPtr decoder);
void CreateComponentInfo(ref Guid component, out IntPtr info);
void CreateDecoder(ref Guid containerFormat, IntPtr vendor, out IntPtr decoder);
void CreateEncoder(ref Guid containerFormat, IntPtr vendor, out IntPtr encoder);
void CreatePalette(out IntPtr palette);
void CreateFormatConverter(out IWICFormatConverter converter);
void CreateBitmapScaler(out IWICBitmapScaler scaler);
// Le voci successive della vtable non sono usate e volutamente non dichiarate.
}
internal static class Wic
{
public static readonly Guid ClsidImagingFactory = new("cacaf262-9370-4615-a13b-9f5539da4c0a");
public static readonly Guid IidImagingFactory = new("ec5ec8a9-c395-4314-9c77-54d7a935ff70");
public static readonly Guid PixelFormat32bppBGRA = new("6fddc324-4e03-4bfe-b185-3d77768dc90f");
public static readonly Guid PixelFormat48bppRGB = new("6fddc324-4e03-4bfe-b185-3d77768dc915");
public const uint GenericRead = 0x80000000;
public const int MetadataCacheOnDemand = 0;
public const int DitherTypeNone = 0;
public const int PaletteTypeCustom = 0;
public const int InterpolationFant = 3;
private const uint ClsCtxInprocServer = 1;
private const uint CoInitMultithreaded = 0;
[ThreadStatic] private static IWICImagingFactory? _threadFactory;
[ThreadStatic] private static bool _comReady;
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(ref Guid clsid, IntPtr outer, uint context, ref Guid iid, out IntPtr instance);
[DllImport("ole32.dll")]
private static extern int CoInitializeEx(IntPtr reserved, uint flags);
/// <summary>
/// Factory WIC per thread: gli oggetti COM restano confinati al thread che li crea,
/// così i worker di decodifica lavorano davvero in parallelo senza marshalling.
/// </summary>
public static IWICImagingFactory Factory
{
get
{
if (_threadFactory is not null) return _threadFactory;
if (!_comReady)
{
// S_OK, S_FALSE e RPC_E_CHANGED_MODE sono tutti esiti accettabili.
CoInitializeEx(IntPtr.Zero, CoInitMultithreaded);
_comReady = true;
}
var clsid = ClsidImagingFactory;
var iid = IidImagingFactory;
int hr = CoCreateInstance(ref clsid, IntPtr.Zero, ClsCtxInprocServer, ref iid, out IntPtr raw);
if (hr < 0 || raw == IntPtr.Zero)
throw new InvalidOperationException($"Impossibile creare la factory WIC (HRESULT 0x{hr:X8}).");
try
{
_threadFactory = (IWICImagingFactory)Marshal.GetObjectForIUnknown(raw);
}
finally
{
Marshal.Release(raw);
}
return _threadFactory!;
}
}
public static void Release(object? comObject)
{
if (comObject is not null && Marshal.IsComObject(comObject))
{
try { Marshal.ReleaseComObject(comObject); } catch (ArgumentException) { /* già rilasciato */ }
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.Globalization;
namespace Titano.Metadata;
/// <summary>Origine del timestamp di scatto, usata per segnalare in UI la qualità del dato.</summary>
public enum TimestampSource
{
None = 0,
Exif,
ExifSubSecond,
Xmp,
FileSystem,
}
/// <summary>
/// Metadati di un singolo fotogramma della sequenza, estratti dal parser binario in-house.
/// Immutabile per costruzione: la pipeline la tratta come dato condivisibile fra thread.
/// </summary>
public sealed class FrameMetadata
{
public required string FilePath { get; init; }
public required string FileName { get; init; }
public long FileSize { get; init; }
/// <summary>Istante di scatto, comprensivo di frazione di secondo quando disponibile.</summary>
public DateTime? CaptureTime { get; init; }
public TimestampSource CaptureSource { get; init; }
public TimeSpan? UtcOffset { get; init; }
public double? ExposureSeconds { get; init; }
public double? FNumber { get; init; }
public int? Iso { get; init; }
public double? FocalLength { get; init; }
public double? ExposureBias { get; init; }
public int PixelWidth { get; init; }
public int PixelHeight { get; init; }
public int Orientation { get; init; } = 1;
public string? Camera { get; init; }
public string? Lens { get; init; }
/// <summary>Errori non fatali incontrati durante il parsing (file troncato, IFD corrotta...).</summary>
public string? Warning { get; init; }
public string ExposureText => ExposureSeconds is not { } e
? "—"
: e >= 1
? string.Format(CultureInfo.CurrentCulture, "{0:0.#}s", e)
: string.Format(CultureInfo.CurrentCulture, "1/{0:0.#}", 1.0 / Math.Max(e, 1e-9));
public string ApertureText => FNumber is { } f ? "f/" + f.ToString("0.#", CultureInfo.CurrentCulture) : "—";
public string IsoText => Iso is { } i ? i.ToString(CultureInfo.CurrentCulture) : "—";
/// <summary>Costruisce l'istante completo a partire dai campi Exif grezzi.</summary>
internal static DateTime? CombineDateTime(string? exifDateTime, string? subSec, out TimestampSource source)
{
source = TimestampSource.None;
if (string.IsNullOrWhiteSpace(exifDateTime)) return null;
// Formato canonico Exif: "YYYY:MM:DD HH:MM:SS" (alcuni firmware usano '-' o '/').
Span<int> parts = stackalloc int[6];
int found = 0;
int value = 0;
bool inNumber = false;
foreach (char c in exifDateTime)
{
if (c is >= '0' and <= '9')
{
value = value * 10 + (c - '0');
inNumber = true;
if (value > 999999) return null;
}
else if (inNumber)
{
if (found < 6) parts[found++] = value;
value = 0;
inNumber = false;
if (found == 6) break;
}
}
if (inNumber && found < 6) parts[found++] = value;
if (found < 6) return null;
int year = parts[0], month = parts[1], day = parts[2], hour = parts[3], minute = parts[4], second = parts[5];
if (year < 1900 || year > 3000 || month is < 1 or > 12 || day is < 1 or > 31) return null;
if (hour > 23 || minute > 59 || second > 60) return null;
if (second == 60) second = 59; // leap second difensivo
if (day > DateTime.DaysInMonth(year, month)) return null;
var stamp = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
source = TimestampSource.Exif;
// SubSecTime è una stringa di cifre decimali: "37" -> 0.37 s, "004" -> 0.004 s.
if (!string.IsNullOrWhiteSpace(subSec))
{
double fraction = 0, scale = 0.1;
int digits = 0;
foreach (char c in subSec.Trim())
{
if (c is < '0' or > '9') break;
fraction += (c - '0') * scale;
scale *= 0.1;
if (++digits >= 7) break;
}
if (digits > 0 && fraction > 0)
{
stamp = stamp.AddTicks((long)Math.Round(fraction * TimeSpan.TicksPerSecond));
source = TimestampSource.ExifSubSecond;
}
else if (digits > 0)
{
source = TimestampSource.ExifSubSecond; // frazione presente ma nulla: precisione comunque nota
}
}
return stamp;
}
/// <summary>Interpreta un offset fuso orario Exif nella forma "+02:00".</summary>
internal static TimeSpan? ParseUtcOffset(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
text = text.Trim();
if (text.Length < 3) return null;
int sign = text[0] == '-' ? -1 : text[0] == '+' ? 1 : 0;
if (sign == 0) return null;
int colon = text.IndexOf(':');
string hh = colon > 0 ? text[1..colon] : text[1..Math.Min(3, text.Length)];
string mm = colon > 0 && colon + 1 < text.Length ? text[(colon + 1)..] : "0";
if (!int.TryParse(hh, NumberStyles.Integer, CultureInfo.InvariantCulture, out int h)) return null;
if (!int.TryParse(mm, NumberStyles.Integer, CultureInfo.InvariantCulture, out int m)) m = 0;
if (h > 14 || m > 59) return null;
return new TimeSpan(sign * h, sign * m, 0);
}
}
+399
View File
@@ -0,0 +1,399 @@
using System.Text;
namespace Titano.Metadata;
/// <summary>
/// Ingestion: apre il file, riconosce il contenitore (JPEG, TIFF/RAW, PNG, HEIF/AVIF, WebP),
/// individua i blocchi Exif e XMP e li decodifica con i parser binari in-house.
/// Nessuna libreria esterna, nessun processo figlio.
/// </summary>
public static class MetadataReader
{
/// <summary>Quantità massima di header letta per i contenitori TIFF/RAW (le IFD stanno all'inizio).</summary>
private const int TiffHeaderBudget = 4 * 1024 * 1024;
private static readonly byte[] ExifSignature = "Exif\0\0"u8.ToArray();
private static readonly byte[] XmpSignature = "http://ns.adobe.com/xap/1.0/\0"u8.ToArray();
public static readonly string[] SupportedExtensions =
[
".jpg", ".jpeg", ".jpe", ".jfif",
".tif", ".tiff",
".png",
".heic", ".heif", ".avif",
".webp",
".dng", ".cr2", ".cr3", ".nef", ".nrw", ".arw", ".srf", ".sr2",
".orf", ".rw2", ".raf", ".pef", ".raw", ".3fr", ".iiq",
];
public static bool IsSupported(string path)
=> SupportedExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
public static FrameMetadata Read(string path)
{
var info = new FileInfo(path);
string? warning = null;
DateTime? capture = null;
var source = TimestampSource.None;
TimeSpan? utcOffset = null;
double? exposure = null, fnumber = null, focal = null, bias = null;
int? iso = null;
int width = 0, height = 0, orientation = 1;
string? camera = null, lens = null;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
64 * 1024, FileOptions.SequentialScan);
var container = DetectContainer(stream, out byte[] head);
TiffBlock? tiff = null;
string? xmpPacket = null;
switch (container)
{
case ContainerKind.Jpeg:
ScanJpeg(stream, ref tiff, ref xmpPacket, ref width, ref height, ref warning);
break;
case ContainerKind.Tiff:
{
// Nessuna lettura a blocco fisso: la finestra si estende solo se serve.
// Nei TIFF e nei RAW l'XMP vive nel tag 0x02BC, letto più sotto.
tiff = TiffBlock.ParseStream(stream, info.Length);
if (tiff is null) warning = "Header TIFF non interpretabile.";
break;
}
case ContainerKind.Png:
ScanPng(stream, ref tiff, ref xmpPacket, ref width, ref height);
break;
default:
{
// Contenitori ISO-BMFF (HEIC/AVIF), WebP e formati proprietari: individuiamo
// il blocco Exif/XMP per scansione diretta dell'header.
int budget = (int)Math.Min(info.Length, TiffHeaderBudget);
byte[] buffer = ReadAt(stream, 0, budget);
tiff = FindTiffInBuffer(buffer);
xmpPacket = FindXmpInBuffer(buffer);
if (tiff is null && xmpPacket is null)
warning = "Nessun blocco Exif/XMP riconosciuto nell'header.";
break;
}
}
if (tiff is not null)
{
string? dt = tiff.GetString(TiffTags.DateTimeOriginal)
?? tiff.GetString(TiffTags.DateTimeDigitized)
?? tiff.GetString(TiffTags.DateTime);
string? sub = tiff.GetString(TiffTags.SubSecTimeOriginal)
?? tiff.GetString(TiffTags.SubSecTimeDigitized)
?? tiff.GetString(TiffTags.SubSecTime);
capture = FrameMetadata.CombineDateTime(dt, sub, out source);
utcOffset = FrameMetadata.ParseUtcOffset(
tiff.GetString(TiffTags.OffsetTimeOriginal) ?? tiff.GetString(TiffTags.OffsetTime));
if (tiff.TryGetDouble(TiffTags.ExposureTime, out double e) && e > 0) exposure = e;
else if (tiff.TryGetDouble(TiffTags.ShutterSpeedValue, out double apexTv))
exposure = Math.Pow(2.0, -apexTv); // APEX: Tv = -log2(t)
if (tiff.TryGetDouble(TiffTags.FNumber, out double f) && f > 0) fnumber = f;
else if (tiff.TryGetDouble(TiffTags.ApertureValue, out double apexAv))
fnumber = Math.Pow(2.0, apexAv / 2.0); // APEX: Av = 2*log2(N)
if (tiff.TryGetFirstUInt32(out uint isoValue, TiffTags.IsoSpeedRatings,
TiffTags.RecommendedExposureIndex, TiffTags.IsoSpeed))
iso = (int)Math.Min(isoValue, int.MaxValue);
if (tiff.TryGetDouble(TiffTags.FocalLength, out double fl) && fl > 0) focal = fl;
if (tiff.TryGetDouble(TiffTags.ExposureBiasValue, out double eb)) bias = eb;
if (tiff.TryGetUInt32(TiffTags.Orientation, out uint o) && o is >= 1 and <= 8) orientation = (int)o;
// PixelXDimension descrive per definizione l'immagine finale: quando c'è, vince.
if (width == 0 &&
tiff.TryGetUInt32(TiffTags.PixelXDimension, out uint pw) &&
tiff.TryGetUInt32(TiffTags.PixelYDimension, out uint ph))
{
width = (int)pw;
height = (int)ph;
}
// Altrimenti si individua la directory dell'immagine principale. Leggere
// ImageWidth dalla IFD0 sarebbe sbagliato: nei DNG quella è la miniatura.
if (width == 0 && tiff.TryGetMainImageSize(out int mainWidth, out int mainHeight))
{
width = mainWidth;
height = mainHeight;
}
string? make = tiff.GetString(TiffTags.Make);
string? model = tiff.GetString(TiffTags.Model);
camera = ComposeCamera(make, model);
lens = tiff.GetString(TiffTags.LensModel);
// Alcuni contenitori riportano l'XMP dentro il tag TIFF 0x02BC.
if (xmpPacket is null && tiff.TryFind(TiffTags.XmpPacket, out var xmpEntry))
{
byte[] raw = tiff.View.ToArray(xmpEntry.ValuePosition, Math.Min(xmpEntry.ByteLength, 1 << 20));
if (raw.Length > 0) xmpPacket = XmpScanner.ExtractPacket(Encoding.UTF8.GetString(raw));
}
}
// XMP come sorgente complementare: riempie solo i campi ancora ignoti.
if (xmpPacket is not null)
{
var x = XmpScanner.Scan(xmpPacket);
if (capture is null && x.CaptureTime is { } xc)
{
capture = xc;
source = TimestampSource.Xmp;
}
utcOffset ??= x.UtcOffset;
exposure ??= x.ExposureSeconds;
fnumber ??= x.FNumber;
iso ??= x.Iso;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
warning = "Lettura non riuscita: " + ex.Message;
}
catch (Exception ex)
{
warning = "Metadati non interpretabili: " + ex.Message;
}
if (capture is null)
{
// Ultima risorsa: la data di modifica del file, marcata come tale in UI.
capture = info.Exists ? info.LastWriteTime : null;
if (capture is not null) source = TimestampSource.FileSystem;
}
return new FrameMetadata
{
FilePath = path,
FileName = Path.GetFileName(path),
FileSize = info.Exists ? info.Length : 0,
CaptureTime = capture,
CaptureSource = source,
UtcOffset = utcOffset,
ExposureSeconds = exposure,
FNumber = fnumber,
Iso = iso,
FocalLength = focal,
ExposureBias = bias,
PixelWidth = width,
PixelHeight = height,
Orientation = orientation,
Camera = camera,
Lens = lens,
Warning = warning,
};
}
private static string? ComposeCamera(string? make, string? model)
{
make = make?.Trim();
model = model?.Trim();
if (string.IsNullOrEmpty(model)) return string.IsNullOrEmpty(make) ? null : make;
if (string.IsNullOrEmpty(make)) return model;
// Molti modelli ripetono già il produttore ("NIKON D850" con make "NIKON CORPORATION").
string firstWord = make.Split(' ')[0];
return model.StartsWith(firstWord, StringComparison.OrdinalIgnoreCase) ? model : make + " " + model;
}
// ------------------------------------------------------------------ contenitori
private enum ContainerKind { Unknown, Jpeg, Tiff, Png }
private static ContainerKind DetectContainer(FileStream stream, out byte[] head)
{
head = ReadAt(stream, 0, 16);
if (head.Length >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF) return ContainerKind.Jpeg;
if (head.Length >= 4 && ((head[0] == 0x49 && head[1] == 0x49) || (head[0] == 0x4D && head[1] == 0x4D)))
return ContainerKind.Tiff;
if (head.Length >= 8 && head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47)
return ContainerKind.Png;
return ContainerKind.Unknown;
}
/// <summary>
/// Percorre i marker JPEG senza decodificare l'immagine: legge solo gli header di segmento
/// e i payload APP1 (Exif/XMP), estraendo le dimensioni dal segmento SOF.
/// </summary>
private static void ScanJpeg(FileStream stream, ref TiffBlock? tiff, ref string? xmp,
ref int width, ref int height, ref string? warning)
{
stream.Position = 2;
Span<byte> header = stackalloc byte[4];
long length = stream.Length;
int guard = 0;
while (stream.Position < length && guard++ < 4096)
{
int b = stream.ReadByte();
if (b < 0) break;
if (b != 0xFF) continue; // risincronizzazione sul prossimo marker
int marker;
do { marker = stream.ReadByte(); } while (marker == 0xFF);
if (marker < 0) break;
// Marker senza payload.
if (marker is 0x01 or 0xD8 or 0xD9 || (marker >= 0xD0 && marker <= 0xD7)) continue;
if (marker == 0xDA) break; // inizio dati compressi: oltre non serve
if (stream.Read(header[..2]) != 2) break;
int segLength = (header[0] << 8) | header[1];
if (segLength < 2) break;
int payload = segLength - 2;
long payloadStart = stream.Position;
if (marker == 0xE1 && payload > 6)
{
byte[] data = ReadExactly(stream, Math.Min(payload, 16 * 1024 * 1024));
if (data.Length >= ExifSignature.Length && Matches(data, ExifSignature))
{
tiff ??= TiffBlock.Parse(data, ExifSignature.Length, data.Length - ExifSignature.Length);
if (tiff is null) warning = "Blocco Exif presente ma non interpretabile.";
}
else if (data.Length >= XmpSignature.Length && Matches(data, XmpSignature))
{
xmp ??= XmpScanner.ExtractPacket(Encoding.UTF8.GetString(data, XmpSignature.Length,
data.Length - XmpSignature.Length));
}
}
else if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC)
{
// SOFn: precision(1) height(2) width(2)
byte[] sof = ReadExactly(stream, Math.Min(payload, 9));
if (sof.Length >= 5)
{
height = (sof[1] << 8) | sof[2];
width = (sof[3] << 8) | sof[4];
}
}
stream.Position = payloadStart + payload;
}
}
private static void ScanPng(FileStream stream, ref TiffBlock? tiff, ref string? xmp, ref int width, ref int height)
{
stream.Position = 8;
Span<byte> header = stackalloc byte[8];
int guard = 0;
while (stream.Position + 8 <= stream.Length && guard++ < 1024)
{
if (stream.Read(header) != 8) break;
uint len = ((uint)header[0] << 24) | ((uint)header[1] << 16) | ((uint)header[2] << 8) | header[3];
string type = Encoding.ASCII.GetString(header[4..].ToArray());
long dataStart = stream.Position;
if (len > 64 * 1024 * 1024) break;
switch (type)
{
case "IHDR" when len >= 8:
{
byte[] ihdr = ReadExactly(stream, 8);
if (ihdr.Length == 8)
{
width = (ihdr[0] << 24) | (ihdr[1] << 16) | (ihdr[2] << 8) | ihdr[3];
height = (ihdr[4] << 24) | (ihdr[5] << 16) | (ihdr[6] << 8) | ihdr[7];
}
break;
}
case "eXIf":
{
byte[] data = ReadExactly(stream, (int)len);
tiff ??= TiffBlock.Parse(data, 0, data.Length);
break;
}
case "iTXt" or "tEXt" or "zTXt":
{
byte[] data = ReadExactly(stream, (int)Math.Min(len, 4 * 1024 * 1024));
xmp ??= XmpScanner.ExtractPacket(Encoding.UTF8.GetString(data));
break;
}
case "IDAT" or "IEND":
return; // i metadati utili precedono i dati pixel
}
stream.Position = dataStart + len + 4; // + CRC
}
}
// ------------------------------------------------------------------ fallback per scansione
private static TiffBlock? FindTiffInBuffer(byte[] buffer)
{
var view = new ByteView(buffer, 0, buffer.Length, false);
int idx = view.IndexOf(ExifSignature);
if (idx >= 0)
{
var block = TiffBlock.Parse(buffer, idx + ExifSignature.Length, buffer.Length - idx - ExifSignature.Length);
if (block is not null) return block;
}
// Alcuni contenitori scrivono l'header TIFF senza prefisso "Exif\0\0".
foreach (var marker in (ReadOnlySpan<byte[]>)[[0x49, 0x49, 0x2A, 0x00], [0x4D, 0x4D, 0x00, 0x2A]])
{
int at = view.IndexOf(marker);
if (at < 0) continue;
var block = TiffBlock.Parse(buffer, at, buffer.Length - at);
if (block is not null && (block.Exif.Count > 0 || block.Ifd0.Count > 3)) return block;
}
return null;
}
private static string? FindXmpInBuffer(byte[] buffer)
{
var view = new ByteView(buffer, 0, buffer.Length, false);
int idx = view.IndexOf("<x:xmpmeta"u8);
if (idx < 0) idx = view.IndexOf("<?xpacket"u8);
if (idx < 0) return null;
int take = Math.Min(buffer.Length - idx, 512 * 1024);
return XmpScanner.ExtractPacket(Encoding.UTF8.GetString(buffer, idx, take));
}
// ------------------------------------------------------------------ utilità di I/O
private static bool Matches(byte[] data, byte[] signature)
{
if (data.Length < signature.Length) return false;
for (int i = 0; i < signature.Length; i++)
{
if (data[i] != signature[i]) return false;
}
return true;
}
private static byte[] ReadAt(FileStream stream, long offset, int count)
{
if (count <= 0) return [];
stream.Position = offset;
return ReadExactly(stream, count);
}
private static byte[] ReadExactly(FileStream stream, int count)
{
if (count <= 0) return [];
var buffer = new byte[count];
int read = 0;
while (read < count)
{
int n = stream.Read(buffer, read, count - read);
if (n <= 0) break;
read += n;
}
if (read == count) return buffer;
Array.Resize(ref buffer, read);
return buffer;
}
}
+370
View File
@@ -0,0 +1,370 @@
namespace Titano.Metadata;
/// <summary>
/// Parser in-house di un blocco TIFF (header + catena di IFD), condiviso da JPEG/APP1,
/// file TIFF nativi, DNG e dai principali formati RAW derivati da TIFF (CR2, NEF, ARW, ORF...).
/// </summary>
internal sealed class TiffBlock
{
public ByteView View { get; }
public Dictionary<ushort, TiffEntry> Ifd0 { get; } = [];
public Dictionary<ushort, TiffEntry> Exif { get; } = [];
public Dictionary<ushort, TiffEntry> Gps { get; } = [];
public List<Dictionary<ushort, TiffEntry>> SubIfds { get; } = [];
/// <summary>Tutte le directory che descrivono un'immagine: IFD0, la catena e le SubIFD.</summary>
private readonly List<Dictionary<ushort, TiffEntry>> _imageDirectories = [];
private TiffBlock(ByteView view) => View = view;
/// <summary>Blocco TIFF già interamente in memoria (segmento APP1 di un JPEG, chunk PNG).</summary>
public static TiffBlock? Parse(byte[] data, int origin, int length)
=> Parse(new ByteView(data, origin, length, false));
/// <summary>
/// Blocco TIFF su file (TIFF, DNG, RAW): la finestra si estende da sola se una directory
/// risiede oltre la testa già letta, così un RAW da 15 MiB costa una lettura di 128 KiB.
/// </summary>
public static TiffBlock? ParseStream(Stream stream, long total, int initialRead = 128 * 1024)
=> Parse(new ByteView(new ByteWindow(stream, total, initialRead), 0, false));
/// <summary>Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory.</summary>
private static TiffBlock? Parse(ByteView probe)
{
if (!probe.TryGetUInt16(0, out ushort order)) return null;
bool bigEndian;
if (order == 0x4949) bigEndian = false; // "II" - Intel
else if (order == 0x4D4D) bigEndian = true; // "MM" - Motorola
else return null;
var view = probe.WithEndianness(bigEndian);
if (!view.TryGetUInt16(2, out ushort magic)) return null;
// 42 = TIFF classico. 0x4F52/0x5352 compaiono in alcuni RAW Olympus, li accettiamo.
if (magic != 42 && magic != 0x4F52 && magic != 0x5352) return null;
if (!view.TryGetUInt32(4, out uint firstIfd)) return null;
var block = new TiffBlock(view);
block.ReadChain(firstIfd);
return block;
}
private void ReadChain(uint offset)
{
var visited = new HashSet<uint>();
int guard = 0;
uint next = offset;
while (next != 0 && visited.Add(next) && guard++ < 32)
{
var dir = ReadDirectory(next, out uint following);
if (dir is null) break;
_imageDirectories.Add(dir);
if (Ifd0.Count == 0) MergeInto(Ifd0, dir);
else SubIfds.Add(dir);
// Puntatori alle sub-directory standard.
if (dir.TryGetValue(TiffTags.ExifIfdPointer, out var exifPtr) && TryReadPointer(exifPtr, out uint eo))
{
var d = ReadDirectory(eo, out _);
if (d is not null) MergeInto(Exif, d);
}
if (dir.TryGetValue(TiffTags.GpsIfdPointer, out var gpsPtr) && TryReadPointer(gpsPtr, out uint go))
{
var d = ReadDirectory(go, out _);
if (d is not null) MergeInto(Gps, d);
}
// SubIFDs (DNG / RAW): possono contenere le dimensioni dell'immagine full-res.
if (dir.TryGetValue(TiffTags.SubIfds, out var subs))
{
int count = (int)Math.Min(subs.Count, 8);
for (int i = 0; i < count; i++)
{
if (!View.TryGetUInt32(subs.ValuePosition + i * 4, out uint so)) break;
var d = ReadDirectory(so, out _);
if (d is null) continue;
_imageDirectories.Add(d);
SubIfds.Add(d);
}
}
next = following;
}
}
private static void MergeInto(Dictionary<ushort, TiffEntry> target, Dictionary<ushort, TiffEntry> source)
{
foreach (var kv in source) target.TryAdd(kv.Key, kv.Value);
}
private bool TryReadPointer(in TiffEntry entry, out uint offset)
{
offset = 0;
if (entry.Count < 1) return false;
return View.TryGetUInt32(entry.ValuePosition, out offset);
}
private Dictionary<ushort, TiffEntry>? ReadDirectory(uint offset, out uint nextIfd)
{
nextIfd = 0;
if (!View.TryGetUInt16(offset, out ushort count) || count == 0 || count > 4096) return null;
var dir = new Dictionary<ushort, TiffEntry>(count);
long p = offset + 2L;
for (int i = 0; i < count; i++, p += 12)
{
if (!View.TryGetUInt16(p, out ushort tag)) break;
if (!View.TryGetUInt16(p + 2, out ushort rawType)) break;
if (!View.TryGetUInt32(p + 4, out uint n)) break;
var type = (TiffType)rawType;
int unit = TiffEntry.SizeOf(type);
if (unit == 0) continue; // tipo sconosciuto: voce ignorata
if (n > int.MaxValue / Math.Max(unit, 1)) continue;
long bytes = (long)unit * n;
long valuePos;
if (bytes <= 4)
{
valuePos = p + 8; // valore inline nei 4 byte della voce
}
else
{
if (!View.TryGetUInt32(p + 8, out uint far)) continue;
valuePos = far;
}
if (!View.InRange(valuePos, bytes)) continue;
dir[tag] = new TiffEntry(tag, type, n, valuePos);
}
View.TryGetUInt32(p, out nextIfd);
return dir;
}
// ---------------------------------------------------------------- accesso ai valori
public bool TryFind(ushort tag, out TiffEntry entry)
{
if (Exif.TryGetValue(tag, out entry)) return true;
if (Ifd0.TryGetValue(tag, out entry)) return true;
foreach (var sub in SubIfds)
{
if (sub.TryGetValue(tag, out entry)) return true;
}
entry = default;
return false;
}
public string? GetString(ushort tag)
{
if (!TryFind(tag, out var e)) return null;
if (e.Type is TiffType.Ascii or TiffType.Byte or TiffType.Undefined)
return View.GetAscii(e.ValuePosition, e.ByteLength);
// Alcuni firmware scrivono valori numerici dove ci si aspetta testo.
return TryGetDouble(tag, out double d) ? d.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
}
public bool TryGetUInt32(ushort tag, out uint value)
{
value = 0;
if (!TryFind(tag, out var e) || e.Count < 1) return false;
return TryReadScalarUInt(e, 0, out value);
}
private bool TryReadScalarUInt(in TiffEntry e, int index, out uint value)
{
value = 0;
long pos = e.ValuePosition + (long)index * TiffEntry.SizeOf(e.Type);
switch (e.Type)
{
case TiffType.Byte or TiffType.SByte or TiffType.Undefined:
if (!View.TryGetByte(pos, out byte b)) return false;
value = b; return true;
case TiffType.Short or TiffType.SShort:
if (!View.TryGetUInt16(pos, out ushort s)) return false;
value = s; return true;
case TiffType.Long or TiffType.SLong or TiffType.Ifd:
return View.TryGetUInt32(pos, out value);
default:
if (!TryReadIndexedDouble(e, index, out double d)) return false;
value = d is >= 0 and <= uint.MaxValue ? (uint)d : 0;
return true;
}
}
public bool TryGetDouble(ushort tag, out double value)
{
value = 0;
if (!TryFind(tag, out var e) || e.Count < 1) return false;
return TryReadIndexedDouble(e, 0, out value);
}
private bool TryReadIndexedDouble(in TiffEntry e, int index, out double value)
{
value = 0;
long pos = e.ValuePosition + (long)index * TiffEntry.SizeOf(e.Type);
switch (e.Type)
{
case TiffType.Rational:
{
if (!View.TryGetUInt32(pos, out uint num) || !View.TryGetUInt32(pos + 4, out uint den)) return false;
if (den == 0) { value = num == 0 ? 0 : double.PositiveInfinity; return num == 0; }
value = (double)num / den;
return true;
}
case TiffType.SRational:
{
if (!View.TryGetInt32(pos, out int num) || !View.TryGetInt32(pos + 4, out int den)) return false;
if (den == 0) return false;
value = (double)num / den;
return true;
}
case TiffType.Float:
{
if (!View.TryGetSingle(pos, out float f)) return false;
value = f; return true;
}
case TiffType.Double:
return View.TryGetDouble(pos, out value);
case TiffType.SShort:
{
if (!View.TryGetUInt16(pos, out ushort us)) return false;
value = (short)us; return true;
}
case TiffType.SLong:
{
if (!View.TryGetInt32(pos, out int i)) return false;
value = i; return true;
}
default:
{
if (!TryReadScalarUIntRaw(e.Type, pos, out uint u)) return false;
value = u; return true;
}
}
}
private bool TryReadScalarUIntRaw(TiffType type, long pos, out uint value)
{
value = 0;
switch (type)
{
case TiffType.Byte or TiffType.SByte or TiffType.Undefined:
if (!View.TryGetByte(pos, out byte b)) return false;
value = b; return true;
case TiffType.Short:
if (!View.TryGetUInt16(pos, out ushort s)) return false;
value = s; return true;
case TiffType.Long or TiffType.Ifd:
return View.TryGetUInt32(pos, out value);
default:
return false;
}
}
/// <summary>
/// Dimensioni dell'immagine principale.
///
/// Non basta leggere ImageWidth dalla prima directory: nei DNG — e in quasi tutti i RAW —
/// la IFD0 descrive una miniatura (NewSubfileType = 1) e l'immagine vera vive in una
/// SubIFD. Si scorrono quindi tutte le directory preferendo quelle non marcate come
/// versione ridotta e, a parità di rango, la più grande.
/// </summary>
public bool TryGetMainImageSize(out int width, out int height)
{
width = 0;
height = 0;
long bestArea = 0;
bool bestIsFull = false;
foreach (var directory in _imageDirectories)
{
if (!TryReadDimension(directory, TiffTags.ImageWidth, out int w)) continue;
if (!TryReadDimension(directory, TiffTags.ImageLength, out int h)) continue;
bool full = true;
if (directory.TryGetValue(TiffTags.NewSubfileType, out var marker) &&
TryReadScalarUInt(marker, 0, out uint kind))
{
full = (kind & 1) == 0; // bit 0 acceso = versione a risoluzione ridotta
}
long area = (long)w * h;
bool better = (full && !bestIsFull) || (full == bestIsFull && area > bestArea);
if (!better) continue;
width = w;
height = h;
bestArea = area;
bestIsFull = full;
}
return width > 0 && height > 0;
}
private bool TryReadDimension(Dictionary<ushort, TiffEntry> directory, ushort tag, out int value)
{
value = 0;
if (!directory.TryGetValue(tag, out var entry) || entry.Count < 1) return false;
if (!TryReadScalarUInt(entry, 0, out uint raw) || raw == 0 || raw > int.MaxValue) return false;
value = (int)raw;
return true;
}
/// <summary>Primo valore non nullo di una lista di tag (utile per ISO, presente in più varianti).</summary>
public bool TryGetFirstUInt32(out uint value, params ushort[] tags)
{
foreach (ushort t in tags)
{
if (TryGetUInt32(t, out value) && value != 0) return true;
}
value = 0;
return false;
}
}
/// <summary>Numerazione dei tag TIFF/Exif effettivamente utilizzati dal motore.</summary>
internal static class TiffTags
{
public const ushort NewSubfileType = 0x00FE;
public const ushort ImageWidth = 0x0100;
public const ushort ImageLength = 0x0101;
public const ushort Make = 0x010F;
public const ushort Model = 0x0110;
public const ushort Orientation = 0x0112;
public const ushort SubIfds = 0x014A;
public const ushort DateTime = 0x0132;
public const ushort XmpPacket = 0x02BC;
public const ushort ExposureTime = 0x829A;
public const ushort FNumber = 0x829D;
public const ushort ExifIfdPointer = 0x8769;
public const ushort IsoSpeedRatings = 0x8827;
public const ushort SensitivityType = 0x8830;
public const ushort RecommendedExposureIndex = 0x8832;
public const ushort IsoSpeed = 0x8833;
public const ushort GpsIfdPointer = 0x8825;
public const ushort DateTimeOriginal = 0x9003;
public const ushort DateTimeDigitized = 0x9004;
public const ushort OffsetTime = 0x9010;
public const ushort OffsetTimeOriginal = 0x9011;
public const ushort OffsetTimeDigitized = 0x9012;
public const ushort ShutterSpeedValue = 0x9201;
public const ushort ApertureValue = 0x9202;
public const ushort ExposureBiasValue = 0x9204;
public const ushort MeteringMode = 0x9207;
public const ushort FocalLength = 0x920A;
public const ushort SubSecTime = 0x9290;
public const ushort SubSecTimeOriginal = 0x9291;
public const ushort SubSecTimeDigitized = 0x9292;
public const ushort PixelXDimension = 0xA002;
public const ushort PixelYDimension = 0xA003;
public const ushort ExposureMode = 0xA402;
public const ushort WhiteBalance = 0xA403;
public const ushort LensModel = 0xA434;
}
+259
View File
@@ -0,0 +1,259 @@
namespace Titano.Metadata;
/// <summary>Tipi di dato definiti dalla specifica TIFF 6.0 / Exif 2.32.</summary>
internal enum TiffType : ushort
{
Unknown = 0,
Byte = 1,
Ascii = 2,
Short = 3,
Long = 4,
Rational = 5,
SByte = 6,
Undefined = 7,
SShort = 8,
SLong = 9,
SRational = 10,
Float = 11,
Double = 12,
Ifd = 13,
}
/// <summary>Voce di una IFD: 12 byte nel file, con il valore inline se occupa ≤ 4 byte.</summary>
internal readonly struct TiffEntry
{
public readonly ushort Tag;
public readonly TiffType Type;
public readonly uint Count;
/// <summary>Offset assoluto (rispetto all'inizio del blocco TIFF) dove risiede il valore.</summary>
public readonly long ValuePosition;
public TiffEntry(ushort tag, TiffType type, uint count, long valuePosition)
{
Tag = tag;
Type = type;
Count = count;
ValuePosition = valuePosition;
}
public static int SizeOf(TiffType type) => type switch
{
TiffType.Byte or TiffType.Ascii or TiffType.SByte or TiffType.Undefined => 1,
TiffType.Short or TiffType.SShort => 2,
TiffType.Long or TiffType.SLong or TiffType.Float or TiffType.Ifd => 4,
TiffType.Rational or TiffType.SRational or TiffType.Double => 8,
_ => 0,
};
public long ByteLength => (long)SizeOf(Type) * Count;
}
/// <summary>
/// Finestra su una sorgente di byte, caricata progressivamente.
///
/// I file RAW pesano decine di megabyte ma le directory dei metadati stanno nei primi
/// chilobyte: leggerli per intero, o anche solo a blocchi fissi di qualche megabyte,
/// costa centinaia di millisecondi per file e rende impraticabile l'ingestion di una
/// sequenza da mille scatti. Qui si legge il minimo indispensabile e si estende la
/// finestra solo se un offset punta oltre quanto già caricato.
/// </summary>
internal sealed class ByteWindow
{
/// <summary>Granularità delle letture: evita una chiamata di I/O per ogni tag.</summary>
private const int ChunkSize = 64 * 1024;
private readonly Stream? _stream;
private readonly long _total;
private byte[] _buffer;
private int _loaded;
/// <summary>Sorgente già interamente in memoria (segmento APP1 di un JPEG, chunk PNG).</summary>
public ByteWindow(byte[] data, int length)
{
_buffer = data;
_loaded = Math.Clamp(length, 0, data.Length);
_total = _loaded;
}
/// <summary>Sorgente su file: si carica subito solo la testa.</summary>
public ByteWindow(Stream stream, long total, int initial)
{
_stream = stream;
_total = Math.Max(0, total);
_buffer = new byte[(int)Math.Min(_total, Math.Max(ChunkSize, initial))];
Ensure(Math.Min(_total, initial));
}
public byte[] Buffer => _buffer;
/// <summary>Byte effettivamente presenti in memoria.</summary>
public int Loaded => _loaded;
/// <summary>Byte complessivamente disponibili nella sorgente.</summary>
public long Total => _total;
/// <summary>Garantisce che i primi <paramref name="end"/> byte siano caricati.</summary>
public bool Ensure(long end)
{
if (end <= _loaded) return true;
if (end > _total || _stream is null || end > int.MaxValue) return false;
long target = Math.Min(_total, Math.Max(end, (long)_loaded + ChunkSize));
if (_buffer.Length < target)
{
long grown = Math.Min(_total, Math.Max(target, (long)_buffer.Length * 2));
Array.Resize(ref _buffer, (int)grown);
}
try
{
_stream.Position = _loaded;
while (_loaded < target)
{
int read = _stream.Read(_buffer, _loaded, (int)(target - _loaded));
if (read <= 0) break;
_loaded += read;
}
}
catch (IOException)
{
return false;
}
return end <= _loaded;
}
}
/// <summary>
/// Lettore binario endian-aware su una <see cref="ByteWindow"/>, con origine propria.
/// Tutti gli accessi sono limitati: un file malformato produce valori assenti, mai eccezioni.
/// </summary>
internal readonly struct ByteView
{
private readonly ByteWindow _window;
private readonly int _origin;
public readonly bool BigEndian;
public ByteView(ByteWindow window, int origin, bool bigEndian)
{
_window = window;
_origin = Math.Max(0, origin);
BigEndian = bigEndian;
}
public ByteView(byte[] data, int origin, int length, bool bigEndian)
: this(new ByteWindow(data, Math.Clamp(origin + length, 0, data.Length)), origin, bigEndian)
{
}
public ByteView WithEndianness(bool bigEndian) => new(_window, _origin, bigEndian);
/// <summary>Byte teoricamente raggiungibili da questa origine.</summary>
public long Length => Math.Max(0, _window.Total - _origin);
/// <summary>Byte già caricati a partire da questa origine: limite delle ricerche per scansione.</summary>
private int Available => Math.Max(0, _window.Loaded - _origin);
public bool InRange(long offset, long count)
=> offset >= 0 && count >= 0 && _window.Ensure(_origin + offset + count);
public bool TryGetByte(long offset, out byte value)
{
if (!InRange(offset, 1)) { value = 0; return false; }
value = _window.Buffer[_origin + (int)offset];
return true;
}
public bool TryGetUInt16(long offset, out ushort value)
{
value = 0;
if (!InRange(offset, 2)) return false;
var data = _window.Buffer;
int p = _origin + (int)offset;
value = BigEndian
? (ushort)((data[p] << 8) | data[p + 1])
: (ushort)((data[p + 1] << 8) | data[p]);
return true;
}
public bool TryGetUInt32(long offset, out uint value)
{
value = 0;
if (!InRange(offset, 4)) return false;
var data = _window.Buffer;
int p = _origin + (int)offset;
value = BigEndian
? ((uint)data[p] << 24) | ((uint)data[p + 1] << 16) | ((uint)data[p + 2] << 8) | data[p + 3]
: ((uint)data[p + 3] << 24) | ((uint)data[p + 2] << 16) | ((uint)data[p + 1] << 8) | data[p];
return true;
}
public bool TryGetInt32(long offset, out int value)
{
bool ok = TryGetUInt32(offset, out uint raw);
value = unchecked((int)raw);
return ok;
}
public bool TryGetSingle(long offset, out float value)
{
value = 0f;
if (!TryGetUInt32(offset, out uint raw)) return false;
value = BitConverter.UInt32BitsToSingle(raw);
return true;
}
public bool TryGetDouble(long offset, out double value)
{
value = 0d;
if (!InRange(offset, 8)) return false;
TryGetUInt32(offset, out uint a);
TryGetUInt32(offset + 4, out uint b);
ulong raw = BigEndian ? ((ulong)a << 32) | b : ((ulong)b << 32) | a;
value = BitConverter.UInt64BitsToDouble(raw);
return true;
}
public string? GetAscii(long offset, long count)
{
if (count <= 0 || !InRange(offset, count)) return null;
var data = _window.Buffer;
int p = _origin + (int)offset;
int n = (int)count;
// Le stringhe Exif sono NUL-terminate; alcuni firmware riempiono di spazi.
int end = n;
for (int i = 0; i < n; i++)
{
if (data[p + i] == 0) { end = i; break; }
}
while (end > 0 && (data[p + end - 1] == ' ' || data[p + end - 1] == '\t')) end--;
if (end <= 0) return null;
return System.Text.Encoding.Latin1.GetString(data, p, end);
}
/// <summary>
/// Ricerca di un pattern di byte nella porzione già caricata; -1 se assente.
/// Usata dai ripieghi per scansione, che lavorano su sorgenti interamente in memoria.
/// </summary>
public int IndexOf(ReadOnlySpan<byte> pattern, int startAt = 0)
{
int available = Available;
if (pattern.Length == 0 || pattern.Length > available) return -1;
int from = Math.Clamp(startAt, 0, available);
var haystack = new ReadOnlySpan<byte>(_window.Buffer, _origin + from, available - from);
int index = haystack.IndexOf(pattern);
return index < 0 ? -1 : index + from;
}
public byte[] ToArray(long offset, long count)
{
if (count <= 0 || !InRange(offset, count)) return [];
var result = new byte[count];
Array.Copy(_window.Buffer, _origin + (int)offset, result, 0, (int)count);
return result;
}
}
+149
View File
@@ -0,0 +1,149 @@
using System.Globalization;
namespace Titano.Metadata;
/// <summary>
/// Estrattore XMP minimale scritto in-house: non costruisce un albero XML, esegue una
/// scansione lineare del packet cercando le proprietà utili sia in forma attributo
/// (<c>xmp:CreateDate="..."</c>) sia in forma elemento (<c>&lt;xmp:CreateDate&gt;...&lt;/&gt;</c>).
/// </summary>
internal static class XmpScanner
{
public readonly record struct XmpFields(
DateTime? CaptureTime,
TimeSpan? UtcOffset,
double? ExposureSeconds,
double? FNumber,
int? Iso);
private static readonly string[] DateProps = ["exif:DateTimeOriginal", "photoshop:DateCreated", "xmp:CreateDate"];
public static XmpFields Scan(string packet)
{
DateTime? time = null;
TimeSpan? offset = null;
foreach (string prop in DateProps)
{
string? raw = ReadProperty(packet, prop);
if (raw is null) continue;
if (TryParseIso8601(raw, out var local, out var off))
{
time = local;
offset = off;
break;
}
}
double? exposure = ReadRational(packet, "exif:ExposureTime");
double? fnumber = ReadRational(packet, "exif:FNumber");
if (fnumber is null && ReadRational(packet, "exif:ApertureValue") is { } apex)
fnumber = Math.Round(Math.Pow(2.0, apex / 2.0), 2);
int? iso = null;
// ISOSpeedRatings è tipicamente una rdf:Seq: prendiamo il primo <rdf:li>.
string? isoBlock = ReadProperty(packet, "exif:ISOSpeedRatings") ?? ReadProperty(packet, "exif:PhotographicSensitivity");
if (isoBlock is not null)
{
string digits = ExtractFirstNumber(isoBlock);
if (int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) && v > 0) iso = v;
}
return new XmpFields(time, offset, exposure, fnumber, iso);
}
/// <summary>Individua un packet XMP dentro un buffer generico di testo/binario.</summary>
public static string? ExtractPacket(string text)
{
int start = text.IndexOf("<x:xmpmeta", StringComparison.Ordinal);
if (start < 0) start = text.IndexOf("<rdf:RDF", StringComparison.Ordinal);
if (start < 0) return null;
int end = text.IndexOf("</x:xmpmeta>", start, StringComparison.Ordinal);
if (end < 0) end = text.IndexOf("</rdf:RDF>", start, StringComparison.Ordinal);
if (end < 0) return text[start..];
return text[start..Math.Min(text.Length, end + 12)];
}
private static string? ReadProperty(string packet, string name)
{
// Forma attributo: name="value"
int idx = 0;
while ((idx = packet.IndexOf(name, idx, StringComparison.Ordinal)) >= 0)
{
int after = idx + name.Length;
if (after >= packet.Length) break;
char c = packet[after];
if (c == '=')
{
int q = packet.IndexOfAny(['"', '\''], after);
if (q < 0) break;
char quote = packet[q];
int close = packet.IndexOf(quote, q + 1);
if (close < 0) break;
return packet[(q + 1)..close];
}
if (c is '>' or ' ' or '\r' or '\n' or '\t' or '/')
{
int gt = packet.IndexOf('>', after);
if (gt < 0) break;
if (packet[gt - 1] == '/') { idx = after; continue; } // elemento vuoto
int closeTag = packet.IndexOf("</" + name, gt, StringComparison.Ordinal);
if (closeTag < 0) break;
return packet[(gt + 1)..closeTag].Trim();
}
idx = after;
}
return null;
}
private static double? ReadRational(string packet, string name)
{
string? raw = ReadProperty(packet, name);
if (string.IsNullOrWhiteSpace(raw)) return null;
raw = raw.Trim();
int slash = raw.IndexOf('/');
if (slash > 0)
{
if (double.TryParse(raw[..slash], NumberStyles.Float, CultureInfo.InvariantCulture, out double n) &&
double.TryParse(raw[(slash + 1)..], NumberStyles.Float, CultureInfo.InvariantCulture, out double d) &&
d != 0)
return n / d;
return null;
}
return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double v) ? v : null;
}
private static string ExtractFirstNumber(string text)
{
int i = 0;
while (i < text.Length && (text[i] < '0' || text[i] > '9')) i++;
int start = i;
while (i < text.Length && text[i] is >= '0' and <= '9') i++;
return start < i ? text[start..i] : string.Empty;
}
/// <summary>Parsing ISO-8601 ("2024-06-01T18:32:11.250+02:00") senza dipendere dal formattatore di sistema.</summary>
internal static bool TryParseIso8601(string text, out DateTime local, out TimeSpan? offset)
{
local = default;
offset = null;
text = text.Trim();
if (text.Length < 10) return false;
if (!DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto))
{
// Fallback per date parziali "YYYY-MM-DD".
if (!DateTime.TryParseExact(text[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out local)) return false;
return true;
}
bool hasOffset = text.EndsWith('Z') || text.LastIndexOfAny(['+', '-']) > 10;
if (hasOffset) offset = dto.Offset;
local = DateTime.SpecifyKind(dto.DateTime, DateTimeKind.Unspecified);
return true;
}
}
View File
+104
View File
@@ -0,0 +1,104 @@
namespace Titano.Motion;
/// <summary>
/// Trasformata di Fourier discreta, implementata in-house con lo schema di CooleyTukey
/// a base due, iterativo e in loco. Serve alla correlazione di fase della stabilizzazione:
/// il teorema di convoluzione trasforma la ricerca dello spostamento fra due fotogrammi,
/// che in forma diretta costerebbe una ricerca esaustiva, in un prodotto punto per punto.
///
/// L'implementazione lavora su due array separati per parte reale e immaginaria: nel nostro
/// uso l'ingresso è reale, quindi la parte immaginaria parte a zero e non vale la pena di
/// impacchettare i campioni in una struttura complessa.
/// </summary>
public static class Fourier
{
/// <summary>Vero se il valore è una potenza di due maggiore di zero.</summary>
public static bool IsPowerOfTwo(int value) => value > 0 && (value & (value - 1)) == 0;
/// <summary>Più grande potenza di due minore o uguale al valore indicato.</summary>
public static int FloorPowerOfTwo(int value)
{
if (value < 1) return 0;
int result = 1;
while (result * 2 <= value) result *= 2;
return result;
}
/// <summary>
/// Trasformata monodimensionale in loco su <paramref name="length"/> campioni contigui a
/// partire da <paramref name="offset"/>, con passo <paramref name="stride"/>: lo stesso
/// codice serve così sia le righe sia le colonne di una matrice.
/// </summary>
public static void Transform(float[] re, float[] im, int offset, int stride, int length, bool inverse)
{
if (length <= 1) return;
// Permutazione a bit invertiti: porta i campioni nell'ordine in cui le farfalle
// successive li combinano senza ulteriori spostamenti.
for (int i = 1, j = 0; i < length; i++)
{
int bit = length >> 1;
for (; (j & bit) != 0; bit >>= 1) j ^= bit;
j ^= bit;
if (i >= j) continue;
int a = offset + i * stride;
int b = offset + j * stride;
(re[a], re[b]) = (re[b], re[a]);
(im[a], im[b]) = (im[b], im[a]);
}
double sign = inverse ? 1.0 : -1.0;
for (int size = 2; size <= length; size <<= 1)
{
double angle = sign * 2.0 * Math.PI / size;
float stepRe = (float)Math.Cos(angle);
float stepIm = (float)Math.Sin(angle);
for (int start = 0; start < length; start += size)
{
float wRe = 1f, wIm = 0f;
int half = size >> 1;
for (int k = 0; k < half; k++)
{
int even = offset + (start + k) * stride;
int odd = offset + (start + k + half) * stride;
float tRe = re[odd] * wRe - im[odd] * wIm;
float tIm = re[odd] * wIm + im[odd] * wRe;
re[odd] = re[even] - tRe;
im[odd] = im[even] - tIm;
re[even] += tRe;
im[even] += tIm;
float nextRe = wRe * stepRe - wIm * stepIm;
wIm = wRe * stepIm + wIm * stepRe;
wRe = nextRe;
}
}
}
if (!inverse) return;
float scale = 1f / length;
for (int i = 0; i < length; i++)
{
int index = offset + i * stride;
re[index] *= scale;
im[index] *= scale;
}
}
/// <summary>
/// Trasformata bidimensionale di una matrice quadrata di lato <paramref name="size"/>,
/// per separabilità: prima tutte le righe, poi tutte le colonne.
/// </summary>
public static void Transform2D(float[] re, float[] im, int size, bool inverse)
{
for (int y = 0; y < size; y++) Transform(re, im, y * size, 1, size, inverse);
for (int x = 0; x < size; x++) Transform(re, im, x, size, size, inverse);
}
}
+48
View File
@@ -0,0 +1,48 @@
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>
/// Interpolazione temporale fra due fotogrammi guidata dal campo vettoriale.
///
/// Si usa il warping all'indietro bidirezionale: l'istante t viene ricostruito prelevando da
/// A lungo t·v e da B lungo +(1t)·v, poi i due contributi vengono miscelati con pesi
/// complementari. Il warping all'indietro non lascia buchi (a differenza di quello in avanti)
/// e la miscelazione incrociata attenua gli errori del campo nelle zone di occlusione.
/// </summary>
public static class FrameInterpolator
{
/// <summary>Genera il fotogramma all'istante <paramref name="t"/> ∈ [0,1] fra A e B.</summary>
public static void Interpolate(ImageBuffer a, ImageBuffer b, MotionField flowAtoB,
float t, ImageBuffer destination)
{
if (t <= 0f) { destination.CopyFrom(a); return; }
if (t >= 1f) { destination.CopyFrom(b); return; }
int width = a.Width;
int height = a.Height;
var srcA = a.Data;
var srcB = b.Data;
var dst = destination.Data;
float wa = 1f - t;
Parallel.For(0, height, y =>
{
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int index = rowBase + x * ImageBuffer.Channels;
flowAtoB.Sample(x, y, out float vx, out float vy);
MotionBlurRenderer.SampleBilinear(srcA, width, height, x - vx * t, y - vy * t,
out float ar, out float ag, out float ab);
MotionBlurRenderer.SampleBilinear(srcB, width, height, x + vx * wa, y + vy * wa,
out float br, out float bg, out float bb);
dst[index] = ar * wa + br * t;
dst[index + 1] = ag * wa + bg * t;
dst[index + 2] = ab * wa + bb * t;
}
});
}
}
+160
View File
@@ -0,0 +1,160 @@
using System.Runtime.CompilerServices;
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Piano di luminanza percettiva a precisione singola, con campionamento bilineare.</summary>
public sealed class GrayImage(int width, int height)
{
public int Width { get; } = width;
public int Height { get; } = height;
public float[] Data { get; } = new float[width * height];
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float At(int x, int y)
{
x = x < 0 ? 0 : x >= Width ? Width - 1 : x;
y = y < 0 ? 0 : y >= Height ? Height - 1 : y;
return Data[y * Width + x];
}
/// <summary>Campionamento bilineare con estensione dei bordi.</summary>
public float Sample(float x, float y)
{
int x0 = (int)MathF.Floor(x);
int y0 = (int)MathF.Floor(y);
float fx = x - x0;
float fy = y - y0;
float a = At(x0, y0);
float b = At(x0 + 1, y0);
float c = At(x0, y0 + 1);
float d = At(x0 + 1, y0 + 1);
float top = a + (b - a) * fx;
float bottom = c + (d - c) * fx;
return top + (bottom - top) * fy;
}
}
/// <summary>
/// Piramide gaussiana costruita in-house.
///
/// Il livello 0 è la luminanza dell'immagine ridotta alla risoluzione di analisi: il campo di
/// movimento non richiede la piena risoluzione e lavorare in scala ridotta rende il costo
/// dell'optical flow indipendente dalla dimensione dei file sorgente.
/// La luminanza viene ricodificata in gamma percettiva, perché in luce lineare i gradienti
/// delle zone scure sarebbero numericamente trascurabili rispetto alle alte luci.
/// </summary>
public sealed class GrayPyramid
{
public GrayImage[] Levels { get; }
/// <summary>Rapporto fra la risoluzione di analisi (livello 0) e quella dell'immagine originale.</summary>
public float AnalysisScale { get; }
private GrayPyramid(GrayImage[] levels, float analysisScale)
{
Levels = levels;
AnalysisScale = analysisScale;
}
public int LevelCount => Levels.Length;
/// <summary>
/// Solo il piano di luminanza alla risoluzione di analisi, senza costruire la piramide.
/// Lo usa la correlazione di fase, che lavora su un unico livello.
/// </summary>
public static GrayImage Plane(ImageBuffer frame, int maxAnalysisWidth)
{
float scale = Math.Min(1f, maxAnalysisWidth / (float)frame.Width);
int w = Math.Max(16, (int)MathF.Round(frame.Width * scale));
int h = Math.Max(16, (int)MathF.Round(frame.Height * scale));
var plane = new GrayImage(w, h);
Downsample(frame, plane);
return plane;
}
public static GrayPyramid Build(ImageBuffer frame, int maxAnalysisWidth, int requestedLevels)
{
float scale = Math.Min(1f, maxAnalysisWidth / (float)frame.Width);
int w = Math.Max(16, (int)MathF.Round(frame.Width * scale));
int h = Math.Max(16, (int)MathF.Round(frame.Height * scale));
scale = w / (float)frame.Width;
var level0 = new GrayImage(w, h);
Downsample(frame, level0);
int levels = Math.Clamp(requestedLevels, 1, 6);
while (levels > 1 && (w >> (levels - 1)) < 24) levels--;
var all = new GrayImage[levels];
all[0] = level0;
for (int i = 1; i < levels; i++) all[i] = HalveWithBlur(all[i - 1]);
return new GrayPyramid(all, scale);
}
/// <summary>Riduzione da RGB lineare a luminanza percettiva con media d'area (box filter).</summary>
private static void Downsample(ImageBuffer source, GrayImage destination)
{
int sw = source.Width, sh = source.Height;
int dw = destination.Width, dh = destination.Height;
var src = source.Data;
var dst = destination.Data;
float xRatio = sw / (float)dw;
float yRatio = sh / (float)dh;
Parallel.For(0, dh, dy =>
{
int y0 = (int)(dy * yRatio);
int y1 = Math.Max(y0 + 1, Math.Min(sh, (int)((dy + 1) * yRatio)));
for (int dx = 0; dx < dw; dx++)
{
int x0 = (int)(dx * xRatio);
int x1 = Math.Max(x0 + 1, Math.Min(sw, (int)((dx + 1) * xRatio)));
float sum = 0;
int count = 0;
for (int y = y0; y < y1; y++)
{
int rowBase = y * sw * ImageBuffer.Channels;
for (int x = x0; x < x1; x++)
{
int i = rowBase + x * ImageBuffer.Channels;
sum += ColorSpace.Luminance(src[i], src[i + 1], src[i + 2]);
count++;
}
}
dst[dy * dw + dx] = ColorSpace.ToSrgb(count > 0 ? sum / count : 0f);
}
});
}
/// <summary>Dimezzamento con kernel binomiale 1-2-1 separabile.</summary>
private static GrayImage HalveWithBlur(GrayImage source)
{
int w = Math.Max(1, source.Width / 2);
int h = Math.Max(1, source.Height / 2);
var result = new GrayImage(w, h);
var dst = result.Data;
for (int y = 0; y < h; y++)
{
int sy = y * 2;
for (int x = 0; x < w; x++)
{
int sx = x * 2;
float sum =
source.At(sx - 1, sy - 1) + 2 * source.At(sx, sy - 1) + source.At(sx + 1, sy - 1)
+ 2 * source.At(sx - 1, sy) + 4 * source.At(sx, sy) + 2 * source.At(sx + 1, sy)
+ source.At(sx - 1, sy + 1) + 2 * source.At(sx, sy + 1) + source.At(sx + 1, sy + 1);
dst[y * w + x] = sum / 16f;
}
}
return result;
}
}
+194
View File
@@ -0,0 +1,194 @@
using System.Runtime.CompilerServices;
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Parametri del motion blur sintetico, esposti nel pannello "Elaborazione immagini".</summary>
public sealed class MotionBlurSettings
{
public bool Enabled { get; set; } = true;
/// <summary>Shutter angle desiderato: 180° è la convenzione cinematografica.</summary>
public double TargetShutterAngle { get; set; } = 180.0;
/// <summary>Quota della sfocatura mancante effettivamente sintetizzata.</summary>
public double Strength { get; set; } = 1.0;
/// <summary>Limite superiore della scia, in pixel: protegge dalle stime di movimento errate.</summary>
public double MaxBlurPixels { get; set; } = 48.0;
/// <summary>Numero massimo di campioni per pixel lungo la scia.</summary>
public int MaxSamples { get; set; } = 25;
public MotionBlurSettings Clone() => (MotionBlurSettings)MemberwiseClone();
}
/// <summary>
/// Motion blur direzionale sintetico lungo il campo vettoriale.
///
/// Il fattore di sfocatura mancante deriva dalla composizione delle varianze: la scia
/// realmente incisa nel fotogramma (proporzionale allo shutter angle di scatto) e quella
/// sintetica si sommano in quadratura, quindi per raggiungere l'apertura obiettivo serve
/// una scia lunga √(target² reale²) volte lo spostamento. Sommare linearmente
/// produrrebbe un'immagine sistematicamente troppo morbida.
///
/// Il filtro di ricostruzione è di tipo "gather": ogni campione contribuisce al pixel
/// centrale solo se la propria scia lo raggiunge davvero, così lo sfondo fermo non viene
/// trascinato dentro i soggetti in movimento.
/// </summary>
public static class MotionBlurRenderer
{
/// <summary>
/// Frazione di spostamento da sintetizzare per portare <paramref name="actualAngle"/>
/// al valore <paramref name="targetAngle"/>. Zero se il fotogramma è già abbastanza mosso.
/// </summary>
public static double MissingBlurFactor(double actualAngle, double targetAngle, double strength)
{
double target = Math.Clamp(targetAngle, 0, 360) / 360.0;
double actual = Math.Clamp(actualAngle, 0, 360) / 360.0;
if (target <= actual) return 0;
return Math.Sqrt(target * target - actual * actual) * Math.Clamp(strength, 0, 1);
}
/// <summary>
/// Applica la sfocatura da <paramref name="source"/> a <paramref name="destination"/>.
/// Restituisce la lunghezza media della scia effettivamente resa, in pixel.
/// </summary>
public static double Render(ImageBuffer source, ImageBuffer destination, MotionField field,
double missingFactor, MotionBlurSettings settings)
{
if (missingFactor <= 1e-4)
{
destination.CopyFrom(source);
return 0;
}
int width = source.Width;
int height = source.Height;
float factor = (float)missingFactor;
float maxBlur = (float)Math.Max(1.0, settings.MaxBlurPixels);
int maxSamples = Math.Clamp(settings.MaxSamples, 3, 129);
var src = source.Data;
var dst = destination.Data;
double lengthSum = 0;
object sumLock = new();
Parallel.For(0, height, () => 0.0, (y, _, localSum) =>
{
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int index = rowBase + x * ImageBuffer.Channels;
field.Sample(x, y, out float vx, out float vy);
float magnitude = MathF.Sqrt(vx * vx + vy * vy);
float length = Math.Min(magnitude * factor, maxBlur);
localSum += length;
if (length < 0.5f || magnitude < 1e-4f)
{
dst[index] = src[index];
dst[index + 1] = src[index + 1];
dst[index + 2] = src[index + 2];
continue;
}
float dirX = vx / magnitude;
float dirY = vy / magnitude;
float half = length * 0.5f;
int taps = Math.Min(maxSamples, Math.Max(3, (int)MathF.Ceiling(length) | 1));
int side = taps / 2;
float step = half / side;
// Il pixel centrale è sempre presente: garantisce continuità con le zone ferme.
float accR = src[index], accG = src[index + 1], accB = src[index + 2];
float weightSum = 1f;
for (int k = 1; k <= side; k++)
{
float offset = k * step;
weightSum += Accumulate(src, field, width, height, x, y,
dirX, dirY, offset, factor, maxBlur, ref accR, ref accG, ref accB);
weightSum += Accumulate(src, field, width, height, x, y,
dirX, dirY, -offset, factor, maxBlur, ref accR, ref accG, ref accB);
}
float inv = 1f / weightSum;
dst[index] = accR * inv;
dst[index + 1] = accG * inv;
dst[index + 2] = accB * inv;
}
return localSum;
},
localSum =>
{
lock (sumLock) lengthSum += localSum;
});
return lengthSum / Math.Max(1, (long)width * height);
}
/// <summary>
/// Aggiunge il contributo del campione spostato di <paramref name="offset"/> lungo la scia.
/// Il peso vale 1 solo se il movimento proprio del campione arriva a coprire il pixel centrale.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float Accumulate(float[] src, MotionField field, int width, int height,
int x, int y, float dirX, float dirY, float offset,
float factor, float maxBlur,
ref float accR, ref float accG, ref float accB)
{
float sx = x + dirX * offset;
float sy = y + dirY * offset;
if (sx < 0 || sy < 0 || sx > width - 1 || sy > height - 1) return 0f;
field.Sample(sx, sy, out float tvx, out float tvy);
float projection = MathF.Abs(tvx * dirX + tvy * dirY) * factor * 0.5f;
projection = MathF.Min(projection, maxBlur * 0.5f);
// Transizione morbida su un pixel: evita i gradini sui bordi della scia.
float weight = projection - MathF.Abs(offset) + 1f;
if (weight <= 0f) return 0f;
if (weight > 1f) weight = 1f;
SampleBilinear(src, width, height, sx, sy, out float r, out float g, out float b);
accR += r * weight;
accG += g * weight;
accB += b * weight;
return weight;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void SampleBilinear(float[] data, int width, int height, float x, float y,
out float r, out float g, out float b)
{
if (x < 0) x = 0; else if (x > width - 1) x = width - 1;
if (y < 0) y = 0; else if (y > height - 1) y = height - 1;
int x0 = (int)x;
int y0 = (int)y;
int x1 = x0 + 1 < width ? x0 + 1 : x0;
int y1 = y0 + 1 < height ? y0 + 1 : y0;
float fx = x - x0;
float fy = y - y0;
int i00 = (y0 * width + x0) * ImageBuffer.Channels;
int i10 = (y0 * width + x1) * ImageBuffer.Channels;
int i01 = (y1 * width + x0) * ImageBuffer.Channels;
int i11 = (y1 * width + x1) * ImageBuffer.Channels;
float w00 = (1 - fx) * (1 - fy);
float w10 = fx * (1 - fy);
float w01 = (1 - fx) * fy;
float w11 = fx * fy;
r = data[i00] * w00 + data[i10] * w10 + data[i01] * w01 + data[i11] * w11;
g = data[i00 + 1] * w00 + data[i10 + 1] * w10 + data[i01 + 1] * w01 + data[i11 + 1] * w11;
b = data[i00 + 2] * w00 + data[i10 + 2] * w10 + data[i01 + 2] * w01 + data[i11 + 2] * w11;
}
}
+103
View File
@@ -0,0 +1,103 @@
using System.Runtime.CompilerServices;
namespace Titano.Motion;
/// <summary>
/// Campo vettoriale di movimento memorizzato su griglia rada: un nodo ogni
/// <see cref="CellSize"/> pixel dell'immagine a piena risoluzione. I vettori sono espressi
/// in pixel di spostamento fra il fotogramma corrente e il successivo.
///
/// La griglia rada mantiene l'occupazione trascurabile (per un 4K con celle da 16 px sono
/// ~260 KB) e la lettura bilineare restituisce comunque un campo continuo per-pixel.
/// </summary>
public sealed class MotionField
{
public int GridWidth { get; }
public int GridHeight { get; }
/// <summary>Passo della griglia in pixel dell'immagine a piena risoluzione (può non essere intero).</summary>
public float CellSize { get; }
public int ImageWidth { get; }
public int ImageHeight { get; }
public float[] Vx { get; }
public float[] Vy { get; }
public MotionField(int imageWidth, int imageHeight, float cellSize)
: this(imageWidth, imageHeight, cellSize,
Math.Max(2, (int)Math.Ceiling(imageWidth / Math.Max(1f, cellSize)) + 1),
Math.Max(2, (int)Math.Ceiling(imageHeight / Math.Max(1f, cellSize)) + 1))
{
}
internal MotionField(int imageWidth, int imageHeight, float cellSize, int gridWidth, int gridHeight)
{
ImageWidth = imageWidth;
ImageHeight = imageHeight;
CellSize = Math.Max(1f, cellSize);
GridWidth = Math.Max(2, gridWidth);
GridHeight = Math.Max(2, gridHeight);
Vx = new float[GridWidth * GridHeight];
Vy = new float[GridWidth * GridHeight];
}
public int NodeCount => GridWidth * GridHeight;
/// <summary>Campionamento bilineare del campo in coordinate immagine.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Sample(float x, float y, out float vx, out float vy)
{
float gx = x / CellSize;
float gy = y / CellSize;
int x0 = (int)gx;
int y0 = (int)gy;
float fx = gx - x0;
float fy = gy - y0;
if (x0 < 0) { x0 = 0; fx = 0; }
if (y0 < 0) { y0 = 0; fy = 0; }
if (x0 >= GridWidth - 1) { x0 = GridWidth - 2; fx = 1; }
if (y0 >= GridHeight - 1) { y0 = GridHeight - 2; fy = 1; }
int i00 = y0 * GridWidth + x0;
int i10 = i00 + 1;
int i01 = i00 + GridWidth;
int i11 = i01 + 1;
float w00 = (1 - fx) * (1 - fy);
float w10 = fx * (1 - fy);
float w01 = (1 - fx) * fy;
float w11 = fx * fy;
vx = Vx[i00] * w00 + Vx[i10] * w10 + Vx[i01] * w01 + Vx[i11] * w11;
vy = Vy[i00] * w00 + Vy[i10] * w10 + Vy[i01] * w01 + Vy[i11] * w11;
}
/// <summary>Modulo mediano dei vettori: stima robusta dell'entità del movimento.</summary>
public double MedianMagnitude()
{
int n = NodeCount;
if (n == 0) return 0;
var magnitudes = new float[n];
for (int i = 0; i < n; i++) magnitudes[i] = MathF.Sqrt(Vx[i] * Vx[i] + Vy[i] * Vy[i]);
Array.Sort(magnitudes);
return magnitudes[n / 2];
}
/// <summary>Direzione dominante del campo, in gradi, pesata sul modulo dei vettori.</summary>
public double DominantDirection()
{
double sx = 0, sy = 0;
for (int i = 0; i < Vx.Length; i++)
{
float m = MathF.Sqrt(Vx[i] * Vx[i] + Vy[i] * Vy[i]);
sx += Vx[i] * m;
sy += Vy[i] * m;
}
if (Math.Abs(sx) < 1e-6 && Math.Abs(sy) < 1e-6) return 0;
double deg = Math.Atan2(sy, sx) * 180.0 / Math.PI;
return deg < 0 ? deg + 360 : deg;
}
}
+194
View File
@@ -0,0 +1,194 @@
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Parametri del calcolo del campo vettoriale.</summary>
public sealed class OpticalFlowSettings
{
/// <summary>Larghezza massima a cui viene condotta l'analisi del movimento.</summary>
public int AnalysisWidth { get; set; } = 960;
/// <summary>Passo della griglia di nodi, in pixel della risoluzione di analisi.</summary>
public int CellSize { get; set; } = 8;
/// <summary>Livelli della piramide: determina lo spostamento massimo inseguibile.</summary>
public int PyramidLevels { get; set; } = 4;
/// <summary>Semi-lato della finestra di correlazione.</summary>
public int WindowRadius { get; set; } = 6;
/// <summary>Iterazioni di raffinamento per livello.</summary>
public int Iterations { get; set; } = 5;
/// <summary>Regolarizzazione del sistema normale: stabilizza le zone senza tessitura.</summary>
public double Regularization { get; set; } = 1e-4;
public OpticalFlowSettings Clone() => (OpticalFlowSettings)MemberwiseClone();
}
/// <summary>
/// Calcolo del campo di movimento fra due fotogrammi adiacenti, implementato interamente
/// in-house con uno schema differenziale piramidale (famiglia LucasKanade).
///
/// Su ogni nodo della griglia si risolve iterativamente il sistema normale 2×2
/// costruito dai gradienti spaziali del primo fotogramma e dalla differenza temporale
/// rispetto al secondo, ricampionato secondo la stima corrente. La piramide permette di
/// inseguire spostamenti ampi (nuvole, stelle, folla) partendo dai livelli grossolani;
/// un filtro mediano fra un livello e l'altro elimina i vettori spuri delle zone piatte.
/// </summary>
public sealed class OpticalFlowEngine(OpticalFlowSettings settings)
{
private readonly OpticalFlowSettings _settings = settings;
/// <summary>
/// Calcola il campo dal fotogramma <paramref name="current"/> verso <paramref name="next"/>.
/// I vettori restituiti sono espressi in pixel dell'immagine a piena risoluzione.
/// </summary>
public MotionField Compute(ImageBuffer current, ImageBuffer next)
{
var a = GrayPyramid.Build(current, _settings.AnalysisWidth, _settings.PyramidLevels);
var b = GrayPyramid.Build(next, _settings.AnalysisWidth, _settings.PyramidLevels);
return Compute(a, b, current.Width, current.Height);
}
public MotionField Compute(GrayPyramid a, GrayPyramid b, int fullWidth, int fullHeight)
{
int levels = Math.Min(a.LevelCount, b.LevelCount);
int cell = Math.Max(2, _settings.CellSize);
var baseLevel = a.Levels[0];
int gw = Math.Max(2, baseLevel.Width / cell + 1);
int gh = Math.Max(2, baseLevel.Height / cell + 1);
var vx = new float[gw * gh];
var vy = new float[gw * gh];
for (int level = levels - 1; level >= 0; level--)
{
float levelScale = 1f / (1 << level);
RefineLevel(a.Levels[level], b.Levels[level], vx, vy, gw, gh, cell * levelScale);
MedianFilter(vx, vy, gw, gh);
if (level > 0)
{
// Passando a un livello di risoluzione doppia raddoppia anche lo spostamento.
for (int i = 0; i < vx.Length; i++) { vx[i] *= 2f; vy[i] *= 2f; }
}
}
// Conversione dalle unità della risoluzione di analisi a quelle dell'immagine piena.
float toFull = a.AnalysisScale > 0 ? 1f / a.AnalysisScale : 1f;
var field = new MotionField(fullWidth, fullHeight, cell * toFull, gw, gh);
for (int i = 0; i < vx.Length; i++)
{
field.Vx[i] = vx[i] * toFull;
field.Vy[i] = vy[i] * toFull;
}
return field;
}
/// <summary>Raffina la stima corrente su un livello della piramide.</summary>
private void RefineLevel(GrayImage a, GrayImage b, float[] vx, float[] vy,
int gridWidth, int gridHeight, float nodeSpacing)
{
int radius = Math.Max(2, _settings.WindowRadius);
int iterations = Math.Max(1, _settings.Iterations);
double lambda = Math.Max(0, _settings.Regularization);
float maxStep = Math.Max(4f, radius * 1.5f);
Parallel.For(0, gridHeight, gy =>
{
float py = gy * nodeSpacing;
for (int gx = 0; gx < gridWidth; gx++)
{
float px = gx * nodeSpacing;
int node = gy * gridWidth + gx;
float u = vx[node];
float v = vy[node];
for (int iter = 0; iter < iterations; iter++)
{
double sxx = lambda, sxy = 0, syy = lambda, sxt = 0, syt = 0;
for (int wy = -radius; wy <= radius; wy++)
{
float sy = py + wy;
for (int wx = -radius; wx <= radius; wx++)
{
float sx = px + wx;
// Gradiente spaziale del primo fotogramma (differenze centrali).
float ix = (a.Sample(sx + 1, sy) - a.Sample(sx - 1, sy)) * 0.5f;
float iy = (a.Sample(sx, sy + 1) - a.Sample(sx, sy - 1)) * 0.5f;
// Differenza temporale con il secondo fotogramma ricampionato.
float it = b.Sample(sx + u, sy + v) - a.Sample(sx, sy);
sxx += ix * ix;
sxy += ix * iy;
syy += iy * iy;
sxt += ix * it;
syt += iy * it;
}
}
double det = sxx * syy - sxy * sxy;
if (Math.Abs(det) < 1e-12) break;
// Soluzione del sistema normale: d = -A⁻¹·b
double du = -(syy * sxt - sxy * syt) / det;
double dv = -(sxx * syt - sxy * sxt) / det;
if (double.IsNaN(du) || double.IsNaN(dv)) break;
du = Math.Clamp(du, -maxStep, maxStep);
dv = Math.Clamp(dv, -maxStep, maxStep);
u += (float)du;
v += (float)dv;
if (Math.Abs(du) < 0.01 && Math.Abs(dv) < 0.01) break;
}
// Vincolo fisico: nessun punto può uscire di più dell'intera immagine.
vx[node] = Math.Clamp(u, -a.Width, a.Width);
vy[node] = Math.Clamp(v, -a.Height, a.Height);
}
});
}
private static void MedianFilter(float[] vx, float[] vy, int gridWidth, int gridHeight)
{
var ox = (float[])vx.Clone();
var oy = (float[])vy.Clone();
Span<float> wx = stackalloc float[9];
Span<float> wy = stackalloc float[9];
for (int gy = 0; gy < gridHeight; gy++)
{
for (int gx = 0; gx < gridWidth; gx++)
{
int n = 0;
for (int dy = -1; dy <= 1; dy++)
{
int yy = gy + dy;
if (yy < 0 || yy >= gridHeight) continue;
for (int dx = -1; dx <= 1; dx++)
{
int xx = gx + dx;
if (xx < 0 || xx >= gridWidth) continue;
int idx = yy * gridWidth + xx;
wx[n] = ox[idx];
wy[n] = oy[idx];
n++;
}
}
wx[..n].Sort();
wy[..n].Sort();
int center = gy * gridWidth + gx;
vx[center] = wx[n / 2];
vy[center] = wy[n / 2];
}
}
}
}
+298
View File
@@ -0,0 +1,298 @@
namespace Titano.Motion;
/// <summary>Spostamento misurato su un riquadro, con l'indice di attendibilità del picco.</summary>
public readonly record struct PhaseShift(float Dx, float Dy, float Confidence)
{
public static PhaseShift None => new(0, 0, 0);
}
/// <summary>
/// Correlazione di fase fra due riquadri omologhi di fotogrammi adiacenti.
///
/// Il principio: una traslazione nel dominio spaziale è uno sfasamento lineare nel dominio
/// di Fourier. Normalizzando lo spettro incrociato al modulo unitario resta solo la fase, e
/// l'antitrasformata di una fase lineare pura è un impulso posto esattamente sullo
/// spostamento. Il metodo ignora quindi per costruzione le differenze di luminosità e di
/// contrasto fra i due fotogrammi — che in un time-lapse ci sono sempre — e reagisce
/// unicamente alla geometria.
///
/// L'antitrasformata dà però il picco solo sui campioni interi. Per scendere sotto il pixel
/// si ricostruisce la superficie di correlazione a passo fine nell'intorno del massimo,
/// valutando direttamente la somma di Fourier sulle posizioni intermedie invece di
/// interpolare i campioni già calcolati. La differenza è sostanziale: interpolare con una
/// parabola tre campioni di una cresta che parabola non è introduce un errore sistematico
/// che cresce con l'entità dello spostamento, mentre la valutazione diretta è esatta a meno
/// del passo scelto, perché il segnale è a banda limitata per costruzione.
///
/// L'istanza possiede i propri buffer di lavoro e non è utilizzabile da più thread insieme.
/// </summary>
public sealed class PhaseCorrelator
{
/// <summary>Campioni per lato della griglia di raffinamento, su un intorno di ±1 pixel.</summary>
private const int RefineSteps = 17;
private readonly int _size;
private readonly float[] _aRe;
private readonly float[] _aIm;
private readonly float[] _bRe;
private readonly float[] _bIm;
private readonly float[] _window;
/// <summary>Radici dell'unità di ordine N: servono a spostare la valutazione di un intero.</summary>
private readonly float[] _rootRe;
private readonly float[] _rootIm;
/// <summary>Fattori di fase degli scostamenti frazionari, precalcolati una volta sola.</summary>
private readonly float[] _deltaRe;
private readonly float[] _deltaIm;
private readonly float[] _rowRe;
private readonly float[] _rowIm;
private readonly int[] _frequency;
public int Size => _size;
public PhaseCorrelator(int size)
{
if (!Fourier.IsPowerOfTwo(size))
throw new ArgumentException("La correlazione di fase richiede un lato potenza di due.", nameof(size));
_size = size;
int samples = size * size;
_aRe = new float[samples];
_aIm = new float[samples];
_bRe = new float[samples];
_bIm = new float[samples];
_window = BuildWindow(size);
_rootRe = new float[size];
_rootIm = new float[size];
for (int m = 0; m < size; m++)
{
double angle = 2.0 * Math.PI * m / size;
_rootRe[m] = (float)Math.Cos(angle);
_rootIm[m] = (float)Math.Sin(angle);
}
// Frequenza con segno: oltre metà spettro l'indice rappresenta una frequenza negativa.
// Usare l'indice grezzo darebbe una interpolazione sbagliata sulle posizioni frazionarie.
_frequency = new int[size];
for (int k = 0; k < size; k++) _frequency[k] = k < size / 2 ? k : k - size;
_deltaRe = new float[RefineSteps * size];
_deltaIm = new float[RefineSteps * size];
for (int step = 0; step < RefineSteps; step++)
{
double delta = -1.0 + 2.0 * step / (RefineSteps - 1);
for (int k = 0; k < size; k++)
{
double angle = 2.0 * Math.PI * _frequency[k] * delta / size;
_deltaRe[step * size + k] = (float)Math.Cos(angle);
_deltaIm[step * size + k] = (float)Math.Sin(angle);
}
}
_rowRe = new float[size * RefineSteps];
_rowIm = new float[size * RefineSteps];
}
/// <summary>
/// Finestra di Hann separabile. Senza di essa il bordo del riquadro si comporta come un
/// gradino e la sua trasformata riempie di righe l'intero spettro, coprendo il picco.
/// </summary>
private static float[] BuildWindow(int size)
{
var line = new float[size];
for (int i = 0; i < size; i++)
line[i] = 0.5f * (1f - MathF.Cos(2f * MathF.PI * i / (size - 1)));
var window = new float[size * size];
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++) window[y * size + x] = line[y] * line[x];
}
return window;
}
/// <summary>
/// Misura lo spostamento del contenuto del riquadro fra <paramref name="a"/> e
/// <paramref name="b"/>: il risultato è il vettore d per cui b(p) ≈ a(p d).
/// </summary>
public PhaseShift Correlate(GrayImage a, GrayImage b, int originX, int originY)
{
Load(a, originX, originY, _aRe, _aIm);
Load(b, originX, originY, _bRe, _bIm);
Fourier.Transform2D(_aRe, _aIm, _size, false);
Fourier.Transform2D(_bRe, _bIm, _size, false);
// Spettro incrociato coniugato e normalizzato: conj(A)·B / |conj(A)·B|.
// Con questa combinazione l'impulso dell'antitrasformata cade su +d.
for (int i = 0; i < _aRe.Length; i++)
{
float cr = _aRe[i] * _bRe[i] + _aIm[i] * _bIm[i];
float ci = _aRe[i] * _bIm[i] - _aIm[i] * _bRe[i];
float magnitude = MathF.Sqrt(cr * cr + ci * ci);
if (magnitude < 1e-12f) { _aRe[i] = 0; _aIm[i] = 0; continue; }
_aRe[i] = cr / magnitude;
_aIm[i] = ci / magnitude;
}
// Lo spettro normalizzato serve ancora al raffinamento: i buffer di B sono liberi.
Array.Copy(_aRe, _bRe, _aRe.Length);
Array.Copy(_aIm, _bIm, _aIm.Length);
Fourier.Transform2D(_aRe, _aIm, _size, true);
int peak = 0;
float best = float.NegativeInfinity;
double energy = 0;
for (int i = 0; i < _aRe.Length; i++)
{
float value = _aRe[i];
energy += Math.Abs(value);
if (value <= best) continue;
best = value;
peak = i;
}
if (best <= 0) return PhaseShift.None;
// Il piano della correlazione è periodico: la metà superiore rappresenta gli
// spostamenti negativi.
int px = peak % _size;
int py = peak / _size;
int ix = px > _size / 2 ? px - _size : px;
int iy = py > _size / 2 ? py - _size : py;
Refine(ix, iy, out float dx, out float dy);
// Attendibilità: quanto il picco svetta sul livello medio del piano. Un riquadro
// senza tessitura, o coperto da una nuvola che si è mossa da sola, produce una
// collina larga e bassa, non un impulso.
double mean = energy / _aRe.Length;
float confidence = mean > 1e-9 ? (float)(best / mean) : 0;
return new PhaseShift(dx, dy, confidence);
}
/// <summary>
/// Ricostruisce la correlazione a passo fine su ±1 pixel attorno al massimo intero,
/// valutando la somma di Fourier direttamente sulle posizioni intermedie. La somma è
/// separabile: prima si risolve la direzione orizzontale per ogni riga dello spettro,
/// poi si combinano le righe. Il costo resta dello stesso ordine della trasformata.
/// </summary>
private void Refine(int integerX, int integerY, out float dx, out float dy)
{
int n = _size;
// Somma sulle frequenze orizzontali, per ogni riga dello spettro e ogni scostamento.
for (int ky = 0; ky < n; ky++)
{
int spectrumRow = ky * n;
for (int step = 0; step < RefineSteps; step++)
{
int deltaRow = step * n;
float sumRe = 0, sumIm = 0;
for (int kx = 0; kx < n; kx++)
{
// Fase totale = parte intera (radice dell'unità) × parte frazionaria.
int rotation = ((_frequency[kx] * integerX) % n + n) % n;
float phaseRe = _rootRe[rotation] * _deltaRe[deltaRow + kx]
- _rootIm[rotation] * _deltaIm[deltaRow + kx];
float phaseIm = _rootRe[rotation] * _deltaIm[deltaRow + kx]
+ _rootIm[rotation] * _deltaRe[deltaRow + kx];
float re = _bRe[spectrumRow + kx];
float im = _bIm[spectrumRow + kx];
sumRe += re * phaseRe - im * phaseIm;
sumIm += re * phaseIm + im * phaseRe;
}
_rowRe[ky * RefineSteps + step] = sumRe;
_rowIm[ky * RefineSteps + step] = sumIm;
}
}
float bestValue = float.NegativeInfinity;
int bestX = RefineSteps / 2, bestY = RefineSteps / 2;
Span<float> surface = stackalloc float[RefineSteps * RefineSteps];
for (int stepY = 0; stepY < RefineSteps; stepY++)
{
int deltaRow = stepY * n;
for (int stepX = 0; stepX < RefineSteps; stepX++)
{
float sum = 0;
for (int ky = 0; ky < n; ky++)
{
int rotation = ((_frequency[ky] * integerY) % n + n) % n;
float phaseRe = _rootRe[rotation] * _deltaRe[deltaRow + ky]
- _rootIm[rotation] * _deltaIm[deltaRow + ky];
float phaseIm = _rootRe[rotation] * _deltaIm[deltaRow + ky]
+ _rootIm[rotation] * _deltaRe[deltaRow + ky];
float re = _rowRe[ky * RefineSteps + stepX];
float im = _rowIm[ky * RefineSteps + stepX];
sum += re * phaseRe - im * phaseIm; // basta la parte reale
}
surface[stepY * RefineSteps + stepX] = sum;
if (sum <= bestValue) continue;
bestValue = sum;
bestX = stepX;
bestY = stepY;
}
}
float span = 2f / (RefineSteps - 1);
// Sulla griglia fine la cresta è ormai ben campionata: qui la parabola è legittima.
float offsetX = bestX is > 0 and < RefineSteps - 1
? ParabolicOffset(surface[bestY * RefineSteps + bestX - 1], bestValue,
surface[bestY * RefineSteps + bestX + 1])
: 0;
float offsetY = bestY is > 0 and < RefineSteps - 1
? ParabolicOffset(surface[(bestY - 1) * RefineSteps + bestX], bestValue,
surface[(bestY + 1) * RefineSteps + bestX])
: 0;
dx = integerX + (-1f + bestX * span) + offsetX * span;
dy = integerY + (-1f + bestY * span) + offsetY * span;
}
/// <summary>Vertice della parabola per i tre campioni attorno al picco, in [-0.5, 0.5].</summary>
private static float ParabolicOffset(float left, float centre, float right)
{
float denominator = 2f * (2f * centre - left - right);
if (MathF.Abs(denominator) < 1e-9f) return 0;
float offset = (right - left) / denominator;
return MathF.Abs(offset) > 0.5f ? 0 : offset;
}
/// <summary>Estrae il riquadro, ne toglie la media e vi applica la finestra.</summary>
private void Load(GrayImage image, int originX, int originY, float[] re, float[] im)
{
double sum = 0;
for (int y = 0; y < _size; y++)
{
int rowBase = y * _size;
for (int x = 0; x < _size; x++)
{
float value = image.At(originX + x, originY + y);
re[rowBase + x] = value;
sum += value;
}
}
float mean = (float)(sum / re.Length);
for (int i = 0; i < re.Length; i++)
{
re[i] = (re[i] - mean) * _window[i];
im[i] = 0f;
}
}
}
+401
View File
@@ -0,0 +1,401 @@
using Titano.Core;
namespace Titano.Motion;
/// <summary>
/// Similitudine piana: rotazione, scala isotropa e traslazione, nella forma q = s·R(θ)·p + t.
///
/// Le coordinate sono normalizzate sulla larghezza del fotogramma e centrate: un punto vale
/// ((x W/2)/W, (y H/2)/W). Così la stessa trasformazione descrive il movimento tanto alla
/// risoluzione ridotta dell'analisi quanto a quella piena del rendering, senza conversioni
/// e senza il rischio di applicare uno spostamento misurato in pixel sbagliati.
/// </summary>
public readonly record struct SimilarityTransform(double Scale, double Rotation, double Tx, double Ty)
{
public static SimilarityTransform Identity => new(1, 0, 0, 0);
public bool IsIdentity => Math.Abs(Scale - 1) < 1e-9 && Math.Abs(Rotation) < 1e-9
&& Math.Abs(Tx) < 1e-9 && Math.Abs(Ty) < 1e-9;
public (double X, double Y) Apply(double x, double y)
{
double cos = Math.Cos(Rotation), sin = Math.Sin(Rotation);
return (Scale * (cos * x - sin * y) + Tx,
Scale * (sin * x + cos * y) + Ty);
}
public SimilarityTransform Inverse
{
get
{
double scale = Math.Abs(Scale) < 1e-12 ? 1 : 1.0 / Scale;
double cos = Math.Cos(-Rotation), sin = Math.Sin(-Rotation);
return new SimilarityTransform(scale, -Rotation,
-scale * (cos * Tx - sin * Ty),
-scale * (sin * Tx + cos * Ty));
}
}
/// <summary>Composizione <paramref name="outer"/> ∘ <paramref name="inner"/>: prima inner, poi outer.</summary>
public static SimilarityTransform Compose(in SimilarityTransform outer, in SimilarityTransform inner)
{
var (tx, ty) = outer.Apply(inner.Tx, inner.Ty);
return new SimilarityTransform(outer.Scale * inner.Scale,
outer.Rotation + inner.Rotation,
tx, ty);
}
/// <summary>
/// Interpolazione fra due trasformazioni. La scala si interpola in scala logaritmica,
/// l'unica in cui il valore intermedio fra 1× e 4× è 2× e non 2,5×.
/// </summary>
public static SimilarityTransform Lerp(in SimilarityTransform a, in SimilarityTransform b, double t)
{
if (t <= 0) return a;
if (t >= 1) return b;
double logScale = Math.Log(Math.Max(1e-6, a.Scale)) * (1 - t) + Math.Log(Math.Max(1e-6, b.Scale)) * t;
return new SimilarityTransform(Math.Exp(logScale),
a.Rotation * (1 - t) + b.Rotation * t,
a.Tx * (1 - t) + b.Tx * t,
a.Ty * (1 - t) + b.Ty * t);
}
}
/// <summary>Parametri della stabilizzazione sub-pixel.</summary>
public sealed class StabilizationSettings
{
public bool Enabled { get; set; }
/// <summary>Lato dei riquadri di correlazione, in pixel della risoluzione di analisi (potenza di due).</summary>
public int PatchSize { get; set; } = 128;
/// <summary>Riquadri per lato: 3 significa nove misure indipendenti per coppia di fotogrammi.</summary>
public int Grid { get; set; } = 3;
/// <summary>Larghezza di analisi della correlazione, in pixel.</summary>
public int AnalysisWidth { get; set; } = 960;
/// <summary>
/// Finestra della lisciatura del percorso: quanto un movimento deve durare per essere
/// considerato voluto. Corta rimuove solo il tremolio, lunga blocca anche le panoramiche.
/// </summary>
public int SmoothingFrames { get; set; } = 45;
/// <summary>Corregge anche la rotazione residua, tipica del vento che fa torcere la testa del treppiede.</summary>
public bool CompensateRotation { get; set; } = true;
/// <summary>Quota della correzione applicata.</summary>
public double Strength { get; set; } = 1.0;
/// <summary>Limite della correzione, in frazione della larghezza: protegge da stime sbagliate.</summary>
public double MaxCorrectionFraction { get; set; } = 0.06;
/// <summary>Rapporto minimo fra picco e fondo della correlazione perché la misura sia accettata.</summary>
public double MinConfidence { get; set; } = 2.5;
public StabilizationSettings Clone() => (StabilizationSettings)MemberwiseClone();
}
/// <summary>Percorso della camera ricostruito e correzione da applicare a ciascun fotogramma.</summary>
public sealed class StabilizationPath
{
public required SimilarityTransform[] Correction { get; init; }
/// <summary>Trasformazione assoluta misurata, rispetto al primo fotogramma. Serve al grafico.</summary>
public required SimilarityTransform[] Measured { get; init; }
/// <summary>Spostamento residuo medio rimosso, in frazione della larghezza.</summary>
public double MeanShake { get; init; }
/// <summary>Correzione massima applicata: da qui si ricava il margine da ritagliare.</summary>
public double MaxCorrection { get; init; }
/// <summary>Frazione di coppie in cui la correlazione non ha dato un picco attendibile.</summary>
public double UnreliableFraction { get; init; }
public int Count => Correction.Length;
/// <summary>Correzione a posizione frazionaria, per i fotogrammi sintetizzati dal time-ramping.</summary>
public SimilarityTransform At(double position)
{
if (Correction.Length == 0) return SimilarityTransform.Identity;
int i = (int)Math.Floor(position);
if (i < 0) return Correction[0];
if (i >= Correction.Length - 1) return Correction[^1];
return SimilarityTransform.Lerp(Correction[i], Correction[i + 1], position - i);
}
/// <summary>
/// Ingrandimento minimo che tiene i bordi vuoti fuori dall'inquadratura. Spostando il
/// fotogramma per compensare un urto si scopre una striscia di nulla sul lato opposto:
/// l'unico rimedio onesto è ritagliare quel tanto.
/// </summary>
public double RequiredZoom(double aspect)
{
double translation = MaxCorrection;
double rotation = 0;
foreach (var c in Correction) rotation = Math.Max(rotation, Math.Abs(c.Rotation));
// Il vertice più lontano dal centro percorre un arco di raggio pari alla semidiagonale.
double halfDiagonal = 0.5 * Math.Sqrt(1 + aspect * aspect);
double rotationLoss = halfDiagonal * Math.Abs(Math.Sin(rotation));
double needed = 1.0 + 2.0 * (translation + rotationLoss);
return Math.Clamp(needed, 1.0, 1.35);
}
}
/// <summary>
/// Stabilizzazione dei micro-urti: vento sul treppiede, passi vicino alla macchina, scatto
/// dello specchio. Sono spostamenti di pochi pixel che a velocità di time-lapse diventano un
/// tremolio continuo, ben visibile e impossibile da togliere in ripresa.
///
/// Per ogni coppia di fotogrammi adiacenti si misura lo spostamento su una griglia di
/// riquadri con la correlazione di fase; dalle misure si stima ai minimi quadrati una
/// similitudine — traslazione, rotazione e scala — scartando i riquadri il cui picco non è
/// attendibile, tipicamente quelli occupati da nuvole o fronde che si muovono per conto loro.
///
/// Le trasformazioni relative vengono poi composte in un percorso assoluto, che viene lisciato:
/// ciò che resta fra percorso misurato e percorso liscio è il tremolio, e la sua inversa è la
/// correzione. Una panoramica voluta sopravvive perché è già liscia; un urto no perché non lo è.
///
/// L'istanza contiene i buffer della trasformata e non è condivisibile fra thread.
/// </summary>
public sealed class Stabilizer(StabilizationSettings settings)
{
private readonly StabilizationSettings _settings = settings;
private PhaseCorrelator? _correlator;
/// <summary>
/// Stima la trasformazione che porta le coordinate di <paramref name="a"/> in quelle di
/// <paramref name="b"/>, in unità normalizzate sulla larghezza.
/// </summary>
public SimilarityTransform Estimate(GrayImage a, GrayImage b, out double confidence)
{
confidence = 0;
if (a.Width != b.Width || a.Height != b.Height) return SimilarityTransform.Identity;
int requested = Math.Max(32, Fourier.FloorPowerOfTwo(_settings.PatchSize));
int patch = Fourier.FloorPowerOfTwo(Math.Min(requested, Math.Min(a.Width, a.Height)));
if (patch < 32) return SimilarityTransform.Identity;
if (_correlator is null || _correlator.Size != patch) _correlator = new PhaseCorrelator(patch);
int grid = Math.Clamp(_settings.Grid, 1, 5);
int spanX = Math.Max(0, a.Width - patch);
int spanY = Math.Max(0, a.Height - patch);
double width = a.Width;
double centreX = a.Width / 2.0;
double centreY = a.Height / 2.0;
var px = new double[grid * grid];
var py = new double[grid * grid];
var qx = new double[grid * grid];
var qy = new double[grid * grid];
var weight = new double[grid * grid];
int samples = 0;
double confidenceSum = 0;
for (int gy = 0; gy < grid; gy++)
{
int originY = grid == 1 ? spanY / 2 : spanY * gy / (grid - 1);
for (int gx = 0; gx < grid; gx++)
{
int originX = grid == 1 ? spanX / 2 : spanX * gx / (grid - 1);
var shift = _correlator.Correlate(a, b, originX, originY);
confidenceSum += shift.Confidence;
if (shift.Confidence < _settings.MinConfidence) continue;
// Uno spostamento superiore a un quarto del fotogramma non è un micro-urto:
// è un picco spurio, oppure la scena è cambiata del tutto.
if (Math.Abs(shift.Dx) > a.Width * 0.25 || Math.Abs(shift.Dy) > a.Height * 0.25) continue;
double ux = (originX + patch / 2.0 - centreX) / width;
double uy = (originY + patch / 2.0 - centreY) / width;
px[samples] = ux;
py[samples] = uy;
qx[samples] = ux + shift.Dx / width;
qy[samples] = uy + shift.Dy / width;
weight[samples] = shift.Confidence;
samples++;
}
}
confidence = grid * grid > 0 ? confidenceSum / (grid * grid) : 0;
if (samples == 0) return SimilarityTransform.Identity;
var estimate = Fit(px, py, qx, qy, weight, samples, _settings.CompensateRotation);
// Seconda passata: i riquadri che si discostano dal modello comune vengono spenti.
// È il caso classico del riquadro sul cielo, dove le nuvole scorrono da sole.
if (samples >= 4)
{
var residuals = new double[samples];
for (int i = 0; i < samples; i++)
{
var (fx, fy) = estimate.Apply(px[i], py[i]);
residuals[i] = Math.Sqrt((fx - qx[i]) * (fx - qx[i]) + (fy - qy[i]) * (fy - qy[i]));
}
var sorted = (double[])residuals.Clone();
Array.Sort(sorted);
double median = sorted[samples / 2];
double scale = Math.Max(1.4826 * median, 1e-5);
for (int i = 0; i < samples; i++)
{
double u = residuals[i] / (3.0 * scale);
weight[i] *= u >= 1 ? 0 : (1 - u * u) * (1 - u * u);
}
double total = 0;
for (int i = 0; i < samples; i++) total += weight[i];
if (total > 1e-9) estimate = Fit(px, py, qx, qy, weight, samples, _settings.CompensateRotation);
}
return Sanitize(estimate);
}
/// <summary>Similitudine ai minimi quadrati pesati fra due insiemi di punti omologhi.</summary>
private static SimilarityTransform Fit(double[] px, double[] py, double[] qx, double[] qy,
double[] weight, int count, bool allowRotation)
{
double sw = 0, pxBar = 0, pyBar = 0, qxBar = 0, qyBar = 0;
for (int i = 0; i < count; i++)
{
double w = weight[i];
if (w <= 0) continue;
sw += w;
pxBar += w * px[i];
pyBar += w * py[i];
qxBar += w * qx[i];
qyBar += w * qy[i];
}
if (sw <= 1e-9) return SimilarityTransform.Identity;
pxBar /= sw; pyBar /= sw; qxBar /= sw; qyBar /= sw;
if (!allowRotation) return new SimilarityTransform(1, 0, qxBar - pxBar, qyBar - pyBar);
double dot = 0, cross = 0, norm = 0;
for (int i = 0; i < count; i++)
{
double w = weight[i];
if (w <= 0) continue;
double ax = px[i] - pxBar, ay = py[i] - pyBar;
double bx = qx[i] - qxBar, by = qy[i] - qyBar;
dot += w * (ax * bx + ay * by);
cross += w * (ax * by - ay * bx);
norm += w * (ax * ax + ay * ay);
}
// Riquadri troppo vicini fra loro non vincolano rotazione e scala: resta la traslazione.
if (norm < 1e-9) return new SimilarityTransform(1, 0, qxBar - pxBar, qyBar - pyBar);
double rotation = Math.Atan2(cross, dot);
double scale = Math.Sqrt(dot * dot + cross * cross) / norm;
if (double.IsNaN(scale) || scale <= 0) scale = 1;
double cos = Math.Cos(rotation), sin = Math.Sin(rotation);
double tx = qxBar - scale * (cos * pxBar - sin * pyBar);
double ty = qyBar - scale * (sin * pxBar + cos * pyBar);
return new SimilarityTransform(scale, rotation, tx, ty);
}
/// <summary>Limiti fisici: fra due scatti adiacenti nessuno di questi parametri può esplodere.</summary>
private static SimilarityTransform Sanitize(in SimilarityTransform t)
{
double scale = double.IsFinite(t.Scale) ? Math.Clamp(t.Scale, 0.9, 1.1) : 1;
double rotation = double.IsFinite(t.Rotation) ? Math.Clamp(t.Rotation, -0.09, 0.09) : 0;
double tx = double.IsFinite(t.Tx) ? Math.Clamp(t.Tx, -0.25, 0.25) : 0;
double ty = double.IsFinite(t.Ty) ? Math.Clamp(t.Ty, -0.25, 0.25) : 0;
return new SimilarityTransform(scale, rotation, tx, ty);
}
// ------------------------------------------------------------------ percorso
/// <summary>
/// Compone le trasformazioni relative in un percorso assoluto, lo liscia e ne ricava la
/// correzione per fotogramma. <paramref name="relative"/>[i] porta le coordinate del
/// fotogramma i1 in quelle del fotogramma i; l'elemento zero è ignorato.
/// </summary>
public static StabilizationPath BuildPath(IReadOnlyList<SimilarityTransform> relative,
IReadOnlyList<double> confidence,
StabilizationSettings settings)
{
int n = relative.Count;
var measured = new SimilarityTransform[n];
var correction = new SimilarityTransform[n];
if (n == 0)
{
return new StabilizationPath { Correction = correction, Measured = measured };
}
measured[0] = SimilarityTransform.Identity;
for (int i = 1; i < n; i++) measured[i] = SimilarityTransform.Compose(relative[i], measured[i - 1]);
var tx = new double[n];
var ty = new double[n];
var rotation = new double[n];
var logScale = new double[n];
for (int i = 0; i < n; i++)
{
tx[i] = measured[i].Tx;
ty[i] = measured[i].Ty;
rotation[i] = measured[i].Rotation;
logScale[i] = Math.Log(Math.Max(1e-6, measured[i].Scale));
}
int window = Math.Max(3, settings.SmoothingFrames | 1);
var smoothTx = LocalRegression.Smooth(tx, window, false);
var smoothTy = LocalRegression.Smooth(ty, window, false);
var smoothRotation = LocalRegression.Smooth(rotation, window, false);
var smoothLogScale = LocalRegression.Smooth(logScale, window, false);
double strength = Math.Clamp(settings.Strength, 0, 1);
double limit = Math.Max(0.001, settings.MaxCorrectionFraction);
double shakeSum = 0;
double maxCorrection = 0;
for (int i = 0; i < n; i++)
{
var target = new SimilarityTransform(Math.Exp(smoothLogScale[i]), smoothRotation[i],
smoothTx[i], smoothTy[i]);
// Dal punto in cui il fotogramma si trova davvero a quello in cui dovrebbe stare.
var raw = SimilarityTransform.Compose(target, measured[i].Inverse);
var scaled = SimilarityTransform.Lerp(SimilarityTransform.Identity, raw, strength);
double magnitude = Math.Sqrt(scaled.Tx * scaled.Tx + scaled.Ty * scaled.Ty);
if (magnitude > limit)
{
double factor = limit / magnitude;
scaled = new SimilarityTransform(scaled.Scale, scaled.Rotation,
scaled.Tx * factor, scaled.Ty * factor);
magnitude = limit;
}
correction[i] = scaled;
shakeSum += magnitude;
maxCorrection = Math.Max(maxCorrection, magnitude);
}
int unreliable = 0;
for (int i = 1; i < n && i < confidence.Count; i++)
{
if (confidence[i] < settings.MinConfidence) unreliable++;
}
return new StabilizationPath
{
Correction = correction,
Measured = measured,
MeanShake = shakeSum / n,
MaxCorrection = maxCorrection,
UnreliableFraction = n > 1 ? unreliable / (double)(n - 1) : 0,
};
}
}
+217
View File
@@ -0,0 +1,217 @@
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Modalità di accumulo temporale.</summary>
public enum StackingMode
{
Off,
/// <summary>Mediana su una finestra di fotogrammi: rimuove ciò che passa e non appartiene alla scena.</summary>
Median,
/// <summary>Massimo progressivo: fonde i picchi di luce in scie continue.</summary>
Maximum,
}
/// <summary>Parametri dell'accumulo temporale.</summary>
public sealed class StackingSettings
{
public StackingMode Mode { get; set; } = StackingMode.Off;
/// <summary>Fotogrammi della finestra della mediana; viene reso dispari internamente.</summary>
public int WindowFrames { get; set; } = 5;
/// <summary>Quota dell'effetto miscelata sul fotogramma originale.</summary>
public double Strength { get; set; } = 1.0;
/// <summary>
/// Lunghezza delle scie stellari in fotogrammi; 0 significa scie che non si spengono mai.
/// Un valore finito fa svanire la coda e mantiene leggibile il paesaggio sotto.
/// </summary>
public double TrailFrames { get; set; }
public StackingSettings Clone() => (StackingSettings)MemberwiseClone();
/// <summary>Semiampiezza effettiva della finestra: quanti fotogrammi servono da ogni lato.</summary>
public int MedianRadius => Mode == StackingMode.Median
? Math.Clamp((Math.Max(3, WindowFrames) - 1) / 2, 1, 24)
: 0;
}
/// <summary>
/// Accumulo e filtraggio temporale.
///
/// <b>Mediana.</b> Su una finestra di fotogrammi consecutivi, ogni pixel prende il valore
/// centrale della propria serie temporale. Ciò che è stabile — il paesaggio — resta se stesso,
/// perché la maggioranza dei campioni lo mostra; ciò che attraversa l'inquadratura una volta
/// sola — una persona, un'automobile, un uccello, il faro di un'auto lontana — occupa una
/// minoranza dei campioni e la mediana lo scarta. La media non funzionerebbe: lascerebbe un
/// fantasma tanto più visibile quanto più l'intruso era contrastato.
///
/// <b>Massimo.</b> Ogni pixel trattiene il valore più alto incontrato finora. Sui cieli
/// notturni è il modo classico di ottenere le scie stellari: la stella si sposta di poco a
/// ogni scatto, il massimo ne conserva il passaggio e il risultato è un arco continuo invece
/// di un punto. Con una lunghezza di scia finita l'accumulo sfuma piano, e le scie hanno una
/// coda invece di riempire progressivamente tutto il cielo.
///
/// Entrambe le modalità accettano un allineamento per fotogramma: se la stabilizzazione è
/// attiva i fotogrammi vanno sovrapposti dopo averli raddrizzati, altrimenti la mediana
/// vedrebbe come intruso il tremolio stesso e le scie uscirebbero doppie.
/// </summary>
public static class TemporalStacker
{
/// <summary>Numero massimo di fotogrammi mediabili in una finestra.</summary>
public const int MaxWindow = 49;
/// <summary>
/// Mediana temporale della finestra indicata, miscelata sul fotogramma centrale secondo
/// l'intensità. <paramref name="alignment"/> può essere null quando i fotogrammi sono già
/// sovrapposti; altrimenti ogni elemento porta le coordinate del fotogramma centrale in
/// quelle del fotogramma corrispondente.
/// </summary>
public static void Median(IReadOnlyList<ImageBuffer> window, IReadOnlyList<SourceMapping>? alignment,
ImageBuffer centre, ImageBuffer destination, double strength)
{
int count = Math.Min(window.Count, MaxWindow);
if (count <= 1)
{
destination.CopyFrom(centre);
return;
}
int width = destination.Width;
int height = destination.Height;
var dst = destination.Data;
var src = centre.Data;
float blend = (float)Math.Clamp(strength, 0, 1);
bool aligned = alignment is not null;
Parallel.For(0, height, y =>
{
Span<float> red = stackalloc float[MaxWindow];
Span<float> green = stackalloc float[MaxWindow];
Span<float> blue = stackalloc float[MaxWindow];
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
for (int k = 0; k < count; k++)
{
var frame = window[k];
if (aligned)
{
var (sx, sy) = alignment![k].Apply(x, y);
MotionBlurRenderer.SampleBilinear(frame.Data, frame.Width, frame.Height,
(float)sx, (float)sy,
out red[k], out green[k], out blue[k]);
}
else
{
int index = (y * frame.Width + x) * ImageBuffer.Channels;
red[k] = frame.Data[index];
green[k] = frame.Data[index + 1];
blue[k] = frame.Data[index + 2];
}
}
int destinationIndex = rowBase + x * ImageBuffer.Channels;
dst[destinationIndex] = Mix(src[destinationIndex], Median(red[..count]), blend);
dst[destinationIndex + 1] = Mix(src[destinationIndex + 1], Median(green[..count]), blend);
dst[destinationIndex + 2] = Mix(src[destinationIndex + 2], Median(blue[..count]), blend);
}
});
}
/// <summary>
/// Aggiorna l'accumulatore dei massimi con un nuovo fotogramma. Con
/// <paramref name="fade"/> minore di uno l'accumulo decade e le scie hanno una coda.
/// </summary>
public static void Accumulate(ImageBuffer accumulator, ImageBuffer frame,
in SourceMapping alignment, float fade)
{
int width = accumulator.Width;
int height = accumulator.Height;
var acc = accumulator.Data;
bool aligned = !alignment.IsIdentity || frame.Width != width || frame.Height != height;
double ax = alignment.Ax, bx = alignment.Bx, tx = alignment.Tx;
double ay = alignment.Ay, by = alignment.By, ty = alignment.Ty;
Parallel.For(0, height, y =>
{
int rowBase = y * width * ImageBuffer.Channels;
double rowX = bx * y + tx;
double rowY = by * y + ty;
for (int x = 0; x < width; x++)
{
float r, g, b;
if (aligned)
{
MotionBlurRenderer.SampleBilinear(frame.Data, frame.Width, frame.Height,
(float)(ax * x + rowX), (float)(ay * x + rowY),
out r, out g, out b);
}
else
{
int source = rowBase + x * ImageBuffer.Channels;
r = frame.Data[source];
g = frame.Data[source + 1];
b = frame.Data[source + 2];
}
int index = rowBase + x * ImageBuffer.Channels;
acc[index] = MathF.Max(acc[index] * fade, r);
acc[index + 1] = MathF.Max(acc[index + 1] * fade, g);
acc[index + 2] = MathF.Max(acc[index + 2] * fade, b);
}
});
}
/// <summary>Miscela l'accumulatore sul fotogramma corrente secondo l'intensità richiesta.</summary>
public static void Blend(ImageBuffer accumulator, ImageBuffer current, ImageBuffer destination, double strength)
{
float blend = (float)Math.Clamp(strength, 0, 1);
var acc = accumulator.Data;
var src = current.Data;
var dst = destination.Data;
int count = destination.SampleCount;
if (blend >= 0.999f)
{
Array.Copy(acc, dst, count);
return;
}
for (int i = 0; i < count; i++) dst[i] = src[i] + (acc[i] - src[i]) * blend;
}
/// <summary>Fattore di decadimento per la lunghezza di scia richiesta.</summary>
public static float FadeFactor(double trailFrames)
=> trailFrames <= 0 ? 1f : (float)Math.Exp(-1.0 / Math.Max(1.0, trailFrames));
private static float Mix(float original, float stacked, float blend)
=> original + (stacked - original) * blend;
/// <summary>
/// Mediana per inserzione. Su finestre di pochi elementi batte qualunque algoritmo
/// asintoticamente migliore: nessuna allocazione, nessun salto, tutto in registri.
/// </summary>
private static float Median(Span<float> values)
{
for (int i = 1; i < values.Length; i++)
{
float key = values[i];
int j = i - 1;
while (j >= 0 && values[j] > key)
{
values[j + 1] = values[j];
j--;
}
values[j + 1] = key;
}
return values[values.Length / 2];
}
}
+144
View File
@@ -0,0 +1,144 @@
using Titano.Core;
namespace Titano.Motion;
/// <summary>
/// Nodo del movimento virtuale: dove sta il centro dell'inquadratura e quanto è stretta,
/// a un dato punto della sequenza. Le maniglie governano il modo in cui il movimento
/// parte da qui e arriva qui.
/// </summary>
public sealed class CameraKeyframe
{
/// <summary>Posizione lungo la sequenza, da 0 (primo fotogramma) a 1 (ultimo).</summary>
public double Time { get; set; }
/// <summary>Centro dell'inquadratura in coordinate normalizzate; 0,5 è il centro del sensore.</summary>
public double CentreX { get; set; } = 0.5;
public double CentreY { get; set; } = 0.5;
/// <summary>Fattore di ritaglio: 1 usa tutto il fotogramma, 2 ne usa metà per lato.</summary>
public double Zoom { get; set; } = 1.0;
/// <summary>Quanto il movimento indugia partendo da questo nodo, da 0 (parte netto) a 1 (parte lentissimo).</summary>
public double EaseOut { get; set; } = 0.42;
/// <summary>Quanto il movimento rallenta arrivando su questo nodo.</summary>
public double EaseIn { get; set; } = 0.42;
public CameraKeyframe Clone() => (CameraKeyframe)MemberwiseClone();
}
/// <summary>Inquadratura risolta a un istante: centro e ritaglio, in coordinate normalizzate.</summary>
public readonly record struct CameraFraming(double CentreX, double CentreY, double Zoom)
{
public static CameraFraming Full => new(0.5, 0.5, 1.0);
}
/// <summary>Parametri del movimento di macchina virtuale.</summary>
public sealed class VirtualCameraSettings
{
public bool Enabled { get; set; }
public List<CameraKeyframe> Keyframes { get; set; } =
[
new() { Time = 0.0 },
new() { Time = 1.0 },
];
/// <summary>Impedisce all'inquadratura di uscire dal fotogramma sorgente.</summary>
public bool KeepInsideFrame { get; set; } = true;
public VirtualCameraSettings Clone()
{
var copy = (VirtualCameraSettings)MemberwiseClone();
copy.Keyframes = [.. Keyframes.Select(k => k.Clone())];
return copy;
}
}
/// <summary>
/// Movimento di macchina virtuale: panoramiche, inclinazioni e carrellate ottenute ritagliando
/// dentro la risoluzione nativa invece di muovere qualcosa in ripresa.
///
/// Un sensore da dodici megapixel contiene un 4K con un fattore di ritaglio abbondante: da lì
/// si ricava un movimento che in ripresa avrebbe richiesto una slitta motorizzata, e lo si
/// decide dopo, guardando il materiale. Il prezzo è la risoluzione consumata dallo zoom, ed è
/// l'unico vincolo reale del metodo.
///
/// Fra un nodo e il successivo il tempo non scorre uniforme: passa per una curva di Bézier
/// con estremi fissi, la stessa che i programmi di montaggio chiamano accelerazione. Senza di
/// essa il movimento partirebbe e si fermerebbe di colpo, e in un time-lapse — dove ogni
/// difetto di fluidità è amplificato dalla velocità — lo scatto si vede benissimo.
/// </summary>
public static class VirtualCamera
{
/// <summary>Inquadratura all'istante normalizzato <paramref name="time"/> ∈ [0,1].</summary>
public static CameraFraming Resolve(VirtualCameraSettings settings, double time)
{
if (!settings.Enabled || settings.Keyframes.Count == 0) return CameraFraming.Full;
var keyframes = settings.Keyframes;
if (keyframes.Count == 1) return Framing(keyframes[0]);
// I nodi vivono in una lista che l'utente può riordinare a piacere: qui serve
// l'ordine temporale, e cercarlo linearmente su una manciata di elementi non costa nulla.
int index = -1;
for (int i = 0; i < keyframes.Count - 1; i++)
{
if (time >= keyframes[i].Time && time <= keyframes[i + 1].Time) { index = i; break; }
}
if (index < 0) return time <= keyframes[0].Time ? Framing(keyframes[0]) : Framing(keyframes[^1]);
var from = keyframes[index];
var to = keyframes[index + 1];
double span = to.Time - from.Time;
if (span <= 1e-9) return Framing(to);
double local = (time - from.Time) / span;
// Maniglie: la prima allunga l'avvio, la seconda anticipa la frenata. Il valore
// predefinito 0,42 è la curva morbida simmetrica di uso comune.
double eased = Spline.Ease(local,
Math.Clamp(from.EaseOut, 0, 1), 0.0,
1.0 - Math.Clamp(to.EaseIn, 0, 1), 1.0);
double zoom = Math.Exp(Lerp(Math.Log(Math.Max(1e-3, from.Zoom)),
Math.Log(Math.Max(1e-3, to.Zoom)), eased));
return new CameraFraming(Lerp(from.CentreX, to.CentreX, eased),
Lerp(from.CentreY, to.CentreY, eased),
zoom);
}
/// <summary>
/// Riporta l'inquadratura dentro il fotogramma sorgente. Un centro troppo spostato
/// scoprirebbe una fascia vuota sul lato opposto: si preferisce arrestare la panoramica
/// sul bordo piuttosto che mostrare il nulla.
/// </summary>
public static CameraFraming Constrain(in CameraFraming framing, bool keepInside, double extraZoom = 1.0)
{
double zoom = Math.Max(1e-3, framing.Zoom * Math.Max(1e-3, extraZoom));
if (!keepInside) return new CameraFraming(framing.CentreX, framing.CentreY, zoom);
zoom = Math.Max(1.0, zoom);
double half = 0.5 / zoom;
return new CameraFraming(Math.Clamp(framing.CentreX, half, 1 - half),
Math.Clamp(framing.CentreY, half, 1 - half),
zoom);
}
/// <summary>Zoom massimo richiesto dai nodi: serve a dire quanta risoluzione nativa occorre.</summary>
public static double MaximumZoom(VirtualCameraSettings settings)
{
double max = 1.0;
if (!settings.Enabled) return max;
foreach (var keyframe in settings.Keyframes) max = Math.Max(max, keyframe.Zoom);
return max;
}
private static CameraFraming Framing(CameraKeyframe keyframe)
=> new(keyframe.CentreX, keyframe.CentreY, keyframe.Zoom);
private static double Lerp(double a, double b, double t) => a + (b - a) * t;
}
+425
View File
@@ -0,0 +1,425 @@
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using Titano.Imaging;
namespace Titano.Pipeline;
/// <summary>Come vengono gestiti i fotogrammi decodificati in anticipo che non stanno in memoria.</summary>
public sealed class FrameCacheSettings
{
/// <summary>
/// Quanti fotogrammi decodificare in anticipo oltre la finestra attiva. Una lettura di
/// anticipo profonda tiene occupati tutti i processori sulla decodifica dei RAW, che è la
/// parte più lenta della pipeline e l'unica che non si può accelerare in altro modo.
/// </summary>
public int PrefetchDepth { get; set; } = 8;
/// <summary>
/// Memoria concessa ai fotogrammi decodificati, in mebibyte. 0 significa nessun limite.
/// La finestra attiva sta sempre in memoria: il limite governa la lettura di anticipo.
/// </summary>
public int MemoryBudgetMiB { get; set; } = 3072;
/// <summary>Consente di parcheggiare su disco i fotogrammi in eccesso invece di rinunciarvi.</summary>
public bool AllowDiskSpill { get; set; } = true;
/// <summary>Cartella del file di parcheggio; vuota significa la cartella temporanea di sistema.</summary>
public string SpillDirectory { get; set; } = string.Empty;
public FrameCacheSettings Clone() => (FrameCacheSettings)MemberwiseClone();
}
/// <summary>Contatori della finestra, riportati a fine esportazione.</summary>
public readonly record struct FrameWindowStatistics(long PeakResidentBytes, int SpilledFrames,
long SpillBytesWritten, long SpillBytesRead,
int Decoded, int Failed);
/// <summary>
/// Finestra scorrevole di fotogrammi decodificati, con lettura di anticipo e parcheggio su disco.
///
/// I moduli avanzati hanno rotto l'assunto su cui la pipeline originale era costruita: che
/// bastassero due fotogrammi vivi alla volta. La mediana temporale ne vuole un'intera finestra
/// simultaneamente, la rimappatura del tempo può chiedere lo stesso fotogramma per molti
/// fotogrammi d'uscita consecutivi. L'occupazione resta comunque indipendente dalla lunghezza
/// della sequenza — dipende dall'ampiezza della finestra, che l'utente decide — ma non è più
/// una manciata di buffer.
///
/// La regola di residenza è semplice e non produce mai andirivieni: la finestra attiva sta
/// sempre in memoria, perché chi la usa ha bisogno di tutti i suoi fotogrammi insieme; i
/// fotogrammi letti in anticipo, che serviranno solo più avanti, finiscono su disco quando il
/// tetto di memoria è raggiunto e vengono ripresi una volta sola, quando entrano nella
/// finestra. Non esiste un caso in cui lo stesso fotogramma vada e torni dal disco più volte.
///
/// Il file di parcheggio nasce con l'opzione di cancellazione alla chiusura: se il programma
/// termina male, il sistema lo rimuove comunque.
/// </summary>
public sealed class FrameWindow : IDisposable
{
private readonly int _count;
private readonly int _width;
private readonly int _height;
private readonly long _frameBytes;
private readonly int _parallelism;
private readonly FrameCacheSettings _settings;
private readonly Func<int, FrameBufferPool, ImageBuffer?> _decode;
private readonly FrameBufferPool _pool;
private readonly Dictionary<int, ImageBuffer> _resident = [];
private readonly Dictionary<int, long> _spilled = [];
private readonly Dictionary<int, Task> _pending = [];
private readonly HashSet<int> _failed = [];
private readonly List<long> _freeBlocks = [];
private readonly Lock _gate = new();
private SafeFileHandle? _spillHandle;
private string? _spillPath;
private long _spillLength;
private long _residentBytes;
private long _peakResidentBytes;
private long _spillWritten;
private long _spillRead;
private int _spilledFrames;
private int _decoded;
private int _first = int.MaxValue;
private int _last = int.MinValue;
public FrameWindow(int count, int width, int height, int parallelism,
FrameCacheSettings settings, Func<int, FrameBufferPool, ImageBuffer?> decode)
{
_count = count;
_width = width;
_height = height;
_frameBytes = (long)width * height * ImageBuffer.Channels * sizeof(float);
_parallelism = Math.Clamp(parallelism, 1, 32);
_settings = settings;
_decode = decode;
// Il pool deve poter contenere la finestra, la lettura di anticipo e i buffer di lavoro.
_pool = new FrameBufferPool(_parallelism + Math.Max(4, settings.PrefetchDepth) + 8);
}
public FrameBufferPool Pool => _pool;
public FrameWindowStatistics Statistics
{
get
{
lock (_gate)
{
return new FrameWindowStatistics(_peakResidentBytes, _spilledFrames, _spillWritten,
_spillRead, _decoded, _failed.Count);
}
}
}
/// <summary>Vero se il tetto di memoria ha costretto a usare il disco almeno una volta.</summary>
public bool UsedDisk => _spilledFrames > 0;
private long Budget => _settings.MemoryBudgetMiB <= 0
? long.MaxValue
: (long)_settings.MemoryBudgetMiB * 1024 * 1024;
/// <summary>
/// Porta in memoria l'intervallo richiesto, libera ciò che sta prima e lancia la lettura
/// di anticipo. Gli estremi vengono riportati dentro la sequenza dal chiamante.
/// </summary>
public void EnsureRange(int first, int last, CancellationToken cancellation)
{
first = Math.Clamp(first, 0, Math.Max(0, _count - 1));
last = Math.Clamp(last, first, Math.Max(0, _count - 1));
_first = first;
_last = last;
Evict(first);
StartDecoding(first, Math.Min(_count - 1, last + Math.Max(0, _settings.PrefetchDepth)), cancellation);
WaitFor(first, last, cancellation);
for (int i = first; i <= last; i++) PageIn(i);
}
/// <summary>
/// Fotogramma già portato in memoria da <see cref="EnsureRange"/>. Restituisce null solo
/// se il file era illeggibile: il chiamante decide con cosa sostituirlo.
/// </summary>
public ImageBuffer? Get(int index)
{
lock (_gate)
{
return _resident.TryGetValue(index, out var buffer) ? buffer : null;
}
}
/// <summary>
/// Il fotogramma richiesto oppure, se il file era illeggibile, quello valido più vicino
/// fra quelli in memoria. Ripiegare sul vicino costa nulla e mantiene il sincronismo:
/// nel video si vede un fotogramma ripetuto invece di un salto.
/// </summary>
public ImageBuffer? GetNearest(int index)
{
lock (_gate)
{
if (_resident.TryGetValue(index, out var exact)) return exact;
for (int distance = 1; distance <= _count; distance++)
{
if (_resident.TryGetValue(index - distance, out var before)) return before;
if (_resident.TryGetValue(index + distance, out var after)) return after;
if (index - distance < 0 && index + distance >= _count) break;
}
return null;
}
}
// ------------------------------------------------------------------ decodifica
private void StartDecoding(int first, int last, CancellationToken cancellation)
{
lock (_gate)
{
int inFlight = _pending.Count;
for (int i = first; i <= last; i++)
{
if (inFlight >= _parallelism && i > _last) break; // l'anticipo non scavalca la finestra
if (_resident.ContainsKey(i) || _spilled.ContainsKey(i) ||
_pending.ContainsKey(i) || _failed.Contains(i)) continue;
int index = i;
_pending[index] = Task.Run(() => DecodeInto(index), cancellation);
inFlight++;
}
}
}
/// <summary>
/// Decodifica un fotogramma e decide dove metterlo. La scelta avviene qui, dentro il
/// lucchetto, perché il tetto di memoria va confrontato con l'occupazione del momento e
/// non con quella di quando il compito è stato creato.
/// </summary>
private void DecodeInto(int index)
{
ImageBuffer? buffer;
try
{
buffer = _decode(index, _pool);
}
catch (Exception)
{
buffer = null;
}
lock (_gate)
{
_decoded++;
if (buffer is null)
{
_failed.Add(index);
return;
}
bool insideWindow = index >= _first && index <= _last;
bool fits = _residentBytes + _frameBytes <= Budget;
if (insideWindow || fits || !_settings.AllowDiskSpill)
{
Store(index, buffer);
return;
}
}
// La scrittura su disco avviene fuori dal lucchetto: è l'operazione lenta e non deve
// fermare gli altri thread di decodifica.
long offset = Spill(index, buffer);
if (offset >= 0) return;
lock (_gate) Store(index, buffer);
}
private void Store(int index, ImageBuffer buffer)
{
if (!_resident.TryAdd(index, buffer))
{
buffer.Dispose();
return;
}
_residentBytes += _frameBytes;
if (_residentBytes > _peakResidentBytes) _peakResidentBytes = _residentBytes;
}
private void WaitFor(int first, int last, CancellationToken cancellation)
{
while (true)
{
cancellation.ThrowIfCancellationRequested();
var waiting = new List<Task>();
lock (_gate)
{
for (int i = first; i <= last; i++)
{
if (_pending.TryGetValue(i, out var task)) waiting.Add(task);
}
}
if (waiting.Count == 0) break;
Task.WaitAll([.. waiting], cancellation);
lock (_gate)
{
foreach (int key in _pending.Keys.ToArray())
{
if (_pending[key].IsCompleted) _pending.Remove(key);
}
}
}
}
// ------------------------------------------------------------------ disco
private long Spill(int index, ImageBuffer buffer)
{
try
{
var handle = OpenSpill();
long offset;
lock (_gate)
{
if (_freeBlocks.Count > 0)
{
offset = _freeBlocks[^1];
_freeBlocks.RemoveAt(_freeBlocks.Count - 1);
}
else
{
offset = _spillLength;
_spillLength += _frameBytes;
}
}
var bytes = MemoryMarshal.AsBytes(buffer.Data.AsSpan(0, buffer.SampleCount));
RandomAccess.Write(handle, bytes, offset);
lock (_gate)
{
_spilled[index] = offset;
_spilledFrames++;
_spillWritten += _frameBytes;
}
buffer.Dispose();
return offset;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
// Disco pieno o non scrivibile: si tiene tutto in memoria e si tira avanti.
return -1;
}
}
private SafeFileHandle OpenSpill()
{
lock (_gate)
{
if (_spillHandle is not null) return _spillHandle;
string directory = string.IsNullOrWhiteSpace(_settings.SpillDirectory)
? Path.GetTempPath()
: _settings.SpillDirectory;
Directory.CreateDirectory(directory);
_spillPath = Path.Combine(directory,
$"titano-{Environment.ProcessId:X}-{Guid.NewGuid():N}.frames");
_spillHandle = File.OpenHandle(_spillPath, FileMode.CreateNew, FileAccess.ReadWrite,
FileShare.None, FileOptions.DeleteOnClose | FileOptions.RandomAccess);
return _spillHandle;
}
}
/// <summary>Riporta in memoria un fotogramma parcheggiato. Avviene una volta sola per fotogramma.</summary>
private void PageIn(int index)
{
long offset;
SafeFileHandle handle;
lock (_gate)
{
if (_resident.ContainsKey(index) || !_spilled.TryGetValue(index, out offset)) return;
if (_spillHandle is null) return;
handle = _spillHandle;
}
var buffer = _pool.Rent(_width, _height);
try
{
var bytes = MemoryMarshal.AsBytes(buffer.Data.AsSpan(0, buffer.SampleCount));
int read = RandomAccess.Read(handle, bytes, offset);
lock (_gate)
{
if (read < bytes.Length) { _failed.Add(index); buffer.Dispose(); }
else Store(index, buffer);
_spilled.Remove(index);
_freeBlocks.Add(offset);
_spillRead += read;
}
}
catch (IOException)
{
buffer.Dispose();
lock (_gate) _failed.Add(index);
}
}
private void Evict(int before)
{
lock (_gate)
{
foreach (int key in _resident.Keys.ToArray())
{
if (key >= before) continue;
_resident[key].Dispose();
_resident.Remove(key);
_residentBytes -= _frameBytes;
}
foreach (int key in _spilled.Keys.ToArray())
{
if (key >= before) continue;
_freeBlocks.Add(_spilled[key]);
_spilled.Remove(key);
}
}
}
public void Dispose()
{
Task[] pending;
lock (_gate) pending = [.. _pending.Values];
try { Task.WaitAll(pending, TimeSpan.FromSeconds(10)); }
catch (Exception) { /* le decodifiche interrotte non hanno nulla da salvare */ }
lock (_gate)
{
foreach (var buffer in _resident.Values) buffer.Dispose();
_resident.Clear();
_spilled.Clear();
_pending.Clear();
_residentBytes = 0;
_spillHandle?.Dispose();
_spillHandle = null;
}
// DeleteOnClose se ne occupa già; questo copre il caso in cui l'opzione non sia
// stata onorata dal file system sottostante.
if (_spillPath is not null && File.Exists(_spillPath))
{
try { File.Delete(_spillPath); } catch (IOException) { }
}
_spillPath = null;
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace Titano.Pipeline;
public enum PipelinePhase
{
Ingestion,
Analysis,
Rendering,
Finalizing,
Completed,
Failed,
}
/// <summary>Stato d'avanzamento pubblicato verso l'interfaccia; immutabile e sicuro da marshalare.</summary>
public readonly record struct PipelineProgress(
PipelinePhase Phase,
int Completed,
int Total,
string Message,
double FramesPerSecond = 0,
TimeSpan Remaining = default)
{
public double Fraction => Total <= 0 ? 0 : Math.Clamp(Completed / (double)Total, 0, 1);
}
/// <summary>Esito di una esportazione completata.</summary>
public sealed record RenderResult(
string OutputPath,
int EncodedFrames,
long OutputBytes,
TimeSpan Elapsed,
string EncoderName,
bool HardwareAccelerated,
long PeakPixelMemoryBytes,
string PlanDescription = "",
int SpilledFrames = 0,
long SpillBytes = 0)
{
/// <summary>Vero se il tetto di memoria ha imposto di parcheggiare fotogrammi su disco.</summary>
public bool UsedDisk => SpilledFrames > 0;
}
+640
View File
@@ -0,0 +1,640 @@
using System.Diagnostics;
using Titano.Analysis;
using Titano.Core;
using Titano.Imaging;
using Titano.Metadata;
using Titano.Motion;
using Titano.Video;
namespace Titano.Pipeline;
/// <summary>
/// Orchestrazione delle fasi di lavoro.
///
/// Il rendering non percorre più la sequenza sorgente ma un <see cref="RenderPlan"/>: un
/// elenco di posizioni, anche frazionarie, con la relativa durata. Tutte le modalità
/// temporali — durata costante, proporzionale, cadenza uniformata, rimappatura non lineare —
/// si riducono a quella forma, quindi il ciclo di rendering è uno solo e non contiene rami
/// per i singoli casi.
///
/// I fotogrammi arrivano da una finestra scorrevole che li decodifica in anticipo su più
/// thread e, se il tetto di memoria lo impone, ne parcheggia una parte su disco. L'unico file
/// che sopravvive all'elaborazione resta il video: il parcheggio nasce con la cancellazione
/// automatica alla chiusura.
/// </summary>
public sealed class RenderPipeline(TitanoProject project)
{
private readonly TitanoProject _project = project;
// ------------------------------------------------------------------ ingestion
/// <summary>Legge i metadati dei file indicati e costruisce la sequenza ordinata.</summary>
public static async Task<TimelapseSequence> IngestAsync(IReadOnlyList<string> paths,
double cadenceTolerance,
IProgress<PipelineProgress>? progress,
CancellationToken cancellation)
{
var metadata = new FrameMetadata[paths.Count];
int done = 0;
await Task.Run(() =>
{
var options = new ParallelOptions
{
CancellationToken = cancellation,
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 1, 16),
};
Parallel.For(0, paths.Count, options, i =>
{
metadata[i] = MetadataReader.Read(paths[i]);
int completed = Interlocked.Increment(ref done);
if (completed % 16 == 0 || completed == paths.Count)
{
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, completed, paths.Count,
"Lettura metadati…"));
}
});
}, cancellation).ConfigureAwait(false);
var sequence = TimelapseSequence.Build(metadata);
sequence.RecomputeTiming(cadenceTolerance);
return sequence;
}
// ------------------------------------------------------------------ analisi
/// <summary>
/// Passata di analisi. Misura la luminanza di ogni fotogramma — globale e, se richiesto,
/// per regione — e nel frattempo, senza rileggere i file, stima lo spostamento fra
/// fotogrammi adiacenti per la stabilizzazione. Da lì si ricavano l'analisi delle
/// transizioni e la curva di correzione.
///
/// La decodifica avviene a risoluzione ridotta: la media logaritmica troncata è
/// invariante alla scala, e anche la correlazione di fase lavora bene in scala ridotta,
/// perché lo spostamento si misura in frazioni della larghezza e non in pixel.
/// </summary>
public async Task AnalyzeAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
int count = sequence.Count;
if (count == 0) return;
var (workingWidth, workingHeight) = _project.ResolveWorkingSize();
if (workingWidth <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
int orientation = _project.EffectiveOrientation;
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
// ---- maschera delle regioni: una volta sola per sequenza
if (_project.Regions.Mode != RegionMode.Off)
{
progress?.Report(new PipelineProgress(PipelinePhase.Analysis, 0, count,
"Segmentazione delle regioni…"));
var paths = sequence.Frames.Select(f => f.FilePath).ToArray();
_project.Mask = await Task.Run(() => RegionSegmenter.Build(paths, orientation, _project.Regions,
cancellation), cancellation)
.ConfigureAwait(false);
}
else
{
_project.Mask = null;
}
int analysisWidth = Math.Clamp(_project.General.AnalysisWidth, 128, Math.Max(128, workingWidth));
int analysisHeight = Math.Max(2, (int)Math.Round(analysisWidth * workingHeight / (double)workingWidth));
var stats = new LuminanceStats[count];
var failures = new bool[count];
var relative = new SimilarityTransform[count];
var confidence = new double[count];
Array.Fill(relative, SimilarityTransform.Identity);
bool stabilize = _project.Stabilization.Enabled;
var mask = _project.Mask;
int done = 0;
await Task.Run(() =>
{
// I blocchi sono contigui perché la stabilizzazione confronta ogni fotogramma con
// il precedente e ha quindi bisogno dell'ordine. Se ne creano più dei thread
// disponibili, così un blocco lento non ferma tutti gli altri; il prezzo è un
// fotogramma in più decodificato all'inizio di ciascun blocco, per agganciarlo
// alla coda di quello che lo precede.
int blocks = Math.Clamp(count / 4, 1, parallelism * 4);
var pool = new FrameBufferPool(parallelism + 4);
var options = new ParallelOptions
{
CancellationToken = cancellation,
MaxDegreeOfParallelism = parallelism,
};
Parallel.For(0, blocks, options, block =>
{
int start = (int)((long)block * count / blocks);
int end = (int)((long)(block + 1) * count / blocks);
if (start >= end) return;
var stabilizer = stabilize ? new Stabilizer(_project.Stabilization) : null;
GrayImage? previous = null;
if (stabilize && start > 0)
{
previous = DecodePlane(sequence.Frames[start - 1].FilePath, analysisWidth, analysisHeight,
orientation, pool, _project.Stabilization.AnalysisWidth);
}
for (int i = start; i < end; i++)
{
cancellation.ThrowIfCancellationRequested();
var record = sequence.Frames[i];
try
{
using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight,
orientation, pool);
stats[i] = LuminanceAnalyzer.Analyze(buffer, mask);
if (stabilizer is not null)
{
var plane = GrayPyramid.Plane(buffer, _project.Stabilization.AnalysisWidth);
if (previous is not null)
{
relative[i] = stabilizer.Estimate(previous, plane, out double measured);
confidence[i] = measured;
}
previous = plane;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failures[i] = true;
}
int completed = Interlocked.Increment(ref done);
progress?.Report(new PipelineProgress(PipelinePhase.Analysis, completed, count,
"Analisi della luminanza…"));
}
});
}, cancellation).ConfigureAwait(false);
// I fotogrammi illeggibili ereditano la misura del vicino: la curva resta continua.
for (int i = 0; i < count; i++)
{
if (!failures[i]) continue;
stats[i] = i > 0 ? stats[i - 1] : LuminanceStats.Empty;
}
var metadata = sequence.Frames.Select(f => f.Metadata).ToArray();
_project.Transitions = HolyGrailEngine.Analyze(stats, metadata, _project.HolyGrail);
// Gli spostamenti grezzi restano nel progetto: da lì il percorso si ricostruisce in un
// istante quando l'utente regola quanto la stabilizzazione deve essere decisa.
_project.MotionRelative = stabilize ? relative : null;
_project.MotionConfidence = stabilize ? confidence : null;
_project.RebuildStabilizationPath();
var curve = DeflickerEngine.Compute(stats, _project.Deflicker, _project.Regions,
_project.Transitions, _project.HolyGrail);
_project.Stats = stats;
_project.Curve = curve;
UpdateRecords(sequence, curve, stats, failures);
}
private static GrayImage? DecodePlane(string path, int width, int height, int orientation,
FrameBufferPool pool, int planeWidth)
{
try
{
using var buffer = ImageDecoder.Decode(path, width, height, orientation, pool);
return GrayPyramid.Plane(buffer, planeWidth);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Ricalcola solo la curva a partire dalle statistiche già misurate: permette di muovere
/// i cursori del deflicker con riscontro immediato, senza rileggere i file.
/// </summary>
public void RecomputeCurve()
{
if (_project.Stats is not { } stats || _project.Sequence is not { } sequence) return;
// Transizioni e percorso di stabilizzazione dipendono solo da misure già in mano:
// si rifanno qui, così muovere la lunghezza di una transizione o la decisione della
// stabilizzazione dà un riscontro immediato senza rileggere un solo file.
var metadata = sequence.Frames.Select(f => f.Metadata).ToArray();
_project.Transitions = HolyGrailEngine.Analyze(stats, metadata, _project.HolyGrail);
_project.RebuildStabilizationPath();
var curve = DeflickerEngine.Compute(stats, _project.Deflicker, _project.Regions,
_project.Transitions, _project.HolyGrail);
_project.Curve = curve;
UpdateRecords(sequence, curve, stats, null);
}
private void UpdateRecords(TimelapseSequence sequence, DeflickerCurve curve,
IReadOnlyList<LuminanceStats> stats, bool[]? failures)
{
var transitions = _project.Transitions;
var steps = transitions is null ? [] : new HashSet<int>(transitions.StepFrames);
var motion = _project.Motion;
var (sourceWidth, _) = _project.ResolveSourceSize();
for (int i = 0; i < sequence.Count && i < curve.Count; i++)
{
var record = sequence.Frames[i];
record.MeasuredLuminance = Math.Pow(2, curve.Measured[i]);
record.TargetLuminance = Math.Pow(2, curve.Target[i]);
record.Gain = Math.Pow(2, curve.GainStops[i]);
record.ClippedFraction = stats[i].ClippedFraction;
if (failures is not null) record.LuminanceAnalyzed = !failures[i];
record.TemperatureKelvin = transitions is not null && i < transitions.TemperatureKelvin.Length
? transitions.TemperatureKelvin[i]
: double.NaN;
record.IsExposureStep = steps.Contains(i);
if (motion is not null && i < motion.Count)
{
var correction = motion.Correction[i];
record.StabilizationShift = Math.Sqrt(correction.Tx * correction.Tx +
correction.Ty * correction.Ty) * Math.Max(1, sourceWidth);
record.StabilizationRotation = correction.Rotation * 180.0 / Math.PI;
}
else
{
record.StabilizationShift = 0;
record.StabilizationRotation = 0;
}
}
}
// ------------------------------------------------------------------ render ed esportazione
public async Task<RenderResult> RenderAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
if (sequence.Count == 0) throw new InvalidOperationException("La sequenza è vuota.");
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
throw new InvalidOperationException("Percorso di destinazione non impostato.");
if (!_project.IsAnalyzed) await AnalyzeAsync(progress, cancellation).ConfigureAwait(false);
var (width, height) = _project.ResolveWorkingSize();
if (width <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
var export = _project.Export.Clone();
export.Width = width;
export.Height = height;
return await Task.Run(() => RenderCore(sequence, export, progress, cancellation), cancellation)
.ConfigureAwait(false);
}
private RenderResult RenderCore(TimelapseSequence sequence, ExportSettings export,
IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
int count = sequence.Count;
int outputWidth = export.Width;
int outputHeight = export.Height;
var (sourceWidth, sourceHeight) = _project.ResolveSourceSize();
if (sourceWidth <= 0 || sourceHeight <= 0) (sourceWidth, sourceHeight) = (outputWidth, outputHeight);
bool needsGeometry = _project.NeedsGeometry ||
sourceWidth != outputWidth || sourceHeight != outputHeight;
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
int orientation = _project.EffectiveOrientation;
uint baseUnits = (uint)Math.Max(1, Math.Round(export.Timescale / Math.Max(1.0, export.FrameRate)));
var plan = RenderPlanner.Build(sequence, export, _project.TimeRamp, baseUnits);
if (plan.Count == 0) throw new InvalidOperationException("Il piano di rendering è vuoto.");
var stacking = _project.Stacking;
int stackRadius = stacking.MedianRadius;
var curve = _project.Curve;
var mask = _project.Mask;
var deflicker = _project.Deflicker;
var blurSettings = _project.MotionBlur;
var flowEngine = new OpticalFlowEngine(_project.Flow);
var motion = _project.Motion;
using var window = new FrameWindow(count, sourceWidth, sourceHeight, parallelism, _project.Cache,
(index, framePool) => DecodeAndCorrect(sequence, index, sourceWidth, sourceHeight, orientation,
framePool, curve, deflicker, mask));
var pool = window.Pool;
var stopwatch = Stopwatch.StartNew();
using var session = new VideoEncoderSession(export, outputWidth, outputHeight);
// Buffer di lavoro. Restano gli stessi per tutta l'esportazione: nessuna allocazione
// per fotogramma, qualunque sia la lunghezza della sequenza.
ImageBuffer? fallback = null;
ImageBuffer? stackA = null, stackB = null;
ImageBuffer? accumulator = null;
ImageBuffer? interpolated = null;
ImageBuffer? blurScratch = null;
ImageBuffer? geometryOut = null;
int stackIndexA = int.MinValue, stackIndexB = int.MinValue;
int flowIndex = int.MinValue;
MotionField? flowField = null;
int fedThrough = -1;
int encoded = 0;
bool cancelled = false;
try
{
if (stacking.Mode == StackingMode.Median)
{
stackA = pool.Rent(sourceWidth, sourceHeight);
stackB = pool.Rent(sourceWidth, sourceHeight);
}
else if (stacking.Mode == StackingMode.Maximum)
{
accumulator = pool.Rent(sourceWidth, sourceHeight);
Array.Clear(accumulator.Data, 0, accumulator.SampleCount);
stackA = pool.Rent(sourceWidth, sourceHeight);
}
interpolated = pool.Rent(sourceWidth, sourceHeight);
blurScratch = pool.Rent(sourceWidth, sourceHeight);
if (needsGeometry) geometryOut = pool.Rent(outputWidth, outputHeight);
float fade = TemporalStacker.FadeFactor(stacking.TrailFrames);
for (int k = 0; k < plan.Count; k++)
{
if (cancellation.IsCancellationRequested) { cancelled = true; break; }
var planned = plan.Frames[k];
int index = Math.Clamp((int)Math.Floor(planned.SourcePosition), 0, count - 1);
double fraction = planned.SourcePosition - index;
if (index >= count - 1) fraction = 0;
bool needsNext = fraction > 1e-6;
// Solo le scie stellari hanno bisogno di risalire agli scatti già superati,
// perché l'accumulatore va nutrito anche con quelli che la rimappatura salta.
// Estendere la finestra all'indietro anche negli altri casi vorrebbe dire non
// liberare mai nulla, e l'occupazione crescerebbe con la lunghezza della sequenza.
int first = index - stackRadius;
if (stacking.Mode == StackingMode.Maximum) first = Math.Min(first, fedThrough + 1);
int last = index + (needsNext ? 1 : 0) + stackRadius;
window.EnsureRange(first, last, cancellation);
// ---- fotogramma base: sorgente puro, oppure risultato dell'accumulo
var record = sequence.Frames[index];
if (stacking.Mode == StackingMode.Maximum)
{
// L'accumulatore va nutrito con tutti gli scatti attraversati, anche quelli
// che la rimappatura temporale salta: una scia con un buco non è una scia.
for (int j = Math.Max(0, fedThrough + 1); j <= index; j++)
{
var source = Resolve(window, j, pool, ref fallback, sourceWidth, sourceHeight);
TemporalStacker.Accumulate(accumulator!, source,
AlignmentTo(motion, index, j, sourceWidth, sourceHeight), fade);
}
fedThrough = Math.Max(fedThrough, index);
}
var current = BaseFrame(index, ref stackIndexA, ref stackIndexB);
var next = needsNext ? BaseFrame(index + 1, ref stackIndexA, ref stackIndexB) : null;
// ---- campo vettoriale: serve solo se c'è da sfocare o da interpolare
double speed = Math.Max(1e-6, planned.Speed);
double effectiveAngle = Math.Min(360.0, record.ShutterAngle / speed);
double missing = blurSettings.Enabled
? MotionBlurRenderer.MissingBlurFactor(effectiveAngle, blurSettings.TargetShutterAngle,
blurSettings.Strength) * speed
: 0;
bool needsFlow = (missing > 1e-4 || needsNext) && next is not null;
if (needsFlow && flowIndex != index)
{
flowField = flowEngine.Compute(current, next!);
flowIndex = index;
record.MotionMagnitude = flowField.MedianMagnitude();
record.MotionDirection = flowField.DominantDirection();
}
var field = needsFlow ? flowField : null;
// ---- posizione frazionaria: il fotogramma va sintetizzato
var composed = current;
if (needsNext && next is not null)
{
if (field is not null)
{
FrameInterpolator.Interpolate(current, next, field, (float)fraction, interpolated!);
}
else
{
CrossFade(current, next, (float)fraction, interpolated!);
}
composed = interpolated!;
}
// ---- sfocatura di movimento
if (blurSettings.Enabled && field is not null && missing > 1e-4)
{
record.BlurLength = MotionBlurRenderer.Render(composed, blurScratch!, field, missing, blurSettings);
composed = blurScratch!;
}
// ---- inquadratura virtuale e stabilizzazione, in un solo ricampionamento
if (needsGeometry)
{
double normalized = plan.Count > 1 ? k / (double)(plan.Count - 1) : 0;
var framing = _project.FramingAt(normalized);
var stabilization = motion?.At(planned.SourcePosition) ?? SimilarityTransform.Identity;
var map = GeometryStage.Build(sourceWidth, sourceHeight, outputWidth, outputHeight,
framing, stabilization);
GeometryStage.Resample(composed, geometryOut!, map);
composed = geometryOut!;
}
session.EncodeFrame(composed, planned.DurationUnits);
encoded++;
record.OutputDurationUnits = (int)planned.DurationUnits;
ReportRenderProgress(progress, k + 1, plan.Count, encoded, stopwatch);
}
}
finally
{
fallback?.Dispose();
stackA?.Dispose();
stackB?.Dispose();
accumulator?.Dispose();
interpolated?.Dispose();
blurScratch?.Dispose();
geometryOut?.Dispose();
progress?.Report(new PipelineProgress(PipelinePhase.Finalizing, plan.Count, plan.Count,
"Chiusura del contenitore…"));
session.Finish();
}
stopwatch.Stop();
if (cancelled) cancellation.ThrowIfCancellationRequested();
var statistics = window.Statistics;
return new RenderResult(
export.OutputPath,
session.EncodedFrames,
session.OutputBytes,
stopwatch.Elapsed,
session.EncoderName,
session.IsHardware,
Math.Max(statistics.PeakResidentBytes, pool.AllocatedBytes),
plan.Description,
statistics.SpilledFrames,
statistics.SpillBytesWritten);
// ------------------------------------------------------------------ funzioni locali
// Fotogramma pronto per l'elaborazione temporale: quello sorgente quando non c'è
// accumulo, il risultato dello stacking quando c'è. I due esiti più recenti restano
// in cache perché il ciclo chiede sempre l'indice corrente e il successivo, e senza
// cache la mediana verrebbe calcolata due volte per ogni fotogramma d'uscita.
ImageBuffer BaseFrame(int wanted, ref int cachedA, ref int cachedB)
{
int clamped = Math.Clamp(wanted, 0, count - 1);
var source = Resolve(window, clamped, pool, ref fallback, sourceWidth, sourceHeight);
switch (stacking.Mode)
{
case StackingMode.Median:
{
if (cachedA == clamped) return stackA!;
if (cachedB == clamped) return stackB!;
bool intoA = cachedA <= cachedB;
var destination = intoA ? stackA! : stackB!;
BuildMedian(clamped, source, destination);
if (intoA) cachedA = clamped; else cachedB = clamped;
return destination;
}
case StackingMode.Maximum:
{
if (cachedA == clamped) return stackA!;
TemporalStacker.Blend(accumulator!, source, stackA!, stacking.Strength);
cachedA = clamped;
return stackA!;
}
default:
return source;
}
}
void BuildMedian(int centre, ImageBuffer centreFrame, ImageBuffer destination)
{
var frames = new List<ImageBuffer>(2 * stackRadius + 1);
List<SourceMapping>? alignment = motion is not null ? new(2 * stackRadius + 1) : null;
for (int j = centre - stackRadius; j <= centre + stackRadius; j++)
{
int clamped = Math.Clamp(j, 0, count - 1);
frames.Add(Resolve(window, clamped, pool, ref fallback, sourceWidth, sourceHeight));
alignment?.Add(AlignmentTo(motion, centre, clamped, sourceWidth, sourceHeight));
}
TemporalStacker.Median(frames, alignment, centreFrame, destination, stacking.Strength);
}
}
/// <summary>
/// Preleva un fotogramma dalla finestra. Se il file era illeggibile subentra il vicino
/// valido più prossimo; solo una sequenza interamente illeggibile arriva al nero.
/// </summary>
private static ImageBuffer Resolve(FrameWindow window, int index, FrameBufferPool pool,
ref ImageBuffer? black, int width, int height)
{
var buffer = window.GetNearest(index);
if (buffer is not null) return buffer;
if (black is null)
{
black = pool.Rent(width, height);
Array.Clear(black.Data, 0, black.SampleCount);
}
return black;
}
/// <summary>
/// Trasformazione che porta le coordinate del fotogramma <paramref name="centre"/> in
/// quelle del fotogramma <paramref name="other"/>: due scatti stabilizzati in modo diverso
/// vanno riportati sullo stesso reticolo prima di poterli sovrapporre.
/// </summary>
private static SourceMapping AlignmentTo(StabilizationPath? motion, int centre, int other,
int width, int height)
{
if (motion is null || centre == other || centre >= motion.Count || other >= motion.Count)
return SourceMapping.Identity;
var mapping = SimilarityTransform.Compose(motion.Correction[other].Inverse, motion.Correction[centre]);
return GeometryStage.FromSimilarity(width, height, mapping);
}
/// <summary>Dissolvenza lineare: ripiego quando la posizione è frazionaria ma il campo manca.</summary>
private static void CrossFade(ImageBuffer a, ImageBuffer b, float t, ImageBuffer destination)
{
var source = a.Data;
var other = b.Data;
var dst = destination.Data;
int count = destination.SampleCount;
for (int i = 0; i < count; i++) dst[i] = source[i] + (other[i] - source[i]) * t;
}
/// <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,
int orientation, FrameBufferPool pool, DeflickerCurve? curve,
DeflickerSettings settings, RegionMask? mask)
{
var record = sequence.Frames[index];
ImageBuffer buffer;
try
{
buffer = ImageDecoder.Decode(record.FilePath, width, height, orientation, pool);
}
catch (Exception)
{
return null; // la finestra sostituirà con l'ultimo fotogramma valido
}
if (curve is null || index >= curve.Count || !settings.Enabled) return buffer;
record.ClippedFraction = curve.HasRegions && mask is not null
? ExposureProcessor.ApplyRegional(buffer, mask, curve.ChannelGainHigh![index],
curve.ChannelGainLow![index],
settings.ProtectHighlights, settings.HighlightKnee)
: ExposureProcessor.Apply(buffer, curve.ChannelGain[index],
settings.ProtectHighlights, settings.HighlightKnee);
return buffer;
}
private static void ReportRenderProgress(IProgress<PipelineProgress>? progress, int completed, int total,
int encoded, Stopwatch stopwatch)
{
if (progress is null) return;
double seconds = stopwatch.Elapsed.TotalSeconds;
double fps = seconds > 0.001 ? encoded / seconds : 0;
var remaining = fps > 0.01
? TimeSpan.FromSeconds((total - completed) / fps)
: TimeSpan.Zero;
progress.Report(new PipelineProgress(PipelinePhase.Rendering, completed, total,
"Elaborazione e codifica…", fps, remaining));
}
}
+172
View File
@@ -0,0 +1,172 @@
using Titano.Core;
using Titano.Video;
namespace Titano.Pipeline;
/// <summary>Parametri della rimappatura temporale non lineare.</summary>
public sealed class TimeRampSettings
{
public bool Enabled { get; set; }
/// <summary>
/// Curva di velocità: l'ascissa è la posizione lungo la sequenza sorgente (0..1),
/// l'ordinata quanti fotogrammi sorgente vengono consumati per ogni fotogramma d'uscita.
/// Sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta sintetizzandoli.
/// </summary>
public List<SplineKnot> Speed { get; set; } = [new(0.0, 1.0), new(0.5, 1.0), new(1.0, 1.0)];
public double MinSpeed { get; set; } = 0.1;
public double MaxSpeed { get; set; } = 8.0;
public TimeRampSettings Clone()
{
var copy = (TimeRampSettings)MemberwiseClone();
copy.Speed = [.. Speed];
return copy;
}
/// <summary>Velocità alla posizione normalizzata indicata, entro i limiti ammessi.</summary>
public double SpeedAt(double normalized)
=> Math.Clamp(Spline.Evaluate(Speed, normalized), Math.Max(0.02, MinSpeed), Math.Max(0.1, MaxSpeed));
}
/// <summary>
/// Un fotogramma del video finale: da dove viene, quanto dura e quanta sequenza sorgente
/// copre. La posizione è frazionaria perché il fotogramma può cadere fra due scatti, e in
/// quel caso viene sintetizzato dal campo vettoriale.
/// </summary>
public readonly record struct PlannedFrame(double SourcePosition, uint DurationUnits, double Speed);
/// <summary>Elenco ordinato dei fotogrammi da produrre.</summary>
public sealed class RenderPlan
{
public required PlannedFrame[] Frames { get; init; }
public required string Description { get; init; }
/// <summary>Fotogrammi sorgente effettivamente richiesti: serve a dimensionare la finestra.</summary>
public required int SourceSpan { get; init; }
public int Count => Frames.Length;
}
/// <summary>
/// Costruisce l'elenco dei fotogrammi d'uscita a partire dalla cadenza reale della sequenza
/// e dalla modalità temporale scelta.
///
/// Tutte le modalità — durata costante, durata proporzionale all'intervallo, cadenza
/// uniformata, rimappatura non lineare — producono la stessa cosa: una successione di
/// posizioni sorgente con la relativa durata. Averle ridotte a una forma sola vuol dire che
/// il motore di rendering non sa quale modalità sia attiva e non ha un ramo di codice per
/// ciascuna: legge il piano ed esegue.
/// </summary>
public static class RenderPlanner
{
/// <summary>Tetto di sicurezza: una curva di velocità estrema non deve generare un video infinito.</summary>
private const int MaxOutputFrames = 500_000;
public static RenderPlan Build(TimelapseSequence sequence, ExportSettings export,
TimeRampSettings? ramp, uint baseUnits)
{
int count = sequence.Count;
if (count == 0)
{
return new RenderPlan { Frames = [], Description = "sequenza vuota", SourceSpan = 0 };
}
var frames = ramp is { Enabled: true } && count > 1
? BuildRamped(count, ramp, baseUnits, out string description)
: BuildFromTimingMode(sequence, export, baseUnits, out description);
// La velocità di ciascun fotogramma è la distanza dal successivo: da lì dipendono sia
// l'entità della sfocatura sintetica sia l'angolo di otturatore effettivo.
for (int i = 0; i < frames.Length; i++)
{
double speed = i + 1 < frames.Length
? frames[i + 1].SourcePosition - frames[i].SourcePosition
: (i > 0 ? frames[i].SourcePosition - frames[i - 1].SourcePosition : 1.0);
frames[i] = frames[i] with { Speed = Math.Max(0.0, speed) };
}
return new RenderPlan { Frames = frames, Description = description, SourceSpan = count };
}
private static PlannedFrame[] BuildFromTimingMode(TimelapseSequence sequence, ExportSettings export,
uint baseUnits, out string description)
{
int count = sequence.Count;
double nominal = sequence.NominalInterval > 0 ? sequence.NominalInterval : 1.0;
var frames = new List<PlannedFrame>(count);
for (int i = 0; i < count; i++)
{
var record = sequence.Frames[i];
int subdivisions = ComputeSubdivisions(export, record, nominal);
uint duration = ComputeDuration(export, record, nominal, baseUnits);
for (int k = 0; k < subdivisions; k++)
{
frames.Add(new PlannedFrame(i + k / (double)subdivisions, duration, 1.0 / subdivisions));
if (frames.Count >= MaxOutputFrames) break;
}
if (frames.Count >= MaxOutputFrames) break;
}
description = export.Timing switch
{
FrameTimingMode.Adaptive => $"{frames.Count} fotogrammi a durata proporzionale all'intervallo",
FrameTimingMode.Interpolated => $"{frames.Count} fotogrammi su cadenza uniformata " +
$"({frames.Count - count} sintetizzati)",
_ => $"{frames.Count} fotogrammi a durata costante",
};
return [.. frames];
}
/// <summary>
/// Percorre la sequenza integrando la curva di velocità. Il passo non è costante: dove la
/// curva scende sotto uno la sequenza avanza per frazioni di fotogramma e quelli mancanti
/// verranno sintetizzati, dove sale sopra uno alcuni scatti vengono semplicemente saltati.
/// La monotonia della spline garantisce che il tempo non torni mai indietro.
/// </summary>
private static PlannedFrame[] BuildRamped(int count, TimeRampSettings ramp, uint baseUnits,
out string description)
{
var frames = new List<PlannedFrame>(count * 2);
double last = count - 1.0;
double position = 0;
double slowest = double.MaxValue, fastest = 0;
while (position <= last + 1e-9 && frames.Count < MaxOutputFrames)
{
double speed = ramp.SpeedAt(position / last);
slowest = Math.Min(slowest, speed);
fastest = Math.Max(fastest, speed);
frames.Add(new PlannedFrame(Math.Min(position, last), baseUnits, speed));
position += speed;
}
if (frames.Count == 0) frames.Add(new PlannedFrame(0, baseUnits, 1));
description = frames.Count > 1
? $"{frames.Count} fotogrammi rimappati, velocità da {slowest:0.##}× a {fastest:0.##}×"
: $"{frames.Count} fotogrammi rimappati";
return [.. frames];
}
private static int ComputeSubdivisions(ExportSettings export, FrameRecord record, double nominal)
{
if (export.Timing != FrameTimingMode.Interpolated) return 1;
double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal);
int max = (int)Math.Max(1, Math.Round(export.MaxAdaptiveStretch));
return Math.Clamp((int)Math.Round(ratio), 1, max);
}
private static uint ComputeDuration(ExportSettings export, FrameRecord record, double nominal, uint baseUnits)
{
if (export.Timing != FrameTimingMode.Adaptive) return baseUnits;
double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal);
double limited = Math.Clamp(ratio, 1.0 / export.MaxAdaptiveStretch, export.MaxAdaptiveStretch);
return (uint)Math.Max(1, Math.Round(baseUnits * limited));
}
}
+313
View File
@@ -0,0 +1,313 @@
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);
}
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);
}
}
+127
View File
@@ -0,0 +1,127 @@
using System.Runtime.InteropServices;
using Titano.Diagnostics;
namespace Titano;
internal static class Program
{
private const int AttachParentProcess = -1;
[DllImport("kernel32.dll")]
private static extern bool AttachConsole(int processId);
[STAThread]
private static int Main(string[] args)
{
if (args.Length > 0 && args[0] is "--selftest" or "-t")
{
EnsureConsole();
string directory = args.Length > 1
? args[1]
: Path.Combine(Path.GetTempPath(), "Titano.SelfTest");
Directory.CreateDirectory(directory);
Console.WriteLine();
Console.Write(SelfTest.DescribeEnvironment());
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();
Console.WriteLine();
return SequenceDiagnostics.Run(args[1], Console.Out,
renderPath: args.Length > 2 ? args[2] : null,
renderFrames: args.Length > 3 && int.TryParse(args[3], out int n) ? n : 24,
renderWidth: args.Length > 4 && int.TryParse(args[4], out int w) ? w : 0,
renderCodec: args.Length > 5 && args[5].Equals("hevc", StringComparison.OrdinalIgnoreCase)
? Video.VideoCodec.Hevc : Video.VideoCodec.H264);
}
if (args.Length > 1 && args[0] == "--capture")
{
EnsureConsole();
ApplicationConfiguration.Initialize();
return Capture(args[1], args.Length > 2 ? args[2] : null,
args.Length > 3 && int.TryParse(args[3], out int tab) ? tab : 0,
args.Length > 4 && args[4].Equals("avanzato", StringComparison.OrdinalIgnoreCase));
}
ApplicationConfiguration.Initialize();
Application.Run(new UI.MainForm());
return 0;
}
/// <summary>
/// Rende visibili i messaggi delle modalità a riga di comando.
///
/// L'eseguibile è compilato come applicazione a finestre e quindi nasce senza console:
/// agganciare quella del processo padre è l'unico modo di farsi leggere da un terminale.
/// Ma se lo standard output è già dirottato — una pipe, un file di registro, il task di
/// un editor — l'aggancio sostituirebbe quel dirottamento e i messaggi finirebbero nella
/// console invece che dove sono attesi. In quel caso non si aggancia niente: i gestori
/// ereditati vanno già bene.
/// </summary>
private static void EnsureConsole()
{
if (Console.IsOutputRedirected) return;
AttachConsole(AttachParentProcess);
}
/// <summary>
/// Apre l'interfaccia, opzionalmente vi carica una sequenza, e ne salva un'immagine.
/// Serve a verificare la resa dei controlli disegnati a mano senza intervento manuale.
/// </summary>
private static int Capture(string outputPath, string? sequenceDirectory, int settingsTab,
bool advanced = false)
{
using var form = new UI.MainForm();
form.Show();
Pump(200);
// I moduli avanzati vanno accesi prima di caricare: la maschera delle regioni e il
// percorso della stabilizzazione nascono durante l'analisi, non dopo.
if (advanced) form.EnableAdvancedModulesForCapture();
if (sequenceDirectory is not null && Directory.Exists(sequenceDirectory))
{
string[] files = [.. Directory.EnumerateFiles(sequenceDirectory)
.Where(Metadata.MetadataReader.IsSupported)];
var work = form.PrepareForCaptureAsync(files);
while (!work.IsCompleted) Pump(30);
Pump(2500); // attesa del rendering asincrono dell'anteprima
}
if (settingsTab > 0)
{
form.SelectSettingsTab(settingsTab);
Pump(200);
}
using var bitmap = new Bitmap(form.Width, form.Height);
form.DrawToBitmap(bitmap, new Rectangle(0, 0, form.Width, form.Height));
bitmap.Save(outputPath, System.Drawing.Imaging.ImageFormat.Png);
Console.WriteLine($"Interfaccia catturata in {outputPath} ({bitmap.Width}×{bitmap.Height}).");
return 0;
}
private static void Pump(int milliseconds)
{
var deadline = Environment.TickCount64 + milliseconds;
do
{
Application.DoEvents();
Thread.Sleep(10);
}
while (Environment.TickCount64 < deadline);
}
}
+254
View File
@@ -0,0 +1,254 @@
# Titano
Applicazione desktop per la creazione e l'ottimizzazione di time-lapse di livello
professionale, sviluppata interamente in-house.
## Il vincolo che definisce il progetto
**Zero librerie di terze parti.** Il file di progetto non contiene alcun
`<PackageReference>`: tutto ciò che non è la libreria standard di .NET è scritto qui dentro
oppure ottenuto tramite P/Invoke diretto verso componenti del sistema operativo.
| Ambito | Come è risolto |
|---|---|
| Parsing EXIF / XMP | Parser binario proprietario (`Metadata/`) |
| Analisi fotometrica e deflicker | Algoritmi proprietari (`Analysis/`) |
| Campo vettoriale e motion blur | Schema differenziale piramidale proprietario (`Motion/`) |
| Trasformata di Fourier | CooleyTukey a base due, in loco (`Motion/Fourier.cs`) |
| Segmentazione e stabilizzazione | Otsu, gradiente e correlazione di fase proprietarie |
| Interpolazione di curve | Spline monotona e Bézier proprietarie (`Core/Spline.cs`) |
| Contenitore MP4 | Multiplexer ISO-BMFF proprietario (`Video/Mp4Muxer.cs`) |
| Decodifica immagini | WIC, componente di Windows, via COM interop scritto a mano |
| Codifica video | Media Foundation Transform, encoder hardware di sistema |
| Interfaccia grafica | WinForms + GDI+, ogni controllo disegnato a mano (`UI/`) |
Non viene invocato nessun processo esterno: niente FFmpeg, niente ExifTool.
## Principi architetturali
**Il video è l'unico file che sopravvive.** Il flusso compresso viene prodotto in streaming:
`ftyp` e l'header `mdat` all'apertura, i pacchetti dell'encoder direttamente nel file, `moov`
in chiusura. L'elaborazione può creare un solo altro file — il parcheggio temporaneo dei
fotogrammi descritto più sotto — che nasce con la cancellazione automatica alla chiusura e non
contiene nulla di riutilizzabile.
**Impronta di memoria governata, non incidentale.** I fotogrammi vivono in array float presi
da un pool (`Imaging/ImageBuffer.cs`) e passano da una finestra scorrevole
(`Pipeline/FrameWindow.cs`). L'occupazione dipende dall'ampiezza della finestra — che l'utente
sceglie — e mai dalla lunghezza della sequenza: la verifica automatica lo misura esplicitamente.
**Interfaccia reattiva.** Ingestion, analisi e render girano su thread di lavoro; il thread
dell'interfaccia riceve solo aggiornamenti di stato immutabili tramite `IProgress<T>`.
## Struttura
```
Metadata/ parser binario TIFF/Exif, scanner XMP, riconoscimento contenitori
Core/ sequenza, cadenza, shutter angle, spline monotona e Bézier
Imaging/ buffer poolati, spazio colore lineare, decodifica WIC, stadio geometrico
Analysis/ luminanza, deflicker, segmentazione in regioni, transizioni giorno-notte
Motion/ Fourier, correlazione di fase, stabilizzazione, optical flow, blur, stacking
Video/ conversione NV12, encoder Media Foundation, multiplexer MP4
Pipeline/ piano di rendering, finestra scorrevole, orchestrazione, impostazioni
UI/ tema scuro, grafico vettoriale, editor di curve e keyframe, pannelli
Diagnostics/ sequenze sintetiche, verifica end-to-end, ispettore MP4
```
## Il piano di rendering
Il motore non percorre la sequenza sorgente: percorre un elenco di fotogrammi d'uscita, ognuno
con la propria posizione — anche frazionaria — e la propria durata. Durata costante, durata
proporzionale all'intervallo, cadenza uniformata e rimappatura non lineare producono tutte
quella stessa forma, quindi il ciclo di rendering è uno solo e non contiene un ramo per
ciascuna modalità.
Una posizione frazionaria significa un fotogramma che non esiste e va sintetizzato dal campo
vettoriale. Da qui discende anche la sfocatura: se un fotogramma d'uscita copre `v` scatti,
l'angolo di otturatore effettivo è quello di ripresa diviso `v`, e la scia da sintetizzare si
scala della stessa quantità. Accelerare e rallentare restano quindi coerenti con la fisica
dell'esposizione invece di essere un semplice salto di indici.
## Note sugli algoritmi
**Deflicker.** Per ogni fotogramma si esegue una regressione lineare locale pesata sulla
finestra mobile. I pesi combinano una gaussiana sulla distanza temporale e, in seconda
passata, un peso di robustezza di Tukey che neutralizza i fotogrammi anomali. La componente
lineare segue senza ritardo le rampe reali di luce (alba, tramonto) e rimuove solo la
componente ad alta frequenza dovuta alle micro-variazioni del diaframma. La misura è la media
logaritmica troncata: invariante alla scala, insensibile a cieli bruciati e ombre chiuse.
**Motion blur.** Lo shutter angle reale è `360 × posa / intervallo`. La scia già incisa nel
fotogramma e quella sintetica si compongono in quadratura, quindi per raggiungere l'apertura
obiettivo serve una scia di `√(obiettivo² reale²)` volte lo spostamento: sommare
linearmente produrrebbe un'immagine sistematicamente troppo morbida. Il filtro di
ricostruzione è di tipo *gather*: un campione contribuisce al pixel centrale solo se la
propria scia lo raggiunge davvero, così lo sfondo fermo non viene trascinato nei soggetti in
movimento.
**Optical flow.** Schema differenziale piramidale: su ogni nodo di una griglia rada si risolve
iterativamente il sistema normale 2×2 costruito dai gradienti spaziali e dalla differenza
temporale. Un filtro mediano fra un livello e l'altro elimina i vettori spuri delle zone
piatte. L'analisi avviene a risoluzione ridotta e i vettori vengono riportati in scala piena:
il costo non dipende dalla dimensione dei file sorgente. Il campo si calcola solo quando serve
davvero: se la posa copre già più dell'apertura obiettivo — il caso delle riprese notturne, con
pose da trenta secondi su intervalli da trentaquattro — non c'è sfocatura da sintetizzare e la
voce di spesa più pesante della pipeline viene saltata.
## Moduli avanzati
**Stabilizzazione sub-pixel.** Lo spostamento fra fotogrammi adiacenti si misura con la
correlazione di fase: una traslazione nello spazio è uno sfasamento lineare in frequenza, e
normalizzando lo spettro incrociato al modulo unitario resta solo la fase, la cui
antitrasformata è un impulso posto sullo spostamento. Il metodo ignora quindi per costruzione
le differenze di luminosità fra due scatti — che in un time-lapse ci sono sempre — e reagisce
alla sola geometria. Sotto il pixel la superficie di correlazione viene ricostruita a passo
fine valutando direttamente la somma di Fourier sulle posizioni intermedie, invece di
interpolare con una parabola tre campioni di una cresta che parabola non è: l'errore misurato
scende da 0,14 a 0,08 px. Nove riquadri per coppia danno nove misure indipendenti, da cui si
stima ai minimi quadrati una similitudine — traslazione, rotazione e scala — scartando i
riquadri occupati da nuvole o fronde, che si muovono per conto loro. Il percorso assoluto
viene infine lisciato: ciò che resta fra percorso vero e percorso liscio è il tremolio, e la
sua inversa è la correzione. Una panoramica voluta sopravvive perché è già liscia.
**Deflicker per regioni.** Una nuvola densa che attraversa il cielo abbassa la luminanza media
del fotogramma; il deflicker legge un calo di luce e schiarisce tutto, paesaggio compreso, che
invece non era cambiato. Titano divide il fotogramma in due regioni e dà a ciascuna la propria
curva. La maschera nasce una volta sola dalla mediana temporale di un campione di fotogrammi
— la mediana toglie di mezzo proprio ciò che passa e non resta — e la linea d'orizzonte viene
poi agganciata al massimo del gradiente verticale, che cade sul bordo vero e non dove capita
la soglia. Sulla scena di prova il terreno passa da 0,062 a 0,026 stop di oscillazione.
**Transizioni giorno-notte.** Attraversando il tramonto la macchina deve cambiare tempo, ISO o
diaframma, e ogni cambio è quantizzato: nel filmato si vede uno scalino netto. Il deflicker
ordinario non aiuta, perché un gradino non è rumore da mediare e la finestra mobile lo
trasforma in una rampa con due spigoli. Titano legge invece l'ampiezza esatta nei metadati —
il valore di esposizione vale log2(N²/t) log2(ISO/100) — e la ridistribuisce su una
transizione a derivata nulla agli estremi, lunga a piacere. Il deflicker lavora poi sulla serie
già priva di gradini. Il bilanciamento del bianco riceve un trattamento parallelo: si liscia il
rapporto fra i canali invece della loro luminanza, così sparisce il tremolio del bilanciamento
automatico e resta il viaggio verso il caldo del tramonto.
**Movimento di macchina virtuale.** Un sensore da dodici megapixel contiene un 4K con
abbondante margine di ritaglio: da lì si ricava una panoramica che in ripresa avrebbe richiesto
una slitta motorizzata, decisa dopo, guardando il materiale. Fra un nodo e il successivo il
tempo passa per una curva di Bézier con estremi fissi — senza, il movimento partirebbe e si
fermerebbe di colpo, e a velocità di time-lapse lo scatto si vede benissimo. Ritaglio e
correzione di stabilizzazione sono entrambi affini e vengono composti in una sola
trasformazione: il fotogramma viene interpolato una volta sola, con una cubica di Catmull-Rom.
**Rimappatura non lineare del tempo.** Una curva di velocità dice quanti scatti vengono
consumati per ogni fotogramma d'uscita: sopra 1 la sequenza accelera saltando scatti, sotto 1
rallenta e i mancanti vengono sintetizzati. La velocità del filmato resta fissa. La spline è
monotona per costruzione (schema di FritschCarlson): una Catmull-Rom ordinaria
sovraelongerebbe fra due nodi molto diversi, e una velocità negativa farebbe tornare indietro
la sequenza.
**Accumulo temporale.** La mediana su una finestra di fotogrammi dà a ogni pixel il valore
centrale della propria serie: la scena stabile resta se stessa, chi attraversa l'inquadratura
una volta sola sparisce. Una media lascerebbe un fantasma tanto più visibile quanto più
l'intruso era contrastato. Il massimo progressivo conserva invece il valore più alto incontrato
e trasforma le stelle in archi continui; con una lunghezza di scia finita l'accumulo decade e
le scie hanno una coda invece di riempire il cielo. Se la stabilizzazione è attiva i fotogrammi
vengono sovrapposti dopo averli raddrizzati, altrimenti la mediana scambierebbe per intruso il
tremolio stesso.
## Memoria e parcheggio su disco
I moduli avanzati hanno rotto l'assunto su cui la pipeline originale era costruita: che
bastassero due fotogrammi vivi alla volta. La mediana ne vuole un'intera finestra
simultaneamente, la rimappatura può chiedere lo stesso scatto per molti fotogrammi d'uscita
consecutivi.
La regola di residenza è una sola e non produce mai andirivieni: la finestra attiva sta sempre
in memoria, perché chi la usa ha bisogno di tutti i suoi fotogrammi insieme; i fotogrammi letti
in anticipo — che servono a tenere occupati tutti i processori sulla decodifica dei RAW, la
parte più lenta della pipeline — finiscono su disco quando il tetto di memoria è raggiunto, e
vengono ripresi una volta sola, quando entrano nella finestra. Non esiste un caso in cui lo
stesso fotogramma vada e torni dal disco più volte. Il file di parcheggio nasce con l'opzione di
cancellazione alla chiusura: se il programma termina male, lo rimuove il sistema.
## Sorgenti RAW e limiti dei codec
I file RAW e DNG non espongono l'immagine principale nella prima directory: quella è una
miniatura, e le dimensioni vere vivono in una SubIFD marcata come non ridotta. Titano sceglie
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
video uscirebbe, peserebbe, e non si riprodurrebbe. Titano riduce quindi il fotogramma al
massimo riproducibile conservando le proporzioni, e lo dichiara in interfaccia. Le sorgenti
16:9 fino al 4K UHD non vengono toccate.
## Compilazione ed esecuzione
```
dotnet build -c Release
dotnet run -c Release
```
Richiede .NET 10 SDK su Windows x64.
## Verifica automatica
```
Titano.exe --selftest [cartella]
```
Genera sequenze sintetiche dalle proprietà note e le fa attraversare l'intera pipeline,
confrontando 51 grandezze misurate con i valori attesi. Nessuna soglia è scelta a posteriori:
la scena è costruita perché il valore atteso sia la conseguenza aritmetica di come è stata
generata — il tremolio ha un percorso noto, il gradino di esposizione un'ampiezza dichiarata
nei metadati e visibile nei pixel, la nuvola attraversa il solo cielo.
Copre campi Exif, cadenza e shutter angle; riduzione dello sfarfallio e conservazione della
rampa di luce; invarianza della luminanza alla scala di decodifica; correlazione di fase sotto
il pixel; misura e rimozione del tremolio con la panoramica voluta che sopravvive; copertura
della maschera delle regioni; riconoscimento e ridistribuzione dei cambi di esposizione;
lisciatura cromatica con la deriva del tramonto conservata; monotonia e simmetria delle curve
di accelerazione; trasparenza e ritaglio dello stadio geometrico; monotonia della rimappatura
temporale; rimozione dell'intruso da parte della mediana e conservazione dei passaggi da parte
del massimo; allineamento delle NAL dentro ogni campione del contenitore; e — prova conclusiva
— un rendering con tutti i moduli attivi insieme, con tetto di memoria volutamente stretto per
esercitare il parcheggio su disco, riletto poi dal lettore di sistema.
```
Titano.exe --diagnose <cartella> [uscita.mp4] [fotogrammi] [larghezza] [h264|hevc]
```
Riporta cosa il programma riesce davvero a leggere da una cartella reale: metadati file per
file, dimensione dichiarata contro dimensione decodificata, coerenza della luminanza fra
risoluzioni diverse, cadenza sull'intera sequenza. Indicando un file di uscita esegue anche un
render di prova e ne riverifica il contenitore. È lo strumento con cui si distingue un difetto
del motore da un formato che il sistema non sa aprire.
```
Titano.exe --capture <file.png> [cartella-sequenza] [scheda] [avanzato]
```
Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile. Con
`avanzato` i moduli opzionali vengono accesi prima del caricamento, così l'immagine mostra
curve di regione, marcatori dei cambi di esposizione, percorso della macchina virtuale e
contorno delle regioni con dati veri invece che a riposo.
```
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.
+44
View File
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Titano - motore di time-lapse professionale.
VINCOLO ARCHITETTURALE: questo progetto non contiene NESSUN <PackageReference>.
Tutto ciò che non è BCL è implementato in-house oppure ottenuto tramite
P/Invoke diretto verso API native del sistema operativo (WIC, Media Foundation,
GDI+, DWM, Kernel32), che il brief consente esplicitamente.
-->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<AssemblyName>Titano</AssemblyName>
<ApplicationIcon>Titano.ico</ApplicationIcon>
<RootNamespace>Titano</RootNamespace>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<ApplicationDefaultFont>Segoe UI, 9pt</ApplicationDefaultFont>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<InvariantGlobalization>false</InvariantGlobalization>
<Product>Titano</Product>
<Company>Titano</Company>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Version>1.0.0</Version>
<!--
CA1416: le API Windows-only sono intenzionali, il progetto è vincolato a win-x64.
WFO1000: riguarda la serializzazione dei controlli nel designer visuale. Qui l'intera
interfaccia è costruita in codice e nessun controllo viene mai serializzato.
-->
<NoWarn>$(NoWarn);CA1416;WFO1000</NoWarn>
</PropertyGroup>
</Project>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="Titano.csproj" />
</Solution>
+390
View File
@@ -0,0 +1,390 @@
using System.Drawing.Drawing2D;
using Titano.Motion;
namespace Titano.UI;
/// <summary>
/// Editor del movimento di macchina virtuale: mostra il fotogramma sorgente per intero e,
/// dentro di esso, le inquadrature dei nodi con il percorso che il centro descrive fra l'uno
/// e l'altro.
///
/// Il percorso disegnato non è una retta fra i nodi ma la curva davvero eseguita, campionata
/// con la stessa funzione di accelerazione che userà il rendering: è l'unico modo perché
/// l'anteprima dica qualcosa di vero su come si muoverà l'inquadratura, e perché l'effetto di
/// una maniglia di accelerazione si veda mentre la si regola.
///
/// Le inquadrature si spostano trascinandole e si stringono trascinando la maniglia
/// nell'angolo. La striscia in basso è la linea del tempo: ogni nodo è un indicatore che si
/// seleziona con un clic e si sposta trascinandolo.
/// </summary>
internal sealed class CameraEditor : Control
{
private const int TimelineHeight = 26;
private const float HandleSize = 9f;
private VirtualCameraSettings _settings = new();
private int _selected;
private int _dragging = -1;
private bool _draggingZoom;
private bool _draggingTime;
private PointF _grabOffset;
public event EventHandler? Changed;
public event EventHandler? SelectionChanged;
/// <summary>Rapporto larghezza/altezza del fotogramma sorgente.</summary>
public double Aspect { get; set; } = 16.0 / 9.0;
public CameraEditor()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 220;
}
public VirtualCameraSettings Settings
{
get => _settings;
set
{
_settings = value;
_selected = Math.Clamp(_selected, 0, Math.Max(0, value.Keyframes.Count - 1));
Invalidate();
}
}
public int SelectedIndex
{
get => Math.Clamp(_selected, 0, Math.Max(0, _settings.Keyframes.Count - 1));
set
{
int clamped = Math.Clamp(value, 0, Math.Max(0, _settings.Keyframes.Count - 1));
if (clamped == _selected) return;
_selected = clamped;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
public CameraKeyframe? Selected => _settings.Keyframes.Count > 0
? _settings.Keyframes[SelectedIndex]
: null;
/// <summary>Inserisce un nodo a metà fra quello scelto e il successivo.</summary>
public void AddKeyframe()
{
var keyframes = _settings.Keyframes;
int index = SelectedIndex;
double time = index < keyframes.Count - 1
? (keyframes[index].Time + keyframes[index + 1].Time) * 0.5
: Math.Min(1, keyframes[^1].Time + 0.1);
var framing = VirtualCamera.Resolve(new VirtualCameraSettings
{
Enabled = true,
Keyframes = keyframes,
}, time);
keyframes.Add(new CameraKeyframe
{
Time = time,
CentreX = framing.CentreX,
CentreY = framing.CentreY,
Zoom = framing.Zoom,
});
keyframes.Sort((a, b) => a.Time.CompareTo(b.Time));
_selected = keyframes.FindIndex(k => Math.Abs(k.Time - time) < 1e-9);
Changed?.Invoke(this, EventArgs.Empty);
SelectionChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
/// <summary>Toglie il nodo scelto; due devono restare, o non ci sarebbe più un movimento.</summary>
public void RemoveSelected()
{
if (_settings.Keyframes.Count <= 2) return;
_settings.Keyframes.RemoveAt(SelectedIndex);
_selected = Math.Clamp(_selected, 0, _settings.Keyframes.Count - 1);
Changed?.Invoke(this, EventArgs.Empty);
SelectionChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
// ------------------------------------------------------------------ geometria
private Rectangle Timeline => new(10, Height - TimelineHeight, Math.Max(20, Width - 20), TimelineHeight - 8);
/// <summary>Rettangolo che rappresenta il fotogramma sorgente, con le sue proporzioni.</summary>
private RectangleF Stage
{
get
{
var available = new RectangleF(10, 8, Math.Max(20, Width - 20),
Math.Max(20, Height - TimelineHeight - 16));
float aspect = (float)Math.Max(0.1, Aspect);
float width = available.Width;
float height = width / aspect;
if (height > available.Height)
{
height = available.Height;
width = height * aspect;
}
return new RectangleF(available.Left + (available.Width - width) / 2,
available.Top + (available.Height - height) / 2, width, height);
}
}
private RectangleF FramingRect(CameraKeyframe keyframe)
{
var stage = Stage;
var framing = VirtualCamera.Constrain(new CameraFraming(keyframe.CentreX, keyframe.CentreY, keyframe.Zoom),
_settings.KeepInsideFrame);
float width = (float)(stage.Width / framing.Zoom);
float height = (float)(stage.Height / framing.Zoom);
return new RectangleF(stage.Left + (float)(framing.CentreX * stage.Width) - width / 2,
stage.Top + (float)(framing.CentreY * stage.Height) - height / 2,
width, height);
}
private PointF TimelinePoint(CameraKeyframe keyframe)
{
var timeline = Timeline;
return new PointF(timeline.Left + (float)(keyframe.Time * timeline.Width),
timeline.Top + timeline.Height / 2f);
}
// ------------------------------------------------------------------ interazione
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.Button != MouseButtons.Left) { base.OnMouseDown(e); return; }
var keyframes = _settings.Keyframes;
// La linea del tempo ha la precedenza: è la striscia più sottile e va raggiunta prima.
if (e.Y >= Timeline.Top - 6)
{
for (int i = 0; i < keyframes.Count; i++)
{
var point = TimelinePoint(keyframes[i]);
if (Math.Abs(point.X - e.X) > 8) continue;
SelectedIndex = i;
_dragging = i;
_draggingTime = true;
return;
}
return;
}
// Maniglia dell'ingrandimento sull'inquadratura scelta.
if (Selected is { } current)
{
var rect = FramingRect(current);
var handle = new RectangleF(rect.Right - HandleSize, rect.Bottom - HandleSize,
HandleSize * 2, HandleSize * 2);
if (handle.Contains(e.Location))
{
_dragging = SelectedIndex;
_draggingZoom = true;
return;
}
}
// Altrimenti si sceglie l'inquadratura più stretta che contiene il punto: quelle
// piccole stanno dentro le grandi, e cliccando sulla piccola si vuole la piccola.
int best = -1;
double bestArea = double.MaxValue;
for (int i = 0; i < keyframes.Count; i++)
{
var rect = FramingRect(keyframes[i]);
if (!rect.Contains(e.Location)) continue;
double area = rect.Width * (double)rect.Height;
if (area >= bestArea) continue;
bestArea = area;
best = i;
}
if (best < 0) return;
SelectedIndex = best;
_dragging = best;
_draggingZoom = false;
_draggingTime = false;
var selectedRect = FramingRect(keyframes[best]);
_grabOffset = new PointF(e.X - (selectedRect.Left + selectedRect.Width / 2),
e.Y - (selectedRect.Top + selectedRect.Height / 2));
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_dragging < 0 || _dragging >= _settings.Keyframes.Count)
{
Cursor = CursorFor(e.Location);
base.OnMouseMove(e);
return;
}
var keyframe = _settings.Keyframes[_dragging];
var stage = Stage;
if (_draggingTime)
{
var timeline = Timeline;
double time = Math.Clamp((e.X - timeline.Left) / (double)timeline.Width, 0, 1);
// Gli estremi restano agli estremi: il movimento deve coprire tutta la sequenza.
if (_dragging > 0 && _dragging < _settings.Keyframes.Count - 1)
{
keyframe.Time = Math.Clamp(time,
_settings.Keyframes[_dragging - 1].Time + 0.01,
_settings.Keyframes[_dragging + 1].Time - 0.01);
}
}
else if (_draggingZoom)
{
// L'ingrandimento segue la semilarghezza trascinata: il rettangolo insegue il dito.
float halfWidth = Math.Max(4f, e.X - (stage.Left + (float)(keyframe.CentreX * stage.Width)));
double zoom = stage.Width / (2.0 * halfWidth);
keyframe.Zoom = Math.Clamp(zoom, 1.0, 8.0);
}
else
{
keyframe.CentreX = Math.Clamp((e.X - _grabOffset.X - stage.Left) / stage.Width, 0, 1);
keyframe.CentreY = Math.Clamp((e.Y - _grabOffset.Y - stage.Top) / stage.Height, 0, 1);
}
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
}
protected override void OnMouseUp(MouseEventArgs e)
{
if (_dragging >= 0) _settings.Keyframes.Sort((a, b) => a.Time.CompareTo(b.Time));
_dragging = -1;
_draggingZoom = false;
_draggingTime = false;
Invalidate();
base.OnMouseUp(e);
}
private Cursor CursorFor(Point location)
{
if (location.Y >= Timeline.Top - 6) return Cursors.SizeWE;
if (Selected is not { } current) return Cursors.Default;
var rect = FramingRect(current);
var handle = new RectangleF(rect.Right - HandleSize, rect.Bottom - HandleSize,
HandleSize * 2, HandleSize * 2);
if (handle.Contains(location)) return Cursors.SizeNWSE;
return rect.Contains(location) ? Cursors.SizeAll : Cursors.Default;
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var stage = Stage;
Theme.FillRounded(g, stage, 4f, Theme.SurfaceAlt);
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(stage));
TextRenderer.DrawText(g, "risoluzione nativa", Theme.Small,
new Rectangle((int)stage.Left + 6, (int)stage.Top + 4, 160, 14),
Theme.TextFaint, TextFormatFlags.Left);
var keyframes = _settings.Keyframes;
if (keyframes.Count == 0) return;
// Percorso del centro, campionato sulla curva vera: comprende l'accelerazione.
var samples = new PointF[128];
for (int i = 0; i < samples.Length; i++)
{
double t = i / (double)(samples.Length - 1);
var framing = VirtualCamera.Constrain(VirtualCamera.Resolve(
new VirtualCameraSettings { Enabled = true, Keyframes = keyframes }, t),
_settings.KeepInsideFrame);
samples[i] = new PointF(stage.Left + (float)(framing.CentreX * stage.Width),
stage.Top + (float)(framing.CentreY * stage.Height));
}
using (var pathPen = new Pen(Color.FromArgb(150, Theme.Success), 1.6f) { DashStyle = DashStyle.Dash })
{
g.DrawLines(pathPen, samples);
}
// Le tacche lungo il percorso sono equispaziate nel tempo: dove si addensano il
// movimento sta rallentando, dove si diradano sta correndo. È la lettura immediata
// dell'effetto delle maniglie di accelerazione.
using (var tick = new SolidBrush(Color.FromArgb(190, Theme.Success)))
{
for (int i = 0; i < samples.Length; i += 8)
g.FillEllipse(tick, samples[i].X - 1.6f, samples[i].Y - 1.6f, 3.2f, 3.2f);
}
for (int i = 0; i < keyframes.Count; i++)
{
var rect = FramingRect(keyframes[i]);
bool active = i == SelectedIndex;
using var pen = new Pen(active ? Theme.Accent : Color.FromArgb(120, Theme.Text), active ? 2f : 1.2f);
if (!active) pen.DashStyle = DashStyle.Dash;
g.DrawRectangle(pen, rect.Left, rect.Top, rect.Width, rect.Height);
if (!active) continue;
using (var fill = new SolidBrush(Color.FromArgb(26, Theme.Accent))) g.FillRectangle(fill, rect);
using (var handle = new SolidBrush(Theme.Accent))
{
g.FillRectangle(handle, rect.Right - HandleSize / 2, rect.Bottom - HandleSize / 2,
HandleSize, HandleSize);
}
// In alto a destra: a ingrandimento 1 il riquadro coincide con il fotogramma e a
// sinistra c'è già la didascalia della risoluzione nativa.
string label = $"{keyframes[i].Zoom:0.00}×";
TextRenderer.DrawText(g, label, Theme.SmallBold,
new Rectangle((int)rect.Right - 74, (int)rect.Top + 3, 70, 15),
Theme.Text, TextFormatFlags.Right);
}
DrawTimeline(g, keyframes);
}
private void DrawTimeline(Graphics g, List<CameraKeyframe> keyframes)
{
var timeline = Timeline;
Theme.FillRounded(g, new RectangleF(timeline.Left, timeline.Top + timeline.Height / 2f - 2,
timeline.Width, 4), 2f, Theme.SurfaceAlt);
for (int i = 0; i < keyframes.Count; i++)
{
var point = TimelinePoint(keyframes[i]);
bool active = i == SelectedIndex;
float radius = active ? 6f : 4.5f;
// Losanga, come nei programmi di montaggio: si distingue da un punto qualsiasi.
var diamond = new PointF[]
{
new(point.X, point.Y - radius),
new(point.X + radius, point.Y),
new(point.X, point.Y + radius),
new(point.X - radius, point.Y),
};
using var fill = new SolidBrush(active ? Theme.Accent : Theme.TextMuted);
using var stroke = new Pen(Theme.Background, 1.5f);
g.FillPolygon(fill, diamond);
g.DrawPolygon(stroke, diamond);
}
}
}
+488
View File
@@ -0,0 +1,488 @@
using System.Globalization;
namespace Titano.UI;
/// <summary>Pulsante disegnato interamente a mano, con varianti primaria e secondaria.</summary>
internal sealed class DarkButton : Control
{
private bool _hover;
private bool _pressed;
public bool Primary { get; set; }
public bool Danger { get; set; }
public DarkButton()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 32;
Font = Theme.Body;
Cursor = Cursors.Hand;
}
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; _pressed = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseDown(MouseEventArgs e) { _pressed = true; Invalidate(); base.OnMouseDown(e); }
protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; Invalidate(); base.OnMouseUp(e); }
protected override void OnEnabledChanged(EventArgs e) { Invalidate(); base.OnEnabledChanged(e); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
Color fill, stroke, text;
if (!Enabled)
{
fill = Theme.SurfaceAlt;
stroke = Theme.Border;
text = Theme.TextFaint;
}
else if (Primary)
{
fill = _pressed ? Theme.AccentDim : _hover ? Theme.Mix(Theme.Accent, Color.White, 0.12) : Theme.Accent;
stroke = fill;
text = Color.FromArgb(0x0B, 0x12, 0x1C);
}
else if (Danger)
{
fill = _pressed ? Theme.SurfaceAlt : _hover ? Theme.Mix(Theme.Danger, Theme.Surface, 0.75) : Theme.Surface;
stroke = Theme.Danger;
text = Theme.Danger;
}
else
{
fill = _pressed ? Theme.Surface : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
stroke = _hover ? Theme.BorderStrong : Theme.Border;
text = Theme.Text;
}
Theme.FillAndStroke(g, bounds, 6f, fill, stroke);
TextRenderer.DrawText(g, Text, Font, new Rectangle(0, 0, Width, Height), text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis);
}
}
/// <summary>Interruttore a due stati con etichetta, disegnato a mano.</summary>
internal sealed class DarkCheckBox : Control
{
private bool _checked;
private bool _hover;
public event EventHandler? CheckedChanged;
public bool Checked
{
get => _checked;
set
{
if (_checked == value) return;
_checked = value;
Invalidate();
CheckedChanged?.Invoke(this, EventArgs.Empty);
}
}
public DarkCheckBox()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 26;
Font = Theme.Body;
Cursor = Cursors.Hand;
}
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnClick(EventArgs e) { Checked = !Checked; base.OnClick(e); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
const int size = 17;
int top = (Height - size) / 2;
var box = new RectangleF(0.5f, top + 0.5f, size, size);
Color fill = _checked ? Theme.Accent : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
Color stroke = _checked ? Theme.Accent : _hover ? Theme.BorderStrong : Theme.Border;
Theme.FillAndStroke(g, box, 4f, fill, stroke);
if (_checked)
{
using var pen = new Pen(Color.FromArgb(0x0B, 0x12, 0x1C), 2f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
};
g.DrawLines(pen,
[
new PointF(box.Left + 4f, box.Top + 8.5f),
new PointF(box.Left + 7f, box.Top + 11.5f),
new PointF(box.Left + 13f, box.Top + 5f),
]);
}
var textRect = new Rectangle(size + 9, 0, Width - size - 9, Height);
TextRenderer.DrawText(g, Text, Font, textRect, Enabled ? Theme.Text : Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
}
}
/// <summary>
/// Cursore continuo con didascalia e valore, unità di misura opzionale e tacche.
/// Sostituisce il TrackBar di sistema, che non è tematizzabile.
/// </summary>
internal sealed class ParameterSlider : Control
{
private double _value;
private bool _dragging;
private bool _hover;
public string Caption { get; set; } = string.Empty;
public string Unit { get; set; } = string.Empty;
public string ValueFormat { get; set; } = "0.##";
public double Minimum { get; set; }
public double Maximum { get; set; } = 1;
public double Step { get; set; }
public event EventHandler? ValueChanged;
public double Value
{
get => _value;
set
{
double clamped = Math.Clamp(value, Minimum, Maximum);
if (Step > 0) clamped = Math.Round(clamped / Step) * Step;
if (Math.Abs(clamped - _value) < 1e-9) return;
_value = clamped;
Invalidate();
ValueChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>Imposta il valore senza sollevare l'evento: usata al caricamento delle impostazioni.</summary>
public void SetValueSilently(double value)
{
_value = Math.Clamp(value, Minimum, Maximum);
Invalidate();
}
public ParameterSlider()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 46;
Font = Theme.Body;
}
private Rectangle TrackBounds => new(2, Height - 20, Width - 4, 12);
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left || !Enabled) return;
_dragging = true;
UpdateFromMouse(e.X);
base.OnMouseDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_dragging) UpdateFromMouse(e.X);
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_dragging = false;
base.OnMouseUp(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (!Enabled) return;
double increment = Step > 0 ? Step : (Maximum - Minimum) / 50.0;
Value += Math.Sign(e.Delta) * increment;
}
private void UpdateFromMouse(int x)
{
var track = TrackBounds;
double fraction = Math.Clamp((x - track.Left) / (double)Math.Max(1, track.Width), 0, 1);
Value = Minimum + fraction * (Maximum - Minimum);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
Color captionColor = Enabled ? Theme.TextMuted : Theme.TextFaint;
Color valueColor = Enabled ? Theme.Text : Theme.TextFaint;
TextRenderer.DrawText(g, Caption, Theme.Small, new Rectangle(0, 2, Width - 90, 16),
captionColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
string display = _value.ToString(ValueFormat, CultureInfo.CurrentCulture) +
(string.IsNullOrEmpty(Unit) ? string.Empty : " " + Unit);
TextRenderer.DrawText(g, display, Theme.SmallBold, new Rectangle(Width - 92, 2, 92, 16),
valueColor, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
var track = TrackBounds;
float centerY = track.Top + track.Height / 2f;
var groove = new RectangleF(track.Left, centerY - 2f, track.Width, 4f);
Theme.FillRounded(g, groove, 2f, Enabled ? Theme.SurfaceAlt : Theme.Surface);
double span = Maximum - Minimum;
float fraction = span <= 0 ? 0 : (float)((_value - Minimum) / span);
var filled = new RectangleF(track.Left, centerY - 2f, track.Width * fraction, 4f);
if (filled.Width > 0.5f)
Theme.FillRounded(g, filled, 2f, Enabled ? Theme.Accent : Theme.Border);
float knobX = track.Left + track.Width * fraction;
float radius = _dragging ? 7.5f : _hover ? 7f : 6f;
var knob = new RectangleF(knobX - radius, centerY - radius, radius * 2, radius * 2);
using (var brush = new SolidBrush(Enabled ? Theme.Text : Theme.TextFaint)) g.FillEllipse(brush, knob);
using (var pen = new Pen(Enabled ? Theme.Accent : Theme.Border, 2f))
g.DrawEllipse(pen, RectangleF.Inflate(knob, -1f, -1f));
}
}
/// <summary>Etichetta e menu a discesa affiancati, con lo stile del tema.</summary>
internal sealed class LabeledCombo : Panel
{
public ComboBox Combo { get; }
public LabeledCombo(string caption)
{
Height = 46;
BackColor = Theme.Surface;
var label = new Label
{
Text = caption,
Font = Theme.Small,
ForeColor = Theme.TextMuted,
Dock = DockStyle.Top,
Height = 16,
TextAlign = ContentAlignment.MiddleLeft,
};
Combo = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList,
FlatStyle = FlatStyle.Flat,
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
Font = Theme.Body,
Dock = DockStyle.Top,
DrawMode = DrawMode.OwnerDrawFixed,
ItemHeight = 20,
};
Combo.DrawItem += DrawItem;
Controls.Add(Combo);
Controls.Add(label);
}
private void DrawItem(object? sender, DrawItemEventArgs e)
{
if (e.Index < 0) return;
bool selected = (e.State & DrawItemState.Selected) != 0;
e.Graphics.FillRectangle(new SolidBrush(selected ? Theme.AccentDim : Theme.SurfaceAlt), e.Bounds);
TextRenderer.DrawText(e.Graphics, Combo.Items[e.Index]?.ToString() ?? string.Empty, Theme.Body,
Rectangle.Inflate(e.Bounds, -4, 0), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
/// <summary>Intestazione di sezione con filetto di separazione.</summary>
internal sealed class SectionHeader : Control
{
public SectionHeader(string text)
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Text = text;
Height = 30;
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var size = TextRenderer.MeasureText(g, Text, Theme.SmallBold);
TextRenderer.DrawText(g, Text.ToUpperInvariant(), Theme.SmallBold,
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
int lineStart = size.Width + 14;
if (lineStart < Width - 4)
{
using var pen = new Pen(Theme.Border);
int y = Height / 2;
g.DrawLine(pen, lineStart, y, Width - 2, y);
}
}
}
/// <summary>Selettore a schede orizzontali usato dal pannello di configurazione.</summary>
internal sealed class TabStrip : Control
{
private readonly List<string> _tabs = [];
private int _selected;
private int _hovered = -1;
public event EventHandler? SelectedChanged;
public int SelectedIndex
{
get => _selected;
set
{
int clamped = Math.Clamp(value, 0, Math.Max(0, _tabs.Count - 1));
if (clamped == _selected) return;
_selected = clamped;
Invalidate();
SelectedChanged?.Invoke(this, EventArgs.Empty);
}
}
public TabStrip(params string[] tabs)
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
_tabs.AddRange(tabs);
Height = 36;
Cursor = Cursors.Hand;
}
/// <summary>
/// Larghezze proporzionali al testo: le etichette lunghe non vengono troncate solo
/// perché condividono la barra con etichette corte.
/// </summary>
private float[] TabWidths()
{
var widths = new float[_tabs.Count];
float total = 0;
using (var graphics = CreateGraphics())
{
for (int i = 0; i < _tabs.Count; i++)
{
widths[i] = TextRenderer.MeasureText(graphics, _tabs[i], Theme.SmallBold).Width + 22;
total += widths[i];
}
}
if (total <= 0) return widths;
float scale = Width / total;
for (int i = 0; i < widths.Length; i++) widths[i] *= scale;
return widths;
}
private int IndexAt(int x)
{
if (_tabs.Count == 0) return -1;
var widths = TabWidths();
float cursor = 0;
for (int i = 0; i < widths.Length; i++)
{
cursor += widths[i];
if (x < cursor) return i;
}
return _tabs.Count - 1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.X);
if (index != _hovered) { _hovered = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e) { _hovered = -1; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseDown(MouseEventArgs e) { SelectedIndex = IndexAt(e.X); base.OnMouseDown(e); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
if (_tabs.Count == 0) return;
var widths = TabWidths();
float offset = 0;
for (int i = 0; i < _tabs.Count; i++)
{
var bounds = new RectangleF(offset, 0, widths[i], Height);
offset += widths[i];
bool active = i == _selected;
if (active) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.SurfaceAlt);
else if (i == _hovered) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.Surface);
TextRenderer.DrawText(g, _tabs[i], active ? Theme.SmallBold : Theme.Small,
Rectangle.Round(bounds), active ? Theme.Text : Theme.TextMuted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis);
if (active)
{
using var brush = new SolidBrush(Theme.Accent);
g.FillRectangle(brush, bounds.X + bounds.Width / 2 - 12, Height - 3, 24, 2);
}
}
}
}
/// <summary>Barra di avanzamento sottile con etichetta interna.</summary>
internal sealed class DarkProgressBar : Control
{
private double _fraction;
public double Fraction
{
get => _fraction;
set { _fraction = Math.Clamp(value, 0, 1); Invalidate(); }
}
public DarkProgressBar()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 22;
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var bounds = new RectangleF(0, (Height - 8) / 2f, Width, 8);
Theme.FillRounded(g, bounds, 4f, Theme.SurfaceAlt);
if (_fraction > 0.0005)
{
var filled = new RectangleF(bounds.X, bounds.Y, (float)(bounds.Width * _fraction), bounds.Height);
Theme.FillRounded(g, filled, 4f, Theme.Accent);
}
}
}
+288
View File
@@ -0,0 +1,288 @@
using Titano.Core;
using Titano.Metadata;
namespace Titano.UI;
/// <summary>
/// Tabella dei fotogrammi a rendering virtuale: disegna soltanto le righe visibili, quindi
/// regge sequenze da decine di migliaia di scatti senza creare un controllo per riga.
/// Barra di scorrimento, intestazioni e selezione sono disegnate a mano nel tema scuro.
/// </summary>
internal sealed class FrameTable : Control
{
private sealed record Column(string Title, int Width, bool RightAligned, Func<FrameRecord, string> Value);
private const int RowHeight = 24;
private const int HeaderHeight = 30;
private const int ScrollWidth = 12;
private readonly Column[] _columns;
private TimelapseSequence? _sequence;
private int _scroll;
private int _hoverRow = -1;
private int _selectedIndex = -1;
private bool _draggingScroll;
private int _dragOffset;
public event EventHandler? SelectionChanged;
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectedIndex == value) return;
_selectedIndex = value;
EnsureVisible(value);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
public FrameTable()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
TabStop = true;
_columns =
[
new Column("#", 52, true, r => (r.Index + 1).ToString()),
new Column("File", 178, false, r => r.FileName),
new Column("Ora di scatto", 104, false, r => r.Metadata.CaptureTime?.ToString("HH:mm:ss.ff") ?? "—"),
new Column("Δt", 68, true, r => r.CadenceText),
new Column("Posa", 62, true, r => r.Metadata.ExposureText),
new Column("Apertura", 68, true, r => r.Metadata.ApertureText),
new Column("ISO", 52, true, r => r.Metadata.IsoText),
new Column("Otturatore", 74, true, r => r.ShutterAngle > 0 ? $"{r.ShutterAngle:0.#}°" : "—"),
new Column("Luminanza", 80, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.MeasuredLuminance, 1e-9)):0.00}" : "—"),
new Column("Target", 72, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.TargetLuminance, 1e-9)):0.00}" : "—"),
new Column("Guadagno", 76, true, r => r.LuminanceAnalyzed ? $"{r.GainStops:+0.00;-0.00;0.00}" : "—"),
new Column("Blur", 62, true, r => r.BlurLength > 0.01 ? $"{r.BlurLength:0.0} px" : "—"),
new Column("Movimento", 82, true, r => r.MotionMagnitude > 0.01 ? $"{r.MotionMagnitude:0.0} px" : "—"),
];
}
public void SetSequence(TimelapseSequence? sequence)
{
_sequence = sequence;
_scroll = 0;
_hoverRow = -1;
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
Invalidate();
}
public void Refresh(TimelapseSequence? sequence)
{
_sequence = sequence;
Invalidate();
}
private int VisibleRows => Math.Max(1, (Height - HeaderHeight) / RowHeight);
private int RowCount => _sequence?.Count ?? 0;
private int MaxScroll => Math.Max(0, RowCount - VisibleRows);
private void EnsureVisible(int index)
{
if (index < 0) return;
if (index < _scroll) _scroll = index;
else if (index >= _scroll + VisibleRows) _scroll = index - VisibleRows + 1;
_scroll = Math.Clamp(_scroll, 0, MaxScroll);
}
// ------------------------------------------------------------------ interazione
protected override bool IsInputKey(Keys keyData) => keyData is Keys.Up or Keys.Down or Keys.PageUp or Keys.PageDown;
protected override void OnKeyDown(KeyEventArgs e)
{
if (RowCount == 0) return;
switch (e.KeyCode)
{
case Keys.Up: SelectedIndex = Math.Max(0, _selectedIndex - 1); break;
case Keys.Down: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + 1); break;
case Keys.PageUp: SelectedIndex = Math.Max(0, _selectedIndex - VisibleRows); break;
case Keys.PageDown: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + VisibleRows); break;
case Keys.Home: SelectedIndex = 0; break;
case Keys.End: SelectedIndex = RowCount - 1; break;
default: base.OnKeyDown(e); return;
}
e.Handled = true;
}
protected override void OnMouseWheel(MouseEventArgs e)
{
_scroll = Math.Clamp(_scroll - Math.Sign(e.Delta) * 3, 0, MaxScroll);
Invalidate();
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.X >= Width - ScrollWidth && RowCount > VisibleRows)
{
var thumb = ThumbBounds();
if (thumb.Contains(e.Location)) { _draggingScroll = true; _dragOffset = e.Y - thumb.Top; }
else ScrollToThumb(e.Y - thumb.Height / 2);
return;
}
int row = (e.Y - HeaderHeight) / RowHeight;
int index = _scroll + row;
if (e.Y >= HeaderHeight && index >= 0 && index < RowCount) SelectedIndex = index;
base.OnMouseDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_draggingScroll) { ScrollToThumb(e.Y - _dragOffset); return; }
int row = e.Y >= HeaderHeight ? (e.Y - HeaderHeight) / RowHeight : -1;
if (row != _hoverRow) { _hoverRow = row; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e) { _draggingScroll = false; base.OnMouseUp(e); }
protected override void OnMouseLeave(EventArgs e) { _hoverRow = -1; Invalidate(); base.OnMouseLeave(e); }
private Rectangle ThumbBounds()
{
int trackHeight = Height - HeaderHeight;
if (RowCount <= VisibleRows) return new Rectangle(Width - ScrollWidth, HeaderHeight, ScrollWidth, trackHeight);
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / RowCount);
int available = trackHeight - thumbHeight;
int offset = MaxScroll == 0 ? 0 : available * _scroll / MaxScroll;
return new Rectangle(Width - ScrollWidth + 2, HeaderHeight + offset, ScrollWidth - 4, thumbHeight);
}
private void ScrollToThumb(int top)
{
int trackHeight = Height - HeaderHeight;
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / Math.Max(1, RowCount));
int available = Math.Max(1, trackHeight - thumbHeight);
double fraction = Math.Clamp((top - HeaderHeight) / (double)available, 0, 1);
_scroll = (int)Math.Round(fraction * MaxScroll);
Invalidate();
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
int contentWidth = Width - (RowCount > VisibleRows ? ScrollWidth : 0);
DrawHeader(g, contentWidth);
if (_sequence is not { Count: > 0 })
{
TextRenderer.DrawText(g, "Trascina qui le immagini della sequenza, oppure usa «Aggiungi cartella»",
Theme.Body, new Rectangle(0, HeaderHeight, Width, Height - HeaderHeight),
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
int last = Math.Min(RowCount, _scroll + VisibleRows + 1);
for (int index = _scroll; index < last; index++)
{
int y = HeaderHeight + (index - _scroll) * RowHeight;
if (y > Height) break;
DrawRow(g, _sequence.Frames[index], index, y, contentWidth);
}
DrawScrollBar(g);
}
private void DrawHeader(Graphics g, int contentWidth)
{
using (var brush = new SolidBrush(Theme.Background))
g.FillRectangle(brush, 0, 0, Width, HeaderHeight);
using (var pen = new Pen(Theme.Border))
g.DrawLine(pen, 0, HeaderHeight - 1, Width, HeaderHeight - 1);
int x = 8;
foreach (var column in _columns)
{
if (x > contentWidth) break;
var bounds = new Rectangle(x, 0, Math.Min(column.Width - 8, contentWidth - x), HeaderHeight);
TextRenderer.DrawText(g, column.Title, Theme.SmallBold, bounds, Theme.TextMuted,
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
x += column.Width;
}
}
private void DrawRow(Graphics g, FrameRecord record, int index, int y, int contentWidth)
{
bool selected = index == _selectedIndex;
bool hovered = _hoverRow == index - _scroll;
if (selected)
{
using var brush = new SolidBrush(Color.FromArgb(58, Theme.Accent));
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
using var edge = new SolidBrush(Theme.Accent);
g.FillRectangle(edge, 0, y, 2, RowHeight);
}
else if (hovered)
{
using var brush = new SolidBrush(Theme.SurfaceAlt);
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
}
else if ((index & 1) == 1)
{
using var brush = new SolidBrush(Color.FromArgb(0x1F, 0x22, 0x29));
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
}
int x = 8;
for (int c = 0; c < _columns.Length; c++)
{
var column = _columns[c];
if (x > contentWidth) break;
Color color = c switch
{
0 => Theme.TextFaint,
1 => selected ? Theme.Text : Theme.Text,
10 => GainColor(record),
_ => Theme.TextMuted,
};
// Un intervallo anomalo va segnalato dove si legge: sulla colonna Δt.
if (c == 3 && record.IsCadenceAnomaly) color = Theme.Warning;
if (c == 2 && record.Metadata.CaptureSource == TimestampSource.FileSystem) color = Theme.Warning;
var bounds = new Rectangle(x, y, Math.Min(column.Width - 8, Math.Max(0, contentWidth - x)), RowHeight);
TextRenderer.DrawText(g, column.Value(record), c == 1 ? Theme.Body : Theme.Small, bounds, color,
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
x += column.Width;
}
}
private static Color GainColor(FrameRecord record)
{
if (!record.LuminanceAnalyzed) return Theme.TextFaint;
double stops = Math.Abs(record.GainStops);
if (stops < 0.02) return Theme.TextMuted;
return record.GainStops > 0 ? Theme.Success : Theme.Danger;
}
private void DrawScrollBar(Graphics g)
{
if (RowCount <= VisibleRows) return;
using (var track = new SolidBrush(Theme.Background))
g.FillRectangle(track, Width - ScrollWidth, HeaderHeight, ScrollWidth, Height - HeaderHeight);
var thumb = ThumbBounds();
Theme.FillRounded(g, thumb, 3f, _draggingScroll ? Theme.Accent : Theme.BorderStrong);
}
}
+650
View File
@@ -0,0 +1,650 @@
using System.Drawing.Drawing2D;
using Titano.Analysis;
using Titano.Core;
namespace Titano.UI;
/// <summary>
/// Sistema di rendering vettoriale per la curva di esposizione.
///
/// Sovrappone la luminanza misurata (lo sfarfallio, in ambra) alla curva target calcolata
/// dal deflicker (in blu), riempiendo lo scarto fra le due — che è esattamente la correzione
/// applicata. Una corsia inferiore mostra il guadagno in stop per fotogramma e le anomalie
/// di cadenza dell'intervallometro. Tutto è disegnato con primitive vettoriali: zoom e
/// spostamento non degradano la resa.
/// </summary>
internal sealed class LuminanceChart : Control
{
private TimelapseSequence? _sequence;
private DeflickerCurve? _curve;
private double _viewStart;
private double _viewEnd = 1;
private int _hoverIndex = -1;
private int _selectedIndex = -1;
private Point _mousePosition;
private bool _panning;
private double _panAnchor;
private int _panOriginX;
private const int GutterLeft = 62;
private const int GutterBottom = 22;
private const int GutterTop = 26;
private const int GainLaneHeight = 62;
public event EventHandler? SelectionChanged;
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectedIndex == value) return;
_selectedIndex = value;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
public LuminanceChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public void SetData(TimelapseSequence? sequence, DeflickerCurve? curve)
{
_sequence = sequence;
_curve = curve;
_viewStart = 0;
_viewEnd = Math.Max(1, (sequence?.Count ?? 1) - 1);
_hoverIndex = -1;
Invalidate();
}
/// <summary>Aggiorna la sola curva conservando zoom e selezione (cursori del deflicker).</summary>
public void UpdateCurve(DeflickerCurve? curve)
{
_curve = curve;
Invalidate();
}
private Rectangle PlotArea
{
get
{
int height = Math.Max(40, Height - GutterTop - GutterBottom - GainLaneHeight);
return new Rectangle(GutterLeft, GutterTop, Math.Max(10, Width - GutterLeft - 12), height);
}
}
private Rectangle GainArea
{
get
{
var plot = PlotArea;
return new Rectangle(plot.Left, plot.Bottom + 6, plot.Width, GainLaneHeight - 12);
}
}
// ------------------------------------------------------------------ interazione
protected override void OnMouseMove(MouseEventArgs e)
{
_mousePosition = e.Location;
if (_panning)
{
var plot = PlotArea;
double span = _viewEnd - _viewStart;
double delta = (e.X - _panOriginX) / (double)Math.Max(1, plot.Width) * span;
double start = _panAnchor - delta;
int max = Math.Max(0, (_sequence?.Count ?? 1) - 1);
start = Math.Clamp(start, 0, Math.Max(0, max - span));
_viewStart = start;
_viewEnd = start + span;
Invalidate();
return;
}
int index = IndexAt(e.X);
if (index != _hoverIndex) { _hoverIndex = index; Invalidate(); }
else if (index >= 0) Invalidate();
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hoverIndex = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.Button == MouseButtons.Left)
{
int index = IndexAt(e.X);
if (index >= 0) SelectedIndex = index;
}
else if (e.Button == MouseButtons.Right)
{
_panning = true;
_panAnchor = _viewStart;
_panOriginX = e.X;
Cursor = Cursors.SizeWE;
}
base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_panning = false;
Cursor = Cursors.Default;
base.OnMouseUp(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (_sequence is not { Count: > 1 }) return;
int last = _sequence.Count - 1;
var plot = PlotArea;
double fraction = Math.Clamp((e.X - plot.Left) / (double)Math.Max(1, plot.Width), 0, 1);
double focus = _viewStart + fraction * (_viewEnd - _viewStart);
double factor = e.Delta > 0 ? 0.8 : 1.25;
double span = Math.Clamp((_viewEnd - _viewStart) * factor, 4, last);
_viewStart = Math.Clamp(focus - fraction * span, 0, Math.Max(0, last - span));
_viewEnd = _viewStart + span;
Invalidate();
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
_viewStart = 0;
_viewEnd = Math.Max(1, (_sequence?.Count ?? 1) - 1);
Invalidate();
base.OnMouseDoubleClick(e);
}
private int IndexAt(int x)
{
if (_sequence is not { Count: > 0 }) return -1;
var plot = PlotArea;
if (x < plot.Left - 4 || x > plot.Right + 4) return -1;
double fraction = (x - plot.Left) / (double)Math.Max(1, plot.Width);
double position = _viewStart + fraction * (_viewEnd - _viewStart);
return Math.Clamp((int)Math.Round(position), 0, _sequence.Count - 1);
}
private float XFor(double index, Rectangle plot)
{
double span = Math.Max(1e-6, _viewEnd - _viewStart);
return plot.Left + (float)((index - _viewStart) / span * plot.Width);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = PlotArea;
if (_sequence is not { Count: > 1 } || _curve is null || _curve.Count < 2)
{
DrawEmptyState(g);
return;
}
// Estensione verticale: unione delle due curve con un margine costante.
double minValue = double.MaxValue, maxValue = double.MinValue;
int from = Math.Max(0, (int)Math.Floor(_viewStart));
int to = Math.Min(_curve.Count - 1, (int)Math.Ceiling(_viewEnd));
for (int i = from; i <= to; i++)
{
minValue = Math.Min(minValue, Math.Min(_curve.Measured[i], _curve.Target[i]));
maxValue = Math.Max(maxValue, Math.Max(_curve.Measured[i], _curve.Target[i]));
// Le curve di regione stanno per definizione fuori da quella globale: senza
// includerle nella scala uscirebbero dal riquadro.
if (_curve.MeasuredHigh is { } high) maxValue = Math.Max(maxValue, high[i]);
if (_curve.MeasuredLow is { } low) minValue = Math.Min(minValue, low[i]);
}
if (minValue > maxValue) { minValue = -4; maxValue = -1; }
double padding = Math.Max(0.12, (maxValue - minValue) * 0.15);
minValue -= padding;
maxValue += padding;
DrawGrid(g, plot, minValue, maxValue);
DrawCadenceMarkers(g, plot);
DrawExposureSteps(g, plot);
DrawCorrectionBand(g, plot, from, to, minValue, maxValue);
DrawRegionCurves(g, plot, from, to, minValue, maxValue);
DrawCurve(g, plot, _curve.Measured, from, to, minValue, maxValue, Theme.Measured, 1.4f);
DrawCurve(g, plot, _curve.Target, from, to, minValue, maxValue, Theme.Accent, 2.1f);
DrawGainLane(g, from, to);
DrawSelection(g, plot);
DrawLegend(g);
DrawHover(g, plot, minValue, maxValue);
}
private void DrawEmptyState(Graphics g)
{
string message = _sequence is null
? "Nessuna sequenza caricata"
: "Esegui l'analisi per visualizzare la curva di esposizione";
TextRenderer.DrawText(g, message, Theme.Body, new Rectangle(0, 0, Width, Height),
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
private float YFor(double value, Rectangle plot, double min, double max)
{
double span = Math.Max(1e-6, max - min);
return plot.Bottom - (float)((value - min) / span * plot.Height);
}
private void DrawGrid(Graphics g, Rectangle plot, double min, double max)
{
using var gridPen = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
using var axisPen = new Pen(Theme.BorderStrong);
// Linee orizzontali a passo di stop intero (o mezzo stop se l'intervallo è stretto).
double step = (max - min) > 4 ? 1.0 : (max - min) > 1.6 ? 0.5 : 0.25;
double first = Math.Ceiling(min / step) * step;
for (double value = first; value <= max; value += step)
{
float y = YFor(value, plot, min, max);
g.DrawLine(gridPen, plot.Left, y, plot.Right, y);
TextRenderer.DrawText(g, value.ToString("0.##") + " EV", Theme.Small,
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
g.DrawLine(axisPen, plot.Left, plot.Top, plot.Left, plot.Bottom);
g.DrawLine(axisPen, plot.Left, plot.Bottom, plot.Right, plot.Bottom);
// Etichette dei fotogrammi lungo l'asse orizzontale.
int count = _sequence!.Count;
double span = _viewEnd - _viewStart;
double labelStep = NiceStep(span / 8.0);
double firstLabel = Math.Ceiling(_viewStart / labelStep) * labelStep;
for (double index = firstLabel; index <= _viewEnd; index += labelStep)
{
float x = XFor(index, plot);
if (x < plot.Left - 1 || x > plot.Right + 1) continue;
g.DrawLine(gridPen, x, plot.Top, x, plot.Bottom);
int frame = Math.Clamp((int)Math.Round(index), 0, count - 1);
TextRenderer.DrawText(g, (frame + 1).ToString(), Theme.Small,
new Rectangle((int)x - 30, Height - GutterBottom + 2, 60, 16),
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
private static double NiceStep(double raw)
{
if (raw <= 1) return 1;
double magnitude = Math.Pow(10, Math.Floor(Math.Log10(raw)));
double normalized = raw / magnitude;
double nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
/// <summary>Tacche verticali sugli intervalli che si discostano dalla cadenza nominale.</summary>
private void DrawCadenceMarkers(Graphics g, Rectangle plot)
{
if (_sequence is null) return;
using var brush = new SolidBrush(Color.FromArgb(60, Theme.Warning));
for (int i = Math.Max(0, (int)_viewStart); i <= Math.Min(_sequence.Count - 1, (int)Math.Ceiling(_viewEnd)); i++)
{
if (!_sequence.Frames[i].IsCadenceAnomaly) continue;
float x = XFor(i, plot);
float next = XFor(i + 1, plot);
g.FillRectangle(brush, x, plot.Top, Math.Max(1.5f, next - x), plot.Height);
}
}
/// <summary>
/// Segnala i fotogrammi in cui la macchina ha cambiato tempo, diaframma o sensibilità.
/// Sono i punti in cui la curva misurata fa un gradino e quella obiettivo no: vederli
/// spiega a colpo d'occhio da dove viene la correzione più vistosa della sequenza.
/// </summary>
private void DrawExposureSteps(Graphics g, Rectangle plot)
{
if (_sequence is null) return;
int from = Math.Max(0, (int)_viewStart);
int to = Math.Min(_sequence.Count - 1, (int)Math.Ceiling(_viewEnd));
using var pen = new Pen(Color.FromArgb(110, Theme.Success), 1f) { DashStyle = DashStyle.Dash };
using var marker = new SolidBrush(Theme.Success);
for (int i = from; i <= to; i++)
{
if (!_sequence.Frames[i].IsExposureStep) continue;
float x = XFor(i, plot);
if (x < plot.Left || x > plot.Right) continue;
g.DrawLine(pen, x, plot.Top, x, plot.Bottom);
g.FillPolygon(marker,
[
new PointF(x, plot.Top + 7),
new PointF(x - 4.5f, plot.Top),
new PointF(x + 4.5f, plot.Top),
]);
}
}
/// <summary>
/// Curve delle due regioni, quando il deflicker le distingue. Sono disegnate sottili e
/// smorzate: raccontano perché la curva globale si muove come si muove, senza rubarle
/// la scena.
/// </summary>
private void DrawRegionCurves(Graphics g, Rectangle plot, int from, int to, double min, double max)
{
if (_curve is not { HasRegions: true }) return;
var clip = g.Clip;
g.SetClip(Rectangle.Inflate(plot, 2, 2));
DrawThin(_curve.MeasuredHigh, Color.FromArgb(110, Theme.RegionHigh));
DrawThin(_curve.TargetHigh, Color.FromArgb(200, Theme.RegionHigh));
DrawThin(_curve.MeasuredLow, Color.FromArgb(110, Theme.RegionLow));
DrawThin(_curve.TargetLow, Color.FromArgb(200, Theme.RegionLow));
g.Clip = clip;
void DrawThin(double[]? values, Color color)
{
if (values is null || to - from < 1) return;
var points = new PointF[to - from + 1];
for (int i = 0; i < points.Length; i++)
points[i] = new PointF(XFor(from + i, plot), YFor(values[from + i], plot, min, max));
using var pen = new Pen(color, 1.2f) { LineJoin = LineJoin.Round };
g.DrawLines(pen, points);
}
}
/// <summary>Area fra misurato e target: è la correzione che verrà applicata.</summary>
private void DrawCorrectionBand(Graphics g, Rectangle plot, int from, int to, double min, double max)
{
if (to - from < 1) return;
var points = new List<PointF>((to - from + 1) * 2);
for (int i = from; i <= to; i++) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Measured[i], plot, min, max)));
for (int i = to; i >= from; i--) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Target[i], plot, min, max)));
using var path = new GraphicsPath();
path.AddPolygon(points.ToArray());
using var brush = new SolidBrush(Color.FromArgb(38, Theme.Accent));
var clip = g.Clip;
g.SetClip(plot);
g.FillPath(brush, path);
g.Clip = clip;
}
private void DrawCurve(Graphics g, Rectangle plot, double[] values, int from, int to,
double min, double max, Color color, float width)
{
if (to - from < 1) return;
var clip = g.Clip;
g.SetClip(Rectangle.Inflate(plot, 2, 2));
using var pen = new Pen(color, width)
{
LineJoin = LineJoin.Round,
StartCap = LineCap.Round,
EndCap = LineCap.Round,
};
int visible = to - from + 1;
if (visible > plot.Width * 2)
{
// Più campioni che pixel: si traccia l'inviluppo min/max per colonna,
// preservando l'ampiezza reale dello sfarfallio invece di alias arbitrari.
DrawEnvelope(g, plot, values, from, to, min, max, color);
}
else
{
var points = new PointF[visible];
for (int i = 0; i < visible; i++)
{
points[i] = new PointF(XFor(from + i, plot), YFor(values[from + i], plot, min, max));
}
if (points.Length >= 2) g.DrawLines(pen, points);
}
// Punti singoli quando lo zoom è sufficiente a distinguerli.
if (visible <= 90)
{
using var dot = new SolidBrush(color);
for (int i = from; i <= to; i++)
{
float x = XFor(i, plot);
float y = YFor(values[i], plot, min, max);
g.FillEllipse(dot, x - 2f, y - 2f, 4f, 4f);
}
}
g.Clip = clip;
}
private void DrawEnvelope(Graphics g, Rectangle plot, double[] values, int from, int to,
double min, double max, Color color)
{
using var pen = new Pen(Color.FromArgb(200, color), 1f);
double perPixel = (to - from + 1) / (double)plot.Width;
for (int px = 0; px < plot.Width; px++)
{
int start = from + (int)(px * perPixel);
int end = Math.Min(to, from + (int)((px + 1) * perPixel));
if (start > end) continue;
double lo = double.MaxValue, hi = double.MinValue;
for (int i = start; i <= end; i++)
{
lo = Math.Min(lo, values[i]);
hi = Math.Max(hi, values[i]);
}
float x = plot.Left + px;
g.DrawLine(pen, x, YFor(hi, plot, min, max), x, YFor(lo, plot, min, max) + 0.5f);
}
}
/// <summary>Corsia inferiore: guadagno per fotogramma in stop, con lo zero al centro.</summary>
private void DrawGainLane(Graphics g, int from, int to)
{
var lane = GainArea;
if (lane.Height < 12) return;
double peak = 0.05;
for (int i = from; i <= to; i++) peak = Math.Max(peak, Math.Abs(_curve!.GainStops[i]));
peak = Math.Max(peak, 0.05);
float zero = lane.Top + lane.Height / 2f;
using (var basePen = new Pen(Theme.Border)) g.DrawLine(basePen, lane.Left, zero, lane.Right, zero);
TextRenderer.DrawText(g, "guadagno", Theme.Small, new Rectangle(0, (int)zero - 8, GutterLeft - 6, 16),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, $"fondoscala ±{peak:0.##} EV", Theme.Small,
new Rectangle(Width - 172, 4, 160, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
double perColumn = (to - from + 1) / (double)Math.Max(1, lane.Width);
float barWidth = Math.Max(1f, (float)(lane.Width / (double)Math.Max(1, to - from + 1)) - 1f);
using var positive = new SolidBrush(Color.FromArgb(190, Theme.Success));
using var negative = new SolidBrush(Color.FromArgb(190, Theme.Danger));
if (perColumn <= 1.0)
{
for (int i = from; i <= to; i++)
{
double gain = _curve!.GainStops[i];
float x = XFor(i, lane) - barWidth / 2f;
float height = (float)(Math.Abs(gain) / peak * (lane.Height / 2f - 2));
if (height < 0.6f) continue;
var rect = gain >= 0
? new RectangleF(x, zero - height, barWidth, height)
: new RectangleF(x, zero, barWidth, height);
g.FillRectangle(gain >= 0 ? positive : negative, rect);
}
}
else
{
for (int px = 0; px < lane.Width; px++)
{
int start = from + (int)(px * perColumn);
int end = Math.Min(to, from + (int)((px + 1) * perColumn));
if (start > end) continue;
double lo = 0, hi = 0;
for (int i = start; i <= end; i++)
{
lo = Math.Min(lo, _curve!.GainStops[i]);
hi = Math.Max(hi, _curve!.GainStops[i]);
}
float x = lane.Left + px;
float top = zero - (float)(hi / peak * (lane.Height / 2f - 2));
float bottom = zero - (float)(lo / peak * (lane.Height / 2f - 2));
g.FillRectangle(hi >= -lo ? positive : negative, x, top, 1f, Math.Max(1f, bottom - top));
}
}
}
private void DrawSelection(Graphics g, Rectangle plot)
{
if (_selectedIndex < 0 || _sequence is null || _selectedIndex >= _sequence.Count) return;
float x = XFor(_selectedIndex, plot);
if (x < plot.Left || x > plot.Right) return;
using var pen = new Pen(Color.FromArgb(150, Theme.Text), 1f) { DashStyle = DashStyle.Dash };
g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
}
private void DrawLegend(Graphics g)
{
var entries = new List<(Color Color, string Label, bool Band)>
{
(Theme.Measured, "luminanza misurata", false),
(Theme.Accent, "curva target", false),
};
if (_curve is { HasRegions: true })
{
entries.Add((Theme.RegionHigh, "cielo", false));
entries.Add((Theme.RegionLow, "paesaggio", false));
}
if (_sequence is not null && HasExposureSteps()) entries.Add((Theme.Success, "cambio impostazioni", false));
entries.Add((Theme.Warning, "cadenza anomala", true));
int x = GutterLeft;
foreach (var (color, label, band) in entries)
{
var size = TextRenderer.MeasureText(g, label, Theme.Small);
// Oltre il bordo non si disegna: meglio una legenda corta che una tagliata.
if (x + 19 + size.Width > Width - 180) break;
using (var brush = new SolidBrush(band ? Color.FromArgb(90, color) : color))
g.FillRectangle(brush, x, band ? 5 : 10, 14, band ? 12 : 3);
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 19, 4, size.Width + 4, 16),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
x += 19 + size.Width + 16;
}
}
private bool HasExposureSteps()
{
if (_sequence is null) return false;
foreach (var frame in _sequence.Frames)
{
if (frame.IsExposureStep) return true;
}
return false;
}
private void DrawHover(Graphics g, Rectangle plot, double min, double max)
{
if (_hoverIndex < 0 || _sequence is null || _hoverIndex >= _sequence.Count || _curve is null) return;
float x = XFor(_hoverIndex, plot);
if (x < plot.Left - 2 || x > plot.Right + 2) return;
using (var pen = new Pen(Color.FromArgb(90, Theme.Text), 1f)) g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
float measuredY = YFor(_curve.Measured[_hoverIndex], plot, min, max);
float targetY = YFor(_curve.Target[_hoverIndex], plot, min, max);
using (var brush = new SolidBrush(Theme.Measured)) g.FillEllipse(brush, x - 3.5f, measuredY - 3.5f, 7, 7);
using (var brush = new SolidBrush(Theme.Accent)) g.FillEllipse(brush, x - 3.5f, targetY - 3.5f, 7, 7);
var record = _sequence.Frames[_hoverIndex];
var detail = new List<string>
{
$"#{_hoverIndex + 1} {record.FileName}",
$"misurata {_curve.Measured[_hoverIndex]:0.00} EV",
$"target {_curve.Target[_hoverIndex]:0.00} EV",
$"guadagno {_curve.GainStops[_hoverIndex]:+0.00;-0.00;0.00} EV",
$"intervallo {record.CadenceText} otturatore {record.ShutterAngle:0.#}°",
};
if (_curve.HasRegions && _curve.MeasuredHigh is { } high && _curve.MeasuredLow is { } low)
detail.Add($"cielo {high[_hoverIndex]:0.00} EV paesaggio {low[_hoverIndex]:0.00} EV");
if (!double.IsNaN(record.TemperatureKelvin))
detail.Add($"temperatura {Analysis.ColorScience.Describe(record.TemperatureKelvin)}");
if (record.StabilizationShift > 0.01)
detail.Add($"stabilizzazione {record.StabilizationShift:0.0} px, " +
$"{record.StabilizationRotation:0.00}°");
if (record.IsExposureStep) detail.Add("qui la macchina ha cambiato impostazioni");
string[] lines = [.. detail];
int widthNeeded = 0;
foreach (string line in lines)
widthNeeded = Math.Max(widthNeeded, TextRenderer.MeasureText(g, line, Theme.Small).Width);
int boxWidth = widthNeeded + 18;
int boxHeight = lines.Length * 15 + 12;
int boxX = (int)(x + 14);
if (boxX + boxWidth > Width - 6) boxX = (int)(x - 14 - boxWidth);
int boxY = Math.Clamp(_mousePosition.Y - boxHeight / 2, plot.Top, Math.Max(plot.Top, Height - boxHeight - 4));
var box = new RectangleF(boxX, boxY, boxWidth, boxHeight);
Theme.FillAndStroke(g, box, 6f, Color.FromArgb(242, Theme.Background), Theme.BorderStrong);
for (int i = 0; i < lines.Length; i++)
{
TextRenderer.DrawText(g, lines[i], i == 0 ? Theme.SmallBold : Theme.Small,
new Rectangle(boxX + 9, boxY + 6 + i * 15, boxWidth - 18, 15),
i == 0 ? Theme.Text : Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
}
+705
View File
@@ -0,0 +1,705 @@
using Titano.Core;
using Titano.Metadata;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>Finestra principale: collega i controlli disegnati a mano al motore di elaborazione.</summary>
internal sealed class MainForm : Form
{
private readonly TitanoProject _project = new();
private readonly LuminanceChart _chart;
private readonly FrameTable _table;
private readonly PreviewPanel _preview;
private SettingsPanel _settings;
private readonly DarkProgressBar _progress;
private readonly Label _status;
private readonly Label _summary;
private readonly DarkButton _addFilesButton;
private readonly DarkButton _addFolderButton;
private readonly DarkButton _clearButton;
private readonly DarkButton _analyzeButton;
private readonly DarkButton _exportButton;
private readonly DarkButton _cancelButton;
private CancellationTokenSource? _operation;
private bool _busy;
public MainForm()
{
Text = "Titano — time-lapse";
MinimumSize = new Size(1180, 720);
Size = new Size(1560, 950);
StartPosition = FormStartPosition.CenterScreen;
BackColor = Theme.Background;
ForeColor = Theme.Text;
Font = Theme.Body;
AllowDrop = true;
DoubleBuffered = true;
_chart = new LuminanceChart { Dock = DockStyle.Fill };
_table = new FrameTable { Dock = DockStyle.Fill };
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
_progress = new DarkProgressBar { Dock = DockStyle.Fill };
_status = new Label
{
Dock = DockStyle.Fill,
ForeColor = Theme.TextMuted,
Font = Theme.Small,
TextAlign = ContentAlignment.MiddleLeft,
Text = "Pronto. Trascina qui una sequenza di immagini per iniziare.",
};
_summary = new Label
{
Dock = DockStyle.Fill,
ForeColor = Theme.TextFaint,
Font = Theme.Small,
TextAlign = ContentAlignment.MiddleRight,
Text = string.Empty,
};
_addFilesButton = new DarkButton { Text = "Aggiungi file…", Width = 130 };
_addFolderButton = new DarkButton { Text = "Aggiungi cartella…", Width = 150 };
_clearButton = new DarkButton { Text = "Svuota", Width = 84 };
_analyzeButton = new DarkButton { Text = "Analizza sequenza", Width = 160 };
_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();
}
/// <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)
{
base.OnHandleCreated(e);
Theme.ApplyDarkTitleBar(this);
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
// La cornice viene ridisegnata dal sistema alla comparsa: l'attributo va riconfermato.
Theme.ApplyDarkTitleBar(this);
}
// ------------------------------------------------------------------ struttura
private void BuildLayout()
{
var toolbar = BuildToolbar();
var statusBar = BuildStatusBar();
var settingsHost = new Panel
{
Dock = DockStyle.Right,
Width = 372,
BackColor = Theme.Surface,
Padding = new Padding(1, 0, 0, 0),
};
settingsHost.Controls.Add(_settings);
settingsHost.Paint += (_, e) =>
{
using var pen = new Pen(Theme.Border);
e.Graphics.DrawLine(pen, 0, 0, 0, settingsHost.Height);
};
var center = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
var tableHost = Card(_table, "Fotogrammi", DockStyle.Fill);
var chartHost = Card(_chart, "Curva di esposizione", DockStyle.Top, 268);
var previewHost = Card(_preview, "Anteprima", DockStyle.Top, 300);
var chartSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
var previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
center.Controls.Add(tableHost);
center.Controls.Add(chartSplitter);
center.Controls.Add(chartHost);
center.Controls.Add(previewSplitter);
center.Controls.Add(previewHost);
Controls.Add(center);
Controls.Add(settingsHost);
Controls.Add(statusBar);
Controls.Add(toolbar);
}
/// <summary>Riquadro con intestazione: unità visiva ricorrente dell'interfaccia.</summary>
private static Panel Card(Control content, string title, DockStyle dock, int height = 0)
{
var host = new Panel
{
Dock = dock,
BackColor = Theme.Surface,
Padding = new Padding(1, 30, 1, 1),
Margin = new Padding(0),
};
if (height > 0) host.Height = height;
host.Controls.Add(content);
host.Paint += (_, e) =>
{
var g = e.Graphics;
Theme.HighQuality(g);
using (var brush = new SolidBrush(Theme.Background))
g.FillRectangle(brush, 0, 0, host.Width, 30);
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(12, 0, host.Width - 24, 30),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
using var pen = new Pen(Theme.Border);
g.DrawRectangle(pen, 0, 0, host.Width - 1, host.Height - 1);
g.DrawLine(pen, 0, 29, host.Width, 29);
};
return host;
}
private Panel BuildToolbar()
{
var bar = new Panel { Dock = DockStyle.Top, Height = 58, BackColor = Theme.Background };
var title = new Label
{
Text = "TITANO",
Font = Theme.Title,
ForeColor = Theme.Text,
AutoSize = false,
Bounds = new Rectangle(16, 12, 110, 32),
TextAlign = ContentAlignment.MiddleLeft,
};
var subtitle = new Label
{
Text = "elaborazione time-lapse",
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Bounds = new Rectangle(112, 20, 160, 18),
TextAlign = ContentAlignment.MiddleLeft,
};
int x = 288;
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
{
button.Bounds = new Rectangle(x, 13, button.Width, 32);
bar.Controls.Add(button);
x += button.Width + 8;
}
_analyzeButton.Bounds = new Rectangle(x + 16, 13, _analyzeButton.Width, 32);
bar.Controls.Add(_analyzeButton);
x += _analyzeButton.Width + 24;
_exportButton.Bounds = new Rectangle(x, 13, _exportButton.Width, 32);
bar.Controls.Add(_exportButton);
bar.Controls.Add(title);
bar.Controls.Add(subtitle);
bar.Paint += (_, e) =>
{
using var pen = new Pen(Theme.Border);
e.Graphics.DrawLine(pen, 0, bar.Height - 1, bar.Width, bar.Height - 1);
};
return bar;
}
private Panel BuildStatusBar()
{
var bar = new Panel { Dock = DockStyle.Bottom, Height = 52, BackColor = Theme.Background };
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 3,
RowCount = 2,
BackColor = Theme.Background,
Padding = new Padding(16, 6, 16, 6),
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 60));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 104));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
layout.Controls.Add(_status, 0, 0);
layout.Controls.Add(_summary, 1, 0);
layout.Controls.Add(_progress, 0, 1);
layout.SetColumnSpan(_progress, 2);
_cancelButton.Dock = DockStyle.Fill;
_cancelButton.Margin = new Padding(8, 4, 0, 4);
layout.Controls.Add(_cancelButton, 2, 0);
layout.SetRowSpan(_cancelButton, 2);
bar.Controls.Add(layout);
bar.Paint += (_, e) =>
{
using var pen = new Pen(Theme.Border);
e.Graphics.DrawLine(pen, 0, 0, bar.Width, 0);
};
return bar;
}
// ------------------------------------------------------------------ eventi
private void WireEvents()
{
_addFilesButton.Click += (_, _) => AddFiles();
_addFolderButton.Click += (_, _) => AddFolder();
_clearButton.Click += (_, _) => ClearSequence();
_analyzeButton.Click += async (_, _) => await AnalyzeAsync();
_exportButton.Click += async (_, _) => await ExportAsync();
_cancelButton.Click += (_, _) => _operation?.Cancel();
_chart.SelectionChanged += (_, _) =>
{
if (_chart.SelectedIndex >= 0) _table.SelectedIndex = _chart.SelectedIndex;
ShowPreview(_chart.SelectedIndex);
};
_table.SelectionChanged += (_, _) =>
{
if (_table.SelectedIndex >= 0) _chart.SelectedIndex = _table.SelectedIndex;
ShowPreview(_table.SelectedIndex);
};
WireSettingsEvents();
DragEnter += (_, e) =>
{
if (e.Data?.GetDataPresent(DataFormats.FileDrop) == true) e.Effect = DragDropEffects.Copy;
};
DragDrop += (_, e) =>
{
if (e.Data?.GetData(DataFormats.FileDrop) is string[] items) _ = LoadAsync(ExpandPaths(items));
};
}
private void WireSettingsEvents()
{
_settings.DeflickerChanged += (_, _) => RecomputeCurve();
_settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis();
_settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex);
_settings.BrowseOutputRequested += (_, _) => BrowseOutput();
}
// ------------------------------------------------------------------ comandi
private void AddFiles()
{
using var dialog = new OpenFileDialog
{
Multiselect = true,
Title = "Seleziona i fotogrammi della sequenza",
Filter = "Immagini (" + string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + ")|" +
string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + "|Tutti i file|*.*",
};
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(dialog.FileNames);
}
private void AddFolder()
{
using var dialog = new FolderBrowserDialog { Description = "Seleziona la cartella della sequenza" };
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(ExpandPaths([dialog.SelectedPath]));
}
private static string[] ExpandPaths(IEnumerable<string> items)
{
var files = new List<string>();
foreach (string item in items)
{
if (Directory.Exists(item))
{
files.AddRange(Directory.EnumerateFiles(item).Where(MetadataReader.IsSupported));
}
else if (File.Exists(item) && MetadataReader.IsSupported(item))
{
files.Add(item);
}
}
return [.. files];
}
private async Task LoadAsync(IReadOnlyList<string> paths)
{
if (_busy || paths.Count == 0)
{
if (paths.Count == 0) SetStatus("Nessun file d'immagine riconosciuto fra quelli indicati.");
return;
}
BeginOperation("Lettura dei metadati…");
try
{
var progress = new Progress<PipelineProgress>(ReportProgress);
var sequence = await RenderPipeline.IngestAsync(paths, _project.General.CadenceTolerance,
progress, _operation!.Token);
_project.Sequence = sequence;
_project.InvalidateAnalysis();
_project.DetectOrientation();
_settings.ShowDetectedOrientation(_project.Orientation);
_settings.ShowSequenceGeometry();
_settings.ShowAnalysis();
_table.SetSequence(sequence);
_chart.SetData(sequence, null);
SuggestOutputPath(sequence);
UpdateSummary();
ShowPreview(0);
SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " +
$"{sequence.CadenceAnomalies} intervalli anomali. " +
$"Orientamento {_project.Orientation.Description} ({_project.Orientation.Reason})." +
DescribeResolutionClamp());
}
catch (OperationCanceledException)
{
SetStatus("Caricamento annullato.");
}
catch (Exception ex)
{
SetStatus("Caricamento non riuscito: " + ex.Message);
}
finally
{
EndOperation();
}
}
private void ClearSequence()
{
if (_busy) return;
_project.Sequence = null;
_project.InvalidateAnalysis();
_settings.ShowAnalysis();
_table.SetSequence(null);
_chart.SetData(null, null);
_preview.Clear();
_summary.Text = string.Empty;
SetStatus("Sequenza svuotata.");
UpdateCommandState();
}
private async Task AnalyzeAsync()
{
if (_busy || !_project.HasSequence) return;
BeginOperation("Analisi della luminanza…");
try
{
var pipeline = new RenderPipeline(_project);
var progress = new Progress<PipelineProgress>(ReportProgress);
await pipeline.AnalyzeAsync(progress, _operation!.Token);
_chart.SetData(_project.Sequence, _project.Curve);
_table.Refresh(_project.Sequence);
_settings.ShowAnalysis();
UpdateSummary();
ShowPreview(_table.SelectedIndex);
var curve = _project.Curve!;
double before = Analysis.DeflickerCurve.FlickerIndex(curve.Measured);
var corrected = new double[curve.Count];
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
double after = Analysis.DeflickerCurve.FlickerIndex(corrected);
SetStatus($"Analisi completata. Sfarfallio {before:0.000} EV → {after:0.000} EV " +
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)." +
DescribeAdvancedAnalysis());
}
catch (OperationCanceledException)
{
SetStatus("Analisi annullata.");
}
catch (Exception ex)
{
SetStatus("Analisi non riuscita: " + ex.Message);
}
finally
{
EndOperation();
}
}
private async Task ExportAsync()
{
if (_busy || !_project.HasSequence) return;
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
{
BrowseOutput();
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath)) return;
}
BeginOperation("Elaborazione e codifica…");
try
{
var pipeline = new RenderPipeline(_project);
var progress = new Progress<PipelineProgress>(ReportProgress);
var result = await pipeline.RenderAsync(progress, _operation!.Token);
_table.Refresh(_project.Sequence);
SetStatus($"Esportazione completata: {result.EncodedFrames} fotogrammi, " +
$"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.Elapsed.TotalSeconds:0.0} s " +
$"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}. " +
char.ToUpper(result.PlanDescription[0]) + result.PlanDescription[1..] + "." +
(result.UsedDisk
? $" {result.SpilledFrames} fotogrammi sono passati dal parcheggio su disco " +
$"({result.SpillBytes / (1024.0 * 1024.0):0} MiB)."
: string.Empty));
}
catch (OperationCanceledException)
{
SetStatus("Esportazione interrotta. Il file contiene i fotogrammi già codificati.");
}
catch (Exception ex)
{
SetStatus("Esportazione non riuscita: " + ex.Message);
}
finally
{
EndOperation();
}
}
private void BrowseOutput()
{
using var dialog = new SaveFileDialog
{
Title = "Destinazione del video",
Filter = "Video MP4|*.mp4",
DefaultExt = "mp4",
FileName = Path.GetFileName(_project.Export.OutputPath) is { Length: > 0 } name ? name : "timelapse.mp4",
};
if (dialog.ShowDialog(this) != DialogResult.OK) return;
_project.Export.OutputPath = dialog.FileName;
_settings.OutputPathBox.Text = dialog.FileName;
}
private void SuggestOutputPath(TimelapseSequence sequence)
{
if (!string.IsNullOrWhiteSpace(_project.Export.OutputPath) || sequence.Count == 0) return;
string? directory = Path.GetDirectoryName(sequence.Frames[0].FilePath);
if (directory is null) return;
string suggestion = Path.Combine(directory, "timelapse.mp4");
_project.Export.OutputPath = suggestion;
_settings.OutputPathBox.Text = suggestion;
}
// ------------------------------------------------------------------ aggiornamenti
private void RecomputeCurve()
{
if (!_project.IsAnalyzed) return;
new RenderPipeline(_project).RecomputeCurve();
_chart.UpdateCurve(_project.Curve);
_table.Refresh(_project.Sequence);
_settings.ShowAnalysis();
UpdateSummary();
ShowPreview(_table.SelectedIndex);
}
private void InvalidateAnalysis()
{
if (_project.Sequence is { } sequence) sequence.RecomputeTiming(_project.General.CadenceTolerance);
_project.InvalidateAnalysis();
_chart.SetData(_project.Sequence, null);
_table.Refresh(_project.Sequence);
_settings.ShowSequenceGeometry();
_settings.ShowAnalysis();
UpdateSummary();
UpdateCommandState();
}
/// <summary>
/// Cenno compatto a quello che i moduli avanzati hanno trovato. Il dettaglio vive nella
/// sezione "Esito dell'analisi" del pannello: la barra di stato ha una riga sola, e
/// riempirla di numeri significa troncarli tutti.
/// </summary>
private string DescribeAdvancedAnalysis()
{
var parts = new List<string>();
if (_project.Mask is { Coverage: var coverage }) parts.Add($"cielo {coverage * 100:0}%");
if (_project.Transitions is { StepCount: > 0 } transitions)
{
parts.Add(transitions.StepCount == 1
? "1 cambio di esposizione"
: $"{transitions.StepCount} cambi di esposizione");
}
if (_project.Motion is { } motion)
{
var (width, _) = _project.ResolveSourceSize();
parts.Add($"tremolio {motion.MeanShake * Math.Max(1, width):0.0} px");
}
return parts.Count == 0 ? string.Empty : " " + string.Join(" · ", parts) + ".";
}
private void ShowPreview(int index)
{
if (_project.Sequence is not { Count: > 0 } sequence || index < 0) { _preview.Clear(); return; }
_preview.Show(sequence, Math.Clamp(index, 0, sequence.Count - 1));
}
private void UpdateSummary()
{
if (_project.Sequence is not { Count: > 0 } sequence)
{
_summary.Text = string.Empty;
return;
}
var (width, height) = _project.ResolveWorkingSize();
var (requestedWidth, requestedHeight) = _project.ResolveRequestedSize();
double outputSeconds = sequence.Count / Math.Max(1.0, _project.Export.FrameRate);
// Una riduzione imposta dal codec va detta: chi esporta deve sapere che il video
// non esce alla risoluzione della sorgente.
string resolution = width == requestedWidth && height == requestedHeight
? $"{width}×{height}"
: $"{width}×{height} (da {requestedWidth}×{requestedHeight})";
_summary.Text = $"{sequence.Count} scatti · {sequence.TotalDuration:hh\\:mm\\:ss} di ripresa · " +
$"{resolution} · {outputSeconds:0.0} s di video" +
(_project.IsAnalyzed ? " · analizzata" : string.Empty);
}
/// <summary>Avverte quando la conformità del codec impone una risoluzione inferiore alla sorgente.</summary>
private string DescribeResolutionClamp()
{
if (!_project.HasSequence) return string.Empty;
var (width, height) = _project.ResolveWorkingSize();
var (requestedWidth, requestedHeight) = _project.ResolveRequestedSize();
if (width == requestedWidth && height == requestedHeight) return string.Empty;
string codec = _project.Export.Codec == Video.VideoCodec.H264 ? "H.264" : "HEVC";
return $" Risoluzione ridotta a {width}×{height}: {requestedWidth}×{requestedHeight} eccede " +
$"il livello {codec} che i lettori supportano.";
}
private void ReportProgress(PipelineProgress progress)
{
_progress.Fraction = progress.Fraction;
string detail = progress.Total > 0 ? $" {progress.Completed}/{progress.Total}" : string.Empty;
string speed = progress.FramesPerSecond > 0.01
? $" · {progress.FramesPerSecond:0.0} fps · {progress.Remaining:hh\\:mm\\:ss} rimanenti"
: string.Empty;
_status.Text = progress.Message + detail + speed;
}
private void SetStatus(string message)
{
_status.Text = message;
_progress.Fraction = 0;
}
private void BeginOperation(string message)
{
_busy = true;
_operation?.Dispose();
_operation = new CancellationTokenSource();
_status.Text = message;
_progress.Fraction = 0;
_cancelButton.Visible = true;
UpdateCommandState();
}
private void EndOperation()
{
_busy = false;
_cancelButton.Visible = false;
_progress.Fraction = 0;
UpdateCommandState();
}
private void UpdateCommandState()
{
bool hasSequence = _project.HasSequence;
_addFilesButton.Enabled = !_busy;
_addFolderButton.Enabled = !_busy;
_clearButton.Enabled = !_busy && hasSequence;
_analyzeButton.Enabled = !_busy && hasSequence;
_exportButton.Enabled = !_busy && hasSequence;
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
_operation?.Cancel();
base.OnFormClosing(e);
}
/// <summary>
/// Carica e analizza una sequenza senza interazione: usata dalla modalità di cattura
/// dell'interfaccia, che serve a verificare la resa grafica in modo riproducibile.
/// </summary>
internal void SelectSettingsTab(int index) => _settings.SelectPage(index);
internal async Task PrepareForCaptureAsync(IReadOnlyList<string> paths)
{
await LoadAsync(paths);
await AnalyzeAsync();
_table.SelectedIndex = Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1));
}
/// <summary>
/// Accende i moduli avanzati prima di una cattura, così l'immagine mostra i controlli con
/// dati veri invece che a riposo. Serve solo alla verifica riproducibile dell'interfaccia.
/// </summary>
internal void EnableAdvancedModulesForCapture()
{
_project.Regions.Mode = Analysis.RegionMode.SkyGround;
_project.HolyGrail.Enabled = true;
_project.HolyGrail.TransitionFrames = 24;
_project.HolyGrail.SmoothColor = true;
_project.Stabilization.Enabled = true;
_project.Camera.Enabled = true;
_project.Camera.Keyframes =
[
new() { Time = 0.0, CentreX = 0.38, CentreY = 0.42, Zoom = 1.20 },
new() { Time = 0.55, CentreX = 0.52, CentreY = 0.50, Zoom = 1.45, EaseIn = 0.7, EaseOut = 0.2 },
new() { Time = 1.0, CentreX = 0.68, CentreY = 0.58, Zoom = 1.80 },
];
_project.TimeRamp.Enabled = true;
_project.TimeRamp.Speed = [new(0.0, 2.5), new(0.45, 0.4), new(1.0, 2.0)];
_project.Stacking.Mode = Motion.StackingMode.Median;
// I pannelli leggono il progetto alla costruzione: qui vanno rifatti da capo.
var host = _settings.Parent;
int page = 0;
host?.Controls.Remove(_settings);
_settings.Dispose();
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
host?.Controls.Add(_settings);
WireSettingsEvents();
_settings.SelectPage(page);
}
}
+373
View File
@@ -0,0 +1,373 @@
using System.Drawing.Imaging;
using Titano.Core;
using Titano.Imaging;
using Titano.Motion;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Anteprima del fotogramma selezionato, resa dallo stesso motore usato in esportazione:
/// decodifica, correzione di esposizione e — se attivo — motion blur sintetico calcolato
/// sul campo vettoriale verso il fotogramma successivo.
/// </summary>
internal sealed class PreviewPanel : Control
{
private readonly TitanoProject _project;
private Bitmap? _bitmap;
private string _caption = string.Empty;
private string _status = "Nessun fotogramma selezionato";
private CancellationTokenSource? _pending;
private int _requestId;
private bool _busy;
/// <summary>Contorno delle regioni in coordinate normalizzate dell'anteprima.</summary>
private PointF[]? _regionContour;
public PreviewPanel(TitanoProject project)
{
_project = project;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Clear()
{
Interlocked.Increment(ref _requestId);
_pending?.Cancel();
SwapBitmap(null);
_caption = string.Empty;
_status = "Nessun fotogramma selezionato";
Invalidate();
}
/// <summary>Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.</summary>
public void Show(TimelapseSequence sequence, int index)
{
if (index < 0 || index >= sequence.Count) { Clear(); return; }
int requestId = Interlocked.Increment(ref _requestId);
_pending?.Cancel();
var source = new CancellationTokenSource();
_pending = source;
var record = sequence.Frames[index];
_caption = $"#{index + 1} {record.FileName}";
_busy = true;
Invalidate();
var project = _project;
var token = source.Token;
_ = Task.Run(() =>
{
try
{
var (bitmap, status, contour) = Render(project, sequence, index, PreviewSize(), token);
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
{
bitmap?.Dispose();
return;
}
BeginInvoke(() =>
{
if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; }
SwapBitmap(bitmap);
_regionContour = contour;
_status = status;
_busy = false;
Invalidate();
});
}
catch (Exception ex)
{
if (token.IsCancellationRequested) return;
try
{
BeginInvoke(() =>
{
SwapBitmap(null);
_status = "Anteprima non disponibile: " + ex.Message;
_busy = false;
Invalidate();
});
}
catch (InvalidOperationException) { /* finestra già chiusa */ }
}
}, token);
}
private Size PreviewSize()
{
int width = Math.Clamp(Width - 24, 160, 1600);
int height = Math.Clamp(Height - 46, 120, 1200);
return new Size(width, height);
}
private void SwapBitmap(Bitmap? bitmap)
{
var previous = _bitmap;
_bitmap = bitmap;
previous?.Dispose();
}
// ------------------------------------------------------------------ rendering
private static (Bitmap? Bitmap, string Status, PointF[]? Contour) Render(
TitanoProject project, TimelapseSequence sequence, int index, Size available, CancellationToken token)
{
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, orientation);
else if (ImageDecoder.SwapsAxes(orientation))
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile", null);
double scale = Math.Min(available.Width / (double)sourceWidth, available.Height / (double)sourceHeight);
scale = Math.Min(scale, 1.0);
int width = Math.Max(16, (int)(sourceWidth * scale) & ~1);
int height = Math.Max(16, (int)(sourceHeight * scale) & ~1);
var pool = new FrameBufferPool(4);
using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, orientation, pool);
token.ThrowIfCancellationRequested();
var status = new System.Text.StringBuilder();
status.Append($"{sourceWidth}×{sourceHeight}");
var curve = project.Curve;
if (project.Deflicker.Enabled && curve is not null && index < curve.Count)
{
ApplyExposure(project, curve, index, frame);
status.Append($" guadagno {curve.GainStops[index]:+0.00;-0.00;0.00} EV");
}
ImageBuffer result = frame;
ImageBuffer? blurred = null;
if (project.MotionBlur.Enabled && index + 1 < sequence.Count)
{
var nextMetadata = sequence.Frames[index + 1].Metadata;
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)
{
ApplyExposure(project, curve, index + 1, next);
}
var flow = new OpticalFlowEngine(project.Flow).Compute(frame, next);
token.ThrowIfCancellationRequested();
double missing = MotionBlurRenderer.MissingBlurFactor(record.ShutterAngle,
project.MotionBlur.TargetShutterAngle,
project.MotionBlur.Strength);
// La scia è proporzionale alla risoluzione: in anteprima va riscalata.
var scaledSettings = project.MotionBlur.Clone();
scaledSettings.MaxBlurPixels = project.MotionBlur.MaxBlurPixels * scale;
blurred = pool.Rent(width, height);
double length = MotionBlurRenderer.Render(frame, blurred, flow, missing, scaledSettings);
result = blurred;
status.Append($" otturatore {record.ShutterAngle:0.#}° → {project.MotionBlur.TargetShutterAngle:0}°");
status.Append($" scia {length:0.0} px");
}
// Inquadratura virtuale e stabilizzazione: l'anteprima deve mostrare il fotogramma
// come uscirà, altrimenti si regola una panoramica guardando ciò che non si esporta.
ImageBuffer? framed = null;
PointF[]? contour = null;
var mapping = SourceMapping.Identity;
int framedWidth = width, framedHeight = height;
if (project.NeedsGeometry)
{
var (outputWidth, outputHeight) = project.ResolveWorkingSize();
if (outputWidth > 0 && outputHeight > 0)
{
framedWidth = width;
framedHeight = Math.Max(2, (int)Math.Round(width * outputHeight / (double)outputWidth) & ~1);
double normalized = sequence.Count > 1 ? index / (double)(sequence.Count - 1) : 0;
var framing = project.FramingAt(normalized);
var stabilization = project.Motion?.At(index) ?? Motion.SimilarityTransform.Identity;
mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization);
framed = pool.Rent(framedWidth, framedHeight);
GeometryStage.Resample(result, framed, mapping);
result = framed;
status.Append($" inquadratura {framing.Zoom:0.00}×");
}
}
if (project.Mask is { } mask)
{
contour = TraceRegionBoundary(mask, mapping, framedWidth, framedHeight, width, height);
}
var bitmap = ToBitmap(result);
blurred?.Dispose();
framed?.Dispose();
return (bitmap, status.ToString(), contour);
}
private static void ApplyExposure(TitanoProject project, Analysis.DeflickerCurve curve,
int index, ImageBuffer frame)
{
if (curve.HasRegions && project.Mask is { } mask)
{
Analysis.ExposureProcessor.ApplyRegional(frame, mask, curve.ChannelGainHigh![index],
curve.ChannelGainLow![index],
project.Deflicker.ProtectHighlights,
project.Deflicker.HighlightKnee);
return;
}
Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index],
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
}
/// <summary>
/// Confine fra le due regioni, in coordinate normalizzate dell'immagine mostrata. Il
/// campionamento passa per la stessa mappatura del ritaglio, così la linea resta al suo
/// posto anche quando l'anteprima mostra una panoramica virtuale.
/// </summary>
private static PointF[]? TraceRegionBoundary(Analysis.RegionMask mask, in SourceMapping mapping,
int width, int height, int sourceWidth, int sourceHeight)
{
var points = new List<PointF>(width);
float invSourceWidth = sourceWidth > 1 ? 1f / (sourceWidth - 1) : 0f;
float invSourceHeight = sourceHeight > 1 ? 1f / (sourceHeight - 1) : 0f;
for (int x = 0; x < width; x += 2)
{
float previous = float.NaN;
for (int y = 0; y < height; y++)
{
var (sx, sy) = mapping.Apply(x, y);
float weight = mask.Sample((float)sx * invSourceWidth, (float)sy * invSourceHeight);
if (!float.IsNaN(previous) && (previous - 0.5f) * (weight - 0.5f) <= 0)
{
points.Add(new PointF(x / (float)Math.Max(1, width - 1),
y / (float)Math.Max(1, height - 1)));
break;
}
previous = weight;
}
}
return points.Count >= 2 ? [.. points] : null;
}
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
private static unsafe Bitmap ToBitmap(ImageBuffer buffer)
{
var bitmap = new Bitmap(buffer.Width, buffer.Height, PixelFormat.Format32bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, buffer.Width, buffer.Height),
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
try
{
var data = buffer.Data;
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < buffer.Height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
int sourceIndex = y * buffer.Width * ImageBuffer.Channels;
for (int x = 0; x < buffer.Width; x++)
{
int i = sourceIndex + x * ImageBuffer.Channels;
byte* pixel = row + x * 4;
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]);
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]);
pixel[2] = ColorSpace.ToSrgbByte(data[i]);
pixel[3] = 255;
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
return bitmap;
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var frame = new Rectangle(0, 0, Width, Height - 22);
if (_bitmap is null)
{
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, frame,
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
double scale = Math.Min(frame.Width / (double)_bitmap.Width, frame.Height / (double)_bitmap.Height);
int width = Math.Max(1, (int)(_bitmap.Width * scale));
int height = Math.Max(1, (int)(_bitmap.Height * scale));
var target = new Rectangle(frame.Left + (frame.Width - width) / 2,
frame.Top + (frame.Height - height) / 2, width, height);
g.DrawImage(_bitmap, target);
using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, target);
if (_regionContour is { Length: >= 2 } contour)
{
var line = new PointF[contour.Length];
for (int i = 0; i < contour.Length; i++)
{
line[i] = new PointF(target.Left + contour[i].X * target.Width,
target.Top + contour[i].Y * target.Height);
}
using var shadow = new Pen(Color.FromArgb(140, Color.Black), 3f);
using var boundary = new Pen(Color.FromArgb(210, Theme.Success), 1.6f);
g.DrawLines(shadow, line);
g.DrawLines(boundary, line);
}
if (_busy)
{
using var overlay = new SolidBrush(Color.FromArgb(120, Theme.Background));
g.FillRectangle(overlay, target);
TextRenderer.DrawText(g, "Aggiornamento…", Theme.Small, target, Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
var footer = new Rectangle(8, Height - 20, Width - 16, 18);
TextRenderer.DrawText(g, _caption, Theme.SmallBold, footer, Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, _status, Theme.Small, footer, Theme.TextMuted,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_pending?.Cancel();
_pending?.Dispose();
_bitmap?.Dispose();
}
base.Dispose(disposing);
}
}
+772
View File
@@ -0,0 +1,772 @@
using Titano.Analysis;
using Titano.Motion;
using Titano.Pipeline;
using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Pannello di configurazione avanzata, diviso nelle sezioni Generale, Immagine, Movimento,
/// Tempo ed Esportazione. Ogni controllo scrive direttamente nel progetto e dichiara quale
/// parte della pipeline va rifatta: c'è differenza fra spostare un cursore che cambia solo
/// una curva già calcolata e sceglierne uno che obbliga a rileggere ogni file.
/// </summary>
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 TabStrip _tabs;
private readonly Panel[] _pages;
private LabeledCombo? _orientationCombo;
private Label? _analysisNote;
private CameraEditor? _cameraEditor;
private SplineEditor? _rampEditor;
private ParameterSlider? _keyframeCentreX;
private ParameterSlider? _keyframeCentreY;
private ParameterSlider? _keyframeZoom;
private ParameterSlider? _keyframeEaseOut;
private ParameterSlider? _keyframeEaseIn;
private bool _syncingKeyframe;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare le curve già misurate.</summary>
public event EventHandler? DeflickerChanged;
/// <summary>È cambiato un parametro che invalida l'analisi già svolta.</summary>
public event EventHandler? AnalysisInvalidated;
/// <summary>È cambiato un parametro che modifica solo l'anteprima o l'esportazione.</summary>
public event EventHandler? PreviewInvalidated;
public event EventHandler? BrowseOutputRequested;
public TextBox OutputPathBox { get; }
public SettingsPanel(TitanoProject project)
{
_project = project;
BackColor = Theme.Surface;
Padding = new Padding(0);
_tabs = new TabStrip("Generale", "Immagine", "Movimento", "Tempo", "Esportazione")
{
Dock = DockStyle.Top,
};
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
OutputPathBox = new TextBox
{
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.Body,
Dock = DockStyle.Top,
};
OutputPathBox.TextChanged += (_, _) =>
{
_project.Export.OutputPath = OutputPathBox.Text;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
// L'ordine di inserimento determina l'ordine di ancoraggio: i controlli in coda alla
// collezione vengono disposti per primi, quindi la barra delle schede va aggiunta
// dopo le pagine per riservarsi la propria fascia in alto.
_pages = [BuildGeneralPage(), BuildImagePage(), BuildMotionPage(), BuildTimePage(), BuildExportPage()];
foreach (var page in _pages)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
Controls.Add(page);
}
Controls.Add(_tabs);
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>Aggiorna le voci che dipendono dall'analisi: maschera, transizioni, tremolio.</summary>
public void ShowAnalysis()
{
if (_analysisNote is null) return;
var lines = new List<string>();
if (_project.Regions.Mode != RegionMode.Off)
{
lines.Add(_project.Mask is { } mask
? "Regioni: " + mask.Description
: "Regioni: la scena non si divide in modo utile, resta la curva unica.");
}
if (_project.HolyGrail.Enabled && _project.Transitions is { } transitions)
{
lines.Add(transitions.StepCount == 0
? "Transizioni: nessun cambio di impostazione rilevato."
: $"Transizioni: {transitions.StepCount} cambi di impostazione, " +
$"il maggiore di {transitions.LargestStepStops:0.00} EV" +
(transitions.MetadataUsable ? " (dai metadati)." : " (dedotti dalla luminanza)."));
}
if (_project.Stabilization.Enabled && _project.Motion is { } motion)
{
var (width, _) = _project.ResolveSourceSize();
lines.Add($"Stabilizzazione: tremolio medio {motion.MeanShake * Math.Max(1, width):0.0} px, " +
$"ritaglio necessario {(_project.StabilizationZoom - 1) * 100:0.#}%.");
}
_analysisNote.Text = lines.Count > 0
? string.Join(Environment.NewLine, lines)
: "Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.";
LayoutNote(_analysisNote);
}
/// <summary>Aggiorna l'editor del movimento quando cambia la sequenza caricata.</summary>
public void ShowSequenceGeometry()
{
if (_cameraEditor is null) return;
var (width, height) = _project.ResolveNativeSize();
if (width > 0 && height > 0) _cameraEditor.Aspect = width / (double)height;
_cameraEditor.Invalidate();
}
/// <summary>Seleziona una delle sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
private void ShowPage(int index)
{
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
}
// ------------------------------------------------------------------ Generale
private Panel BuildGeneralPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Sequenza"));
stack.Add(Combo("Risoluzione di lavoro",
["Nativa (piena risoluzione)", "3840 px (4K UHD)", "2560 px", "1920 px (Full HD)", "1280 px"],
WorkingWidthToIndex(_project.General.WorkingWidth),
index =>
{
_project.General.WorkingWidth = index switch { 1 => 3840, 2 => 2560, 3 => 1920, 4 => 1280, _ => 0 };
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
value =>
{
_project.General.CadenceTolerance = value;
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, " +
"riquadri della correlazione di fase e risoluzione delle passate di analisi, " +
"e sovrascrive i cursori delle sezioni avanzate."));
stack.Add(new SectionHeader("Prestazioni e memoria"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
value =>
{
_project.General.AnalysisWidth = (int)value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _project.General.DecodeParallelism, 1, "0", "thread",
value =>
{
_project.General.DecodeParallelism = (int)value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Lettura in anticipo", 0, 32, _project.Cache.PrefetchDepth, 1, "0", "fotogrammi",
value => _project.Cache.PrefetchDepth = (int)value));
stack.Add(Slider("Tetto di memoria per i fotogrammi", 256, 32768, _project.Cache.MemoryBudgetMiB, 256, "0", "MiB",
value => _project.Cache.MemoryBudgetMiB = (int)value));
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _project.Cache.AllowDiskSpill,
value => _project.Cache.AllowDiskSpill = value));
stack.Add(Note("La finestra attiva sta sempre in memoria; il tetto governa la lettura in " +
"anticipo, che serve a tenere occupati tutti i processori sulla decodifica dei " +
"RAW. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono ripresi " +
"una volta sola. Il file di parcheggio si cancella da sé alla chiusura."));
stack.Add(new SectionHeader("Esito dell'analisi"));
_analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.");
stack.Add(_analysisNote);
return stack.Panel;
}
// ------------------------------------------------------------------ Immagine
private Panel BuildImagePage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Deflicker"));
stack.Add(Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
{
_project.Deflicker.Enabled = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
value => { _project.Deflicker.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.1, 3.0, _project.Deflicker.MaxCorrectionStops, 0.1, "0.0", "EV",
value => { _project.Deflicker.MaxCorrectionStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers, value =>
{
_project.Deflicker.RejectOutliers = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
{
_project.Deflicker.ProtectHighlights = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty,
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
// ---- Regioni
stack.Add(new SectionHeader("Deflicker per regioni"));
stack.Add(Combo("Divisione del fotogramma",
["Nessuna — una sola curva", "Cielo e paesaggio (linea d'orizzonte)", "Per luminanza"],
_project.Regions.Mode switch { RegionMode.SkyGround => 1, RegionMode.Luminance => 2, _ => 0 },
index =>
{
_project.Regions.Mode = index switch
{
1 => RegionMode.SkyGround,
2 => RegionMode.Luminance,
_ => RegionMode.Off,
};
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Indipendenza delle regioni", 0, 1, _project.Regions.Independence, 0.05, "0.00", string.Empty,
value => { _project.Regions.Independence = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Sfumatura del confine", 0.01, 0.20, _project.Regions.Feather, 0.01, "0.00", string.Empty,
value => { _project.Regions.Feather = value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Fotogrammi campionati", 3, 48, _project.Regions.SampleFrames, 1, "0", string.Empty,
value => { _project.Regions.SampleFrames = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La maschera nasce dalla mediana temporale di un campione di fotogrammi, che " +
"toglie di mezzo nuvole e passanti e lascia la struttura fissa della scena. " +
"Serve a impedire che il transito di una nuvola densa sul cielo faccia " +
"schiarire anche il paesaggio, che invece non è cambiato."));
// ---- Holy Grail
stack.Add(new SectionHeader("Transizioni giorno-notte"));
stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled, value =>
{
_project.HolyGrail.Enabled = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Lunghezza della transizione", 4, 240, _project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
value => { _project.HolyGrail.TransitionFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Soglia di riconoscimento", 0.05, 1.0, _project.HolyGrail.StepThresholdStops, 0.05, "0.00", "EV",
value => { _project.HolyGrail.StepThresholdStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Ricava i salti dai metadati", _project.HolyGrail.UseMetadata, value =>
{
_project.HolyGrail.UseMetadata = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Liscia il bilanciamento del bianco", _project.HolyGrail.SmoothColor, value =>
{
_project.HolyGrail.SmoothColor = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0", "fotogrammi",
value => { _project.HolyGrail.ColorWindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità cromatica", 0, 1, _project.HolyGrail.ColorStrength, 0.05, "0.00", string.Empty,
value => { _project.HolyGrail.ColorStrength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni " +
"salto è nota senza incertezza e viene ridistribuita su una transizione a " +
"derivata nulla agli estremi. La lisciatura cromatica agisce sul rapporto fra " +
"i canali: toglie il tremolio del bilanciamento automatico e lascia intatto " +
"il viaggio verso il caldo del tramonto."));
return stack.Panel;
}
// ------------------------------------------------------------------ Movimento
private Panel BuildMotionPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Stabilizzazione sub-pixel"));
stack.Add(Check("Compensa i micro-urti", _project.Stabilization.Enabled, value =>
{
_project.Stabilization.Enabled = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra del percorso", 5, 151, _project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
value => { _project.Stabilization.SmoothingFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.Stabilization.Strength, 0.05, "0.00", string.Empty,
value => { _project.Stabilization.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005, "0.000", "×L",
value => { _project.Stabilization.MaxCorrectionFraction = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation, value =>
{
_project.Stabilization.CompensateRotation = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Lato dei riquadri di correlazione", 64, 512, _project.Stabilization.PatchSize, 64, "0", "px",
value =>
{
// La trasformata vuole una potenza di due: il cursore si muove per gradini validi.
_project.Stabilization.PatchSize = Fourier.FloorPowerOfTwo((int)value);
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Riquadri per lato", 1, 5, _project.Stabilization.Grid, 1, "0", string.Empty,
value => { _project.Stabilization.Grid = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("Lo spostamento fra fotogrammi adiacenti si misura con la correlazione di " +
"fase, che ignora le differenze di luminosità e reagisce alla sola geometria. " +
"Il percorso ricostruito viene lisciato: quello che resta fra percorso vero e " +
"percorso liscio è il tremolio, e la sua inversa è la correzione. Una " +
"panoramica voluta sopravvive perché è già liscia."));
// ---- Virtual camera
stack.Add(new SectionHeader("Movimento di macchina virtuale"));
stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled, value =>
{
_project.Camera.Enabled = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 210 };
_cameraEditor.Changed += (_, _) =>
{
SyncKeyframeControls();
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
_cameraEditor.SelectionChanged += (_, _) => SyncKeyframeControls();
stack.Add(_cameraEditor);
var keyframeButtons = new Panel { Height = 32, BackColor = Theme.Surface };
var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 130, Height = 28, Left = 0, Top = 0 };
var removeButton = new DarkButton { Text = "Togli nodo", Width = 118, Height = 28, Left = 138, Top = 0 };
addButton.Click += (_, _) => { _cameraEditor.AddKeyframe(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
removeButton.Click += (_, _) => { _cameraEditor.RemoveSelected(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
keyframeButtons.Controls.Add(addButton);
keyframeButtons.Controls.Add(removeButton);
stack.Add(keyframeButtons);
_keyframeCentreX = Slider("Nodo — centro orizzontale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
value => UpdateSelectedKeyframe(k => k.CentreX = value));
_keyframeCentreY = Slider("Nodo — centro verticale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
value => UpdateSelectedKeyframe(k => k.CentreY = value));
_keyframeZoom = Slider("Nodo — ingrandimento", 1, 8, 1, 0.05, "0.00", "×",
value => UpdateSelectedKeyframe(k => k.Zoom = value));
_keyframeEaseOut = Slider("Nodo — indugio in partenza", 0, 1, 0.42, 0.02, "0.00", string.Empty,
value => UpdateSelectedKeyframe(k => k.EaseOut = value));
_keyframeEaseIn = Slider("Nodo — frenata in arrivo", 0, 1, 0.42, 0.02, "0.00", string.Empty,
value => UpdateSelectedKeyframe(k => k.EaseIn = value));
stack.Add(_keyframeCentreX);
stack.Add(_keyframeCentreY);
stack.Add(_keyframeZoom);
stack.Add(_keyframeEaseOut);
stack.Add(_keyframeEaseIn);
stack.Add(Check("Tieni l'inquadratura dentro il fotogramma", _project.Camera.KeepInsideFrame, value =>
{
_project.Camera.KeepInsideFrame = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Note("Le inquadrature si trascinano dentro il riquadro e si stringono dalla " +
"maniglia d'angolo; la striscia in basso è la linea del tempo. Le tacche " +
"lungo il percorso sono equispaziate nel tempo: dove si addensano il " +
"movimento rallenta. Con il movimento attivo i fotogrammi vengono letti a " +
"risoluzione nativa, perché il ritaglio deve avere pixel da cui attingere."));
// ---- Motion blur e campo vettoriale
stack.Add(new SectionHeader("Motion blur sintetico"));
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
{
_project.MotionBlur.Enabled = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Shutter angle obiettivo", 0, 360, _project.MotionBlur.TargetShutterAngle, 5, "0", "°",
value => { _project.MotionBlur.TargetShutterAngle = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.MotionBlur.Strength, 0.05, "0.00", string.Empty,
value => { _project.MotionBlur.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza massima della scia", 4, 160, _project.MotionBlur.MaxBlurPixels, 2, "0", "px",
value => { _project.MotionBlur.MaxBlurPixels = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Campioni per pixel", 3, 65, _project.MotionBlur.MaxSamples, 2, "0", string.Empty,
value => { _project.MotionBlur.MaxSamples = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " +
"√(obiettivo² reale²). A 180° si ottiene la resa cinematografica."));
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
value => { _project.Flow.AnalysisWidth = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Passo della griglia", 4, 32, _project.Flow.CellSize, 1, "0", "px",
value => { _project.Flow.CellSize = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Livelli della piramide", 1, 6, _project.Flow.PyramidLevels, 1, "0", string.Empty,
value => { _project.Flow.PyramidLevels = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Raggio della finestra", 2, 12, _project.Flow.WindowRadius, 1, "0", "px",
value => { _project.Flow.WindowRadius = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
SyncKeyframeControls();
return stack.Panel;
}
private void UpdateSelectedKeyframe(Action<CameraKeyframe> change)
{
if (_syncingKeyframe || _cameraEditor?.Selected is not { } keyframe) return;
change(keyframe);
_cameraEditor.Invalidate();
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}
/// <summary>Riporta nei cursori i valori del nodo scelto, senza farli reagire.</summary>
private void SyncKeyframeControls()
{
if (_cameraEditor?.Selected is not { } keyframe) return;
_syncingKeyframe = true;
_keyframeCentreX?.SetValueSilently(keyframe.CentreX);
_keyframeCentreY?.SetValueSilently(keyframe.CentreY);
_keyframeZoom?.SetValueSilently(keyframe.Zoom);
_keyframeEaseOut?.SetValueSilently(keyframe.EaseOut);
_keyframeEaseIn?.SetValueSilently(keyframe.EaseIn);
_syncingKeyframe = false;
}
// ------------------------------------------------------------------ Tempo
private Panel BuildTimePage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Andamento temporale"));
stack.Add(Combo("Durata dei fotogrammi",
["Costante — un fotogramma per scatto",
"Adattiva — durata proporzionale all'intervallo",
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
(int)_project.Export.Timing,
index =>
{
_project.Export.Timing = (FrameTimingMode)index;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
value => _project.Export.MaxAdaptiveStretch = value));
// ---- Time ramping
stack.Add(new SectionHeader("Rimappatura non lineare"));
stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled, value =>
{
_project.TimeRamp.Enabled = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
_rampEditor = new SplineEditor
{
Minimum = 0.1,
Maximum = 8.0,
Height = 168,
StartLabel = "primo scatto",
EndLabel = "ultimo scatto",
};
_rampEditor.SetKnots(_project.TimeRamp.Speed);
_rampEditor.Changed += (_, _) =>
{
_project.TimeRamp.Speed = [.. _rampEditor.Knots];
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
stack.Add(_rampEditor);
stack.Add(Note("Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro. " +
"La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: " +
"sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta e i fotogrammi " +
"mancanti vengono sintetizzati dal campo vettoriale. La velocità del filmato " +
"resta fissa. La spline è monotona per costruzione, quindi il tempo non può " +
"tornare indietro fra due nodi."));
// ---- Stacking
stack.Add(new SectionHeader("Accumulo temporale"));
stack.Add(Combo("Modalità",
["Nessuna", "Mediana — rimuove gli elementi di passaggio", "Massimo — scie stellari"],
_project.Stacking.Mode switch { StackingMode.Median => 1, StackingMode.Maximum => 2, _ => 0 },
index =>
{
_project.Stacking.Mode = index switch
{
1 => StackingMode.Median,
2 => StackingMode.Maximum,
_ => StackingMode.Off,
};
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra della mediana", 3, 49, _project.Stacking.WindowFrames, 2, "0", "fotogrammi",
value => { _project.Stacking.WindowFrames = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità dell'accumulo", 0, 1, _project.Stacking.Strength, 0.05, "0.00", string.Empty,
value => { _project.Stacking.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza delle scie", 0, 400, _project.Stacking.TrailFrames, 5, "0", "fotogrammi",
value => { _project.Stacking.TrailFrames = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la " +
"scena stabile resta se stessa, chi attraversa l'inquadratura una volta sola " +
"sparisce. Il massimo conserva il valore più alto incontrato e trasforma le " +
"stelle in archi continui; a zero le scie non si spengono mai. La finestra " +
"della mediana tiene occupata memoria: sono tutti fotogrammi vivi insieme."));
return stack.Panel;
}
// ------------------------------------------------------------------ Esportazione
private Panel BuildExportPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Formato"));
stack.Add(Combo("Codec", ["H.264 / AVC", "H.265 / HEVC"], _project.Export.Codec == VideoCodec.H264 ? 0 : 1,
index =>
{
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
index => _project.Export.Profile = index switch
{
0 => H264Profile.Baseline,
1 => H264Profile.Main,
_ => H264Profile.High,
}));
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
value => _project.Export.BitrateMbps = value));
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
value => _project.Export.KeyframeIntervalSeconds = (int)value));
stack.Add(new SectionHeader("Codifica"));
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
value => _project.Export.PreferHardware = value));
stack.Add(new SectionHeader("Destinazione"));
stack.Add(OutputPathBox);
var browse = new DarkButton { Text = "Scegli il file di destinazione…", Height = 32, Dock = DockStyle.Top };
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
stack.Add(browse);
stack.Add(Note("Il video viene scritto in un unico flusso continuo. L'unico altro file che " +
"l'elaborazione può creare è il parcheggio temporaneo dei fotogrammi, che si " +
"cancella da sé e non contiene nulla di riutilizzabile."));
return stack.Panel;
}
// ------------------------------------------------------------------ costruttori di controlli
private sealed class Stack(Panel panel)
{
public Panel Panel { get; } = panel;
private int _y;
public void Add(Control control)
{
control.Dock = DockStyle.None;
control.Left = 14;
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) MeasureNote(note);
Panel.Controls.Add(control);
_y += control.Height + 6;
}
}
private static void MeasureNote(Label note)
{
var measured = TextRenderer.MeasureText(note.Text, note.Font,
new Size(Math.Max(40, note.Width), 0),
TextFormatFlags.WordBreak);
note.Height = measured.Height + 8;
}
/// <summary>Rimisura una nota il cui testo è cambiato dopo la costruzione della pagina.</summary>
private static void LayoutNote(Label note)
{
int before = note.Height;
MeasureNote(note);
if (note.Height == before || note.Parent is null) return;
// Le note sotto vanno fatte scorrere: la colonna è posizionata a coordinate assolute.
int delta = note.Height - before;
foreach (Control sibling in note.Parent.Controls)
{
if (sibling != note && sibling.Top > note.Top) sibling.Top += delta;
}
}
private static Stack NewStack()
{
var panel = new Panel
{
BackColor = Theme.Surface,
Padding = new Padding(0, 8, 0, 12),
Width = 360,
};
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 360 };
panel.Controls.Add(host);
return new Stack(host);
}
private static ParameterSlider Slider(string caption, double min, double max, double value, double step,
string format, string unit, Action<double> onChange)
{
var slider = new ParameterSlider
{
Caption = caption,
Minimum = min,
Maximum = max,
Step = step,
ValueFormat = format,
Unit = unit,
};
slider.SetValueSilently(value);
slider.ValueChanged += (_, _) => onChange(slider.Value);
return slider;
}
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
{
var box = new DarkCheckBox { Text = caption, Checked = value };
box.CheckedChanged += (_, _) => onChange(box.Checked);
return box;
}
private static LabeledCombo Combo(string caption, string[] items, int selected, Action<int> onChange)
{
var row = new LabeledCombo(caption);
row.Combo.Items.AddRange(items);
row.Combo.SelectedIndex = Math.Clamp(selected, 0, items.Length - 1);
row.Combo.SelectedIndexChanged += (_, _) => onChange(row.Combo.SelectedIndex);
return row;
}
private static Label Note(string text) => new()
{
Text = text,
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Height = 52,
BackColor = Theme.Surface,
};
private static int WorkingWidthToIndex(int width) => width switch
{
3840 => 1,
2560 => 2,
1920 => 3,
1280 => 4,
_ => 0,
};
}
+252
View File
@@ -0,0 +1,252 @@
using System.Drawing.Drawing2D;
using Titano.Core;
namespace Titano.UI;
/// <summary>
/// Editor grafico di una curva a nodi: si trascinano i punti, si aggiungono con un doppio
/// clic e si tolgono con il tasto destro. La curva disegnata è la stessa che il motore
/// valuta, non un'approssimazione: quello che si vede è quello che verrà eseguito.
///
/// L'asse verticale è logaritmico perché il valore rappresenta una velocità, e per una
/// velocità il contrario di "doppio" è "metà", non "meno uno": su una scala lineare metà
/// dello spazio finirebbe fra 1× e 8× e l'altra metà schiacciata fra 0,1× e 1×, rendendo i
/// rallentamenti impossibili da regolare.
/// </summary>
internal sealed class SplineEditor : Control
{
private const int Gutter = 38;
private const float HitRadius = 9f;
private List<SplineKnot> _knots = [new(0, 1), new(0.5, 1), new(1, 1)];
private int _dragging = -1;
private int _hovered = -1;
public event EventHandler? Changed;
public double Minimum { get; set; } = 0.1;
public double Maximum { get; set; } = 8.0;
public string UnitFormat { get; set; } = "0.##×";
/// <summary>Etichette dei due estremi dell'asse orizzontale.</summary>
public string StartLabel { get; set; } = "inizio";
public string EndLabel { get; set; } = "fine";
public SplineEditor()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 168;
Cursor = Cursors.Hand;
}
public IReadOnlyList<SplineKnot> Knots => _knots;
public void SetKnots(IEnumerable<SplineKnot> knots)
{
_knots = [.. knots.OrderBy(k => k.X)];
if (_knots.Count < 2) _knots = [new(0, 1), new(1, 1)];
Invalidate();
}
// ------------------------------------------------------------------ interazione
private Rectangle Plot => new(Gutter, 12, Math.Max(20, Width - Gutter - 14),
Math.Max(20, Height - 12 - 24));
private PointF ToScreen(SplineKnot knot)
{
var plot = Plot;
double logMin = Math.Log(Minimum), logMax = Math.Log(Maximum);
double normalized = (Math.Log(Math.Clamp(knot.Y, Minimum, Maximum)) - logMin) / (logMax - logMin);
return new PointF(plot.Left + (float)(knot.X * plot.Width),
plot.Bottom - (float)(normalized * plot.Height));
}
private SplineKnot ToValue(float x, float y)
{
var plot = Plot;
double logMin = Math.Log(Minimum), logMax = Math.Log(Maximum);
double fx = Math.Clamp((x - plot.Left) / (double)plot.Width, 0, 1);
double fy = Math.Clamp((plot.Bottom - y) / (double)plot.Height, 0, 1);
return new SplineKnot(fx, Math.Exp(logMin + fy * (logMax - logMin)));
}
private int HitTest(Point location)
{
for (int i = 0; i < _knots.Count; i++)
{
var point = ToScreen(_knots[i]);
float dx = point.X - location.X;
float dy = point.Y - location.Y;
if (dx * dx + dy * dy <= HitRadius * HitRadius) return i;
}
return -1;
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
int index = HitTest(e.Location);
if (e.Button == MouseButtons.Right)
{
// Gli estremi non si tolgono: senza di loro la curva non coprirebbe la sequenza.
if (index > 0 && index < _knots.Count - 1)
{
_knots.RemoveAt(index);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
}
return;
}
if (e.Button == MouseButtons.Left) _dragging = index;
base.OnMouseDown(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left || HitTest(e.Location) >= 0) return;
var value = ToValue(e.X, e.Y);
_knots.Add(value);
_knots = [.. _knots.OrderBy(k => k.X)];
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_dragging >= 0 && _dragging < _knots.Count)
{
var value = ToValue(e.X, e.Y);
// Gli estremi restano ancorati; gli altri non possono scavalcare i vicini,
// altrimenti l'ordine dei nodi — su cui si regge la monotonia — salterebbe.
double x = _dragging == 0 ? 0
: _dragging == _knots.Count - 1 ? 1
: Math.Clamp(value.X, _knots[_dragging - 1].X + 0.01, _knots[_dragging + 1].X - 0.01);
_knots[_dragging] = new SplineKnot(x, value.Y);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
int hovered = HitTest(e.Location);
if (hovered != _hovered) { _hovered = hovered; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_dragging = -1;
base.OnMouseUp(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = -1;
Invalidate();
base.OnMouseLeave(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var plot = Plot;
Theme.FillRounded(g, plot, 4f, Theme.SurfaceAlt);
using (var gridPen = new Pen(Theme.Border) { DashStyle = DashStyle.Dot })
using (var unityPen = new Pen(Color.FromArgb(150, Theme.TextFaint)))
{
foreach (double value in (ReadOnlySpan<double>)[0.25, 0.5, 1, 2, 4])
{
if (value < Minimum || value > Maximum) continue;
var point = ToScreen(new SplineKnot(0, value));
bool unity = Math.Abs(value - 1) < 1e-9;
g.DrawLine(unity ? unityPen : gridPen, plot.Left, point.Y, plot.Right, point.Y);
TextRenderer.DrawText(g, value.ToString(UnitFormat), Theme.Small,
new Rectangle(0, (int)point.Y - 8, Gutter - 4, 16),
unity ? Theme.TextMuted : Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
for (int i = 1; i < 4; i++)
{
float x = plot.Left + plot.Width * i / 4f;
g.DrawLine(gridPen, x, plot.Top, x, plot.Bottom);
}
}
// Curva: un campione per pixel, valutata esattamente come la valuta il motore.
var points = new PointF[Math.Max(2, plot.Width)];
for (int i = 0; i < points.Length; i++)
{
double x = i / (double)(points.Length - 1);
double y = Math.Clamp(Spline.Evaluate(_knots, x), Minimum, Maximum);
points[i] = ToScreen(new SplineKnot(x, y));
}
using (var area = new GraphicsPath())
{
var polygon = new PointF[points.Length + 2];
Array.Copy(points, polygon, points.Length);
polygon[^2] = new PointF(plot.Right, plot.Bottom);
polygon[^1] = new PointF(plot.Left, plot.Bottom);
area.AddPolygon(polygon);
using var fill = new SolidBrush(Color.FromArgb(34, Theme.Accent));
var clip = g.Clip;
g.SetClip(plot);
g.FillPath(fill, area);
g.Clip = clip;
}
using (var pen = new Pen(Theme.Accent, 2f) { LineJoin = LineJoin.Round })
{
g.DrawLines(pen, points);
}
for (int i = 0; i < _knots.Count; i++)
{
var point = ToScreen(_knots[i]);
float radius = i == _dragging ? 6.5f : i == _hovered ? 6f : 5f;
using var fill = new SolidBrush(Theme.Text);
using var ring = new Pen(Theme.Accent, 2f);
g.FillEllipse(fill, point.X - radius, point.Y - radius, radius * 2, radius * 2);
g.DrawEllipse(ring, point.X - radius, point.Y - radius, radius * 2, radius * 2);
}
// Le tre etichette condividono una sola riga: quelle laterali sono corte per scelta e
// al centro resta lo spazio per il valore del nodo sotto il puntatore.
int labelY = plot.Bottom + 4;
var startSize = TextRenderer.MeasureText(g, StartLabel, Theme.Small);
var endSize = TextRenderer.MeasureText(g, EndLabel, Theme.Small);
TextRenderer.DrawText(g, StartLabel, Theme.Small,
new Rectangle(plot.Left, labelY, startSize.Width + 4, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, EndLabel, Theme.Small,
new Rectangle(plot.Right - endSize.Width - 4, labelY, endSize.Width + 4, 16),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
if (_hovered >= 0 && _hovered < _knots.Count)
{
int left = plot.Left + startSize.Width + 10;
int right = plot.Right - endSize.Width - 10;
TextRenderer.DrawText(g, _knots[_hovered].Y.ToString(UnitFormat), Theme.SmallBold,
new Rectangle(left, labelY, Math.Max(20, right - left), 16), Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
namespace Titano.UI;
/// <summary>
/// Tavolozza e primitive di disegno del tema scuro. Tutti i controlli dell'applicazione
/// sono resi con GDI+ a partire da questi valori: nessun tema di terze parti.
/// </summary>
internal static class Theme
{
public static readonly Color Background = Color.FromArgb(0x14, 0x16, 0x1A);
public static readonly Color Surface = Color.FromArgb(0x1B, 0x1E, 0x24);
public static readonly Color SurfaceAlt = Color.FromArgb(0x22, 0x26, 0x2E);
public static readonly Color SurfaceHover = Color.FromArgb(0x2A, 0x2F, 0x39);
public static readonly Color Border = Color.FromArgb(0x2E, 0x33, 0x3D);
public static readonly Color BorderStrong = Color.FromArgb(0x3C, 0x43, 0x50);
public static readonly Color Text = Color.FromArgb(0xE6, 0xE9, 0xEF);
public static readonly Color TextMuted = Color.FromArgb(0x98, 0xA0, 0xAE);
public static readonly Color TextFaint = Color.FromArgb(0x6B, 0x73, 0x82);
public static readonly Color Accent = Color.FromArgb(0x4C, 0x9A, 0xFF);
public static readonly Color AccentDim = Color.FromArgb(0x2F, 0x6C, 0xC2);
public static readonly Color Measured = Color.FromArgb(0xF5, 0xA5, 0x24);
// Le due regioni hanno colori propri, scelti lontani dall'ambra del misurato e dal blu
// del target: sul grafico compaiono insieme a quelle, e due curve dello stesso colore
// sarebbero peggio che non disegnarle.
public static readonly Color RegionHigh = Color.FromArgb(0x5A, 0xD1, 0xC8);
public static readonly Color RegionLow = Color.FromArgb(0xC0, 0x84, 0x57);
public static readonly Color Success = Color.FromArgb(0x35, 0xC4, 0x8F);
public static readonly Color Warning = Color.FromArgb(0xE8, 0xB3, 0x39);
public static readonly Color Danger = Color.FromArgb(0xF0, 0x57, 0x5A);
public static readonly Font Body = new("Segoe UI", 9f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font BodyBold = new("Segoe UI", 9f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Small = new("Segoe UI", 8f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font SmallBold = new("Segoe UI", 8f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Title = new("Segoe UI Semibold", 14f, FontStyle.Regular, GraphicsUnit.Point);
private const int DwmUseImmersiveDarkMode = 20;
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
/// <summary>Estende il tema scuro alla barra del titolo, disegnata dal sistema.</summary>
public static void ApplyDarkTitleBar(Form form)
{
if (!form.IsHandleCreated) return;
int enabled = 1;
DwmSetWindowAttribute(form.Handle, DwmUseImmersiveDarkMode, ref enabled, sizeof(int));
}
/// <summary>Rettangolo con angoli arrotondati, primitiva di base dell'interfaccia.</summary>
public static GraphicsPath RoundedRect(RectangleF bounds, float radius)
{
var path = new GraphicsPath();
if (radius <= 0.5f)
{
path.AddRectangle(bounds);
return path;
}
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;
}
public static void FillRounded(Graphics g, RectangleF bounds, float radius, Color fill)
{
using var path = RoundedRect(bounds, radius);
using var brush = new SolidBrush(fill);
g.FillPath(brush, path);
}
public static void DrawRounded(Graphics g, RectangleF bounds, float radius, Color stroke, float width = 1f)
{
using var path = RoundedRect(bounds, radius);
using var pen = new Pen(stroke, width);
g.DrawPath(pen, path);
}
public static void FillAndStroke(Graphics g, RectangleF bounds, float radius, Color fill, Color stroke)
{
FillRounded(g, bounds, radius, fill);
DrawRounded(g, RectangleF.Inflate(bounds, -0.5f, -0.5f), radius, stroke);
}
public static void HighQuality(Graphics g)
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
}
public static Color Mix(Color a, Color b, double t)
{
t = Math.Clamp(t, 0, 1);
return Color.FromArgb(
(int)(a.A + (b.A - a.A) * t),
(int)(a.R + (b.R - a.R) * t),
(int)(a.G + (b.G - a.G) * t),
(int)(a.B + (b.B - a.B) * t));
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace Titano.Video;
/// <summary>
/// Scansione di un bitstream Annex-B (quello prodotto dagli encoder di sistema): individua
/// le NAL unit delimitate dai codici di avvio 00 00 01 / 00 00 00 01, senza copie di memoria.
/// </summary>
internal static class AnnexBParser
{
internal readonly record struct NalRange(int Start, int Length);
public static NalEnumerator EnumerateNals(ReadOnlySpan<byte> data) => new(data);
internal ref struct NalEnumerator(ReadOnlySpan<byte> data)
{
private readonly ReadOnlySpan<byte> _data = data;
private int _position = 0;
private NalRange _current = default;
public readonly NalEnumerator GetEnumerator() => this;
public readonly NalRange Current => _current;
public bool MoveNext()
{
int start = FindStartCode(_data, _position, out int codeLength);
if (start < 0) return false;
int payloadStart = start + codeLength;
int next = FindStartCode(_data, payloadStart, out _);
int end = next < 0 ? _data.Length : next;
// Gli zeri finali non fanno parte della NAL (trailing_zero_8bits).
while (end > payloadStart && _data[end - 1] == 0) end--;
_position = next < 0 ? _data.Length : next;
_current = new NalRange(payloadStart, end - payloadStart);
return _current.Length > 0 || next >= 0;
}
private static int FindStartCode(ReadOnlySpan<byte> data, int from, out int codeLength)
{
for (int i = Math.Max(0, from); i + 2 < data.Length; i++)
{
if (data[i] != 0 || data[i + 1] != 0) continue;
if (data[i + 2] == 1)
{
codeLength = 3;
return i;
}
if (data[i + 2] == 0 && i + 3 < data.Length && data[i + 3] == 1)
{
codeLength = 4;
return i;
}
}
codeLength = 0;
return -1;
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using System.Buffers.Binary;
using System.Text;
namespace Titano.Video;
/// <summary>
/// Scrittore di box ISO-BMFF. Ogni box riserva quattro byte per la propria dimensione e li
/// corregge alla chiusura: i box possono così essere annidati scrivendo direttamente sul
/// flusso di uscita, senza costruire alberi in memoria.
/// </summary>
internal sealed class BoxWriter : IDisposable
{
private readonly Stream _stream;
private readonly long _start;
private bool _closed;
public BoxWriter(Stream stream, string type)
{
_stream = stream;
_start = stream.Position;
Span<byte> header = stackalloc byte[8];
BinaryPrimitives.WriteUInt32BigEndian(header, 0); // segnaposto
Encoding.ASCII.GetBytes(type, header[4..]);
_stream.Write(header);
}
public BoxWriter Child(string type) => new(_stream, type);
public void WriteFullBoxHeader(byte version, uint flags)
{
Span<byte> buffer = stackalloc byte[4];
buffer[0] = version;
buffer[1] = (byte)((flags >> 16) & 0xFF);
buffer[2] = (byte)((flags >> 8) & 0xFF);
buffer[3] = (byte)(flags & 0xFF);
_stream.Write(buffer);
}
public void WriteByte(byte value) => _stream.WriteByte(value);
public void WriteBytes(ReadOnlySpan<byte> value) => _stream.Write(value);
public void WriteUInt16(ushort value)
{
Span<byte> buffer = stackalloc byte[2];
BinaryPrimitives.WriteUInt16BigEndian(buffer, value);
_stream.Write(buffer);
}
public void WriteUInt32(uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32BigEndian(buffer, value);
_stream.Write(buffer);
}
public void WriteUInt64(ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(buffer, value);
_stream.Write(buffer);
}
public void WriteFourCc(string value)
{
Span<byte> buffer = stackalloc byte[4];
buffer.Fill((byte)' ');
Encoding.ASCII.GetBytes(value.AsSpan(0, Math.Min(4, value.Length)), buffer);
_stream.Write(buffer);
}
/// <summary>Stringa terminata da NUL, come richiesto dal box hdlr.</summary>
public void WriteCString(string value)
{
_stream.Write(Encoding.UTF8.GetBytes(value));
_stream.WriteByte(0);
}
/// <summary>Stringa Pascal in campo fisso da 32 byte (compressorname del sample entry).</summary>
public void WritePascalString32(string value)
{
Span<byte> buffer = stackalloc byte[32];
buffer.Clear();
int length = Math.Min(31, Encoding.ASCII.GetByteCount(value));
buffer[0] = (byte)length;
Encoding.ASCII.GetBytes(value.AsSpan(0, length), buffer[1..]);
_stream.Write(buffer);
}
/// <summary>Matrice di trasformazione identità in virgola fissa 16.16 / 2.30.</summary>
public void WriteMatrix()
{
WriteUInt32(0x00010000);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0x00010000);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0x40000000);
}
public void Dispose()
{
if (_closed) return;
_closed = true;
long end = _stream.Position;
long size = end - _start;
_stream.Position = _start;
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)size);
_stream.Write(buffer);
_stream.Position = end;
}
}
+70
View File
@@ -0,0 +1,70 @@
namespace Titano.Video;
/// <summary>
/// Limiti di conformità dei codec, applicati alla risoluzione di lavoro.
///
/// Un encoder hardware accetta volentieri fotogrammi enormi e dichiara un livello alto,
/// producendo un flusso formalmente valido che però nessun decodificatore comune sa aprire:
/// il file esce, pesa, e non si riproduce. Una sorgente 4:3 da 4000×3000 — tipica delle
/// GoPro convertite in DNG — cade esattamente in questo caso, perché supera il Livello 5.2.
///
/// Qui la risoluzione viene ricondotta al livello che i decodificatori supportano
/// universalmente, conservando le proporzioni. Le sorgenti 16:9 fino al 4K UHD non
/// vengono toccate: 3840×2160 sta comodamente dentro i limiti.
/// </summary>
public static class CodecLimits
{
/// <summary>Macroblocchi 16×16 per fotogramma ammessi dal Livello 5.2 di H.264.</summary>
private const int H264MaxMacroblocks = 36_864;
/// <summary>Campioni di luminanza per fotogramma ammessi dal Livello 5.1 di HEVC.</summary>
private const int HevcMaxLumaSamples = 8_912_896;
/// <summary>Lato massimo accettato dai decodificatori hardware diffusi.</summary>
private const int MaxDimension = 4096;
/// <summary>Numero massimo di pixel per fotogramma con il codec indicato.</summary>
public static long MaxPixels(VideoCodec codec)
=> codec == VideoCodec.H264 ? (long)H264MaxMacroblocks * 256 : HevcMaxLumaSamples;
public static bool IsWithinLimits(VideoCodec codec, int width, int height)
{
if (width <= 0 || height <= 0) return false;
if (width > MaxDimension || height > MaxDimension) return false;
return codec == VideoCodec.H264
? (long)CeilBlocks(width) * CeilBlocks(height) <= H264MaxMacroblocks
: (long)width * height <= HevcMaxLumaSamples;
}
/// <summary>
/// Riduce la risoluzione al massimo riproducibile conservando le proporzioni.
/// Restituisce le dimensioni invariate se sono già ammissibili.
/// </summary>
public static (int Width, int Height) Clamp(VideoCodec codec, int width, int height)
{
if (width <= 0 || height <= 0) return (width, height);
if (IsWithinLimits(codec, width, height)) return (Even(width), Even(height));
double aspect = height / (double)width;
double scale = Math.Sqrt(MaxPixels(codec) / ((double)width * height));
int candidate = (int)(width * Math.Min(1.0, scale));
candidate = Math.Min(candidate, MaxDimension);
if ((int)(candidate * aspect) > MaxDimension) candidate = (int)(MaxDimension / aspect);
// Si scende a passi di un macroblocco: il primo valore ammissibile è anche il migliore.
for (int w = candidate - candidate % 16; w >= 16; w -= 16)
{
int h = Even((int)Math.Round(w * aspect));
if (h <= 0) continue;
if (IsWithinLimits(codec, w, h)) return (w, h);
}
return (Even(Math.Min(width, MaxDimension)), Even(Math.Min(height, MaxDimension)));
}
private static int CeilBlocks(int size) => (size + 15) / 16;
private static int Even(int value) => Math.Max(2, value & ~1);
}
+55
View File
@@ -0,0 +1,55 @@
namespace Titano.Video;
/// <summary>Come viene tradotta in durate di fotogramma la cadenza reale della sequenza.</summary>
public enum FrameTimingMode
{
/// <summary>Ogni scatto dura esattamente un fotogramma: il time-lapse classico.</summary>
Constant,
/// <summary>La durata segue l'intervallo reale: le pause dell'intervallometro restano visibili.</summary>
Adaptive,
/// <summary>La sequenza viene riportata su una griglia temporale uniforme sintetizzando i fotogrammi mancanti.</summary>
Interpolated,
}
public enum H264Profile
{
Baseline = 66,
Main = 77,
High = 100,
}
/// <summary>Parametri del pannello "Esportazione video".</summary>
public sealed class ExportSettings
{
public string OutputPath { get; set; } = string.Empty;
public VideoCodec Codec { get; set; } = VideoCodec.H264;
public H264Profile Profile { get; set; } = H264Profile.High;
/// <summary>Larghezza del video; 0 = dedotta dal primo fotogramma.</summary>
public int Width { get; set; }
public int Height { get; set; }
public double FrameRate { get; set; } = 30.0;
/// <summary>Bitrate medio in megabit al secondo.</summary>
public double BitrateMbps { get; set; } = 60.0;
public FrameTimingMode Timing { get; set; } = FrameTimingMode.Constant;
/// <summary>Fattore di dilatazione applicato in modalità adattiva, limitato per non congelare la scena.</summary>
public double MaxAdaptiveStretch { get; set; } = 4.0;
public bool PreferHardware { get; set; } = true;
public int KeyframeIntervalSeconds { get; set; } = 2;
/// <summary>Unità temporali della traccia video: 90 kHz consente durate variabili precise.</summary>
public uint Timescale { get; set; } = 90000;
public uint AverageBitrate => (uint)Math.Clamp(BitrateMbps * 1_000_000.0, 1_000_000.0, 800_000_000.0);
public ExportSettings Clone() => (ExportSettings)MemberwiseClone();
}
+433
View File
@@ -0,0 +1,433 @@
using System.Runtime.InteropServices;
namespace Titano.Video;
// ---------------------------------------------------------------------------------------
// Binding manuale verso Media Foundation (mfplat.dll / mfreadwrite.dll), lo stack di
// codifica nativo di Windows che espone gli encoder hardware di Intel, AMD e NVIDIA.
// Le interfacce sono dichiarate con l'ordine di vtable esatto; le voci non utilizzate
// sono comunque presenti per non alterare gli offset.
// ---------------------------------------------------------------------------------------
[ComImport, Guid("2cd2d921-c447-44a7-a13c-4adabfc247e3"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFAttributes
{
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
}
[ComImport, Guid("44ae0fa8-ea31-4109-8d2e-4cae4997c555"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaType
{
// --- IMFAttributes
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFMediaType
void GetMajorType(out Guid majorType);
void IsCompressedFormat([MarshalAs(UnmanagedType.Bool)] out bool compressed);
[PreserveSig] int IsEqual(IMFMediaType type, out uint flags);
void GetRepresentation(Guid representation, out IntPtr data);
void FreeRepresentation(Guid representation, IntPtr data);
}
[ComImport, Guid("045fa593-8799-42b8-bc8d-8968c6453507"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaBuffer
{
void Lock(out IntPtr buffer, out uint maxLength, out uint currentLength);
void Unlock();
void GetCurrentLength(out uint length);
void SetCurrentLength(uint length);
void GetMaxLength(out uint length);
}
[ComImport, Guid("c40a00f2-b93a-4d80-ae8c-5a1c634f58e4"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFSample
{
// --- IMFAttributes
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFSample
void GetSampleFlags(out uint flags);
void SetSampleFlags(uint flags);
[PreserveSig] int GetSampleTime(out long time);
void SetSampleTime(long time);
[PreserveSig] int GetSampleDuration(out long duration);
void SetSampleDuration(long duration);
void GetBufferCount(out uint count);
void GetBufferByIndex(uint index, out IMFMediaBuffer buffer);
void ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
void AddBuffer(IMFMediaBuffer buffer);
void RemoveBufferByIndex(uint index);
void RemoveAllBuffers();
void GetTotalLength(out uint length);
void CopyToBuffer(IMFMediaBuffer buffer);
}
[ComImport, Guid("bf94c121-5b05-4e6f-8000-ba598961414d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFTransform
{
void GetStreamLimits(out uint inputMin, out uint inputMax, out uint outputMin, out uint outputMax);
void GetStreamCount(out uint inputs, out uint outputs);
[PreserveSig] int GetStreamIDs(uint inputSize, [Out, MarshalAs(UnmanagedType.LPArray)] uint[] inputIds,
uint outputSize, [Out, MarshalAs(UnmanagedType.LPArray)] uint[] outputIds);
void GetInputStreamInfo(uint streamId, out MftInputStreamInfo info);
void GetOutputStreamInfo(uint streamId, out MftOutputStreamInfo info);
[PreserveSig] int GetAttributes(out IMFAttributes attributes);
[PreserveSig] int GetInputStreamAttributes(uint streamId, out IMFAttributes attributes);
[PreserveSig] int GetOutputStreamAttributes(uint streamId, out IMFAttributes attributes);
[PreserveSig] int DeleteInputStream(uint streamId);
[PreserveSig] int AddInputStreams(uint count, [MarshalAs(UnmanagedType.LPArray)] uint[] ids);
[PreserveSig] int GetInputAvailableType(uint streamId, uint index, out IMFMediaType type);
[PreserveSig] int GetOutputAvailableType(uint streamId, uint index, out IMFMediaType type);
[PreserveSig] int SetInputType(uint streamId, IMFMediaType? type, uint flags);
[PreserveSig] int SetOutputType(uint streamId, IMFMediaType? type, uint flags);
[PreserveSig] int GetInputCurrentType(uint streamId, out IMFMediaType type);
[PreserveSig] int GetOutputCurrentType(uint streamId, out IMFMediaType type);
[PreserveSig] int GetInputStatus(uint streamId, out uint flags);
[PreserveSig] int GetOutputStatus(out uint flags);
[PreserveSig] int SetOutputBounds(long lower, long upper);
[PreserveSig] int ProcessEvent(uint streamId, IntPtr mediaEvent);
[PreserveSig] int ProcessMessage(int message, IntPtr param);
[PreserveSig] int ProcessInput(uint streamId, IMFSample sample, uint flags);
[PreserveSig] int ProcessOutput(uint flags, uint count, ref MftOutputDataBuffer buffers, out uint status);
}
[ComImport, Guid("7fee9e9a-4a89-47a6-899c-b6a53a70fb67"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFActivate
{
// --- IMFAttributes
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFActivate
[PreserveSig] int ActivateObject(ref Guid riid, out IntPtr instance);
[PreserveSig] int ShutdownObject();
[PreserveSig] int DetachObject();
}
[ComImport, Guid("2cd0bd52-bcd5-4b89-b62c-eadc0c031e7d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaEventGenerator
{
[PreserveSig] int GetEvent(uint flags, out IMFMediaEvent mediaEvent);
[PreserveSig] int BeginGetEvent(IntPtr callback, IntPtr state);
[PreserveSig] int EndGetEvent(IntPtr result, out IMFMediaEvent mediaEvent);
[PreserveSig] int QueueEvent(uint met, ref Guid extendedType, int status, IntPtr value);
}
[ComImport, Guid("df598932-f10c-4e39-bba2-c308f101daa3"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaEvent
{
// --- IMFAttributes (le prime 30 voci)
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFMediaEvent
[PreserveSig] int GetEventType(out uint met);
[PreserveSig] int GetExtendedType(out Guid extendedType);
[PreserveSig] int GetStatus(out int status);
[PreserveSig] int GetValue(IntPtr value);
}
[ComImport, Guid("901db4c7-31ce-41a2-85dc-8fa0bf41b8da"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ICodecAPI
{
[PreserveSig] int IsSupported(ref Guid api);
[PreserveSig] int IsModifiable(ref Guid api);
[PreserveSig] int GetParameterRange(ref Guid api, out PropVariant min, out PropVariant max, out PropVariant delta);
[PreserveSig] int GetParameterValues(ref Guid api, out IntPtr values, out uint count);
[PreserveSig] int GetDefaultValue(ref Guid api, out PropVariant value);
[PreserveSig] int GetValue(ref Guid api, out PropVariant value);
[PreserveSig] int SetValue(ref Guid api, ref PropVariant value);
}
[StructLayout(LayoutKind.Sequential)]
internal struct MftInputStreamInfo
{
public long MaxLatency;
public uint Flags;
public uint Size;
public uint MaxLookahead;
public uint Alignment;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MftOutputStreamInfo
{
public uint Flags;
public uint Size;
public uint Alignment;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MftOutputDataBuffer
{
public uint StreamId;
public IntPtr Sample;
public uint Status;
public IntPtr Events;
}
/// <summary>PROPVARIANT ridotta ai soli tipi scalari usati per la configurazione dell'encoder.</summary>
[StructLayout(LayoutKind.Sequential, Size = 24)]
internal struct PropVariant
{
public ushort Type;
public ushort Reserved1;
public ushort Reserved2;
public ushort Reserved3;
public ulong Value;
public const ushort VtUI4 = 19;
public const ushort VtBool = 11;
public static PropVariant FromUInt32(uint value) => new() { Type = VtUI4, Value = value };
}
internal static class MediaFoundation
{
public const uint Version = 0x00020070;
public const uint StartupLite = 1;
// Categorie e formati
public static Guid CategoryVideoEncoder = new("f79eac7d-e545-4387-bdee-d647d7bde42a");
public static Guid MajorTypeVideo = new("73646976-0000-0010-8000-00aa00389b71");
public static Guid VideoFormatH264 = new("34363248-0000-0010-8000-00aa00389b71");
public static Guid VideoFormatHevc = new("43564548-0000-0010-8000-00aa00389b71");
public static Guid VideoFormatNv12 = new("3231564e-0000-0010-8000-00aa00389b71");
// Attributi dei media type
public static Guid MtMajorType = new("48eba18e-f8c9-4687-bf11-0a74c9f96a8f");
public static Guid MtSubtype = new("f7e34c9a-42e8-4714-b74b-cb29d72c35e5");
public static Guid MtAvgBitrate = new("20332624-fb0d-4d9e-bd0d-cbf6786c102e");
public static Guid MtFrameSize = new("1652c33d-d6b2-4012-b834-72030849a37d");
public static Guid MtFrameRate = new("c459a2e8-3d2c-4e44-b132-fee5156c7bb0");
public static Guid MtPixelAspectRatio = new("c6376a1e-8d0a-4027-be45-6d9a0ad39bb6");
public static Guid MtInterlaceMode = new("e2724bb8-e676-4806-b4b2-a8d6efb44ccd");
public static Guid MtMpeg2Profile = new("ad76a80b-2d5c-4e0b-b375-64e520137036");
public static Guid MtMpegSequenceHeader = new("3c036de7-3ad0-4c9e-9216-ee6d6ac21cb3");
public static Guid MtAllSamplesIndependent = new("c9173739-5e56-461c-b713-46fb995cb95f");
public static Guid MtYuvMatrix = new("3e23d450-2c75-4d25-a00e-b91670d12327");
public static Guid MtVideoPrimaries = new("dbfbe4d7-0740-4ee0-8192-850ab0e21935");
public static Guid MtTransferFunction = new("5fb0fce9-be5c-4935-a811-ec838f8eed93");
public static Guid MtVideoNominalRange = new("c21b8ee5-b956-4071-8daf-325edf5cab11");
// Attributi delle trasformazioni
public static Guid TransformAsync = new("f81a699a-649a-497d-8c73-29f8fed6ad7a");
public static Guid TransformAsyncUnlock = new("e5666d6b-3422-4eb6-a421-da7db1f8e207");
public static Guid LowLatency = new("9c27891a-ed7a-40e1-88e8-b22727a024ee");
public static Guid MftFriendlyName = new("314ffbae-5b41-4c95-9c19-4e7d586face3");
public static Guid MftEnumHardwareUrl = new("2fb866ac-b078-4942-ab6c-003d05cda674");
// Attributi dei campioni
public static Guid SampleCleanPoint = new("9cdf01d8-a0f0-43ba-b077-eaa06cbd728a");
// Parametri ICodecAPI
public static Guid AvEncCommonRateControlMode = new("1c0608e9-370c-4710-8a58-cb6181c42423");
public static Guid AvEncCommonMeanBitRate = new("f7222374-2144-4815-b550-a37f8e12ee52");
public static Guid AvEncMpvDefaultBPictureCount = new("8d390aac-dc5c-4200-b57f-814d04babab2");
public static Guid AvEncMpvgopSize = new("95f31b26-95a4-41aa-9303-246a7fc6eef1");
// Messaggi e stati
public const int MessageCommandFlush = 0x00000000;
public const int MessageCommandDrain = 0x00000001;
public const int MessageNotifyBeginStreaming = 0x10000000;
public const int MessageNotifyEndStreaming = 0x10000001;
public const int MessageNotifyEndOfStream = 0x10000002;
public const int MessageNotifyStartOfStream = 0x10000003;
public const uint EventTransformNeedInput = 601;
public const uint EventTransformHaveOutput = 602;
public const uint EventTransformDrainComplete = 603;
public const uint EnumFlagSyncMft = 0x00000001;
public const uint EnumFlagAsyncMft = 0x00000002;
public const uint EnumFlagHardware = 0x00000004;
public const uint EnumFlagTranscodeOnly = 0x00000010;
public const uint EnumFlagSortAndFilter = 0x00000040;
public const uint OutputStreamProvidesSamples = 0x00000100;
public const uint InterlaceModeProgressive = 2;
public const int ErrorTransformNeedMoreInput = unchecked((int)0xC00D6D72);
public const int ErrorTransformStreamChange = unchecked((int)0xC00D6D61);
public const int ErrorNoMoreTypes = unchecked((int)0xC00D36B9);
public const int ErrorAttributeNotFound = unchecked((int)0xC00D36E6);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFStartup(uint version, uint flags);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFShutdown();
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFCreateMediaType(out IMFMediaType type);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFCreateSample(out IMFSample sample);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFCreateMemoryBuffer(uint maxLength, out IMFMediaBuffer buffer);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFTEnumEx(Guid category, uint flags, IntPtr inputType, IntPtr outputType,
out IntPtr activateArray, out uint count);
[DllImport("ole32.dll", ExactSpelling = true)]
public static extern void CoTaskMemFree(IntPtr memory);
/// <summary>Impacchetta due valori a 32 bit in un attributo UINT64 (dimensioni, frame rate).</summary>
public static ulong Pack(uint high, uint low) => ((ulong)high << 32) | low;
public static void Check(int hr, string what)
{
if (hr < 0) throw new VideoEncoderException($"{what} non riuscita (HRESULT 0x{hr:X8}).");
}
}
/// <summary>Errore riconducibile allo stack di codifica di sistema.</summary>
public sealed class VideoEncoderException(string message) : Exception(message);
[StructLayout(LayoutKind.Sequential)]
internal struct MftRegisterTypeInfo
{
public Guid MajorType;
public Guid Subtype;
}
+503
View File
@@ -0,0 +1,503 @@
using System.Buffers.Binary;
using System.Text;
namespace Titano.Video;
public enum VideoCodec
{
H264,
Hevc,
}
/// <summary>
/// Multiplexer ISO Base Media File Format (MP4) scritto interamente in-house.
///
/// Struttura generata: ftyp → mdat (in streaming, con dimensione a 64 bit corretta a
/// posteriori) → moov. I campioni vengono scritti sul file di uscita mentre arrivano
/// dall'encoder: nessun file temporaneo, nessuna copia intermedia dell'intero flusso.
///
/// Il muxer accetta bitstream in formato Annex-B (quello prodotto dagli encoder di sistema),
/// ne estrae i parameter set per la configurazione del codec e converte le NAL nel formato
/// a lunghezza prefissata richiesto dal contenitore.
/// </summary>
public sealed class Mp4Muxer : IDisposable
{
private readonly Stream _output;
private readonly VideoCodec _codec;
private readonly int _width;
private readonly int _height;
private readonly uint _timescale;
private readonly List<uint> _sampleSizes = [];
private readonly List<uint> _sampleDurations = [];
private readonly List<int> _syncSamples = [];
private readonly List<byte[]> _vps = [];
private readonly List<byte[]> _sps = [];
private readonly List<byte[]> _pps = [];
private long _mdatHeaderPosition;
private long _mdatPayloadStart;
private long _mediaDuration;
private bool _finished;
/// <summary>
/// Buffer di lavoro riusato per la conversione Annex-B → lunghezza prefissata.
/// Parte piccolo di proposito: così il percorso di crescita viene esercitato da
/// qualunque sequenza, verifica sintetica compresa, invece di restare latente
/// fino al primo fotogramma chiave di una ripresa ad alta risoluzione.
/// </summary>
private byte[] _scratch = new byte[64 * 1024];
public int SampleCount => _sampleSizes.Count;
public long BytesWritten { get; private set; }
public bool HasParameterSets => _sps.Count > 0;
public Mp4Muxer(Stream output, VideoCodec codec, int width, int height, uint timescale)
{
if (!output.CanSeek) throw new ArgumentException("Il flusso di uscita deve essere posizionabile.", nameof(output));
_output = output;
_codec = codec;
_width = width;
_height = height;
_timescale = timescale == 0 ? 90000u : timescale;
WriteFileTypeBox();
BeginMediaData();
}
/// <summary>
/// Registra parameter set forniti fuori banda dall'encoder (blob MF_MT_MPEG_SEQUENCE_HEADER).
/// </summary>
public void AddParameterSets(ReadOnlySpan<byte> annexB)
{
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
{
ClassifyAndStore(annexB, nal);
}
}
/// <summary>
/// Scrive un campione codificato. <paramref name="duration"/> è espressa nella timescale
/// della traccia, così ogni fotogramma può avere durata propria (playback adattivo).
/// </summary>
public void WriteSample(ReadOnlySpan<byte> annexB, uint duration)
{
ObjectDisposedException.ThrowIf(_finished, this);
int written = 0;
bool keyframe = false;
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
{
var payload = annexB.Slice(nal.Start, nal.Length);
if (payload.Length == 0) continue;
if (IsParameterSet(payload[0]))
{
ClassifyAndStore(annexB, nal);
continue; // i parameter set vivono in stsd, non in mdat
}
if (IsKeyframeNal(payload[0])) keyframe = true;
EnsureScratch(written + payload.Length + 4);
BinaryPrimitives.WriteUInt32BigEndian(_scratch.AsSpan(written), (uint)payload.Length);
payload.CopyTo(_scratch.AsSpan(written + 4));
written += payload.Length + 4;
}
if (written == 0) return;
_output.Write(_scratch, 0, written);
BytesWritten += written;
if (keyframe) _syncSamples.Add(_sampleSizes.Count + 1); // gli indici in stss partono da 1
_sampleSizes.Add((uint)written);
_sampleDurations.Add(duration);
_mediaDuration += duration;
}
/// <summary>Chiude mdat, scrive moov e finalizza il file.</summary>
public void Finish()
{
if (_finished) return;
_finished = true;
long mdatEnd = _output.Position;
long mdatSize = mdatEnd - _mdatHeaderPosition;
// Correzione della dimensione a 64 bit riservata all'apertura del box.
_output.Position = _mdatHeaderPosition + 8;
Span<byte> size = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(size, mdatSize);
_output.Write(size);
_output.Position = mdatEnd;
WriteMovieBox();
_output.Flush();
}
public void Dispose() => Finish();
// ------------------------------------------------------------------ box di apertura
private void WriteFileTypeBox()
{
using var box = new BoxWriter(_output, "ftyp");
box.WriteFourCc("isom");
box.WriteUInt32(0x200);
box.WriteFourCc("isom");
box.WriteFourCc("iso2");
box.WriteFourCc(_codec == VideoCodec.H264 ? "avc1" : "hvc1");
box.WriteFourCc("mp41");
}
private void BeginMediaData()
{
_mdatHeaderPosition = _output.Position;
Span<byte> header = stackalloc byte[16];
BinaryPrimitives.WriteUInt32BigEndian(header, 1); // size = 1 → largesize a 64 bit
Encoding.ASCII.GetBytes("mdat", header[4..]);
BinaryPrimitives.WriteInt64BigEndian(header[8..], 16); // valore provvisorio
_output.Write(header);
_mdatPayloadStart = _output.Position;
}
// ------------------------------------------------------------------ moov
private void WriteMovieBox()
{
uint movieTimescale = 1000;
long movieDuration = _timescale == 0 ? 0 : _mediaDuration * movieTimescale / _timescale;
using var moov = new BoxWriter(_output, "moov");
using (var mvhd = moov.Child("mvhd"))
{
mvhd.WriteFullBoxHeader(0, 0);
mvhd.WriteUInt32(0); // creation_time
mvhd.WriteUInt32(0); // modification_time
mvhd.WriteUInt32(movieTimescale);
mvhd.WriteUInt32((uint)movieDuration);
mvhd.WriteUInt32(0x00010000); // rate 1.0
mvhd.WriteUInt16(0x0100); // volume 1.0
mvhd.WriteUInt16(0); // reserved
mvhd.WriteUInt32(0);
mvhd.WriteUInt32(0);
mvhd.WriteMatrix();
for (int i = 0; i < 6; i++) mvhd.WriteUInt32(0); // pre_defined
mvhd.WriteUInt32(2); // next_track_ID
}
using (var trak = moov.Child("trak"))
{
using (var tkhd = trak.Child("tkhd"))
{
tkhd.WriteFullBoxHeader(0, 0x000007); // enabled | in movie | in preview
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(1); // track_ID
tkhd.WriteUInt32(0); // reserved
tkhd.WriteUInt32((uint)movieDuration);
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(0);
tkhd.WriteUInt16(0); // layer
tkhd.WriteUInt16(0); // alternate_group
tkhd.WriteUInt16(0); // volume (0 per il video)
tkhd.WriteUInt16(0);
tkhd.WriteMatrix();
tkhd.WriteUInt32((uint)_width << 16);
tkhd.WriteUInt32((uint)_height << 16);
}
using var mdia = trak.Child("mdia");
using (var mdhd = mdia.Child("mdhd"))
{
mdhd.WriteFullBoxHeader(0, 0);
mdhd.WriteUInt32(0);
mdhd.WriteUInt32(0);
mdhd.WriteUInt32(_timescale);
mdhd.WriteUInt32((uint)_mediaDuration);
mdhd.WriteUInt16(0x55C4); // lingua "und" impacchettata a 5 bit
mdhd.WriteUInt16(0);
}
using (var hdlr = mdia.Child("hdlr"))
{
hdlr.WriteFullBoxHeader(0, 0);
hdlr.WriteUInt32(0); // pre_defined
hdlr.WriteFourCc("vide");
hdlr.WriteUInt32(0);
hdlr.WriteUInt32(0);
hdlr.WriteUInt32(0);
hdlr.WriteCString("Titano Video Handler");
}
using var minf = mdia.Child("minf");
using (var vmhd = minf.Child("vmhd"))
{
vmhd.WriteFullBoxHeader(0, 1);
vmhd.WriteUInt16(0); // graphicsmode
vmhd.WriteUInt16(0); // opcolor
vmhd.WriteUInt16(0);
vmhd.WriteUInt16(0);
}
using (var dinf = minf.Child("dinf"))
using (var dref = dinf.Child("dref"))
{
dref.WriteFullBoxHeader(0, 0);
dref.WriteUInt32(1); // entry_count
using var url = dref.Child("url ");
url.WriteFullBoxHeader(0, 1); // flag 1 = dati nello stesso file
}
using var stbl = minf.Child("stbl");
WriteSampleDescription(stbl);
WriteTimeToSample(stbl);
WriteSyncSamples(stbl);
WriteSampleToChunk(stbl);
WriteSampleSizes(stbl);
WriteChunkOffsets(stbl);
}
}
private void WriteSampleDescription(BoxWriter stbl)
{
using var stsd = stbl.Child("stsd");
stsd.WriteFullBoxHeader(0, 0);
stsd.WriteUInt32(1); // entry_count
string entryName = _codec == VideoCodec.H264 ? "avc1" : "hvc1";
using var entry = stsd.Child(entryName);
for (int i = 0; i < 6; i++) entry.WriteByte(0); // reserved
entry.WriteUInt16(1); // data_reference_index
entry.WriteUInt16(0); // pre_defined
entry.WriteUInt16(0); // reserved
for (int i = 0; i < 3; i++) entry.WriteUInt32(0);
entry.WriteUInt16((ushort)_width);
entry.WriteUInt16((ushort)_height);
entry.WriteUInt32(0x00480000); // 72 dpi orizzontali
entry.WriteUInt32(0x00480000); // 72 dpi verticali
entry.WriteUInt32(0); // reserved
entry.WriteUInt16(1); // frame_count
entry.WritePascalString32("Titano"); // compressorname
entry.WriteUInt16(0x0018); // profondità 24 bit
entry.WriteUInt16(0xFFFF); // pre_defined = -1
if (_codec == VideoCodec.H264) WriteAvcConfiguration(entry);
else WriteHevcConfiguration(entry);
using (var colr = entry.Child("colr"))
{
colr.WriteFourCc("nclx");
colr.WriteUInt16(1); // primarie BT.709
colr.WriteUInt16(1); // funzione di trasferimento BT.709
colr.WriteUInt16(1); // matrice BT.709
colr.WriteByte(0); // range televisivo (16-235)
}
using var pasp = entry.Child("pasp");
pasp.WriteUInt32(1); // pixel quadrati
pasp.WriteUInt32(1);
}
private void WriteAvcConfiguration(BoxWriter entry)
{
using var avcc = entry.Child("avcC");
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
avcc.WriteByte(1); // configurationVersion
avcc.WriteByte(sps.Length > 1 ? sps[1] : (byte)0x64); // AVCProfileIndication
avcc.WriteByte(sps.Length > 2 ? sps[2] : (byte)0x00); // profile_compatibility
avcc.WriteByte(sps.Length > 3 ? sps[3] : (byte)0x28); // AVCLevelIndication
avcc.WriteByte(0xFF); // 6 bit riservati + lengthSizeMinusOne = 3
avcc.WriteByte((byte)(0xE0 | Math.Min(_sps.Count, 31))); // 3 bit riservati + numOfSPS
foreach (var set in _sps)
{
avcc.WriteUInt16((ushort)set.Length);
avcc.WriteBytes(set);
}
avcc.WriteByte((byte)Math.Min(_pps.Count, 255));
foreach (var set in _pps)
{
avcc.WriteUInt16((ushort)set.Length);
avcc.WriteBytes(set);
}
}
private void WriteHevcConfiguration(BoxWriter entry)
{
using var hvcc = entry.Child("hvcC");
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
// profile_tier_level occupa 12 byte subito dopo i due byte di header NAL e il byte
// che contiene sps_video_parameter_set_id / sps_max_sub_layers_minus1.
Span<byte> ptl = stackalloc byte[12];
if (sps.Length >= 15) sps.AsSpan(3, 12).CopyTo(ptl);
else ptl[0] = 0x01; // Main profile come ripiego
hvcc.WriteByte(1); // configurationVersion
hvcc.WriteByte(ptl[0]); // profile_space/tier/profile_idc
hvcc.WriteBytes(ptl[1..5]); // general_profile_compatibility_flags
hvcc.WriteBytes(ptl[5..11]); // general_constraint_indicator_flags
hvcc.WriteByte(ptl[11]); // general_level_idc
hvcc.WriteUInt16(0xF000); // min_spatial_segmentation = 0
hvcc.WriteByte(0xFC); // parallelismType sconosciuto
hvcc.WriteByte(0xFD); // chromaFormat 4:2:0
hvcc.WriteByte(0xF8); // bitDepthLumaMinus8 = 0
hvcc.WriteByte(0xF8); // bitDepthChromaMinus8 = 0
hvcc.WriteUInt16(0); // avgFrameRate (non dichiarato)
hvcc.WriteByte(0x0F); // costantFrameRate/temporalId + lengthSizeMinusOne
hvcc.WriteByte((byte)((_vps.Count > 0 ? 1 : 0) + (_sps.Count > 0 ? 1 : 0) + (_pps.Count > 0 ? 1 : 0)));
WriteHevcArray(hvcc, 32, _vps);
WriteHevcArray(hvcc, 33, _sps);
WriteHevcArray(hvcc, 34, _pps);
}
private static void WriteHevcArray(BoxWriter hvcc, byte nalType, List<byte[]> sets)
{
if (sets.Count == 0) return;
hvcc.WriteByte((byte)(0x80 | nalType)); // array_completeness + NAL_unit_type
hvcc.WriteUInt16((ushort)sets.Count);
foreach (var set in sets)
{
hvcc.WriteUInt16((ushort)set.Length);
hvcc.WriteBytes(set);
}
}
private void WriteTimeToSample(BoxWriter stbl)
{
// Codifica a corse: le durate uguali consecutive occupano una sola voce.
var runs = new List<(uint Count, uint Delta)>();
foreach (uint delta in _sampleDurations)
{
if (runs.Count > 0 && runs[^1].Delta == delta) runs[^1] = (runs[^1].Count + 1, delta);
else runs.Add((1, delta));
}
using var stts = stbl.Child("stts");
stts.WriteFullBoxHeader(0, 0);
stts.WriteUInt32((uint)runs.Count);
foreach (var (count, delta) in runs)
{
stts.WriteUInt32(count);
stts.WriteUInt32(delta);
}
}
private void WriteSyncSamples(BoxWriter stbl)
{
// Se ogni campione è un punto di sincronizzazione la tabella si omette per convenzione.
if (_syncSamples.Count == _sampleSizes.Count || _syncSamples.Count == 0) return;
using var stss = stbl.Child("stss");
stss.WriteFullBoxHeader(0, 0);
stss.WriteUInt32((uint)_syncSamples.Count);
foreach (int index in _syncSamples) stss.WriteUInt32((uint)index);
}
private void WriteSampleToChunk(BoxWriter stbl)
{
using var stsc = stbl.Child("stsc");
stsc.WriteFullBoxHeader(0, 0);
stsc.WriteUInt32(1); // una sola voce: tutti i campioni in un chunk
stsc.WriteUInt32(1); // first_chunk
stsc.WriteUInt32((uint)Math.Max(1, _sampleSizes.Count));
stsc.WriteUInt32(1); // sample_description_index
}
private void WriteSampleSizes(BoxWriter stbl)
{
using var stsz = stbl.Child("stsz");
stsz.WriteFullBoxHeader(0, 0);
stsz.WriteUInt32(0); // dimensione variabile
stsz.WriteUInt32((uint)_sampleSizes.Count);
foreach (uint size in _sampleSizes) stsz.WriteUInt32(size);
}
private void WriteChunkOffsets(BoxWriter stbl)
{
using var co64 = stbl.Child("co64");
co64.WriteFullBoxHeader(0, 0);
co64.WriteUInt32(1);
co64.WriteUInt64((ulong)_mdatPayloadStart);
}
// ------------------------------------------------------------------ NAL
private bool IsParameterSet(byte header)
{
if (_codec == VideoCodec.H264)
{
int type = header & 0x1F;
return type is 7 or 8; // SPS, PPS
}
int hevcType = (header >> 1) & 0x3F;
return hevcType is 32 or 33 or 34; // VPS, SPS, PPS
}
private bool IsKeyframeNal(byte header)
{
if (_codec == VideoCodec.H264) return (header & 0x1F) == 5; // IDR
int type = (header >> 1) & 0x3F;
return type is >= 16 and <= 21; // BLA/IDR/CRA
}
private void ClassifyAndStore(ReadOnlySpan<byte> source, AnnexBParser.NalRange nal)
{
if (nal.Length <= 0) return;
var payload = source.Slice(nal.Start, nal.Length);
byte header = payload[0];
List<byte[]> target;
if (_codec == VideoCodec.H264)
{
int type = header & 0x1F;
if (type == 7) target = _sps;
else if (type == 8) target = _pps;
else return;
}
else
{
int type = (header >> 1) & 0x3F;
if (type == 32) target = _vps;
else if (type == 33) target = _sps;
else if (type == 34) target = _pps;
else return;
}
var copy = payload.ToArray();
foreach (var existing in target)
{
if (existing.AsSpan().SequenceEqual(copy)) return;
}
target.Add(copy);
}
/// <summary>
/// Allarga il buffer di conversione conservandone il contenuto.
///
/// La copia non è un dettaglio: il buffer accumula le NAL di un campione una dopo l'altra,
/// quindi rimpiazzarlo con un array nuovo azzererebbe quelle già scritte. Il campione
/// uscirebbe con un prefisso di lunghezza nullo in testa e nessun decodificatore lo
/// supererebbe. Il caso si presenta al primo fotogramma chiave, l'unico abbastanza
/// grande da superare la capacità iniziale.
/// </summary>
private void EnsureScratch(int required)
{
if (_scratch.Length >= required) return;
long size = Math.Max(_scratch.Length, 1);
while (size < required) size *= 2;
Array.Resize(ref _scratch, (int)Math.Min(size, Array.MaxLength));
}
}
+81
View File
@@ -0,0 +1,81 @@
using Titano.Imaging;
namespace Titano.Video;
/// <summary>
/// Conversione da RGB lineare a NV12 (Y a piena risoluzione + CbCr interlacciato a metà
/// risoluzione), il formato d'ingresso atteso dagli encoder hardware.
///
/// La matrice è quella BT.709 in range televisivo (Y 16-235, C 16-240): è la convenzione
/// dichiarata nel box "colr" del contenitore, quindi il colore resta coerente in riproduzione.
/// La codifica di gamma viene riapplicata qui, all'ultimo passaggio utile: tutta l'elaborazione
/// a monte è avvenuta in luce lineare.
/// </summary>
public static class Nv12Converter
{
public static int RequiredSize(int width, int height) => width * height * 3 / 2;
public static unsafe void Convert(ImageBuffer source, byte* destination)
{
int width = source.Width;
int height = source.Height;
var data = source.Data;
byte* yPlane = destination;
byte* uvPlane = destination + (long)width * height;
int chromaRows = height / 2;
Parallel.For(0, chromaRows, cy =>
{
int y0 = cy * 2;
int y1 = Math.Min(y0 + 1, height - 1);
for (int cx = 0; cx < width / 2; cx++)
{
int x0 = cx * 2;
int x1 = Math.Min(x0 + 1, width - 1);
double cbSum = 0, crSum = 0;
cbSum += Encode(data, width, x0, y0, yPlane, out double cr00); crSum += cr00;
cbSum += Encode(data, width, x1, y0, yPlane, out double cr10); crSum += cr10;
cbSum += Encode(data, width, x0, y1, yPlane, out double cr01); crSum += cr01;
cbSum += Encode(data, width, x1, y1, yPlane, out double cr11); crSum += cr11;
long uvIndex = (long)cy * width + cx * 2;
uvPlane[uvIndex] = Quantize(128.0 + 224.0 * (cbSum * 0.25));
uvPlane[uvIndex + 1] = Quantize(128.0 + 224.0 * (crSum * 0.25));
}
});
// Riga o colonna dispari residua: la luma va comunque scritta.
if ((height & 1) != 0)
{
int y = height - 1;
for (int x = 0; x < width; x++) Encode(data, width, x, y, yPlane, out _);
}
if ((width & 1) != 0)
{
int x = width - 1;
for (int y = 0; y < height; y++) Encode(data, width, x, y, yPlane, out _);
}
}
/// <summary>Scrive la luma del pixel indicato e restituisce le due crominanze normalizzate.</summary>
private static unsafe double Encode(float[] data, int width, int x, int y, byte* yPlane, out double cr)
{
int index = (y * width + x) * ImageBuffer.Channels;
float r = ColorSpace.ToSrgb(data[index]);
float g = ColorSpace.ToSrgb(data[index + 1]);
float b = ColorSpace.ToSrgb(data[index + 2]);
double luma = ColorSpace.LumaR * r + ColorSpace.LumaG * g + ColorSpace.LumaB * b;
yPlane[(long)y * width + x] = Quantize(16.0 + 219.0 * luma);
cr = (r - luma) / 1.5748;
return (b - luma) / 1.8556;
}
private static byte Quantize(double value)
=> (byte)Math.Clamp((int)(value + 0.5), 0, 255);
}
+638
View File
@@ -0,0 +1,638 @@
using System.Runtime.InteropServices;
using System.Text;
using Titano.Imaging;
namespace Titano.Video;
/// <summary>
/// Sessione di codifica: pilota direttamente la Media Foundation Transform dell'encoder di
/// sistema (hardware se disponibile) e riversa le NAL prodotte nel multiplexer MP4 in-house.
///
/// I fotogrammi entrano come buffer in memoria, escono come pacchetti compressi e finiscono
/// nel file di destinazione: nessun passaggio intermedio su disco.
/// </summary>
public sealed class VideoEncoderSession : IDisposable
{
private readonly ExportSettings _settings;
private readonly int _width;
private readonly int _height;
private readonly Stream _output;
private readonly Mp4Muxer _muxer;
private IMFTransform? _transform;
private IMFMediaEventGenerator? _events;
private uint _inputStreamId;
private uint _outputStreamId;
private bool _async;
private MftOutputStreamInfo _outputInfo;
private readonly Queue<uint> _pendingDurations = new();
private long _presentationTime;
private byte[] _packetBuffer = new byte[1 << 20];
private int _pendingNeedInput;
private bool _finished;
public string EncoderName { get; private set; } = "sconosciuto";
public bool IsHardware { get; private set; }
public int EncodedFrames { get; private set; }
public long OutputBytes => _muxer.BytesWritten;
public VideoEncoderSession(ExportSettings settings, int width, int height)
{
if ((width & 1) != 0 || (height & 1) != 0)
throw new ArgumentException("La codifica 4:2:0 richiede dimensioni pari.");
_settings = settings;
_width = width;
_height = height;
MediaFoundationRuntime.Startup();
_output = new FileStream(settings.OutputPath, FileMode.Create, FileAccess.ReadWrite,
FileShare.Read, 1 << 20, FileOptions.SequentialScan);
try
{
_muxer = new Mp4Muxer(_output, settings.Codec, width, height, settings.Timescale);
CreateTransform();
ConfigureTypes();
StartStreaming();
}
catch
{
_output.Dispose();
TryDeletePartialFile();
throw;
}
}
// ------------------------------------------------------------------ configurazione
private void CreateTransform()
{
Guid subtype = _settings.Codec == VideoCodec.H264
? MediaFoundation.VideoFormatH264
: MediaFoundation.VideoFormatHevc;
var outputInfo = new MftRegisterTypeInfo
{
MajorType = MediaFoundation.MajorTypeVideo,
Subtype = subtype,
};
// Prima gli encoder hardware, poi quelli software: stessa interfaccia, stesso codice.
if (_settings.PreferHardware &&
TryActivate(outputInfo, MediaFoundation.EnumFlagHardware | MediaFoundation.EnumFlagSortAndFilter, true))
return;
if (TryActivate(outputInfo, MediaFoundation.EnumFlagSyncMft | MediaFoundation.EnumFlagAsyncMft |
MediaFoundation.EnumFlagSortAndFilter, false))
return;
throw new VideoEncoderException(
$"Nessun encoder {( _settings.Codec == VideoCodec.H264 ? "H.264" : "HEVC")} disponibile nel sistema. " +
"Su alcune edizioni di Windows occorre installare il Media Feature Pack.");
}
private unsafe bool TryActivate(MftRegisterTypeInfo outputInfo, uint flags, bool hardware)
{
IntPtr array = IntPtr.Zero;
try
{
// I parametri sono già su stack: se ne può prendere l'indirizzo senza "fixed".
MftRegisterTypeInfo* pOutput = &outputInfo;
int hr = MediaFoundation.MFTEnumEx(MediaFoundation.CategoryVideoEncoder, flags,
IntPtr.Zero, (IntPtr)pOutput, out array, out uint count);
if (hr < 0 || count == 0 || array == IntPtr.Zero) return false;
for (uint i = 0; i < count; i++)
{
IntPtr activatePtr = Marshal.ReadIntPtr(array, (int)i * IntPtr.Size);
if (activatePtr == IntPtr.Zero) continue;
var activate = (IMFActivate)Marshal.GetObjectForIUnknown(activatePtr);
try
{
if (_transform is null && TryActivateOne(activate, hardware)) { /* trovato */ }
}
finally
{
Marshal.ReleaseComObject(activate);
Marshal.Release(activatePtr);
}
if (_transform is not null) return true;
}
return false;
}
finally
{
if (array != IntPtr.Zero) MediaFoundation.CoTaskMemFree(array);
}
}
private bool TryActivateOne(IMFActivate activate, bool hardware)
{
var iid = typeof(IMFTransform).GUID;
if (activate.ActivateObject(ref iid, out IntPtr instance) < 0 || instance == IntPtr.Zero) return false;
IMFTransform transform;
try
{
transform = (IMFTransform)Marshal.GetObjectForIUnknown(instance);
}
finally
{
Marshal.Release(instance);
}
try
{
string name = ReadFriendlyName(activate);
// Le trasformazioni hardware sono asincrone e vanno sbloccate esplicitamente.
bool isAsync = false;
if (transform.GetAttributes(out var attributes) >= 0 && attributes is not null)
{
try
{
var asyncKey = MediaFoundation.TransformAsync;
if (attributes.GetUINT32(ref asyncKey, out uint asyncFlag) >= 0 && asyncFlag != 0)
{
isAsync = true;
var unlockKey = MediaFoundation.TransformAsyncUnlock;
attributes.SetUINT32(ref unlockKey, 1);
}
}
finally
{
Marshal.ReleaseComObject(attributes);
}
}
_transform = transform;
_async = isAsync;
IsHardware = hardware;
EncoderName = name;
return true;
}
catch
{
Marshal.ReleaseComObject(transform);
return false;
}
}
private static string ReadFriendlyName(IMFActivate activate)
{
var key = MediaFoundation.MftFriendlyName;
if (activate.GetStringLength(ref key, out uint length) < 0 || length == 0) return "encoder di sistema";
var builder = new StringBuilder((int)length + 1);
uint written = 0;
return activate.GetString(ref key, builder, length + 1, ref written) < 0
? "encoder di sistema"
: builder.ToString();
}
private void ConfigureTypes()
{
var transform = _transform!;
transform.GetStreamCount(out uint inputs, out uint outputs);
var inputIds = new uint[Math.Max(1, inputs)];
var outputIds = new uint[Math.Max(1, outputs)];
if (transform.GetStreamIDs((uint)inputIds.Length, inputIds, (uint)outputIds.Length, outputIds) >= 0)
{
_inputStreamId = inputIds[0];
_outputStreamId = outputIds[0];
}
uint fpsNumerator = (uint)Math.Round(_settings.FrameRate * 1000.0);
const uint fpsDenominator = 1000;
// L'ordine è vincolante: prima il tipo di uscita (compresso), poi quello d'ingresso.
MediaFoundation.Check(MediaFoundation.MFCreateMediaType(out IMFMediaType outputType), "Creazione del tipo di uscita");
try
{
SetGuid(outputType, MediaFoundation.MtMajorType, MediaFoundation.MajorTypeVideo);
SetGuid(outputType, MediaFoundation.MtSubtype,
_settings.Codec == VideoCodec.H264 ? MediaFoundation.VideoFormatH264 : MediaFoundation.VideoFormatHevc);
SetUInt32(outputType, MediaFoundation.MtAvgBitrate, _settings.AverageBitrate);
SetUInt32(outputType, MediaFoundation.MtInterlaceMode, MediaFoundation.InterlaceModeProgressive);
SetUInt64(outputType, MediaFoundation.MtFrameSize, MediaFoundation.Pack((uint)_width, (uint)_height));
SetUInt64(outputType, MediaFoundation.MtFrameRate, MediaFoundation.Pack(fpsNumerator, fpsDenominator));
SetUInt64(outputType, MediaFoundation.MtPixelAspectRatio, MediaFoundation.Pack(1, 1));
if (_settings.Codec == VideoCodec.H264)
SetUInt32(outputType, MediaFoundation.MtMpeg2Profile, (uint)_settings.Profile);
int hr = transform.SetOutputType(_outputStreamId, outputType, 0);
MediaFoundation.Check(hr, "Impostazione del formato compresso");
}
finally
{
Marshal.ReleaseComObject(outputType);
}
MediaFoundation.Check(MediaFoundation.MFCreateMediaType(out IMFMediaType inputType), "Creazione del tipo d'ingresso");
try
{
SetGuid(inputType, MediaFoundation.MtMajorType, MediaFoundation.MajorTypeVideo);
SetGuid(inputType, MediaFoundation.MtSubtype, MediaFoundation.VideoFormatNv12);
SetUInt32(inputType, MediaFoundation.MtInterlaceMode, MediaFoundation.InterlaceModeProgressive);
SetUInt64(inputType, MediaFoundation.MtFrameSize, MediaFoundation.Pack((uint)_width, (uint)_height));
SetUInt64(inputType, MediaFoundation.MtFrameRate, MediaFoundation.Pack(fpsNumerator, fpsDenominator));
SetUInt64(inputType, MediaFoundation.MtPixelAspectRatio, MediaFoundation.Pack(1, 1));
SetUInt32(inputType, MediaFoundation.MtYuvMatrix, 2); // BT.709
SetUInt32(inputType, MediaFoundation.MtVideoPrimaries, 3); // BT.709
SetUInt32(inputType, MediaFoundation.MtTransferFunction, 5); // BT.709
SetUInt32(inputType, MediaFoundation.MtVideoNominalRange, 2); // 16-235
int hr = transform.SetInputType(_inputStreamId, inputType, 0);
MediaFoundation.Check(hr, "Impostazione del formato d'ingresso NV12");
}
finally
{
Marshal.ReleaseComObject(inputType);
}
ConfigureCodecApi();
transform.GetOutputStreamInfo(_outputStreamId, out _outputInfo);
ReadSequenceHeader();
}
/// <summary>
/// Configurazione fine dell'encoder. Le B-frame vengono azzerate: senza riordino,
/// l'ordine di decodifica coincide con quello di presentazione e il muxer può usare
/// direttamente le durate della sequenza, comprese quelle variabili.
/// </summary>
private void ConfigureCodecApi()
{
if (_transform is not ICodecAPI codec) return;
TrySet(codec, MediaFoundation.AvEncMpvDefaultBPictureCount, 0);
TrySet(codec, MediaFoundation.AvEncCommonRateControlMode, 0); // bitrate costante
TrySet(codec, MediaFoundation.AvEncCommonMeanBitRate, _settings.AverageBitrate);
uint gop = (uint)Math.Clamp(_settings.KeyframeIntervalSeconds * _settings.FrameRate, 1, 600);
TrySet(codec, MediaFoundation.AvEncMpvgopSize, gop);
static void TrySet(ICodecAPI codec, Guid parameter, uint value)
{
try
{
var variant = PropVariant.FromUInt32(value);
codec.SetValue(ref parameter, ref variant);
}
catch (COMException)
{
// Parametro non supportato dall'encoder: si prosegue con il valore predefinito.
}
}
}
/// <summary>Preleva i parameter set fuori banda, se l'encoder li espone già.</summary>
private void ReadSequenceHeader()
{
if (_transform!.GetOutputCurrentType(_outputStreamId, out IMFMediaType current) < 0) return;
try
{
var key = MediaFoundation.MtMpegSequenceHeader;
if (current.GetBlobSize(ref key, out uint size) < 0 || size == 0) return;
var blob = new byte[size];
uint actual = 0;
if (current.GetBlob(ref key, blob, size, ref actual) < 0) return;
_muxer.AddParameterSets(blob.AsSpan(0, (int)Math.Min(actual == 0 ? size : actual, size)));
}
finally
{
Marshal.ReleaseComObject(current);
}
}
private void StartStreaming()
{
var transform = _transform!;
if (_async)
{
var generator = transform as IMFMediaEventGenerator
?? throw new VideoEncoderException("L'encoder asincrono non espone la coda eventi.");
_events = generator;
}
MediaFoundation.Check(transform.ProcessMessage(MediaFoundation.MessageNotifyBeginStreaming, IntPtr.Zero),
"Avvio dello streaming");
MediaFoundation.Check(transform.ProcessMessage(MediaFoundation.MessageNotifyStartOfStream, IntPtr.Zero),
"Notifica di inizio flusso");
}
// ------------------------------------------------------------------ codifica
/// <summary>
/// Codifica un fotogramma. <paramref name="durationUnits"/> è la durata nella timescale
/// della traccia, quindi ogni fotogramma può restare a schermo per un tempo diverso.
/// </summary>
public unsafe void EncodeFrame(ImageBuffer frame, uint durationUnits)
{
ObjectDisposedException.ThrowIf(_finished, this);
if (frame.Width != _width || frame.Height != _height)
throw new ArgumentException("Il fotogramma non corrisponde alla risoluzione della sessione.");
long durationHns = durationUnits * 10_000_000L / _settings.Timescale;
if (_async) WaitForInputSlot();
int size = Nv12Converter.RequiredSize(_width, _height);
MediaFoundation.Check(MediaFoundation.MFCreateMemoryBuffer((uint)size, out IMFMediaBuffer buffer),
"Allocazione del buffer d'ingresso");
IMFSample? sample = null;
try
{
buffer.Lock(out IntPtr pointer, out _, out _);
try
{
Nv12Converter.Convert(frame, (byte*)pointer);
}
finally
{
buffer.Unlock();
}
buffer.SetCurrentLength((uint)size);
MediaFoundation.Check(MediaFoundation.MFCreateSample(out sample), "Creazione del campione");
sample.AddBuffer(buffer);
sample.SetSampleTime(_presentationTime);
sample.SetSampleDuration(durationHns);
_pendingDurations.Enqueue(durationUnits);
int hr = _transform!.ProcessInput(_inputStreamId, sample, 0);
if (hr < 0)
{
_pendingDurations.Dequeue();
MediaFoundation.Check(hr, "Invio del fotogramma all'encoder");
}
_presentationTime += durationHns;
}
finally
{
if (sample is not null) Marshal.ReleaseComObject(sample);
Marshal.ReleaseComObject(buffer);
}
if (!_async)
{
while (TryDrainOutput()) { }
}
}
/// <summary>Chiude il flusso, svuota l'encoder e finalizza il contenitore.</summary>
public void Finish()
{
if (_finished) return;
_finished = true;
try
{
var transform = _transform;
if (transform is not null)
{
transform.ProcessMessage(MediaFoundation.MessageNotifyEndOfStream, IntPtr.Zero);
transform.ProcessMessage(MediaFoundation.MessageCommandDrain, IntPtr.Zero);
if (_async) PumpUntilDrained();
else
{
while (TryDrainOutput()) { }
}
transform.ProcessMessage(MediaFoundation.MessageNotifyEndStreaming, IntPtr.Zero);
}
}
finally
{
_muxer.Finish();
_output.Dispose();
ReleaseTransform();
}
}
// ------------------------------------------------------------------ pompa eventi (MFT asincrone)
private void WaitForInputSlot()
{
while (_pendingNeedInput == 0)
{
if (!ProcessNextEvent()) throw new VideoEncoderException("L'encoder ha chiuso la coda eventi.");
}
_pendingNeedInput--;
}
private void PumpUntilDrained()
{
int guard = 0;
while (guard++ < 1_000_000)
{
if (!ProcessNextEvent()) return;
if (_drainComplete) return;
}
}
private bool _drainComplete;
private bool ProcessNextEvent()
{
var generator = _events;
if (generator is null) return false;
int hr = generator.GetEvent(0, out IMFMediaEvent mediaEvent);
if (hr < 0 || mediaEvent is null) return false;
try
{
if (mediaEvent.GetEventType(out uint type) < 0) return true;
switch (type)
{
case MediaFoundation.EventTransformNeedInput:
_pendingNeedInput++;
break;
case MediaFoundation.EventTransformHaveOutput:
TryDrainOutput();
break;
case MediaFoundation.EventTransformDrainComplete:
_drainComplete = true;
break;
}
return true;
}
finally
{
Marshal.ReleaseComObject(mediaEvent);
}
}
/// <summary>Estrae un pacchetto compresso dall'encoder e lo consegna al multiplexer.</summary>
private bool TryDrainOutput()
{
var transform = _transform;
if (transform is null) return false;
bool providesSamples = (_outputInfo.Flags & MediaFoundation.OutputStreamProvidesSamples) != 0;
IMFSample? ownSample = null;
IMFMediaBuffer? ownBuffer = null;
IntPtr providedPointer = IntPtr.Zero;
var descriptor = new MftOutputDataBuffer { StreamId = _outputStreamId };
try
{
if (!providesSamples)
{
uint size = Math.Max(_outputInfo.Size, (uint)(_width * _height));
MediaFoundation.Check(MediaFoundation.MFCreateMemoryBuffer(size, out ownBuffer),
"Allocazione del buffer di uscita");
MediaFoundation.Check(MediaFoundation.MFCreateSample(out ownSample), "Creazione del campione di uscita");
ownSample.AddBuffer(ownBuffer);
providedPointer = Marshal.GetIUnknownForObject(ownSample);
descriptor.Sample = providedPointer;
}
int hr = transform.ProcessOutput(0, 1, ref descriptor, out _);
if (hr == MediaFoundation.ErrorTransformNeedMoreInput) return false;
if (hr == MediaFoundation.ErrorTransformStreamChange)
{
RenegotiateOutputType();
return true;
}
MediaFoundation.Check(hr, "Estrazione del pacchetto compresso");
IMFSample? produced = providesSamples
? (descriptor.Sample != IntPtr.Zero ? (IMFSample)Marshal.GetObjectForIUnknown(descriptor.Sample) : null)
: ownSample;
if (produced is null) return false;
try
{
ConsumeSample(produced);
}
finally
{
if (providesSamples) Marshal.ReleaseComObject(produced);
}
return true;
}
finally
{
if (descriptor.Events != IntPtr.Zero) Marshal.Release(descriptor.Events);
if (providesSamples && descriptor.Sample != IntPtr.Zero) Marshal.Release(descriptor.Sample);
if (providedPointer != IntPtr.Zero) Marshal.Release(providedPointer);
if (ownSample is not null) Marshal.ReleaseComObject(ownSample);
if (ownBuffer is not null) Marshal.ReleaseComObject(ownBuffer);
}
}
private unsafe void ConsumeSample(IMFSample sample)
{
sample.ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
try
{
uint length;
buffer.Lock(out IntPtr pointer, out _, out length);
try
{
if (length == 0) return;
if (_packetBuffer.Length < length) _packetBuffer = new byte[Math.Max(length, (uint)_packetBuffer.Length * 2)];
Marshal.Copy(pointer, _packetBuffer, 0, (int)length);
}
finally
{
buffer.Unlock();
}
uint duration = _pendingDurations.Count > 0
? _pendingDurations.Dequeue()
: (uint)Math.Max(1, _settings.Timescale / Math.Max(1.0, _settings.FrameRate));
_muxer.WriteSample(_packetBuffer.AsSpan(0, (int)length), duration);
EncodedFrames++;
}
finally
{
Marshal.ReleaseComObject(buffer);
}
}
/// <summary>L'encoder può richiedere di riconfermare il tipo di uscita dopo la negoziazione iniziale.</summary>
private void RenegotiateOutputType()
{
var transform = _transform!;
if (transform.GetOutputAvailableType(_outputStreamId, 0, out IMFMediaType type) < 0) return;
try
{
transform.SetOutputType(_outputStreamId, type, 0);
transform.GetOutputStreamInfo(_outputStreamId, out _outputInfo);
ReadSequenceHeader();
}
finally
{
Marshal.ReleaseComObject(type);
}
}
// ------------------------------------------------------------------ utilità
private static void SetGuid(IMFMediaType type, Guid key, Guid value) => type.SetGUID(ref key, ref value);
private static void SetUInt32(IMFMediaType type, Guid key, uint value) => type.SetUINT32(ref key, value);
private static void SetUInt64(IMFMediaType type, Guid key, ulong value) => type.SetUINT64(ref key, value);
private void ReleaseTransform()
{
if (_events is not null && !ReferenceEquals(_events, _transform))
{
Marshal.ReleaseComObject(_events);
}
_events = null;
if (_transform is not null)
{
Marshal.ReleaseComObject(_transform);
_transform = null;
}
}
private void TryDeletePartialFile()
{
try
{
if (File.Exists(_settings.OutputPath)) File.Delete(_settings.OutputPath);
}
catch (IOException) { /* il file resta, verrà sovrascritto al tentativo successivo */ }
catch (UnauthorizedAccessException) { }
}
public void Dispose() => Finish();
}
/// <summary>Inizializzazione una tantum della piattaforma Media Foundation.</summary>
internal static class MediaFoundationRuntime
{
private static int _started;
public static void Startup()
{
if (Interlocked.Exchange(ref _started, 1) != 0) return;
int hr = MediaFoundation.MFStartup(MediaFoundation.Version, MediaFoundation.StartupLite);
if (hr < 0)
{
Interlocked.Exchange(ref _started, 0);
throw new VideoEncoderException($"Inizializzazione di Media Foundation non riuscita (HRESULT 0x{hr:X8}).");
}
AppDomain.CurrentDomain.ProcessExit += (_, _) => MediaFoundation.MFShutdown();
}
}