|
|
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<T>(ExifProfile exif, ExifTag<T> tag, out T result) { IExifValue<T>? 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 = "<unk>"; public string FNumber = "<unk>"; public string ExposureTime = "<unk>"; public string IsoSpeed = "<unk>"; public string ExposureBiasValue = "<unk>"; public int Rating = 0; public ushort Orientation = 1; public GpsInfo? Gps = null; public Rectangle CropRectangle = Rectangle.Empty; public Vector2i ViewOffset = Vector2i.Zero; public float RotationDegrees = 0;
private static long touchCounter = 0; private Texture texture; private Texture placeholder; private Texture thumbnailTexture; private Image<Rgba32>? image = null; private Image<Rgba32>? 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<Rgba32> tmp = await Image.LoadAsync<Rgba32>(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<Rgba32> tmp = await Image.LoadAsync<Rgba32>(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<Rgba32> image = await Image.LoadAsync<Rgba32>(Filename)) { Util.RotateImageFromExif(image, Orientation);
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);
if (Gps != null) { GpsInfo gps = (GpsInfo) Gps; exif.SetValue<byte[]>(ExifTag.GPSVersionID, gps.VersionId); exif.SetValue<string>(ExifTag.GPSStatus, gps.Status); exif.SetValue<string>(ExifTag.GPSDateStamp, gps.Datestamp); exif.SetValue<Rational[]>(ExifTag.GPSTimestamp, gps.Timestamp); exif.SetValue<Rational[]>(ExifTag.GPSLatitude, gps.Latitude); exif.SetValue<string>(ExifTag.GPSLatitudeRef, gps.LatitudeRef); exif.SetValue<Rational[]>(ExifTag.GPSLongitude, gps.Longitude); exif.SetValue<string>(ExifTag.GPSLongitudeRef, gps.LongitudeRef); exif.SetValue<Rational>(ExifTag.GPSAltitude, gps.Altitude); exif.SetValue<byte>(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("</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; }
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); } }
|