From 48072d7b4ae7cb1534971dcab55687910993bf81 Mon Sep 17 00:00:00 2001 From: Colin McMillen Date: Thu, 3 Aug 2023 23:41:14 -0400 Subject: [PATCH] pull out Photo into its own file --- Photo.cs | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++++ Program.cs | 288 +--------------------------------------------------- 2 files changed, 292 insertions(+), 287 deletions(-) create mode 100644 Photo.cs diff --git a/Photo.cs b/Photo.cs new file mode 100644 index 0000000..e25db1e --- /dev/null +++ b/Photo.cs @@ -0,0 +1,291 @@ +using OpenTK.Mathematics; +using Image = SixLabors.ImageSharp.Image; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Metadata.Profiles.Xmp; +using SixLabors.ImageSharp.Formats.Jpeg; +using System.Text; +using System.Xml.Linq; + +namespace SemiColinGames; + + +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 = ""; + public string FNumber = ""; + public string ExposureTime = ""; + public string IsoSpeed = ""; + 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? 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 tmp = await Image.LoadAsync(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 image = await Image.LoadAsync(Filename)) { + Util.RotateImageFromExif(image, Orientation); + if (CropRectangle != Rectangle.Empty) { + image.Mutate(x => x.Crop(CropRectangle)); + } + + ExifProfile exif = image.Metadata.ExifProfile ?? new(); + exif.SetValue(ExifTag.Orientation, 1); + exif.SetValue(ExifTag.Artist, "Colin McMillen"); + exif.SetValue(ExifTag.Copyright, "Colin McMillen"); + exif.SetValue(ExifTag.Software, "Totte"); + exif.SetValue(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(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(""); + 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? orientation; + if (exifs.TryGetValue(ExifTag.Orientation, out orientation)) { + Orientation = orientation.Value; + } + + IExifValue? model; + if (exifs.TryGetValue(ExifTag.Model, out model)) { + CameraModel = model.Value ?? ""; + } + + IExifValue? lensModel; + if (exifs.TryGetValue(ExifTag.LensModel, out lensModel)) { + LensModel = lensModel.Value ?? ""; + ShortLensModel = GetShortLensModel(LensModel); + } + + IExifValue? focalLength; + if (exifs.TryGetValue(ExifTag.FocalLength, out focalLength)) { + Rational r = focalLength.Value; + FocalLength = $"{r.Numerator / r.Denominator}mm"; + } + + IExifValue? 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? 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? 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? 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); + } +} diff --git a/Program.cs b/Program.cs index 9889e9d..05d4308 100644 --- a/Program.cs +++ b/Program.cs @@ -5,21 +5,17 @@ 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; @@ -160,288 +156,6 @@ public class CropTool : ITool { } -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 = ""; - public string FNumber = ""; - public string ExposureTime = ""; - public string IsoSpeed = ""; - 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? 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 tmp = await Image.LoadAsync(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 image = await Image.LoadAsync(Filename)) { - Util.RotateImageFromExif(image, Orientation); - if (CropRectangle != Rectangle.Empty) { - image.Mutate(x => x.Crop(CropRectangle)); - } - - ExifProfile exif = image.Metadata.ExifProfile ?? new(); - exif.SetValue(ExifTag.Orientation, 1); - exif.SetValue(ExifTag.Artist, "Colin McMillen"); - exif.SetValue(ExifTag.Copyright, "Colin McMillen"); - exif.SetValue(ExifTag.Software, "Totte"); - exif.SetValue(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(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(""); - 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? orientation; - if (exifs.TryGetValue(ExifTag.Orientation, out orientation)) { - Orientation = orientation.Value; - } - - IExifValue? model; - if (exifs.TryGetValue(ExifTag.Model, out model)) { - CameraModel = model.Value ?? ""; - } - - IExifValue? lensModel; - if (exifs.TryGetValue(ExifTag.LensModel, out lensModel)) { - LensModel = lensModel.Value ?? ""; - ShortLensModel = GetShortLensModel(LensModel); - } - - IExifValue? focalLength; - if (exifs.TryGetValue(ExifTag.FocalLength, out focalLength)) { - Rational r = focalLength.Value; - FocalLength = $"{r.Numerator / r.Denominator}mm"; - } - - IExifValue? 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? 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? 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? 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;