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.
Header
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: 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:
- Ich habe bereits geschrieben, dass diese Vorgehensweise nicht standarisiert ist. Wenn der verwendete Compiler diese Direktive nicht unterstützt, dann funktioniert der Code nicht.
- 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.
- 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.
Formatspezifikation und Links
- Wikipedia
- Microsoft Docs: BITMAPFILEHEADER
- Microsoft Docs: BITMAPINFOHEADER
- Quellcode auf GitHub: GitHub Gist