.NET'te Görsel İşleme: ImageSharp ve SkiaSharp ile Resize, Format Dönüşümü, EXIF ve Güvenli İşleme
Yüklenen görseli önce ilk byte'larından ve piksel sayısından doğrula, sonra EXIF yönünü uygulayıp küçült ve metadata'sız WebP olarak kaydet. Bookstore'un kapak thumbnail'ları SkiaSharp (MIT) ile üretiliyor; ImageSharp 4 Release build'de lisans anahtarı istediği ve metadata'yı varsayılan olarak koruduğu için yalnız Debug örneğinde kaldı.
Sürüm notu: Bu yazıdaki kodlar .NET 10, SkiaSharp 4.156.1 ve SixLabors.ImageSharp 4.1.3 ile derlenip denenmiştir. ImageSharp lisans bilgisi 15 Kasım 2026'da resmi lisans metninden, dokümantasyondan ve fiyat sayfasından kontrol edildi.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) back office'inde çalışan biri, yeni gelen Dune baskısının kapağını telefonla çekip yüklüyor. Bookstore bu görselden katalog için üç boyutta thumbnail üretecek. Üç soru:
Birincisi: fotoğraf telefonda dik görünüyor. Dosyayı decode edip doğrudan küçültürsen thumbnail nasıl çıkar?
İkincisi: 48 KB'lık bir PNG (kayıpsız sıkıştırılmış görsel formatı) dosyası yüklendi. Decode etmek için ne kadar bellek gerekir?
Üçüncüsü: ImageSharp ile thumbnail'ı üretip WebP olarak kaydettin. Telefonun fotoğrafa yazdığı GPS (uydu tabanlı konum) bilgisi thumbnail'da kalır mı?
Cevaplar gerçek koşudan. İlkinde kapak yan yatmış çıkıyor: telefon pikselleri yan kaydedip doğru yönü EXIF (fotoğrafın içine yazılan kamera, tarih ve konum bilgisi) içindeki Orientation alanıyla bildiriyor. İkincisinde dosya 20000x20000 piksel; piksel başına 4 byte'tan 1,6 GB. Üçüncüsünde evet: ImageSharp metadata'yı varsayılan olarak koruyor ve thumbnail'da konum da dahil 7 EXIF değeri kalıyor.
Kütüphane Seçimi: ImageSharp, SkiaSharp ve System.Drawing
.NET'te görsel işlemek için üç yaygın seçenek var ve üçünün de bir koşulu var.
System.Drawing.Common, Windows'un GDI'ı (eski çizim API'si, bugünkü adıyla GDI+) üzerine kurulu. .NET 6'dan beri yalnız Windows'ta destekleniyor ve .NET 7'de onu Linux'ta açan ayar da kaldırıldı. Derlerken CA1416 platform uyarısı veriyor, Linux container'ında ise ilk satırda duruyor:
$ docker run --rm -v ./pub:/app:ro -w /app mcr.microsoft.com/dotnet/runtime:10.0 dotnet CoverResize.dll
Unhandled exception. System.TypeInitializationException: The type initializer for 'Windows.Win32.PInvokeGdiPlus' threw an exception.
---> System.DllNotFoundException: Unable to load shared library 'gdiplus.dll' or one of its dependencies.
Bookstore Linux container'larda çalıştığı için bu seçenek yok.
SixLabors.ImageSharp tamamen managed, API'si rahat ve dokümanı iyi. Lisansı Six Labors Split License 1.0: paketi doğrudan kullanan, yıllık brüt geliri 1 milyon USD'nin altındaki şirketler, open source projeler, kâr amacı gütmeyen kurumlar ve paketi transitive bağımlılık olarak alanlar için Apache 2.0 koşulları geçerli; diğerleri ticari lisans alıyor. Birinci yazıda gördüğümüz gibi 4.x serisi bir adım daha ekliyor: paketi doğrudan referans eden projenin Release build'i lisans anahtarı olmadan duruyor:
$ dotnet build -c Release
SixLabors.ImageSharp.targets(35,5): error : No Six Labors license found. Set $(SixLaborsLicenseKey), set $(SixLaborsLicenseFile), or add a 'sixlabors.lic' file to the project/workspace.
SixLabors.ImageSharp.targets(35,5): error : Please obtain a license from https://sixlabors.com/pricing/
Resmi dokümana göre anahtar 4.0.0'dan beri build sırasında gerekiyor, kontrol yalnız doğrudan referansta yapılıyor ve anahtar SixLaborsLicenseKey property'si, SixLaborsLicenseFile ya da proje klasöründeki sixlabors.lic dosyasıyla veriliyor. Fiyat sayfası, yıllık geliri 1 milyon USD'nin altındaki işletmeler için ücretsiz Community lisansının başvuruyla verildiğini söylüyor (licensing.sixlabors.com). Assembly'ye bağlı community anahtarı repo'ya konabiliyor; bu anahtar paketi doğrudan kullanan her projenin, test projeleri dahil, tam AssemblyName'ini içermeli. Diğer anahtarlar gizli tutuluyor ve CI'a environment variable olarak veriliyor. Ücretli tipler yıllık 799 USD'den (10 developer'a kadar) başlıyor. Yani Bookstore gelir koşulunu sağlasa bile Release build için bir başvuru, bir anahtar ve onun yenileme takvimi gerekiyor.
SkiaSharp, Google'ın Skia çizim motorunun .NET binding'i ve MIT (kodu ticari projede de serbestçe kullanmaya izin veren lisans) lisanslı. Anahtar yok, gelir eşiği yok. Bedeli native bir kütüphane taşıması. Bookstore'da kapak işlemeyi SkiaSharp ile yazdık. ImageSharp örneğini solution dışında, yalnız Debug'da derlenen ayrı bir projede bıraktık. Lisans kontrolü de bu kararı destekliyor: ImageSharp'ın lisansı NuGet'te bir dosya olarak geldiği için birinci yazıdaki araç tipini belirleyemiyor (Unable to determine license from the given license file) ve paketi biri bilinçli bir override yazmadan geçirmiyor.
Önce Dosyanın Kendisini Doğrula: İmza ve Piksel Sınırı
Uzantıya ve istemcinin gönderdiği Content-Type'a güvenmiyoruz. Biçimi dosyanın ilk byte'larından okuyoruz:
// Dosya adına ya da istemcinin gönderdiği Content-Type'a değil, ilk byte'lara bakar
public static UploadedImageFormat? Detect(ReadOnlySpan<byte> header) => header switch
{
[0xFF, 0xD8, 0xFF, ..] => UploadedImageFormat.Jpeg,
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..] => UploadedImageFormat.Png,
[(byte)'R', (byte)'I', (byte)'F', (byte)'F', _, _, _, _, (byte)'W', (byte)'E', (byte)'B', (byte)'P', ..] => UploadedImageFormat.WebP,
_ => null,
};
İmza tek başına yetmiyor; asıl tehlike decompression bomb. Sıkıştırılmış dosya küçük, decode edilmiş hâli devasa. Bu yüzden boyutları piksel decode etmeden, yalnız başlığı okuyarak alıyoruz:
// Yalnız başlığı okur: tek piksel decode edilmeden genişlik ve yükseklik belli olur
using SKData data = SKData.CreateCopy(file);
using SKCodec? codec = SKCodec.Create(data);
if (codec is null)
{
throw new InvalidImageException($"The {format} file could not be read.");
}
long pixels = (long)codec.Info.Width * codec.Info.Height;
if (pixels > o.MaxPixels)
{
throw new InvalidImageException($"{codec.Info.Width}x{codec.Info.Height} is {pixels:N0} pixels, the limit is {o.MaxPixels:N0}.");
}
Sınırı 40 milyon piksel koyduk; o bile decode edilince 160 MB. Üç test dosyası:
$ dotnet run -- inspect cover-dune.jpg
cover-dune.jpg: 112,704 bytes, Jpeg 2400x1600, origin=RightTop (388.4 ms)
$ dotnet run -- inspect bomb.png
bomb.png: rejected in 6.1 ms: 20000x20000 is 400,000,000 pixels, the limit is 40,000,000.
$ dotnet run -- inspect not-an-image.jpg
not-an-image.jpg: rejected in 0.7 ms: Not a JPEG, PNG or WebP file.
bomb.png 48 KB'lık, piksel başına 1 bit'lik ve tamamen siyah bir PNG; decode edilseydi 1,6 GB isterdi. Reddetmek 6 milisaniye sürdü. not-an-image.jpg aslında bir SVG (XML tabanlı vektör görsel formatı) ve içinde script var. SVG'yi kapak olarak hiç kabul etmiyoruz, çünkü tarayıcıda açıldığında script çalıştırabiliyor. İlk satırdaki 388 milisaniye, native kütüphanenin süreçte ilk kez yüklenmesinden.
EXIF Orientation ve Resize
Telefonlar fotoğrafı sensörün yönüyle kaydedip EXIF'e "gösterirken 90 derece döndür" notu düşüyor. SkiaSharp bu notu SKCodec.EncodedOrigin ile veriyor ama decode ederken uygulamıyor; dönüşümü biz yapıyoruz:

// Telefon fotoğrafları çoğu zaman yan kaydedilir; EXIF Orientation nasıl döndürüleceğini söyler
private static SKBitmap ApplyOrigin(SKBitmap source, SKEncodedOrigin origin)
{
if (origin == SKEncodedOrigin.TopLeft)
{
return source.Copy();
}
bool swap = origin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom;
SKBitmap target = new(swap ? source.Height : source.Width, swap ? source.Width : source.Height);
using SKCanvas canvas = new(target);
canvas.SetMatrix(OriginMatrix(origin, source.Width, source.Height));
using SKImage image = SKImage.FromBitmap(source);
canvas.DrawImage(image, 0, 0, SKSamplingOptions.Default);
return target;
}
OriginMatrix sekiz EXIF yönünün her biri için bir dönüşüm matrisi dönüyor. Orientation 6 (RightTop) için 90 derece saat yönünde döndürme. Sonra her genişlik için oranı koruyarak küçültüp WebP'ye çeviriyoruz:
foreach (int width in options.Value.Widths.Where(w => w < upright.Width))
{
int height = (int)Math.Round(upright.Height * (double)width / upright.Width);
using SKBitmap resized = upright.Resize(new SKImageInfo(width, height), new SKSamplingOptions(SKCubicResampler.Mitchell));
using SKImage image = SKImage.FromBitmap(resized);
using SKData webp = image.Encode(SKEncodedImageFormat.Webp, options.Value.WebPQuality);
thumbnails.Add(new CoverThumbnail(width, height, "image/webp", ".webp", webp.ToArray()));
}
SKCubicResampler.Mitchell küçültmede yumuşak ve keskin arasında dengeli bir sonuç veriyor. Varsayılan örnekleme en yakın pikseli seçtiği için kapak yazıları tırtıklı çıkıyor. Orijinalden küçük olmayan genişlikleri atlıyoruz; büyütülmüş thumbnail yalnız dosyayı şişirir.
$ dotnet run -- thumbs
original: 112,704 bytes, EXIF=True
160x240 image/webp 1,410 bytes, EXIF=False
320x480 image/webp 2,762 bytes, EXIF=False
640x960 image/webp 5,682 bytes, EXIF=False
3 thumbnails in 50 ms
Metadata: Konum Bilgisini Yayımlama
Orijinal dosyada telefonun markası, modeli, çekim zamanı ve GPS koordinatları var. Kapağı depoda çeken çalışanın konumunu sitede yayımlamak, KVKK (Kişisel Verilerin Korunması Kanunu) açısından da açıklaması zor bir hata. ImageSharp ile aynı işi yapan Debug projesinin çıktısı:
$ dotnet run
bomb.png: 20000x20000 = 400,000,000 pixels
cover-dune.jpg: 2400x1600, 8 EXIF values
Orientation: 6
Make: ExampleCam
Model: PhoneModel 1
GPSLatitude: 41 0 29.52
GPSLongitude: 28 58 42.24
thumbnail 320x480 in 277 ms, EXIF kept: 7 values, GPS=True
after clearing: 2,616 bytes, EXIF=none
ImageSharp'ta AutoOrient() görüntüyü döndürüyor, ama orijinaldeki 8 EXIF değerinden 7'si, GPS koordinatları dahil, thumbnail'a taşınıyor ve WebP dosyasına bir EXIF chunk'ı olarak yazılıyor. Temizlemek açık bir adım:
// Kaydetmeden önce metadata'yı sil
thumb.Metadata.ExifProfile = null;
thumb.Metadata.XmpProfile = null;
thumb.Metadata.IptcProfile = null;
SkiaSharp'ın encoder'ları metadata yazmadığı için bizim çıktılarımız temiz. Bunu bir kütüphane davranışına bırakmak yerine test ediyoruz. Test, WebP dosyasının chunk listesini okuyup içinde EXIF olmadığını doğruluyor; ImageSharp'ın temizlenmemiş çıktısında liste VP8X, VP8, EXIF, bizimkinde yalnız VP8. Orijinal dosyayı ise private tutuyoruz ve hiçbir zaman olduğu gibi sunmuyoruz.
Format ve Kalite: WebP, JPEG ve AVIF
Aynı 320x480 görseli kalite 80 ile dört encoder'a verdik:
$ dotnet run -- formats
Jpeg: 8,230 bytes
Png: 14,161 bytes
Webp: 2,614 bytes
Avif: no encoder (null)
WebP, JPEG'in (kayıplı sıkıştırılmış fotoğraf formatı) üçte biri boyutunda ve bugün bütün yaygın tarayıcılar onu gösteriyor. AVIF (AV1 video codec'ine dayanan yeni görsel formatı) enum'da var ama bu SkiaSharp build'inde encoder yok ve Encode exception fırlatmadan null dönüyor. null kontrolünü atlarsan bir sonraki satırda NullReferenceException alırsın. AVIF gerekiyorsa ayrı bir encoder gerekiyor; kapak thumbnail'ı için WebP yeterli. Kalite ayarı bir ölçüm konusu: 80 bizim kapaklarımızda gözle fark edilmeyen bir kayıp verdi. Fotoğraf türüne göre aynı denemeyi kendi görsellerinle yap.
Upload'dan Thumbnail'a: Background Job
Upload endpoint'i yalnız ucuz kontrolleri yapıyor: ISBN'in (kitapların uluslararası standart numarası) biçimi, boyut sınırı, imza ve piksel sayısı. Orijinali private bir yola yazıp işi queue'ya bırakıyor. Decode ve resize CPU yiyen işler; bir BackgroundService onları request'in dışında yapıyor. Upload'ın HTTP tarafı (IFormFile, Kestrel limitleri, streaming) ASP.NET Core Web API serisinin konusu.
// Orijinal private kalır: fotoğrafın çekildiği yer gibi EXIF verileri taşıyabilir
string originalPath = $"covers/{isbn}/original{extension}";
await files.SaveAsync(originalPath, new MemoryStream(upload), contentType, ct);
await queue.EnqueueAsync(new CoverThumbnailJob(isbn, originalPath), ct);
return Results.Accepted($"/api/books/{isbn}/cover/320", new { check.Format, check.Width, check.Height, check.Origin });
$ curl -s -w '\nHTTP %{http_code}\n' --data-binary @cover-dune.jpg -H 'Content-Type: image/jpeg' http://127.0.0.1:52560/api/books/9780441172719/cover
{"format":"Jpeg","width":2400,"height":1600,"origin":"RightTop"}
HTTP 202
$ curl -s -w '\nHTTP %{http_code}\n' --data-binary @bomb.png -H 'Content-Type: image/jpeg' http://127.0.0.1:52560/api/books/9780441172719/cover
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"detail":"20000x20000 is 400,000,000 pixels, the limit is 40,000,000."}
HTTP 415
Log'da Cover thumbnails for 9780441172719 ready in 45 ms satırı çıktı ve GET /api/books/9780441172719/cover/320 2.762 byte'lık WebP'yi döndü. ISBN'i regex ile kontrol etmek, URL'den gelen değerin dosya yoluna ../ olarak girmesini de engelliyor; dosya yollarının güvenliğine bir sonraki yazıda ayrıntılı bakıyoruz. Buradaki in-process queue yeniden başlatmada işi kaybediyor; son yazıda bu işleri Hangfire'a taşıyoruz. Azure'da aynı adım bir blob tetiklemeli function da olabilir; Cloud Temelleri serisinin serverless yazısı bu yaklaşımı anlatıyor.
Üretimde Dikkat
SkiaSharp paketi Windows ve macOS için native binary getiriyor, Linux için getirmiyor. Aynı uygulamayı Linux container'ında çalıştırdığımızda:
Unhandled exception. System.TypeInitializationException: The type initializer for 'SkiaSharp.SkiaApi' threw an exception.
---> System.DllNotFoundException: Unable to load shared library 'libSkiaSharp' or one of its dependencies.
SkiaSharp.NativeAssets.Linux.NoDependencies paketini ekledikten sonra aynı image'da üç thumbnail 129 milisaniyede üretildi. NoDependencies sürümü fontconfig gibi sistem kütüphanelerine bağlı değil; metin çizmeyen bir iş için küçük image'larda en az sorun çıkaran seçenek.
Bellek: decode edilmiş görsel piksel başına 4 byte. Aynı anda işlenen görsel sayısını sınırla; 40 megapikselin dört paralel işi 640 MB demek.
Orijinali ayrı ve private sakla. Yeni bir thumbnail boyutu gerektiğinde orijinalden yeniden üretirsin; küçük thumbnail'dan büyütmek kaliteyi bozar.
Kullanıcıya gösterilen her görseli kendi encoder'ından geçir. Gelen dosyayı olduğu gibi sunmak, içindeki metadata'yı ve dosyaya gömülmüş beklenmedik içeriği de sunmak demek.
Sık Düşülen Tuzaklar
Uzantıya güvenmek. .jpg uzantılı SVG, ilk byte kontrolünden geçemedi; uzantıya bakan bir kod onu kapak olarak kaydederdi.
Orijinal büyük görseli listede göstermek. 112 KB'lık orijinal yerine 2,7 KB'lık thumbnail, bir sayfada 50 kapak varken ciddi fark yapıyor.
EXIF'teki kişisel veriyi yayımlamak. Konum, cihaz ve zaman bilgisi thumbnail'a kendiliğinden taşınabiliyor; ImageSharp örneği bunu gösterdi.
Boyutu decode ettikten sonra kontrol etmek. O noktada 1,6 GB çoktan ayrılmış olur.
Lisans anahtarını CI'da fark etmek. ImageSharp 4 geliştirici makinesinde Debug'da derleniyor, Release pipeline'ında duruyor.
Küçük Bir Deneme
★☆☆ CoverImageOptions.MaxPixels değerini 3.000.000 yap ve cover-dune.jpg'yi tekrar incele. Hata mesajı ne diyor? Gerçek kapak fotoğrafları için mantıklı bir sınır ne olabilir?
★★☆ formats komutuna JPEG için 60, 80 ve 95 kalitelerini ekle ve boyutları karşılaştır. Aynı farkı WebP'de de görüyor musun?
★★★ CoverThumbnailWorker'ı aynı anda en fazla iki görsel işleyecek şekilde değiştir ve 10 kapak yükleyip sürelere bak. Sınırı nereye koyarsın: worker'a mı, CoverImageProcessor'a mı?
Kapaklar ve faturalar şimdilik local diskte. Bir sonraki yazıda IFileStore'u Azure Blob Storage ve S3 (Amazon'un object storage servisi) ile çalıştırıyor, path traversal'a karşı local implementasyonu sağlamlaştırıyor ve kısa ömürlü SAS (Shared Access Signature; süresi ve yetkisi sınırlı erişim imzası) ve presigned URL'lerle dosyayı API'den geçirmeden yüklemeyi kuruyoruz.