using OpenTK.Mathematics; using Image = SixLabors.ImageSharp.Image; using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Png; using static System.IO.Path; using System.Text; using System.Xml.Linq; namespace SemiColinGames; // https://exiftool.org/TagNames/GPS.html public struct GpsInfo { public byte[] VersionId; public string Status; public string Datestamp; public Rational[] Timestamp; public Rational[] Latitude; public string LatitudeRef; public Rational[] Longitude; public string LongitudeRef; public Rational Altitude; public byte AltitudeRef; public static GpsInfo? ParseExif(ExifProfile exif) { GpsInfo gps; if (!Parse(exif, ExifTag.GPSVersionID, out gps.VersionId)) { return null; } if (!Parse(exif, ExifTag.GPSStatus, out gps.Status)) { return null; } if (!Parse(exif, ExifTag.GPSDateStamp, out gps.Datestamp)) { return null; } if (!Parse(exif, ExifTag.GPSTimestamp, out gps.Timestamp)) { return null; } if (!Parse(exif, ExifTag.GPSLatitude, out gps.Latitude)) { return null; } if (!Parse(exif, ExifTag.GPSLatitudeRef, out gps.LatitudeRef)) { return null; } if (!Parse(exif, ExifTag.GPSLongitude, out gps.Longitude)) { return null; } if (!Parse(exif, ExifTag.GPSLongitudeRef, out gps.LongitudeRef)) { return null; } if (!Parse(exif, ExifTag.GPSAltitude, out gps.Altitude)) { return null; } if (!Parse(exif, ExifTag.GPSAltitudeRef, out gps.AltitudeRef)) { return null; } return gps; } // FIXME: use this Parse() function in Photo.ParseExif() as well? private static bool Parse(ExifProfile exif, ExifTag tag, out T result) { IExifValue? data; if (exif.TryGetValue(tag, out data)) { if (data != null && data.Value != null) { result = data.Value; return true; } } #pragma warning disable CS8601 result = default(T); #pragma warning restore CS8601 return false; } } 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 string ExposureBiasValue = ""; public int Rating = 0; public ushort Orientation = 1; public GpsInfo? Gps = null; public Rectangle CropRectangle = Rectangle.Empty; public Vector2i ViewOffset = Vector2i.Zero; private static long touchCounter = 0; private Texture texture; private Texture placeholder; private Texture thumbnailTexture; private Image? image = null; private Image? thumbnail = null; public Photo(string filename, Texture placeholder) { Filename = filename; this.placeholder = placeholder; texture = placeholder; thumbnailTexture = 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(Vector2i size) { // 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++; // FIXME: if we zoom in to more than the display size, actually load the whole image? DecoderOptions options = new DecoderOptions { TargetSize = new Size(size.X, size.Y), SkipMetadata = true }; Image tmp = await Image.LoadAsync(options, Filename); Util.RotateImageFromExif(tmp, Orientation); image = tmp; } public async void LoadThumbnailAsync(Vector2i size) { DecoderOptions options = new DecoderOptions { TargetSize = new Size(size.X, size.Y), SkipMetadata = true }; Image tmp = await Image.LoadAsync(options, Filename); Util.RotateImageFromExif(tmp, Orientation); thumbnail = tmp; } public void Unload() { Loaded = false; if (texture != placeholder) { texture.Dispose(); texture = placeholder; } } public async void SaveAsJpegAsync(string outputRoot) { // FIXME: if nothing was changed about this image, just copy the file bytes directly, possibly with metadata changed? string directory = Path.Combine( outputRoot, String.Format("{0:D4}", DateTimeOriginal.Year), String.Format("{0:D2}", DateTimeOriginal.Month), String.Format("{0:D2}", DateTimeOriginal.Day)); Directory.CreateDirectory(directory); Directory.CreateDirectory(Path.Combine(directory, "1-raw")); Directory.CreateDirectory(Path.Combine(directory, "2-jpg")); Directory.CreateDirectory(Path.Combine(directory, "3-edit")); string baseFilename = Path.GetFileName(Filename); string rawFilename = Path.ChangeExtension(Filename, "cr3"); if (Path.Exists(rawFilename)) { string rawOut = Path.Combine(directory, "1-raw", Path.GetFileName(rawFilename)); if (!Path.Exists(rawOut)) { Console.WriteLine($"{rawFilename} => {rawOut}"); System.IO.File.Copy(rawFilename, rawOut); } } // FIXME: add comments / captions as ImageDescription? using (Image image = await Image.LoadAsync(Filename)) { Util.RotateImageFromExif(image, Orientation); 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); if (Gps != null) { GpsInfo gps = (GpsInfo) Gps; exif.SetValue(ExifTag.GPSVersionID, gps.VersionId); exif.SetValue(ExifTag.GPSStatus, gps.Status); exif.SetValue(ExifTag.GPSDateStamp, gps.Datestamp); exif.SetValue(ExifTag.GPSTimestamp, gps.Timestamp); exif.SetValue(ExifTag.GPSLatitude, gps.Latitude); exif.SetValue(ExifTag.GPSLatitudeRef, gps.LatitudeRef); exif.SetValue(ExifTag.GPSLongitude, gps.Longitude); exif.SetValue(ExifTag.GPSLongitudeRef, gps.LongitudeRef); exif.SetValue(ExifTag.GPSAltitude, gps.Altitude); exif.SetValue(ExifTag.GPSAltitudeRef, gps.AltitudeRef); } image.Metadata.XmpProfile = UpdateXmp(image.Metadata.XmpProfile); string jpgOut = Path.Combine(directory, "2-jpg", baseFilename); Console.WriteLine($"{Filename} => {jpgOut}"); await image.SaveAsync(jpgOut, new JpegEncoder() { Quality = 100 }); if (CropRectangle != Rectangle.Empty) { image.Mutate(x => x.Crop(CropRectangle)); } string editOut = Path.Combine(directory, "3-edit", baseFilename); Console.WriteLine($"{Filename} => {editOut}"); await image.SaveAsync(editOut, new JpegEncoder() { Quality = 100 }); // await image.SaveAsync(editOut, new PngEncoder() { // BitDepth = PngBitDepth.Bit8, ChunkFilter = PngChunkFilter.None, ColorType = PngColorType.Rgb, // CompressionLevel = PngCompressionLevel.BestCompression, FilterMethod = PngFilterMethod.Adaptive, // InterlaceMethod = PngInterlaceMode.None }); } } 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; } if (exifs.TryGetValue(ExifTag.Orientation, out var orientation)) { Orientation = orientation.Value; } if (exifs.TryGetValue(ExifTag.Model, out var model)) { CameraModel = model.Value ?? ""; } if (exifs.TryGetValue(ExifTag.LensModel, out var lensModel)) { LensModel = lensModel.Value ?? ""; ShortLensModel = GetShortLensModel(LensModel); } if (exifs.TryGetValue(ExifTag.FocalLength, out var focalLength)) { Rational r = focalLength.Value; FocalLength = $"{r.Numerator / r.Denominator}mm"; } if (exifs.TryGetValue(ExifTag.FNumber, out var 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 ExposureProgram. if (exifs.TryGetValue(ExifTag.ExposureTime, out var 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(); } } if (exifs.TryGetValue(ExifTag.ISOSpeedRatings, out var 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: I think the iPhone stores time in UTC but other cameras report it in local time. if (exifs.TryGetValue(ExifTag.DateTimeOriginal, out var 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}"); } } if (exifs.TryGetValue(ExifTag.SubsecTimeOriginal, out var subsecTimeOriginal)) { double fractionalSeconds; Double.TryParse("0." + subsecTimeOriginal.Value, out fractionalSeconds); DateTimeOriginal = DateTimeOriginal.AddSeconds(fractionalSeconds); } if (exifs.TryGetValue(ExifTag.ExposureBiasValue, out var exposureBiasValue)) { SignedRational r = exposureBiasValue.Value; ExposureBiasValue = r.ToString(); if (r.Numerator >= 0) { ExposureBiasValue = "+" + ExposureBiasValue; } } Gps = GpsInfo.ParseExif(exifs); } 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(image); image.Dispose(); image = null; Loaded = true; } return texture != placeholder ? texture : thumbnailTexture; } public Texture ThumbnailTexture() { if (thumbnailTexture == placeholder && thumbnail != null) { thumbnailTexture = new(thumbnail); thumbnail.Dispose(); thumbnail = null; } return thumbnailTexture; } public string Description() { string date = DateTimeOriginal.ToString("yyyy-MM-dd HH:mm:ss.ff"); return String.Format( "{0,6} {1,-5} {2,-7} {3,-10} EV {9,-4} {7,4}x{8,-4} {4} {5,-20} {6}", FocalLength, FNumber, ExposureTime, IsoSpeed, date, ShortLensModel, Filename, Size.X, Size.Y, ExposureBiasValue); } }