Java tutorial
/* * This file is part of TILT. * * TILT is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * TILT is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with TILT. If not, see <http://www.gnu.org/licenses/>. * (c) copyright Desmond Schmidt 2014 */ package tilt.image.page; import tilt.image.geometry.Polygon; import java.awt.Color; import java.awt.Graphics; import java.awt.Rectangle; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Set; import java.util.Arrays; import tilt.image.geometry.Point; import tilt.image.matchup.Matrix; import tilt.image.matchup.Move; import tilt.exception.*; import java.awt.image.WritableRaster; import java.awt.image.BufferedImage; import org.json.simple.*; import tilt.handler.post.Options; /** * Represent a collection of lines already recognised on a page * @author desmond */ public class Page { ArrayList<Line> lines; QuadTree qt; int medianLineDepth; public int averageLineWidth; public int averageLineDepth; int minWordGap; int numWords; Options options; /** * Convert an array of scaled column-peaks into lines * @param cols a 2-D array of scaled line-peaks going *down* the page * @param hScale the scale in the horizontal dimension * @param vScale the scale in the vertical dimension * @param numWords number of words on page * @param options options for this project * @param cropRect limits of image * @throws Exception */ public Page(ArrayList[] cols, int hScale, int vScale, int numWords, Options options, Rectangle cropRect) throws Exception { this.numWords = numWords; this.options = options; this.qt = new QuadTree(cropRect.x, cropRect.y, cropRect.width, cropRect.height); lines = new ArrayList<>(); for (int i = 0; i < cols.length - 1; i++) { ArrayList col1 = cols[i]; ArrayList col2 = cols[i + 1]; int[] list1 = new int[col1.size()]; int[] list2 = new int[col2.size()]; listToIntArray(col1, list1); listToIntArray(col2, list2); Matrix m = new Matrix(list1, list2, options); Move[] moves = m.traceBack(); int x2, z2; for (int y1 = 0, y2 = 0, j = 0; j < moves.length; j++) { Move move = (Move) moves[j]; switch (move) { case exch: x2 = (i + 1) * hScale; z2 = list2[y2] * vScale; this.update(new Point(x2, z2), list1[y1], list2[y2]); y1++; y2++; break; case ins: x2 = (i + 1) * hScale; z2 = list2[y2] * vScale; open(new Point(x2, z2), list2[y2]); y2++; break; case del: close(list1[y1], hScale); y1++; break; } } } } /** * Convert an ArrayList of Integers to an int array * @param col the ArrayList of Integers * @param list the target int array * @throws Exception medianLineWidth */ private void listToIntArray(ArrayList col, int[] list) throws Exception { if (list.length != col.size()) throw new ArrayIndexOutOfBoundsException("col not the same length as array"); for (int j = 0; j < col.size(); j++) { Object obj = col.get(j); if (obj instanceof Integer) list[j] = ((Integer) col.get(j)).intValue(); else throw new ClassCastException("object is not an Integer"); } } /** * Align a new point to an existing line, or create one * @param loc the new location in true coordinates * @param oldEnd the previous end of the line * @param newEnd the new end of the line */ private void update(Point loc, int oldEnd, int newEnd) { int i; for (i = 0; i < lines.size(); i++) { Line l = lines.get(i); if (l.open && oldEnd == l.end) { l.update(loc, newEnd); break; } } } /** * Stop a line from propagating further * @param end the scaled vertical point of the line * @param hScale width of a rectangle */ private void close(int end, int hScale) { for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); if (l.end == end) { l.close(hScale); break; } } } /** * Insert a new line in the array * @param loc the location of its leftmost position * @param end the unscaled end y-coordinate */ private void open(Point loc, int end) { int i; for (i = 0; i < lines.size(); i++) { Line l = lines.get(i); if (l.end > end) { Line line = new Line(); line.addPoint(loc, end); lines.add(i, line); break; } } if (i == lines.size()) { Line line = new Line(); line.addPoint(loc, end); lines.add(line); } } /** * Get the lines array of this page * @return an array of line objects */ public ArrayList<Line> getLines() { return lines; } /** * Add a shape to the * @param pg the shape to remember */ public void addShape(Polygon pg) { if (pg.getLine() != null) pg.getLine().add(pg); this.qt.addPolygon(pg); } /** * Print the word shapes over the top of the original image * @param original the original raster to write to */ public void drawShapes(BufferedImage original) { Graphics g = original.getGraphics(); for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); l.print(g, original.getRaster(), i + 1); } } /** * Draw the lines on the page * @param g the graphics environment */ public void drawLines(Graphics g) { g.setColor(Color.BLACK); for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); l.draw(g, i + 1); } //System.out.println("drew "+lines.size()+" lines"); } /** * Sort all lines based on their medianY position */ public void sortLines() { int i, j, k, h; Line v; int[] incs = { 1391376, 463792, 198768, 86961, 33936, 13776, 4592, 1968, 861, 336, 112, 48, 21, 7, 3, 1 }; for (k = 0; k < 16; k++) { for (h = incs[k], i = h; i < lines.size(); i++) { v = lines.get(i); j = i; while (j >= h && lines.get(j - h).getAverageY() > v.getAverageY()) { lines.set(j, lines.get(j - h)); j -= h; } lines.set(j, v); } } } /** * Work out the median word-gap and median line-depth * @param wr the raster of the twotone or cleaned image */ public void finalise(WritableRaster wr) { // compute median line depth HashSet<Integer> vGaps = new HashSet<>(); int totalWidth = (lines.size() > 0) ? lines.get(0).getWidth() : 0; int totalLineDepth = 0; for (int i = 0; i < lines.size() - 1; i++) { Line l1 = lines.get(i); Line l2 = lines.get(i + 1); vGaps.add(Math.abs(l2.getAverageY() - l1.getAverageY())); totalWidth += l2.getWidth(); totalLineDepth += l2.getMedianY() - l1.getMedianY(); } Integer[] vArray = new Integer[vGaps.size()]; vGaps.toArray(vArray); Arrays.sort(vArray); medianLineDepth = (vArray.length <= 1) ? 0 : vArray[vArray.length / 2].intValue(); averageLineWidth = totalWidth / lines.size(); averageLineDepth = totalLineDepth / (lines.size() - 1); joinBrokenLines(); } /** * Remove lines with only 1 shape that is < or = minWordGap */ public void pruneShortLines() { ArrayList<Line> delenda = new ArrayList<>(); for (int i = 0; i < this.lines.size(); i++) { Line l = lines.get(i); if (l.shapes.size() == 1) { Polygon pg = l.shapes.get(0); if (pg.getBounds().width <= minWordGap) delenda.add(l); } } for (int i = 0; i < delenda.size(); i++) this.lines.remove(delenda.get(i)); } public void removeBlankLines() { ArrayList<Line> removals = new ArrayList<Line>(); for (Line l : lines) { if (l.countShapes() == 0) removals.add(l); } for (Line rem : removals) { lines.remove(rem); } } /** * Retrieve the minimum gap between words in pixels * @return an int */ public int getWordGap() { if (minWordGap == 0) { HashMap<Integer, Integer> hGaps = new HashMap<>(); for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); l.addGaps(hGaps); } Set<Integer> keys = hGaps.keySet(); Integer[] hArray = new Integer[hGaps.size()]; keys.toArray(hArray); Arrays.sort(hArray); int runningTotal = 0; int targetGaps = (numWords - (lines.size() - 1)) - 1; //System.out.println("target gaps="+targetGaps+" numWords="+numWords); for (int i = hArray.length - 1; i >= 0; i--) { runningTotal += hGaps.get(hArray[i]).intValue(); if (runningTotal >= targetGaps) { minWordGap = hArray[i].intValue(); break; } } } return minWordGap; } /** * Retrieve the median line-height (distance between baselines) in pixels * @return an int */ public int getLineHeight() { return medianLineDepth; } /** * Join up adjacent part-words */ public void joinWords() { int nWords = 0; for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); l.mergeWords(minWordGap); nWords += l.countShapes(); } //System.out.println("recognised"+nWords+" words"); } /** * Join up broken lines BEFORE any shapes have been recognised */ void joinBrokenLines() { // 1. find lines that are less than half the average lineheight apart // and overlap by less than 10% of their overall length ArrayList<ArrayList<Line>> sets = new ArrayList<>(); int halfMedianHeight = medianLineDepth / 2; Line prev = null; ArrayList<Line> current = null; for (int i = 0; i < lines.size(); i++) { Line line = lines.get(i); if (prev != null && Math.abs(prev.getAverageY() - line.getAverageY()) < halfMedianHeight && prev.overlap(line) < 0.25f) { if (current == null) current = new ArrayList<>(); if (!current.contains(prev)) current.add(prev); current.add(line); } else if (current != null) { sets.add(current); current = null; } prev = line; } // coda if (current != null) sets.add(current); // examine closely related lines for (int i = 0; i < sets.size(); i++) { ArrayList<Line> set = sets.get(i); Line[] array = new Line[set.size()]; set.toArray(array); Arrays.sort(array); int mTotal = 0; for (int m = 0; m < set.size(); m++) { mTotal += set.get(m).end; } int newEnd = mTotal / set.size(); Line merged = new Line(); for (int j = 0; j < set.size(); j++) { Line l = set.get(j); for (int k = 0; k < l.points.size(); k++) { Point p = l.points.get(k); merged.addPoint(p, newEnd); } // remove once finished lines.remove(l); } merged.sortPoints(); // insert merged line into page int j; for (j = 0; j < lines.size(); j++) { Line l = lines.get(j); if (l.getAverageY() > merged.getAverageY()) { lines.add(j, merged); break; } } if (j == lines.size()) lines.add(merged); } } /** * Compose the shape information as GeoJson * @param pageWidth the width of the "page" (maybe a part of the image) * @param pageHeight the height of the "page" * @return the GeoJson */ public String toGeoJson(int pageWidth, int pageHeight) { JSONObject image = new JSONObject(); image.put("type", "FeatureCollection"); JSONArray bounds = new JSONArray(); // assume one bounding box for now, without rotation bounds.add(new Double(0.0)); bounds.add(new Double(0.0)); bounds.add(new Double(1.0)); bounds.add(new Double(1.0)); image.put("bbox", bounds); JSONArray features = new JSONArray(); for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); features.add(l.toGeoJSON(pageWidth, pageHeight)); } image.put("features", features); return image.toString(); } /** * Get the number of pixels per char * @param numChars the number of chars on the page * @return the fractional number of pixels per char */ public float pixelsPerChar(int numChars) { float pixelWidth = 0; for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); int[] wordWidths = l.getWidths(); for (int j = 0; j < wordWidths.length; j++) pixelWidth += wordWidths[j]; } return pixelWidth / (float) numChars; } /** * Get the widths of all the shapes on the page in pixels * @return an array of rounded shape widths as ints */ public int[] getShapeWidths() { int size = 0; for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); size += l.countShapes(); } int[] widths = new int[size]; int start = 0; for (int i = 0; i < lines.size(); i++) { Line l = lines.get(i); start += l.getShapeWidths(widths, start); } return widths; } /** * Count the number of syllables needed for words split over lines * @param nShapes number of shapes consumed by one word * @param k offset in current line * @param j index into lineStarts * @param lineStarts the linestarts (shapeOffsets) array * @return number of syllables to divide word into */ private int countSyllables(int nShapes, int k, int j, int[] lineStarts) { int nSyl = 0; while (nShapes > 0 && j < lineStarts.length) { int lineLen; if (j < lineStarts.length - 1) lineLen = lineStarts[j + 1] - lineStarts[j]; else lineLen = lines.get(j).countShapes(); int oldK = k; while (nShapes > 0 && k < lineLen) { nShapes--; k++; } // found at least one shape on line if (oldK < k) nSyl++; j++; k = 0; } return nSyl; } /** * Align word shapes in image to words in text * @param alignments an array of alignments, each [0]=shapes,[1]=words * @param shapeOffsets offsets into the shapes array for each line start * @param words the word objects on the page * @param wr raster of cleaned image * @throws AlignException */ public void align(int[][][] alignments, int[] shapeOffsets, Word[] words, WritableRaster wr) throws AlignException { if (lines.size() > 0) { // line index int j = 0; // shape index per line int k = 0; Line l = lines.get(0); l.reset(); ArrayList<Merge> merges = new ArrayList<>(); ArrayList<Split> splits = new ArrayList<>(); for (int i = 0; i < alignments.length; i++) { int[][] alignment = alignments[i]; int[] sIndices = alignment[0]; int[] wIndices = alignment[1]; while (!l.hasShape(k)) { if (j + 1 == lines.size()) { System.out.println("tried to get missing line"); break; } l = lines.get(++j); l.reset(); k = 0; } if (sIndices.length > 0 && k + shapeOffsets[j] != sIndices[0]) System.out.println("Out of sync!"); if (sIndices.length == wIndices.length) { l.setShapeWord(k++, words[wIndices[0]]); } else if (sIndices.length == 1 && wIndices.length > 1) { Word[] wObjs = new Word[wIndices.length]; for (int m = 0; m < wIndices.length; m++) wObjs[m] = words[wIndices[m]]; splits.add(new Split(l, k++, wObjs, wr)); } else if (sIndices.length > 1 && wIndices.length == 1) { int last = sIndices.length - 1; if (j == shapeOffsets.length - 1 || sIndices[last] < shapeOffsets[j + 1]) { // all fit one line merges.add(new Merge(l, shapeOffsets[j], sIndices, words[wIndices[0]])); k += sIndices.length; } else { // more than one line // m is index into sIndices int m = 0; // s is index into syllables int s = 0; // count number of syllables int nSyl = countSyllables(sIndices.length, k, j, shapeOffsets); Word[] syllables = words[wIndices[m]].spitInto(nSyl); while (m < sIndices.length) { int chunk = Math.min(l.countShapes() - k, sIndices.length - m); if (chunk == 0) { l = lines.get(++j); l.reset(); k = 0; } else { int[] slice = new int[chunk]; for (int n = 0; n < chunk; n++) slice[n] = sIndices[m++]; merges.add(new Merge(l, shapeOffsets[j], slice, syllables[s++])); k += chunk; } } } } else if (wIndices.length == 0) k++; else // no shape for word continue; } // now execute the saved merges and splits try { for (Merge m : merges) m.execute(); for (Split s : splits) s.execute(); } catch (Exception e) { throw new AlignException(e); } } } /** * Get the indices into the shapes for the page at each line start * @return an array of ints */ public int[] getShapeLineStarts() { int[] starts = new int[lines.size()]; int i = 0; int total = 0; for (Line l : lines) { starts[i++] = total; total += l.shapes.size(); } return starts; } public void resetShapes() { for (int i = 0; i < lines.size(); i++) lines.get(i).resetShapes(); } public Polygon shapeForPoint(Point p) { return this.qt.pointInPolygon(p); } public void joinLines() { ArrayList<Line> merges = new ArrayList<>(); for (int i = 1; i < lines.size(); i++) { Line l = lines.get(i); Line p = lines.get(i - 1); // is l to the left of p? float end = l.points.get(l.points.size() - 1).x; float start = p.points.get(0).x; if (end <= start) { float lY = l.points.get(l.points.size() - 1).y; float pY = p.points.get(0).y; if (Math.abs(lY - pY) <= medianLineDepth / 2) merges.add(l); } //is p to the left of l? end = p.points.get(p.points.size() - 1).x; start = l.points.get(0).x; if (end <= start) { float pY = p.points.get(p.points.size() - 1).y; float lY = l.points.get(0).y; if (Math.abs(lY - pY) <= medianLineDepth / 2) merges.add(l); } } for (int i = 0; i < merges.size(); i++) { Line l = merges.get(i); int index = lines.indexOf(l); l.mergeWith(lines.get(index - 1)); lines.remove(index - 1); } } }