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); } }