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>
382 lines
16 KiB
C#
382 lines
16 KiB
C#
using System.Buffers.Binary;
|
|
using System.Drawing.Imaging;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using Titano.Metadata;
|
|
|
|
namespace Titano.Imaging;
|
|
|
|
/// <summary>Formato in cui l'importazione scrive i file convertiti.</summary>
|
|
public enum RasterFormat
|
|
{
|
|
/// <summary>TIFF a 16 bit per canale, codificato in sRGB: il formato di scambio classico.</summary>
|
|
Tiff16,
|
|
|
|
/// <summary>DNG lineare a 16 bit: stessi pixel, in luce lineare e con i tag che lo rendono un DNG.</summary>
|
|
LinearDng,
|
|
|
|
/// <summary>JPEG, tramite il codificatore di sistema.</summary>
|
|
Jpeg,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scrittura di immagini su file, con il contenitore costruito a mano.
|
|
///
|
|
/// <b>Cosa è, e cosa non è, il DNG che questo produce.</b> Un DNG "vero" contiene i valori
|
|
/// grezzi del sensore, prima dell'interpolazione cromatica, ed è quello che permette a un
|
|
/// programma di sviluppo di rifare da capo il bilanciamento del bianco e la demosaicizzazione.
|
|
/// Per ottenerlo da un formato proprietario servirebbe interpretarne il sensore modello per
|
|
/// modello — cioè la libreria del produttore, che il vincolo sulle dipendenze esclude.
|
|
///
|
|
/// Quello che si può fare in-house è un <i>DNG lineare</i>: la specifica lo prevede
|
|
/// (PhotometricInterpretation 34892) e contiene pixel già interpolati, in luce lineare, a 16
|
|
/// bit. È un DNG valido e apribile ovunque, ma non restituisce la libertà di sviluppo del
|
|
/// grezzo. Chi vuole conservare quella libertà copia il file com'è, e infatti la copia resta
|
|
/// l'impostazione predefinita dell'importazione.
|
|
/// </summary>
|
|
public static class RasterWriter
|
|
{
|
|
private const ushort TypeByte = 1;
|
|
private const ushort TypeAscii = 2;
|
|
private const ushort TypeShort = 3;
|
|
private const ushort TypeLong = 4;
|
|
private const ushort TypeRational = 5;
|
|
private const ushort TypeSRational = 10;
|
|
|
|
public static string ExtensionFor(RasterFormat format) => format switch
|
|
{
|
|
RasterFormat.Tiff16 => ".tif",
|
|
RasterFormat.LinearDng => ".dng",
|
|
_ => ".jpg",
|
|
};
|
|
|
|
public static void Write(ImageBuffer frame, string path, FrameMetadata metadata,
|
|
RasterFormat format, int jpegQuality)
|
|
{
|
|
switch (format)
|
|
{
|
|
case RasterFormat.Jpeg:
|
|
WriteJpeg(frame, path, jpegQuality);
|
|
break;
|
|
default:
|
|
WriteTiff(frame, path, metadata, format == RasterFormat.LinearDng);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ JPEG
|
|
|
|
private static unsafe void WriteJpeg(ImageBuffer frame, string path, int quality)
|
|
{
|
|
using var bitmap = new Bitmap(frame.Width, frame.Height, PixelFormat.Format24bppRgb);
|
|
var locked = bitmap.LockBits(new Rectangle(0, 0, frame.Width, frame.Height),
|
|
ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
|
|
try
|
|
{
|
|
var data = frame.Data;
|
|
byte* basePtr = (byte*)locked.Scan0;
|
|
|
|
for (int y = 0; y < frame.Height; y++)
|
|
{
|
|
byte* row = basePtr + (long)y * locked.Stride;
|
|
int source = y * frame.Width * ImageBuffer.Channels;
|
|
for (int x = 0; x < frame.Width; x++)
|
|
{
|
|
int i = source + x * ImageBuffer.Channels;
|
|
byte* pixel = row + x * 3;
|
|
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]); // B
|
|
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]); // G
|
|
pixel[2] = ColorSpace.ToSrgbByte(data[i]); // R
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
bitmap.UnlockBits(locked);
|
|
}
|
|
|
|
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
|
|
using var parameters = new EncoderParameters(1);
|
|
using var setting = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality,
|
|
(long)Math.Clamp(quality, 40, 100));
|
|
parameters.Param[0] = setting;
|
|
bitmap.Save(path, codec, parameters);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ TIFF e DNG
|
|
|
|
/// <summary>Voce di una directory TIFF, con il valore già ridotto a byte.</summary>
|
|
private sealed record Entry(ushort Tag, ushort Type, uint Count, byte[] Payload)
|
|
{
|
|
public bool Inline => Payload.Length <= 4;
|
|
}
|
|
|
|
private static void WriteTiff(ImageBuffer frame, string path, FrameMetadata metadata, bool asDng)
|
|
{
|
|
int width = frame.Width;
|
|
int height = frame.Height;
|
|
long pixelBytes = (long)width * height * 3 * sizeof(ushort);
|
|
|
|
var exif = BuildExifEntries(metadata, width, height);
|
|
var main = BuildMainEntries(metadata, width, height, asDng);
|
|
|
|
// Disposizione: intestazione, directory principale, directory Exif, valori fuori
|
|
// linea, pixel. Le dimensioni si conoscono tutte in anticipo, quindi gli scostamenti
|
|
// si calcolano senza dover tornare indietro a correggerli.
|
|
// Tre voci vengono aggiunte in coda — puntatore alla Exif, posizione e lunghezza dei
|
|
// pixel — e vanno contate qui: sbagliare di una voce sposta tutto ciò che segue di
|
|
// dodici byte, e il file resta apribile ma con i valori presi dal posto sbagliato.
|
|
const int appended = 3;
|
|
long ifdOffset = 8;
|
|
long ifdSize = 2 + 12L * (main.Count + appended) + 4;
|
|
long exifOffset = ifdOffset + ifdSize;
|
|
long exifSize = 2 + 12L * exif.Count + 4;
|
|
long valuesOffset = exifOffset + exifSize;
|
|
|
|
long cursor = valuesOffset;
|
|
var placement = new Dictionary<Entry, long>();
|
|
foreach (var entry in main.Concat(exif))
|
|
{
|
|
if (entry.Inline) continue;
|
|
placement[entry] = cursor;
|
|
cursor += entry.Payload.Length;
|
|
if ((cursor & 1) != 0) cursor++; // le voci partono su indirizzo pari
|
|
}
|
|
|
|
long stripOffset = cursor;
|
|
|
|
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None,
|
|
1 << 20, FileOptions.SequentialScan);
|
|
Span<byte> header = stackalloc byte[8];
|
|
header[0] = (byte)'I';
|
|
header[1] = (byte)'I';
|
|
BinaryPrimitives.WriteUInt16LittleEndian(header[2..], 42);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(header[4..], (uint)ifdOffset);
|
|
stream.Write(header);
|
|
|
|
// Le voci vanno in ordine di tag crescente: è un requisito del formato, non un vezzo.
|
|
var mainOrdered = main
|
|
.Append(new Entry(34665, TypeLong, 1, BitConverter.GetBytes((uint)exifOffset)))
|
|
.Append(new Entry(273, TypeLong, 1, BitConverter.GetBytes((uint)stripOffset)))
|
|
.Append(new Entry(279, TypeLong, 1, BitConverter.GetBytes((uint)pixelBytes)))
|
|
.OrderBy(e => e.Tag)
|
|
.ToList();
|
|
|
|
WriteDirectory(stream, mainOrdered, placement, 0);
|
|
WriteDirectory(stream, [.. exif.OrderBy(e => e.Tag)], placement, 0);
|
|
|
|
foreach (var entry in mainOrdered.Concat(exif))
|
|
{
|
|
if (entry.Inline) continue;
|
|
stream.Position = placement[entry];
|
|
stream.Write(entry.Payload);
|
|
}
|
|
|
|
stream.Position = stripOffset;
|
|
WritePixels(stream, frame, linear: asDng);
|
|
}
|
|
|
|
private static void WriteDirectory(Stream stream, List<Entry> entries,
|
|
Dictionary<Entry, long> placement, uint nextIfd)
|
|
{
|
|
Span<byte> count = stackalloc byte[2];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(count, (ushort)entries.Count);
|
|
stream.Write(count);
|
|
|
|
Span<byte> record = stackalloc byte[12];
|
|
foreach (var entry in entries)
|
|
{
|
|
record.Clear();
|
|
BinaryPrimitives.WriteUInt16LittleEndian(record, entry.Tag);
|
|
BinaryPrimitives.WriteUInt16LittleEndian(record[2..], entry.Type);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(record[4..], entry.Count);
|
|
|
|
if (entry.Inline) entry.Payload.CopyTo(record[8..]);
|
|
else BinaryPrimitives.WriteUInt32LittleEndian(record[8..], (uint)placement[entry]);
|
|
|
|
stream.Write(record);
|
|
}
|
|
|
|
Span<byte> next = stackalloc byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(next, nextIfd);
|
|
stream.Write(next);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pixel a 16 bit. Il DNG lineare vuole luce lineare, che è ciò che il buffer contiene;
|
|
/// il TIFF va invece codificato in sRGB, perché è come qualunque visualizzatore lo
|
|
/// interpreterà. Scrivere gli stessi numeri in entrambi darebbe un TIFF molto scuro.
|
|
/// </summary>
|
|
private static void WritePixels(Stream stream, ImageBuffer frame, bool linear)
|
|
{
|
|
int width = frame.Width;
|
|
var data = frame.Data;
|
|
var row = new byte[width * 3 * sizeof(ushort)];
|
|
|
|
for (int y = 0; y < frame.Height; y++)
|
|
{
|
|
int source = y * width * ImageBuffer.Channels;
|
|
for (int x = 0; x < width; x++)
|
|
{
|
|
int i = source + x * ImageBuffer.Channels;
|
|
for (int channel = 0; channel < 3; channel++)
|
|
{
|
|
float value = data[i + channel];
|
|
float encoded = linear ? Math.Clamp(value, 0f, 1f) : ColorSpace.ToSrgb(value);
|
|
ushort level = (ushort)Math.Clamp((int)(encoded * 65535f + 0.5f), 0, 65535);
|
|
BinaryPrimitives.WriteUInt16LittleEndian(row.AsSpan((x * 3 + channel) * 2), level);
|
|
}
|
|
}
|
|
stream.Write(row);
|
|
}
|
|
}
|
|
|
|
private static List<Entry> BuildMainEntries(FrameMetadata metadata, int width, int height, bool asDng)
|
|
{
|
|
var entries = new List<Entry>
|
|
{
|
|
Long(254, 0),
|
|
Long(256, (uint)width),
|
|
Long(257, (uint)height),
|
|
Shorts(258, [16, 16, 16]),
|
|
Short(259, 1), // nessuna compressione
|
|
Short(262, asDng ? (ushort)34892 : (ushort)2), // LinearRaw oppure RGB
|
|
Short(274, 1),
|
|
Short(277, 3),
|
|
Long(278, (uint)height), // una striscia sola
|
|
Rational(282, 72, 1),
|
|
Rational(283, 72, 1),
|
|
Short(296, 2),
|
|
Ascii(305, "Titano"),
|
|
Shorts(339, [1, 1, 1]), // interi senza segno
|
|
};
|
|
|
|
if (metadata.Camera is { Length: > 0 } camera)
|
|
{
|
|
entries.Add(Ascii(271, camera));
|
|
entries.Add(Ascii(272, camera));
|
|
}
|
|
if (metadata.CaptureTime is { } capture)
|
|
{
|
|
entries.Add(Ascii(306, capture.ToString("yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
if (!asDng) return entries;
|
|
|
|
entries.Add(Bytes(50706, [1, 4, 0, 0])); // DNGVersion
|
|
entries.Add(Bytes(50707, [1, 1, 0, 0])); // DNGBackwardVersion
|
|
entries.Add(Ascii(50708, metadata.Camera ?? "Titano linear"));
|
|
entries.Add(Rationals(50714, [(0, 1)])); // BlackLevel
|
|
entries.Add(Long(50717, 65535)); // WhiteLevel
|
|
entries.Add(Short(50778, 21)); // illuminante di calibrazione: D65
|
|
|
|
// Lo spazio "della fotocamera" qui è l'sRGB lineare, perché è da lì che arrivano i
|
|
// pixel: la matrice dichiarata è quindi la XYZ verso sRGB, non una risposta misurata
|
|
// su un sensore. Dichiararne una inventata sarebbe peggio che dichiarare questa.
|
|
entries.Add(SRationals(50721,
|
|
[
|
|
(3204454, 1000000), (-1537139, 1000000), (-498531, 1000000),
|
|
(-969266, 1000000), (1876011, 1000000), (41556, 1000000),
|
|
(55643, 1000000), (-204026, 1000000), (1057225, 1000000),
|
|
]));
|
|
entries.Add(Rationals(50728, [(1, 1), (1, 1), (1, 1)])); // AsShotNeutral
|
|
|
|
return entries;
|
|
}
|
|
|
|
private static List<Entry> BuildExifEntries(FrameMetadata metadata, int width, int height)
|
|
{
|
|
var entries = new List<Entry>
|
|
{
|
|
Long(40962, (uint)width),
|
|
Long(40963, (uint)height),
|
|
};
|
|
|
|
if (metadata.ExposureSeconds is { } exposure && exposure > 0)
|
|
{
|
|
var (numerator, denominator) = ToRational(exposure);
|
|
entries.Add(Rational(33434, numerator, denominator));
|
|
}
|
|
if (metadata.FNumber is { } aperture && aperture > 0)
|
|
{
|
|
entries.Add(Rational(33437, (uint)Math.Round(aperture * 100), 100));
|
|
}
|
|
if (metadata.Iso is { } iso && iso > 0)
|
|
{
|
|
entries.Add(Short(34855, (ushort)Math.Min(iso, ushort.MaxValue)));
|
|
}
|
|
if (metadata.FocalLength is { } focal && focal > 0)
|
|
{
|
|
entries.Add(Rational(37386, (uint)Math.Round(focal * 100), 100));
|
|
}
|
|
if (metadata.CaptureTime is { } capture)
|
|
{
|
|
entries.Add(Ascii(36867, capture.ToString("yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/// <summary>Frazione che rappresenta un tempo di posa senza perdere i valori tipici (1/125, 1/8000).</summary>
|
|
private static (uint Numerator, uint Denominator) ToRational(double value)
|
|
{
|
|
if (value >= 1) return ((uint)Math.Round(value * 1000), 1000);
|
|
double inverse = 1.0 / value;
|
|
return (1, (uint)Math.Clamp(Math.Round(inverse), 1, uint.MaxValue));
|
|
}
|
|
|
|
// ---- costruttori di voce
|
|
|
|
private static Entry Short(ushort tag, ushort value)
|
|
{
|
|
var payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(payload, value);
|
|
return new Entry(tag, TypeShort, 1, payload);
|
|
}
|
|
|
|
private static Entry Shorts(ushort tag, ushort[] values)
|
|
{
|
|
var payload = new byte[values.Length * 2];
|
|
for (int i = 0; i < values.Length; i++)
|
|
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(i * 2), values[i]);
|
|
return new Entry(tag, TypeShort, (uint)values.Length, payload);
|
|
}
|
|
|
|
private static Entry Long(ushort tag, uint value)
|
|
=> new(tag, TypeLong, 1, BitConverter.GetBytes(value));
|
|
|
|
private static Entry Bytes(ushort tag, byte[] values)
|
|
=> new(tag, TypeByte, (uint)values.Length, values);
|
|
|
|
private static Entry Ascii(ushort tag, string value)
|
|
{
|
|
var payload = Encoding.ASCII.GetBytes(value + "\0");
|
|
return new Entry(tag, TypeAscii, (uint)payload.Length, payload);
|
|
}
|
|
|
|
private static Entry Rational(ushort tag, uint numerator, uint denominator)
|
|
=> Rationals(tag, [(numerator, denominator)]);
|
|
|
|
private static Entry Rationals(ushort tag, (uint Numerator, uint Denominator)[] values)
|
|
{
|
|
var payload = new byte[values.Length * 8];
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(i * 8), values[i].Numerator);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(i * 8 + 4), values[i].Denominator);
|
|
}
|
|
return new Entry(tag, TypeRational, (uint)values.Length, payload);
|
|
}
|
|
|
|
private static Entry SRationals(ushort tag, (int Numerator, int Denominator)[] values)
|
|
{
|
|
var payload = new byte[values.Length * 8];
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(i * 8), values[i].Numerator);
|
|
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(i * 8 + 4), values[i].Denominator);
|
|
}
|
|
return new Entry(tag, TypeSRational, (uint)values.Length, payload);
|
|
}
|
|
}
|