Ana içeriğe geç
Ahmet Faruk UzunkayaYaratıcı geliştirici
Tüm yazılar
DerinlemesineMühendislik7 dk

Glance'te bir sonraki fotoğrafı sen ok tuşuna basmadan hazırlamak

Glance'te "anında açılıyor" derken ölçülebilir bir şeyi kastediyorum: ok tuşuna bastığında görsel zaten çözülmüş ve GPU'da duruyor. Bunun için WIC'e doğrudan COM üzerinden gittim, ön yüklemeyi gezinme yönüne göre sıraladım ve önbelleği bayt bütçesiyle sınırladım. RAW önizleme tarayıcısı, dllhost'ta çalışan Gezgin küçük resimleri ve geri alınabilir silme de yazıda var.

  • C#
  • WinUI 3
  • WIC
  • Performans
Glance'te bir sonraki fotoğrafı sen ok tuşuna basmadan hazırlamak

Canon S90 ile çekilmiş bir CR2 dosyasını tam çözmek 7,6 saniye sürüyor. Kameranın aynı dosyanın içine gömdüğü JPEG önizlemeyi çözmekse 100 ms. Bu ölçümü ADR 0005'e not etmiştim. Glance'i C#, .NET 10, WinUI 3 ve Win2D ile yazdım. Bu görüntüleyicide "anında" derken ölçülebilir bir durumu kastediyorum: ok tuşuna bastığında gösterilecek görsel zaten çözülmüş ve GPU'ya yüklenmiş oluyor. Bunu film şeridindeki 7 piksellik, vurgu renkli noktadan takip edebilirsin. Nokta IsReady özelliğine bağlı; görsel önbellekten düşünce de Evicted olayıyla sönüyor.

Görseli ekran kadar çözmek

Ekranda göstermek için görseli, monitörün fiziksel çözünürlüğüne sığacak kadar çözüyorum. Ölçü olarak pencereyi değil ekranı alıyorum: MainWindow.xaml.cs, DisplayArea üzerinden monitör boyutunu ImageLoader.ScreenSize'a yazıyor (varsayılan 3840×2160). Bu yüzden pencereyi büyüttüğünde ya da tam ekrana geçtiğinde görsel yeniden çözülmüyor. Çözme ve Win2D CanvasBitmap yüklemesi Task.Run içinde, arayüz iş parçacığının dışında çalışıyor.

Windows'un her kurulumunda gelen formatlar (JPEG, PNG, GIF, BMP, TIFF, ICO, JPEG XR, DDS) WIC'ten geçiyor. WebP, HEIC, AVIF ve JPEG XL'i ise codec eklentisi kurulu olsa bile WIC'e hiç göndermiyorum. İlk sürümde WinRT BitmapDecoder kullanıyordum. ADR 0002'deki ölçüm, bu yolun JPEG'in DCT alanında küçültmesinden yararlanmadığını gösterdi. ADR 0005'te de ColorManageToSRgb'nin yalnızca JPEG'de renk yönetimi yaptığı ortaya çıktı. Bu yüzden Faz 1'de WicDecoder'ı doğrudan WIC COM arayüzlerinin üzerine yeniden yazdım.

Bağlamaları Microsoft.Windows.CsWin32 derleme zamanında üretiyor. NativeMethods.json'daki useComSourceGenerators ayarı sayesinde de yerleşik COM yerine GeneratedComInterface ve ComWrappers kodu çıkıyor; bu kod trimming ve AOT ile uyumlu. Kaynak üretimli COM'a, sürüm hattını kurduğum commit'te geçtim. Trim'li yayın o commit'te 203 MB'tan 115 MB'a indi ve bu geçiş oradaki üç küçültmeden biriydi. Boru hattının sırası şöyle: kare, ölçekleyici, çevirme/döndürme, renk dönüşümü ve PBGRA (src/Glance.Imaging/WicDecoder.cs):

var swapped = orientation.Value >= 5;
uint ow = swapped ? h : w, oh = swapped ? w : h;
var scale = Math.Min(1.0, Math.Min((options.MaxWidth ?? (double)ow) / ow, (options.MaxHeight ?? (double)oh) / oh));
 
// Scaler sits directly on the frame so JPEG can use its DCT-domain downscale (ADR 0005).
IWICBitmapSource src = frame;
uint sw = Math.Max(1, (uint)Math.Round(w * scale)), sh = Math.Max(1, (uint)Math.Round(h * scale));
if (sw != w || sh != h)
{
    Factory.CreateBitmapScaler(out var scaler);
    scaler.Initialize(src, sw, sh, WICBitmapInterpolationMode.WICBitmapInterpolationModeFant);
    src = Track(scaler);
}
if (orientation.Value > 1)
{
    Factory.CreateBitmapFlipRotator(out var rotator);
    rotator.Initialize(src, ToTransform(orientation.Value));
    src = Track(rotator);
}
src = ToSrgb(frame, src, Track);

Ölçeği, EXIF yönü uygulanmış boyuta göre hesaplıyorum (5-8 arası yönlerde genişlik ve yükseklik yer değiştiriyor). Ölçeğin 1'i geçmesine de izin vermiyorum, yani küçük görseller büyütülmüyor. Fant ölçekleyicisi doğrudan kareye bağlı olduğu için JPEG çözücüsü küçültmeyi DCT alanında yapabiliyor. ToSrgb, karedeki ilk ICC profilini IWICColorTransform ile sRGB'ye çeviriyor; profil yoksa görseli zaten sRGB sayıyor. COM nesnelerinin hepsini finally bloğunda FinalRelease ile hemen bırakıyorum, çünkü çözücüler görsel boyutunda yerel tamponlar tutuyor. ADR 0005'teki ölçüme göre COM yoluna geçince 12 MP'lik bir JPEG'in çözülmesi 500 ms'den 112 ms'ye, 24 MP'lik bir JPEG'inki 565 ms'den 229 ms'ye indi.

Geri kalan formatlar Magick.NET'ten geçiyor. Orada da gömülü profil TransformColorSpace(profile, ColorProfiles.SRGB) ile sRGB'ye çevriliyor; kodda örnek olarak iPhone'un Display P3 fotoğrafları geçiyor. OpenEXR ve Radiance .hdr dosyalarını SDR'ye ton eşlerken, ortalamayı orta griye oturtan global bir üstel operatör ve 2.2 gama kullanıyorum.

Komşuları önceden hazırlamak

Bir görsel ekrana gelir gelmez komşularını hazırlamaya başlıyorum. Faz 1'i commit'lediğimde bu sıralama hâlâ bir yer tutucuydu ve iki test kırmızı yanıyordu. Bugünkü hali src/Glance.Core/Prefetch.cs'te:

public static int[] Order(int direction)
{
    // Just opened: assume forward, the common case (and what the slideshow does).
    var d = direction == 0 ? 1 : Math.Sign(direction);
    // Next and the one we came from first (a quick step back is common), then a second step each way,
    // then a long run ahead.
    List<int> order = [d, -d, 2 * d, -2 * d];
    for (var i = 3; i <= Ahead; i++)
        order.Add(i * d);
    return [.. order];
}

Ahead sabiti 16, yani ortaya çıkan sıra d, -d, 2d, -2d, 3d … 16d: gidiş yönünde 16, geride 2 görsel. Uygulama yeni açıldığında yönü ileri varsayıyorum. Döngünün kendisi arayüz projesinde, src/Glance.App/MainViewModel.cs içinde:

// Sequential, so neighbours never compete with each other; cancelled as soon as the user navigates.
// Stops after half the cache budget, so a long look-ahead never evicts its own work on low-memory machines.
private async Task PrefetchAsync(int direction, CancellationToken ct)
{
    long loaded = 0;
    foreach (var offset in Prefetch.Order(direction))
    {
        if (ct.IsCancellationRequested || loaded > _loader.BudgetBytes / 2)
            return;
        if (_nav.Peek(offset) is not { } path)
            continue;
        try
        {
            loaded += (await _loader.LoadAsync(path, full: false, ct)).SizeBytes;
            MarkReady(path, true);
        }
        // ...

Komşuları tek tek, sırayla yüklüyorum ki birbirleriyle yarışmasınlar. Her gezinmede ShowAsync önce o an süren yüklemeyi ve ön yüklemeyi iptal ediyor. Yüklenen toplam boyut önbellek bütçesinin yarısını geçince döngü duruyor. Bu yüzden belleği az bir makinede 16'dan az görsel hazırlanıyor, ama karşılığında ön yükleme kendi hazırladığını önbellekten atmıyor.

Önbelleği (src/Glance.Core/LruCache.cs) öğe sayısıyla değil bayt bütçesiyle sınırladım. Bütçe min(RAM'in %10'u, 1 GB):

public static long DefaultBudget() =>
    Math.Min(GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 10, 1L << 30);

İçeride bir Dictionary ile bir LinkedList birlikte çalışıyor. Listenin başında en son kullanılan duruyor; bütçe aşılınca listenin sonundan atılıyor. Bütçeden büyük tek bir değer önbelleğe hiç girmiyor. Contains ise LRU sırasına dokunmuyor, çünkü film şeridinin "hazır mı" diye sorması bir kullanım sayılmamalı.

Önbellekte CPU piksellerini değil, Win2D CanvasBitmap'leri tutuyorum; ImageLoader GPU'ya yükledikten sonra CPU'daki piksel dizisini bırakıyor. Bunun sebebi ADR 0002'deki ilk deneme: pikseller hem CPU'da hem GPU'da durduğu için 100 MP'lik bir görsel 951 MB özel bellek kullanıyordu. Boyut hesabı genişlik × yükseklik × 4 × kare sayısı. Anahtar (Path, Full), yani aynı dosyanın ekran boyutlu ve tam çözünürlüklü sürümleri ayrı girdiler. Ekranda duran, geçişte solan ve karşılaştırma modunda sabitlenen görseller önbellekten çıksa bile dispose edilmiyor. Son commit'te hızlı yüklemenin bir yan etkisini de düzelttim: önbellekten gelen yükleme bittikten sonra bile yükleme halkası görünebiliyordu. Artık yalnızca yükleme sürerken çıkıyor.

RAW'da önce kameranın kendi JPEG'i

Çözücüler bir yönlendiricinin arkasında duruyor (src/Glance.Imaging/DecoderRouter.cs). Yönlendirici dosyanın ilk 64 baytına bakıyor, çözücüleri önceliğe göre deniyor (WIC 0, RAW önizleme 10, Magick 20) ve biri başarısız olursa sıradakine geçiyor. RAW uzantılı dosyalar, TIFF sihirli baytını taşısalar bile WIC'e gitmiyor.

ImageMagick'in bu iş için bir ayarı var: dng:read-thumbnail. Ama ADR 0005'e göre bu ayar Magick.NET 14.17.2'de işe yaramıyordu ve her açılışta tam demosaic yapılıyordu. Ben de kendi tarayıcımı yazdım (src/Glance.Imaging/RawPreviewDecoder.cs), açıkçası projede en sevdiğim parçalardan biri bu:

/// <summary>Largest lossy JPEG stream (SOF0/1/2) in the file; lossless SOF3 streams are raw sensor data, not previews.</summary>
public static (int Start, int Length) FindLargestJpeg(ReadOnlySpan<byte> d)
{
    (int Start, int Length) best = default;
    for (var i = 0; i + 4 < d.Length; i++)
    {
        if (d[i] != 0xFF || d[i + 1] != 0xD8 || d[i + 2] != 0xFF)
            continue;
        var end = JpegEnd(d, i, out var lossy);
        if (end > 0 && lossy && end - i > best.Length)
            best = (i, end - i);
        if (end > 0)
            i = end - 1;   // don't rescan inside a stream we've measured
    }
    return best;
}

Tarama hiçbir RAW formatının yapısını bilmiyor, sadece FF D8 FF dizisini arıyor. JpegEnd de marker segmentlerini SOS'a kadar yürüyüp entropi verisinin içinde EOI'yi buluyor. SOF3 akışları kayıpsız sensör verisi olduğu için sayılmıyor; 64 KB'tan küçük akışlar (160 px küçük resimler) da eleniyor. Önizlemenin kendi EXIF'i olmadığından yön bilgisini RAW'ın TIFF başlığından okuyorum, JPEG'i de WIC yoluyla ekran boyutunda çözüyorum. Tam çözme istendiğinde bu çözücü NotSupportedException atıyor ve yönlendirici Magick'e, yani LibRaw demosaic'ine düşüyor.

Tam çözmenin ne zaman başlayacağına çizim döngüsü karar veriyor (src/Glance.App/MainWindow.xaml.cs):

// Physical pixels per *bitmap* pixel: past 1:1 the screen-sized decode isn't enough any more.
if (_view.Scale * image.OriginalWidth / image.Bitmap.SizeInPixels.Width > 1.01 && !image.IsFull && _animStart < 0)
    _ = ViewModel.LoadFullResolutionAsync();

Tetikleyici her format için aynı: yakınlaştırma çözülmüş bitmap'in 1:1'ini geçtiğinde ve zoom animasyonu bittiğinde tam çözünürlüklü sürüm yükleniyor, görünüm de olduğu yerde kalıyor. ImageLoader bir RAW önizlemesini, boyutu ne olursa olsun "full" saymıyor. Bu yüzden RAW'da tam demosaic yalnızca 1:1'in ötesine geçince çalışıyor. Uzun kenarı GPU'nun azami bitmap boyutunu aşan görsellerde ise tam çözmeyi hiç denemiyorum.

Gezgin'e küçük resim vermek

Windows'un kendi küçük resim işleyicisi olmayan formatlarda Gezgin genel bir simge gösteriyor. src/Glance.Thumbnails, bu formatlar için yazdığım bir IThumbnailProvider. ADR 0006'da verdiğim karar şu: DLL NativeAOT ile derleniyor (NativeLib=Shared, 4,1 MB). Böylece barındıran sürece CLR yüklenmiyor ve diğer kabuk uzantılarıyla çalışma zamanı sürümü çakışmıyor. Paketli sürümde işleyiciyi bir COM surrogate olarak tanımladım (src/Glance.App/Package.appxmanifest):

<com:Extension Category="windows.comServer">
  <com:ComServer>
    <com:SurrogateServer DisplayName="Glance thumbnails">
      <com:Class Id="fa45545e-4edc-4912-90ad-24efae6273d0" Path="Glance.Thumbnails.dll" ThreadingModel="Both" />
    </com:SurrogateServer>
  </com:ComServer>
</com:Extension>

ADR 0006'nın ifadesiyle işleyici explorer.exe'nin içinde değil, paket kimliği taşıyan bir dllhost içinde çalışıyor. COM tarafı kaynak üretimli (GeneratedComClass, StrategyBasedComWrappers); DllGetClassObject ve DllCanUnloadNow, [UnmanagedCallersOnly] ile dışa aktarılıyor. IStream'in yalnızca gereken iki metodunu (Read ve Stat) vtable üzerinden çağırıyorum. NativeAOT imajı bellekten boşaltılamadığı için DllCanUnloadNow hep S_FALSE dönüyor. Surrogate, paket klasöründe DLL aramıyor; bu yüzden işleyici, Magick'in yerel kütüphanesi yüklenmeden önce kendi klasörünü SetDllDirectoryW ile ekliyor. Çözülemeyen bir dosyada bir HRESULT dönüyor ve Gezgin simgeye geri düşüyor.

JPEG ve PNG gibi WIC formatlarını Windows'un kendi işleyicilerine bıraktım. RAW'da burada da gömülü önizlemeyi kullanıyorum. ADR 0006'da 256 px için ölçtüğüm süreler şöyle: CR2 217 ms, JXL 540 ms, WebP ve AVIF yaklaşık 1,2 s (ilk çağrıya Magick'in yerel yüklemesi de dahil). Kurulum sürümü (--register, yalnızca HKCU) işleyiciyi bir uzantıya ancak o uzantının başka bir işleyicisi yoksa bağlıyor; Microsoft'un HEIF eklentisi gibi zaten kurulu işleyicilere dokunmuyor. Bu sürüm kayıt defterine InprocServer32 yazıyor; surrogate'ı açıkça tanımlayan tek yer MSIX manifesti.

Orijinale dokunmadan kaydetmek, silmeyi geri almak

Döndürmeyi kalıcı yapan kod src/Glance.App/Editing.cs'te:

var lossless = await Task.Run(() =>
{
    if (format.Extension == ".jpg" && Orientation.TryRotateJpegInPlace(path, quarterTurns))
        return true;
    var temp = Path.Combine(Path.GetDirectoryName(path)!, $".{Path.GetFileNameWithoutExtension(path)}.glance-tmp{ext}");
    ImageEditor.Save(path, new EditRequest(quarterTurns, Quality: 95), format, temp);
    RecycleBin.Send(path);   // the original stays recoverable
    File.Move(temp, path);
    return false;
});

JPEG'de kayıpsız döndürme pikselleri hiç döndürmüyor: TryRotateJpegInPlace, EXIF bloğundaki 2 baytlık Orientation değerini yerinde yamıyor. Jpeg_rotation_in_place_is_lossless_and_shows_rotated testinde dosya boyu aynı kalıyor ve tam olarak bir bayt değişiyor. JPEG'de yön etiketi yoksa ya da format başkaysa yeni dosyayı önce geçici bir adla, kalite 95 ile yazıyorum. Ardından orijinal Geri Dönüşüm Kutusu'na gidiyor, geçici dosya da orijinalin adını alıyor. HEIC, RAW ve PSD yerinde yazılamıyor. Bunlarda arayüz seni "Farklı kaydet"e yönlendiriyor ve o yol orijinale hiç dokunmuyor. HEIC ise yazılabilir formatlar listesinde hiç yok.

Silme başka türlü çalışıyor. DeleteAsync dosyaya dokunmadan onu listeden ve önbellekten çıkarıyor, sonra 5 saniyelik bir zamanlayıcı başlatıyor:

_pendingDelete = path;
UndoMessage = string.Format(System.Globalization.CultureInfo.CurrentCulture, Strings.Get("Toast_Deleted"), Path.GetFileName(path));
_nav.Remove(path);
_loader.Forget(path);
_undoTimer.Start();

Bu 5 saniye içinde Ctrl+Z'ye basarsan dosya listeye geri geliyor. Geri alma dosyayı çöpten çıkarmıyor, çünkü dosya henüz çöpe gitmedi; yalnızca gönderim iptal oluyor. Dosya çöpe ancak süre dolunca, bir sonraki silmede ya da pencere kapanırken CommitPendingDeleteAsync ile gidiyor. Gönderirken SHFileOperationW'yi FOF_ALLOWUNDO bayrağıyla kullanıyorum; kabuk reddederse hata loglanıyor ve dosya listeye geri ekleniyor.

Uygulama internete çıkmıyor

Repoda HttpClient, WebClient, WebRequest ya da System.Net kullanımı yok. MSIX manifestinde internetClient yeteneği de yok, yalnızca runFullTrust var. Kodda geçen tek https:// adresi, bilgi panelindeki "Haritada aç" düğmesinde:

// Opens the browser only when the user asks (R5: the app itself never goes online).
private async void OpenMap_Click(object sender, RoutedEventArgs e)
{
    if (ViewModel.Photo?.Location is { } p)
        await Launcher.LaunchUriAsync(new Uri(string.Create(System.Globalization.CultureInfo.InvariantCulture,
            $"https://www.openstreetmap.org/?mlat={p.Latitude}&mlon={p.Longitude}#map=15/{p.Latitude}/{p.Longitude}")));
}

Bu düğmeye bastığında konum, varsayılan tarayıcında OpenStreetMap'te açılıyor. Hata günlüğünü yalnızca yerelde, %LOCALAPPDATA%\Glance\logs\glance.log dosyasında tutuyorum. Testleri xUnit v3 ile yazdım. OrientationTests, 8 EXIF yönünün her birini hem WIC hem Magick yolunda ImageMagick'in AutoOrient çıktısıyla piksel piksel karşılaştırıyor. Kararların gerekçelerini docs/adr klasöründe topladım:

ADRKonuDurum
0001Phase 0 stack adjustmentsproposed
0002Spike: large images & WIC decode costproposed, boş bir makinede yeniden ölçülecek
0003Spike: Magick.NET size & lazy loadproposed
0004Spike: RAW decoder choiceADR 0005 ile geçersiz
0005WIC via COM, RAW preview scanner, Magick AnyCPU, dev packagingaccepted
0006Explorer thumbnails via a NativeAOT COM surrogateaccepted

Daha iyi olması gerekenler

ADR 0002 ve 0005'teki sayıları meşgul bir makinede, sentetik JPEG'lerle ölçtüm. ADR 0005, bu gürültülü dosyalarda ekran boyutunda çözmenin tam çözmeden hâlâ pek hızlı olmadığını da not ediyor; gerçek fotoğraflarla yeniden ölçmek hâlâ yapılacaklar listesinde. Her görseli tek bir bitmap'le çizdiğim için GPU'nun azami boyutunu aşan görseller yalnızca ekran boyutunda gösteriliyor; döşemeli çizimi sonraki aşamaya bıraktım. Etiketsiz ya da yalnızca nclx renk bilgisi taşıyan görseller sRGB sayılıyor. HDR ekran çıkışı ve yerel ton eşleme de yok. Klasör sırası, Gezgin'in doğal ad sırası (StrCmpLogicalW, img2 img10'dan önce geliyor). Gezgin'de seçtiğin tarih ya da boyut sıralamasını okumuyorum; dosya izleyici de yalnızca ekleme, silme ve yeniden adlandırma olaylarını dinliyor. Önizleme bölmesi işleyicisini yapmadım, kurulum dosyası da henüz kod imzalı değil.

Projenin ayrıntılarına proje sayfasından, kaynak koduna GitHub'dan göz atabilirsin.