totte/Program.cs

1237 lines
42 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 OpenTK.Graphics.OpenGL4;
using OpenTK.Mathematics;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Common.Input;
using OpenTK.Windowing.Desktop;
using OpenTK.Windowing.GraphicsLibraryFramework;
// https://docs.sixlabors.com/api/ImageSharp/SixLabors.ImageSharp.Image.html
using Image = SixLabors.ImageSharp.Image;
using SixLabors.Fonts;
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
using SixLabors.ImageSharp.Metadata.Profiles.Xmp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Formats.Jpeg;
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
using System.Xml.Linq;
namespace SemiColinGames;
public class FpsCounter {
private readonly int[] frameTimes = new int[30];
private double fps = 0;
private int idx = 0;
public int Fps {
get => (int) Math.Ceiling(fps);
}
public void Update() {
var now = Environment.TickCount; // ms
if (frameTimes[idx] != 0) {
var timeElapsed = now - frameTimes[idx];
fps = 1000.0 * frameTimes.Length / timeElapsed;
}
frameTimes[idx] = now;
idx = (idx + 1) % frameTimes.Length;
}
}
public class CameraInfo {
public readonly Vector2i Resolution;
private CameraInfo(Vector2i resolution) {
Resolution = resolution;
}
public static readonly CameraInfo NIKON_D7000 = new(new Vector2i(4928, 3264));
public static readonly CameraInfo CANON_EOS_R6M2 = new(new Vector2i(6000, 4000));
public static readonly CameraInfo IPHONE_12_MINI = new(new Vector2i(4032, 3024));
}
public enum ToolState {
Active,
Done,
Canceled
}
public interface ITool {
void SetActivePhoto(Photo photo);
ToolState HandleInput(UiGeometry geometry, KeyboardState input, MouseState mouse, Game game);
string Status();
void Draw(UiGeometry geometry, Game game);
}
public class ViewTool : ITool {
Photo? activePhoto;
public void SetActivePhoto(Photo photo) {
activePhoto = photo;
}
public ToolState HandleInput(UiGeometry geometry, KeyboardState input, MouseState mouse, Game game) {
return ToolState.Active;
}
public string Status() {
return "view";
}
public void Draw(UiGeometry geometry, Game game) {
}
}
// FIXME: remove unneeded dependencies on "Game" or at least refactor them a bit.
public class CropTool : ITool {
Photo? activePhoto;
Vector2i mouseDragStart;
Vector2i mouseDragEnd;
string status = "";
public void SetActivePhoto(Photo photo) {
activePhoto = photo;
}
public ToolState HandleInput(UiGeometry geometry, KeyboardState input, MouseState mouse, Game game) {
Vector2i mousePosition = (Vector2i) mouse.Position;
if (mouse.IsButtonPressed(MouseButton.Button1)) {
if (geometry.PhotoBox.ContainsInclusive(mousePosition)) {
mouseDragStart = mousePosition;
}
}
if (mouse.IsButtonDown(MouseButton.Button1)) {
if (geometry.PhotoBox.ContainsInclusive(mousePosition)) {
// FIXME: really this should be clipped to the active photo's drawable area, not the whole photobox.
mouseDragEnd = mousePosition;
}
}
Vector2i start = game.ScreenToImage(mouseDragStart);
// FIXME: this needs to be the actual width of the computed box.
Vector2i size = game.ScreenToImage(mouseDragEnd) - start;
status = $"({start.X}, {start.Y}) {size.X}x{size.Y}";
if (input.IsKeyPressed(Keys.Enter)) {
ApplyCrop(game);
return ToolState.Done;
}
if (input.IsKeyPressed(Keys.Escape)) {
activePhoto.CropRectangle = Rectangle.Empty;
return ToolState.Canceled;
}
return ToolState.Active;
}
// left, right, top, bottom
(int, int, int, int) GetCrop() {
// FIXME: this expects the start point in the top left and the end point
// in the bottom right; some sign flipping needs to occur to make anchors
// in other direction work well.
Vector2i start = mouseDragStart;
Vector2i end = mouseDragEnd;
end.Y = Math.Min(end.Y, start.Y + (end.X - start.X) * 4 / 6);
end.X = start.X + (end.Y - start.Y) * 6 / 4;
int left = Math.Min(start.X, end.X);
int right = Math.Max(start.X, end.X);
int top = Math.Min(start.Y, end.Y);
int bottom = Math.Max(start.Y, end.Y);
return (left, right, top, bottom);
}
void ApplyCrop(Game game) {
var (left, right, top, bottom) = GetCrop();
int area = (right - left) * (bottom - top);
if (area == 0) {
return;
}
Vector2i leftTop = game.ScreenToImage(left, top);
Vector2i rightBottom = game.ScreenToImage(right, bottom);
Rectangle crop = Rectangle.FromLTRB(leftTop.X, leftTop.Y, rightBottom.X, rightBottom.Y);
// FIXME: make sure this doesn't exceed image.Bounds.
// FIXME: once set, display it properly in the PhotoBox.
if (activePhoto != null) {
activePhoto.CropRectangle = crop;
}
Console.WriteLine(crop);
}
public void Draw(UiGeometry geometry, Game game) {
var (left, right, top, bottom) = GetCrop();
int area = (right - left) * (bottom - top);
if (area == 0) {
return;
}
Color4 shadeColor = new Color4(0, 0, 0, 0.75f);
game.DrawFilledBox(new Box2i(0, 0, left, geometry.PhotoBox.Max.Y), shadeColor);
game.DrawFilledBox(new Box2i(left, 0, geometry.PhotoBox.Max.X, top), shadeColor);
game.DrawFilledBox(new Box2i(left, bottom, geometry.PhotoBox.Max.X, geometry.PhotoBox.Max.Y), shadeColor);
game.DrawFilledBox(new Box2i(right, top, geometry.PhotoBox.Max.X, bottom), shadeColor);
game.DrawBox(new Box2i(left, top, right, bottom), 1, Color4.White);
game.DrawBox(new Box2i(left - 1, top - 1 , right + 1, bottom + 1), 1, Color4.Black);
game.DrawBox(new Box2i(left - 2, top - 2 , right + 2, bottom + 2), 1, Color4.White);
game.DrawHorizontalLine(left, Util.Lerp(top, bottom, 1.0 / 3), right, Color4.White);
game.DrawHorizontalLine(left, Util.Lerp(top, bottom, 2.0 / 3), right, Color4.White);
game.DrawVerticalLine(Util.Lerp(left, right, 1.0 / 3), top, bottom, Color4.White);
game.DrawVerticalLine(Util.Lerp(left, right, 2.0 / 3), top, bottom, Color4.White);
}
public string Status() {
return "[crop] " + status;
}
}
// FIXME: this should probably be IDisposable?
public class Photo {
public string Filename;
public bool Loaded = false;
public long LastTouch = 0;
public Vector2i Size;
public DateTime DateTimeOriginal;
public string CameraModel = "";
public string LensModel = "";
public string ShortLensModel = "";
public string FocalLength = "<unk>";
public string FNumber = "<unk>";
public string ExposureTime = "<unk>";
public string IsoSpeed = "<unk>";
public int Rating = 0;
public ushort Orientation = 1;
public Rectangle CropRectangle = Rectangle.Empty;
private static long touchCounter = 0;
private Texture texture;
private Texture placeholder;
private Image<Rgba32>? image = null;
public Photo(string filename, Texture placeholder) {
Filename = filename;
this.placeholder = placeholder;
texture = placeholder;
DateTime creationTime = File.GetCreationTime(filename); // Local time.
DateTimeOriginal = creationTime;
ImageInfo info = Image.Identify(filename);
Size = new(info.Size.Width, info.Size.Height);
Rating = ParseRating(info.Metadata.XmpProfile);
ParseExif(info.Metadata.ExifProfile);
}
public async void LoadAsync() {
// We don't assign to this.image until Load() is done, because we might
// edit the image due to rotation (etc) and don't want to try generating
// a texture for it until that's already happened.
LastTouch = touchCounter++;
Image<Rgba32> tmp = await Image.LoadAsync<Rgba32>(Filename);
Util.RotateImageFromExif(tmp, Orientation);
image = tmp;
}
public void Unload() {
Loaded = false;
if (texture != placeholder) {
texture.Dispose();
texture = placeholder;
}
}
public async void SaveAsJpegAsync(string outputRoot, JpegEncoder encoder) {
// FIXME: if nothing was changed about this image, just copy the file bytes directly, possibly with metadata changed?
string directory = System.IO.Path.Combine(
outputRoot,
String.Format("{0:D4}", DateTimeOriginal.Year),
String.Format("{0:D2}", DateTimeOriginal.Month),
String.Format("{0:D2}", DateTimeOriginal.Day));
Directory.CreateDirectory(directory);
string filename = System.IO.Path.Combine(directory, System.IO.Path.GetFileName(Filename));
Console.WriteLine("saving " + filename);
// FIXME: add comments / captions as ImageDescription?
// FIXME: strip some Exif tags for privacy reasons?
// FIXME: warn if the file already exists?
using (Image<Rgba32> image = await Image.LoadAsync<Rgba32>(Filename)) {
Util.RotateImageFromExif(image, Orientation);
if (CropRectangle != Rectangle.Empty) {
image.Mutate(x => x.Crop(CropRectangle));
}
ExifProfile exif = image.Metadata.ExifProfile ?? new();
exif.SetValue<ushort>(ExifTag.Orientation, 1);
exif.SetValue<string>(ExifTag.Artist, "Colin McMillen");
exif.SetValue<string>(ExifTag.Copyright, "Colin McMillen");
exif.SetValue<string>(ExifTag.Software, "Totte");
exif.SetValue<ushort>(ExifTag.Rating, (ushort) Rating);
DateTime now = DateTime.Now;
string datetime = String.Format(
"{0:D4}:{1:D2}:{2:D2} {3:D2}:{4:D2}:{5:D2}",
now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second);
exif.SetValue<string>(ExifTag.DateTime, datetime);
image.Metadata.XmpProfile = UpdateXmp(image.Metadata.XmpProfile);
await image.SaveAsync(filename, encoder);
}
}
private XElement? GetXmpRoot(XmpProfile? xmp) {
if (xmp == null) {
return null;
}
XDocument? doc = xmp.GetDocument();
if (doc == null) {
return null;
}
return doc.Root;
}
private int ParseRating(XmpProfile? xmp) {
XElement? root = GetXmpRoot(xmp);
if (root == null) {
return 0;
}
foreach (XElement elt in root.Descendants()) {
if (elt.Name == "{http://ns.adobe.com/xap/1.0/}Rating") {
int rating = 0;
if (int.TryParse(elt.Value, out rating)) {
return rating;
}
}
}
return 0;
}
private XmpProfile? UpdateXmp(XmpProfile? xmp) {
if (xmp == null) {
return null;
}
string xmlIn = Encoding.UTF8.GetString(xmp.ToByteArray());
int index = xmlIn.IndexOf("</xmp:Rating>");
if (index == -1) {
return xmp;
}
string xmlOut = xmlIn.Substring(0, index - 1) + Rating.ToString() + xmlIn.Substring(index);
return new XmpProfile(Encoding.UTF8.GetBytes(xmlOut));
}
// Exif (and other image metadata) reference, from the now-defunct Metadata Working Group:
// https://web.archive.org/web/20180919181934/http://www.metadataworkinggroup.org/pdf/mwg_guidance.pdf
//
// Specifically:
//
// In general, date/time metadata is being used to describe the following scenarios:
// * Date/time original specifies when a photo was taken
// * Date/time digitized specifies when an image was digitized
// * Date/time modified specifies when a file was modified by the user
//
// Original Date/Time Creation date of the intellectual content (e.g. the photograph), rather than the creation date of the content being shown
// Exif DateTimeOriginal (36867, 0x9003) and SubSecTimeOriginal (37521, 0x9291)
// IPTC DateCreated (IIM 2:55, 0x0237) and TimeCreated (IIM 2:60, 0x023C)
// XMP (photoshop:DateCreated)
//
// Digitized Date/Time Creation date of the digital representation
// Exif DateTimeDigitized (36868, 0x9004) and SubSecTimeDigitized (37522, 0x9292)
// IPTC DigitalCreationDate (IIM 2:62, 0x023E) and DigitalCreationTime (IIM 2:63, 0x023F)
// XMP (xmp:CreateDate)
//
// Modification Date/Time Modification date of the digital image file
// Exif DateTime (306, 0x132) and SubSecTime (37520, 0x9290)
// XMP (xmp:ModifyDate)
//
// See also: https://exiftool.org/TagNames/EXIF.html
private void ParseExif(ExifProfile? exifs) {
if (exifs == null) {
return;
}
IExifValue<ushort>? orientation;
if (exifs.TryGetValue(ExifTag.Orientation, out orientation)) {
Orientation = orientation.Value;
}
IExifValue<string>? model;
if (exifs.TryGetValue(ExifTag.Model, out model)) {
CameraModel = model.Value ?? "";
}
IExifValue<string>? lensModel;
if (exifs.TryGetValue(ExifTag.LensModel, out lensModel)) {
LensModel = lensModel.Value ?? "";
ShortLensModel = GetShortLensModel(LensModel);
}
IExifValue<Rational>? focalLength;
if (exifs.TryGetValue(ExifTag.FocalLength, out focalLength)) {
Rational r = focalLength.Value;
FocalLength = $"{r.Numerator / r.Denominator}mm";
}
IExifValue<Rational>? fNumber;
if (exifs.TryGetValue(ExifTag.FNumber, out fNumber)) {
Rational r = fNumber.Value;
if (r.Numerator % r.Denominator == 0) {
FNumber = $"f/{r.Numerator / r.Denominator}";
} else {
int fTimesTen = (int) Math.Round(10f * r.Numerator / r.Denominator);
FNumber = $"f/{fTimesTen / 10}.{fTimesTen % 10}";
}
}
// FIXME: could also show ExposureBiasValue, ExposureMode, ExposureProgram?
IExifValue<Rational>? exposureTime;
if (exifs.TryGetValue(ExifTag.ExposureTime, out exposureTime)) {
Rational r = exposureTime.Value;
if (r.Numerator == 1) {
ExposureTime = $"1/{r.Denominator}";
} else if (r.Numerator == 10) {
ExposureTime = $"1/{r.Denominator / 10}";
} else if (r.Denominator == 1) {
ExposureTime = $"{r.Numerator }\"";
} else if (r.Denominator == 10) {
ExposureTime = $"{r.Numerator / 10}.{r.Numerator % 10}\"";
} else {
Console.WriteLine($"*** WARNING: unexpected ExposureTime: {r.Numerator}/{r.Denominator}");
ExposureTime = r.ToString();
}
}
IExifValue<ushort[]>? isoSpeed;
if (exifs.TryGetValue(ExifTag.ISOSpeedRatings, out isoSpeed)) {
ushort[]? iso = isoSpeed.Value;
if (iso != null) {
if (iso.Length != 1) {
Console.WriteLine($"*** WARNING: unexpected ISOSpeedRatings array length: {iso.Length}");
}
if (iso.Length >= 1) {
IsoSpeed = $"ISO {iso[0]}";
}
}
}
// FIXME: there is also a SubSecTimeOriginal tag we could use to get fractional seconds.
// FIXME: I think the iPhone stores time in UTC but other cameras report it in local time.
IExifValue<string>? dateTimeOriginal;
if (exifs.TryGetValue(ExifTag.DateTimeOriginal, out dateTimeOriginal)) {
DateTime date;
if (DateTime.TryParseExact(
dateTimeOriginal.Value ?? "",
"yyyy:MM:dd HH:mm:ss",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeLocal,
out date)) {
DateTimeOriginal = date;
} else {
Console.WriteLine($"*** WARNING: unexpected DateTimeOriginal value: {dateTimeOriginal.Value}");
}
}
}
public string GetShortLensModel(string lensModel) {
// Example Canon RF lens names:
// RF16mm F2.8 STM
// RF24-105mm F4-7.1 IS STM
// RF35mm F1.8 MACRO IS STM
// RF100-400mm F5.6-8 IS USM
string[] tokens = lensModel.Split(' ');
string result = "";
foreach (string token in tokens) {
if (token == "STM" || token == "IS" || token == "USM") {
continue;
}
result += token + " ";
}
return result.Trim();
}
public Texture Texture() {
LastTouch = touchCounter++;
if (texture == placeholder && image != null) {
// The texture needs to be created on the GL thread, so we instantiate
// it here (since this is called from OnRenderFrame), as long as the
// image is ready to go.
texture = new Texture(image);
image.Dispose();
image = null;
Loaded = true;
}
return texture;
}
public string Description() {
string date = DateTimeOriginal.ToString("yyyy-MM-dd HH:mm:ss");
return String.Format(
"{0,6} {1,-5} {2,-7} {3,-10} {4} {5,-20} {6}",
FocalLength, FNumber, ExposureTime, IsoSpeed, date, ShortLensModel, Filename);
}
}
public class Texture : IDisposable {
public int Handle;
public Vector2i Size;
private static int maxHandle = -1;
private bool disposedValue = false;
public Texture(Image<Rgba32> image) {
Size = new Vector2i(image.Width, image.Height);
byte[] pixelBytes = new byte[Size.X * Size.Y * Unsafe.SizeOf<Rgba32>()];
image.CopyPixelDataTo(pixelBytes);
Handle = GL.GenTexture();
if (Handle > maxHandle) {
// Console.WriteLine("GL.GenTexture #" + Handle);
maxHandle = Handle;
}
GL.ActiveTexture(TextureUnit.Texture0);
GL.BindTexture(TextureTarget.Texture2D, Handle);
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, Size.X, Size.Y, 0, PixelFormat.Rgba, PixelType.UnsignedByte, pixelBytes);
//GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.LinearMipmapLinear);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.Linear);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int) TextureMagFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int) TextureWrapMode.ClampToBorder);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int) TextureWrapMode.ClampToBorder);
float[] borderColor = { 0.0f, 0.0f, 0.0f, 1.0f };
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureBorderColor, borderColor);
// FIXME: should we use mipmaps?
//GL.GenerateMipmap(GenerateMipmapTarget.Texture2D);
}
protected virtual void Dispose(bool disposing) {
if (!disposedValue) {
GL.DeleteTexture(Handle);
disposedValue = true;
}
}
~Texture() {
if (!disposedValue) {
Console.WriteLine("~Texture(): resource leak? Dispose() should be called manually.");
}
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
}
public class UiGeometry {
public static Vector2i MIN_WINDOW_SIZE = new(1024, 768);
private static CameraInfo activeCamera = CameraInfo.CANON_EOS_R6M2;
public readonly Vector2i WindowSize;
public readonly Box2i ThumbnailBox;
public readonly List<Box2i> ThumbnailBoxes = new();
public readonly List<Box2i> StarBoxes = new();
public readonly Box2i PhotoBox;
public readonly Box2i StatusBox;
public UiGeometry() : this(MIN_WINDOW_SIZE, 0) {}
public UiGeometry(Vector2i windowSize, int starSize) {
WindowSize = windowSize;
int numThumbnails = Math.Max(WindowSize.Y / 100, 1);
int thumbnailHeight = WindowSize.Y / numThumbnails;
int thumbnailWidth = (int) 1.0 * thumbnailHeight * activeCamera.Resolution.X / activeCamera.Resolution.Y;
Console.WriteLine($"thumbnail size: {thumbnailWidth} x {thumbnailHeight}");
for (int i = 0; i < numThumbnails; i++) {
Box2i box = Util.MakeBox(WindowSize.X - thumbnailWidth, i * thumbnailHeight, thumbnailWidth, thumbnailHeight);
ThumbnailBoxes.Add(box);
}
int statusBoxHeight = 40;
int statusBoxPadding = 4;
PhotoBox = new Box2i(0, 0, WindowSize.X - thumbnailWidth, WindowSize.Y - statusBoxHeight - statusBoxPadding);
StatusBox = new Box2i(0, WindowSize.Y - statusBoxHeight, WindowSize.X - thumbnailWidth, WindowSize.Y);
ThumbnailBox = new Box2i(ThumbnailBoxes[0].Min.X, ThumbnailBoxes[0].Min.Y, WindowSize.X, WindowSize.Y);
int starSpacing = 10;
int starBoxLeft = (int) (PhotoBox.Center.X - 2.5 * starSize - starSpacing * 2);
for (int i = 0; i < 5; i++) {
Box2i box = Util.MakeBox(starBoxLeft + i * (starSize + starSpacing), PhotoBox.Max.Y - starSize - 10, starSize, starSize);
StarBoxes.Add(box);
}
}
}
public static class Util {
public const float PI = (float) Math.PI;
public static int Lerp(int start, int end, double fraction) {
return start + (int) ((end - start) * fraction);
}
public static Box2i MakeBox(int left, int top, int width, int height) {
return new Box2i(left, top, left + width, top + height);
}
public static Image<Rgba32> MakeImage(float width, float height) {
return new((int) Math.Ceiling(width), (int) Math.Ceiling(height));
}
// https://sirv.com/help/articles/rotate-photos-to-be-upright/
public static void RotateImageFromExif(Image<Rgba32> image, ushort orientation) {
if (orientation <= 1) {
return;
}
// FIXME: I'm not convinced that all of these are correct, especially the
// cases that involve flipping (because whether you're flipping before or
// after rotation matters.).
var operations = new Dictionary<ushort, (RotateMode, FlipMode)> {
{ 2, (RotateMode.None, FlipMode.Horizontal) },
{ 3, (RotateMode.Rotate180, FlipMode.None) },
{ 4, (RotateMode.None, FlipMode.Vertical) },
{ 5, (RotateMode.Rotate90, FlipMode.Vertical) },
{ 6, (RotateMode.Rotate90, FlipMode.None) },
{ 7, (RotateMode.Rotate270, FlipMode.Vertical) },
{ 8, (RotateMode.Rotate270, FlipMode.None) },
};
var (rotate, flip) = operations[orientation];
image.Mutate(x => x.RotateFlip(rotate, flip));
}
public static Texture RenderText(string text) {
return RenderText(text, 16);
}
public static Texture RenderText(string text, int size) {
Font font = SystemFonts.CreateFont("Consolas", size, FontStyle.Bold);
TextOptions options = new(font);
FontRectangle rect = TextMeasurer.Measure(text, new TextOptions(font));
Image<Rgba32> image = MakeImage(rect.Width, rect.Height);
IBrush brush = Brushes.Solid(Color.White);
image.Mutate(x => x.DrawText(options, text, brush));
Texture texture = new Texture(image);
image.Dispose();
return texture;
}
// FIXME: make a real icon stored as a PNG...
public static OpenTK.Windowing.Common.Input.Image[] RenderAppIcon() {
int size = 64;
Font font = SystemFonts.CreateFont("MS Mincho", size, FontStyle.Bold);
TextOptions options = new(font);
Image<Rgba32> image = MakeImage(size, size);
IBrush brush = Brushes.Solid(Color.Black);
image.Mutate(x => x.DrawText(options, "撮", brush));
byte[] pixelBytes = new byte[size * size * 4];
image.CopyPixelDataTo(pixelBytes);
image.Dispose();
OpenTK.Windowing.Common.Input.Image opentkImage = new(size, size, pixelBytes);
return new OpenTK.Windowing.Common.Input.Image[]{ opentkImage };
}
public static Texture RenderStar(float radius, bool filled) {
IPath path = new Star(x: radius, y: radius + 1, prongs: 5, innerRadii: radius * 0.4f, outerRadii: radius, angle: Util.PI);
// We add a little bit to the width & height because the reported
// path.Bounds are often a little tighter than they should be & a couple
// pixels end up obviously missing...
Image<Rgba32> image = MakeImage(path.Bounds.Width + 2, path.Bounds.Height + 2);
IBrush brush = Brushes.Solid(Color.White);
IPen white = Pens.Solid(Color.White, 1.5f);
IPen black = Pens.Solid(Color.Black, 3f);
image.Mutate(x => x.Draw(black, path));
if (filled) {
image.Mutate(x => x.Fill(brush, path));
}
image.Mutate(x => x.Draw(white, path));
Texture texture = new Texture(image);
image.Dispose();
return texture;
}
}
public class Game : GameWindow {
public Game(GameWindowSettings gwSettings, NativeWindowSettings nwSettings) : base(gwSettings, nwSettings) {
activeTool = viewTool;
}
private static string outputRoot = @"c:\users\colin\desktop\totte-output";
// private static string outputRoot = @"c:\users\colin\pictures\photos";
private static Texture TEXTURE_WHITE = new(new Image<Rgba32>(1, 1, new Rgba32(255, 255, 255)));
private static Texture TEXTURE_BLACK = new(new Image<Rgba32>(1, 1, new Rgba32(0, 0, 0)));
private static Texture STAR_FILLED = Util.RenderStar(20, true);
private static Texture STAR_EMPTY = Util.RenderStar(20, false);
private static Texture STAR_SMALL = Util.RenderStar(6, true);
UiGeometry geometry = new();
FpsCounter fpsCounter = new();
// Four points, each consisting of (x, y, z, tex_x, tex_y).
float[] vertices = new float[20];
// Indices to draw a rectangle from two triangles.
uint[] indices = {
0, 1, 3, // first triangle
1, 2, 3 // second triangle
};
int VertexBufferObject;
int ElementBufferObject;
int VertexArrayObject;
List<Photo> allPhotos = new();
List<Photo> photos = new();
HashSet<Photo> loadedImages = new();
HashSet<Photo> loadingImages = new();
readonly object loadedImagesLock = new();
readonly ViewTool viewTool = new ViewTool();
ITool activeTool;
int photoIndex = 0;
int ribbonIndex = 0;
Vector2i mousePosition;
float activeScale = 1f;
Vector2i activeOffset;
Shader shader = new();
Matrix4 projection;
float zoomLevel = 0f;
protected override void OnUpdateFrame(FrameEventArgs e) {
base.OnUpdateFrame(e);
KeyboardState input = KeyboardState;
bool shiftIsDown = input.IsKeyDown(Keys.LeftShift) || input.IsKeyDown(Keys.RightShift);
bool altIsDown = input.IsKeyDown(Keys.LeftAlt) || input.IsKeyDown(Keys.RightAlt);
bool ctrlIsDown = input.IsKeyDown(Keys.LeftControl) || input.IsKeyDown(Keys.RightControl);
// FIXME: add a confirm dialog before closing. (Also for the window-close button.)
// FIXME: don't quit if there's pending file-write operations.
// Close when Ctrl-Q is pressed.
if (input.IsKeyPressed(Keys.Q) && ctrlIsDown) {
Close();
}
int lastPhotoIndex = photoIndex;
mousePosition = (Vector2i) MouseState.Position;
// Look for mouse clicks on thumbnails or stars.
//
// Note that we don't bounds-check photoIndex until after all the possible
// inputs that might affect it. That simplifies this logic significantly.
if (MouseState.IsButtonPressed(MouseButton.Button1)) {
for (int i = 0; i < geometry.StarBoxes.Count; i++) {
if (geometry.StarBoxes[i].ContainsInclusive(mousePosition)) {
photos[photoIndex].Rating = i + 1;
}
}
for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
if (geometry.ThumbnailBoxes[i].ContainsInclusive(mousePosition)) {
photoIndex = ribbonIndex + i;
}
}
}
if (MouseState.IsButtonPressed(MouseButton.Button4)) {
photoIndex--;
}
if (MouseState.IsButtonPressed(MouseButton.Button5)) {
photoIndex++;
}
if (MouseState.ScrollDelta.Y < 0) {
photoIndex++;
}
if (MouseState.ScrollDelta.Y > 0) {
photoIndex--;
}
if (input.IsKeyPressed(Keys.Down)) {
photoIndex++;
}
if (input.IsKeyPressed(Keys.Up)) {
photoIndex--;
}
if (input.IsKeyPressed(Keys.Home)) {
photoIndex = 0;
}
if (input.IsKeyPressed(Keys.End)) {
photoIndex = photos.Count - 1;
}
if (input.IsKeyPressed(Keys.PageDown)) {
photoIndex += 5;
}
if (input.IsKeyPressed(Keys.PageUp)) {
photoIndex -= 5;
}
if (input.IsKeyPressed(Keys.P) && altIsDown) {
ExportPhotos();
}
// Make sure the photoIndex is actually valid.
if (photos.Count == 0) {
photoIndex = 0;
} else {
photoIndex = Math.Clamp(photoIndex, 0, photos.Count - 1);
}
if (photoIndex != lastPhotoIndex) {
// FIXME!!!: do something to reset tool state here
}
// Handle presses of the "rating" keys -- 0-5 and `.
// A normal press just sets the rating of the current photo.
// If the user is holding "shift", we instead filter to only show photos of that rating or higher.
int rating = -1;
if (input.IsKeyPressed(Keys.D0) || input.IsKeyPressed(Keys.GraveAccent)) {
rating = 0;
}
if (input.IsKeyPressed(Keys.D1)) {
rating = 1;
}
if (input.IsKeyPressed(Keys.D2)) {
rating = 2;
}
if (input.IsKeyPressed(Keys.D3)) {
rating = 3;
}
if (input.IsKeyPressed(Keys.D4)) {
rating = 4;
}
if (input.IsKeyPressed(Keys.D5)) {
rating = 5;
}
if (rating >= 0) {
if (shiftIsDown) {
FilterByRating(rating);
} else {
if (photos.Count > 0) {
photos[photoIndex].Rating = rating;
}
}
}
if (input.IsKeyPressed(Keys.Q)) {
zoomLevel = 0f;
}
if (input.IsKeyPressed(Keys.W)) {
zoomLevel = 1f;
}
if (input.IsKeyPressed(Keys.E)) {
zoomLevel = 2f;
}
if (input.IsKeyPressed(Keys.R)) {
zoomLevel = 4f;
}
if (input.IsKeyPressed(Keys.T)) {
zoomLevel = 8f;
}
if (input.IsKeyPressed(Keys.Y)) {
zoomLevel = 16f;
}
// Handle tool switching.
if (activeTool == viewTool) {
if (input.IsKeyPressed(Keys.C)) {
activeTool = new CropTool();
}
}
// Delegate input to the active tool.
activeTool.SetActivePhoto(photos[photoIndex]);
ToolState state = activeTool.HandleInput(geometry, KeyboardState, MouseState, this);
// Change back to the default tool if the active tool is done.
if (state != ToolState.Active) {
activeTool = viewTool;
}
}
void FilterByRating(int rating) {
Console.WriteLine("filter to " + rating);
Photo previouslyActive = photos.Count > 0 ? photos[photoIndex] : allPhotos[0];
photos = allPhotos.Where(p => p.Rating >= rating).ToList();
// Move photoIndex to wherever the previously active photo was, or if it
// was filtered out, to whichever unfiltered photo comes before it. This
// is O(n) in the length of allPhotos, but how bad can it be? :)
photoIndex = -1;
for (int i = 0; i < allPhotos.Count; i++) {
Photo candidate = allPhotos[i];
if (candidate.Rating >= rating) {
photoIndex++;
}
if (candidate == previouslyActive) {
break;
}
}
photoIndex = Math.Max(0, photoIndex);
}
// FIXME: switch to immediate mode??
// https://gamedev.stackexchange.com/questions/198805/opentk-immediate-mode-on-net-core-doesnt-work
// https://www.youtube.com/watch?v=Q23Kf9QEaO4
protected override void OnLoad() {
base.OnLoad();
GL.ClearColor(0f, 0f, 0f, 1f);
GL.Enable(EnableCap.Blend);
GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
VertexArrayObject = GL.GenVertexArray();
GL.BindVertexArray(VertexArrayObject);
VertexBufferObject = GL.GenBuffer();
ElementBufferObject = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.DynamicDraw);
GL.BindBuffer(BufferTarget.ElementArrayBuffer, ElementBufferObject);
GL.BufferData(BufferTarget.ElementArrayBuffer, indices.Length * sizeof(uint), indices, BufferUsageHint.DynamicDraw);
shader.Init();
shader.Use();
// Because there's 5 floats between the start of the first vertex and the start of the second,
// the stride is 5 * sizeof(float).
// This will now pass the new vertex array to the buffer.
var vertexLocation = shader.GetAttribLocation("aPosition");
GL.EnableVertexAttribArray(vertexLocation);
GL.VertexAttribPointer(vertexLocation, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), 0);
// Next, we also setup texture coordinates. It works in much the same way.
// We add an offset of 3, since the texture coordinates comes after the position data.
// We also change the amount of data to 2 because there's only 2 floats for texture coordinates.
var texCoordLocation = shader.GetAttribLocation("aTexCoord");
GL.EnableVertexAttribArray(texCoordLocation);
GL.VertexAttribPointer(texCoordLocation, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), 3 * sizeof(float));
// Load photos from a directory.
string[] files = Directory.GetFiles(@"c:\users\colin\desktop\photos-test\");
// string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\14\");
// string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\23\");
// string[] files = Directory.GetFiles(@"G:\DCIM\100EOSR6\");
// string[] files = Directory.GetFiles(@"c:\users\colin\desktop\totte-output\2023\07\31");
// string[] files = Directory.GetFiles(@"C:\Users\colin\Pictures\photos\2018\06\23");
// string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\Germany all\104D7000");
// string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\many-birds\");
for (int i = 0; i < files.Count(); i++) {
string file = files[i];
if (file.ToLower().EndsWith(".jpg")) {
Photo photo = new Photo(file, TEXTURE_BLACK);
allPhotos.Add(photo);
}
}
allPhotos.Sort(ComparePhotosByDate);
photos = allPhotos;
}
private static int ComparePhotosByDate(Photo x, Photo y) {
int compare = x.DateTimeOriginal.CompareTo(y.DateTimeOriginal);
if (compare != 0) {
return compare;
}
// If the photos have the same seconds value, sort by filename
// (since cameras usually increment the filename for successive shots.)
return x.Filename.CompareTo(y.Filename);
}
protected override void OnUnload() {
base.OnUnload();
}
private void UnloadImages() {
// Unload images that haven't been touched in a while.
// FIXME: keep around thumbnail-sized textures?
lock (loadedImagesLock) {
while (loadedImages.Count > 100) {
long earliestTime = long.MaxValue;
Photo? earliest = null;
foreach (Photo photo in loadedImages) {
if (photo.LastTouch < earliestTime) {
earliest = photo;
earliestTime = photo.LastTouch;
}
}
if (earliest != null) {
Console.WriteLine($"loadedImages.Count: {loadedImages.Count}, evicting {earliest.Filename} @ {earliestTime}");
// TODO: we have to free textures on the GL thread, but could we do that async'ly to keep the UI responsive?
earliest.Unload();
loadedImages.Remove(earliest);
}
}
}
}
private async void LoadImagesAsync() {
foreach (Photo p in loadingImages) {
if (p.Loaded) {
lock (loadedImagesLock) {
loadedImages.Add(p);
loadingImages.Remove(p);
}
}
}
// Start loading any images that are in our window but not yet loaded.
int minLoadedImage = Math.Max(0, photoIndex - 30);
int maxLoadedImage = Math.Min(photoIndex + 30, photos.Count - 1);
List<Photo> toLoad = new();
for (int i = minLoadedImage; i <= maxLoadedImage; i++) {
lock (loadedImagesLock) {
if (!loadedImages.Contains(photos[i]) && !loadingImages.Contains(photos[i])) {
Console.WriteLine("loading " + i);
loadingImages.Add(photos[i]);
toLoad.Add(photos[i]);
}
}
}
foreach (Photo p in toLoad) {
await Task.Run( () => { p.LoadAsync(); });
}
}
// To find the JPEG compression level of a file from the command line:
// $ identify -verbose image.jpg | grep Quality:
// FIXME: don't ExportPhotos() if another export is already active.
// FIXME: show a progress bar or something.
private async void ExportPhotos() {
JpegEncoder encoder = new JpegEncoder() { Quality = 100 };
foreach (Photo p in photos) {
await Task.Run( () => { p.SaveAsJpegAsync(outputRoot, encoder); });
}
}
protected override void OnRenderFrame(FrameEventArgs e) {
base.OnRenderFrame(e);
fpsCounter.Update();
UnloadImages();
LoadImagesAsync();
GL.Clear(ClearBufferMask.ColorBufferBit);
GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
GL.ActiveTexture(TextureUnit.Texture0);
if (photos.Count > 0) {
DrawPhotos();
} else {
DrawText("No photos found.", 10, 10);
}
activeTool.Draw(geometry, this);
SwapBuffers();
}
void DrawPhotos() {
Photo activePhoto = photos[photoIndex];
Texture active = activePhoto.Texture();
// FIXME: make a function for scaling & centering one box on another.
float scaleX = 1f * geometry.PhotoBox.Size.X / active.Size.X;
float scaleY = 1f * geometry.PhotoBox.Size.Y / active.Size.Y;
float scale = Math.Min(scaleX, scaleY);
if (zoomLevel > 0f) {
scale = zoomLevel;
}
activeScale = scale;
Vector2i renderSize = (Vector2i) (((Vector2) active.Size) * scale);
Vector2i center = (Vector2i) geometry.PhotoBox.Center;
Box2i photoBox = Util.MakeBox(center.X - renderSize.X / 2, center.Y - renderSize.Y / 2, renderSize.X, renderSize.Y);
activeOffset = new(photoBox.Min.X, photoBox.Min.Y);
DrawTexture(active, photoBox);
for (int i = 0; i < 5; i++) {
Texture star = (activePhoto.Rating > i) ? STAR_FILLED : STAR_EMPTY;
DrawTexture(star, geometry.StarBoxes[i].Min.X, geometry.StarBoxes[i].Min.Y);
}
// Draw thumbnail boxes.
ribbonIndex = Math.Clamp(photoIndex - (geometry.ThumbnailBoxes.Count - 1) / 2, 0, Math.Max(0, photos.Count - geometry.ThumbnailBoxes.Count));
DrawFilledBox(geometry.ThumbnailBox, Color4.Black);
for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
if (ribbonIndex + i >= photos.Count) {
break;
}
Photo photo = photos[ribbonIndex + i];
Box2i box = geometry.ThumbnailBoxes[i];
DrawTexture(photo.Texture(), box);
for (int j = 0; j < photo.Rating; j++) {
DrawTexture(STAR_SMALL, box.Min.X + 8 + ((STAR_SMALL.Size.X + 2) * j), box.Min.Y + 8);
}
if (ribbonIndex + i == photoIndex) {
DrawBox(box, 5, Color4.Black);
DrawBox(box, 3, Color4.White);
}
}
// Draw status box.
int statusPadding = 2;
DrawFilledBox(geometry.StatusBox, Color4.Black);
// First line.
int y = geometry.StatusBox.Min.Y + statusPadding;
DrawText(String.Format("{0,4}/{1,-4}", photoIndex + 1, photos.Count), geometry.StatusBox.Min.X, y);
DrawText(activePhoto.Description(), geometry.StatusBox.Min.X + 88, y);
// Second line.
y += 20;
DrawText(activeTool.Status(), geometry.StatusBox.Min.X, y);
DrawText(String.Format("FPS: {0,2}", fpsCounter.Fps), geometry.StatusBox.Max.X - 66, y);
if (activePhoto.Loaded) {
DrawText($"{(scale * 100):F1}%", geometry.StatusBox.Max.X - 136, y);
}
}
public Vector2i ScreenToImage(int x, int y) {
int rx = (int) ((x - activeOffset.X) / activeScale);
int ry = (int) ((y - activeOffset.Y) / activeScale);
return new(rx, ry);
}
public Vector2i ScreenToImage(Vector2i position) {
return ScreenToImage(position.X, position.Y);
}
public void DrawTexture(Texture texture, int x, int y) {
DrawTexture(texture, Util.MakeBox(x, y, texture.Size.X, texture.Size.Y));
}
public void DrawTexture(Texture texture, Box2i box) {
DrawTexture(texture, box, Color4.White);
}
public void DrawTexture(Texture texture, Box2i box, Color4 color) {
GL.Uniform4(shader.GetUniformLocation("color"), color);
SetVertices(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y);
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.DynamicDraw);
GL.BindTexture(TextureTarget.Texture2D, texture.Handle);
GL.DrawElements(PrimitiveType.Triangles, indices.Length, DrawElementsType.UnsignedInt, 0);
}
public void DrawHorizontalLine(int left, int top, int right, Color4 color) {
DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, right - left, 1), color);
}
public void DrawVerticalLine(int left, int top, int bottom, Color4 color) {
DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, 1, bottom - top), color);
}
public void DrawBox(Box2i box, int thickness, Color4 color) {
DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, thickness), color);
DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, thickness, box.Size.Y), color);
DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Max.Y - thickness, box.Size.X, thickness), color);
DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Max.X - thickness, box.Min.Y, thickness, box.Size.Y), color);
}
public void DrawFilledBox(Box2i box, Color4 color) {
DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y), color);
}
public void DrawText(string text, int x, int y) {
Texture label = Util.RenderText(text);
DrawTexture(label, x, y);
label.Dispose();
}
protected override void OnResize(ResizeEventArgs e) {
base.OnResize(e);
Console.WriteLine($"OnResize: {e.Width}x{e.Height}");
geometry = new UiGeometry(e.Size, STAR_FILLED.Size.X);
projection = Matrix4.CreateOrthographicOffCenter(0f, e.Width, e.Height, 0f, -1f, 1f);
GL.UniformMatrix4(shader.GetUniformLocation("projection"), true, ref projection);
GL.Viewport(0, 0, e.Width, e.Height);
}
private void SetVertices(float left, float top, float width, float height) {
// top left
vertices[0] = left;
vertices[1] = top;
vertices[2] = 0f;
vertices[3] = 0f;
vertices[4] = 0f;
// top right
vertices[5] = left + width;
vertices[6] = top;
vertices[7] = 0f;
vertices[8] = 1f;
vertices[9] = 0f;
// bottom right
vertices[10] = left + width;
vertices[11] = top + height;
vertices[12] = 0f;
vertices[13] = 1f;
vertices[14] = 1f;
// bottom left
vertices[15] = left;
vertices[16] = top + height;
vertices[17] = 0f;
vertices[18] = 0f;
vertices[19] = 1f;
}
}
static class Program {
static void Main(string[] args) {
List<MonitorInfo> monitors = Monitors.GetMonitors();
MonitorInfo bestMonitor = monitors[0];
int bestResolution = bestMonitor.HorizontalResolution * bestMonitor.VerticalResolution;
for (int i = 1; i < monitors.Count; i++) {
MonitorInfo monitor = monitors[i];
int resolution = monitor.HorizontalResolution * monitor.VerticalResolution;
if (resolution > bestResolution) {
bestResolution = resolution;
bestMonitor = monitor;
}
}
Console.WriteLine($"best monitor: {bestMonitor.HorizontalResolution}x{bestMonitor.VerticalResolution}");
GameWindowSettings gwSettings = new();
gwSettings.UpdateFrequency = 30.0;
gwSettings.RenderFrequency = 30.0;
NativeWindowSettings nwSettings = new();
nwSettings.WindowState = WindowState.Normal;
nwSettings.CurrentMonitor = bestMonitor.Handle;
nwSettings.Location = new Vector2i(bestMonitor.WorkArea.Min.X + 1, bestMonitor.WorkArea.Min.Y + 31);
// nwSettings.Size = new Vector2i(bestMonitor.WorkArea.Size.X - 2, bestMonitor.WorkArea.Size.Y - 32);
nwSettings.Size = new Vector2i(1600, 900);
nwSettings.MinimumSize = UiGeometry.MIN_WINDOW_SIZE;
nwSettings.Title = "Totte";
nwSettings.IsEventDriven = false;
nwSettings.Icon = new WindowIcon(Util.RenderAppIcon());
using (Game game = new(gwSettings, nwSettings)) {
game.Run();
}
}
}