Compare commits
192 Commits
5e178aae1f
...
main
Author | SHA1 | Date | |
---|---|---|---|
ce4a726a03 | |||
4ce32172f9 | |||
ed4044a115 | |||
775252b567 | |||
7bd3719c72 | |||
87e029e7f8 | |||
063cb2f449 | |||
5c8aa2520a | |||
8b57464056 | |||
b144da2399 | |||
a2a5448afe | |||
5f636dd910 | |||
9db0ddd15c | |||
0417c354d5 | |||
96526a5b76 | |||
4f3e9f50ed | |||
45027ec8b9 | |||
f306fc3bc9 | |||
389ab12fb2 | |||
4cd0d4efc6 | |||
d72b804b92 | |||
7b7eb22c91 | |||
adc6bf7972 | |||
ae0fa8f934 | |||
aa5b3e7083 | |||
31f2668aa3 | |||
d72a90a9f0 | |||
4a84774855 | |||
e612429298 | |||
31e419b81b | |||
f612d0dc42 | |||
75ad5bd478 | |||
4f86338a3e | |||
5d70b4c54b | |||
c1ed48b917 | |||
3d4ad61f5e | |||
c0ac67103f | |||
0ca67f2cef | |||
32403ddeb5 | |||
7c7976a82d | |||
bcb2e7be7d | |||
6478f1ee95 | |||
393369773b | |||
9620a3af1d | |||
e9a13dba49 | |||
870daa3851 | |||
95882f3135 | |||
67736630e0 | |||
50b0e8d8e0 | |||
cbea050334 | |||
44752c5567 | |||
c053672108 | |||
c77183915b | |||
3d507f3794 | |||
d63bdb3265 | |||
e736d2db5f | |||
82e9f59f5e | |||
1c586a6197 | |||
c953fa2b47 | |||
1793fee616 | |||
304097177c | |||
48072d7b4a | |||
2a28f94dc1 | |||
086088fb4c | |||
ff938693ff | |||
08862f52af | |||
8d4bf9c69f | |||
6df7f1e53b | |||
4514509c62 | |||
6076abe9d1 | |||
7db0ec7e62 | |||
6d07a533d7 | |||
473fac7a6f | |||
e5c1b01806 | |||
190dda46d4 | |||
1ad8b06be8 | |||
9cdef230f4 | |||
58a19d061d | |||
9e03d8f2c5 | |||
c68f42915c | |||
ac93b55609 | |||
c74d5c5385 | |||
831637338c | |||
cf354937e7 | |||
a326c8432e | |||
073c9745e9 | |||
3e4bd73091 | |||
fe02e13a11 | |||
33f6ee739e | |||
ad598e68a5 | |||
a36bad5c1f | |||
3a0060fc9d | |||
75b80507bd | |||
1bedac471d | |||
0b6de0840f | |||
ca5b2d94f5 | |||
dc909a80f4 | |||
3c1e2c8dad | |||
067b54a77d | |||
42ea309794 | |||
247d6ac6f2 | |||
8ca4ff7cad | |||
e98b19d87b | |||
7ce97438ae | |||
77b0f2b191 | |||
7b8c64d8be | |||
f83d7f13aa | |||
a92d089c5b | |||
3012f67c9f | |||
a67912e1ce | |||
c7ff5ee15d | |||
07d25742fb | |||
57e6968f3c | |||
32cb03ab15 | |||
b0fd20cd89 | |||
27a4a64ccb | |||
fb26de5fa1 | |||
3b85c5d21a | |||
af4827a127 | |||
50445dbe59 | |||
3cf125fba7 | |||
f9aeb81313 | |||
4f1adef895 | |||
060565b44a | |||
9796827f96 | |||
e6d3f197ea | |||
1336498682 | |||
4f667f2400 | |||
c461d55101 | |||
98cae0e9c1 | |||
cb01d1dbea | |||
a33304ae7d | |||
3a9192b478 | |||
7f375a6446 | |||
d52e8fce79 | |||
cdc2cb336d | |||
25ca0d99ff | |||
6c7dbe5516 | |||
4d63f64be6 | |||
56311bc56c | |||
a0d6267905 | |||
028419eb20 | |||
4c72f797ab | |||
fe9da67b04 | |||
f853ef7cda | |||
ec6be80143 | |||
9de8c6de51 | |||
4a857b461c | |||
b02647d469 | |||
4b66284c65 | |||
0bc0d97122 | |||
faee485b5c | |||
693269b8f5 | |||
e58f717ffe | |||
75e186c392 | |||
8600a7e490 | |||
ee3af4fc57 | |||
b3e3d3d11a | |||
22aa1e0d88 | |||
87c8b6cfd0 | |||
95397087d2 | |||
5194fec400 | |||
ec2989a9b0 | |||
30f21943a8 | |||
30ac76f4c0 | |||
72e65b912c | |||
b41321b0c7 | |||
6812699401 | |||
6670b358c0 | |||
eff73b2ced | |||
5c2b831ced | |||
5b135bc889 | |||
c7bb0deb0c | |||
cadc8e4779 | |||
dd7c3f6615 | |||
ca4ea40916 | |||
cb3994a378 | |||
4ed9ed0796 | |||
536abbf2b4 | |||
4e0a15e3a0 | |||
a94a3938dc | |||
ab9d9a0709 | |||
855c97241b | |||
904726aba6 | |||
0625040304 | |||
a3360b1420 | |||
f822a07602 | |||
712472bdfb | |||
225a1a623c | |||
6fc66aba8f | |||
20f8b1e285 | |||
d3e7718b5f |
3
.gitignore
vendored
3
.gitignore
vendored
@ -4,3 +4,6 @@
|
|||||||
# Build results
|
# Build results
|
||||||
[Bb]in/
|
[Bb]in/
|
||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
|
|
||||||
|
# Packaged Zip file.
|
||||||
|
totte.zip
|
||||||
|
440
Photo.cs
Normal file
440
Photo.cs
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
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 int RotationDegreeHundredths = 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 SaveAsync(string outputRoot, Toast toast) {
|
||||||
|
// 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, "jpg"));
|
||||||
|
string baseFilename = Path.GetFileName(Filename);
|
||||||
|
|
||||||
|
string rawFilename = Path.ChangeExtension(Filename, "cr3");
|
||||||
|
// FIXME: if there's a JPG but not a RAW, make a "RAW" that's the
|
||||||
|
// camera's original JPG converted to something lossless (PNG? TIFF?).
|
||||||
|
if (Path.Exists(rawFilename)) {
|
||||||
|
string rawOut = Path.Combine(directory, Path.GetFileName(rawFilename));
|
||||||
|
if (!Path.Exists(rawOut)) {
|
||||||
|
System.IO.File.Copy(rawFilename, rawOut);
|
||||||
|
toast.Set($"{rawFilename} => {rawOut}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (RotationDegreeHundredths != 0) {
|
||||||
|
image.Mutate(x => x.Rotate(RotationDegreeHundredths / 100f));
|
||||||
|
}
|
||||||
|
if (CropRectangle != Rectangle.Empty) {
|
||||||
|
image.Mutate(x => x.Crop(CropRectangle));
|
||||||
|
}
|
||||||
|
|
||||||
|
string jpgOut = Path.Combine(directory, "jpg", baseFilename);
|
||||||
|
await image.SaveAsync(jpgOut, new JpegEncoder() { Quality = 100 });
|
||||||
|
toast.Set($"{Filename} => {jpgOut}");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
1319
Program.cs
1319
Program.cs
File diff suppressed because it is too large
Load Diff
118
Shader.cs
Normal file
118
Shader.cs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
using OpenTK.Graphics.OpenGL4;
|
||||||
|
|
||||||
|
namespace SemiColinGames;
|
||||||
|
|
||||||
|
public class Shader : IDisposable {
|
||||||
|
public int Handle;
|
||||||
|
private bool init = false;
|
||||||
|
|
||||||
|
public Shader() {}
|
||||||
|
|
||||||
|
public void Init() {
|
||||||
|
init = true;
|
||||||
|
int VertexShader;
|
||||||
|
int FragmentShader;
|
||||||
|
|
||||||
|
string VertexShaderSource = @"
|
||||||
|
#version 330
|
||||||
|
|
||||||
|
layout(location = 0) in vec3 aPosition;
|
||||||
|
layout(location = 1) in vec2 aTexCoord;
|
||||||
|
|
||||||
|
out vec2 texCoord;
|
||||||
|
|
||||||
|
uniform mat4 projection;
|
||||||
|
|
||||||
|
void main(void) {
|
||||||
|
texCoord = aTexCoord;
|
||||||
|
gl_Position = vec4(aPosition, 1.0) * projection;
|
||||||
|
}";
|
||||||
|
|
||||||
|
string FragmentShaderSource = @"
|
||||||
|
#version 330
|
||||||
|
|
||||||
|
out vec4 outputColor;
|
||||||
|
in vec2 texCoord;
|
||||||
|
uniform sampler2D texture0;
|
||||||
|
uniform vec4 color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
outputColor = texture(texture0, texCoord) * color;
|
||||||
|
}";
|
||||||
|
|
||||||
|
VertexShader = GL.CreateShader(ShaderType.VertexShader);
|
||||||
|
GL.ShaderSource(VertexShader, VertexShaderSource);
|
||||||
|
|
||||||
|
FragmentShader = GL.CreateShader(ShaderType.FragmentShader);
|
||||||
|
GL.ShaderSource(FragmentShader, FragmentShaderSource);
|
||||||
|
|
||||||
|
GL.CompileShader(VertexShader);
|
||||||
|
|
||||||
|
int success;
|
||||||
|
GL.GetShader(VertexShader, ShaderParameter.CompileStatus, out success);
|
||||||
|
if (success == 0) {
|
||||||
|
string infoLog = GL.GetShaderInfoLog(VertexShader);
|
||||||
|
Console.WriteLine(infoLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
GL.CompileShader(FragmentShader);
|
||||||
|
|
||||||
|
GL.GetShader(FragmentShader, ShaderParameter.CompileStatus, out success);
|
||||||
|
if (success == 0) {
|
||||||
|
string infoLog = GL.GetShaderInfoLog(FragmentShader);
|
||||||
|
Console.WriteLine(infoLog);
|
||||||
|
}
|
||||||
|
Handle = GL.CreateProgram();
|
||||||
|
|
||||||
|
GL.AttachShader(Handle, VertexShader);
|
||||||
|
GL.AttachShader(Handle, FragmentShader);
|
||||||
|
|
||||||
|
GL.LinkProgram(Handle);
|
||||||
|
|
||||||
|
GL.GetProgram(Handle, GetProgramParameterName.LinkStatus, out success);
|
||||||
|
if (success == 0) {
|
||||||
|
string infoLog = GL.GetProgramInfoLog(Handle);
|
||||||
|
Console.WriteLine(infoLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
GL.DetachShader(Handle, VertexShader);
|
||||||
|
GL.DetachShader(Handle, FragmentShader);
|
||||||
|
GL.DeleteShader(FragmentShader);
|
||||||
|
GL.DeleteShader(VertexShader);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Use() {
|
||||||
|
if (!init) {
|
||||||
|
Console.WriteLine("Shader.Use(): must call Init() first");
|
||||||
|
}
|
||||||
|
GL.UseProgram(Handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool disposedValue = false;
|
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing) {
|
||||||
|
if (!disposedValue) {
|
||||||
|
GL.DeleteProgram(Handle);
|
||||||
|
disposedValue = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
~Shader() {
|
||||||
|
if (disposedValue == false) {
|
||||||
|
Console.WriteLine("~Shader(): resource leak? Dispose() should be called manually.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetAttribLocation(string name) {
|
||||||
|
return GL.GetAttribLocation(Handle, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetUniformLocation(string name) {
|
||||||
|
return GL.GetUniformLocation(Handle, name);
|
||||||
|
}
|
||||||
|
}
|
55
Texture.cs
Normal file
55
Texture.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
using OpenTK.Graphics.OpenGL4;
|
||||||
|
using OpenTK.Mathematics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace SemiColinGames;
|
||||||
|
|
||||||
|
public class Texture : IDisposable {
|
||||||
|
public int Handle;
|
||||||
|
public Vector2i Size;
|
||||||
|
|
||||||
|
private static int maxHandle = -1;
|
||||||
|
private bool disposedValue = false;
|
||||||
|
|
||||||
|
public Texture(Image<Rgba32> image) {
|
||||||
|
Size = new Vector2i(image.Width, image.Height);
|
||||||
|
byte[] pixelBytes = new byte[Size.X * Size.Y * Unsafe.SizeOf<Rgba32>()];
|
||||||
|
image.CopyPixelDataTo(pixelBytes);
|
||||||
|
|
||||||
|
Handle = GL.GenTexture();
|
||||||
|
if (Handle > maxHandle) {
|
||||||
|
// Console.WriteLine("GL.GenTexture #" + Handle);
|
||||||
|
maxHandle = Handle;
|
||||||
|
}
|
||||||
|
GL.ActiveTexture(TextureUnit.Texture0);
|
||||||
|
GL.BindTexture(TextureTarget.Texture2D, Handle);
|
||||||
|
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, Size.X, Size.Y, 0, PixelFormat.Rgba, PixelType.UnsignedByte, pixelBytes);
|
||||||
|
//GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.LinearMipmapLinear);
|
||||||
|
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.Linear);
|
||||||
|
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int) TextureMagFilter.Nearest);
|
||||||
|
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int) TextureWrapMode.ClampToBorder);
|
||||||
|
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int) TextureWrapMode.ClampToBorder);
|
||||||
|
float[] borderColor = { 0.0f, 0.0f, 0.0f, 1.0f };
|
||||||
|
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureBorderColor, borderColor);
|
||||||
|
// FIXME: should we use mipmaps?
|
||||||
|
//GL.GenerateMipmap(GenerateMipmapTarget.Texture2D);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing) {
|
||||||
|
if (!disposedValue) {
|
||||||
|
GL.DeleteTexture(Handle);
|
||||||
|
disposedValue = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
~Texture() {
|
||||||
|
if (!disposedValue) {
|
||||||
|
Console.WriteLine("~Texture(): resource leak? Dispose() should be called manually.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OpenTK" Version="4.7.7" />
|
<PackageReference Include="OpenTK" Version="4.7.7" />
|
||||||
|
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta19" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta15" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
4
package_debug_zip.sh
Normal file
4
package_debug_zip.sh
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
revision=r$(git log | egrep "^commit [0-9a-f]{40}$" | wc -l)
|
||||||
|
mkdir totte-$revision && cp -r bin/Debug/net7.0/* totte-$revision/ && zip -r totte-$revision.zip totte-$revision/ && rm -rf totte-$revision
|
Loading…
Reference in New Issue
Block a user