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.

416 lines
14 KiB

10 months ago
  1. using OpenTK.Mathematics;
  2. using Image = SixLabors.ImageSharp.Image;
  3. using SixLabors.ImageSharp.Metadata.Profiles.Exif;
  4. using SixLabors.ImageSharp.Metadata.Profiles.Xmp;
  5. using SixLabors.ImageSharp.Formats;
  6. using SixLabors.ImageSharp.Formats.Jpeg;
  7. using System.Text;
  8. using System.Xml.Linq;
  9. namespace SemiColinGames;
  10. // https://exiftool.org/TagNames/GPS.html
  11. public struct GpsInfo {
  12. public byte[] VersionId;
  13. public string Status;
  14. public string Datestamp;
  15. public Rational[] Timestamp;
  16. public Rational[] Latitude;
  17. public string LatitudeRef;
  18. public Rational[] Longitude;
  19. public string LongitudeRef;
  20. public Rational Altitude;
  21. public byte AltitudeRef;
  22. // GpsStatus? DateStamp and TimeStamp?
  23. public static GpsInfo? ParseExif(ExifProfile exif) {
  24. GpsInfo gps;
  25. IExifValue<byte[]>? versionId;
  26. IExifValue<string>? status;
  27. IExifValue<string>? datestamp;
  28. IExifValue<Rational[]>? timestamp;
  29. IExifValue<Rational[]>? latitude;
  30. IExifValue<string>? latitudeRef;
  31. IExifValue<Rational[]>? longitude;
  32. IExifValue<string>? longitudeRef;
  33. IExifValue<Rational>? altitude;
  34. IExifValue<byte>? altitudeRef;
  35. if (!exif.TryGetValue(ExifTag.GPSVersionID, out versionId)) {
  36. return null;
  37. }
  38. gps.VersionId = versionId.Value ?? throw new NullReferenceException();
  39. if (!exif.TryGetValue(ExifTag.GPSStatus, out status)) {
  40. return null;
  41. }
  42. gps.Status = status.Value ?? throw new NullReferenceException();
  43. if (!exif.TryGetValue(ExifTag.GPSDateStamp, out datestamp)) {
  44. return null;
  45. }
  46. gps.Datestamp = datestamp.Value ?? throw new NullReferenceException();
  47. if (!exif.TryGetValue(ExifTag.GPSTimestamp, out timestamp)) {
  48. return null;
  49. }
  50. gps.Timestamp = timestamp.Value ?? throw new NullReferenceException();
  51. if (!exif.TryGetValue(ExifTag.GPSLatitude, out latitude)) {
  52. return null;
  53. }
  54. gps.Latitude = latitude.Value ?? throw new NullReferenceException();
  55. if (!exif.TryGetValue(ExifTag.GPSLatitudeRef, out latitudeRef)) {
  56. return null;
  57. }
  58. gps.LatitudeRef = latitudeRef.Value ?? throw new NullReferenceException();
  59. if (!exif.TryGetValue(ExifTag.GPSLongitude, out longitude)) {
  60. return null;
  61. }
  62. gps.Longitude = longitude.Value ?? throw new NullReferenceException();
  63. if (!exif.TryGetValue(ExifTag.GPSLongitudeRef, out longitudeRef)) {
  64. return null;
  65. }
  66. gps.LongitudeRef = longitudeRef.Value ?? throw new NullReferenceException();
  67. if (!exif.TryGetValue(ExifTag.GPSAltitude, out altitude)) {
  68. return null;
  69. }
  70. gps.Altitude = altitude.Value;
  71. if (!exif.TryGetValue(ExifTag.GPSAltitudeRef, out altitudeRef)) {
  72. return null;
  73. }
  74. gps.AltitudeRef = altitudeRef.Value;
  75. return gps;
  76. }
  77. }
  78. public class Photo {
  79. public string Filename;
  80. public bool Loaded = false;
  81. public long LastTouch = 0;
  82. public Vector2i Size;
  83. public DateTime DateTimeOriginal;
  84. public string CameraModel = "";
  85. public string LensModel = "";
  86. public string ShortLensModel = "";
  87. public string FocalLength = "<unk>";
  88. public string FNumber = "<unk>";
  89. public string ExposureTime = "<unk>";
  90. public string IsoSpeed = "<unk>";
  91. public int Rating = 0;
  92. public ushort Orientation = 1;
  93. public GpsInfo? Gps = null;
  94. public Rectangle CropRectangle = Rectangle.Empty;
  95. private static long touchCounter = 0;
  96. private Texture texture;
  97. private Texture placeholder;
  98. private Texture thumbnailTexture;
  99. private Image<Rgba32>? image = null;
  100. private Image<Rgba32>? thumbnail = null;
  101. public Photo(string filename, Texture placeholder) {
  102. Filename = filename;
  103. this.placeholder = placeholder;
  104. texture = placeholder;
  105. thumbnailTexture = placeholder;
  106. DateTime creationTime = File.GetCreationTime(filename); // Local time.
  107. DateTimeOriginal = creationTime;
  108. ImageInfo info = Image.Identify(filename);
  109. Size = new(info.Size.Width, info.Size.Height);
  110. Rating = ParseRating(info.Metadata.XmpProfile);
  111. ParseExif(info.Metadata.ExifProfile);
  112. }
  113. public async void LoadAsync(Vector2i size) {
  114. // We don't assign to this.image until Load() is done, because we might
  115. // edit the image due to rotation (etc) and don't want to try generating
  116. // a texture for it until that's already happened.
  117. LastTouch = touchCounter++;
  118. // FIXME: if we zoom in to more than the display size, actually load the whole image?
  119. DecoderOptions options = new DecoderOptions {
  120. TargetSize = new Size(size.X, size.Y),
  121. SkipMetadata = true
  122. };
  123. Image<Rgba32> tmp = await Image.LoadAsync<Rgba32>(options, Filename);
  124. Util.RotateImageFromExif(tmp, Orientation);
  125. image = tmp;
  126. }
  127. public async void LoadThumbnailAsync(Vector2i size) {
  128. DecoderOptions options = new DecoderOptions {
  129. TargetSize = new Size(size.X, size.Y),
  130. SkipMetadata = true
  131. };
  132. Image<Rgba32> tmp = await Image.LoadAsync<Rgba32>(options, Filename);
  133. Util.RotateImageFromExif(tmp, Orientation);
  134. thumbnail = tmp;
  135. }
  136. public void Unload() {
  137. Loaded = false;
  138. if (texture != placeholder) {
  139. texture.Dispose();
  140. texture = placeholder;
  141. }
  142. }
  143. public async void SaveAsJpegAsync(string outputRoot, JpegEncoder encoder) {
  144. // FIXME: if nothing was changed about this image, just copy the file bytes directly, possibly with metadata changed?
  145. string directory = System.IO.Path.Combine(
  146. outputRoot,
  147. String.Format("{0:D4}", DateTimeOriginal.Year),
  148. String.Format("{0:D2}", DateTimeOriginal.Month),
  149. String.Format("{0:D2}", DateTimeOriginal.Day));
  150. Directory.CreateDirectory(directory);
  151. string filename = System.IO.Path.Combine(directory, System.IO.Path.GetFileName(Filename));
  152. Console.WriteLine("saving " + filename);
  153. // FIXME: add comments / captions as ImageDescription?
  154. // FIXME: warn if the file already exists?
  155. using (Image<Rgba32> image = await Image.LoadAsync<Rgba32>(Filename)) {
  156. Util.RotateImageFromExif(image, Orientation);
  157. if (CropRectangle != Rectangle.Empty) {
  158. image.Mutate(x => x.Crop(CropRectangle));
  159. }
  160. ExifProfile exif = image.Metadata.ExifProfile ?? new();
  161. exif.SetValue<ushort>(ExifTag.Orientation, 1);
  162. exif.SetValue<string>(ExifTag.Artist, "Colin McMillen");
  163. exif.SetValue<string>(ExifTag.Copyright, "Colin McMillen");
  164. exif.SetValue<string>(ExifTag.Software, "Totte");
  165. exif.SetValue<ushort>(ExifTag.Rating, (ushort) Rating);
  166. DateTime now = DateTime.Now;
  167. string datetime = String.Format(
  168. "{0:D4}:{1:D2}:{2:D2} {3:D2}:{4:D2}:{5:D2}",
  169. now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second);
  170. exif.SetValue<string>(ExifTag.DateTime, datetime);
  171. if (Gps != null) {
  172. GpsInfo gps = (GpsInfo) Gps;
  173. exif.SetValue<byte[]>(ExifTag.GPSVersionID, gps.VersionId);
  174. exif.SetValue<string>(ExifTag.GPSStatus, gps.Status);
  175. exif.SetValue<string>(ExifTag.GPSDateStamp, gps.Datestamp);
  176. exif.SetValue<Rational[]>(ExifTag.GPSTimestamp, gps.Timestamp);
  177. exif.SetValue<Rational[]>(ExifTag.GPSLatitude, gps.Latitude);
  178. exif.SetValue<string>(ExifTag.GPSLatitudeRef, gps.LatitudeRef);
  179. exif.SetValue<Rational[]>(ExifTag.GPSLongitude, gps.Longitude);
  180. exif.SetValue<string>(ExifTag.GPSLongitudeRef, gps.LongitudeRef);
  181. exif.SetValue<Rational>(ExifTag.GPSAltitude, gps.Altitude);
  182. exif.SetValue<byte>(ExifTag.GPSAltitudeRef, gps.AltitudeRef);
  183. }
  184. image.Metadata.XmpProfile = UpdateXmp(image.Metadata.XmpProfile);
  185. await image.SaveAsync(filename, encoder);
  186. }
  187. }
  188. private XElement? GetXmpRoot(XmpProfile? xmp) {
  189. if (xmp == null) {
  190. return null;
  191. }
  192. XDocument? doc = xmp.GetDocument();
  193. if (doc == null) {
  194. return null;
  195. }
  196. return doc.Root;
  197. }
  198. private int ParseRating(XmpProfile? xmp) {
  199. XElement? root = GetXmpRoot(xmp);
  200. if (root == null) {
  201. return 0;
  202. }
  203. foreach (XElement elt in root.Descendants()) {
  204. if (elt.Name == "{http://ns.adobe.com/xap/1.0/}Rating") {
  205. int rating = 0;
  206. if (int.TryParse(elt.Value, out rating)) {
  207. return rating;
  208. }
  209. }
  210. }
  211. return 0;
  212. }
  213. private XmpProfile? UpdateXmp(XmpProfile? xmp) {
  214. if (xmp == null) {
  215. return null;
  216. }
  217. string xmlIn = Encoding.UTF8.GetString(xmp.ToByteArray());
  218. int index = xmlIn.IndexOf("</xmp:Rating>");
  219. if (index == -1) {
  220. return xmp;
  221. }
  222. string xmlOut = xmlIn.Substring(0, index - 1) + Rating.ToString() + xmlIn.Substring(index);
  223. return new XmpProfile(Encoding.UTF8.GetBytes(xmlOut));
  224. }
  225. // Exif (and other image metadata) reference, from the now-defunct Metadata Working Group:
  226. // https://web.archive.org/web/20180919181934/http://www.metadataworkinggroup.org/pdf/mwg_guidance.pdf
  227. //
  228. // Specifically:
  229. //
  230. // In general, date/time metadata is being used to describe the following scenarios:
  231. // * Date/time original specifies when a photo was taken
  232. // * Date/time digitized specifies when an image was digitized
  233. // * Date/time modified specifies when a file was modified by the user
  234. //
  235. // Original Date/Time – Creation date of the intellectual content (e.g. the photograph), rather than the creation date of the content being shown
  236. // Exif DateTimeOriginal (36867, 0x9003) and SubSecTimeOriginal (37521, 0x9291)
  237. // IPTC DateCreated (IIM 2:55, 0x0237) and TimeCreated (IIM 2:60, 0x023C)
  238. // XMP (photoshop:DateCreated)
  239. //
  240. // Digitized Date/Time – Creation date of the digital representation
  241. // Exif DateTimeDigitized (36868, 0x9004) and SubSecTimeDigitized (37522, 0x9292)
  242. // IPTC DigitalCreationDate (IIM 2:62, 0x023E) and DigitalCreationTime (IIM 2:63, 0x023F)
  243. // XMP (xmp:CreateDate)
  244. //
  245. // Modification Date/Time – Modification date of the digital image file
  246. // Exif DateTime (306, 0x132) and SubSecTime (37520, 0x9290)
  247. // XMP (xmp:ModifyDate)
  248. //
  249. // See also: https://exiftool.org/TagNames/EXIF.html
  250. private void ParseExif(ExifProfile? exifs) {
  251. if (exifs == null) {
  252. return;
  253. }
  254. IExifValue<ushort>? orientation;
  255. if (exifs.TryGetValue(ExifTag.Orientation, out orientation)) {
  256. Orientation = orientation.Value;
  257. }
  258. IExifValue<string>? model;
  259. if (exifs.TryGetValue(ExifTag.Model, out model)) {
  260. CameraModel = model.Value ?? "";
  261. }
  262. IExifValue<string>? lensModel;
  263. if (exifs.TryGetValue(ExifTag.LensModel, out lensModel)) {
  264. LensModel = lensModel.Value ?? "";
  265. ShortLensModel = GetShortLensModel(LensModel);
  266. }
  267. IExifValue<Rational>? focalLength;
  268. if (exifs.TryGetValue(ExifTag.FocalLength, out focalLength)) {
  269. Rational r = focalLength.Value;
  270. FocalLength = $"{r.Numerator / r.Denominator}mm";
  271. }
  272. IExifValue<Rational>? fNumber;
  273. if (exifs.TryGetValue(ExifTag.FNumber, out fNumber)) {
  274. Rational r = fNumber.Value;
  275. if (r.Numerator % r.Denominator == 0) {
  276. FNumber = $"f/{r.Numerator / r.Denominator}";
  277. } else {
  278. int fTimesTen = (int) Math.Round(10f * r.Numerator / r.Denominator);
  279. FNumber = $"f/{fTimesTen / 10}.{fTimesTen % 10}";
  280. }
  281. }
  282. // FIXME: could also show ExposureBiasValue, ExposureMode, ExposureProgram?
  283. IExifValue<Rational>? exposureTime;
  284. if (exifs.TryGetValue(ExifTag.ExposureTime, out exposureTime)) {
  285. Rational r = exposureTime.Value;
  286. if (r.Numerator == 1) {
  287. ExposureTime = $"1/{r.Denominator}";
  288. } else if (r.Numerator == 10) {
  289. ExposureTime = $"1/{r.Denominator / 10}";
  290. } else if (r.Denominator == 1) {
  291. ExposureTime = $"{r.Numerator }\"";
  292. } else if (r.Denominator == 10) {
  293. ExposureTime = $"{r.Numerator / 10}.{r.Numerator % 10}\"";
  294. } else {
  295. Console.WriteLine($"*** WARNING: unexpected ExposureTime: {r.Numerator}/{r.Denominator}");
  296. ExposureTime = r.ToString();
  297. }
  298. }
  299. IExifValue<ushort[]>? isoSpeed;
  300. if (exifs.TryGetValue(ExifTag.ISOSpeedRatings, out isoSpeed)) {
  301. ushort[]? iso = isoSpeed.Value;
  302. if (iso != null) {
  303. if (iso.Length != 1) {
  304. Console.WriteLine($"*** WARNING: unexpected ISOSpeedRatings array length: {iso.Length}");
  305. }
  306. if (iso.Length >= 1) {
  307. IsoSpeed = $"ISO {iso[0]}";
  308. }
  309. }
  310. }
  311. // FIXME: there is also a SubSecTimeOriginal tag we could use to get fractional seconds.
  312. // FIXME: I think the iPhone stores time in UTC but other cameras report it in local time.
  313. IExifValue<string>? dateTimeOriginal;
  314. if (exifs.TryGetValue(ExifTag.DateTimeOriginal, out dateTimeOriginal)) {
  315. DateTime date;
  316. if (DateTime.TryParseExact(
  317. dateTimeOriginal.Value ?? "",
  318. "yyyy:MM:dd HH:mm:ss",
  319. System.Globalization.CultureInfo.InvariantCulture,
  320. System.Globalization.DateTimeStyles.AssumeLocal,
  321. out date)) {
  322. DateTimeOriginal = date;
  323. } else {
  324. Console.WriteLine($"*** WARNING: unexpected DateTimeOriginal value: {dateTimeOriginal.Value}");
  325. }
  326. }
  327. Gps = GpsInfo.ParseExif(exifs);
  328. }
  329. public string GetShortLensModel(string lensModel) {
  330. // Example Canon RF lens names:
  331. // RF16mm F2.8 STM
  332. // RF24-105mm F4-7.1 IS STM
  333. // RF35mm F1.8 MACRO IS STM
  334. // RF100-400mm F5.6-8 IS USM
  335. string[] tokens = lensModel.Split(' ');
  336. string result = "";
  337. foreach (string token in tokens) {
  338. if (token == "STM" || token == "IS" || token == "USM") {
  339. continue;
  340. }
  341. result += token + " ";
  342. }
  343. return result.Trim();
  344. }
  345. public Texture Texture() {
  346. LastTouch = touchCounter++;
  347. if (texture == placeholder && image != null) {
  348. // The texture needs to be created on the GL thread, so we instantiate
  349. // it here (since this is called from OnRenderFrame), as long as the
  350. // image is ready to go.
  351. texture = new(image);
  352. image.Dispose();
  353. image = null;
  354. Loaded = true;
  355. }
  356. return texture != placeholder ? texture : thumbnailTexture;
  357. }
  358. public Texture ThumbnailTexture() {
  359. if (thumbnailTexture == placeholder && thumbnail != null) {
  360. thumbnailTexture = new(thumbnail);
  361. thumbnail.Dispose();
  362. thumbnail = null;
  363. }
  364. return thumbnailTexture;
  365. }
  366. public string Description() {
  367. string date = DateTimeOriginal.ToString("yyyy-MM-dd HH:mm:ss");
  368. return String.Format(
  369. "{0,6} {1,-5} {2,-7} {3,-10} {7,4}x{8,-4} {4} {5,-20} {6}",
  370. FocalLength, FNumber, ExposureTime, IsoSpeed, date, ShortLensModel, Filename, Size.X, Size.Y);
  371. }
  372. }