diff --git a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs
new file mode 100644
index 0000000..9869fdf
--- /dev/null
+++ b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs
@@ -0,0 +1,54 @@
+using System;
+
+namespace PhotoLocator.BitmapOperations
+{
+ class AstroStretchOperation : OperationBase
+ {
+ public double Stretch { get; set; } = 10;
+
+ public double BackgroundSmooth { get; set; } = 8;
+
+ public override void Apply()
+ {
+ if (SrcBitmap is not null && DstBitmap != SrcBitmap)
+ DstBitmap.Assign(SrcBitmap);
+ if (Stretch > 0)
+ {
+ var s = (float)Math.Exp(-Stretch);
+ DstBitmap.ProcessElementWise(p => Math.Max(0, (s - 1) * p / ((2 * s - 1) * p - s)));
+ }
+ if (BackgroundSmooth > 0)
+ {
+ var background = ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap);
+ IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth));
+ DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b, 0));
+ }
+ }
+
+ public static double OptimizeStretch(FloatBitmap srcBitmap)
+ {
+ const double TargetMean = 0.1;
+ const int SampleHeight = 100;
+
+ var grayImage = ConvertToGrayscaleOperation.ConvertToGrayscale(srcBitmap);
+ srcBitmap = new FloatBitmap(srcBitmap.Width * SampleHeight / srcBitmap.Height, SampleHeight, 1);
+ BilinearResizeOperation.ApplyToPlaneParallel(grayImage, srcBitmap);
+
+ double bestStretch = 1;
+ double bestMean = 0;
+ var op = new AstroStretchOperation { SrcBitmap = srcBitmap, DstBitmap = new(), BackgroundSmooth = 0 };
+ for (double s = 1; s <= 20; s += 0.2)
+ {
+ op.Stretch = s;
+ op.Apply();
+ var mean = op.DstBitmap.Mean();
+ if (Math.Abs(mean - TargetMean) < Math.Abs(bestMean - TargetMean))
+ {
+ bestStretch = s;
+ bestMean = mean;
+ }
+ }
+ return bestStretch;
+ }
+ }
+}
diff --git a/PhotoLocator/BitmapOperations/FloatBitmap.cs b/PhotoLocator/BitmapOperations/FloatBitmap.cs
index e980b92..a22e2f2 100644
--- a/PhotoLocator/BitmapOperations/FloatBitmap.cs
+++ b/PhotoLocator/BitmapOperations/FloatBitmap.cs
@@ -386,6 +386,21 @@ public float Min()
return (min, max);
}
+ public double Mean()
+ {
+ double sum = 0;
+ unsafe
+ {
+ fixed (float* elements = Elements)
+ {
+ var size = Size;
+ for (var i = 0; i < size; i++)
+ sum += elements[i];
+ }
+ }
+ return sum / Size;
+ }
+
///
/// Apply operation to all elements
///
diff --git a/PhotoLocator/BitmapOperations/StarfieldRenderer.cs b/PhotoLocator/BitmapOperations/StarfieldRenderer.cs
new file mode 100644
index 0000000..d4ac51b
--- /dev/null
+++ b/PhotoLocator/BitmapOperations/StarfieldRenderer.cs
@@ -0,0 +1,161 @@
+using PhotoLocator.Helpers;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading.Tasks;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace PhotoLocator.BitmapOperations
+{
+ ///
+ /// Render a simple starfield (flying through stars) producing 8-bit grayscale frames.
+ ///
+ public class StarfieldRenderer
+ {
+ readonly int _width;
+ readonly int _height;
+ readonly int _centerX;
+ readonly int _centerY;
+ readonly Star[] _stars;
+ readonly Random _rnd;
+ readonly float _focal; // projection focal length
+ readonly float _growthFactor;
+ readonly float _speed;
+
+ struct Star
+ {
+ public float X; // world X (centered)
+ public float Y; // world Y (centered)
+ public float Z; // depth in range (0..1], smaller = closer
+ }
+
+ ///
+ /// Create a starfield renderer.
+ ///
+ /// Frame width in pixels.
+ /// Frame height in pixels.
+ /// Number of stars to render.
+ /// Per-frame approach speed. Typical small values like 0.01 - 0.1.
+ /// How much stars grow when they get close.
+ /// Optional random seed for reproducible results.
+ public StarfieldRenderer(int width, int height, int starCount, float speed = 0.02f, float growthFactor = 1.5f, int? seed = null)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(starCount);
+
+ _width = width;
+ _height = height;
+ _centerX = width / 2;
+ _centerY = height / 2;
+ _stars = new Star[starCount];
+ _rnd = seed.HasValue ? new Random(seed.Value) : new Random();
+ _focal = Math.Max(width, height) * 0.5f; // reasonable focal length
+ _growthFactor = Math.Max(0.1f, growthFactor);
+ _speed = Math.Max(0f, speed);
+
+ InitializeStars();
+ }
+
+ void InitializeStars()
+ {
+ for (int i = 0; i < _stars.Length; i++)
+ _stars[i] = CreateStar();
+ }
+
+ Star CreateStar()
+ {
+ // World coordinates centered around 0. X range roughly [-width/2, width/2]
+#pragma warning disable CA5394 // Do not use insecure randomness
+ var x = (float)(_rnd.NextDouble() * _width - _centerX);
+ var y = (float)(_rnd.NextDouble() * _height - _centerY);
+ // Z in (0.05 .. 1], avoid zero
+ var z = (float)(_rnd.NextDouble() * 0.95 + 0.05);
+#pragma warning restore CA5394 // Do not use insecure randomness
+ return new Star { X = x, Y = y, Z = z };
+ }
+
+ ///
+ /// Generate a sequence of frames. Each yielded BitmapSource is frozen and is an 8-bit grayscale image.
+ ///
+ /// Number of frames to generate.
+ public IEnumerable GenerateFrames(int frameCount)
+ {
+ for (int f = 0; f < frameCount; f++)
+ {
+ // create fresh pixel buffer for this frame
+ var pixels = new byte[_width * _height * 3];
+
+ // update stars and draw
+ for (int i = 0; i < _stars.Length; i++)
+ {
+ var s = _stars[i];
+
+ // Move star closer by decreasing Z
+ s.Z -= _speed;
+ if (s.Z <= 0.02f)
+ {
+ // respawn at far distance
+ s = CreateStar();
+ s.Z = 1.0f;
+ }
+
+ // projection
+ var invZ = 1f / s.Z;
+ var sx = IntMath.Round(_centerX + s.X * invZ * (_focal / _centerX));
+ var sy = IntMath.Round(_centerY + s.Y * invZ * (_focal / _centerY));
+
+ // compute intensity and radius
+ // closer stars (smaller Z) are brighter and bigger
+ var brightness = (1f - s.Z) * 255f;
+ var b = Math.Clamp((int)brightness, 0, 255);
+ var radius = Math.Max(0.0f, (1f - s.Z) * _growthFactor * 2.0f);
+ Debug.Assert(radius >= 0f);
+ var r = (int)radius;
+
+ // draw filled circle
+ if (sx >= -r && sx < _width + r && sy >= -r && sy < _height + r)
+ {
+ for (int yy = -r; yy <= r; yy++)
+ {
+ int py = sy + yy;
+ if (py < 0 || py >= _height) continue;
+ int dx = (int)Math.Floor(Math.Sqrt(radius * radius - yy * yy));
+ int x0 = Math.Max(0, sx - dx);
+ int x1 = Math.Min(_width - 1, sx + dx);
+ int baseIndex = py * _width * 3;
+ for (int px = x0; px <= x1; px++)
+ {
+ int idx = baseIndex + px * 3;
+ // additive blending clamped to 255
+ var val = (byte)Math.Min(pixels[idx] + b, 255);
+ pixels[idx] = val;
+ pixels[idx + 1] = val;
+ pixels[idx + 2] = val;
+ }
+ }
+ }
+
+ _stars[i] = s;
+ }
+
+ var bmp = BitmapSource.Create(_width, _height, 96, 96, PixelFormats.Rgb24, null, pixels, _width * 3);
+ bmp.Freeze();
+ yield return bmp;
+ }
+ }
+
+ internal static BitmapSource AddFrames(BitmapSource frame1, BitmapSource frame2)
+ {
+ var buf1 = new byte[frame1.PixelWidth * frame1.PixelHeight * 3];
+ var buf2 = new byte[frame2.PixelWidth * frame2.PixelHeight * 3];
+ frame1.CopyPixels(buf1, frame1.PixelWidth * 3, 0);
+ frame2.CopyPixels(buf2, frame1.PixelWidth * 3, 0);
+ Parallel.For(0, buf1.Length, i => buf1[i] = (byte)Math.Min(buf1[i] + buf2[i], 255));
+ var result = BitmapSource.Create(frame1.PixelWidth, frame1.PixelHeight, 96, 96, frame1.Format, null, buf1, frame1.PixelWidth * 3);
+ result.Freeze();
+ return result;
+ }
+ }
+}
diff --git a/PhotoLocator/JpegTransformCommands.cs b/PhotoLocator/JpegTransformCommands.cs
index 7df6504..0ea522a 100644
--- a/PhotoLocator/JpegTransformCommands.cs
+++ b/PhotoLocator/JpegTransformCommands.cs
@@ -103,7 +103,7 @@ await Task.Run(() =>
using (var cursor = new MouseCursorOverride())
{
(var image, metadata) = await Task.Run(() => LoadImageWithMetadataAsync(selectedItem));
- localContrastViewModel = new LocalContrastViewModel() { SourceBitmap = image };
+ localContrastViewModel = new LocalContrastViewModel() { SourceBitmap = image, IsAstroModeEnabled = o as string == "Astro" };
}
var window = new LocalContrastView();
window.Owner = Application.Current.MainWindow;
diff --git a/PhotoLocator/LocalContrastView.xaml b/PhotoLocator/LocalContrastView.xaml
index 4c958ef..20c3965 100644
--- a/PhotoLocator/LocalContrastView.xaml
+++ b/PhotoLocator/LocalContrastView.xaml
@@ -9,12 +9,13 @@
d:DataContext="{d:DesignInstance Type=local:LocalContrastViewModel, IsDesignTimeCreatable=True}"
Background="Black"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
- Title="Local contrast, brightness and colors" Height="960" Width="800" WindowState="Maximized">
+ Title="Local contrast, brightness and colors" Height="1000" Width="800" WindowState="Maximized">
+
@@ -44,7 +45,7 @@
-
+
@@ -55,7 +56,29 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PhotoLocator/LocalContrastViewModel.cs b/PhotoLocator/LocalContrastViewModel.cs
index fbd26f3..512d108 100644
--- a/PhotoLocator/LocalContrastViewModel.cs
+++ b/PhotoLocator/LocalContrastViewModel.cs
@@ -20,7 +20,7 @@ class LocalContrastViewModel : INotifyPropertyChanged, IImageZoomPreviewViewMode
static readonly List _adjustmentClipboard = [];
static readonly List _lastUsedValues = [];
readonly DispatcherTimer _updateTimer;
- readonly LaplacianFilterOperation _laplacianFilterOperation = new() { SrcBitmap = new() };
+ readonly LaplacianFilterOperation _laplacianFilterOperation = new();
readonly IncreaseLocalContrastOperation _localContrastOperation = new() { DstBitmap = new() };
readonly ColorToneAdjustOperation _colorToneOperation = new();
Task _previewTask = Task.CompletedTask;
@@ -64,12 +64,13 @@ public BitmapSource? SourceBitmap
if (value is not null)
{
Mouse.OverrideCursor = Cursors.AppStarting;
- _laplacianFilterOperation.SrcBitmap.Assign(value, FloatBitmap.DefaultMonitorGamma);
+ _sourceFloatBitmap.Assign(value, FloatBitmap.DefaultMonitorGamma);
_updateTimer.Start();
}
field = value;
}
}
+ readonly FloatBitmap _sourceFloatBitmap = new();
public BitmapSource? PreviewPictureSource
{
@@ -92,6 +93,41 @@ public int PreviewZoom
public ICommand ZoomInCommand => new RelayCommand(o => PreviewZoom = Math.Min(PreviewZoom + 1, 4));
public ICommand ZoomOutCommand => new RelayCommand(o => PreviewZoom = Math.Max(PreviewZoom - 1, 0));
+ public bool IsAstroModeEnabled
+ {
+ get;
+ set
+ {
+ if (value && SetProperty(ref field, value) && SourceBitmap is not null)
+ AstroStretch = AstroStretchOperation.OptimizeStretch(_sourceFloatBitmap);
+ }
+ }
+
+ public const double DefaultAstroStretch = 10;
+ public double AstroStretch
+ {
+ get;
+ set
+ {
+ if (SetProperty(ref field, value))
+ StartUpdateTimer(true, true);
+ }
+ } = DefaultAstroStretch;
+ public ICommand ResetAstroStretchCommand => new RelayCommand(o => AstroStretch = DefaultAstroStretch);
+
+ public const double DefaultBackgroundRemovalSmooth = 8;
+ public double BackgroundRemovalSmooth
+ {
+ get;
+ set
+ {
+ if (SetProperty(ref field, value))
+ StartUpdateTimer(true, true);
+ }
+ } = DefaultBackgroundRemovalSmooth;
+ public ICommand ResetBackgroundRemovalSmoothCommand => new RelayCommand(o => BackgroundRemovalSmooth = DefaultBackgroundRemovalSmooth);
+
+ public const double DefaultHighlightStrength = 10;
public double HighlightStrength
{
get;
@@ -101,11 +137,9 @@ public double HighlightStrength
StartUpdateTimer(false, true);
}
} = DefaultHighlightStrength;
-
- public const double DefaultHighlightStrength = 10;
-
public ICommand ResetHighlightCommand => new RelayCommand(o => HighlightStrength = DefaultHighlightStrength);
+ public const double DefaultShadowStrength = 10;
public double ShadowStrength
{
get;
@@ -115,11 +149,9 @@ public double ShadowStrength
StartUpdateTimer(false, true);
}
} = DefaultShadowStrength;
-
- public const double DefaultShadowStrength = 10;
-
public ICommand ResetShadowCommand => new RelayCommand(o => ShadowStrength = DefaultShadowStrength);
+ public const double DefaultMaxStretch = 50;
public double MaxStretch
{
get;
@@ -129,11 +161,9 @@ public double MaxStretch
StartUpdateTimer(false, true);
}
} = DefaultMaxStretch;
-
- public const double DefaultMaxStretch = 50;
-
public ICommand ResetMaxStretchCommand => new RelayCommand(o => MaxStretch = DefaultMaxStretch);
+ public const double DefaultOutlierReductionStrength = 10;
public double OutlierReductionStrength
{
get;
@@ -143,11 +173,9 @@ public double OutlierReductionStrength
StartUpdateTimer(false, true);
}
} = DefaultOutlierReductionStrength;
-
- public const double DefaultOutlierReductionStrength = 10;
-
public ICommand ResetOutlierReductionCommand => new RelayCommand(o => OutlierReductionStrength = DefaultOutlierReductionStrength);
+ public const double DefaultContrast = 1;
public double Contrast
{
get;
@@ -157,11 +185,9 @@ public double Contrast
StartUpdateTimer(false, true);
}
} = DefaultContrast;
-
- public const double DefaultContrast = 1;
-
public ICommand ResetContrastCommand => new RelayCommand(o => Contrast = DefaultContrast);
+ public const double DefaultToneMapping = 1;
public double ToneMapping
{
get;
@@ -175,11 +201,9 @@ public double ToneMapping
}
}
} = DefaultToneMapping;
-
- public const double DefaultToneMapping = 1;
-
public ICommand ResetToneMappingCommand => new RelayCommand(o => ToneMapping = DefaultToneMapping);
+ public const double DefaultDetailHandling = 1;
public double DetailHandling
{
get;
@@ -189,9 +213,6 @@ public double DetailHandling
StartUpdateTimer(true, true);
}
} = DefaultDetailHandling;
-
- public const double DefaultDetailHandling = 1;
-
public ICommand ResetDetailHandlingCommand => new RelayCommand(o => DetailHandling = DefaultDetailHandling);
public ColorToneAdjustOperation.ToneAdjustment[] ToneAdjustments => _colorToneOperation.ToneAdjustments;
@@ -340,6 +361,8 @@ public double ToneRotation
public ICommand ResetCommand => new RelayCommand(o =>
{
+ AstroStretch = DefaultAstroStretch;
+ BackgroundRemovalSmooth = DefaultBackgroundRemovalSmooth;
HighlightStrength = DefaultHighlightStrength;
ShadowStrength = DefaultShadowStrength;
MaxStretch = DefaultMaxStretch;
@@ -368,6 +391,8 @@ public void SaveLastUsedValues()
private void StoreAdjustmentValues(List valueStore)
{
valueStore.Clear();
+ valueStore.Add(AstroStretch);
+ valueStore.Add(BackgroundRemovalSmooth);
valueStore.Add(HighlightStrength);
valueStore.Add(ShadowStrength);
valueStore.Add(MaxStretch);
@@ -388,6 +413,8 @@ private void StoreAdjustmentValues(List valueStore)
private void RestoreAdjustmentValues(List valueStore)
{
int a = 0;
+ AstroStretch = valueStore[a++];
+ BackgroundRemovalSmooth = valueStore[a++];
HighlightStrength = valueStore[a++];
ShadowStrength = valueStore[a++];
MaxStretch = valueStore[a++];
@@ -419,6 +446,24 @@ private void StartUpdateTimer(bool laplacianPyramidParamsChanged, bool localCont
_updateTimer.Start();
}
+ void ApplyAstroStretchOperation()
+ {
+ if (IsAstroModeEnabled && (AstroStretch > 0 || BackgroundRemovalSmooth > 0))
+ {
+ var astroStretch = new AstroStretchOperation()
+ {
+ SrcBitmap = _sourceFloatBitmap,
+ DstBitmap = new(),
+ Stretch = AstroStretch,
+ BackgroundSmooth = BackgroundRemovalSmooth,
+ };
+ astroStretch.Apply();
+ _laplacianFilterOperation.SrcBitmap = astroStretch.DstBitmap;
+ }
+ else
+ _laplacianFilterOperation.SrcBitmap = _sourceFloatBitmap;
+ }
+
private void ApplyLaplacianFilterOperation()
{
if (DetailHandling != 1 || ToneMapping != 1)
@@ -472,6 +517,7 @@ public async Task UpdatePreviewAsync()
_localContrastOperation.SourceChanged();
_laplacianPyramidParamsChanged = false;
}
+ ApplyAstroStretchOperation();
ApplyLaplacianFilterOperation();
if (SourceBitmap is null || _updateTimer.IsEnabled)
return;
@@ -495,7 +541,7 @@ public void ShowSourceHistogram()
{
if (SourceBitmap is null)
return;
- (_, var histogramTask) = _laplacianFilterOperation.SrcBitmap.ToBitmapSourceWithHistogram(SourceBitmap.DpiX, SourceBitmap.DpiY, FloatBitmap.DefaultMonitorGamma);
+ (_, var histogramTask) = _sourceFloatBitmap.ToBitmapSourceWithHistogram(SourceBitmap.DpiX, SourceBitmap.DpiY, FloatBitmap.DefaultMonitorGamma);
histogramTask.ContinueWith(task => SetHistogram(task.Result), TaskScheduler.Current);
});
}
@@ -517,7 +563,8 @@ public BitmapSource ApplyOperations(BitmapSource source)
{
if (IsNoOperation)
return source;
- _laplacianFilterOperation.SrcBitmap.Assign(source, FloatBitmap.DefaultMonitorGamma);
+ _sourceFloatBitmap.Assign(source, FloatBitmap.DefaultMonitorGamma);
+ ApplyAstroStretchOperation();
_laplacianFilterOperation.SourceChanged();
ApplyLaplacianFilterOperation();
_localContrastOperation.SourceChanged();
diff --git a/PhotoLocator/MainWindow.xaml b/PhotoLocator/MainWindow.xaml
index 59484a3..86999bc 100644
--- a/PhotoLocator/MainWindow.xaml
+++ b/PhotoLocator/MainWindow.xaml
@@ -35,6 +35,7 @@
+
@@ -218,6 +219,11 @@
+