Suchbild (Linux-Magazin, August 2008)

Wem das manuelle Erfassen der Referenzkarten zur Farbkorrektur eines digitalen Fotos im letzten Snapshot zu mühselig war, der wird sich dieses Mal an einem Skript erfreuen, das den Vorgang automatisiert.

Letzten Monat ging es darum, den von der Kamera erzeugten Farbstich eines Fotos mit Hilfe von ins Bild gehaltenen Referenzkarten (Abbildung 1) nachträglich zu korrigieren. Diese im Fotofachhandel erhältlichen Plastikkärtchen mit genormten Schwarz, Weiß- und Grauwerten sollten im Bild keinerlei Farbwerte erzeugen, und bieten daher drei Messpunkte für geringe, mittlere und hohe Lichtintensität, an denen man mit dem Fototool Gimp die Farbkurve eines Fotos korrigieren kann.

Abbildung 1: Es gilt, die Farbwerte der drei ins Bild gehaltenen Karten zu ermitteln. Hierzu führt das Skript die Farbwerte entlang der horizontalen Mittelinie ab.

Wie kann nun ein einfaches Perlskript ohne Einsatz von künstlicher Intelligenz herausfinden, welche Pixelwerte die drei Karten erzeugen, deren Lage im Bild nicht genau bekannt ist? Wenn man es schafft, die drei Karten so wie in Abbildung 1 gezeigt in der Bildmitte aufgefächert zu halten, kann ein Skript auf der (gedachten) horizontalen Mittellinie entlangwandern und die Karten anhand der Pixelwerte entlang der x-Achse ermitteln. Entlang der zu Illustrationszwecken eingezeichnete Linie bleibt die gemessene Lichtintensität relativ weite Strecken konstant, solange die Linie über einer Referenzkarte verweilt. Streift die Linie hingegen den den Bildhintergrund, schwanken die Pixelwerte relativ stark.

RGB: Dreimal 0 bis 255

Listing graphdraw erzeugt mit Hilfe des Imager-Moduls vom CPAN den in Abbildung 2 gezeigten Kurvenverlauf. Die drei Graphen bilden die Rot-, Grün- und Blauwerte entlang der in Abbildung 1 eingezeichneten horizontalen Linie in ein Koordinatensystem ab, dessen x-Achse den X-Koordinaten im Bild entspricht und dessen y-Wert den jeweiligen Farbanteil von 0 bis 255 repräsentiert.

Abbildung 2: Die Farbwerte des ungefilterten Bildes sind zu zittrig, um die Karten zuverlässig zu erkennen.

Abbildung 3: Der Blur-Filter mit der Einstellung "Gaussian Blur" und einem Radius von 5 Pixeln bringt Unschärfe und glättet die Wogen der Pixelwerte.

Abbildung 4: Das mit dem Blur-Filter unscharf gemachte Bild weist glattere Kurvenverläufe auf, an denen man die ins Bild gehaltenen Karten an den flachen Stellen erkennt.

Listing 1: graphdraw

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Imager;
    04 use Imager::Plot;
    05 use Log::Log4perl;
    06 
    07 my($file) = @ARGV;
    08 die "No file given" unless defined $file;
    09 
    10 my $img = Imager->new();
    11 $img->read( file => $file ) or 
    12   die $img->errstr();
    13 
    14 $img->filter(
    15   type   => "gaussian", 
    16   stddev => 10 ) or die $img->errstr;
    17 
    18 my $y     = int( $img->getheight() / 2 );
    19 my $width = $img->getwidth();
    20 
    21 my $data = {};
    22 
    23 for my $x (0..$width-1) {
    24   push @{ $data->{ x } }, $x;
    25 
    26   my $color = $img->getpixel( x => $x,
    27                               y => $y );
    28   my @components = $color->rgba();
    29   for my $color_name (qw(red green blue)) {
    30     push @{ $data->{ $color_name } },
    31          shift @components;
    32     }
    33 }
    34 
    35 my $plot = Imager::Plot->new(
    36   Width  => 550,
    37   Height => 350,
    38   GlobalFont =>
    39   '/usr/share/fonts/truetype/msttcorefonts/Verdana.ttf');
    40 
    41 for my $color_name (qw(red green blue)) {
    42   $plot->AddDataSet(
    43     X => $data->{x}, 
    44     Y => $data->{$color_name},
    45     style => { 
    46       marker => { 
    47         size   => 2,
    48         symbol => 'circle',
    49         color => Imager::Color->new($color_name),
    50       }
    51     }
    52   );
    53 }
    54 
    55 my $graph = Imager->new(
    56         xsize => 600, 
    57         ysize => 400);
    58 
    59 $graph->box(filled => 1, color => 'white');
    60 
    61     # Add text
    62 $plot->{'Ylabel'} = 'RGB Values';
    63 $plot->{'Xlabel'} = 'X-Pixel';
    64 $plot->{'Title'}  = 'RGB-Distribution';
    65 
    66 $plot->Render(
    67   Image => $graph,
    68   Xoff  => 40, 
    69   Yoff  => 370);
    70 
    71 $graph->write(file => "graph.png") or die $graph->errstr();

Multitalent

Die Methode read() des Imager-Moduls vom CPAN ist ein Multitalent, das alle gängigen Bildformate erkennt, einliest und in das interne Imager-Format zur zügigen Weiterverarbeitung umwandelt. Geht irgend etwas schief, liefern die Imager-Methoden falsche Werte zurück. Um mehr Details über einen aufgetretenen Fehler herauszufinden, ruft der sorgfältige Programmierer in derartigen Fällen die Methode errstr() auf, die eine Klartextbeschreibung des Fehlers zurückliefert.

Die Methode getpixel() untersucht die RGB-Werte eines per X- und Y-Koordinate festgelegten Pixels im Bild. Sie gibt ein Objekt vom Typ Imager::Color zurück. Dieses enthält die RGB-Werte des Pixels und gibt sie mit der Methode rgba() samt dem Wert des Alpha-Kanals preis. Es interessieren nur die ersten drei RGB-Werte, die das Skript mit shift in Zeile 31 extrahiert.

Das Modul Imager::Plot stellt öde Zahlenkolonnen in ansprechend gestalteten Koordinatensystem dar ohne dass hierfür viel Fitzelei mit Skalierung, Achsenbeschriftungen oder graphischem Layout notwendig wäre. Es liefert Bilddateien in allen gängigen Formaten, die der erfreute Nutzer anschließend mit einem Image-Viewer oder einem Webbrowser begutachtet. Der Konstruktor new() nimmt die gewünschten Dimensionen der Achsengrafik und den Pfad zu einem installierten True-Type Font für die Achsenbeschriftung entgegen.

Das Skript sammelt alle Koordinatendaten in einem Hash von Hashes, auf den die Referenz $data zeigt. Es legt alle X-Koordinaten in $data->{x} und alle Rot-Werte in $data->{red} ab. Analoges gilt für die Grün- und Blauwerte entlang der X-Achse. Die Methode AddDataSet bestimmt jeweils die Daten für einen der drei Graphen. Das Skript ruft sie dreimal auf, um die Daten für die drei Graphen in drei verschiedenen Farben einzupauken.

Zeile 55 erzeugt anschließend ein neues Imager-Objekt, das später die gewünschte Grafikdatei erzeugen wird. Erst füllt die Methode box() den Bildhintergrund weiß aus, dann malt Render() das Koordinatensystem, die Beschriftung und schließlich die drei Graphen in einem Rutsch.

Die Methode write() schreibt schließlich die Ausgabedatei im PNG-Format auf die Festplatte.

Unscharf ist sanfter

Bevor ein Skript die drei gesuchten Regionen in der Bildmitte fehlerfrei erkennen kann, sind noch einige Vorbereitungsschritte erforderlich. Aus Abbildung 2 wird deutlich, dass der Graph stark schwankt und deswegen die Erkennung der flachen Stellen erschwert. Das Erkennungsskript cardfind nutzt deswegen einen Blur-Filter, um das Bild mit dem Verfahren ``Gaussian Blur'' und dem Radius 10 unscharf zu machen. In einem unscharfen Bild (Abbildung 3) sind die Farbübergänge zwischen den einzelnen Pixeln weniger abrupt. Statt zum Beispiel direkt von einem weißen Pixel auf einen schwarzen zu springen, bringt eine unscharfes Bild mehrere Grautönen als Übergang. Entsprechend geglättet stellt sich der Graph in Abbildung 4 dar, der die Pixelwerte entlang der gleichen horizontalen Linie darstellt. Dies erleichtert die Erfassung der drei gesuchten Regionen.

In der Schule aufgepasst?

In diesen Bereichen verläuft die Kurve über hunderte von Pixeln weit recht flach dahin. Wer sich noch an die Schulmathematik erinnert, dem fällt vielleicht ein, dass die erste Ableitung eines solchen Graphen an flachen Stellen konstant und etwa gleich Null ist, während sie sonst deutlich höhere Werte aufweist und stark schwankt. Abbildung 5 zeigt die erste Ableitung der Intensitätswerte, die sich aus der Addition der Pixelwerte für den roten, den grünen und den blauen Kanal ergeben. Die aufgezeichneten Werte stellen ein Maß für die Schwankungen der ursprünglichen Kurve dar und bewegen sich über lange Strecken nahe des Nullpunkts. Dies sind die Stellen, an denen im ursprünglichen Bild die homogen belichteten Karten liegen. Das Skript also muss nur an diesem Graphen entlangwandern, einen Ringpuffer von etwa 50 durchwanderten Werten anlegen und Alarm schlagen, falls die dort liegenden Werte durchschnittlich etwa gleich Null sind. Dann befindet es sich über einer Karte.

Fangen in diesem Zustand die Pufferwerte plötzlich wieder zu holpern an, wurde der der Bereich einer Karte verlassen und das Skript geht wieder in den Zustand ``suche nach der nächsten homogenen Stelle'' über. So sollte es alle drei gesuchten Regionen finden und die gefundenen RGB-Werte im YAML-Format ausspucken. Damit kann das im letzten Snapshot vorgestellte Skript picfix die White-Balance weiterer Bilder mit denselben Lichtverhältnissen korrgieren. Wendet sich der Fotograf also einer neuen Szene zu, zieht er die drei Kärtchen aus der Hosentasche, hält sie ins Bild und knipst ein Referenzfoto. Alle anschließend geschossenen Fotos können dann später daheim mit Hilfe des Gimp und dem letztens vorgestellten picfix-Skript korrigiert werden.

Damit das Verfahren bei einem homogenen Bildhintergrund nicht ins Schleudern kommt, prüfen die Zeilen 48 bis 50 nicht nur ab, ob der Durchschnittswert im Puffer kleiner als 3 ist, sondern auch ob sich der Algorithmus gerade im mittleren Bilddrittel aufhält. Die äußeren Bilddrittel ignoriert es schlicht.

Abbildung 5: Die erste Ableitung des Intensitätsgraphen weist für die flachen (also: homogenen) Bildstellen Werte nahe Null auf.

Als Ringpuffer verwendet das Skript normale Perl-Arrays. Neue Werte hängt es mit push() hintenan und prüft anschließend, ob der Array damit die Maximallänge des Ringpuffers überschritten hat. Ist dies der Fall, löscht es das erste Arrayelement mit shift(). Anschließend ist der Array nicht nur um eins kürzer, sondern das zweite Element ist die neue Nummer eins.

Um die erste Ableitung der recht komplexen Pixelfunktion zu ermitteln, kommt ein vereinfachtes numerisches Verfahren zum Einsatz. Im Ringpuffer @intens_ring liegen die Intensitätswerte der letzten 50 Pixel, die durch Addition der Rot-, Grün- und Blauwerte an den bereits verarbeiteten x-Koordinaten entstanden sind. Zur Extraktion der drei Werte aus dem von der Methode rgba() zurückgegebenen 4-Teiler nutzt das ein sogenanntes Hash-Slice mit der Notation @components[0,1,2].

Der Wert der ersten Ableitung, also die Steigung des Graphen an dieser Stelle ermittelt sich anschließend näherungsweise aus der Differenz des ersten und des letzten Ringpufferelements. Positive oder negative Steigungsraten interessieren nicht, also egalisiert die Funktion abs() diese zu positiven Werten.

Um festzustellen, ob der Algorithmus sich gerade in einem der gesuchten flachen Teile der Kurve befindet oder in einem eher welligen Bereich, unterhält das Skript einen zweiten Ringpuffer @diff_ring, der die letzten 50 ermittelten Werte für die erste Ableitung des Graphen enthält. Die ab Zeile 76 definierte Funktion avg() rechnet den Mittelwert aller 50 Intensitätswerte aus. Ist der Algorithmus gerade in einem welligen Teil, reicht ein Mittelwert unterhalb der Schwelle von 3, damit ein flacher Teil erkannt wird. Einmal in diesem Modus angelangt ist allerdings eine mittlere Steigung von mehr als 10 erforderlich, damit der Zustandsautomat sich wieder in einem welligen Bereich wähnt.

Jedes Mal, wenn ein flacher Bereich erkannt wurde, speichert Zeile 52 die RGB-Werte des ersten dort gefundenen Pixels im Array @ctl_points. Nur drei flache Bereiche werden gesucht, etwaigen weiteren bereitet die last-Anweisung in Zeile 70 den Garaus. Die Funktion Dump aus dem YAML-Modul vom CPAN schließlich gibt das Ergebnis nach Abbildung 6 aus. In einer .yml-Datei sample.yml gespeichert und mit -c sample.yml an das im letzten Snapshot vorgestellte Skript picfix überreicht, und schon farbkorrigiert es nicht nur das Bild mit den abgebildeten Karten, sondern auch noch beliebig viele weitere, die in den gleichen Lichtverhältnissen geschossen wurden. Doch nicht vergessen: Die Karten müssen mittig ins Bild gehalten werden, damit der einfach Algorithmus sie findet. Sonst muss tatsächlich ein ausgefuchsteres Verfahren ran, aber wie immer sind in Perl mit der reichen Modulauswahl auf dem CPAN der Fantasie keine Grenzen gesetzt.

Abbildung 6: Das Skript cardfind nimmt den Namen einer Bilddatei entgegen, führt die Berechnungen eigenständig durch und gibt die Farbwerte der gesuchten Messwerte auf den Referenz-Karten im YAML-Format aus.

Listing 2: cardfind

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Imager;
    04 use YAML qw(Dump);
    05 
    06 my($file) = @ARGV;
    07 die "No file given" unless defined $file;
    08 
    09 my $img = Imager->new();
    10 $img->read(file => $file) or 
    11     die "Can't read $file";
    12 
    13   # Blur
    14 $img->filter(
    15     type   => "gaussian", 
    16     stddev => 10 ) or die $img->errstr;
    17 
    18 my $y     = int( $img->getheight() / 2 );
    19 my $width = $img->getwidth();
    20 
    21 my @intens_ring = ();
    22 my @diff_ring   = ();
    23 my $found       = 0;
    24 my @ctl_points  = ();
    25 
    26 for my $x (0..$width-1) {
    27   my $color = $img->getpixel( x => $x,
    28                               y => $y );
    29   my @components = $color->rgba();
    30 
    31     # Save current intensity in ring buffer
    32   my $intens = @components[0,1,2];
    33   push @intens_ring, $intens;
    34   shift @intens_ring if @intens_ring > 50;
    35 
    36     # Store slope between x and x-50
    37   push @diff_ring, 
    38        abs($intens - $intens_ring[0]);
    39   shift @diff_ring if @diff_ring > 50;
    40 
    41   if($found) {
    42       # Inside flat region
    43     if(avg(\@diff_ring) > 10) {
    44       $found = 0;
    45     }
    46   } else {
    47       # Outside flat region
    48     if($x > $width/3 and 
    49        $x < 2/3*$width and
    50        avg(\@diff_ring) < 3) {
    51       $found = 1;
    52       push @ctl_points, 
    53            [@components[0,1,2]];
    54     }
    55   }
    56 }
    57 
    58 my $out = {};
    59 my @labels = qw(low medium high);
    60 
    61   # Sort by intensity
    62 for my $ctl_point (sort { 
    63         $a->[0] + $a->[1] + $a->[2] <=> 
    64         $b->[0] + $b->[1] + $b->[2] } 
    65         @ctl_points) {
    66   my $label = shift @labels;
    67   $out->{$label}->{red}  = $ctl_point->[0];
    68   $out->{$label}->{green}= $ctl_point->[1];
    69   $out->{$label}->{blue} = $ctl_point->[2];
    70   last unless @labels;
    71 }
    72 
    73 print Dump($out);
    74 
    75 ###########################################
    76 sub avg {
    77 ###########################################
    78     my($arr) = @_;
    79 
    80     my $sum = 0;
    81     $sum += $_ for @$arr;
    82     return $sum/@$arr;
    83 }

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2008/08/Perl

[2]
``<Juli-Snapshot-Titel>'', Michael Schilli, Linux Magazin 07/2008 http://www.linux-magazin.de/Artikel/ausgabe/2008/07/perl/perl.html

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.