Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che governa insieme il contenuto principale e la colonna delle impostazioni: una sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da tenere allineate a mano; adesso ce n'e' una sola, in verticale ci sta il nome per esteso, e la sezione scelta resta leggibile mentre si lavora - cosa che in una fila di schede in cima si perde appena l'occhio scende sul contenuto. Accanto a ogni voce compare il numero di avvisi che la riguardano. L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto sempre, perche' in un programma che tratta immagini l'immagine si guarda mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile per giudicare e non solo per guardare. Zoom e trascinamento, perche' una stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono invece di essere interpolati. Confronto a tendina fra originale e corretto, perche' l'unico modo di capire cosa una correzione stia facendo e' vedere accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore calcola comunque e che spiega perche' la sfocatura viene come viene, soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una panoramica virtuale. Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini e pannello degli avvisi sulla sequenza, istogramma e forma d'onda sull'esposizione, percorso ricostruito della stabilizzazione sul movimento, piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e che finivano in due numeri in fondo a una riga di stato, cioe' invisibili. Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu': un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello che il menu dell'orientamento usava da solo, esteso a larghezza di analisi, finestra del deflicker, lunghezza della transizione, finestra della stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del deflicker viene da quattro periodi dello sfarfallio misurati sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi. Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce meno decisioni e lascia il cursore dov'e'. Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e' l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un ostacolo. Gli annunci si leggono da una cartella locale con un listino in formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta montando un time-lapse. Le campagne si aggiornano copiando file; a listino vuoto compaiono note interne; il riquadro si spegne dalle preferenze. Aggiunge anche le preferenze dell'applicazione, distinte da quelle del progetto, con persistenza in un file di testo scritto a mano nello stesso spirito del resto: una riga per voce, correggibile con un editor. Il progetto descrive come trattare questi fotogrammi, le preferenze come si comporta il programma. Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche' trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e anteprima dei fotogrammi mentre vengono codificati, che non migliora il risultato di un pixel ma cambia molto un'attesa di mezz'ora. Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le sezioni ispezionate a video sulla scena di prova. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
9.8 KiB
C#
254 lines
9.8 KiB
C#
using Titano.Imaging;
|
|
|
|
namespace Titano.UI;
|
|
|
|
/// <summary>
|
|
/// Misure di distribuzione di un fotogramma, calcolate una volta sola dal buffer
|
|
/// dell'anteprima e riusate dai due strumenti che le mostrano.
|
|
/// </summary>
|
|
internal sealed class FrameScopes
|
|
{
|
|
public const int Levels = 256;
|
|
public const int WaveformColumns = 320;
|
|
public const int WaveformRows = 128;
|
|
|
|
public required int[] Red { get; init; }
|
|
public required int[] Green { get; init; }
|
|
public required int[] Blue { get; init; }
|
|
public required int Peak { get; init; }
|
|
|
|
/// <summary>Densità della forma d'onda: colonne dell'immagine per livelli di luminanza.</summary>
|
|
public required byte[] Waveform { get; init; }
|
|
|
|
public required double ClippedFraction { get; init; }
|
|
public required double BlackFraction { get; init; }
|
|
|
|
/// <summary>
|
|
/// Calcola istogramma e forma d'onda dal buffer in luce lineare, convertendo in sRGB:
|
|
/// è lo spazio in cui il fotogramma verrà mostrato e codificato, e quindi l'unico in cui
|
|
/// "saturo" e "nero" vogliono dire quello che l'occhio vede.
|
|
/// </summary>
|
|
public static FrameScopes Compute(ImageBuffer frame)
|
|
{
|
|
var red = new int[Levels];
|
|
var green = new int[Levels];
|
|
var blue = new int[Levels];
|
|
var waveform = new byte[WaveformColumns * WaveformRows];
|
|
|
|
var data = frame.Data;
|
|
int width = frame.Width;
|
|
int height = frame.Height;
|
|
|
|
// Un campionamento a griglia tiene il costo costante anche su un fotogramma da 12 MP:
|
|
// per una distribuzione bastano poche centinaia di migliaia di campioni.
|
|
int step = Math.Max(1, (int)Math.Sqrt(frame.PixelCount / 240_000.0));
|
|
long clipped = 0, black = 0, counted = 0;
|
|
var columnCounts = new int[WaveformColumns * WaveformRows];
|
|
|
|
for (int y = 0; y < height; y += step)
|
|
{
|
|
int rowBase = y * width * ImageBuffer.Channels;
|
|
for (int x = 0; x < width; x += step)
|
|
{
|
|
int i = rowBase + x * ImageBuffer.Channels;
|
|
byte r = ColorSpace.ToSrgbByte(data[i]);
|
|
byte g = ColorSpace.ToSrgbByte(data[i + 1]);
|
|
byte b = ColorSpace.ToSrgbByte(data[i + 2]);
|
|
|
|
red[r]++;
|
|
green[g]++;
|
|
blue[b]++;
|
|
counted++;
|
|
|
|
int luma = (r * 54 + g * 183 + b * 19) >> 8;
|
|
if (luma >= 253) clipped++;
|
|
else if (luma <= 2) black++;
|
|
|
|
int column = x * WaveformColumns / width;
|
|
int row = (Levels - 1 - luma) * WaveformRows / Levels;
|
|
columnCounts[row * WaveformColumns + Math.Min(column, WaveformColumns - 1)]++;
|
|
}
|
|
}
|
|
|
|
// Normalizzazione logaritmica: senza, la forma d'onda di un cielo notturno sarebbe
|
|
// una riga sola in basso e tutto il resto invisibile.
|
|
int densest = 1;
|
|
foreach (int value in columnCounts) densest = Math.Max(densest, value);
|
|
double scale = 255.0 / Math.Log(1 + densest);
|
|
for (int i = 0; i < columnCounts.Length; i++)
|
|
{
|
|
waveform[i] = columnCounts[i] == 0 ? (byte)0 : (byte)Math.Clamp(Math.Log(1 + columnCounts[i]) * scale, 0, 255);
|
|
}
|
|
|
|
int peak = 1;
|
|
for (int i = 1; i < Levels - 1; i++)
|
|
{
|
|
peak = Math.Max(peak, Math.Max(red[i], Math.Max(green[i], blue[i])));
|
|
}
|
|
|
|
return new FrameScopes
|
|
{
|
|
Red = red,
|
|
Green = green,
|
|
Blue = blue,
|
|
Peak = peak,
|
|
Waveform = waveform,
|
|
ClippedFraction = counted > 0 ? clipped / (double)counted : 0,
|
|
BlackFraction = counted > 0 ? black / (double)counted : 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Istogramma e forma d'onda del fotogramma in anteprima.
|
|
///
|
|
/// Servono a una cosa sola ma importante: rendere ovvio il clipping. È la condizione che
|
|
/// rovina più decisioni a valle — sui file reali ha guastato insieme stabilizzazione,
|
|
/// segmentazione e riconoscimento dei cambi di esposizione — e finora si leggeva soltanto
|
|
/// come una percentuale in una colonna della tabella, dove nessuno la guarda.
|
|
/// </summary>
|
|
internal sealed class ScopesPanel : Control
|
|
{
|
|
private FrameScopes? _scopes;
|
|
private bool _waveform;
|
|
|
|
public ScopesPanel()
|
|
{
|
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
|
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
|
BackColor = Theme.Surface;
|
|
Height = 132;
|
|
Cursor = Cursors.Hand;
|
|
}
|
|
|
|
/// <summary>Alterna istogramma e forma d'onda: dicono la stessa cosa in due modi diversi.</summary>
|
|
public bool ShowWaveform
|
|
{
|
|
get => _waveform;
|
|
set { _waveform = value; Invalidate(); }
|
|
}
|
|
|
|
public void SetScopes(FrameScopes? scopes)
|
|
{
|
|
_scopes = scopes;
|
|
Invalidate();
|
|
}
|
|
|
|
protected override void OnMouseDown(MouseEventArgs e)
|
|
{
|
|
if (e.Button == MouseButtons.Left) ShowWaveform = !ShowWaveform;
|
|
base.OnMouseDown(e);
|
|
}
|
|
|
|
protected override void OnPaint(PaintEventArgs e)
|
|
{
|
|
var g = e.Graphics;
|
|
Theme.HighQuality(g);
|
|
g.Clear(Theme.Surface);
|
|
|
|
var plot = new Rectangle(8, 20, Math.Max(20, Width - 16), Math.Max(20, Height - 28));
|
|
|
|
TextRenderer.DrawText(g, _waveform ? "FORMA D'ONDA" : "ISTOGRAMMA", Theme.SmallBold,
|
|
new Rectangle(10, 2, 200, 16), Theme.TextFaint,
|
|
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
|
|
|
if (_scopes is null)
|
|
{
|
|
TextRenderer.DrawText(g, "nessun fotogramma", Theme.Small, plot, Theme.TextFaint,
|
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
|
return;
|
|
}
|
|
|
|
TextRenderer.DrawText(g,
|
|
$"saturi {_scopes.ClippedFraction * 100:0.00}% neri {_scopes.BlackFraction * 100:0.00}% " +
|
|
$"(clic per cambiare)",
|
|
Theme.Small, new Rectangle(Width - 320, 2, 312, 16),
|
|
_scopes.ClippedFraction > 0.02 ? Theme.Warning : Theme.TextFaint,
|
|
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
|
|
|
using (var frame = new Pen(Theme.Border)) g.DrawRectangle(frame, plot);
|
|
|
|
if (_waveform) DrawWaveform(g, plot, _scopes);
|
|
else DrawHistogram(g, plot, _scopes);
|
|
}
|
|
|
|
private static void DrawHistogram(Graphics g, Rectangle plot, FrameScopes scopes)
|
|
{
|
|
var clip = g.Clip;
|
|
g.SetClip(plot);
|
|
|
|
// I tre canali sovrapposti in additiva: dove coincidono si legge il grigio, dove
|
|
// divergono si vede subito quale canale sta andando a fondo scala.
|
|
DrawChannel(g, plot, scopes.Red, scopes.Peak, Color.FromArgb(150, 0xF0, 0x5D, 0x5D));
|
|
DrawChannel(g, plot, scopes.Green, scopes.Peak, Color.FromArgb(150, 0x5D, 0xE0, 0x8A));
|
|
DrawChannel(g, plot, scopes.Blue, scopes.Peak, Color.FromArgb(150, 0x5D, 0x9D, 0xF0));
|
|
|
|
g.Clip = clip;
|
|
|
|
using var guide = new Pen(Color.FromArgb(70, Theme.Text)) { DashStyle = System.Drawing.Drawing2D.DashStyle.Dot };
|
|
for (int i = 1; i < 4; i++)
|
|
{
|
|
float x = plot.Left + plot.Width * i / 4f;
|
|
g.DrawLine(guide, x, plot.Top, x, plot.Bottom);
|
|
}
|
|
}
|
|
|
|
private static void DrawChannel(Graphics g, Rectangle plot, int[] values, int peak, Color color)
|
|
{
|
|
var points = new PointF[FrameScopes.Levels + 2];
|
|
points[0] = new PointF(plot.Left, plot.Bottom);
|
|
|
|
for (int i = 0; i < FrameScopes.Levels; i++)
|
|
{
|
|
float x = plot.Left + plot.Width * i / (float)(FrameScopes.Levels - 1);
|
|
float height = Math.Min(1f, values[i] / (float)peak) * (plot.Height - 3);
|
|
points[i + 1] = new PointF(x, plot.Bottom - height);
|
|
}
|
|
points[^1] = new PointF(plot.Right, plot.Bottom);
|
|
|
|
using var brush = new SolidBrush(color);
|
|
g.FillPolygon(brush, points);
|
|
}
|
|
|
|
private static unsafe void DrawWaveform(Graphics g, Rectangle plot, FrameScopes scopes)
|
|
{
|
|
using var bitmap = new Bitmap(FrameScopes.WaveformColumns, FrameScopes.WaveformRows,
|
|
System.Drawing.Imaging.PixelFormat.Format32bppRgb);
|
|
var locked = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height),
|
|
System.Drawing.Imaging.ImageLockMode.WriteOnly,
|
|
System.Drawing.Imaging.PixelFormat.Format32bppRgb);
|
|
try
|
|
{
|
|
byte* basePtr = (byte*)locked.Scan0;
|
|
for (int y = 0; y < bitmap.Height; y++)
|
|
{
|
|
byte* row = basePtr + (long)y * locked.Stride;
|
|
for (int x = 0; x < bitmap.Width; x++)
|
|
{
|
|
byte density = scopes.Waveform[y * FrameScopes.WaveformColumns + x];
|
|
byte* pixel = row + x * 4;
|
|
pixel[0] = (byte)(density * 0.55f + 0x14); // B
|
|
pixel[1] = (byte)(density * 0.90f + 0x16); // G
|
|
pixel[2] = (byte)(density * 0.70f + 0x14); // R
|
|
pixel[3] = 255;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
bitmap.UnlockBits(locked);
|
|
}
|
|
|
|
var previous = g.InterpolationMode;
|
|
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBilinear;
|
|
g.DrawImage(bitmap, Rectangle.Inflate(plot, -1, -1));
|
|
g.InterpolationMode = previous;
|
|
|
|
// Le due soglie che contano: il bianco e il nero. Una forma d'onda che le tocca
|
|
// dice che l'informazione oltre quel punto non c'è più.
|
|
using var limit = new Pen(Color.FromArgb(120, Theme.Danger));
|
|
g.DrawLine(limit, plot.Left, plot.Top + 1, plot.Right, plot.Top + 1);
|
|
g.DrawLine(limit, plot.Left, plot.Bottom - 1, plot.Right, plot.Bottom - 1);
|
|
}
|
|
}
|