10 Temmuz 2025 Perşembe

Python ile Görsellere Metin Eklemek

Merhaba. Bu yazıda ihtiyaca yönelik bir problemin çözümüne değindim. Problem, bir dizindeki görsellere toplu bir şekilde yazı eklemek. Örneğin bir telif notu veya adres bilgisi gibi. Sanıyorum bu, GIMP'te bir şablon (stencil) oluşturularak çözülebilir. Peki eklenecek metin sabit olmadığı durumda? Benim sorunum görsellere sıra numarası eklemekti. Her fotoğrafın köşesine birden başlayan ardışık sayılar... Bu durumda, şablon çözüm olmuyor. Ayrıca bu GIMP'le tek tek yapılabiliyorsa bile yüze yakın görsel için pratik değil. En pratik çözüm bunun için basit bir script yazmak. Bu arada, bu numaralandırmayı dosya adıyla yapabilirdim, ama dosya adındaki zaman damgasını korumak istediğim için buna dokunmak istemedim. Üstelik bu görselleri bir web sayfasında kullanmak isteseydim, dosya adları görünmüyor olacaktı. Son olarak bu script ile istenirse dosya adlarından, görselin kenarına 90'larda fotoğraf makinalarının yaptığına benzer şekilde fotoğrafın ne zaman çekildiği yazılabilir. 

Script deyince akl(ım)a ilk gelen bash maalesef bu iş için en iyi araç değil. Bildiğim kadarıyla bash için yazılmış bir görsel kütüphanesi yok. Yapılmaz değil elbette yapılır, ama nasıl ki balyoz varken duvar yıkmak için çekiç kullanılmıyorsa, her iş için ona uygun aracı seçmek çözümün ilk adımı. Python sever okurlar kızacaktır ama bash'tan sonra ikinci en iyi script dili python'un bu iş için Python Imaging Library (PIL) adında uygun bir kütüphanesi var. Ben kendi scriptimde PIL'in Pillow adındaki fork'unu kullandım. Kütüphane pip install pillow komutuyla kolayca yükleniyor. 

Yazıyı kısa tutmak için kodu yine github hesabıma yükledim. Kısa bir script ve içinde sadece ufak numaralar var. Öncelikle her normal python script'i gibi başta import'lar var. PIL kütüphanesindeki Image modülü görsellerle ilgili fonksiyonları içeriyor. ImageDraw, görsellerle ilgili basit 2B efektler, benim kodumda görseli döndürmek için gerekli ve son olarak ImageFont font ve diğer metin efekleri için. 


Exif Verileri ve Oryantasyon

Bu projedeki birinci zorluk, görsellerin bilgisayarda, telefonda çekildikleri şekilde görüntülenememeleri. Örn. aşağıda cep telefonumla çektiğim görseli ele alalım:


Yukarıda, tarayıcıda dikey formatta görünüyor. Kendi bilgisayarımda Gwenview ile açtığımda yine dikey formatta görünüyor. Ancak aşağıdaki kodla açtığımda

>>> from PIL import Image
>>> img = Image.open("image.jpg")
>>> img.show()

görsel yatay formatta görünüyor. 

ve GIMP'le açtığımda garip bir pencere görselin "Exif orientation metadata" içerdiğini ve bunu çevirmek isteyip istemediğimi soruyor. Peki neden?

Cep telefonunu yatay* tutup fotoğraf çekerken, telefon fotoğrafa herhangi bir döndürme işlemi uygulamaz. Cep telefonu veya dijital fotoğraf makinasıyla çekilmiş fotoğrafları python ile yukarıdaki gibi açtığımda, dikey açıyla çekilmemiş olan fotoğraflar bu yüzden çekildikleri açıda ekrana basılırlar. Kamera fotoğrafın çekildiği oryantasyonu da birlikte kaydedip, görüntüleme sırasında fotoları çevirir. Yönelim (oryantasyon) bilgisi kaydedilmemiş olsaydı, fotoğrafı düzgün görmek için her seferinde bizim telefonu ilk çekildiği açıya çevirmemiz gerekirdi. 

*: Bazı kameralarda dikey pozisyon default olup, yatay pozisyonun yönelimini kaydederken, bazılarında tam tersidir. 

Dolayısıyla bir görsel dosyasının içinde sadece piksellere ait bilgiler olmadığı yukarıdaki ifadeden de anlaşılabilir. Dosyada görsele ait metadatanın saklandığı Exif adlı bir alan vardır ve bugün tüm görsel formatları ve kameralar Exif'i destekler. Wikipedia'da Exif'te saklanan verilere ait örnek bir tablo var. Buna göre başlıca alanlar, kameranın marka ve modeli, görselin yönelimi, çekildiği gün ve saat, çözünürlük vb. Örn. çekildiği gün ve saat bilgisinin fotoğrafa gömülmesi, dosya adı 20230809_143341.jpg formatında olmasa bile fotoğrafın ne zaman çekildiğinin bulunmasına ve tarihe göre sıralabilmesine olanak sağlar. Öte yandan bazı telefonlar GPS'ten aldığı koordinat verisini Exif'e gömerek fotoğrafın nerede çekildiğini açık ederler. Bazı ortamlar böylece fotoğrafın çekildiği yeri paylaşılırken otomatik etiketleyebilir. Bunlar kişisel bilgilerin gizliliği konusunda hassas olanların tüylerini diken diken edecek durumlar. Bir söylentiye göre Ukraynalılar, Rus askerlerinden online foto isteyip bundan elde ettikleri koordinatlara saldırılar düzenlediler. 

Linux'ta exiftool adında bir araçla bu bilgiler görüntülenebilir (exiftool -list <dosyaadi>), değiştirilebilir veya tamamen silinebilir (exiftool -all= <dosyaadi>). Yukarıdaki örnek görselin tüm Exif verisi silinmiş haliyle orjinali arasında 77 KB civarı bir fark var. 

Exif konusunda gereğinden uzun bir açıklama yaptıktan sonra, tekrar Exif oryantasyon bilgisine geri döneyim. Pillow kütüphanesinde Exif'te saklanan veriyi okumak için Image.getexif() fonksiyonu var [1]. Bunun çıktısını ekrana yazdırdığımda (kodun içinde 22., 23. ve 24. satırlar, comment out edilmiş) dizindeki .jpg dosyaların 'Orientation' bilgisini görüyorum. [1]'de de belirtildiği gibi 2, 7, 4 ve 5 benim görsellerde yoktu. Keza 8 de bulunmadığı için bunu kendi kodumda uygulamadım, ancak bunu uygulamak gayet kolay. 1 değeri için if yapısında else'i kullandım (satır 31), bu nedenle 8 ve diğer değerlerde görsel döndürülmüyor. 

Döndürme için Pillow'da Image.rotate() fonksiyonu var [2]. Bunu kullanarak Orientation=3 için görseli 90 derece ve Orientation=6 için görseli 270 derece çevirdim. Burada 27. satır için bir parantez açmak gerek: Eğer Exif'te yönelim alanı bulunmuyorsa veya görselde Exif bilgisi yoksa kod hata verecektir. Bu nedenle getexif fonksiyonundan dönen değeri kontrol edip herşey tamamsa döndürmek daha doğru. Bendeki verilerin hepsinde Exif bulunduğu için sorun yaşamadım, bu nedenle bu kısmı hızlı ve çabuk şekilde halletmeyi tercih ettim.

Buraya kadar görselin yönelimini düzeltmiş olduk. Ana problemin çözümünde [3]'ten yararlandım. Burada nasıl yazı ekleneceği basitçe gösterilmiş, ben yalnızca kendi problemime göre parametreleri düzenledim. Satır 35'te yazı eklenmek üzere bir ImageDraw nesnesi oluşturuluyor. Sonraki satırda ImageFont.truetype() fonksiyonuyla bir font nesnesi oluşturuluyor. [3]'te kullanılan font benim makinamda olmadığından (ve fonksiyondan dönen değeri hata için kontrol etmediğimden), ben /usr/share/fonts/ altındaki fontlardan birini seçtim. İkinci parametre font büyüklüğü (punto). Bunu deneme yanılma yoluyla buldum. Bendeki görseller görece büyüktü (8 MP) bu nedenle 128 ancak görülebilir bir yazı üretebildi. Ondan sonraki satırda sırasıyla, görselin verilen koordinatına (25, 25 - sol üst), sirano değişkenini, bir önceki satırla oluşturduğum fontla ve kırmızı renkle ekledim. Bu adımda, yukarıda bahsettiğim, dosyanın adından veya Exif etiketinden fotoğrafın ne zaman çekildiği bilgisi alınarak yazdırılabilirdi. Aşağıda örnek bir numaralandırılmış fotoğraf görülüyor:


Son olarak, 40. satırdaki yorum kaldırılarak görsel ekranda gösterilebilir ve/veya 43. satırdaki yorum kaldırılarak "_enum" sonekiyle kaydedilebilir. Ben bu scripti yazarken yüze yakın görselin olduğu bir dizinde çalıştırdığım için her seferinde yüz fotoyu göstertmek veya kaydetmek istemediğimden o satırları comment out etmiştim. 


Not: Alternatif olarak bunu OpenCV'de cv2.putText() fonksiyonuyla yapabilmek de mümkün ama bu başka bir yazıya kalsın. 

 

[1]: https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
[2]: https://note.nkmk.me/en/python-pillow-rotate/
[3]: https://www.geeksforgeeks.org/python/adding-text-on-image-using-python-pil/ 

5 Ocak 2025 Pazar

Amazon S3 Objelerinde ETag Hesplaması


Merhaba. Bu yazıda S3'e yüklenen dosyaların ETag adı verilen hash'lerinin nasıl hesaplandıklarından bahsedeceğim. ETag genel olarak, bir S3 bucket'ına yüklenen her dosya için hesaplanan MD5 hash'ten başka birşey değil. S3'teki dosyaların gerçekte bizim anladığımız anlamda dosya olmadıklarını biliyoruz. S3, "Object Storage" olarak alınıyor ve dosyalar da doğru terminolojiyle obje olarak saklanıyor. 

Belli bir dosya büyüklüğünden sonra, aws s3 cp veya aws s3 sync ile yapılan yüklemeler, (muhtemelen) daha kolay saklanabilmesi için otomatik olarak eş büyüklükte parçalara bölünür. Buna multipart objeler denir. Peki ne kadar bir büyüklük? Genel geçer bir ölçü olmasa da benim dosyalarım şu anda 8 ila 16M arası parçalar olarak saklanıyor. Yazıyı hazırlarken kullandığım kaynakta, 5G'ye kadar olan dosyaların parçalanmadıklarından bahsedilmiş [1][2], ancak benim gözlemime göre bu artık doğru değil ama bu değer çok da önemli değil.

Eğer bir dosya bu eşikten küçükse, tek parça olarak saklanır ve objenin ETag'i MD5 hash'ine eşit. Buraya kadar bir sorun yok. Eğer dosya bu eşikten büyükse multipart obje olarak saklanınca işler biraz karışıyor. Bir objenin multipart olup olmadığı ETag'ına bakarak kolayca anlaşılabilir. Normal bir MD5 hash yalnızca hexadecimal basamaklardan oluşur. Dolayısıyla tire işareti ( - ) MD5 hash'e ait değildir. Eğer S3'teki bir dosyanın ETag'ında tire işareti varsa, bu multipart bir objedir ve dosyanın kaç parçaya bölündüğü tireden sonra gelen kısımdadır. Bunların hepsine ait somut örnekleri yazının ilerleyen kısmında vereceğim. 

Multipart objelerde ETag hesaplaması şöyle işliyor: Her bir parça ayrı ayrı MD5'le hash'leniyor, çıkan hash'ler uç uca eklenip tekrar hash'leniyor. Bu ETag'ın tireden önceki kısmı. Parça sayısı basitçe tireden sonra en sonra ekleniyor [3].

Ben bilgisayarlarımın disklerini düzenli olarak Clonezilla ile yedekliyorum. Yedekleri önce harici diske alıp, bunları S3'e kopyalıyorum. Harici diskte en yeni kopya duruyor, son üç kopya S3'te. Geriye doğru (FAT32) uyumluluk nedeniyle, yedekleri 4G'lik parçalara bölüyorum (her ne kadar yedeği FAT32 ortama almasam da). Zaten ETag karşılaştırma ihtiyacı S3'teki kopyaları doğrulamak istememden çıktı.

Bu noktada aws komut satırı arabiriminin yüklü ve ayarlı olduğunu varsayıyorum. Ayarlar .aws/config dosyasından yapılıyor ancak yazıyı uzatmamak için buna değinmeyeceğim. Önce küçük dosya örneğini ele alalım:

$ aws s3api head-object --bucket mybucket --key image_backup/2023-10-15-10-img/Info-lshw.txt
{
    "AcceptRanges": "bytes",
    "LastModified": "2023-10-15T18:28:31+00:00",
    "ContentLength": 40960,
    "ETag": "\"fe78f69cb9d41a23ba23b4783e542a7b\"",
    "ContentType": "text/plain",
    "ServerSideEncryption": "AES256",
    "Metadata": {}
}

Önceden belirttiğim gibi, bu multipart obje değil. Haliyle MD5 hash'i yani ETag'ı basitçe bulunabilir. Aşağıda büyük dosya örneği var: 

$ aws s3api head-object --bucket mybucket --key image_backup/2024-12-01-13-img/sda5.ntfs-ptcl-img.xz.ac
{
    "AcceptRanges": "bytes",
    "LastModified": "2024-12-03T17:00:58+00:00",
    "ContentLength": 4096008192,
    "ETag": "\"360f5e8babf8cd28673eaafd32eb405f-489\"",
    "ContentType": "application/vnd.nokia.n-gage.ac+xml",
    "ServerSideEncryption": "AES256",
    "Metadata": {}
}

Bu 4096 MB'lık bir dosya ve ETag'dan görüleceği gibi 489 parçadan oluşuyor. Burada önemli olan parçaların büyüklüklerini bulmak. ContentLength, 489'a bölününce 8M'ye çok yakın bir değer bulunuyor. Buradan aslında dosyanın 8M'lik parçalara bölündüğünü varsayabilirim ama bir programda kullanmak için bunun kesin değerini bulmak gerek. Bunun için aynı komuta --part-number parametresini ekleyip tek bir parçayı inceleyeceğim. Dosyalar sabit büyüklükte parçalandıklarından yalnızca en son parçanın boyutu farklı, ancak her parça için ETag değeri aynı. Başka bir deyişle --part-number her parçanın ayrı ayrı MD5 hash'ini vermiyor.

$ aws s3api head-object --bucket mybucket --key image_backup/2023-10-15-10-img/sda5.ntfs-ptcl-img.gz.aac --part-number 1
{
    "AcceptRanges": "bytes",
    "LastModified": "2023-10-15T18:28:31+00:00",
    "ContentLength": 16777216,
    "ETag": "\"aba379cb0d00f21f53da5136fc5b0366-299\"",
    "ContentType": "audio/aac",
    "ServerSideEncryption": "AES256",
    "Metadata": {},
    "PartsCount": 299
}

$ aws s3api head-object --bucket mybucket --key image_backup/2023-10-15-10-img/sda5.ntfs-ptcl-img.gz.aac --part-number 299
{
    "AcceptRanges": "bytes",
    "LastModified": "2023-10-15T18:28:31+00:00",
    "ContentLength": 401408,
    "ETag": "\"aba379cb0d00f21f53da5136fc5b0366-299\"",
    "ContentType": "audio/aac",
    "ServerSideEncryption": "AES256",
    "Metadata": {},
    "PartsCount": 299
}

Bu arada resmi AWS dökümantasyonuna göre (Aralık 2024 itibariyle) [4] default parça büyüklüğü 8 MB ancak yukarıda görüldüğü üzere Ekim 2023'te bir dosya 16 MB'lik parçalarla yüklenmiş. Dolayısıyla bu değeri sabit kabul etmek yerine, ContentLength alanından almak daha mantıklı. Görünüşe göre Amazon'dakiler canları sıkıldıkça default'u değiştiriyorlar. Bu arada aws komutu json çıktı üretiyor. bash script'le çalışırken, çıktıyı grep yerine jq ile parse etmek daha şık sonuç veriyor:

$ aws s3api head-object --bucket mybucket --key image_backup/2023-10-15-10-img/sda5.ntfs-ptcl-img.gz.aac --part-number 1 | jq -r '.ETag'
"aba379cb0d00f21f53da5136fc5b0366-299"

$ aws s3api head-object --bucket mybucket --key image_backup/2023-10-15-10-img/sda5.ntfs-ptcl-img.gz.aac --part-number 1 | jq -r '.ContentLength'
16777216

Ben, aldığım yedekteki tüm dosyaları tek tek karşılaştırmak için bir script hazırladım. Biraz uzun olduğu için buradan paylaşmayacağım, repo linkiyle ulaşılabilir. Script, kullanıcıdan basitçe bucket adını ve yedeklerin olduğu dizinin adını alıyor. Ben yedekleri image_backup adında bir dizinde, <YYYY-MM-DD-HH-img> formatlı alt dizinlerde tutuyorum, bu kısım (satır 12) ihtiyaca göre değiştirilebilir. Parça sayısı birse, doğrudan md5 alınıyor (satır 26). Birden fazla parça varsa, bu parçalar dd ile bölünüyor (satır 36), hepsinin ayrı ayrı hash'leri geçici bir dosyaya yazılıyor. Parçalar bittiği zaman oluşan dosyanın tekrar hash'i alınıp dosya siliniyor (satır 41-42). Dosyanın geri kalan kısmı bash string işlemleriyle hash'ler karşılaştırılıp aynı ise OK farklı ise FAIL yazdırılıyor.


[1]: https://stackoverflow.com/questions/45421156
[2]: https://stackoverflow.com/questions/6591047
[3]: https://stackoverflow.com/questions/12186993
[4]: https://docs.aws.amazon.com/cli/latest/topic/s3-config.html#multipart-chunksize