NHollmann

English | German

Hi, ich bin Nicolas Hollmann und das ist meine Seite und Blog.
Viel Spaß beim Lesen! 📖

Bildformate schreiben: Windows Bitmap

April 03, 202111 Min. Lesedauer
Tags:

Bildformate schreiben: Windows Bitmap

Im letzten Post haben wir uns das sehr einfache Portable Anymap Format zum Schreiben von Bilddateien angeschaut. Allerdings gibt es ein Problem mit Portable Anymaps: sie sind nicht besonders verbreitet. Unter macOS und den meisten Linux-Desktops werden sie sogar mit Thumbnail angezeigt, aber unter Windows gibt es garkeine standardmäßige Unterstützung. Außerdem zeigen die meisten Webbrowser sie auch nicht an. Außerhalb der Softwareentwicklung ist das Format allgemein eher unüblich. In diesem Post schauen wir uns ein viel verbreiteteres Bildformat an: Windows Bitmaps.

Windows Bitmaps mit ihrer ikonischen Dateiendung .bmp werden von fast jeder Software, die Bilder verarbeitet, verstanden. Darüber hinaus sind sie nur bedingt komplizierter als Portable Anymaps und damit leicht zu schreiben und zu lesen. Auf der anderen Seite haben BMPs eine Menge Einschränkungen die ihren Nutzen limitieren. Eine Begrenzung ist, dass BMPs keine Transparenz speichern können. Ein weiteres Problem ist die Größe von BMPs, da sie für gewöhnlich unkomprimierte Bilddaten speichern.

Das Bitmap Dateiformat

Wir werden uns nicht jedes Detail des Bitmap-Dateiformats anschauen, sondern uns nur auf die wichtigen Features und Eigenschaften konzentrieren, um selbst unkomprimierte Bitmaps schnell und einfach zu schreiben. Für einen tieferen Einblick in das Format empfehle ich den englischen Wikipedia Artikel, der zum aktuellen Zeitpunkt einen guten Einstieg in das BMP Format bietet.

Das Bitmap-Bildformat ist ein vollständig binäres Dateiformat. Das ist keineswegs schlecht, sondern reduziert im verlgiech zu einer ASCII Darstellung die Dateigröße. Auf der anderen Seite kann es dadurch von Menschen nicht direkt gelesen werden, was allerdings insbesondere bei Bilddaten auch textuell schwierig ist. Darüber hinaus ist ein Binärformat keine schwarze Magie. Wir müssen nur eine Sache beachten: Die Byte Reihenfolge. Ich habe bereits einen Artikel zu diesem Thema geschrieben. Windows Bitmaps speichern immer alle Multibyte-Daten als Little-Endian.

Eine Bitmap fängt mit zwei Headern an, einer der die Datei selbst beschreibt (der sogenannte BITMAPFILEHEADER) und einer der die gespeicherten Daten beschreibt (der BITMAPINFOHEADER). Es werden immer beide benötigt, sodass es technisch keine Rolle spielt, ob es nun zwei kleine Header oder einen großen gibt, allerdings behalte ich hier den offiziellen Aufbau aus der Dokumentation bei.

In den nächsten beiden Unterkapiteln stelle ich die Attribute dieser beiden Header vor. Auf den ersten Blick können diese Strukturen überwältigend wirken. Allerdings täuscht dies. Viele Attribute haben konstante Werte oder können leicht berechnet werden. Bei Unklarheiten hilft hoffentlich das Code-Beispiel.

Ein letztes Wort zu den Datentyp-Informationen in diesen Tabellen. Diese Typen stammen aus der Microsoft Dokumentation und sind typisch in Windows Code. In wirklichkeit sind es nur andere Namen für gewöhnliche Datentypen. Um Bitmaps zu schreiben muss man nur wissen, welche Datentypen sie repräsentieren. Hier ist eine kurze Tabelle:

Typ Beschreibung
WORD 2 byte / 16 bit unsigned integer
DWORD 4 byte / 32 bit unsigned integer, DWORD steht für DOUBLE WORD
LONG 4 byte / 32 bit signed integer
BITMAPFILEHEADER

Dieser Header steht ganz am Anfang einer Bitmap-Datei.

Name Datentyp Größe Kurzbeschreibung
bfType WORD 2 bytes Sind immer "BM"
bfSize DWORD 4 bytes Die Dateigröße in bytes
bfReserved1 WORD 2 bytes Reserviert und unbenutzt. Muss 0 sein (1)
bfReserved2 WORD 2 bytes Reserviert und unbenutzt. Muss 0 sein (1)
bfOffBits DWORD 4 bytes Offset zu den Bilddaten vom Begin der Datei aus

(1) Einige Programme nutzen diesen Wert trotzdem.

Diese Namen sind direkt aus der Microsoft Bitmap Dokumentation. Sie ist verlinkt in der Formatspezifikation und Links Sektion.

BITMAPINFOHEADER

Direkt nach dem BITMAPFILEHEADER folgt dieser Header.

Name Datentyp Größe Kurzbeschreibung
biSize DWORD 4 bytes Größe dieses Headers, hier kann die Konstante 40 verwendet werden
biWidth LONG 4 bytes Breite des Bildes
biHeight LONG 4 bytes Höhe des Bildes. Dieser Wert kann negativ sein. Dazu komme ich später
biPlanes WORD 2 bytes wird nicht mehr verwendet, muss 1 sein
biBitCount WORD 2 bytes Bits pro Pixel, kann 1, 4, 8, 16, 24 oder 32 sein
biCompression DWORD 4 bytes Die verwendete Kompression. 0, 1, 2 oder 3
biSizeImage DWORD 4 bytes Die Größe der Bilddaten in Byte, ohne Kompression reicht der Wert 0
biXPelsPerMeter LONG 4 bytes Horizontale Auflösung in Pixel pro Meter, wird meistens auf 0 gesetzt
biYPelsPerMeter LONG 4 bytes Vertikale Auflösung in Pixel pro Meter, wird meistens auf 0 gesetzt
biClrUsed DWORD 4 bytes Ohne Farbtabelle kann der Wert auf 0 gesetzt werden
biClrImportant DWORD 4 bytes Ohne Farbtabelle kann der Wert auf 0 gesetzt werden

Einige der Attribute in diesem Header müssen wir uns genauer anschauen. Fangen wir mit dem merkwürdigstem an: Die Höhe kann negativ sein. Wenn die Höhe positiv ist, wird erwartet, dass die Bilddaten von unten nach oben geschrieben wurden. Ich weiß nicht, warum man sich für diese Vorgehensweise entschieden hat, aber offenbar ist das die Richtung, mit der die meisten Programme Bitmaps speichern. Einige der Kompressionsverfahren sind auch nur verfügbar, wenn die Bitmap von unten nach oben geschrieben wird. Wenn man aber eine negative Höhe angibt, dann wird erwartet, dass die Bilddaten in der IMHO mehr natürlichen Richtung von oben nach unten geschrieben wurden.

Nun schauen wir uns den biBitCount an. Dieser Wert beschreibt, wieviele Bits pro Pixel verwendet werden. Die Werte 1, 4 und 8 sind für indizierte Farben. Das heißt, wir definieren eine Farbtabelle, in der alle Farben stehen, die im Bild vorkommen können. Dann speichern wir in den eigentlichen Bilddaten nurnoch den Index einer Farbe in der Tabelle ab. In diesem Post werden wir uns aber noch nicht die Vor- und Nachteile dieses Ansatzes anschauen sondern ausschließlich mit 24 Bit pro Pixel arbeiten. Das ist der einfachste Weg Bilder zu schreiben, da jeder Farbkanal (Rot, Grün und Blau) jeweils ein volles Byte pro Pixel erhalten (3 * 8 Bit).

Daten

Kommen wir nun zum Besten: Da wir die Bitmaps unkomprimiert speichern können wir unsere Pixeldaten einfach rauschschreiben. Ein Pixel der nur 0-bytes enthält steht für Schwarz und nur 255-bytes für Weiß. Allerdings muss man zwei Sachen beachten: Bitmaps speichern Pixel in BGR (Blau Grün Rot) und nicht in RGB (Rot Grün Blau). Heutzutage werden Pixel meistens in RGB Reigenfolge abgespeichert. Deshalb muss beim Schreiben die Reihenfolge invertiert werden.

Kommen wir nun zur zweiten Sache. Dabei handelt es sich um die bereits beschrieben Reihenfolge der Zeilen, also standardmäßig von unten nach oben. Wenn die Bilddaten bereits in dieser Reihenfolge vorliegen, können sie direkt in die Datei geschrieben werden. Aber wenn sie von in der Reihenfolge von oben nach unten vorliegen müssen sie entweder beim Schreiben umgekehrt werden, oder im Vorraus einmal vertikal gespiegelt werden. Sie im Vorraus zu spiegeln könnte einige Performance Vorteile mit sich bringen, da so ggf. die Prozessor Cachlines vielleicht besser genutzt werden könnten. Hierzu habe ich aber keine Benchmarks durchgeführt. Und in diesem Artikel versuche ich die Vorgehensweise so einfach wie möglich darzustellen, weswegen wir die Daten direkt beim schreiben umdrehen. Alternativ kann man natürlich auch mit einer negativen Höhe arbeiten, allerdings ist dies wie bereits beschrieben eher unüblich.

Implementierung

Wie auch schon bei Portable Anymaps demonstriere ich das Schreiben einer Windows Bitmap mit einem C Programm.

#include <stdio.h>
#include <stdint.h>

#define WIDTH 300
#define HEIGHT 200

// Die folgende Zeile verändert das Padding-Verhalten
// des Compilers. Ich gehe darauf später ein.
#pragma pack(push, 1)

typedef struct {
    uint16_t bfType;
    uint32_t bfSize;
    uint16_t bfReserved1;
    uint16_t bfReserved2;
    uint32_t bfOffBits;
} BitmapFileHeader;

typedef struct {
    uint32_t biSize;
    int32_t biWidth;
    int32_t biHeight;
    uint16_t biPlanes;
    uint16_t biBitCount;
    uint32_t biCompression;
    uint32_t biSizeImage;
    int32_t biXPelsPerMeter;
    int32_t biYPelsPerMeter;
    uint32_t biClrUsed;
    uint32_t biClrImportant;
} BitmapInfoHeader;

// Das stellt das Padding-Verhalten wieder her.
#pragma pack(pop)

int main()
{
    FILE* outfile = fopen("out.bmp", "wb");

    // Beide Header haben eine gemeinsame Größe von 54 Bytes.
    // Wir brauchen WIDTH * HEIGHT Pixel und jeder braucht 
    // drei Bytes (eins für jeden Kanal).
    uint32_t filesize = 54 + WIDTH * HEIGHT * 3;

    // Als erstes füllen wir den Datei-Header
    BitmapFileHeader fileHeader;
    fileHeader.bfType = 'B' | 'M' << 8; // Aufgrund von Little Endian umgekehrt
    fileHeader.bfSize = filesize;
    fileHeader.bfReserved1 = 0;
    fileHeader.bfReserved2 = 0;
    fileHeader.bfOffBits = 54; // Beide Header sind 54 Bytes groß

    // Als nächstes füllen wir den Info-Header
    BitmapInfoHeader infoHeader;
    infoHeader.biSize = 40; // Dieser Header ist 40 Bytes lang
    infoHeader.biWidth = WIDTH;
    infoHeader.biHeight = HEIGHT;
    infoHeader.biPlanes = 1; // Muss 1 sein
    infoHeader.biBitCount = 24; // 24 bit pro Pixel = 1 Byte pro Kanal
    infoHeader.biCompression = 0; // Keine Kompression
    infoHeader.biSizeImage = 0; // 0 weil wir keine Kompression nutzen
    infoHeader.biXPelsPerMeter = 0;
    infoHeader.biYPelsPerMeter = 0;
    infoHeader.biClrUsed = 0;
    infoHeader.biClrImportant = 0;

    // Dann schreiben wir beide Header in die Datei.
    // Dies funktioniert nur aufgrund des veränderten Padding-
    // Verhaltens und ignoriert die Endianess. Normalerweise
    // sollte dies NICHT so umgesetzt werden, allerdings vereinfacht
    // diese Vorgehensweise das Beispiel.
    fwrite(&fileHeader, sizeof(BitmapFileHeader), 1, outfile);
    fwrite(&infoHeader, sizeof(BitmapInfoHeader), 1, outfile);

    // Wir schreiben das Bild von unten nach oben.
    // In diesem Beispiel werden die Bilddaten prozedual erstellt.
    // Insofern ist es nicht nötig die Schleife rückwärts zu durchlaufen.
    // Aber wenn man die Bilddaten aus einem Array lesen würde, könnte
    // man die folgenden Schleifen nutzen:
    for (int y = HEIGHT - 1; y >= 0; y--)
    {
        for (int x = 0; x < WIDTH; x++)
        {
            // Bilddatengenerierung:
            int r = (x / (float)WIDTH) * 255;
            int g = (y / (float)WIDTH) * 255;
            int b = (y / (float)HEIGHT) * 255;

            // Wir schreiben hier einen einzelnen Pixel in BGR Reihenfolge
            unsigned char colors[3] = {b, g, r};
            fwrite(colors, 3, 1, outfile);
        }
    }

    fclose(outfile);
    return 0;
}

Das Beispiel erzeugt das folgende Bild: Bitmap Beispiel Tatsächlich habe ich die originale BMP Datei zu einer PNG Datei konvertiert. Dadurch sinkt die Dateigröße und außerdem ist meine Seite nicht dafür konfiguriert BMP Dateien auszuliefern.

Wie vielleicht aufgefallen ist, verwende ich hier Typen wie uint16_t aus stdint.h anstatt von WORD, DWORD und LONG. Dadurch wird der Code mehr portable, da die anderen Typen windowsspeziefisch sind. Wenn windows.h sowieso schon verwendet wird, können aber auch einfach die Windows-Typen verwendet werden. Tatsächlich können dann auch direkt die BITMAPFILEHEADER und BITMAPINFOHEADER structs genutzt werden.

Da dies nur ein Beispiel ist, habe ich hier ein Bild einfach prozedual erzeugt. Und in diesem Fall spielt die Orientierung des Bilder keine Rolle. Normalerweise würde sie aber eine Rolle spielen, deshalb habe ich zur demonstration die Y-Schleife von unten nach oben laufen lassen, als ob es sich um echte Bilddaten handeln würde. Ich hoffe das verdeutlicht den Schreibvorgang.

Zum Schluss muss ich nochmal über Padding, Packed Structs und warum dieses Beispiel nicht sehr portabel ist schreiben. Vielleicht ist bereits die #pragma pack(...) Direktive im Beispiel aufgefallen. Sie existiert, da C structs ein sogenanntes Padding haben können. Um es einfach zu beschreiben, ein Padding wird eingebaut damit die Attribute um Speicher an Adressen auszurichten, die durch einen gewissen Faktor geteilt werden können (engl. Alignment). Zum Beispiel wird ein 2 Byte uint16t an gerade Adressen ausgerichtet während ein 4 Byte uint32t an einer Adresse die durch 4 Teilbar ist ausgerichtet. Wenn ein 2 Byte Attribut in einem Struct vor einem 4 Byte Attribut steht, dann werden 2 Byte Padding eingeführt, damit das zweite Attribut korrekt ausgerichtet ist. Genau das passiert in dem BITMAPFILEHEADER Header im Beispielcode zwischen bfType und bfSize. Das führt dazu, dass das Struct eine Größe von 16 Byte anstatt von 14 Byte hat. Das korrumpiert unseren Header, sodass ein damit geschriebenes Bild nicht gelesen werden kann. Padding ist normalerweise für die Performance eines Programmes sinnvoll, da dadurch Speicherzugriffe kürzer sind. Aber wenn wir ein Struct direkt aus einer Datei lesen oder schreiben wollen, führt dies zu Problemen.

Die Lösung für das Problem ist, ein Packed Struct zu verwenden. Ein Packed Struct enthält kein Padding. Deshalb kann es ohne Probleme direkt geschrieben werden. Hier kommt aber das erste Problem: Es gibt keinen standarisierten Weg ein Struct als Packed zu deklarieren. Ich habe mich für die #pragma pack(...) Direktive entschieden, da sie scheinbar von vielen Compilern unterstützt wird. Das #pragma pack(push, 1) speichert die aktuelle Padding-Einstellung auf einem Stack und setzt sie danach direkt auf 1, was bedeutet, das wir Padding komplett deaktivieren. Das #pragma pack(pop) stellt den alten Zustand wieder her.

Es gibt mehrere Probleme mit dieser Lösung:

  1. Ich habe bereits geschrieben, dass diese Vorgehensweise nicht standarisiert ist. Wenn der verwendete Compiler diese Direktive nicht unterstützt, dann funktioniert der Code nicht.
  2. Attribute eines Structs abzurufen, die nicht Aligned sind, ist langsamer. Das ist nicht so wichtig, wenn das Struct nur verwendet wird um ein Bild zu speichern. Aber wenn das selbe Struct auch für einige Berechnungen verwendet wird kann dies der Performance schaden.
  3. Außerdem ist die Lösung auch ein gewisse Architekturen wie x86 gekoppelt.

Die x86 Architektur ist sehr verbreitet in PCs, aber es ist bei weitem nicht die einzige Architektur. Padding und Alignment sind abhängig von der verwendeten Architektur. Einige Architekturen fordern zum Beispiel ein gewisses Alignment um überhaupt auf Attribute zuzugreifen. Außerdem haben wir die Endianess komplett ignoriert, da x86 wie das BMP Format Little Endian verwendet. Aber auf Big Endian Systemen müssten alle Mulibyte-Attribute geswappt werden.

Durch die Lösung, die ich im Beispiel verwendet habe, blieb dieses kurz und leicht verständlich. Die Lösung ist auch OK für kleine Experimente oder Demos. Aber für alles, das auch nur ein wenig wichtiger ist, sollte eine andere Vorgehensweise gewählt werden. In den meisten Fällen ist es besser, jedes Attribut eines Structs einzelnd zu schreiben (aber am besten mit einem IO Buffer). Auf diese Weise entsteht zwar viel repetativer Code, allerdings muss man sich so keine Gedanken über Padding und Alignment machen. Außerdem kann (z.B. über ein Makro) für jedes Feld auch die Endianness bei Bedarf korrigiert werden.



© 2022, Nicolas Hollmann