Compare commits
6
Commits
165eff627b
...
3d51e98ca3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d51e98ca3 | ||
|
|
a815a91006 | ||
|
|
332f02d17d | ||
|
|
e476ebb648 | ||
|
|
5d8798f1f7 | ||
|
|
cc1d040ac0 |
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
// Fornisce il debugger "coreclr" richiesto da launch.json.
|
||||
"ms-dotnettools.csharp",
|
||||
"ms-dotnettools.csdevkit"
|
||||
]
|
||||
}
|
||||
Vendored
+43
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+95
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 Fritsch–Carlson) 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 Fritsch–Carlson 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 Fritsch–Carlson.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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><xmp:CreateDate>...</></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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace Titano.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Trasformata di Fourier discreta, implementata in-house con lo schema di Cooley–Tukey
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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 +(1−t)·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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 Lucas–Kanade).
|
||||
///
|
||||
/// 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 i−1 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 | Cooley–Tukey 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 Fritsch–Carlson): 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.
|
||||
@@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="Titano.csproj" />
|
||||
</Solution>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user