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.

443 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 float RotationDegrees = 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 SaveAsJpegAsync(string outputRoot) {
  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, "1-raw"));
  150. Directory.CreateDirectory(Path.Combine(directory, "2-jpg"));
  151. Directory.CreateDirectory(Path.Combine(directory, "3-edit"));
  152. string baseFilename = Path.GetFileName(Filename);
  153. string rawFilename = Path.ChangeExtension(Filename, "cr3");
  154. if (Path.Exists(rawFilename)) {
  155. string rawOut = Path.Combine(directory, "1-raw", Path.GetFileName(rawFilename));
  156. if (!Path.Exists(rawOut)) {
  157. Console.WriteLine($"{rawFilename} => {rawOut}");
  158. System.IO.File.Copy(rawFilename, rawOut);
  159. }
  160. }
  161. // FIXME: add comments / captions as ImageDescription?
  162. using (Image<Rgba32> image = await Image.LoadAsync<Rgba32>(Filename)) {
  163. Util.RotateImageFromExif(image, Orientation);
  164. ExifProfile exif = image.Metadata.ExifProfile ?? new();
  165. exif.SetValue<ushort>(ExifTag.Orientation, 1);
  166. exif.SetValue<string>(ExifTag.Artist, "Colin McMillen");
  167. exif.SetValue<string>(ExifTag.Copyright, "Colin McMillen");
  168. exif.SetValue<string>(ExifTag.Software, "Totte");
  169. exif.SetValue<ushort>(ExifTag.Rating, (ushort) Rating);
  170. DateTime now = DateTime.Now;
  171. string datetime = String.Format(
  172. "{0:D4}:{1:D2}:{2:D2} {3:D2}:{4:D2}:{5:D2}",
  173. now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second);
  174. exif.SetValue<string>(ExifTag.DateTime, datetime);
  175. if (Gps != null) {
  176. GpsInfo gps = (GpsInfo) Gps;
  177. exif.SetValue<byte[]>(ExifTag.GPSVersionID, gps.VersionId);
  178. exif.SetValue<string>(ExifTag.GPSStatus, gps.Status);
  179. exif.SetValue<string>(ExifTag.GPSDateStamp, gps.Datestamp);
  180. exif.SetValue<Rational[]>(ExifTag.GPSTimestamp, gps.Timestamp);
  181. exif.SetValue<Rational[]>(ExifTag.GPSLatitude, gps.Latitude);
  182. exif.SetValue<string>(ExifTag.GPSLatitudeRef, gps.LatitudeRef);
  183. exif.SetValue<Rational[]>(ExifTag.GPSLongitude, gps.Longitude);
  184. exif.SetValue<string>(ExifTag.GPSLongitudeRef, gps.LongitudeRef);
  185. exif.SetValue<Rational>(ExifTag.GPSAltitude, gps.Altitude);
  186. exif.SetValue<byte>(ExifTag.GPSAltitudeRef, gps.AltitudeRef);
  187. }
  188. image.Metadata.XmpProfile = UpdateXmp(image.Metadata.XmpProfile);
  189. string jpgOut = Path.Combine(directory, "2-jpg", baseFilename);
  190. Console.WriteLine($"{Filename} => {jpgOut}");
  191. await image.SaveAsync(jpgOut, new JpegEncoder() { Quality = 100 });
  192. if (Math.Abs(RotationDegrees) > 0.001) {
  193. image.Mutate(x => x.Rotate(RotationDegrees));
  194. }
  195. if (CropRectangle != Rectangle.Empty) {
  196. image.Mutate(x => x.Crop(CropRectangle));
  197. }
  198. string editOut = Path.Combine(directory, "3-edit", baseFilename);
  199. Console.WriteLine($"{Filename} => {editOut}");
  200. await image.SaveAsync(editOut, new JpegEncoder() { Quality = 100 });
  201. // await image.SaveAsync(editOut, new PngEncoder() {
  202. // BitDepth = PngBitDepth.Bit8, ChunkFilter = PngChunkFilter.None, ColorType = PngColorType.Rgb,
  203. // CompressionLevel = PngCompressionLevel.BestCompression, FilterMethod = PngFilterMethod.Adaptive,
  204. // InterlaceMethod = PngInterlaceMode.None });
  205. }
  206. }
  207. private XElement? GetXmpRoot(XmpProfile? xmp) {
  208. if (xmp == null) {
  209. return null;
  210. }
  211. XDocument? doc = xmp.GetDocument();
  212. if (doc == null) {
  213. return null;
  214. }
  215. return doc.Root;
  216. }
  217. private int ParseRating(XmpProfile? xmp) {
  218. XElement? root = GetXmpRoot(xmp);
  219. if (root == null) {
  220. return 0;
  221. }
  222. foreach (XElement elt in root.Descendants()) {
  223. if (elt.Name == "{http://ns.adobe.com/xap/1.0/}Rating") {
  224. int rating = 0;
  225. if (int.TryParse(elt.Value, out rating)) {
  226. return rating;
  227. }
  228. }
  229. }
  230. return 0;
  231. }
  232. private XmpProfile? UpdateXmp(XmpProfile? xmp) {
  233. if (xmp == null) {
  234. return null;
  235. }
  236. string xmlIn = Encoding.UTF8.GetString(xmp.ToByteArray());
  237. int index = xmlIn.IndexOf("</xmp:Rating>");
  238. if (index == -1) {
  239. return xmp;
  240. }
  241. string xmlOut = xmlIn.Substring(0, index - 1) + Rating.ToString() + xmlIn.Substring(index);
  242. return new XmpProfile(Encoding.UTF8.GetBytes(xmlOut));
  243. }
  244. // Exif (and other image metadata) reference, from the now-defunct Metadata Working Group:
  245. // https://web.archive.org/web/20180919181934/http://www.metadataworkinggroup.org/pdf/mwg_guidance.pdf
  246. //
  247. // Specifically:
  248. //
  249. // In general, date/time metadata is being used to describe the following scenarios:
  250. // * Date/time original specifies when a photo was taken
  251. // * Date/time digitized specifies when an image was digitized
  252. // * Date/time modified specifies when a file was modified by the user
  253. //
  254. // Original Date/Time – Creation date of the intellectual content (e.g. the photograph), rather than the creation date of the content being shown
  255. // Exif DateTimeOriginal (36867, 0x9003) and SubSecTimeOriginal (37521, 0x9291)
  256. // IPTC DateCreated (IIM 2:55, 0x0237) and TimeCreated (IIM 2:60, 0x023C)
  257. // XMP (photoshop:DateCreated)
  258. //
  259. // Digitized Date/Time – Creation date of the digital representation
  260. // Exif DateTimeDigitized (36868, 0x9004) and SubSecTimeDigitized (37522, 0x9292)
  261. // IPTC DigitalCreationDate (IIM 2:62, 0x023E) and DigitalCreationTime (IIM 2:63, 0x023F)
  262. // XMP (xmp:CreateDate)
  263. //
  264. // Modification Date/Time – Modification date of the digital image file
  265. // Exif DateTime (306, 0x132) and SubSecTime (37520, 0x9290)
  266. // XMP (xmp:ModifyDate)
  267. //
  268. // See also: https://exiftool.org/TagNames/EXIF.html
  269. private void ParseExif(ExifProfile? exifs) {
  270. if (exifs == null) {
  271. return;
  272. }
  273. if (exifs.TryGetValue(ExifTag.Orientation, out var orientation)) {
  274. Orientation = orientation.Value;
  275. }
  276. if (exifs.TryGetValue(ExifTag.Model, out var model)) {
  277. CameraModel = model.Value ?? "";
  278. }
  279. if (exifs.TryGetValue(ExifTag.LensModel, out var lensModel)) {
  280. LensModel = lensModel.Value ?? "";
  281. ShortLensModel = GetShortLensModel(LensModel);
  282. }
  283. if (exifs.TryGetValue(ExifTag.FocalLength, out var focalLength)) {
  284. Rational r = focalLength.Value;
  285. FocalLength = $"{r.Numerator / r.Denominator}mm";
  286. }
  287. if (exifs.TryGetValue(ExifTag.FNumber, out var fNumber)) {
  288. Rational r = fNumber.Value;
  289. if (r.Numerator % r.Denominator == 0) {
  290. FNumber = $"f/{r.Numerator / r.Denominator}";
  291. } else {
  292. int fTimesTen = (int) Math.Round(10f * r.Numerator / r.Denominator);
  293. FNumber = $"f/{fTimesTen / 10}.{fTimesTen % 10}";
  294. }
  295. }
  296. // FIXME: could also show ExposureProgram.
  297. if (exifs.TryGetValue(ExifTag.ExposureTime, out var exposureTime)) {
  298. Rational r = exposureTime.Value;
  299. if (r.Numerator == 1) {
  300. ExposureTime = $"1/{r.Denominator}";
  301. } else if (r.Numerator == 10) {
  302. ExposureTime = $"1/{r.Denominator / 10}";
  303. } else if (r.Denominator == 1) {
  304. ExposureTime = $"{r.Numerator }\"";
  305. } else if (r.Denominator == 10) {
  306. ExposureTime = $"{r.Numerator / 10}.{r.Numerator % 10}\"";
  307. } else {
  308. Console.WriteLine($"*** WARNING: unexpected ExposureTime: {r.Numerator}/{r.Denominator}");
  309. ExposureTime = r.ToString();
  310. }
  311. }
  312. if (exifs.TryGetValue(ExifTag.ISOSpeedRatings, out var isoSpeed)) {
  313. ushort[]? iso = isoSpeed.Value;
  314. if (iso != null) {
  315. if (iso.Length != 1) {
  316. Console.WriteLine($"*** WARNING: unexpected ISOSpeedRatings array length: {iso.Length}");
  317. }
  318. if (iso.Length >= 1) {
  319. IsoSpeed = $"ISO {iso[0]}";
  320. }
  321. }
  322. }
  323. // FIXME: I think the iPhone stores time in UTC but other cameras report it in local time.
  324. if (exifs.TryGetValue(ExifTag.DateTimeOriginal, out var dateTimeOriginal)) {
  325. DateTime date;
  326. if (DateTime.TryParseExact(
  327. dateTimeOriginal.Value ?? "",
  328. "yyyy:MM:dd HH:mm:ss",
  329. System.Globalization.CultureInfo.InvariantCulture,
  330. System.Globalization.DateTimeStyles.AssumeLocal,
  331. out date)) {
  332. DateTimeOriginal = date;
  333. } else {
  334. Console.WriteLine($"*** WARNING: unexpected DateTimeOriginal value: {dateTimeOriginal.Value}");
  335. }
  336. }
  337. if (exifs.TryGetValue(ExifTag.SubsecTimeOriginal, out var subsecTimeOriginal)) {
  338. double fractionalSeconds;
  339. Double.TryParse("0." + subsecTimeOriginal.Value, out fractionalSeconds);
  340. DateTimeOriginal = DateTimeOriginal.AddSeconds(fractionalSeconds);
  341. }
  342. if (exifs.TryGetValue(ExifTag.ExposureBiasValue, out var exposureBiasValue)) {
  343. SignedRational r = exposureBiasValue.Value;
  344. ExposureBiasValue = r.ToString();
  345. if (r.Numerator >= 0) {
  346. ExposureBiasValue = "+" + ExposureBiasValue;
  347. }
  348. }
  349. Gps = GpsInfo.ParseExif(exifs);
  350. }
  351. public string GetShortLensModel(string lensModel) {
  352. // Example Canon RF lens names:
  353. // RF16mm F2.8 STM
  354. // RF24-105mm F4-7.1 IS STM
  355. // RF35mm F1.8 MACRO IS STM
  356. // RF100-400mm F5.6-8 IS USM
  357. string[] tokens = lensModel.Split(' ');
  358. string result = "";
  359. foreach (string token in tokens) {
  360. if (token == "STM" || token == "IS" || token == "USM") {
  361. continue;
  362. }
  363. result += token + " ";
  364. }
  365. return result.Trim();
  366. }
  367. public Texture Texture() {
  368. LastTouch = touchCounter++;
  369. if (texture == placeholder && image != null) {
  370. // The texture needs to be created on the GL thread, so we instantiate
  371. // it here (since this is called from OnRenderFrame), as long as the
  372. // image is ready to go.
  373. texture = new(image);
  374. image.Dispose();
  375. image = null;
  376. Loaded = true;
  377. }
  378. return texture != placeholder ? texture : thumbnailTexture;
  379. }
  380. public Texture ThumbnailTexture() {
  381. if (thumbnailTexture == placeholder && thumbnail != null) {
  382. thumbnailTexture = new(thumbnail);
  383. thumbnail.Dispose();
  384. thumbnail = null;
  385. }
  386. return thumbnailTexture;
  387. }
  388. public string Description() {
  389. string date = DateTimeOriginal.ToString("yyyy-MM-dd HH:mm:ss.ff");
  390. return String.Format(
  391. "{0,6} {1,-5} {2,-7} {3,-10} EV {9,-4} {7,4}x{8,-4} {4} {5,-20} {6}",
  392. FocalLength, FNumber, ExposureTime, IsoSpeed, date, ShortLensModel, Filename, Size.X, Size.Y, ExposureBiasValue);
  393. }
  394. }