You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

440 lines
15 KiB

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