using System.Collections.Generic;
using System.Threading.Tasks;
static readonly HttpClient _httpClient = new HttpClient();
var imageUrls = new[] { 338, 2075, 7942, 44602 }
"https://raw.githubusercontent.com/ProductiveRage/NaivePerspectiveCorrection/main/" +
"Samples/Frames/frame_" + frameIndex + ".jpg"
var (topLeft, topRight, bottomRight, bottomLeft) =
imageUrls.Select(async url =>
using var image = await DownloadImage(url);
return IlluminatedAreaLocator.GetMostHighlightedArea(image);
.OrderByDescending(group => group.Count())
.Select(group => group.Key)
Console.WriteLine($"Most common highlighted area: {topLeft}, {topRight}, {bottomRight}, {bottomLeft}");
const int resizeLargestSideTo = 150;
var urlToUseForPreview = imageUrls.Last();
using var previewImage = await DownloadImage(urlToUseForPreview);
Console.Write("Below is a data url that is a shrunken version of one the input frames:");
Console.WriteLine(" (paste it into your browser address bar to view):");
Console.WriteLine(GenerateDataUrlForResizedVersion(previewImage, resizeLargestSideTo));
Console.WriteLine("Below is a data url that is the masked-out highlighted area from that frame:");
MaskOutArea(previewImage, (topLeft, topRight, bottomRight, bottomLeft));
Console.WriteLine(GenerateDataUrlForResizedVersion(previewImage, resizeLargestSideTo));
Console.WriteLine("The original (full size) version of the frame is available here:");
Console.WriteLine(urlToUseForPreview);
static async Task<SKBitmap> DownloadImage(string url)
using var downloadStream = await _httpClient.GetStreamAsync(url);
return SKBitmap.Decode(downloadStream);
static void MaskOutArea(SKBitmap image, (Point P1, Point P2, Point P3, Point P4) area)
var polygon = new[] { area.P1, area.P2, area.P3, area.P4 };
var pixels = image.Pixels;
for (var x = 0; x < image.Width; x++)
for (var y = 0; y < image.Height; y++)
if (!IsPointInPolygon(new Point(x, y), polygon))
pixels[(y * image.Width) + x] = new SKColor(0, 0, 0);
static bool IsPointInPolygon(Point testPoint, Point[] polygon)
var j = polygon.Length - 1;
for (var i = 0; i < polygon.Length; i++)
if (((polygon[i].Y < testPoint.Y) && (polygon[j].Y >= testPoint.Y))
|| ((polygon[j].Y < testPoint.Y) && (polygon[i].Y >= testPoint.Y)))
var crossingPointForLineOnX =
((testPoint.Y - polygon[i].Y) / (float)(polygon[j].Y - polygon[i].Y) * (polygon[j].X - polygon[i].X));
if (crossingPointForLineOnX < testPoint.X)
static string GenerateDataUrlForResizedVersion(SKBitmap image, int resizeLargestSideTo)
using var shrunken = image.CopyAndResize(resizeLargestSideTo);
using var shrunkenData = shrunken.Encode(SKEncodedImageFormat.Jpeg, 80);
using var memoryStream = new MemoryStream();
shrunkenData.SaveTo(memoryStream);
var shrunkenBytes = memoryStream.ToArray();
return "data:image/jpg;base64," + Convert.ToBase64String(shrunkenBytes);
static async Task<IEnumerable<(string Filename, Uri Url)>> GetImageFileLocations()
using var request = new HttpRequestMessage();
request.Headers.Add("User-Agent", "Productive Rage Blog Post Example");
request.RequestUri = new Uri(
"https://api.github.com/repos/" +
"ProductiveRage/NaivePerspectiveCorrection/contents/Samples/Frames"
using var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
throw new Exception("GitHub API rejected request for list of sample images");
var namesAndUrls = JsonConvert.DeserializeAnonymousType(
await response.Content.ReadAsStringAsync(),
new[] { new { Name = "", Download_Url = (Uri?)null } }
return namesAndUrls is null
? Array.Empty<(string, Uri)>()
.Where(entry => entry.Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase))
.Select(entry => (entry.Name, entry.Download_Url!));
static class BitmapExtensions
public static DataRectangle<double> GetGreyscale(this SKBitmap image) =>
.Transform(c => (0.2989 * c.Red) + (0.5870 * c.Green) + (0.1140 * c.Blue));
public static SKBitmap CopyAndResize(this SKBitmap image, int resizeLargestSideTo)
var (width, height) = (image.Width > image.Height)
? (resizeLargestSideTo, (int)((double)image.Height / image.Width * resizeLargestSideTo))
: ((int)((double)image.Width / image.Height * resizeLargestSideTo), resizeLargestSideTo);
var resized = new SKBitmap(width, height);
image.ScalePixels(resized, SKFilterQuality.High);
public static DataRectangle<SKColor> GetAllPixels(this SKBitmap image)
var values = new SKColor[image.Width, image.Height];
var pixels = image.Pixels;
for (var y = 0; y < image.Height; y++)
for (var x = 0; x < image.Width; x++)
values[x, y] = pixels[(y * image.Width) + x];
return DataRectangle.For(values);
static class DataRectangle
public static DataRectangle<T> For<T>(T[,] values) => new DataRectangle<T>(values);
sealed class DataRectangle<T>
readonly T[,] _protectedValues;
public DataRectangle(T[,] values) : this(values, isolationCopyMayBeBypassed: false) { }
DataRectangle(T[,] values, bool isolationCopyMayBeBypassed)
if ((values.GetLowerBound(0) != 0) || (values.GetLowerBound(1) != 0))
throw new ArgumentException("Both dimensions must have lower bound zero");
var arrayWidth = values.GetUpperBound(0) + 1;
var arrayHeight = values.GetUpperBound(1) + 1;
if ((arrayWidth == 0) || (arrayHeight == 0))
throw new ArgumentException("zero element arrays are not supported");
if (isolationCopyMayBeBypassed)
_protectedValues = values;
_protectedValues = new T[Width, Height];
Array.Copy(values, _protectedValues, Width * Height);
public int Width { get; }
public int Height { get; }
public T this[int x, int y]
if ((x < 0) || (x >= Width))
throw new ArgumentOutOfRangeException(nameof(x));
if ((y < 0) || (y >= Height))
throw new ArgumentOutOfRangeException(nameof(y));
return _protectedValues[x, y];
public IEnumerable<(Point Point, T Value)> Enumerate()
for (var x = 0; x < Width; x++)
for (var y = 0; y < Height; y++)
var value = _protectedValues[x, y];
var point = new Point(x, y);
yield return (point, value);
public DataRectangle<TResult> Transform<TResult>(Func<T, TResult> transformer)
var transformed = new TResult[Width, Height];
for (var x = 0; x < Width; x++)
for (var y = 0; y < Height; y++)
transformed[x, y] = transformer(_protectedValues[x, y]);
return new DataRectangle<TResult>(transformed, isolationCopyMayBeBypassed: true);
static class DataRectangleOfDoubleExtensions
public static (double Min, double Max) GetMinAndMax(this DataRectangle<double> source) =>
.Select(pointAndValue => pointAndValue.Value)
seed: (Min: double.MaxValue, Max: double.MinValue),
func: (acc, value) => (Math.Min(value, acc.Min), Math.Max(value, acc.Max))
public static DataRectangle<bool> Mask(this DataRectangle<double> values, double threshold) =>
values.Transform(value => value >= threshold);
static class IlluminatedAreaLocator
public static (Point TopLeft, Point TopRight, Point BottomRight, Point BottomLeft) GetMostHighlightedArea(
int resizeLargestSideToForProcessing = 400)
using var resizedImage = image.CopyAndResize(resizeLargestSideToForProcessing);
var greyScaleImageData = resizedImage.GetGreyscale();
const double thresholdOfRange = 2 / 3d;
var (min, max) = greyScaleImageData.GetMinAndMax();
var thresholdForMasking = min + (range * thresholdOfRange);
var mask = greyScaleImageData.Mask(thresholdForMasking);
var highlightedAreas = GetDistinctObjects(mask);
if (!highlightedAreas.Any())
var pointsInLargestHighlightedArea = highlightedAreas
.OrderByDescending(points => points.Count())
var distanceFromRight = greyScaleImageData.Width - p.X;
var distanceFromBottom = greyScaleImageData.Height - p.Y;
var fromLeftScore = p.X * p.X;
var fromTopScore = p.Y * p.Y;
var fromRightScore = distanceFromRight * distanceFromRight;
var fromBottomScore = distanceFromBottom * distanceFromBottom;
FromTopLeft = fromLeftScore + fromTopScore,
FromBottomLeft = fromLeftScore + fromBottomScore,
FromTopRight = fromRightScore + fromTopScore,
FromBottomRight = fromRightScore + fromBottomScore
var reducedImageSideBy = (double)image.Width / greyScaleImageData.Width;
pointsInLargestHighlightedArea.OrderBy(p => p.FromTopLeft).First().Point,
pointsInLargestHighlightedArea.OrderBy(p => p.FromTopRight).First().Point,
pointsInLargestHighlightedArea.OrderBy(p => p.FromBottomRight).First().Point,
pointsInLargestHighlightedArea.OrderBy(p => p.FromBottomLeft).First().Point,
static IEnumerable<IEnumerable<Point>> GetDistinctObjects(DataRectangle<bool> mask)
var allPoints = mask.Enumerate().Where(pointAndIsMasked => pointAndIsMasked.Value)
.Select(pointAndIsMasked => pointAndIsMasked.Point)
var currentPoint = allPoints.First();
var pointsInObject = GetPointsInObject(currentPoint).ToArray();
foreach (var point in pointsInObject)
yield return pointsInObject;
IEnumerable<Point> GetPointsInObject(Point startAt)
var pixels = new Stack<Point>();
var valueAtOriginPoint = mask[startAt.X, startAt.Y];
var filledPixels = new HashSet<Point>();
var currentPoint = pixels.Pop();
if ((currentPoint.X < 0) || (currentPoint.X >= mask.Width) || (currentPoint.Y < 0) || (currentPoint.Y >= mask.Height))
if ((mask[currentPoint.X, currentPoint.Y] == valueAtOriginPoint) && !filledPixels.Contains(currentPoint))
filledPixels.Add(new Point(currentPoint.X, currentPoint.Y));
pixels.Push(new Point(currentPoint.X - 1, currentPoint.Y));
pixels.Push(new Point(currentPoint.X + 1, currentPoint.Y));
pixels.Push(new Point(currentPoint.X, currentPoint.Y - 1));
pixels.Push(new Point(currentPoint.X, currentPoint.Y + 1));
static (Point TopLeft, Point TopRight, Point BottomRight, Point BottomLeft) Resize(
Point topLeft, Point topRight, Point bottomRight, Point bottomLeft,
int minX, int maxX, int minY, int maxY)
throw new ArgumentOutOfRangeException("must be a positive value", nameof(resizeBy));
Constrain(Multiply(topLeft)),
Constrain(Multiply(topRight)),
Constrain(Multiply(bottomRight)),
Constrain(Multiply(bottomLeft))
Point Multiply(Point p) =>
new Point((int)Math.Round(p.X * resizeBy), (int)Math.Round(p.Y * resizeBy));
Point Constrain(Point p) =>
new Point(Math.Min(Math.Max(p.X, minX), maxX), Math.Min(Math.Max(p.Y, minY), maxY));