Dienstag, 11. Oktober 2016

ResponsiveGridLayout - a responsive grad based Layout for SWT

I have implemented a grid based responsive SWT layout for my Document Management Software.
I believe it could be quite useful for other project and release it under the Eclipse Public License (EPL).

The layout arranges components in a grid where all cells have equal dimensions. Number of columns and column width adapts repsonsive to the available width.
Row height adapt to column width using a configurable ratio.

To see it in action please take a look at the demo video:



Here comes the source code. Please leave the header with the license and copyright information

 package biz.pinnau.dms.rcp;  
   
   
 import org.eclipse.swt.SWT;  
 import org.eclipse.swt.graphics.Point;  
 import org.eclipse.swt.widgets.Composite;  
 import org.eclipse.swt.widgets.Control;  
 import org.eclipse.swt.widgets.Layout;  
   
 /**  
  * Released under Eclipse Public License (EPL) 1.0  
  *   
  * Copyright: Peter Pinnau (peter@pinnau.biz)  
  *   
  * Gridbased SWT Layout  
  *   
  * 1. All cells have equal dimensions  
  * 2. Cell widths increases/decreases automatically and adjusts to   
  *  available width of the container  
  * 3. Cell height is calculated with a ratio + offset from calculated  
  *  cell width: height = width * ratio + offset  
  * 4. Column count can be automatically calculated according to available width  
  *   
  * @author Peter Pinnau (peter@pinnau.biz)  
  * @version 1.0  
  *   
  * Last modified: 2016-10-11  
  *   
  * Class was implemented for System Concept DMS  
  *   
  * System Concept DMS - Das papierlose Büro  
  *   
  * www.pinnau.biz/dms.html  
  *  
  */  
 public class ResponsiveGridLayout extends Layout {  
   
      /**  
       * Fixed number if columns. Default = 0 => adaptive number of columns  
       */  
      private int fixedNumColumns = 0;  
   
      /**  
       * Mimimum column width  
       */  
      private int minColumnWidth;  
        
      /**  
       * ratio to calculate row height  
       */  
      public float ratio = 1f;  
        
      /**  
       * Offset for row height calculation  
       */  
      public int offset = 0;  
        
      /**  
       * margin around grid  
       */  
      public int margin = 0;  
        
      /**  
       * spacing between cells  
       */  
      public int spacing = 10;  
             
      /**  
       * Creates an instance of the layout using a fixed number of columns  
       *   
       * @param numColumns  
       */  
      public ResponsiveGridLayout(int numColumns, int minimumColumnWidth) {  
           this.fixedNumColumns = numColumns;            
           this.minColumnWidth = minimumColumnWidth;  
      }  
        
      /**  
       * Creates an instance of the layout with adaptive number of columns depending  
       * on available width and minColumnWidth  
       *   
       * @param columnCounts  
       */  
      public ResponsiveGridLayout(int minimumColumnWidth) {  
           this.minColumnWidth = minimumColumnWidth;  
      }       
        
      private int getBoxHeight(int boxWidth) {  
           return (int) (boxWidth * ratio) + offset;  
      }  
        
      private int getNumColumns(int availableWidth) {  
           // Fixed number of columns  
           if (fixedNumColumns > 0) return fixedNumColumns;  
             
           // No available width specified  
           if (availableWidth == SWT.DEFAULT) {  
                // assume 1 column  
                return 1;  
           }  
             
           // Start with width of 1 column  
           int totalWidth = minColumnWidth + 2 * margin;  
           int numColumns = 0;  
             
           // Increase column count until totalWidth exceeds avaiblableWidth  
           while (totalWidth < availableWidth) {  
                // Add spacing + minColumnWidth (1 new column)  
                totalWidth = totalWidth + spacing + minColumnWidth;  
                                 
                numColumns++;  
           }  
             
           if (numColumns == 0) numColumns = 1;  
             
           return numColumns;            
      }  
        
      @Override  
      protected Point computeSize(Composite composite, int wHint, int hHint, boolean flushCache) {  
           Point size = new Point(0, 0);  
             
           // Get number of columns  
           int numColumns = getNumColumns(wHint);  
           int boxWidth = getAvailableBoxWidth(wHint, numColumns);  
                       
           int componentCount = composite.getChildren().length;  
           int rows = (int) Math.ceil(componentCount / (double) numColumns);  
             
           if (rows == 0) rows = 1;  
             
           size.x = numColumns * boxWidth + (numColumns-1) * spacing + 2 * margin;  
           size.y = rows * getBoxHeight(boxWidth) + (rows-1) * spacing + 2 * margin;  
             
           return size;  
      }  
        
      /**  
       * Calculates the available cell width  
       *   
       * @param width  
       * @param numColumns  
       * @return  
       */  
      private int getAvailableBoxWidth(int width, int numColumns) {  
           if (width == SWT.DEFAULT) return minColumnWidth;  
             
           int boxWidth = (width - 2 * margin - (numColumns * spacing)) / numColumns;  
             
           if (boxWidth < minColumnWidth) return minColumnWidth;  
             
           return boxWidth;  
      }  
             
      @Override  
      protected void layout(Composite composite, boolean flushCache) {  
           Control[] children = composite.getChildren();  
        
           // Get number of columns  
           int numColumns = getNumColumns(composite.getClientArea().width);  
             
           int boxWidth = getAvailableBoxWidth(composite.getSize().x, numColumns);  
           int boxHeight = getBoxHeight(boxWidth);  
             
           // Build the grid  
           int x = margin;  
           int y = margin;  
             
           for (int i=0; i<children.length; i++) {  
                if (i % numColumns == 0) {  
                     x = margin;  
                       
                     if (i > 0) {  
                          y = y + spacing + boxHeight;   
                     } else {  
                          y = margin;  
                     }  
                } else {  
                     x = x + boxWidth + spacing;                      
                }  
                                 
                children[i].setSize(boxWidth, boxHeight);  
                children[i].setLocation(x, y);                                
           }  
      }  
 }  
   



The following example shows how to use the layout together with a ScrolledComposite.


 scroll = new ScrolledComposite(parent, SWT.V_SCROLL | SWT.H_SCROLL );  
   
container = new Composite(scroll, SWT.None);  
               
// Create adaptive Grid  
layout = new ResponsiveGridLayout(150);  
layout.offset = 50;  
layout.ratio = 1.5f;  
layout.margin = 5;  
layout.spacing = 5;  
               
container.setLayout(layout);              
                           
// Set the child as the scrolled content of the ScrolledComposite  
scroll.setContent(container);  
               
// Expand both horizontally and vertically  
scroll.setExpandHorizontal(true);  
scroll.setExpandVertical(true);  
               
scroll.addListener(SWT.Resize, new Listener() {                    
      @Override  
      public void handleEvent(Event event) {  
            scroll.setMinSize(container.computeSize(scroll.getClientArea().width, SWT.DEFAULT));  
      }  
});  
               
// Add some controls to container ...  

Dienstag, 13. September 2016

Kerberos Authentication with Tomcat 8.0 and pure Java

I am working on service/web based search platform for indexing and querying file servers. One of the key challenges is authentication against the same resource as the fileserver itself does. In most cases this will be an Active Directory or something similar.

My plan is to use Apache Tomcat to deploy Web GUI/ REST/ SOAP or whatever services so I tried to configure Java's buildin Kerberos JAAS authentication in Tomcat.

There is a complicated howto on the web:
https://tomcat.apache.org/tomcat-7.0-doc/windows-auth-howto.html

The howto says that configuration is hard and tricky and does not allow much flexibility.


Kerberos and single sign on (SSO)


The Tomcat howto focuses on single sign on. This means that your Windows login credentials (or precisely a ticket) is passed from the browser to the Tomcat webserver and is used for authentication. There is no need to prompt for credentials.

For real Kerberos single sign on you need a perfectly configured Tomcat and a so called "kerberised" client, i.e. a modern web broswer.
If the Tomcat deploys a REST service the REST client must be kerberised, too.

In a perfect world all that will be easily available. But we are still far away from that world ;)


Kerberos without SSO


To be honest I do not need SSO for my purpose. It will really be OK if the user or the service consumer has to specify login credentials.

I just do not want to manage these credentials in an extra source. So the authentication must be done against a Kerberos server (i.e. Windows domain controller).

My focus is a central user/password database which does NOT mean SSO.

Unfortunately the howto and other web resoruces do not cover that. I spend some hours on testing and finally got a working solution.


Howto


The following steps show how to setup simple Kerberos authentication for a servlet. I do not explain how to setup and deploy the servel itself. This should be common knowledge.

A real simple Servlet

Let's start with a real simple servlet. It Just prints the system time.

public class HelloWorld extends HttpServlet {
 
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {  

    response.getWriter().append("Served at: ").append(request.getContextPath());
  }

  protected void doPost(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
    
    doGet(request, response);
  }

}

Please configure your project and web.xml so that you have that simple servlet working. Of course there is no authentication at the moment.

Defining security contraints in web.xml

To force authentication on a servlet you need to define a security constraint in the web.xml


 <servlet-mapping>  
  <servlet-name>HelloWorld</servlet-name>  
  <url-pattern>/admin/HelloWorld</url-pattern>  
 </servlet-mapping>   
        
 <security-constraint>  
  <web-resource-collection>  
   <web-resource-name>Admin</web-resource-name>  
   <url-pattern>/admin/*</url-pattern>  
  </web-resource-collection>  
   
  <auth-constraint>             
   <role-name>peter@SC.LOCAL</role-name>  
  </auth-constraint>  
 </security-constraint>  
        
 <security-role>  
  <role-name>peter@SC.LOCAL</role-name>        
 </security-role>  
        
 <login-config>  
  <auth-method>BASIC</auth-method>  
  <realm-name>DMS</realm-name>  
 </login-config>  

Notes for web.xml


  • the servlet URL is set to admin/HellWorld
  • a security constraint is defined for the pattern admin/*. So that constraint applies to the servlet.
  • the security constraint requires that the user has the role peter@SC.LOCAL
  • the roles has to be defined (security-role)
  • the login-config element configures the JAAS realm to use and the way to prompt for the credentials. The JAAS realm is named DMS. With BASIC auth the browser pops up the little htaccess credentials window.

When you deploy the project you will not be able to access the servlet anymore. The browser will show a popup to enter credentials but no matter what you type - access will be denied.

JAAS config

We need a JAAS configuration file and pass that file with the -Djava.security.auth.login.config VM argument to the tomcat server.

Here comes my jaas config example:


 DMS {  
      com.sun.security.auth.module.Krb5LoginModule required  
      debug=true  
      useTicketCache=false;    
 };  
   

Note: The name of the config (here: DMS) matches the realm-name in web.xml

Edit your Tomcat startup script and specify the path to the jaas config file via:

-Djava.security.auth.login.config=/path/to/jaas.conf

We need 2 other VM arguments:

-Djava.security.krb5.realm=SC.LOCAL
-Djava.security.krb5.kdc=IP/hostname of Kerberos server (domain controller)

Set -Djava.security.krb5.realm to your windows domain (including .local and all uppercase!)
Set -Djava.security.krb5.kdc to your domain controller. If you use a hostname here DNS must work. If you receive problems try to setup an IP here.

Alternative: default realm and kdc can also be set in a krb5.conf file. You can also specify multiple kdcs there. The path to krb5.conf must be passed via a VM option.


context.xml


Last but not least you must create or modify the file META-INF/context.xml in your project. The context.xml must be deployed with the project.


 <Context>  
  <Realm className="org.apache.catalina.realm.JAASRealm" appName="DMS"      
     
   roleClassNames="javax.security.auth.kerberos.KerberosPrincipal"  
  />  
 </Context>  


Notes:

  • the realm className has to be set to org.apache.catalina.realm.JAASRealm. That tells tomcat to use JAAS athentication.
  • The appName must match the JAAS config name and the realm-name from web.xml
  • Important: set roleClassNames to javax.security.auth.kerberos.KerberosPrincipal. Otherwise the subject (here peter@SC.LOCAL) will not be added to the roles and authorisation fails. You will be authenticated but authorisation fails (since you do not have any role).

Test


You are now ready to test the setup. Start Tomcat and enter your servlet URL into a browser. Enter a valid login (without domain name) when the popup appears and your servlet will report the system time.

In the Tomcat console you will see a message similar to:

principal is peter@SC.LOCAL
Commit Succeeded

This message reports a successful authentication.


Defining real roles


Congratulations if you got it working so far. But what about roles/groups? For now we need to specify users in the web.xml security constraint.
The problem is that the builtin Java Kerberos implementation just authenticates. It does not query groups for the user. So we have no roles.

I will spend more time on that. For the moment I have a very simple solution:

Create a subclass of Krb5LoginModule. I believe it will be perfect to override commit() and do the role logic there but unfortunately there is no way to access the subject there (private member).
So override initialize(...):


public void initialize(Subject subject,  
     CallbackHandler callbackHandler,  
     Map<String, ?> sharedState,  
     Map<String, ?> options) {  
             
     if (subject != null) {  
         subject.getPrincipals().add(new RolePrincipal("Everyone"));  
     }  
             
     super.initialize(subject, callbackHandler, sharedState, options);            
}  

This code simply adds a role "Everybody" to the subject. The class RolePrincipal is a simple Java bean which implements javax.security.Principal


public class RolePrincipal implements Principal {  
         
       private String name;  
         
       public RolePrincipal(String name) {  
        super();  
        this.name = name;  
       }  
   
       public void setName(String name) {  
        this.name = name;  
       }  
   
       @Override  
       public String getName() {  
        return name;  
       }  
}  


Now there are 3 things to change:


  • Replace com.sun.security.auth.module.Krb5LoginModule in jaas.conf with the subclassed implementation
  • Replace/add RolePrincipal class to roleClassNames attribute in context.xml
  • Modify web.xml and use the pseudo role "Everyone" instead of individual users

No every authenticated user will be able to access the servlet. There is no need to define individual users in web.xml.


TODOs


There are some points to spend further work on:


  1. Use https instead of http. Ensure that credentials are submitted in a secure way
  2. Implement flexible role configuration. Either configure role assignment in simple way or retrieve groups from AD (but what about nested groups?)
  3. Test authentication with SOAP and REST services.


Donnerstag, 4. August 2016

Berechnete Datensätze mit SQL INSERT INTO () SELECT ergänzen

Ein Kunde setzt seit 2011 eine zentrale Verwaltung und Erfassung von Zählern ein. Die Ablesung erfolgt monatlich mit Hilfe eines mobilen Gerätes. Die abgelesenen Stände werden anschließend per LAN in eine Oracle Datenbank synchronisiert.
Dabei werden gleichzeitig neue Stammdaten (Zähler) auf das mobile Gerät übernommen.

Die Auswertung der Daten erfolgt über ein zentrales Berichtssystem mit JasperReports unabhängig von der Zählerverwaltung.

In der Auswertung werden monatliche Verbrauche dargestellt. Die Ermittlung erfolgt ganz einfach durch Subtraktion eines Zählerstands mit dem Stand des Vormonats.
Dies funktioniert natürlich nur, wenn für alle Monate lückenlos Stände erfasst wurden.

Aufgrund von Urlaub wurde nun für den Monat Juni 2016 kein Stand abgelesen. Damit waren die Auswertungen unvollständig, da ohne den Stand für Juni keine Verbrauche für Mai und Juni ermittelt werden können.
Das Problem ist erst Anfang August aufgefallen und nun musste eine Lösung gefunden werden.

Lösung:

  • Die Stände für Juli werden regulär erfasst.
  • Die Stände für Juni werden berechnet und nachgetragen. Dabei wird die Mitte zwischen Mai und Juli angenommen.


Im System sind ca. 120 Zähler abzulesen. Eine manuelle Berechnung und Nacherfassung der Stände für Juni hätte Arbeit und vor Allem Fehlerpotenzial bedeutet.

Ich habe daher eine SQL Abfrage erstellt, die die fehlenden Stände nachträgt. Dafür eignet sich die INSERT INTO (...) SELECT ... Syntax.
Mit dieser Syntax werden Datensätze eingefügt, die aus einer SELECT Abfrage gewonnen werden.


Vorabprüfung und Backup

Man muss bei solchen Aktionen natürlich aufpassen, da man mit fehlerhaftem SQL ganz fix den Datenbestand zerstören kann. Man startet also am Besten mit einer Datensicherung.

Danach habe ich geprüft, ob für Mai und Juli gleich viele Stände abgelesen wurden.

Anzahl Stände für Mai

select count(*) from zaehler_stand zs
  where zs.PERIODE_BEGINN = TO_DATE('1.5.2016')

Ergebnis: 122


Anzahl Stände für Juli

select count(*) from zaehler_stand zs
  where zs.PERIODE_BEGINN = TO_DATE('1.7.2016')

Ergebnis: 123

Die Abweichung resultiert aus einem neuen Zähler, der erst im Juni in Betrieb gegangen ist. Die Basis für die INSERT INTO Abfrage bilden also alle Zähler für die im Mai ein Stand erfasst wurde.


Hier nun die entsprechende Abfrage:


insert into zaehler_stand (periode_beginn, periode_ende,
     zaehler_nummer, datum_ablesung, version, zaehler_stand)

select 

TO_DATE('1.6.2016'),
TO_DATE('30.6.2016'),
zaehler.zaehler_nummer,
TO_DATE('4.8.2016'),
1,
(
   (select zaehler_stand from zaehler_stand zs where 
       zs.PERIODE_BEGINN = TO_DATE('1.7.2016')
       and zs.zaehler_nummer = zaehler.zaehler_nummer) + 
   (select zaehler_stand from zaehler_stand zs where
       zs.PERIODE_BEGINN = TO_DATE('1.5.2016') 
       and zs.zaehler_nummer = zaehler.zaehler_nummer)
) / 2

from zaehler

where zaehler_nummer in 
 (select zaehler_nummer from zaehler_stand where
     PERIODE_BEGINN = TO_DATE('1.5.2016'))


Die Datumsangaben entsprechend den Werten für Juni 2016. Das Feld version wird mit 1 belegt. Die Zählernummer wird aus dem aktuellen Datensatz der Tabelle zaehler übernommen.

Über 2 Subqueries werden die Stände des aktuellen Zählers für Mai und Juli 2016 ermittelt. Die Stände werden addiert und durch 2 dividiert. Dies ergibt den Mittelwert für Juni.

Im WHERE Teil der Hauptabfrage werden per Subquery die Zähler eingeschränkt, so dass nur die Zähler verarbeitet werden für die im Mai 2016 ein Stand abgelesen wurde.


Donnerstag, 28. Juli 2016

XulRunner deprecation, Eclipse SWT and Mozilla PDFJS

I currently spend a lot of work on System Concept DMS Document Management System. Last month I brought the project to the recent Apache PDF Box version 2.0.2 and Eclipse release.

Today I checked the Mozilla PDFJS version. PDFJS is used to have a full featured and fast PDF viewer in the DMS. PDFJS is JavaScript based and is intergrated via org.swt.Broswer.

I had to use the SWT.MOZILLA flag with the browser since PDFJS did not run with GTK webkit (Linux native) and Internet Explorer (Windows native).
The application has to be shipped with XulRunner 24.0 which is compatible with the SWT library.

With the old PDFJS version 1.2.109 this worked fine.

For testing I switched to current stable PDFJS 1.4.20. Unfortunately lots of images are not rendered anymore.
I believe that the outdated XulRunner version is the reason. I tried to find out if there is support for a newer one in the Eclipse Neon platform.

The result:
https://bugs.eclipse.org/bugs/show_bug.cgi?id=474625

The XulRunner project is deprecated and there is no effort in the Eclipse team to get the latest version working with SWT. This is bad news.

Compatibiliy table


I tried to leave XulRunner and work with the native OS browsers (IE 11 for windows and GTK Webkit on Linux). The tests started hopefully but in the end serious problems occured.

For now XulRunner has to be used and shipped with the SWT application to get a stable PDFJS integration.

The follwing table summarized the test results.


Date: 07-28-2016

Eclipse: Neon 4.6, Build I20160606-1100

SWT: I found two jars in the plugins folder:

swt_3.105.0.v20160127-0039.jar
swt_3.105.0.v20160603-0902.jar

SWT.getVersion() returns 4623


OSSWT Browser flagBrowserPDFJSResult
Windows 7SWT.NONEIE 11All versionsPDF is not rendered
In standalone IE 11 PDF 1.5.188 works but not embedded in SWT
SWT.MOZILLAXulRunner 241.2.109OK
1.4.20Not tested
1.5.188Not tested
XulRunner 311.2.109Not tested
1.4.20OK
1.5.188OK
Ubuntu 14.04.1SWT.NONEGTK WebKit1.2.109PDF is not rendered
GTK WebKit1.4.20, 1.5.188PDF rendered but VM crashes when resizing browser widget
SWT.MOZILLAXulRunner 241.2.109, 1.5.188OK
1.4.20Rendered but images missing
XulRunner 31All versionsBrowser widget cannot be created. Known bug.
Windows 10Not systematically tested yet. SWT.MOZILLA + XulRunner 24 + PDFJS 1.2.109 works

Browser features and versions is a mess!


Donnerstag, 21. Juli 2016

System Concept DMS: Release 1.0.11

Nach 4 Monaten habe ich heute ein neues Release des System Concept DMS online gestellt. Die Software funktioniert inzwischen wirklich gut. Allerdings ist die Ersteinrichtung nach wie vor nicht ganz einfach.
In diesem Bereich und auch bei der Dokumentation ist noch viel zu tun.

Die wesentlichen Neuerungen sind:

  • Unterstützung für das EML Format. Damit können E-Mails jetzt genau wie PDF Dokumente mit dem System verschlagwortet und archviert werden.
    Ein E-Mail Archiver, der selbstständig auf Postfächer zugreift und Mails regelbasiert archiviert ist in Entwicklung.
    Fürs erste müssen für die Archivierung relevante Mails also als EML-Datei gespeichert und regulär verschlagwortet werden.
  • Integration eines einfachen Text E-Mail Viewer
  • Update auf aktuelle PDF Library: Apache PDFBox 2.0.2
  • Update auf aktuelle Eclipse Platform
  • Bugfixes
  • Neue Option zum Einstellen der beim Programmstart aktiven Resource


Für die private Nutzung kann das DMS kostenfrei verwendet werden. Für die Nutzung im Unternehmensumfeld wird ein Pilotprojekt gesucht. Unsere Kunden sind dafür entweder zu groß, zu chaotisch oder haben etwas anderes.

Bei Interesse bitte Kontakt aufnehmen.


Download und weitere Infos: www.pinnau.biz/dms.html

Donnerstag, 23. Juni 2016

Java: Create OpenCV Mat from BufferedImage.TYPE_BYTE_BINARY

In some cases it is very easy to migrate BufferedImage to Open CV Mat and vice-versa. The reason is that certain OpenCV Mat types and corresponding BufferedImages types use an identical storage format in their internal byte buffer.

CvType.CV_8UC3 corresponding to BufferedImage.TYPE_3BYTE_BGR.
CvType.CV_8UC1 corresponding to BufferedImage.TYPE_GRAY

You can easily create an OpenCV Mat from a BufferedImage with the following code:

BufferedImage inputImage = ...
Mat mat = null;

if (inputImage.getType() == BufferedImage.TYPE_BYTE_GRAY) {

  mat = new Mat(inputImage.getHeight(), inputImage.getWidth(),
     CvType.CV_8UC1);

  mat.put(0, 0, ((DataBufferByte) inputImage.getRaster().
     getDataBuffer()).getData());

} else if (inputImage.getType() == BufferedImage.TYPE_3BYTE_BGR) {

  mat = new Mat(inputImage.getHeight(), inputImage.getWidth(),
     CvType.CV_8UC3);

  mat.put(0, 0, ((DataBufferByte) inputImage.getRaster().
     getDataBuffer()).getData());
}


This code only supports GRAYSCALE and 3BYTE_BGR BufferedImage.

To get a BufferedImage from an OpenCV Mat instance the code is quite simliar:

// Create BufferedImage with dimensions and type (corresponding to Mat)
BufferedImage img = new BufferedImage(mat.width(), mat.height(),
  BufferedImage.TYPE_BYTE_GRAY);
   
// BufferedImage internat byte[] storage
byte[] data = ((DataBufferByte) codeImage.getRaster().getDataBuffer()).getData();

// Write image data
mat.get(0, 0, data);


Dealing with BufferedImage.TYPE_BYTE_BINARY



There is no CvType corresponding to BufferedImage.TYPE_BYTE_BINARY - or at least I did not find it.
So I created a CvType.CV_8UC1 (Grayscale) Mat with dimensions of the BufferedImage. After that the code iterates cols and rows of the Image and writes 0 (black) or 255 (white) to the OpenCV Mat.



BufferedImage img = ...

Mat mat = new Mat(img.getHeight(), img.getWidth(), CvType.CV_8UC1);
   
byte[] white = new byte[] { (byte) 255 };
byte[] black = new byte[] { (byte) 0 };
   
for (int x=0; x<img.getWidth(); x++) {
  for (int y=0; y<img.getHeight(); y++) {
    if (img.getRGB(x, y) == Color.BLACK.getRGB()) {
      mat.put(y, x, black);
    } else {
      mat.put(y, x, white);
    }
  }
}


Please note: img.getRGB(x,y) but mat.put(y,x). Otherwise the resulting Mat is rotated by 90 degrees and cut.


Java: Load TIFF images into BufferedImage

One can believe that loading TIFF images in Java is quite simple as accessing jpg or png via the ImageIO factory:

BufferedImage img = ImageIO.read(new File("/path/to/tiff"));

Unfortunately TIFF ist not supported in vanilla JRE or JDK. So ImageIO.read returns just null.

To get a list of the built-in image formats just query:


for (String format: ImageIO.getReaderFormatNames()) {
  System.out.println(format);
}


Output is:

JPG
jpg
bmp
BMP
gif
GIF
WBMP
png
PNG
jpeg
wbmp
JPEG


In fact TIFF has many options concerning compression, multi-page ...

I need to open GROUP-4 compressed TIFFs with multiple pages to scan individual pages for barcodes.
I tried ImageJ together with bio-formats which was able to deal with multi-page tiffs. In the end the ImageJ library could not process the group-4 compression which is state of the art in TIFF scans.


Java Advanced Imaging (JAI)


I early read about JAI but it seems to be retired and web resources pointed out that installation is required. This is not the case.
After some hard search it was sufficiend to place two jars in the classpath:
  • jai-core.jar
  • jai-codec.jar

The latest version seems to be 1.1.3 and I found the jars on www.java2s.com . I have no idea why there is no offical binary distribution available.

Now I was able to load the TIFF file, get page count and convert single pages to BufferedImage:


import javax.media.jai.PlanarImage;

import com.sun.media.jai.codec.FileSeekableStream;
import com.sun.media.jai.codec.ImageCodec;
import com.sun.media.jai.codec.ImageDecoder;
import com.sun.media.jai.codec.SeekableStream;

File file = new File("/path/to/tiff");

SeekableStream stream = new FileSeekableStream(file);
String[] names = ImageCodec.getDecoderNames(stream);
ImageDecoder decoder = ImageCodec.createImageDecoder(names[0], stream, null);

for (int i=0; i<decoder.getNumPages(); i++) {
  RenderedImage im = decoder.decodeAsRenderedImage(i);
  
  BufferedImage img = PlanarImage.wrapRenderedImage(im).getAsBufferedImage();

  // Do somthing with the BufferedImage
}

In the end quite easy.



Freitag, 17. Juni 2016

Barcodes in gescanten Dokumenten aufbereiten

Dieser Post ist "Follow-Up" zum Artikel

Barcodeerkennung mit OpenCV und ZXing

Mit mehr Testmaterial stellte sich heraus, dass durch die dort beschriebene Lösung leider nicht alle Barcodes erkannt werden. Die Bildbereiche werden zwar richtig ermittelt, jedoch liefert ZXing teilweise keine Ergebnisse.
Mit der Barcode Scanner App, die ebenfalls auf ZXing basiert konnte ich die Codes vom Bildschirm ablesen allerdings ging dies relativ zähe. Die Bildqualität ist also zu schlecht.

Ich habe die Online Services einiger kommerzieller Libraries getestet und die konnten die betroffenen Codes in der Regel korrekt auslesen. Es muss also möglich sein.

Grundsätzlich ist die Helligkeit des Scanmaterials unterschiedlich. Bis zu einem gewissen Grad funktioniert die Erkennung aber irgendwann werden die Barcodes nicht mehr erkannt.

Die Codes auf den folgenden 2 Bilder werden korrekt erkannt. Die unterschiedlichen Helligkeiten sind aber deutlich zu erkennen.

Korrekt erkannt - hoher schwarz Anteil

Korrekt erkannt - wenig schwarz

Einfache Weiß Interpolation


Der hier gezeigte Code wurde nicht mehr erkannt. Es ist deutlich zu sehen, dass die weißen Zwischenräume teilweise fast vollständig geschlossen sind.
Auch die Android App hat Schwierigkeiten, diesen Code vom Bildschirm zu scannen.

Das 2. Bild zeigt das veränderte Bild mit Hilfe einer einfachen Interpolation. Dabei wurden die weißen Bereiche vergrößert. Der Code wird nun erkannt und auch die Android App scannt den Code wesentlich schneller.

Scan mit zu wenig weiss. Code wird nicht erkannt.

Ergebnis der Interpolation. Code wird erkannt
Das Interpolationsverfahren ist sehr einfach aber funktioniert. Das Eingangsbild wird pixelweise verarbeitet.
Zu jedem Pixel werden die beiden Nachbarn rechts sowie oben und unten ausgewertet. Die Weiß Interpolation arbeitet nach folgender Logik:

Nachbar rechts = black UND 2. Nachbar rechts = white => setze Pixel auf white
Nachbar oben = white ODER 2. Nachbar oben = white => setze Pixel auf white
Nachbar unten = white ODER 2. Nachbar unten = white => setze Pixel auf white

Im Ergebnis werden die weißen Zwischenräume minimal breiter und auch nach oben und unten verlängert.

Im Moment führt die Interpolation noch dazu, dass durch die Regeln 2 und 3 oben und unten 2 Pixel des Codes abgeschnitten werden. Das ist ein bekannter Fehler, den ich jedoch in Kauf nehme.

Einfache Schwarz Interpolation


Der hier gezeigte Code wurde nicht mehr erkannt. Es ist deutlich zu sehen, dass die schwarzen Balken an den Kannten unscharf sind und weisse Lücken enthalten. Auch die Android App erkennt diesen Code nicht mehr ohne Weiteres.

Das 2. Bild zeigt das veränderte Bild mit Hilfe einer einfachen Interpolation. Dabei wird schwarz aufgefüllt und die schwarzen Bereiche vergrößert. Der Code wird nun erkannt und auch die Android App scannt den Code zuverlässig.


Scan mit zu wenig schwarz. Code wird nicht erkannt


Ergebnis der Interpolation. Code wird erkannt


Das Interpolationsverfahren funktioniert genauso wie das beschriebene Weiß Verfahren, nur umgekehrt.
Zu jedem Pixel werden die beiden Nachbarn rechts sowie oben und unten ausgewertet. Die Schwarz Interpolation arbeitet nach folgender Logik:

Nachbar rechts = white UND 2. Nachbar rechts = black => setze Pixel auf black
Nachbar oben = black ODER 2. Nachbar oben = black => setze Pixel auf black
Nachbar unten = black ODER 2. Nachbar unten = black => setze Pixel auf black

Im Ergebnis werden die scharzen Balken minimal breiter und auch nach oben und unten verlängert.

Im Moment führt die Interpolation noch dazu, dass durch die Regeln 2 und 3 der Code oben und um 2 Pixel erweitert wird. Das ist ein bekannter Fehler, den ich jedoch in Kauf nehme.


Ich hoffe, dass die Barcodeerkennung nun soweit ausgereift ist, dass sie die Anforderungen des aktuellen Projekts erfüllt. Budgetmäßig war dieser Aufwand nicht eingeplant.

Merkwürdigerweise sind es aber oft genau solche nicht eingeplanten aber interessenten Dinge, die den Spaß an der Arbeit erhalten.


Freitag, 10. Juni 2016

Barcodeerkennung mit OpenCV und ZXing

Im Jahr 2015 habe ich ein Projekt zur automatisierten Übertragung von Dokumenten aus einem Warenwirtschaftssystem in ein DMS realisiert.
Dabei mussten auch unterschriebene Papierdokumente als Stapelscan automatisiert verarbeitet werden. Auf die Dokumente wurde dazu ein Barcode aufgedruckt mit dessen Hilfe eine Trennung des Stapels sowie die automatische Vorgangszuordung im DMS erfolgen konnte.

Die Barcodeerkennung habe ich mit ZXing (Zebra Crossing) realisiert. Nach Justierung der Scaneinstellungen funktioniert das sehr gut, da die Barcodes an festen Positionen auf den Dokumenten aufgedruckt sind.

Im Jahr 2016 ging es um eine ähnlich Sache. Einziger Unterschied: Es werden Papierdokumente gescannt, die per Briefpost eingehen. Auf jede erste Seite wird ein Barcodeaufkleber aus einem Etikettendrucker geklebt.
Im Anschluss werden die Dokumente im Stapel gescannt. Bei der Stapelverarbeitung muss zunächst eine Trennung anhand der Barcodes erfolgen. Danach wird über die im Barcode kodierte Nummer eine automatisierte Verarbeitung angestossen.

Leider haben erste Versuche mit der ZXing-Lösung eine sehr schlechte Erkennung geliefert. Da die Codes an freie Stellen auf den Dokumenten geklebt werden, steht die Position nicht fest. Der Barcode kann sich theoretisch überall befinden.
Nach einigen Versuchen stellte sich schnell heraus, dass ZXing frei positionierte Barcodes nur sehr mäßig findet.

Ich habe dann recherchiert, ob OpenCV eine Möglichkeit zum Auffinden der Barcodes bietet. Ich bin auf folgenden Artikel gestossen:

http://www.pyimagesearch.com/2014/11/24/detecting-barcodes-images-python-opencv

Für mein Projekt musste ich diese Vorgehensweise etwas verändern aber grundsätzlich verwende ich dieselbe Technik.

Ausgangsbasis


Ausgangsbasis sind mehrseitige PDF-Dateien mit 300dpi schwarz/weiss Scans. Das folgende Bild zeigt ein Beispiel.

Gescannte Seite mit Barcodes

Der obere Barcode (316000003) wurde aufgeklebt. Der andere ist fest aufgedruckt. Im aktuellen Projekt ist nur der Aufkleber relevant.

PDF Verarbeitung


Der Scan liegt als mehrseitiges PDF vor. Die PDF-Dateien verarbeite ich mit Apache PDFBox zu je einem BufferedImage pro Seite.

// Create PDFRenderer
PDFRenderer renderer = new PDFRenderer(pdDocument);  
  
// Scale = resolution / 72DPI
BufferedImage bi = renderer.renderImage(pageNum,
   resolution / (float) 72, ImageType.GRAY);


Zur Weiterverarbeitung mit OpenCV muss das BufferedImage in eine OpenCV Mat überführt
werden. Da beim PDF-Rendering der Type ImageType.GRAY übergeben wurde, kann das BufferedImage einfach in eine CvType.CV_8UC1 Mat überführt werden:

mat = new Mat(bi.getHeight(), bi.getWidth(), CvType.CV_8UC1);
mat.put(0, 0, ((DataBufferByte) bi.getRaster().getDataBuffer()).getData());


Finden möglicher Barcodes in einem Dokument (OpenCV)


Im ersten Schritt sollen mit OpenCV Teilbereiche des Dokuments erkannt werden, in denen ein Barcode vermutet wird. Nach vielen Versuchen erwies es sich als vorteilhaft, das Ausgangsdokument zunächst mit einem Weichzeichner zu bearbeiten.

Nach weiteren Tests hat sich der Weichzeichner als kontraproduktiv erwiesen. Im Endeffekt werden dadurch die scharfen Ränder, die zur Erkennung des Barcodes wichtig sind, verwischt.


Im nächsten Schritt wird wie im o.g. Link der Scharr-Filter angewendet. Dieser arbeitet mit vertikalen bzw. horizontalen Gradienten. Barcode Bereiche haben hohe vertikale Gradienten (senkrechte Striche).


// Scharr
Mat gradX = new Mat();
Mat gradY = new Mat();

// horizontal    
Imgproc.Sobel(blurMat, gradX, -1, 1, 0, -1, 1, 0);
// vertical
Imgproc.Sobel(blurMat, gradY, -1, 0, 1, -1, 1, 0);

// Subtract
Core.subtract(gradX, gradY, mat);

Core.convertScaleAbs(mat, mat2);

Durch Subtraktion werden waagerechte Linien schwächer. Die letzte Code-Zeile habe ich von der Website übernommen. Was diese genau bewirkt weiss ich noch nicht ;)
Die folgende Abbildung zeigt das Zwischenergebnis in mat2. Man erkennt, dass vertikale Linien betont sind und horizontale Linien geschwächt bzw. eliminiert sind.

Verarbeitung mit Scharr Algorithmus

Wie auf der Website beschrieben sollen nun Rechtecke geschlossen werden. Angepasst auf die 300dpi und die vorkommenden Barcodes haben sich x=30 und y=5 bewährt.
Bei dem anderen Barcode stehen die Balken teilweise recht weit auseinander. Ein kleineres X führt dazu, dass 2 getrennte Bereiche erkannt werden.

// Close rectangles
Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(30,5));
Imgproc.morphologyEx(mat2, mat, Imgproc.MORPH_CLOSE, kernel);  

Das folgende Bild zeigt das Zwischenergebnis. Es sind viele geschlossene Rechtecke entstanden. Die beiden Barcodes fallen jetzt schon sehr deutlich auf so dass also auch der Rechner sie nun bald sehen wird ;)

Rechtecke wurden geschlossen

Wie auf der Website beschrieben kommen nun 2 Techniken zum Einsatz, um alles ausser den Barcodes zu eliminieren.

Imgproc.erode(mat, mat2, Imgproc.getStructuringElement(
    Imgproc.MORPH_RECT, new Size(50,20)), new Point(-1, -1), 4);

Zunächst werden durch 4-mailige Erosion (Iteration=4) weisse Bereiche entfernt bzw. verkleinert. Das Zwischenergebnis enthält ein fast komplett schwarzes Bild. Die verwendete Size (x,y) muss so angepasst werden, dass möglichst keine falschen Bereiche übrig bleiben aber die Barcodes nicht vernichtet werden.
Im Endeffekt muss man hier eher defensiv vorgehen. Es dürfen keine Barcodes verloren gehen. Ein irrtümlich erkannter Barcode-Bereich liefert zum Abschluss einfach keinen gültigen Barcode und wird verworfen.

Das folgende Bild zeigt das Erode Ergebnis.

Ergebnis nach Erosion
Da die Barcodebereiche stark verkleinert wurden müssen diese im nächsten Schritt wieder erweitert werden.
Die geschieht mit der Funktion dilate (Pendant zu erode). Die Aufrufparameter müssen an die Erosions-Parameter angepasst werden.


Imgproc.dilate(mat2, mat, Imgproc.getStructuringElement(
    Imgproc.MORPH_RECT, new Size(50,20)), new Point(-1, -1), 4);


Im Ergebnis sind die Bereiche der Barcodes weiss und der Rest schwarz.

Erweitertes Bild

Nun wird eine Konturensuche durchgeführt. Die Grenzlinien zwischen schwarz und weiss liefern je eine Kontur.
Die gefundenen Konturen werden durchlaufen und das einschließende Rechteck (BoundingBox) gebildet. Dieses Rechteck wird links und rechts um 10% erweitert.
Mit Hilfe des Rechtecks wird der Teilbereich aus dem Originalbild ausgeschnitten und wieder in ein BufferedImage überführt.
Das BufferedImage kann dann mit ZXing weiter verarbeitet werden. Dazu ggf. später noch etwas wobei dies eine einfache Sache ist und online gut erklärt wird.

// Ergebnisliste für Konturensuche
List contours = new ArrayList<>();
    
Imgproc.findContours(mat, contours, new Mat(),
    Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);
    
for (Iterator j = contours.iterator(); j.hasNext(); ) {
  MatOfPoint contour = j.next();
   
  // Bounding Box der Kontur
  Rect boundingBox = Imgproc.boundingRect(contour);
  
  int increase = (int) (boundingBox.width * 0.1);
   
  boundingBox.x -= increase;    
  if (boundingBox.x < 0) boundingBox.x = 0;
       
  boundingBox.width += 2* increase;
   
  if (boundingBox.x + boundingBox.width > blurMat.width()) {
     boundingBox.width = blurMat.width() - boundingBox.x;
  }

  // Extract area from blurred original image       
  Mat codeMat = blurMat.submat(boundingBox);

  // Create BufferedImage   
  BufferedImage bi = new BufferedImage(codeMat.width(), codeMat.height(),
     BufferedImage.TYPE_BYTE_GRAY);
   
  byte[] data = ((DataBufferByte) codeImage.getRaster().
        getDataBuffer()).getData();
  codeMat.get(0, 0, data);
   
  // Add BufferedImage to result or whatever ...   
  result.add(codeImage);
}


Mit den bisher getesteten Beispieldokumenten funktioniert die Erkennung zu 100%.

Auslieferung von OpenCV


Mein Entwicklungssystem ist ein Ubuntu 14.04 mit Eclipse. OpenCV habe ich über die Paketverwaltung installiert und in Eclipse als User Library eingerichtet. Mehr dazu hier:

http://pinnau.blogspot.de/2015/06/zahlen-in-explosionszeichnungen.html

Die fertige Barcode Routine soll als möglichst schlankes Programm beim Kunden unter Windows laufen.
Nach dem Download von OpenCV für Windows habe ich zunächst einen Schreck bekommen. Das entpackte Paket ist 2,7 GB groß.
Man benötigt für ein Java-Programm (32-bit) aber nur ganze 2 Dateien aus der OpenCV Installation:

  1. Java Library: build/java/opencv-VERSION.jar
  2. JNI Library: build/java/x86/opencv_javaVERSION.dll

Die Java Library muss logischerweise im Classpath liegen. Der Pfad zum Verzeichnis der JNI-Library muss der JVM über -Djava.library.path mitgeteilt werden. Relative Pfade werden dabei unterstützt. Ich habe die DLL einfach in ein Unterverzeichnis native gelegt:

java -Djava.library.path=native -cp lib/opencv-2413.jar biz.pinnau.barcode.MainClass ...



Fazit


Fürs erste bin ich zufrieden. Die Sache läuft. Ich hätte nicht gedacht dass man zum Auffinden von frei positionierten Barcodes einen solchen Zirkus machen muss.
Wenn ich dann an autonom fahrende Autos in der Stadt denke wird mit Angst und Bange ;)

Sobald das Projekt an den Start geht werde ich sehen wo es noch klemmt.


Mittwoch, 9. März 2016

Scaleable PDF preview (first page) in SWT using PDF.js and XulRunner

I desired to have a fast PDF preview in my Document Management Software. The DMS is based on Eclipse RCP (SWT).

The best way to render PDF in SWT is to use Mozilla PDF.js in an embedded org.eclipse.swt.browser.Browser using the SWT.MOZILLA flag (XulRunner).

Setting up XulRunner with different Eclipse/SWT versions and OS-architectures is not quite easy and would fill an own blog post.
I assume that there is a ready-to-use SWT browser created with SWT.MOZILLA flag.

Before implementing the preview we need the following setup steps.


Download PDF.js


Clone or download Mozilla PDF.js: https://mozilla.github.io/pdf.js. Place the sources in your project directory.

Enable local file access


For security reasons local file access (using the file:// protocol) is disabled in XulRunners JavaScript setup.
If yout want to display documents using the file:// protocol you must first disable the option

security.fileuri.strict_origin_policy

Do so by going to URL about:config in the embeded browser and change the setting.


Test with the shipped full featured PDF viewer


PDF.js comes with a full featured PDF viewer. The following code creates a SWT Browser and points the URL to PDF.js viewer.html. The PDF document to display is passed via URLs query string.


// Create Browser with SWT.MOZILLA flag (XulRunner)
Browser browser = new Browser(parent, SWT.MOZILLA);
  
// Sample PDF document to display
File file = new File("documents/test.pdf");
  
// Set URL
browser.setUrl("file://pdfjs/web/viewer.html?file=" +
  URLEncoder.encode(file.toURI().toString(), "utf-8") );

You will have to adjust the locations of the sample PDF and the PDF.js web directory.

If everything is setup correctly you will see the sample document viewer in your application. The PDF.js viewer is nice and offers navigation, zooming etc.


Implement one page preview


To have just a lightweight preview of the document my wishlist contained the follwing:
  1. PDF rendering using PDF.js
  2. Only first page should be rendered
  3. Scale to 100% of available width
  4. No toolbar and user actions available

The solution is quite simple. You will have to create a new HTML-file:


<body onload="load()">

  <canvas id="the-canvas" 
    style="border: 1px solid black; width: 100%;">

</canvas></body>


The HTML is basically taken from the PDF.js example. I only added width:100% to CSS attributes.

The follwing JavaScript will extract the PDF URL from the site URL and renders page 1:


 function parseQueryString(query) {  
    var parts = query.split('&');  
    var params = {};  
      
    for (var i = 0, ii = parts.length; i < ii; ++i) {  
       var param = parts[i].split('=');  
       var key = param[0].toLowerCase();  
       var value = param.length > 1 ? param[1] : null;  
       params[decodeURIComponent(key)] = decodeURIComponent(value);  
    }  
      
    return params;  
 }  
   
 function load() {  
    var DEFAULT_URL = '../web/compressed.tracemonkey-pldi-09.pdf';  
   
    var queryString = document.location.search.substring(1);  
    var params = parseQueryString(queryString);  
    var file = 'file' in params ? params.file : DEFAULT_URL;  
   
    PDFJS.getDocument(file).then(function(pdf) {  
       pdf.getPage(1).then(function(page) {  
          var canvas = document.getElementById('the-canvas');  
          var context = canvas.getContext('2d');  
            
          var viewport = page.getViewport(1);  
          var scale = canvas.width / viewport.width;  
          var scaledViewport = page.getViewport(scale);           
            
          canvas.height = viewport.height;  
          canvas.width = viewport.width;  
            
          var renderContext = {  
           canvasContext: context,  
           viewport: viewport  
          };  
            
          page.render(renderContext);      
       });  
    });  
 }  


All that is left to do is to open the new HTML file instead of the shipped viewer.html.

Preview integrated in RCP application

The image shows the result. The preview is placed in a seperate ViewPart and scales smoothly when resizing the part.
With the platforms SelectionService the preview follows the Workbench Selection. The preview will then always show the currently selected document - no matter where in the application it is selected.





Freitag, 19. Februar 2016

Uhr auf Windows Server läuft zu langsam

Bei einem Kunden wurden neue Server Hardware in Betrieb genommen. Bisher waren drei baugleiche Hardware-Server im Einsatz (Windows Server 2008 R2). Auf diesen läuft HyperV und die eigentlichen Serversysteme sind darauf virtualisiert.

Bisher lief das alles erstaunlich gut aber die Hardware ist in die Jahre gekommen. An einem der Server ist nun auch eine Platte des RAID-Verbundes ausgefallen.

Der neue Server soll 2 der alten Geräte ersetzen, bekommt Server 2012 und virtualisiert ebenfalls mit HyperV.

Nach dem Einbau wurde begonnen, die virtualisierten System auf den neuen Host zu verschieben. Auch das klappt gut.
Zuerst wurde das Gerät mit der defekten Platte frei gezogen und dann heruntergefahren.

Einen Tag später fingen die Probleme an ... Zu Arbeitsbeginn spinnen die Windows Freigaben und der Exchange verweigert die Verbindung. Als Fehler wird eine unzulässige Zeitabweichung angegeben.
Ein Blick auf die Uhrzeiten offenbart, dass der wichtigste der verbleibenden 2 alten Server gute 10 Minuten nachgeht. Der andere geht ebenfalls nach aber nicht ganz so dramatisch.

Nach einer Online Synchronisierung der Uhr und Kontrolle der Einstellungen geht alles wieder und der nächste Tag kann kommen.
Er beginnt mit dem gleichen Problem. Die Uhr geht wieder gute 10 Minuten nach. Ich stelle noch einmal nach und kontrolliere die Einstellungen.

Ich behalte die Zeiten nun im Blick. Auf beiden Altgeräten läuft die Uhr wesentlich zu langsam, so dass sich pro Stunde ca. 30 Sekunden Nachlauf ergeben.
Der Online Zeitabgleich geschieht unter Windows standardmäßig nur ein Mal pro Woche. Das reicht in diesem Fall in keinster Weise.

Nach etwas Recherche stelle ich fest, dass solche Probleme merkwürdigerweise recht verbreitet sind. Einzige zuverlässige Lösung:

Über die Aufgabenplanung einen Task einrichten, der in kurzen Abständen die Synchronisierung auslöst. Die geht mit dem Kommando

w32tm.exe /resync

Die Onlinesynchronisierung der Systemzeit muss dazu jedoch aktiviert sein und der Windows Zeitgeber Dienst muss laufen. Außerdem muss der Task mit der Option höchste Privilegien eingerichtet werden.

Ich lasse diesen Task auf beiden Altsystemen jetzt alle 30 Minuten laufen und damit reduziert sich der Nachlauf der Uhr auf maximal ca. 15 Sekunden.

Frage Nummer 1 ist, warum die Uhren dieser Server so langsam laufen. Man müsste mal einen Blick in die BIOS Einstellungen werfen aber das geht bei 24x7 nicht so ohne weiteres und Remote ohnehin nicht.

Frage Nummer 2 ist, warum das erst jetzt akut geworden ist. Die einzige logische Erklärung ist, dass der Vorgänger Administrator auf dem im Zuge der Migration abgeschalteten Altserver einen ähnlichen Task eingerichtet hatte, der per RPC auch die Zeitsynchronisierung auch auf den anderen beiden Maschinen ausgelöst hat.
Nach Abschalten dieser Maschine nahmen die Dinge ihren Lauf ...

Montag, 25. Januar 2016

Baum Abfragen mit SQL und CTE (Common Table Expression)

Ich habe eine interessante Projektanfrage bekommen. Es geht um die Erstellung von Auswertungen für Telefondaten z.B. aus einem Call Center.
Die Daten liegen in einer SQL-Datenbank vor und der aktuelle Bearbeiter kommt nicht so richtig weiter.
Ein Problem ist, dass die Weiterverbindung von Anrufen in einer Baumstruktur gespeichert wird. Zur Auswertung müssen Daten aller verbundenen Knoten (Teilnehmer) aggregiert werden.

Mit Programmlogik sicher kein Problem aber nur mit SQL? Mir fällt spontan Oracles CONNECT BY PRIOR ein aber es ist ein anderes DB-System.

Ich recherchiere dazu und stoße schließlich auf CTE. Common Table Expression sind im SQL 99 Standard definiert und werden von vielen großen Datenbanksystemen unterstützt (z.B. Oracle, DB/2, MSSQL Server, PostGre und mehr).

Die Syntax ist etwas gewöhnungsbedürftig und ich habe mir ein kleines Beispiel erstellt.


Tabelle mit Anrufen:

create table phone_call (
  id int primary key,
  delay int not null,
  duration int not null,
  parent_id int,
  agent varchar2(10)
);


  • id: Primärschlüssel des Anrufs
  • delay: Verzögerung in Sekunden bis ein Mitarbeiter abnimmt
  • duration: Dauer des Gesprächs
  • parent_id: Falls der Anruf innerhalb des Callcenters weiter verbunden wurde verweist parent_id auf den Anruf von dem weiterverbunden wurde.
  • agent: Mitarbeiter des Callcenters (vereinfacht ohne Relation)

Die folgenden Inserts erzeugen Beispieldaten:

INSERT INTO phone_call VALUES(1, 3, 20, null, 'Müller');
INSERT INTO phone_call VALUES(2, 4, 15, 1, 'Meier');
INSERT INTO phone_call VALUES(3, 3, 300, 2, 'Schulze');

INSERT INTO phone_call VALUES(4, 5, 200, null, 'Pinnau');

INSERT INTO phone_call VALUES(5, 3, 20, null, 'Schulze');
INSERT INTO phone_call VALUES(6, 5, 120, 5, 'Pinnau');


  • Die Beispieldaten loggen 3 von aussen eingehende Anrufe (erkennbar an parent_id = null). Der 1. Anruf wird von Müller weiter zu Meier und dann zu Schulze verbunden.
  • Der 2. Anruf bleibt bei Pinnau und wird nicht weiterverbunden.
  • Der 3. Anruf wird von Schulze angenommen und zu Pinnau verbunden.

Es soll jetzt eine Auswertung gemacht werden. Im Ergebnis soll jeder von aussen eingehende Anruf ausgegeben werden und folgende Statistiken geliefert werden:

  1. Gesamtzeit des Telefonats (alle Stationen: delay + duration)
  2. Gesamtzeit Warten (alle Stationen: delay)
  3. Gesamtzeit Sprechen (alle Stationen: duration)
  4. Anzahl der Weiterverbindungen

Für den Anfang starte ich mit folgendem CTE-SQL Block:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
        WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, 0
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT * from call_tree

Die entscheidenden Zeilen befinden sich innerhalb des WITH-Blocks. Um einen Baum von oben nach unten zu traversieren, startet man zunächst mit der Abfrage der Wurzel-Element. Im Beispiel sind das alle Anruf-Datesätze mit parent_id is null:

SELECT id, delay, duration, 0 FROM phone_call WHERE parent_id is null

Mit einem UNION ALL werden dann die Ergebnisse der rekursiven Abfrage (Kinder und Kindeskinder) hinzugefügt:

SELECT id, delay + c_delay, duration + c_duration, 0
    FROM phone_call, call_tree WHERE parent_id = c_id

Im rekursive Teil  werden die Tabellen phone_call und call_tree (WITH-Alias) per INNER JOIN verbunden (WHERE parent_id = c_id).

Das Ergenbnis sieht schonmal ganz gut aus. Es werden alle Anrufstationen mit den im Baum aufgelaufenen Summen für delay und duration ausgegeben.
C_ID C_DELAY C_DURATION C_HOPS
1 3 20 0
4 5 200 0
5 3 20 0
2 7 35 0
6 8 140 0
3 10 335 0

Es gibt noch 3 Probleme:

  1. c_hops ist immer 0 (Anzahl der Weiterverbindungen)
  2. Die Ergebnismenge enthält für jeden Datensatz aus phone_call eine Zeile. Es soll nur eine Zeile pro eingehendem Anruf kommen.
  3. Gesamtzeit aus duration + delay fehlt noch

Um die Hops zu ermitteln muss im rekursiven Teil die 0 nur durch c_hops+1 ersetzt werden. Im Initialteil bleibt die 0 stehen, damit der Hop-Zähler bei 0 anfängt:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
        WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, c_hops+1
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT * from call_tree

Jetzt sollen nur noch die letzten Datensätze im Baum (also die Blätter) angezeigt werden. Das ist etwas schwierig. Mir ist erstmal nichts besseres eingefallen als in der äußeren Abfrage mit einem COUNT()-Subquery zu filtern:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
        WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, c_hops+1
       FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT * from call_tree

-- Only leafs: children count = 0
WHERE ( SELECT count(*) from phone_call WHERE parent_id = c_id ) = 0

Im letzten Schritt muss noch die Summe aus delay und duration eingebaut werden:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
       WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, c_hops+1
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT c_id, c_delay + c_duration as c_total, c_delay,
    c_duration from call_tree
WHERE (SELECT count(*) from phone_call WHERE parent_id = c_id) = 0

Ergebnis:
C_ID C_TOTAL C_DELAY C_DURATION
6 148 8 140
4 205 5 200
3 345 10 335



Man könnte noch mehr machen und z.B. auch die Namen der an der Anrufkette beteiligten Mitarbeiter als verketteten VARCHAR ausgeben usw.

Verbesserung


Für das konkrete Beispiel ist mir noch eine wesentliche Verbesserung eingefallen mit der auch das COUNT Subquery entfallen kann.
Dazu wird die Root-ID in alle Datensätze aufgenommen. Diese wird im Initial-Query selektiert und in den rekursiven Abfragen unverändert durchgeleitet. Damit kann man zum Schluß nach der Root-ID gruppieren und die Aggregate für jeden Ast des Baumes bilden:


WITH call_tree (c_id, c_delay, c_duration, c_root_id) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, id FROM phone_call
       WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay, duration, c_root_id
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT c_root_id, sum(c_delay + c_duration) as c_total,
    sum(c_delay) as c_delay, sum(c_duration) as c_duration,
    count(c_root_id) - 1 as hops from call_tree
GROUP BY c_root_id

Die Hops ermittelt man mit COUNT(c_root_id) - 1.

Freitag, 8. Januar 2016

DMS-spezifische Berechtigungen in Dateisystemen (NTFS)

Digitales Document Management ist eines der aktuellen Themen in der IT. Am Markt gibt es viele Systeme die aber alle ihre eigene Suppe kochen.

Ich arbeite intensiv an einer anderen Lösung und ich möchte bei der Speicherung der Dokumente auf vorhandenes setzen.
Im einfachsten Fall werden die Dokumente (PDF) also in einer Ordnerstruktur im Dateisystem abgelegt.
Ein solcher Dokumentbestand wird auch in 20 Jahren problemlos lesbar sein ohne das man spezielle Software aus einem Museum beschaffen und auf der dann vorhandenen Technik zum Laufen bringen muss.

Auf meinem Wunschzettel stehen allerdings ein paar spezielle Berechtigungseinstellungen, die ich bisher noch nicht mit den gebräuchlichen Dateisystemen realisiert habe:


  1. Lese- und Schreibzugriff auf Dateien, jedoch kein Recht zum Löschen von Dateien.
  2. Löschen von leeren Ordnern ohne Inhalt. Jedoch kein Löschen von Ordnern, die Dateien enthalten.
  3. Verschieben von Ordnern soll unterbunden werden.
  4. Nutzer können Dateien in einen Ordner schreiben (Kopieren oder Verschieben), haben danach aber keinen Zugriff mehr darauf. Man muss sich das wie einen Briefkasten vorstellen: Man kann einen Brief für den Besitzer des Briefkastens einwerfen. Man kann aber weder den Inhalt des Briefkastens sehen noch den eingeworfenen Brief oder andere Briefe lesen oder herausnehmen.

Die genannten Szenarios werden relevant sobald mehrere Nutzer an einem digitalen Dokumentbestand arbeiten:


  1. Löschen ist in der Regel unerwünscht und sollte nur durch spezielle Mitarbeiter möglich sein. Ein versehentliches Löschen von Dateien (z.B. durch Drücken der Entfernen-Taste) muss unbedingt verhindert werden.
  2. Da die Ordnerstruktur automatisch aufgebaut wird ist das Verschieben von kompletten Ordnern nicht sinnvoll.
  3. Einzelne Nutzer sollen ggf. Dokumente in den Bestand einstellen danach aber keine Änderungen daran machen können und ggf. auch keinen Lesezugriff haben (Briefkasten).

Ich habe mir zunächst NTFS genauer angesehen. Über die erweiterten Einstellungen stehen insgesamt 14 Berechtigungen zur Verfügung.

Lese- und Schreibzugriff, jedoch kein Recht zum Löschen


Dies lässt sich mit NTFS grundsätzlich realisieren. Der entscheidende Punkt ist der Unterschied zwischen den Berechtigungen Schreiben und Ändern:

  1. Schreiben: Dateiinhalt kann geändert werden, d.h. eine Textdatei kann z.B. über einen Editor bearbeitet werden.
  2. Ändern: Datei kann gelöscht, verschoben oder umbenannt werden.


Damit hat die Sache aber auch schon einen gravierenden Haken. Sobald das Recht Ändern verwehrt wird, kann die Datei auch nicht mehr umbenannt oder verschoben werden.
Technisch laufen Löschen, Verschieben und Umbenennen in nahezu allen Dateisystemen auf dieselbe Operation hinaus.

Für Document Managment bedeutet dies leider, dass die NTFS-Berechtigungen nicht zur Umsetzung der Anforderung geeignet sind.
Das Umbenennen oder Verschieben der Datei innerhalb der Archivstruktur soll möglich sein - nur das Löschen nicht.


Ich bin nicht der erste, der diese Anforderungen hat. Das Netz ist voll von Fragen dazu und es kommen immer die gleichen ernüchternden Antworten.
Zumindest lässt sich die Anforderung teilweise umsetzen. Der Nutzer wäre in der Lage, ein Dokument in den Archivbestand zu übernehmen. Der Inhalt des Dokuments bleibt änderbar, d.h. es können z.B. Seiten angehängt oder Notizen eingefügt werden.
Das Dokument kann nicht gelöscht werden. Allerdings kann er das Dokument auch nicht mehr umbenennen oder innerhalb des Archivs verschieben.

Briefkasten


Der Briefkasten kann grundsätzlich mit NTFS realisiert werden. Dazu muss man die speziellen Berechtigungen setzen:


  • Entziehen: Recht Dateien auflisten/ Daten lesen
  • Entziehen: Recht Ordner durchsuchen / Dateien ausführen
  • Erlauben: Dateien erstellen / Daten schreiben
  • Erlauben: Attribute schreiben
  • Erlauben: Erweiterte Attribute schreiben


Damit kann ein Nutzer Dokumente im entsprechenden Verzeichnis ablegen hat aber weder Lesezugriff auf den Ordner noch auf die Dateien, die sich in diesem Ordner befinden.

Die Sache hat aber wieder einen Haken: Der Nutzer kann im Ordner vorhandene Dateien überschreiben.
Dies widerspricht dem Briefkasten Prinzip. Es ist dabei auch irrelevant ob die zu überschreibende Datei vom Nutzer selbst oder einem anderen Nutzer stammt.




FAZIT


Leider lassen sich trotz der komplexen Einstellungsmöglichkeiten im NFTS die Anforderungen nur begrenzt umsetzen.

Welche Schlussfolgerungen ich daraus ziehe weiss ich noch nicht.


Dienstag, 5. Januar 2016

Umgang mit verschiedenen Rastern und Maßstäben mit Hilfe von Promille Koordinaten

Heute habe ich Quellcode in meinem java-basierten DMS verbessert. Über die betroffenen Funktionen wird dem Nutzer eine Miniaturansicht von PDF-Seiten angezeigt. Mit der Maus kann eine Position bzw. ein Bereich auf einer Dokumentseite festgelegt werden.
Die grafische Darstellung ist mit Hilfe von SWT und Apache PDFBox umgesetzt. Grundsätzlich ist die PDF-Rendering Library austauschbar. Zwischendurch hatte ich JPod im Einsatz. Zur Darstellung wird ein bestimmter Maßstab (Zoomlevel) verwendet.

Die Auswahl des Bereichs ist erforderlich, um eine der folgenden Operationen durchzuführen:

  • Einfärben des Bereichs (schwärzen)
  • Aufkleben einer digitalen Haftnotiz
  • Digital stempeln

Bei der Umsetzung entsteht schnell ein Durcheinander, wenn man mit verschiedenen Koordinatensystemen, Maßstäben usw. arbeitet. Fest kodierte Umrechnungsfaktoren erschweren das Verständnis und die Wartung.
Im Beispiel kommt hinzu, dass die Y-Achse innerhalb des PDF invers ist, d.h. 0 ist unten und nicht oben.

Relative Koordinaten


Um die verschiedenen Koordinatensysteme voneinander zu entkoppeln bin ich dazu übergegangen, alle Angaben (x,y,width,height) relativ in Promille zu verarbeiten:

x(relativ) = x(absolut) / Seitenbreite * 1000
y(relativ) = y(absolut) / Seitenhöhe * 1000

width(relativ) = width(absolut) / Seitenbreite * 1000
height(relativ) = height(absolut) / Seitenhöhe * 1000

Promille bringt gegenüber Prozent einen entscheidenden Vorteil: Für die berechneten Werte reichen in der Regel Ganzzahlen (Integer) aus.

Die relativen Promille-Koordinaten werden im Zielkoordinatensystem wieder in absolute Koordinaten umgerechnet.

Das bringt folgende Vorteile:

  1. Keine Kodierung von Umrechnungsfaktoren zwischen den Koordinatensystemen
  2. Entkoppelung der Koordinatensysteme
  3. In der Darstellung verwendete Maßstäbe (Zoom) sind nicht relevant, da die relativen Verhältnisse immer gleich bleiben.
  4. Zur Transformation in relative bzw. absolute Koordinaten kann ein einfaches API verwendet werden (dazu später mehr)

Transform API


Zur Übergabe von ganzzahligen Promille Koordinaten habe ich zunächst eine ganze einfache Klasse erstellt:

/**
 * Ganzzahlige Koordinaten
 * 
 * @author Peter Pinnau (peter@pinnau.biz)
 *
 */
public class Position {
 
  public int x;
 
  public int y;
 
  public int width;
 
  public int height;

}

Für die Transformation und Berechnung der absoluten Koordinaten in den einzelnen Koordinatensystemen ist bei Apache PDFBox eine höhere Genauigkeit erforderlich. Dafür habe ich die einfache Klasse FloatPosition erstellt. Diese wird aber nur innerhalb eines Koordinatensystems verwendet.

/**
 * Float Koordinaten
 * 
 * @author Peter Pinnau (peter@pinnau.biz)
 *
 */
public class FloatPosition {

 public float x;
 
 public float y;
 
 public float width;
 
 public float height;
  
}

Zur Transformation von ganzzahligen Koordinaten (Position) in Fließkommakoordinaten (FloatPosition) und zur Berechnung von relativen, absoluten und inversen Koordinaten habe ich eine statische Klasse Transform erstellt:


1:  /**  
2:   * Statische Klasse zum Druchführen von Koordinaten- und Größentransformationen  
3:   *   
4:   * @author Peter Pinnau (peter@pinnau.biz)  
5:   *  
6:   */  
7:  public class Transform {  
8:    
9:     /**  
10:      * Erzeugt eine FloatPosition aus einer Position  
11:      * @param p  
12:      * @return  
13:      */  
14:     public static FloatPosition fromPosition(Position p) {  
15:        FloatPosition pos = new FloatPosition();  
16:          
17:        pos.x = p.x;  
18:        pos.y = p.y;  
19:          
20:        pos.height = p.height;  
21:        pos.width = p.width;  
22:          
23:        return pos;  
24:     }  
25:       
26:     /**  
27:      * Erzeugt eine FloatPosition aus x,y,width und height  
28:      *   
29:      * @param x  
30:      * @param y  
31:      * @param width  
32:      * @param height  
33:      * @return  
34:      */  
35:     public static FloatPosition create(float x, float y, float width, float height) {  
36:        FloatPosition pos = new FloatPosition();  
37:          
38:        pos.x = x;  
39:        pos.y = y;  
40:        pos.width = width;  
41:        pos.height = height;  
42:          
43:        return pos;  
44:     }  
45:       
46:     /**  
47:      * Berechnet eine absolute FloatPosition aus Promillewerten und einem Bezugsbereich  
48:      *   
49:      * @param promille  
50:      * @param area  
51:      * @return  
52:      */  
53:     public static FloatPosition fromPromille(FloatPosition promille, FloatPosition area) {  
54:        FloatPosition p = new FloatPosition();  
55:          
56:        float widthFactor = area.width / 1000;  
57:        float heightFactor = area.height / 1000;  
58:          
59:        p.x = promille.x * widthFactor;  
60:        p.y = promille.y * heightFactor;  
61:          
62:        p.width = promille.width * widthFactor;  
63:        p.height = promille.height * heightFactor;  
64:          
65:        return p;  
66:     }  
67:       
68:     /**  
69:      * Berechnet Promillewerte aus absoluten Werten und einem Bezugsbereich  
70:      *   
71:      * @param pos  
72:      * @param area  
73:      * @return  
74:      */  
75:     public static FloatPosition toPromille(FloatPosition pos, FloatPosition area) {  
76:        FloatPosition p = new FloatPosition();  
77:                
78:        p.x = pos.x * 1000 / area.width;        
79:        p.y = pos.y * 1000 / area.height;  
80:          
81:        p.width = pos.width * 1000 / area.width;  
82:        p.height = pos.height * 1000 / area.height;  
83:          
84:        return p;              
85:     }  
86:       
87:     /**  
88:      * Invertiert die Y-Achse  
89:      *   
90:      * y(neu) = area.height - y  
91:      *   
92:      * @param pos  
93:      * @param area  
94:      */  
95:     public static void invertY(FloatPosition pos, FloatPosition area) {        
96:        pos.y = area.height - pos.y;  
97:     }  
98:       
99:  }  
100:    


Alle Methoden erhalten als Übergabeparameter eine Position-Instanz (ganzzahlige, relative Promille-Koordinaten).
Die absoluten Koordinaten im benötigten Koordinatensystem werden dann mit Hilfe der statischen Methoden aus Transform berechnet:



// Als Parameter übergebene relative Koordinaten
Position position ...

// Seite des PDF
PDPage page = pdDocument.getPage(0);
    
// CROPbox (kompletter Seitenbereich)  
PDRectangle pageSize = page.getCropBox();

// FloatPosition für komplette Seite erstellen (Bezugsbereich)
FloatPosition area = Transform.create(0, 0,
    pageSize.width, pageSize.height);   

// Transform Promille Position to absolute FloatPosition
FloatPosition transformed = Transform.fromPromille(
    Transform.fromPosition(position), area);

// Y Achse invertieren (PDF Koordinaten)
Transform.invertY(transformed, area);

// => transformed enthält jetzt die absoluten Koordinaten im Zielsystem


Nachteil


Ich möchte nicht verschweigen, dass die Performance theoretisch schlechter wird. Der Grund dafür ist, dass keine direkte Umrechnung zwischen den Systemen erfolgt (fester Faktor).
Es müssen zunächst relative Koordinaten aus dem Quellsystem ermittelt werden und diese dann wieder in absolute Koordinaten des Zielsystems umgerechnet werden.

Insgesamt ergibt sich logischerweise wieder der feste Faktor, jedoch wird dieser nicht kodiert und ergibt sich immer automatisch anhand der verwendeten Koordinatensysteme.

Diesen geringen Performancenachteil kann ich in meinem System in Kauf nehmen, da keine Massenoperationen durchgeführt werden.
Die Lesbarkeit des Quelltextes hat sich erheblich verbessert und vor Allem sind die unterschiedlichen Koordinatensystem entkoppelt.