Colin McMillen
11 months ago
2 changed files with 292 additions and 287 deletions
-
291Photo.cs
-
288Program.cs
@ -0,0 +1,291 @@ |
|||
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 = "<unk>"; |
|||
public string FNumber = "<unk>"; |
|||
public string ExposureTime = "<unk>"; |
|||
public string IsoSpeed = "<unk>"; |
|||
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<Rgba32>? 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<Rgba32> tmp = await Image.LoadAsync<Rgba32>(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<Rgba32> image = await Image.LoadAsync<Rgba32>(Filename)) { |
|||
Util.RotateImageFromExif(image, Orientation); |
|||
if (CropRectangle != Rectangle.Empty) { |
|||
image.Mutate(x => x.Crop(CropRectangle)); |
|||
} |
|||
|
|||
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); |
|||
|
|||
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("</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; |
|||
} |
|||
|
|||
IExifValue<ushort>? orientation; |
|||
if (exifs.TryGetValue(ExifTag.Orientation, out orientation)) { |
|||
Orientation = orientation.Value; |
|||
} |
|||
|
|||
IExifValue<string>? model; |
|||
if (exifs.TryGetValue(ExifTag.Model, out model)) { |
|||
CameraModel = model.Value ?? ""; |
|||
} |
|||
|
|||
IExifValue<string>? lensModel; |
|||
if (exifs.TryGetValue(ExifTag.LensModel, out lensModel)) { |
|||
LensModel = lensModel.Value ?? ""; |
|||
ShortLensModel = GetShortLensModel(LensModel); |
|||
} |
|||
|
|||
IExifValue<Rational>? focalLength; |
|||
if (exifs.TryGetValue(ExifTag.FocalLength, out focalLength)) { |
|||
Rational r = focalLength.Value; |
|||
FocalLength = $"{r.Numerator / r.Denominator}mm"; |
|||
} |
|||
|
|||
IExifValue<Rational>? 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<Rational>? 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<ushort[]>? 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<string>? 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); |
|||
} |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue