Files
Titano/Titano/Motion/MotionBlurRenderer.cs
T
Alby96andClaude Opus 5 cc1d040ac0 Titano: motore e interfaccia per time-lapse, senza dipendenze di terze parti
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>
2026-08-13 21:40:26 +02:00

195 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.CompilerServices;
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Parametri del motion blur sintetico, esposti nel pannello "Elaborazione immagini".</summary>
public sealed class MotionBlurSettings
{
public bool Enabled { get; set; } = true;
/// <summary>Shutter angle desiderato: 180° è la convenzione cinematografica.</summary>
public double TargetShutterAngle { get; set; } = 180.0;
/// <summary>Quota della sfocatura mancante effettivamente sintetizzata.</summary>
public double Strength { get; set; } = 1.0;
/// <summary>Limite superiore della scia, in pixel: protegge dalle stime di movimento errate.</summary>
public double MaxBlurPixels { get; set; } = 48.0;
/// <summary>Numero massimo di campioni per pixel lungo la scia.</summary>
public int MaxSamples { get; set; } = 25;
public MotionBlurSettings Clone() => (MotionBlurSettings)MemberwiseClone();
}
/// <summary>
/// Motion blur direzionale sintetico lungo il campo vettoriale.
///
/// Il fattore di sfocatura mancante deriva dalla composizione delle varianze: la scia
/// realmente incisa nel fotogramma (proporzionale allo shutter angle di scatto) e quella
/// sintetica si sommano in quadratura, quindi per raggiungere l'apertura obiettivo serve
/// una scia lunga √(target² reale²) volte lo spostamento. Sommare linearmente
/// produrrebbe un'immagine sistematicamente troppo morbida.
///
/// Il filtro di ricostruzione è di tipo "gather": ogni campione contribuisce al pixel
/// centrale solo se la propria scia lo raggiunge davvero, così lo sfondo fermo non viene
/// trascinato dentro i soggetti in movimento.
/// </summary>
public static class MotionBlurRenderer
{
/// <summary>
/// Frazione di spostamento da sintetizzare per portare <paramref name="actualAngle"/>
/// al valore <paramref name="targetAngle"/>. Zero se il fotogramma è già abbastanza mosso.
/// </summary>
public static double MissingBlurFactor(double actualAngle, double targetAngle, double strength)
{
double target = Math.Clamp(targetAngle, 0, 360) / 360.0;
double actual = Math.Clamp(actualAngle, 0, 360) / 360.0;
if (target <= actual) return 0;
return Math.Sqrt(target * target - actual * actual) * Math.Clamp(strength, 0, 1);
}
/// <summary>
/// Applica la sfocatura da <paramref name="source"/> a <paramref name="destination"/>.
/// Restituisce la lunghezza media della scia effettivamente resa, in pixel.
/// </summary>
public static double Render(ImageBuffer source, ImageBuffer destination, MotionField field,
double missingFactor, MotionBlurSettings settings)
{
if (missingFactor <= 1e-4)
{
destination.CopyFrom(source);
return 0;
}
int width = source.Width;
int height = source.Height;
float factor = (float)missingFactor;
float maxBlur = (float)Math.Max(1.0, settings.MaxBlurPixels);
int maxSamples = Math.Clamp(settings.MaxSamples, 3, 129);
var src = source.Data;
var dst = destination.Data;
double lengthSum = 0;
object sumLock = new();
Parallel.For(0, height, () => 0.0, (y, _, localSum) =>
{
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int index = rowBase + x * ImageBuffer.Channels;
field.Sample(x, y, out float vx, out float vy);
float magnitude = MathF.Sqrt(vx * vx + vy * vy);
float length = Math.Min(magnitude * factor, maxBlur);
localSum += length;
if (length < 0.5f || magnitude < 1e-4f)
{
dst[index] = src[index];
dst[index + 1] = src[index + 1];
dst[index + 2] = src[index + 2];
continue;
}
float dirX = vx / magnitude;
float dirY = vy / magnitude;
float half = length * 0.5f;
int taps = Math.Min(maxSamples, Math.Max(3, (int)MathF.Ceiling(length) | 1));
int side = taps / 2;
float step = half / side;
// Il pixel centrale è sempre presente: garantisce continuità con le zone ferme.
float accR = src[index], accG = src[index + 1], accB = src[index + 2];
float weightSum = 1f;
for (int k = 1; k <= side; k++)
{
float offset = k * step;
weightSum += Accumulate(src, field, width, height, x, y,
dirX, dirY, offset, factor, maxBlur, ref accR, ref accG, ref accB);
weightSum += Accumulate(src, field, width, height, x, y,
dirX, dirY, -offset, factor, maxBlur, ref accR, ref accG, ref accB);
}
float inv = 1f / weightSum;
dst[index] = accR * inv;
dst[index + 1] = accG * inv;
dst[index + 2] = accB * inv;
}
return localSum;
},
localSum =>
{
lock (sumLock) lengthSum += localSum;
});
return lengthSum / Math.Max(1, (long)width * height);
}
/// <summary>
/// Aggiunge il contributo del campione spostato di <paramref name="offset"/> lungo la scia.
/// Il peso vale 1 solo se il movimento proprio del campione arriva a coprire il pixel centrale.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float Accumulate(float[] src, MotionField field, int width, int height,
int x, int y, float dirX, float dirY, float offset,
float factor, float maxBlur,
ref float accR, ref float accG, ref float accB)
{
float sx = x + dirX * offset;
float sy = y + dirY * offset;
if (sx < 0 || sy < 0 || sx > width - 1 || sy > height - 1) return 0f;
field.Sample(sx, sy, out float tvx, out float tvy);
float projection = MathF.Abs(tvx * dirX + tvy * dirY) * factor * 0.5f;
projection = MathF.Min(projection, maxBlur * 0.5f);
// Transizione morbida su un pixel: evita i gradini sui bordi della scia.
float weight = projection - MathF.Abs(offset) + 1f;
if (weight <= 0f) return 0f;
if (weight > 1f) weight = 1f;
SampleBilinear(src, width, height, sx, sy, out float r, out float g, out float b);
accR += r * weight;
accG += g * weight;
accB += b * weight;
return weight;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void SampleBilinear(float[] data, int width, int height, float x, float y,
out float r, out float g, out float b)
{
if (x < 0) x = 0; else if (x > width - 1) x = width - 1;
if (y < 0) y = 0; else if (y > height - 1) y = height - 1;
int x0 = (int)x;
int y0 = (int)y;
int x1 = x0 + 1 < width ? x0 + 1 : x0;
int y1 = y0 + 1 < height ? y0 + 1 : y0;
float fx = x - x0;
float fy = y - y0;
int i00 = (y0 * width + x0) * ImageBuffer.Channels;
int i10 = (y0 * width + x1) * ImageBuffer.Channels;
int i01 = (y1 * width + x0) * ImageBuffer.Channels;
int i11 = (y1 * width + x1) * ImageBuffer.Channels;
float w00 = (1 - fx) * (1 - fy);
float w10 = fx * (1 - fy);
float w01 = (1 - fx) * fy;
float w11 = fx * fy;
r = data[i00] * w00 + data[i10] * w10 + data[i01] * w01 + data[i11] * w11;
g = data[i00 + 1] * w00 + data[i10 + 1] * w10 + data[i01 + 1] * w01 + data[i11 + 1] * w11;
b = data[i00 + 2] * w00 + data[i10 + 2] * w10 + data[i01 + 2] * w01 + data[i11 + 2] * w11;
}
}