Aggiunge l'importazione da scheda di memoria e riordina l'interfaccia attorno a quello che ciascuna sezione serve a fare. L'importazione riconosce i supporti collegati, cerca nelle sottocartelle e prima di toccare il disco mostra il piano: quali cartelle nasceranno e che nome avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e guardare prima costa un istante. Fa poi tre cose che a mano si sbagliano sempre: separa le sessioni sulla pausa fra due scatti, invece di lasciare piu' riprese mescolate in una cartella sola; costruisce i nomi da modelli con segnaposto, perche' un archivio si consulta anni dopo e il nome e' l'unica cosa leggibile senza aprire nulla; e rilegge quello che ha scritto confrontandone l'impronta, perche' una scheda che si scollega a meta' copia produce file della dimensione giusta e del contenuto troncato, e il danno si scopre mesi dopo. Sulla conversione in DNG va detto con precisione cosa si puo' e non si puo' fare. Un DNG vero contiene i valori del sensore prima dell'interpolazione cromatica; ottenerlo da un formato proprietario richiede la libreria del produttore, che il vincolo sulle dipendenze esclude. Quello che si ottiene in-house e' un DNG lineare - la specifica lo prevede, i pixel sono gia' interpolati, il file e' valido e apribile ovunque ma non restituisce la liberta' del grezzo. La copia resta quindi la scelta predefinita, e l'interfaccia lo dice invece di lasciarlo intuire. Il contenitore TIFF/DNG e' scritto a mano come il multiplexer MP4, e la verifica lo rilegge con il parser di questo stesso programma: due implementazioni indipendenti dello stesso formato, e al primo confronto e' saltato fuori un errore di dodici byte per voce nel calcolo degli scostamenti - la directory Exif finiva oltre il puntatore che la indicava. Le spiegazioni passano dalle note stampate ai suggerimenti a comparsa. Una nota sotto un cursore occupa spazio a chi la conosce gia', quindi deve restare corta; un suggerimento che appare solo quando serve non ha quel vincolo e puo' dire l'unica cosa che conta - perche' quel parametro esiste e cosa succede a spostarlo nel verso sbagliato. Ogni sezione guadagna un comando Ottimizza che rileva le impostazioni migliori per quella sola parte e riferisce cosa ha cambiato e perche'. Si distingue dal pilota automatico, che lavora di continuo sui parametri deducibili senza ambiguita': l'ottimizzazione si chiede a mano perche' accende e spegne interi moduli, e sostituire quelle scelte in silenzio sarebbe peggio che lasciarle sbagliate. Dove il dato manca non tira a indovinare: lo dice. L'uscita guadagna rapporto e ritaglio. Cambiare rapporto non deforma piu' l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il fotogramma, il che ha anche corretto un difetto latente dello stadio geometrico, che con rapporti diversi fra sorgente e uscita stirava invece di tagliare. La zona da tenere si sceglie trascinandola. I comandi seguono la sezione: importazione e analisi compaiono solo dove servono. Un pulsante che non ha senso dove ci si trova non va disabilitato ma tolto, perche' disabilitato resta un ingombro che chiede perche' non funziona. Le preferenze dell'applicazione non mostrano piu' anteprima, riepilogo della sequenza ne' riga di stato che ne parli: non riguardano la sequenza caricata, e tenerle accanto confondeva due piani diversi. Via anche le descrizioni inutili: la finestra si chiama Titano e basta. Due difetti trovati durante la verifica a video. La barra di navigazione nasceva sulla prima voce, che ora e' Archivio, quindi il selettore usciva subito perche' l'indice coincideva e la sezione non veniva mai mostrata: barra su una voce, contenuto su un'altra. E il posizionamento dei pulsanti leggeva Visible, che in WinForms resta falso finche' la finestra non e' stata mostrata perche' riporta la visibilita' dell'intera catena: alla costruzione nessun pulsante veniva collocato e restavano tutti impilati sull'angolo. Verifica: da 54 a 60 controlli. I nuovi coprono i modelli di percorso, la sostituzione dei caratteri illegali, il rifiuto dei segnaposto inventati, il riconoscimento delle sessioni e la rilettura del DNG scritto - sia dal parser interno sia dal decodificatore di sistema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
9.0 KiB
C#
202 lines
9.0 KiB
C#
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;
|
|
|
|
// Il ritaglio prende il rapporto dell'uscita, non quello della sorgente: altrimenti
|
|
// passare da 4:3 a 16:9 stirerebbe l'immagine invece di tagliarla. A zoom uno si
|
|
// prende il rettangolo più grande con quel rapporto che ci sta dentro.
|
|
double zoom = Math.Max(1e-3, framing.Zoom);
|
|
double outputAspect = outputHeight > 0 ? outputWidth / (double)outputHeight
|
|
: sourceWidth / (double)Math.Max(1, sourceHeight);
|
|
double baseWidth = Math.Min(sourceWidth, sourceHeight * outputAspect);
|
|
double baseHeight = baseWidth / Math.Max(1e-6, outputAspect);
|
|
|
|
double cropWidth = baseWidth / zoom;
|
|
double cropHeight = baseHeight / 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;
|
|
}
|
|
}
|