Montag, 29. Juni 2015

Probleme bei der Bearbeitung von Verschlagwortungsmasken in ELO Enterprise

Die Lösung eines einfachen Problems hat jetzt so viel Zeit gekostet, dass eine Sofort-Dokumentation erforderlich ist.

Was habe ich gemacht?


Ganz einfach: Ich habe eine neue Verschlagwortungsmaske über den ELO Windows Client angelegt. Das hat grundsätzlich alles funktioniert und ich konnte die Maske auch verwenden.

Wo entstand das Problem?


In der betroffenen ELO Installation exisitieren eine Reihe von Skripten und Zusatztools, die diverse Aufgaben erledigen.
Aus zunächst völlig unerklärlichen Gründen gab es bei Dokumenten mit der neuen Maske Schwierigkeiten. Diese äußerten sich immer, wenn mit dem ELO IndexServer API (eloix) auf die Verschlagwortungsfelder zugegriffen werden sollte:


  1. Auf die mit Daten belegten Felder konnte auch über eloIX zugegriffen werden
  2. Die leeren Felder waren allerdings nicht im ObjKeys-Array enthalten, so dass die Elementanzahl in diesem Array je nach Anzahl der belegten Felder schwankte.
    Da einige Zusatztools über die Feldnummer auf bestimmte Indexfelder zugreifen, kommt es dabei natürlich zu Problemen.

Ich konnte mir das zunächst absolut nicht erklären. Ich habe dann einen Blick in die ELO Administration Konsole geworfen und da fehlte die neue Maske.

Der ELO Tomcat Server cached also die Maskendefinitionen und der ELO Windows Client führt Änderungen der Maskendefinition nicht über den IndexServer aus sondern direkt über seinen ODBC-Link.

Ein Neustart des ELO Tomcats schaffte das Problem aus der Welt. Danach enthielt das ObjKeys Array auch bei Dokumenten der neuen Maske für jedes Indexfeld ein Element - auch für die nicht belegten Indexfelder.

Sonntag, 28. Juni 2015

Logarithmische Skala - anschaulich erklärt

Zwischendurch mal ein weitestgehend sinnfreier Beitrag zur Auflockerung. Ich habe mir vor knapp 2 Jahren ein neues Mountain Bike gekauft und fahre seitdem relativ viel damit.
Natürlich habe ich einen Tachometer am Rad und verfolge, wie die gefahrenen Kilometer mehr werden.
So ein bisschen arbeitet man beim Radfahren immer an gewissen Kilometer Marken - ich zumindest. Die ersten 100 sind schnell voll.
Es geht dann zügig weiter und die 1000-Kilometer-Marke kommt in Reichweite. Nach 2 Jahren kommen nun die 10.000 Kilometer in den Zielfokus. Das hat gedauert.
Diese Marken folgen einer logarithmischen Skala und da wir Menschen im Dezimal-Zahlensystem denken ist die Basis der dekadische Logarithmus (Basis 10):


10er Exponent   Kilometer
0  (10 hoch 0)         1
1  (10 hoch 1)        10
2  (10 hoch 2)       100
3  (10 hoch 3)      1000
4  (10 hoch 4)     10000
5  (10 hoch 5)    100000
usw.

Die Kilometer Meilensteine anhand der belegten Tachostellen folgen also einer logarithmischen 10er-Skala (0,1,2,3 usw.). Und es liegt in der Natur der Sache, dass es zunehmend aufwändiger wird, einen weiteren Wert auf der Skala zu schaffen.
Während ich von der 0 zur 1 nur 9 km fahren musste, waren es von der 1 zur 2 schon 90 und von der 2 zur 3 dann 900 km.
Um meinen aktuellen Meilenstein, die 10000 km (also die 4 auf der Logarithmus Skala) zu erreichen, muss ich seit der 3  9000 km zurücklegen.

Die 4 auf der Logarithmus Skala habe ich in spätestens 2 Monaten. Und die 5? Tja, bei jetzt aufgerundet 10.000 km in 2 Jahren müsste ich 18 Jahre in dieser Intensität weiterradeln, um die 90.000 km zur 5 (also 100.000 km) zu schaffen. Ich bin jetzt 35 - also bis zum Alter von 53 Jahren weiterradeln und vor allem nicht nachlassen!

Die 100.000 km sind also möglicherweise noch drin. Wie viel Jahre ich für die 6 - also 1 Million Kilometer - radeln müsste ist die Hausaufgabe.

Logarithmische Skala im Alltag:


Dezibel (db): Basis dieser logarithmischen Skala ist ebenfalls 10. Daher ist auch klar, warum zwischen 40 und 50db absolut gesehen ein wesentlich kleinerer Unterschied besteht als zwischen 50 und 60db.

Richterskale (Erdbeben): Basis 10

pH-Wert: Basis dieser logarithmischen Skala ist ebenfalls 10. Der pH-Wert gibt an, wie sauer bzw. basisch eine chemische Lösung ist. pH = 7 ist neutral, d.h. weder basisch noch sauer. pH < 7 ist sauer, pH > 7 basisch.
Da es sich um eine logarithmische Skala handelt verhält sich auch hier wie bei den Fahhrad Meilensteinen:
Während die Werte zwischen 7 und 2,5 von allerhand säuerlichen Lebensmitteln erreicht werden (Kaffee = 5, Wein = 4, Zitronensaft = 2,4), liegt Magensäure zwischen 1 und 2. Zwischen 0 und 1 liegen dann sämtliche harten Sachen, wie z.B. Batteriesäure.


Samstag, 27. Juni 2015

Aufbewahrung von Versionen mit rsync

Für die Sicherung meiner Daten nutze ich rsync. Ich habe dazu 2 Skripte:

  1. rsync aller wichtigen Datenverzeichnisse von meinem Notebook per SSH auf einen Server, der bei mir zu Hause steht. Diese Sicherung führe ich häufig durch.
  2. Der Server verfügt über 2 identische Platten, von denen jedoch normalerweise nur eine eingebaut ist. Die andere liegt an einem sicheren Ort. Hin und wieder hole ich die zweite Platte, stecke diese ein und führe dann mit rsync einen Abgleich von der ersten auf die zweite Platte durch. Danach geht die Platte wieder zurück auf den Lagerplatz.

Falls hier wirklich mal die ganz Bude abbrennen sollte kann ich mit der Offline-Platte zumindest wieder an einem definierten Punkt beginnen. Ich hoffe natürlich, dass ich die nie brauche.

Seit ich mein eigenes Document Management System einsetze und den überwiegenden Teil der Papierdokumente vernichte hat sich mein Anspruch an die Datensicherung etwas erhöht. Bisher löscht rsync gelöschte Dateien auch in der Sicherung (Option --delete). Geänderte Dateien werden in der Sicherung einfach überschrieben.
Ich wollte nun eine minimale Versionierung in der Sicherung haben.

GIT


Ich habe erst einen Versuch mit GIT gemacht. Das Erstellen eines lokalen Repositories ist ganz einfach und das initiale Hinzufügen der bereits vorhandenen Dateien ebenfalls. Danach wird es aber schwieriger:

  1. Neu hinzugekommene Dateien müssen mit git add XYZ in das Repository aufgenommen werden
  2. Gelöschte Dateien müssen mit git rm XYZ aus dem Repository entfernt werden
  3. Zum Festschreiben aller Änderungen muss außerdem ein git commit aufgerufen werden.

Die ersten beiden Punkte bedeuten Zusatzaufwand für jede Datei und das ist beim Umgang mit Dokumenten nicht praktikabel.

rsync --backup und --backup-dir


Ich bin dann auf zwei rsync Optionen gestossen:

  1. --backup: Weist rsync an, Sicherungskopien geänderter und gelöschter Dateien zu erstellen
  2. --backup-dir: Gibt an in welchem Verzeichnis die Kopien abgelegt werden. Das Verzeichnis ist relativ zum Zielpfad

Ich habe mein rsync Skrip entsprechend angepasst:


# Datum ermitteln fuer backup change Verzeichnis
DATE=`date +%Y_%m_%d-%H_%M_%S`
REVISION_DIR="revisions_$DATE"

# rsync starten (mit --backup und --backup-dir)
rsync -e ssh -avzP --delete --backup --backup-dir=$REVISION_DIR /home/peter/DocumentManagement/archiv peter@parmesan:/data/disk1/home/peter/archiv_backup/


Zunächst wird ein Verzeichnisname für das BACKUP-DIR erzeugt. Über date +%Y_%m_%d-%H_%M_%S wird dazu Datum+Uhrzeit in eine Variabel geschrieben. REVISION_DIR enthält den fertigen Verzeichnisnamen.
Das Listing des Sicherungsverzeichnis auf dem Server sieht so aus:

peter@parmesan:~/archiv_backup$ ls -1
archiv
revisions_2015_06_27-22_05_28
revisions_2015_06_27-22_06_35

Im Unterverzeichnis archiv befindet sich der aktuell gesicherte Stand. Die revisions_... Unterverzeichnisse enthalten jeweils geänderte und gelöschte Dateien des jeweiligen Sicherungslaufs. rsync baut auch in den BACKUP-DIRs die gesamte Verzeichnisstruktur bis zu den entsprechenden Files auf.

FAZIT: Über die rsync Optionen --backup und --backup-dir konnte ich meine Sicherung so anpassen, dass generell keine Dateien überschrieben oder gelöscht werden.
Für jeden Sicherungslauf wird ein Backup-Verzeichnis erstellt und dort werden die Ursprungsversionen aller veränderten und gelöschten Dateien gesichert.

Dienstag, 16. Juni 2015

WSUS: Voll gelaufene SQLExpress verkleinern

Anderer Kunde ähnliche Probleme. Der WSUS läuft hier auf einem separaten Windows System was die gefühlte Performance gegenüber einer SBS Installation schonmal um Größenordnungen erhöht.

Ich muss tätig werden, da der WSUS offenbar keine Updates mehr anbietet. Ein Blick in die Verwaltungskonsole offenbart, dass die Synchronisierungen seit längerem fehlschlagen. Der Fehler ist wie immer nichtssagend.

Festplattenplatz ist genügend frei. Ich starte zunächst das System durch und starte dann eine manuelle Synchronisierung. Diese läuft und läuft und läuft und steht bei 14%.

Das wird nichts mehr. Ich schaue die Windows Protokolle durch und dort findet sich nun eine Spur:

Speicherplatz für das 'dbo.tbEventInstance'.'PK__tbEventInstance__2C3E80C8'-Objekt in der 'SUSDB'-Datenbank konnte nicht zugeordnet werden, da die Dateigruppe 'PRIMARY' voll ist. Speicherplatz kann durch Löschen nicht benötigter Dateien, Löschen von Objekten in der Dateigruppe, Hinzufügen von Dateien zur Dateigruppe oder Festlegen der automatischen Vergrößerung für vorhandene Dateien in der Dateigruppe gewonnen werden.

Für Zugriff auf SUSDB über Management Studio folgende Verbindung manuell eingeben:

\\.\pipe\mssql$microsoft##ssee\sql\query

Über das SQL Server Management Studio Express schaue ich mir die Datendateieinstellungen für die SUSDB an. Es ist Autoextend eingestellt. Ein Versuch, die Datendatei manuell zu vergrößern schlägt fehl:

Der Fehler lautete sinngemäß, dass die Datenbank bereits die maximal lizensierte Größe von 4096MB hat und nicht vergrößert werden kann. Die hier installierte SQL Server Express Edition kann also nicht mehr. Super.

Man kann über das Management Studio unter SUSDB => Tasks => Verkleinern => Datenbank versuchen, die Datenbank zu verkleinern. Im Dialog wird angezeigt wie viel Speicher frei gegeben werden kann. In meinem Fall leider 0%.
Nach weiterer Recherche habe ich einfach mal den Bereinigungsassistent des WSUS gestartet. Beim vorherigen WSUS (anderer Kunde mit SBS) hat der nichts geschafft.

Hier sieht es nun anders aus. Der Assistent läuft zwar scheinbar wieder endlos aber im Hintergrund tut sich auch was. Ich habe ab und zu mal im Management Studio unter SUSDB => Tasks => Verkleinern => Datenbank geschaut und der freizugebene Speicher wurde langsam größer. Natürlich habe ich die Verkleinerung noch nicht gestartet, da der WSUS ja auf der DB arbeitet.
Aktuell werden bereits 100MB (2%) angezeigt. Der Assistent läuft jetzt ca. 20 Minuten und läuft weiter. Ich hoffe, dass er über Nacht noch ein paar 100MB schafft.

Morgen geht es weiter ...



Montag, 1. Juni 2015

Zahlen in Explosionszeichnungen erkennen mit OpenCV Java

Einleitung


In Vorbereitung auf ein möglicherweise kommendes Projekt aber wohl in erster Linie aus technischem Interesse habe ich mich mit der Erkennung von Ziffern in Ersatzteil Zeichnungen beschäftigt.
Ziel ist es, die in der Grafik enthaltenen Ziffern inklusive Koordinaten zu erkennen, um damit eine interaktive Darstellung der Grafik zu implementieren.

Im ersten Versuch habe ich ein paar freie OCR Tools auf die Bilder angesetzt. Die Ergebnisse waren schlecht. Dies hatte verschiedene Gründe:

  1. Bescheidene Qualität des Bildmaterials
  2. OCR-Tools sind auf Fließtext optimiert und nicht auf Grafiken, in denen an verschiedenen Stellen nur vereinzelte Zahlen stehen

Ein Bekannter hat mir dann von OpenCV erzählt. Dabei handelt es sich um eine Bibliothek mit Funktionen und Algorithmen zur Bildverarbeitung, Objekterkennung usw.
Es ist eine C-Bibliothek mit Schnittstellen für C, C++, Python und Java. Ich habe das Java JNI Interface genutzt.

Bevor es losgeht schonmal ein Bild mit dem was am Ende herauskommt. Das ist eine alte Explosionszeichnung aus den 50er Jahren. Ich habe diese gescannt und das fertige Programm damit gefüttert.
Erkannte Zahlen sind rot eingerahmt und der Zahlenwert steht darüber. Blau eingerahmte Objekte sind als mögliche Zahl erkannt worden aber brachten keine ausreichende Übereinstimmung:

Bild 1: Verarbeitete Zeichnung mit erkannten Ziffern

Wie funktioniert es grundsätzlich?


Inspiriert wurde ich durch folgenden Link:

Abgeleitet aus dem was dort steht soll meine Routine folgendes tun:

  1. Input Bild laden
  2. Konturensuche durchführen. Konturen sind geschlossene Linien. Bei der "1" läuft die Kontur ein Mal um die Zahl herum. Eine "6" hat zwei Konturen - aussen herum und der Bauch innen. Eine "8" hat drei Konturen.
  3. Größenprüfung: Zu große bzw. zu kleine Konturen werden verworfen
  4. Klassifizierung: Für jede Kontur wird ermittelt, ob und um welche Ziffer es sich handelt.
  5. Zusammenfassung benachbarter Ziffern: Aus "1" und "0" nebeneinander wird eine "10".
  6. Erzeugen des Ausgabebildes
Die Klassifizierung erfolgt durch Ermittlung des nearest Neighbours. Diese Methode muss angelernt werden, d.h. es muss zu einer bestimmten Menge Ziffernbilder vorgegeben werden welche Ziffer das Bild zeigt.


Einrichtung Betriebssystem und IDE


Ich nutze Ubuntu 14.04 und Eclipse. Das OpenCV Java Interface habe ich über die Paketverwaltung installiert (2 Pakete):

sudo apt-get install libopencv2.4-java libopencv2.4-jni

In Eclipse legt man am einfachsten eine User Library an. Wie das geht steht hier:

http://docs.opencv.org/doc/tutorials/introduction/java_eclipse/java_eclipse.html

Wenn man wie ich die Standard Ubuntu Pakete verwendet, müssen bei der User Library folgende Pfade eingestellt werden:

JAR: /usr/share/OpenCV/java/opencv-248.jar
Native library location: /usr/lib/jni

Die native library location kann man einstellen, wenn man die angelegte User library aufklappt (im Preferences Dialog). Ich habe das nicht gleich gesehen und das hat etwas Zeit gekostet.

Vorbereitung der Bilder


Nach vielen Versuchen hat sich folgende Vorgehensweise zum Erstellen des Eingabematerials bewährt:

  1. Scannen der Zeichnung mit 300 DPI, Graustufen
  2. Festlegen des relevanten Ausschnitts
  3. Konvertierung in schwarz/weiss Bild mit convert (imagemagick)
    peter@gorgonzola:~$ convert seite5.png -threshold 67% 5.png
Die hier gezeigten Bilder haben im Original einen graubraunen leicht gefleckten Hintergrund. Dies führt bei der Konturensuche zu einem enormen Aufwand, da sehr viel kleine Konturen erkannt werden. Durch convert -threshold 67% wird dieser Hintergrund weiss ohne das die Ziffern verschwinden.
Je nach Eingabematerial muss man die Aufbereitung anpassen. Eine Lösung für alles gibt es in diesem Fall nicht.

Bild 2: Konvertierung Graustufen in S/W mit 67% threshold

Einlesen des Eingabematerials, Konturensuche

Jetzt kann es losgehen. Das folgende Snippet lädt ein Eingabebild und führt eine Konturensuche durch. Die Konturensuche benötigt ein BINARY image. Da ich S/W-Bilder verwende kann ich diese 1:1 für die Konturensuche verwenden.



 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// OpenCV Library laden - einmalig beim Programmstart
System.loadLibrary( Core.NATIVE_LIBRARY_NAME );

// Input laden (UNCHANGED, da bereits S/W)  
Mat image = Highgui.imread(inputFilename, Highgui.CV_LOAD_IMAGE_UNCHANGED);

// Ergebnisliste für Konturensuche
List contours = new ArrayList<>();
        
// Konturen suchen
Imgproc.findContours(image, contours, new Mat(), Imgproc.RETR_TREE,  Imgproc.CHAIN_APPROX_NONE);

Hinweis: findContours verändert das übergebene image. Es ist danach mehr oder weniger invertiert. Wenn man also mit dem image im Anschluß noch etwas vor hat, muss man findContours eine Kopie der Mat übergeben:

// Kopie erstellen, da Konturensuche das Eingabebild invertiert
Mat copy = new Mat();
image.copyTo(copy);

Größenprüfung, Extrahierung Teilbild und Weichzeichner


Im Anschluß werden die Konturen durchlaufen und anhand der BoundingBox (kleinstes einschließendes Rechteck) eine Größenprüfung vorgenommen.

Über subMat() werden die Teilbilder der Konturen extrahiert und mittels resize() auf eine fixe Größe gebracht. Die feste Größe ist für die Klassifizierung (Nearest Neighbour) notwendig. Auf die Teilbilder wird ein Gaußscher Weichzeichner angewendet. Durch das Weichzeichnen werden sich die Bilder ähnlicher, da die Kanten verwischen. Dies bringt bei der Klassifizierung bessere Ergebnisse. Wenn der Weichzeichner zu stark eingestellt wird, kommt es aber zu Falscherkennungen, d.h. Kleinteile werden als Ziffern erkannt.
Die besten Einstellungen hängen wieder vom Eingabematerial ab.



 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Größenfilter
Rect sizeFilter = new Rect(new Point(8, 16), new Point(50, 32));

// Feste Größe für Teilbilder
Size digitSize = new Size(15,15);

// Gefundene Konturen durchlaufen
for (Iterator i = contours.iterator(); i.hasNext(); ) {
  MatOfPoint contour = i.next();
   
  // Bounding Box der Kontur
  Rect box = Imgproc.boundingRect(contour);
      
  // Größenfilter für Kontur
  if (box.width < sizeFilter.tl().x ||
      box.width > sizeFilter.br().x || 
      box.height < sizeFilter.tl().y ||
      box.height > sizeFilter.br().y) {
        i.remove();
     continue;
  }
   
  // Einzelbild
  Mat subImg = new Mat();   
   
  Imgproc.resize(image.submat(box), subImg, digitSize);
   
  // Weichzeichner auf Einzelbild anwenden
  blurImage = new Mat();    
  Imgproc.GaussianBlur(subImg, blurImage, new Size(5,5), 3);

  // Einzelbild abspeichern (z.B. zum Anlernen des Nearest Neighbour)
  Highgui.imwrite("path/to/image.png", blurImage);
}

Anlernen der Nearest Neighbour Klassifizierung



Damit die Teilbilder klassifziert werden können (Erkennung der Ziffer) wird zunächst eine Menge an Teilbildern benötigt, zu den die entsprechende Ziffer vorgegeben wird.

Um dies einfach zu realisieren bin ich folgenden Weg gegangen:

  1. Erstellung einer Main-Klasse zum Erzeugen von Trainingsbildern
  2. Konturensuche in allen EIngabebildern.
  3. Speichern der Teilbilder in einem Unterverzeichnis (train/Nummer des Eingabebildes). Als Dateiname wird eine fortlaufende Nummer verwendet.
  4. Erzeugung einer Java-Properties Datei unter train/Nummer des Eingabebildes.txt. Diese Properties Datei enthält leere Eigenschaften für alle erzeugten Einzelbilder.

Zum Anlernen öffnet man die properties Datei und notiert dort die Ziffern, die auf den entsprechenden Teilbilder zu sehen sind.

Bild 3: Einzelbilder nummeriert und Properties Datei mit Ziffern zum Anlernen
Die Abbildung zeigt die Properties Datei und dahinter den Ordner mit den Einzelbildern. Bild 0 zeigt eine 9, Bild 1 eine 1. Die Bilder 2 bis 7 sind keine Ziffern. Bild 8 zeigt eine 2 usw.

NACHTRAG: Die Abbildung enthält einen Fehler. Bild 0 zeigt keine "9".

Hierzu ist natürlich etwas Basis Programmierarbeit erforderlich auf die ich jetzt nicht weiter eingehe.

Zum Anlernen der Klassifizierung müssen die Informationen aus der Properties Datei zusammen mit den Einzelbildern wieder in OpenCV Matrizes geladen werden (Mat).
Da dies nicht wirklich selbsterklärend ist hier der gesamte Code, um das Model zur Klassifizierung zu erzeugen:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public CvKNearest getModel() throws Exception {
  // Trainingsmaterial einlesen
  File trainDir = new File(TRAIN_DIR);
  // Mat mit den angelernten Einzelbildern (alle hintereinander)  
  Mat trainingImages = new Mat();
  // Mat mit den entsprechenden Ziffern als double (hintereinander)
  Mat trainingLabels = new Mat();

  // Verzeichnisse mit Einzelbildern durchlaufen (1 pro Eingabebild)
  for (File f: trainDir.listFiles()) {
    // Only directories
    if (!f.isDirectory())
      continue;
   
    // Get imgId from dirname
   int imgId = StringHelper.toInt(f.getName());   
   if (imgId == -1) continue;
   
   // Trainingfile
   File propFile = new File(trainDir, imgId + ".txt");
   // No trainingfile found => ignore dir
   if (!(propFile.isFile())) continue;
   
   // Load training properties
   Properties props = new Properties();
   props.load(new FileInputStream(propFile));
   
   // List PNGs train/imgid
   for (File imgFile: f.listFiles()) {
     // Alles ausser png ignorieren    
     if (!imgFile.getName().endsWith(".png"))
       continue;
    
     int trainImgId = StringHelper.getIntFromFileName(imgFile);    
     if (trainImgId == -1) continue;   
       // Vorgabewert für Einzelbild
       String propValue = props.getProperty(getPropName(trainImgId));
     
       // Einzelbilder ohne Vorgabe ignorieren
       if (propValue == null || propValue.isEmpty()) continue;
   
       try {
         int propValueInt = Integer.parseInt(propValue);

         // Einzelbild laden
         Mat imgData = Highgui.imread(imgFile.getAbsolutePath(), 
            Highgui.CV_LOAD_IMAGE_GRAYSCALE);
 
         // Einzelbild in Mat schreiben (reshape setzt Dimension)    
         trainingImages.push_back(imgData.reshape(1, 1));
 
         // Vorgabeziffer in Mat schreiben
         Mat mat = new Mat(new Size(1,1), CvType.CV_32FC1);
         mat.setTo(new Scalar(propValueInt));
         
         trainingLabels.push_back(mat);
       } catch (NumberFormatException e) {
         // Ignore non int prop values
       }
     }   
   }
   
   // Matrixtyp setzen (erforderlich für CvKNearest)  
   trainingImages.convertTo(trainingImages, CvType.CV_32FC1);

   // Model erzeugen    
   CvKNearest model = new CvKNearest();  

   // Model anlernen
   model.train(trainingImages, trainingLabels);  
    
   return model;
}


Mit der Anweisung

trainingImages.push_back(imgData.reshape(1, 1));

werden die angelernten Teilbilder in eine große Matrix hintereinander geschrieben. Die Methode CvKNearest.train() erwartet dies so. Zuvor muss noch mit

trainingImages.convertTo(trainingImages, CvType.CV_32FC1);

eine Matrix des richtigen Typs erzeugt werden.


Durchführen der Klassifizierung (Erkennung von Ziffern)



Das angelernte Model kann nun verwendet werden, um Einzelbilder zu klassifizieren. Die Methode CvKNearest.find_nearest() ermittelt dazu zu einem Bild die beste Übereinstimmung aus dem angelernten Material.
Außerdem kann der Abweichungssgrad abgefragt werden. Anhand der Abweichung entscheide ich, ob ich die Ziffer akzeptiere. Bei zu hoher Abweichung zeigt das Bild in der Regel keine Ziffer sondern ein kleines Einzelteil o.ä.

Um die Klassifizierung auszuführen müssen die zu klassifizierenden Bilder wieder hintereinander in eine Matrix geschrieben werden (wie beim Anlernen).
Wie man CvKNearest nutzt und auf die Ergebnisse zugreift sollte in folgendem Beispiel klar werden:
Hinweis: Die Klasse Match ist eine kleine Hilfsklasse, die ein Ergebnis der Konturensuche enthält.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Ergebnisse der Klassifizierung (Zahlen)
Mat results = new Mat();
// Abweichungen
Mat distances = new Mat();

// Zu klassifizierende Bilder  
Mat samples = new Mat();

// Gefundene Bilder durchlaufen  
for (Match box: boxes) {   
  // Bilder hintereinanderschreiben
  samples.push_back(box.img.reshape(1, 1));
}
    
samples.convertTo(samples, CvType.CV_32FC1);

// Klassifizierung ausführen
model.find_nearest(samples, 1, results, new Mat(), distances);

for (int i=0; i<boxes.size(); i++) {
  Match box = boxes.get(i);

  // Ergebnis Klassifizierung   
  double[] digit = results.get(i, 0);
  int digitInt = (int) digit[0];
  
  // Abweichung   
  double[] dists = distances.get(i, 0);
  // Prüfung der Abweichung     
  if (dists[0] > 200000) {
    continue; // Zu groß => ignorieren
  }
   
  box.digit = digitInt;
}


Zusammenfassen von mehrstelligen Zahlen



Dicht nebeneinander stehende Ziffern auf gleicher Höhe müssen zu mehrstelligen Zahlen zusammengefasst werden. Also aus "1" "0" muss eine "10" werden.
Die lässt sich realtiv einfach umsetzen:

  1. Erkannte Ziffern durchlaufen
  2. In der Schleife übrige Ziffern durchlaufen und X und Y-Differenz berechnen. Sobald Schwellenwerde unterschritten werden kann man davon ausgehen, dass die Ziffern zusammengehören.
  3. Box der ersten Ziffer um die Box der zweiten erweitern.
  4. Erste Ziffer * 10 + zweite Ziffer der ersten Box als Zahl zuweisen
  5. Zahl bei zweiter Box auf -1 setzen (keine).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Gefundene Konturen durchlaufen
for (Match box: boxes) {
  // Falls zur Kontur keine Ziffer erkannt wurde => ignorieren
  if (box.digit == -1) continue;

  // Übrige Konturen durchlaufen   
  for (Match box2: boxes) {    
    if (box == box2) continue;    
    if (box2.digit == -1) continue;

    // X-Differenz    
    double diffX = box.box.tl().x - box2.box.tl().x;
    // Schwellenwert X-Differenz
    if (diffX < -9 || diffX > 0)
      continue;

    // Y-Differenz    
    double diffY = box.box.tl().y - box2.box.tl().y;

    // Schwellenwert X-Differenz    
    if (diffY > 4 || diffY < -4) continue;
    
    // Ziffern gehören zusammen
    
    // Box1 erweitern => Boundingbox für beide Boxes bestimmen
    MatOfPoint points = new MatOfPoint(box.box.tl(), box.box.br(), 
       box2.box.tl(), box2.box.br());

    Rect newBox = Imgproc.boundingRect(points);
    box.box = newBox;

    // Wert berechnen
    box.digit = box.digit * 10 + box2.digit;
    
    // Wert aus Box2 entfernen
    box2.digit = -1;
  }
}

Darstellung der Ergebnisse


Im letzten Schritt werden die gefundenen Konturen und die erkannten Ziffern in einem Ausgabebild dargestellt. Man möchte ja auch sehen was das Programm kann.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Input laden (FARBE)  
Mat image = Highgui.imread(inputFilename, Highgui.CV_LOAD_IMAGE_COLOR);

// Gefundene Konturen druchlaufen
for (int i=0; i<boxes.size(); i++) {
  Match box = boxes.get(i);

  // Kontur ohne erkannte Ziffer
  if (box.digit == -1) {
    // Blaues Rechteck
    Core.rectangle(image, box.box.tl(), box.box.br(), colorGreen, 2);
  } else { // Kontur mit erkannter Ziffer
    // Rotes Rechteck
    Core.rectangle(image, box.box.tl(), box.box.br(), colorRed, 2);

    // Ziffer als Text   
    Core.putText(image, "" + box.digit, box.box.tl(), Core.FONT_ITALIC,
    sizeFilter.tl().y / 20, colorRed, 2);
  }
}

// Ausgabebild schreiben
Highgui.imwrite(OUTPUT_DIR + "/" + imgId + ".jpg", image);


Offene Probleme

Die Routine hat leider ein paar Schwachstellen, die je nach Eingabematerial mehr oder weniger stark zum Tragen kommen:


  1. Die Ziffern müssen frei stehen. Wenn z.B. ein Zeigestrich bis an die Zahl heranreicht wird dieser mit in die Kontur einbezogen. Als Lösung kann man die Schwellenwerte für die Konturengröße höher setzen und die entsprechenden Einzelbilder mit Strich anlernen.
    Eine andere Lösung ist eine Vorbearbeitung der Eingabebilder bei der man bis an die Zahlen heranreichende Striche kürzt.
  2. Wenn Ziffern zu dicht zusammen stehen, werden diese ebenfalls zu einer Kontur zusammen gefasst. Auch hier kann man durch Erhöhung der Schwellenwerte und Anlernen des entsprechenden Einzelbildes eine Lösung erreichen.
  3. Sobald die Ziffern in größere Teile hineinragen scheitert die Routine ebenfalls.
Ein weiteres Problem sind alle Pixelangaben im Programm. Diese korrespondieren natürlich mit der Schriftgröße im Eingabematerial. Folgende Pixelangaben müssen in Abhängigkeit der Zifferngröße in Pixel angepasst werden:

  1. Größenfilter für Bounding Boxes
  2. Größe der Einzelbilder für CvKNearest
  3. Einstellung für Weichzeichner. Je größer die Ziffern sind, um so stärker kann der Weichzeichner eingestellt werden.
  4. Toleranzschwellen (x/y) zum Zusammenfassen von Ziffern.
  5. Toleranzschwelle für Abweichungsgrad. Je größer die Einzelbilder sind, um so höher werden die Abweichungen bei gleicher Ähnlichkeit. Möglicherweise wird die Abweichung pro Pixel quadriert und aufsummiert bzw. etwas vergleichbares.
    Daher muss der Schwellenwert erhöht werden sobald die Einzelbildgröße erhöht wird.


In einem ersten Schritt könnte man dem Programm die Schriftgröße in Pixel als Parameter mitgeben und die übrigen Einstellungen und Schwellenwerte werden daraus ermittelt. Das wäre ein Anfang.


Zur Erkennung nicht 100% frei stehender Ziffern ist mir auch noch etwas eingefallen: Sobald die Bounding Box für eine Ziffer deutlich zu groß ist, werden die 4 Ecken in Zifferngröße weiterverarbeitet. Mit dieser Lösung würde man zusammenstehende mehrstellig Zahlen trennen (zumindest 2-stellige). Falls von einer Seite ein Strich o.ä. bis an die Zahl reicht würde man diesen ebenfalls abschneiden.
Diese Idee werde ich bei Gelegenheit auf jeden Fall noch einarbeiten.

Fazit

Damit die hier gezeigte Erkennung gut arbeitet, braucht man eine größere Anzahl Eingabebilder mit sehr ähnlichem Schriftbild. Die Ziffern müssen frei stehen.

Ich habe mit vier Beispielbildern gearbeitet. Ich musste allerdings bei diesen Bildern ein paar Striche retuschieren, da diese bis an die Ziffern reichten.
Bild 1 und 2 habe ich komplett manuell klassifiziert und damit das CvKNearest Model angelernt.

Bei Bild 3 (siehe 1. Bild im Blog oben)  betrug die Erkennungsrate gut 90%. Man sieht, dass einige mehrstellige Ziffern als eine Kontur erkannt und damit nicht klassifiziert wurden.
Bei Bild 4 wurden alle Ziffern erkannt - also 100%.

Bild 4: 100% Erkennung ohne Vorgabe


Man kann die Verfahrensweise bestimmt noch an der ein oder anderen Stelle verbessern. Mal schauen ob daraus ein Projekt wird.
Auf jeden Fall ein spannendes Thema.


Wer bis hierhin durchgehalten hat: Vielen Dank für Euer Interesse und bis bald ...

Haltet durch!



1. Verbesserung - Aufteilen übergroßer Konturen


Sobald Ziffern nicht 100% freistehen treten die folgenden Probleme auf:

  1. Sich berührende mehrstellige Ziffern werden als eine Kontur erkannt und müssen manuell klassifiziert werden.
  2. Falls Striche o.ä. eine Ziffer berühren wird die Kontur ebenfalls erweitert und die Klassifizierung erschwert bzw. unmöglich.
Um diese Fehler zu reduzieren habe ich die Größenprüfung der Konturen umgearbeitet:
  1. Zu kleine Konturen werden verworfen (zu klein für eine Ziffer)
  2. Konturen die 30% breiter als die eingestellte Ziffernbreite und/ oder 30% höher als die eingestellte Ziffernhöhe sind werden geteilt und einzeln verarbeitet.
  3. Je nach Überschreitung werden 4 Eckbereiche, Links/Rechts oder Oben/Unten getrennt.

Bild 5: Aufgeteilte Kontur - linker und rechter Bereich
Das Bild zeigt die Ziffern der 36. Diese berühren sich und werden daher als eine gesamte Kontur gefunden. Da die Breite der Bounding-Box mehr als 30% über der eingestellten Ziffernbreite (hier 16 Pixel liegt), die Höhe der Bounding-Box jedoch nicht diesen Schwellenwert überschreitet wird die Box in den linken und rechten Bereich geteilt und getrennt verarbeitet.
Nach der Klassifizierung wurden die Ziffern wieder zur 36 vereint, da die Boxes als zusammengehörend erkannt wurden (erkennbar am roten und blauen Rahmen sowie den roten Ziffern).

Im Test brachte diese Verbesserung sehr gute Resulate. Die Erkennungsrate in den beiden unbekannten Bildern liegt jetzt bei 100%.




2. Verbesserung - Erneute Konturensuche in den geteilten Konturen

Um die Erkennung noch weiter zu verbessern könnte in den mit Verbesserung 1 aufgeteilten Bereichen eine erneute Konturensuche durchgeführt werden.
Sobald Ziffern durch Striche o.ä. mit größeren Objekten verbunden sind, wird die Gesamtkontur zwar in die 4 Ecken geteilt, jedoch befindet sich dort nicht unbedingt die Ziffer.
Diese befindet sich wahrscheinlich in einem der 4 Randstreifen und müsste dort mit einer erneuten Konturensuche gefunden werden.

Bild 6: Randbereiche (rot) einer übergroßen Kontur in denen eine neue Konturensuche gemacht werden müsste
Diese Verbesserung habe ich noch nicht umgesetzt. Es besteht auch das Risiko, dass sich in den Randbereichen weitere Ziffern befinden, die nicht zu der Konture gehören aus der die Bounding-Box ermittelt wurde.
Diese werden dann doppelt gefunden und müssten aussortiert werden.