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>
847 lines
33 KiB
C#
847 lines
33 KiB
C#
using System.Drawing.Drawing2D;
|
||
using System.Drawing.Imaging;
|
||
using Titano.Core;
|
||
using Titano.Imaging;
|
||
using Titano.Motion;
|
||
using Titano.Pipeline;
|
||
|
||
namespace Titano.UI;
|
||
|
||
/// <summary>Frecce del campo vettoriale, in coordinate normalizzate sull'immagine mostrata.</summary>
|
||
internal sealed record MotionArrows(PointF[] Origins, PointF[] Vectors, double MedianMagnitude);
|
||
|
||
/// <summary>
|
||
/// Anteprima del fotogramma selezionato, resa dallo stesso motore usato in esportazione.
|
||
///
|
||
/// Tre cose la rendono utilizzabile per giudicare, e non solo per guardare. Lo zoom, perché
|
||
/// una stabilizzazione sotto il pixel o il bordo di una scia non si vedono su un'immagine
|
||
/// rimpicciolita per stare in un riquadro. Il confronto a tendina, perché l'unico modo di
|
||
/// capire cosa una correzione stia facendo è vedere accanto ciò che c'era prima. E la
|
||
/// sovrapposizione del campo vettoriale, che il motore calcola comunque e che spiega in un
|
||
/// colpo d'occhio perché la sfocatura viene come viene — soprattutto quando è sbagliata.
|
||
/// </summary>
|
||
internal sealed class PreviewPanel : Control
|
||
{
|
||
private readonly TitanoProject _project;
|
||
|
||
private Bitmap? _after;
|
||
private Bitmap? _before;
|
||
private PointF[]? _regionContour;
|
||
private MotionArrows? _arrows;
|
||
|
||
private string _caption = string.Empty;
|
||
private string _status = "Nessun fotogramma selezionato";
|
||
private CancellationTokenSource? _pending;
|
||
private int _requestId;
|
||
private bool _busy;
|
||
private bool _live;
|
||
|
||
// ---- stato della vista
|
||
private float _zoom; // 0 = adatta al riquadro
|
||
private PointF _centre = new(0.5f, 0.5f); // punto dell'immagine al centro della vista
|
||
private float _wipe = 1f; // 1 = tutto "dopo", 0 = tutto "prima"
|
||
private bool _panning;
|
||
private bool _draggingWipe;
|
||
private Point _dragOrigin;
|
||
private PointF _centreOrigin;
|
||
|
||
public event EventHandler<FrameScopes?>? ScopesChanged;
|
||
|
||
public PreviewPanel(TitanoProject project)
|
||
{
|
||
_project = project;
|
||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||
BackColor = Theme.Background;
|
||
}
|
||
|
||
/// <summary>Mostra la tendina di confronto fra fotogramma corretto e originale.</summary>
|
||
public bool CompareMode { get; private set; }
|
||
|
||
/// <summary>Disegna il campo vettoriale sopra il fotogramma.</summary>
|
||
public bool ShowMotionField { get; private set; }
|
||
|
||
/// <summary>Disegna il confine fra le regioni del deflicker.</summary>
|
||
public bool ShowRegions { get; private set; } = true;
|
||
|
||
public bool IsZoomed => _zoom > 0;
|
||
|
||
public string ZoomText => _zoom <= 0 ? "adatta" : $"{_zoom * 100:0}%";
|
||
|
||
// ------------------------------------------------------------------ comandi della vista
|
||
|
||
public void SetCompareMode(bool value)
|
||
{
|
||
if (CompareMode == value) return;
|
||
CompareMode = value;
|
||
_wipe = value ? 0.5f : 1f;
|
||
Invalidate();
|
||
RequestRefresh();
|
||
}
|
||
|
||
public void SetMotionField(bool value)
|
||
{
|
||
if (ShowMotionField == value) return;
|
||
ShowMotionField = value;
|
||
RequestRefresh();
|
||
}
|
||
|
||
public void SetRegionOverlay(bool value)
|
||
{
|
||
ShowRegions = value;
|
||
Invalidate();
|
||
}
|
||
|
||
/// <summary>Alterna fra adattamento al riquadro e scala uno a uno.</summary>
|
||
public void ToggleZoom()
|
||
{
|
||
_zoom = _zoom > 0 ? 0 : 1f;
|
||
_centre = new PointF(0.5f, 0.5f);
|
||
Invalidate();
|
||
}
|
||
|
||
public void ResetView()
|
||
{
|
||
_zoom = 0;
|
||
_centre = new PointF(0.5f, 0.5f);
|
||
Invalidate();
|
||
}
|
||
|
||
private event EventHandler? RefreshRequested;
|
||
|
||
/// <summary>Rende disponibile al chiamante la richiesta di rigenerare il fotogramma.</summary>
|
||
public void OnRefreshNeeded(EventHandler handler) => RefreshRequested += handler;
|
||
|
||
private void RequestRefresh() => RefreshRequested?.Invoke(this, EventArgs.Empty);
|
||
|
||
// ------------------------------------------------------------------ contenuto
|
||
|
||
public void Clear()
|
||
{
|
||
Interlocked.Increment(ref _requestId);
|
||
_pending?.Cancel();
|
||
SwapBitmaps(null, null);
|
||
_regionContour = null;
|
||
_arrows = null;
|
||
_live = false;
|
||
_caption = string.Empty;
|
||
_status = "Nessun fotogramma selezionato";
|
||
ScopesChanged?.Invoke(this, null);
|
||
Invalidate();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mostra un fotogramma appena codificato durante l'esportazione. Non passa dalla
|
||
/// pipeline dell'anteprima: è già il risultato, arrivato dall'encoder.
|
||
/// </summary>
|
||
public void ShowLiveFrame(Bitmap frame, string caption, string status)
|
||
{
|
||
Interlocked.Increment(ref _requestId);
|
||
_pending?.Cancel();
|
||
SwapBitmaps(frame, null);
|
||
_regionContour = null;
|
||
_arrows = null;
|
||
_live = true;
|
||
_busy = false;
|
||
_caption = caption;
|
||
_status = status;
|
||
Invalidate();
|
||
}
|
||
|
||
public void EndLiveFrames() => _live = false;
|
||
|
||
/// <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}";
|
||
_live = false;
|
||
_busy = true;
|
||
Invalidate();
|
||
|
||
var project = _project;
|
||
var token = source.Token;
|
||
bool compare = CompareMode;
|
||
bool motion = ShowMotionField;
|
||
|
||
_ = Task.Run(() =>
|
||
{
|
||
try
|
||
{
|
||
var output = Render(project, sequence, index, PreviewSize(), compare, motion, token);
|
||
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
|
||
{
|
||
output.After?.Dispose();
|
||
output.Before?.Dispose();
|
||
return;
|
||
}
|
||
|
||
BeginInvoke(() =>
|
||
{
|
||
if (requestId != Volatile.Read(ref _requestId))
|
||
{
|
||
output.After?.Dispose();
|
||
output.Before?.Dispose();
|
||
return;
|
||
}
|
||
|
||
SwapBitmaps(output.After, output.Before);
|
||
_regionContour = output.Contour;
|
||
_arrows = output.Arrows;
|
||
_status = output.Status;
|
||
_busy = false;
|
||
ScopesChanged?.Invoke(this, output.Scopes);
|
||
Invalidate();
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
if (token.IsCancellationRequested) return;
|
||
try
|
||
{
|
||
BeginInvoke(() =>
|
||
{
|
||
SwapBitmaps(null, null);
|
||
_status = "Anteprima non disponibile: " + ex.Message;
|
||
_busy = false;
|
||
ScopesChanged?.Invoke(this, null);
|
||
Invalidate();
|
||
});
|
||
}
|
||
catch (InvalidOperationException) { /* finestra già chiusa */ }
|
||
}
|
||
}, token);
|
||
}
|
||
|
||
private Size PreviewSize()
|
||
{
|
||
// Con lo zoom attivo si decodifica più grande del riquadro: guardare un pixel
|
||
// richiede che quel pixel esista, non che venga interpolato dall'anteprima.
|
||
int factor = _zoom > 0 ? 2 : 1;
|
||
int width = Math.Clamp((Width - 24) * factor, 160, 2600);
|
||
int height = Math.Clamp((Height - 46) * factor, 120, 2000);
|
||
return new Size(width, height);
|
||
}
|
||
|
||
private void SwapBitmaps(Bitmap? after, Bitmap? before)
|
||
{
|
||
var previousAfter = _after;
|
||
var previousBefore = _before;
|
||
_after = after;
|
||
_before = before;
|
||
previousAfter?.Dispose();
|
||
previousBefore?.Dispose();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ rendering
|
||
|
||
private sealed record RenderOutput(Bitmap? After, Bitmap? Before, PointF[]? Contour,
|
||
MotionArrows? Arrows, FrameScopes? Scopes, string Status);
|
||
|
||
private static RenderOutput Render(TitanoProject project, TimelapseSequence sequence, int index,
|
||
Size available, bool compare, bool motionField,
|
||
CancellationToken token)
|
||
{
|
||
var record = sequence.Frames[index];
|
||
var metadata = record.Metadata;
|
||
int orientation = project.EffectiveOrientation;
|
||
|
||
var (sourceWidth, sourceHeight) = project.ResolveNativeSize();
|
||
if (sourceWidth <= 0 || sourceHeight <= 0)
|
||
return new RenderOutput(null, null, null, null, null, "Immagine non leggibile");
|
||
|
||
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(6);
|
||
using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, orientation, pool);
|
||
token.ThrowIfCancellationRequested();
|
||
|
||
Bitmap? before = compare ? ToBitmap(frame) : null;
|
||
|
||
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;
|
||
MotionArrows? arrows = null;
|
||
|
||
bool wantsFlow = motionField || project.MotionBlur.Enabled;
|
||
if (wantsFlow && 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();
|
||
|
||
if (motionField) arrows = SampleArrows(flow, width, height);
|
||
|
||
if (project.MotionBlur.Enabled)
|
||
{
|
||
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)
|
||
{
|
||
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) ?? 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 scopes = FrameScopes.Compute(result);
|
||
var bitmap = ToBitmap(result);
|
||
|
||
blurred?.Dispose();
|
||
framed?.Dispose();
|
||
return new RenderOutput(bitmap, before, contour, arrows, scopes, status.ToString());
|
||
}
|
||
|
||
/// <summary>
|
||
/// Estrae dal campo una griglia rada di frecce. Disegnarle tutte sarebbe illeggibile:
|
||
/// se ne prende una ogni tot pixel dell'immagine mostrata, che è la densità a cui la
|
||
/// direzione del movimento si legge senza che le frecce si accavallino.
|
||
/// </summary>
|
||
private static MotionArrows SampleArrows(MotionField field, int width, int height)
|
||
{
|
||
const int spacing = 46;
|
||
int columns = Math.Max(2, width / spacing);
|
||
int rows = Math.Max(2, height / spacing);
|
||
|
||
var origins = new PointF[columns * rows];
|
||
var vectors = new PointF[columns * rows];
|
||
int n = 0;
|
||
|
||
for (int row = 0; row < rows; row++)
|
||
{
|
||
float y = (row + 0.5f) * height / rows;
|
||
for (int column = 0; column < columns; column++)
|
||
{
|
||
float x = (column + 0.5f) * width / columns;
|
||
field.Sample(x, y, out float vx, out float vy);
|
||
|
||
origins[n] = new PointF(x / width, y / height);
|
||
vectors[n] = new PointF(vx / width, vy / width); // stessa scala sui due assi
|
||
n++;
|
||
}
|
||
}
|
||
|
||
return new MotionArrows(origins[..n], vectors[..n], field.MedianMagnitude());
|
||
}
|
||
|
||
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>
|
||
/// Riduzione veloce a bitmap per l'anteprima dal vivo durante l'esportazione: campiona a
|
||
/// passo costante invece di mediare. Converte un fotogramma da dodici megapixel in pochi
|
||
/// millisecondi, e siccome gira sul thread che sta codificando, quei millisecondi sono
|
||
/// tolti al lavoro vero.
|
||
/// </summary>
|
||
internal static unsafe Bitmap ToThumbnail(ImageBuffer buffer, int maxWidth)
|
||
{
|
||
int step = Math.Max(1, (int)Math.Ceiling(buffer.Width / (double)Math.Max(16, maxWidth)));
|
||
int width = Math.Max(1, buffer.Width / step);
|
||
int height = Math.Max(1, buffer.Height / step);
|
||
|
||
var bitmap = new Bitmap(width, height, PixelFormat.Format32bppRgb);
|
||
var locked = bitmap.LockBits(new Rectangle(0, 0, width, height),
|
||
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
|
||
try
|
||
{
|
||
var data = buffer.Data;
|
||
byte* basePtr = (byte*)locked.Scan0;
|
||
|
||
for (int y = 0; y < height; y++)
|
||
{
|
||
byte* row = basePtr + (long)y * locked.Stride;
|
||
int sourceRow = y * step * buffer.Width * ImageBuffer.Channels;
|
||
for (int x = 0; x < width; x++)
|
||
{
|
||
int i = sourceRow + x * step * 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;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ vista: zoom e trascinamento
|
||
|
||
private Rectangle Viewport => new(0, 0, Width, Math.Max(20, Height - 22));
|
||
|
||
private float ScaleFor(Bitmap bitmap)
|
||
{
|
||
if (_zoom > 0) return _zoom;
|
||
var view = Viewport;
|
||
return (float)Math.Min(view.Width / (double)bitmap.Width, view.Height / (double)bitmap.Height);
|
||
}
|
||
|
||
private RectangleF TargetFor(Bitmap bitmap)
|
||
{
|
||
var view = Viewport;
|
||
float scale = ScaleFor(bitmap);
|
||
float width = bitmap.Width * scale;
|
||
float height = bitmap.Height * scale;
|
||
|
||
if (_zoom <= 0)
|
||
{
|
||
return new RectangleF(view.Left + (view.Width - width) / 2f,
|
||
view.Top + (view.Height - height) / 2f, width, height);
|
||
}
|
||
|
||
return new RectangleF(view.Left + view.Width / 2f - _centre.X * width,
|
||
view.Top + view.Height / 2f - _centre.Y * height, width, height);
|
||
}
|
||
|
||
protected override void OnMouseWheel(MouseEventArgs e)
|
||
{
|
||
if (_after is null) return;
|
||
|
||
var target = TargetFor(_after);
|
||
// Punto dell'immagine sotto il puntatore, che deve restare fermo mentre si ingrandisce.
|
||
float anchorX = target.Width > 0 ? (e.X - target.Left) / target.Width : 0.5f;
|
||
float anchorY = target.Height > 0 ? (e.Y - target.Top) / target.Height : 0.5f;
|
||
|
||
float current = ScaleFor(_after);
|
||
float next = Math.Clamp(current * (e.Delta > 0 ? 1.25f : 0.8f), 0.05f, 8f);
|
||
|
||
var view = Viewport;
|
||
float fit = (float)Math.Min(view.Width / (double)_after.Width, view.Height / (double)_after.Height);
|
||
|
||
if (next <= fit * 1.02f)
|
||
{
|
||
ResetView();
|
||
return;
|
||
}
|
||
|
||
_zoom = next;
|
||
float width = _after.Width * next;
|
||
float height = _after.Height * next;
|
||
_centre = new PointF(
|
||
Math.Clamp(anchorX + (view.Width / 2f - e.X) / Math.Max(1f, width), 0f, 1f),
|
||
Math.Clamp(anchorY + (view.Height / 2f - e.Y) / Math.Max(1f, height), 0f, 1f));
|
||
|
||
Invalidate();
|
||
}
|
||
|
||
private bool OverWipeHandle(Point location)
|
||
{
|
||
if (!CompareMode || _before is null || _after is null) return false;
|
||
var target = TargetFor(_after);
|
||
float x = target.Left + target.Width * _wipe;
|
||
return Math.Abs(location.X - x) <= 7 && location.Y >= target.Top && location.Y <= target.Bottom;
|
||
}
|
||
|
||
protected override void OnMouseDown(MouseEventArgs e)
|
||
{
|
||
Focus();
|
||
if (e.Button != MouseButtons.Left) { base.OnMouseDown(e); return; }
|
||
|
||
if (OverWipeHandle(e.Location))
|
||
{
|
||
_draggingWipe = true;
|
||
return;
|
||
}
|
||
|
||
if (_zoom > 0)
|
||
{
|
||
_panning = true;
|
||
_dragOrigin = e.Location;
|
||
_centreOrigin = _centre;
|
||
}
|
||
base.OnMouseDown(e);
|
||
}
|
||
|
||
protected override void OnMouseMove(MouseEventArgs e)
|
||
{
|
||
if (_draggingWipe && _after is not null)
|
||
{
|
||
var target = TargetFor(_after);
|
||
_wipe = target.Width > 0 ? Math.Clamp((e.X - target.Left) / target.Width, 0f, 1f) : 0.5f;
|
||
Invalidate();
|
||
return;
|
||
}
|
||
|
||
if (_panning && _after is not null)
|
||
{
|
||
var target = TargetFor(_after);
|
||
_centre = new PointF(
|
||
Math.Clamp(_centreOrigin.X - (e.X - _dragOrigin.X) / Math.Max(1f, target.Width), 0f, 1f),
|
||
Math.Clamp(_centreOrigin.Y - (e.Y - _dragOrigin.Y) / Math.Max(1f, target.Height), 0f, 1f));
|
||
Invalidate();
|
||
return;
|
||
}
|
||
|
||
Cursor = OverWipeHandle(e.Location) ? Cursors.SizeWE
|
||
: _zoom > 0 ? Cursors.SizeAll
|
||
: Cursors.Default;
|
||
base.OnMouseMove(e);
|
||
}
|
||
|
||
protected override void OnMouseUp(MouseEventArgs e)
|
||
{
|
||
_panning = false;
|
||
_draggingWipe = false;
|
||
base.OnMouseUp(e);
|
||
}
|
||
|
||
protected override void OnMouseDoubleClick(MouseEventArgs e)
|
||
{
|
||
if (!OverWipeHandle(e.Location)) ToggleZoom();
|
||
base.OnMouseDoubleClick(e);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ disegno
|
||
|
||
protected override void OnPaint(PaintEventArgs e)
|
||
{
|
||
var g = e.Graphics;
|
||
Theme.HighQuality(g);
|
||
g.Clear(Theme.Background);
|
||
|
||
var view = Viewport;
|
||
|
||
if (_after is null)
|
||
{
|
||
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, view,
|
||
Theme.TextFaint,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||
DrawFooter(g);
|
||
return;
|
||
}
|
||
|
||
var target = TargetFor(_after);
|
||
|
||
// A scala uno a uno l'interpolazione morbida nasconderebbe proprio ciò che si vuole
|
||
// guardare: da lì in su si mostrano i pixel come sono.
|
||
g.InterpolationMode = ScaleFor(_after) >= 1f ? InterpolationMode.NearestNeighbor
|
||
: InterpolationMode.HighQualityBilinear;
|
||
g.PixelOffsetMode = PixelOffsetMode.Half;
|
||
|
||
var clip = g.Clip;
|
||
g.SetClip(view);
|
||
|
||
if (CompareMode && _before is not null)
|
||
{
|
||
g.DrawImage(_before, target);
|
||
|
||
float split = target.Left + target.Width * _wipe;
|
||
g.SetClip(new RectangleF(split, target.Top, Math.Max(0, target.Right - split), target.Height),
|
||
CombineMode.Intersect);
|
||
g.DrawImage(_after, target);
|
||
g.SetClip(view, CombineMode.Replace);
|
||
|
||
DrawWipeHandle(g, target, split);
|
||
}
|
||
else
|
||
{
|
||
g.DrawImage(_after, target);
|
||
}
|
||
|
||
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
|
||
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||
|
||
if (ShowRegions && _regionContour is { Length: >= 2 }) DrawRegionContour(g, target);
|
||
if (ShowMotionField && _arrows is not null) DrawMotionField(g, target);
|
||
|
||
g.Clip = clip;
|
||
|
||
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(target));
|
||
|
||
if (_busy)
|
||
{
|
||
using var overlay = new SolidBrush(Color.FromArgb(120, Theme.Background));
|
||
g.FillRectangle(overlay, view);
|
||
TextRenderer.DrawText(g, "Aggiornamento…", Theme.Small, view, Theme.Text,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||
}
|
||
|
||
if (_zoom > 0) DrawZoomBadge(g, view);
|
||
if (_live) DrawLiveBadge(g, view);
|
||
|
||
DrawFooter(g);
|
||
}
|
||
|
||
private static void DrawWipeHandle(Graphics g, RectangleF target, float x)
|
||
{
|
||
using (var line = new Pen(Color.FromArgb(220, Color.White), 1.6f))
|
||
g.DrawLine(line, x, target.Top, x, target.Bottom);
|
||
|
||
float cy = target.Top + target.Height / 2f;
|
||
var grip = new RectangleF(x - 11, cy - 15, 22, 30);
|
||
Theme.FillAndStroke(g, grip, 5f, Color.FromArgb(225, Theme.Background), Color.FromArgb(220, Color.White));
|
||
|
||
using var arrows = new Pen(Color.White, 1.5f)
|
||
{
|
||
StartCap = LineCap.Round,
|
||
EndCap = LineCap.Round,
|
||
};
|
||
g.DrawLines(arrows, [new PointF(x - 3.5f, cy - 4), new PointF(x - 6.5f, cy), new PointF(x - 3.5f, cy + 4)]);
|
||
g.DrawLines(arrows, [new PointF(x + 3.5f, cy - 4), new PointF(x + 6.5f, cy), new PointF(x + 3.5f, cy + 4)]);
|
||
|
||
TextRenderer.DrawText(g, "originale", Theme.SmallBold,
|
||
new Rectangle((int)target.Left + 8, (int)target.Top + 6, 90, 16),
|
||
Color.FromArgb(210, Color.White), TextFormatFlags.Left);
|
||
TextRenderer.DrawText(g, "corretto", Theme.SmallBold,
|
||
new Rectangle((int)target.Right - 98, (int)target.Top + 6, 90, 16),
|
||
Color.FromArgb(210, Color.White), TextFormatFlags.Right);
|
||
}
|
||
|
||
private void DrawRegionContour(Graphics g, RectangleF target)
|
||
{
|
||
var contour = _regionContour!;
|
||
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.RegionHigh), 1.6f);
|
||
g.DrawLines(shadow, line);
|
||
g.DrawLines(boundary, line);
|
||
}
|
||
|
||
private void DrawMotionField(Graphics g, RectangleF target)
|
||
{
|
||
var arrows = _arrows!;
|
||
if (arrows.Origins.Length == 0) return;
|
||
|
||
// La lunghezza si normalizza sul vettore mediano: su una ripresa notturna gli
|
||
// spostamenti sono di pochi pixel e a scala reale le frecce sarebbero invisibili.
|
||
// Il limite inferiore però conta: su una scena ferma il mediano tende a zero, e senza
|
||
// di esso il rumore del campo verrebbe amplificato fino a sembrare movimento vero.
|
||
// Sotto un terzo di pixel non c'è movimento da mostrare, solo il rumore del campo:
|
||
// si smette di normalizzare e si usa una scala fissa modesta, così le frecce restano
|
||
// visibili senza raccontare uno spostamento che non c'è.
|
||
float gain = arrows.MedianMagnitude < 0.35
|
||
? 8f
|
||
: (float)Math.Min(30.0, 26.0 / arrows.MedianMagnitude);
|
||
|
||
using var pen = new Pen(Color.FromArgb(200, Theme.Accent), 1.4f)
|
||
{
|
||
EndCap = LineCap.ArrowAnchor,
|
||
StartCap = LineCap.Round,
|
||
};
|
||
using var dot = new SolidBrush(Color.FromArgb(150, Theme.Accent));
|
||
|
||
for (int i = 0; i < arrows.Origins.Length; i++)
|
||
{
|
||
float ox = target.Left + arrows.Origins[i].X * target.Width;
|
||
float oy = target.Top + arrows.Origins[i].Y * target.Height;
|
||
float dx = arrows.Vectors[i].X * target.Width * gain;
|
||
float dy = arrows.Vectors[i].Y * target.Width * gain;
|
||
|
||
float length = MathF.Sqrt(dx * dx + dy * dy);
|
||
if (length < 1.5f)
|
||
{
|
||
g.FillEllipse(dot, ox - 1.4f, oy - 1.4f, 2.8f, 2.8f);
|
||
continue;
|
||
}
|
||
|
||
// Un limite alla lunghezza disegnata: un vettore sbagliato non deve attraversare
|
||
// tutta l'immagine e coprire quelli giusti.
|
||
if (length > 44f) { dx *= 44f / length; dy *= 44f / length; }
|
||
g.DrawLine(pen, ox, oy, ox + dx, oy + dy);
|
||
}
|
||
|
||
string caption = arrows.MedianMagnitude < 0.35
|
||
? $"campo vettoriale · mediana {arrows.MedianMagnitude:0.00} px, movimento trascurabile"
|
||
: $"campo vettoriale · mediana {arrows.MedianMagnitude:0.0} px";
|
||
TextRenderer.DrawText(g, caption, Theme.Small,
|
||
new Rectangle((int)target.Left + 8, (int)target.Bottom - 20,
|
||
Math.Max(120, (int)target.Width - 16), 16),
|
||
Color.FromArgb(200, Theme.Accent), TextFormatFlags.Left);
|
||
}
|
||
|
||
private void DrawZoomBadge(Graphics g, Rectangle view)
|
||
{
|
||
string text = $"{ZoomText} · doppio clic per adattare";
|
||
var size = TextRenderer.MeasureText(g, text, Theme.Small);
|
||
var box = new RectangleF(view.Right - size.Width - 24, view.Top + 8, size.Width + 14, 20);
|
||
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(220, Theme.Background), Theme.Border);
|
||
TextRenderer.DrawText(g, text, Theme.Small, Rectangle.Round(box), Theme.TextMuted,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||
}
|
||
|
||
private static void DrawLiveBadge(Graphics g, Rectangle view)
|
||
{
|
||
const string text = "IN CODIFICA";
|
||
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
|
||
var box = new RectangleF(view.Left + 10, view.Top + 8, size.Width + 16, 20);
|
||
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(230, Theme.AccentDim), Theme.Accent);
|
||
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Color.White,
|
||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||
}
|
||
|
||
private void DrawFooter(Graphics g)
|
||
{
|
||
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();
|
||
_after?.Dispose();
|
||
_before?.Dispose();
|
||
}
|
||
base.Dispose(disposing);
|
||
}
|
||
}
|