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

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