Applicazione desktop completa per creazione e ottimizzazione di time-lapse. Il vincolo che ne definisce l'architettura è l'assenza totale di componenti di terze parti: il progetto non ha alcun PackageReference e non invoca processi esterni. Oltre alla libreria standard di .NET si usano solo API native di Windows (WIC, Media Foundation, GDI+, DWM) richiamate via P/Invoke scritto a mano. Sono in-house tutte le parti che di norma si delegherebbero a una libreria: il parser binario EXIF/XMP, la misura di luminanza e la curva di deflicker, il calcolo del campo vettoriale di movimento con il motion blur sintetico, il multiplexer MP4 e ogni controllo dell'interfaccia. Scelte algoritmiche che meritano una nota: - il deflicker usa una regressione lineare locale pesata con seconda passata robusta, così le rampe reali di luce (alba, tramonto) sopravvivono mentre lo sfarfallio del diaframma viene rimosso; una media mobile semplice le appiattirebbe entrambe; - la sfocatura mancante si compone in quadratura con quella già incisa nello scatto, perché sommarla linearmente renderebbe l'immagine troppo morbida; - la luminanza si misura come media logaritmica troncata, invariante alla scala e insensibile a cieli bruciati e ombre chiuse. L'elaborazione non produce file temporanei e mantiene un'occupazione di memoria stazionaria: buffer poolati e canale a capacità limitata rendono i fotogrammi vivi indipendenti dalla lunghezza della sequenza. Verificato con "Titano.exe --selftest": 23 controlli su una sequenza sintetica dalle proprietà note, incluse la struttura del contenitore prodotto e la sua ri-decodifica con il lettore di sistema. Tutti superati. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
4.0 KiB
C#
117 lines
4.0 KiB
C#
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);
|
|
}
|
|
}
|