Olaf Lischke
.NET Allgemein Powershell
Powershell: EXIF-Daten aus Bildern auslesen

Powershell: EXIF-Daten aus Bildern auslesen

Digitale Kameras (ja, auch die in Handys!) schreiben beim Speichern einer Aufnahme eine ganze Reihe fotografisch relevanter Daten (Belichtung, ISO-Zahl, Brennweite, Blende, Kameramodell) als sog. EXIF-Daten in die Bilddatei – übrigens nahezu unabhängig vom Zielformat, sowohl RAW als auch JPG enthalten diese Daten. Und auch unabhängig vom Kamerahersteller, denn EXIF ist ein weltweiter Standard.

Allerdings, da das Speicherformat von Bildern immer ein binäres ist, werden die EXIF-Daten ebenfalls dort binär abgelegt, was das Auslesen etwas aufwändiger macht. Jedem EXIF-Tag (Belichtung, ISO-Zahl, Brennweite,..) ist eine hexadezimale Tag-ID zugeordnet, und jeder EXIF-Tag hat einen spezifischen Datentyp, ggf. sogar eine Auswahlliste fest vorgegebener Werte. Wer sich reinnerden will: https://exiftool.org/TagNames/EXIF.html.

Aber egal, ob für eine Bilddatenbank oder RAG für eine lokale KI, man kann den Job mit Powershell erledigen, wir bauen uns ein CmdLet Get-ExifData, das für ein gegebenes Bild die EXIF-Daten ausliest.

Zwei kleine Helper-Funktionen

Ich kann mich an eine Auftragsarbeit in C# vor Jahren erinnern, wo das Auslesen der EXIF-Daten in eine ziemliche Konvertierungsorgie eskalierte. Powershell ist da freundlicher, weil es viele Konvertierungen implizit vornimmt. Trotzdem erstellen wir uns eine kleine Konvertierungsfunktion für die wichtigsten Datentypen, auf die wir treffen werden – Byte, ASCII, short, long, rational (Fließkomma):

function Get-ExifValue {
      param ($prop)
      switch ($prop.Type) {
        1 { return $prop.Value[0] } # BYTE
        2 { return ([System.Text.Encoding]::ASCII.GetString($prop.Value)).Trim([char]0) } # ASCII
        3 { return [BitConverter]::ToUInt16($prop.Value, 0) } # SHORT
        4 { return [BitConverter]::ToUInt32($prop.Value, 0) } # LONG
        5 { return Convert-Rational $prop.Value } # RATIONAL
        7 { return $prop.Value } # UNDEFINED
        9 { return [BitConverter]::ToInt32($prop.Value, 0) } # SLONG
        10 { return Convert-Rational $prop.Value } # SRATIONAL
        default { return $prop.Value }
      }
    }

Und weil beim rational-Datenformat jemand ganz kreativ war (es stellt eigentlich einen ganzzahligen Bruch dar: Die ersten 4 Bytes sind der Zähler, die zweiten 4 Bytes der Nenner), hier noch eine zusätzliche Konvertierung, damit daraus ein in Powershell verwendbarer double wird:

function Convert-Rational {
      param ($bytes)
      $num = [BitConverter]::ToUInt32($bytes, 0)
      $den = [BitConverter]::ToUInt32($bytes, 4)
      if ($den -eq 0) { return $num }
      return [math]::Round($num / $den, 4)
    }

Außerdem holen wir uns von https://exiftool.org/TagNames/EXIF.html die IDs der Tags, die uns interessieren, und legen sie in ein Array:

$exifTags = @{
      0x0100 = "ImageWidth"
      0x0101 = "ImageHeight"
      0x010F = "Make"
      0x0110 = "Model"
      0x0112 = "Orientation"
      0x0132 = "DateTime"
      0x829A = "ExposureTime"
      0x829D = "FNumber"
      0x8833 = "ISOSpeed"
      0x9003 = "DateTimeOriginal"
      0x9201 = "ShutterSpeedValue"
      0x9202 = "ApertureValue"
      0x9204 = "ExposureBiasValue"
      0x9209 = "Flash"
      0x920A = "FocalLength"
      0xA002 = "PixelXDimension"
      0xA003 = "PixelYDimension"
      0xA405 = "FocalLengthIn35mmFilm"
      0xA432 = "LensSpecification"
      0xA434 = "LensModel"
      0xA420 = "ImageUniqueID"
    }

Diese Liste kann natürlich beliebig erweitert werden, hier wurden nur die wichtigsten der gut 80 EXIF-Tags ausgewählt.

Die eigentliche Skript-Logik

Und dann kann es auch schon losgehen: $Image enthält den vollständigen Pfad zu einer Bilddatei, aus der wir ein System.Drawing.Image machen. Diese .NET-Klasse stellt uns die EXIF-Daten als PropertyItems zur Verfügung, über die wir iterieren und nach „unseren“ TagIDs suchen. Bei einem Treffer kommt unsere Get-ExifValue Funktion zum Einsatz, und wir schreiben den nun für Powershell lesbaren Wert in unsere Ergebnisdaten $exifData.

if (-not (Test-Path $Image)) {
      Write-Error "File not found: $Image"
      return
    }
    try {
      $imageObj = [System.Drawing.Image]::FromFile($Image)
      # Hashtabelle für EXIF-Daten
      $exifData = [ordered]@{}
      # Die EXIF-Daten sind PropertyItems des Image-Objekts
      foreach ($prop in $imageObj.PropertyItems) {
        # Konvertiere die numerische Property-ID in einen 4-stelligen Hexadezimal-String (z.B. 0x829A)
        $id = '{0:X4}' -f $prop.Id
        # Verwende einen sprechenden Namen aus $exifTags, falls verfügbar, sonst einen generischen Namen
        $name = $exifTags[$prop.Id] ? $exifTags[$prop.Id] : "Unknown_0x$id"
        # Dekodiere den Property-Wert mit der Hilfsfunktion, abhängig vom Typ
        $value = Get-ExifValue $prop
        # Speichere den Property-Namen und den dekodierten Wert in der Hashtabelle
        $exifData[$name] = $value
      }
      $imageObj.Dispose()
      # Gib alle EXIF-Daten als PowerShell-Objekt aus
      [PSCustomObject]$exifData
    }
    catch {
      Write-Error "Could not read EXIF data from $Image. $_"
    }

Weil Speicher begrenzt ist, wird am Ende noch das Image entsorgt (Dispose()), und weil Dateizugriffe kleine Zicken sind, liegt das Ganze in einem robusten Try...Catch Block.

Das fertige CmdLet im Ganzen

Und hier nochmal das ganze Skript verpackt in ein CmdLet Get-ExifData:

function Get-ExifData {
  [CmdletBinding()]
  param (
    [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [Alias("FullName")]
    [string]$Image
  )

  begin {
    $exifTags = @{
      0x0100 = "ImageWidth"
      0x0101 = "ImageHeight"
      0x010F = "Make"
      0x0110 = "Model"
      0x0112 = "Orientation"
      0x0132 = "DateTime"
      0x829A = "ExposureTime"
      0x829D = "FNumber"
      0x8833 = "ISOSpeed"
      0x9003 = "DateTimeOriginal"
      0x9201 = "ShutterSpeedValue"
      0x9202 = "ApertureValue"
      0x9204 = "ExposureBiasValue"
      0x9209 = "Flash"
      0x920A = "FocalLength"
      0xA002 = "PixelXDimension"
      0xA003 = "PixelYDimension"
      0xA405 = "FocalLengthIn35mmFilm"
      0xA432 = "LensSpecification"
      0xA434 = "LensModel"
      0xA420 = "ImageUniqueID"
    }

    function Convert-Rational {
      param ($bytes)
      $num = [BitConverter]::ToUInt32($bytes, 0)
      $den = [BitConverter]::ToUInt32($bytes, 4)
      if ($den -eq 0) { return $num }
      return [math]::Round($num / $den, 4)
    }
    
    function Get-ExifValue {
      param ($prop)
      switch ($prop.Type) {
        1 { return $prop.Value[0] } # BYTE
        2 { return ([System.Text.Encoding]::ASCII.GetString($prop.Value)).Trim([char]0) } # ASCII
        3 { return [BitConverter]::ToUInt16($prop.Value, 0) } # SHORT
        4 { return [BitConverter]::ToUInt32($prop.Value, 0) } # LONG
        5 { return Convert-Rational $prop.Value } # RATIONAL
        7 { return $prop.Value } # UNDEFINED
        9 { return [BitConverter]::ToInt32($prop.Value, 0) } # SLONG
        10 { return Convert-Rational $prop.Value } # SRATIONAL
        default { return $prop.Value }
      }
    }
  }
  process {
    if (-not (Test-Path $Image)) {
      Write-Error "File not found: $Image"
      return
    }
    try {
      $imageObj = [System.Drawing.Image]::FromFile($Image)
      # Hashtabelle für EXIF-Daten
      $exifData = [ordered]@{}
      # Die EXIF-Daten sind PropertyItems des Image-Objekts
      foreach ($prop in $imageObj.PropertyItems) {
        # Konvertiere die numerische Property-ID in einen 4-stelligen Hexadezimal-String (z.B. 0x829A)
        $id = '{0:X4}' -f $prop.Id
        # Verwende einen sprechenden Namen aus $exifTags, falls verfügbar, sonst einen generischen Namen
        $name = $exifTags[$prop.Id] ? $exifTags[$prop.Id] : "Unknown_0x$id"
        # Dekodiere den Property-Wert mit der Hilfsfunktion, abhängig vom Typ
        $value = Get-ExifValue $prop
        # Speichere den Property-Namen und den dekodierten Wert in der Hashtabelle
        $exifData[$name] = $value
      }
      $imageObj.Dispose()
      # Gib alle EXIF-Daten als PowerShell-Objekt aus
      [PSCustomObject]$exifData
    }
    catch {
      Write-Error "Could not read EXIF data from $Image. $_"
    }
  }
}

Export-ModuleMember -Function Get-ExifData

# Anwendung:
# Import-Module ".\Get-ExifData.psm1"
# Get-ExifData -Image "C:\Path\To\Your\image.jpg"
.NET C#
Stack und Heap in .NET/C#

Stack und Heap in .NET/C#

Selbst erfahrene C#-Entwickler kann man mit Fragen nach Stack und Heap ins Schwimmen bringen. Deshalb hier ein kurzer Refresher zu dem Thema.

Stack – der kleine, schnelle Speicher

Der Stack ist ein sehr schneller, linearer Speicherbereich, der vom Betriebssystem für jeden Thread bereitgestellt wird. Er wird für die Verwaltung von lokalen Variablen und Funktionsaufrufen genutzt. Hier speichert .NET/C# Werte von Werttypen (wie int, double, bool, structs), die als lokale Variablen in Methoden deklariert sind. Außerdem werden hier Funktionsaufrufe (Call Stack, Rücksprungadressen, lokale Variablen) abgelegt.
Speicher in diesem Bereich wird automatisch freigegeben, sobald eine Methode verlassen wird, aber das Betriebssystem begrenzt den Speicher standardmäßig auf 1 MB pro Thread (ASP.NET Core: 256KB), wenn der Entwickler nichts anderes angibt. Stack-Variablen existieren also nur so lange wie die entsprechende Methode aktiv ist.

Heap – der Objektspeicher

Der Heap ist ein Speicherbereich für die dynamische Verwaltung von Daten. Hier werden Referenztypen abgelegt, also Objekte und Arrays – und alle Daten, die über die Lebensdauer einer Methode hinaus existieren sollen. Theoretisch ist der Heap nur durch die Größe des RAM der Maschine begrenzt.
Die Speicherverwaltung erfolgt hier durch den Garbage Collector von .NET: Nicht mehr benötigte Objekte werden automatisch freigegeben, die Lebensdauer von Daten ist nicht an die Lebensdauer von Methoden gebunden.

Zusammenspiel von Stack und Heap

Nehmen wir an, wir erzeugen eine Objekt-Instanz:

Chicken huhn = new Chicken() { Name = "Hilde", Weight = 1250 };

Der Konstruktoraufruf new Chicken() reserviert im Heap einen Bereich für die Daten des Huhns. Die Variable huhn jedoch ist eine lokale Variable und liegt daher auf dem Stack. Aber: huhn enthält nicht die Daten des Objekts, sondern nur die Adresse (Referenz), wo die Instanz im Heap gespeichert ist. Das Gewicht des Tiers, obwohl ein Wertetyp (int, double,...), liegt ebenfalls im Heap, weil es Teil des Objekts Chicken ist! Noch einen Schritt weiter geht die Name-Property unsers Huhns, denn die Chicken-Instanz enthält in ihrem Heap-Bereich einen Zeiger(!) auf das eigentlich String-Objekt, das ebenfalls im Heap zu finden ist.
Kurz gesagt: Außer der Variable, die auf die Objekt-Instanz verweist, liegen bei Referenztypen alle Daten im Heap!
Und weil der Heap von der Garbage Collection verwaltet wird, ist in C# im Zweifel explizites Disposing bei komplexeren Objektstrukturen immanent wichtig.