totte/Photo.cs

444 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (Math.Abs(RotationDegrees) > 0.001) {
image.Mutate(x => x.Rotate(RotationDegrees));
}
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);
}
}