From 4871e55f2d3fa2094868db35c899f7d376c59638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominique=20Eifl=C3=A4nder?= Date: Thu, 15 Feb 2024 16:54:07 +0100 Subject: [PATCH] More refactoring --- .../processor/LayoutParsingPipeline.java | 4 +- .../services/docstrum/DocstrumSegmenter.java | 88 +++--- .../services/docstrum/LineBuilderService.java | 48 ++++ .../docstrum/NearestNeighbourService.java | 78 +++++ .../services/docstrum/SpacingService.java | 55 ++++ .../services/docstrum/ZoneBuilderService.java | 84 ++++++ .../model/refactor/docstrum/Character.java | 51 +--- .../refactor/docstrum/CharacterLine.java | 111 +++++++ .../refactor/docstrum/CharacterZone.java | 17 ++ .../services/visualization/PdfDraw.java | 270 ++++++++++++++++++ .../server/graph/ViewerDocumentTest.java | 2 + .../resources/files/VV-640252-Seite16.pdf | Bin 0 -> 17184 bytes 12 files changed, 727 insertions(+), 81 deletions(-) create mode 100644 layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/LineBuilderService.java create mode 100644 layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/NearestNeighbourService.java create mode 100644 layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/SpacingService.java create mode 100644 layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/ZoneBuilderService.java create mode 100644 layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterLine.java create mode 100644 layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterZone.java create mode 100644 layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/visualization/PdfDraw.java create mode 100644 layoutparser-service/layoutparser-service-server/src/test/resources/files/VV-640252-Seite16.pdf diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/LayoutParsingPipeline.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/LayoutParsingPipeline.java index 95ce4c6..f36444b 100644 --- a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/LayoutParsingPipeline.java +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/LayoutParsingPipeline.java @@ -90,7 +90,7 @@ public class LayoutParsingPipeline { RedactManagerBlockificationService redactManagerBlockificationService; LayoutGridService layoutGridService; ObservationRegistry observationRegistry; - // DocstrumSegmenter docstrumSegmenter; + DocstrumSegmenter docstrumSegmenter; HierarchicalReadingOrderResolver hierarchicalReadingOrderResolver; @@ -251,7 +251,7 @@ public class LayoutParsingPipeline { // Docstrum AtomicInteger num = new AtomicInteger(pageNumber); - var zones = new DocstrumSegmenter().segmentPage(stripper.getTextPositionSequences()); + var zones = docstrumSegmenter.segmentPage(stripper.getTextPositionSequences()); zones = hierarchicalReadingOrderResolver.resolve(zones); List pageBlocks = new ArrayList<>(); diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/DocstrumSegmenter.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/DocstrumSegmenter.java index 7c3c2fe..65848c0 100644 --- a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/DocstrumSegmenter.java +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/DocstrumSegmenter.java @@ -22,9 +22,16 @@ import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.mo import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.utils.BoundingBoxBuilder; import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.utils.ZoneUtils; +import lombok.RequiredArgsConstructor; + @Service +@RequiredArgsConstructor public class DocstrumSegmenter { + private final NearestNeighbourService nearestNeighbourService; + private final SpacingService spacingService; + private final LineBuilderService lineBuilderService; + public static final int MAX_ZONES_PER_PAGE = 300; private static final double DISTANCE_STEP = 16.0; @@ -167,18 +174,14 @@ public class DocstrumSegmenter { var components = positions.stream().map(chunk -> new Character(chunk)).collect(Collectors.toList()); - Character[] componentsArray = new Character[positions.size()]; - components.toArray(componentsArray); + nearestNeighbourService.findNearestNeighbors(components); - Arrays.sort(componentsArray, Character.CharacterXComparator.getInstance()); - findNeighbors(componentsArray); + double orientation = 0; - double orientation = computeInitialOrientation(components); + double characterSpacing = spacingService.computeCharacterSpacing(components); + double lineSpacing = spacingService.computeLineSpacing(components); - double characterSpacing = computeCharacterSpacing(components, orientation); - double lineSpacing = computeLineSpacing(components, orientation); - - List lines = determineLines(components, characterSpacing * COMP_DIST_CHAR, lineSpacing * MAX_VERTICAL_COMP_DIST); + List lines = lineBuilderService.buildLines(components, characterSpacing, lineSpacing); List> zones = determineZones(lines, orientation, @@ -322,34 +325,52 @@ public class DocstrumSegmenter { } - /** - * Groups components into text lines. - * - * @param components component list - * @param maxHorizontalDistance - maximum horizontal distance between components - * @param maxVerticalDistance - maximum vertical distance between components - * @return lines of components - */ - private List determineLines(List components, double maxHorizontalDistance, double maxVerticalDistance) { + private List determineLines(List characters, double characterSpacing, double lineSpacing) { - DisjointSets sets = new DisjointSets(components); + double maxHorizontalDistance = characterSpacing * COMP_DIST_CHAR; + double maxVerticalDistance = lineSpacing * MAX_VERTICAL_COMP_DIST; + +// DisjointSets sets = new DisjointSets(characters); +// AngleFilter filter = AngleFilter.newInstance(-ANGLE_TOLERANCE, ANGLE_TOLERANCE); +// for (Character component : characters) { +// for (Neighbor neighbor : component.getNeighbors()) { +// double x = neighbor.getHorizontalDistance() / maxHorizontalDistance; +// double y = neighbor.getVerticalDistance() / maxVerticalDistance; +// if (filter.matches(neighbor) && x * x + y * y <= 1) { +// sets.union(component, neighbor.getCharacter()); +// } +// } +// } +// List lines = new ArrayList(); +// for (Set group : sets) { +// List lineComponents = new ArrayList(group); +// lineComponents.sort(Comparator.comparingDouble(Character::getX)); +// lines.add(new ComponentLine(lineComponents)); +// } +// return lines; + + DisjointSets sets = new DisjointSets<>(characters); AngleFilter filter = AngleFilter.newInstance(-ANGLE_TOLERANCE, ANGLE_TOLERANCE); - for (Character component : components) { - for (Neighbor neighbor : component.getNeighbors()) { + + characters.forEach(character -> { + character.getNeighbors().forEach(neighbor -> { double x = neighbor.getHorizontalDistance() / maxHorizontalDistance; double y = neighbor.getVerticalDistance() / maxVerticalDistance; - if (filter.matches(neighbor) && x * x + y * y <= 1) { - sets.union(component, neighbor.getCharacter()); + if (filter.matches(neighbor) && Math.pow(x, 2) + Math.pow(y, 2) <= 1) { + sets.union(character, neighbor.getCharacter()); } - } - } - List lines = new ArrayList(); - for (Set group : sets) { - List lineComponents = new ArrayList(group); - Collections.sort(lineComponents, Character.CharacterXComparator.getInstance()); + }); + }); + + List lines = new ArrayList<>(); + sets.forEach(group -> { + List lineComponents = new ArrayList<>(group); + lineComponents.sort(Comparator.comparingDouble(Character::getX)); lines.add(new ComponentLine(lineComponents)); - } + }); + return lines; + } @@ -508,7 +529,7 @@ public class DocstrumSegmenter { this.y1 = a + b * this.x1; } else if (!components.isEmpty()) { Character component = components.get(0); - double dx = component.getChunk().getWidthDirAdj() / 3; + double dx = component.getTextPosition().getWidthDirAdj() / 3; double dy = dx * Math.tan(0); this.x0 = component.getX() - dx; this.x1 = component.getX() + dx; @@ -590,14 +611,15 @@ public class DocstrumSegmenter { Character previousComponent = null; for (Character component : components) { if (previousComponent != null) { - double dist = component.getChunk().getXDirAdj() - previousComponent.getChunk().getXDirAdj() - previousComponent.getChunk().getWidthDirAdj(); + double dist = component.getTextPosition().getXDirAdj() - previousComponent.getTextPosition().getXDirAdj() - previousComponent.getTextPosition() + .getWidthDirAdj(); if (dist > wordSpacing) { BoundingBoxBuilder.setBounds(word); line.addWord(word); word = new Word(); } } - word.addChunk(component.getChunk()); + word.addChunk(component.getTextPosition()); previousComponent = component; } BoundingBoxBuilder.setBounds(word); diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/LineBuilderService.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/LineBuilderService.java new file mode 100644 index 0000000..217329d --- /dev/null +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/LineBuilderService.java @@ -0,0 +1,48 @@ +package com.knecon.fforesight.service.layoutparser.processor.services.docstrum; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.DisjointSets; +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum.Character; + +@Service +public class LineBuilderService { + + private static final double CHARACTER_SPACING_DISTANCE_MULTIPLIER = 3.5; + private static final double MAX_VERTICAL_CHARACTER_DISTANCE = 0.67; + private static final double ANGLE_TOLERANCE = Math.PI / 6; + + + public List buildLines(List characters, double characterSpacing, double lineSpacing) { + + double maxHorizontalDistance = characterSpacing * CHARACTER_SPACING_DISTANCE_MULTIPLIER; + double maxVerticalDistance = lineSpacing * MAX_VERTICAL_CHARACTER_DISTANCE; + + DisjointSets sets = new DisjointSets<>(characters); + AngleFilter filter = AngleFilter.newInstance(-ANGLE_TOLERANCE, ANGLE_TOLERANCE); + + characters.forEach(character -> { + character.getNeighbors().forEach(neighbor -> { + double x = neighbor.getHorizontalDistance() / maxHorizontalDistance; + double y = neighbor.getVerticalDistance() / maxVerticalDistance; + if (filter.matches(neighbor) && Math.pow(x, 2) + Math.pow(y, 2) <= 1) { + sets.union(character, neighbor.getCharacter()); + } + }); + }); + + List lines = new ArrayList<>(); + sets.forEach(group -> { + List lineComponents = new ArrayList<>(group); + lineComponents.sort(Comparator.comparingDouble(Character::getX)); + lines.add(new DocstrumSegmenter.ComponentLine(lineComponents)); + }); + + return lines; + } + +} diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/NearestNeighbourService.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/NearestNeighbourService.java new file mode 100644 index 0000000..5cbd406 --- /dev/null +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/NearestNeighbourService.java @@ -0,0 +1,78 @@ +package com.knecon.fforesight.service.layoutparser.processor.services.docstrum; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum.Character; +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum.Neighbor; + +@Service +public class NearestNeighbourService { + + private static final int NUMBER_OF_NEIGHBOURS = 8; + private static final double STEP = 16.0; + + + public void findNearestNeighbors(List characters) { + + if (characters.isEmpty()) { + return; + } + + characters.sort(Comparator.comparingDouble(Character::getX)); + + int maxNeighborCount = NUMBER_OF_NEIGHBOURS; + if (characters.size() <= NUMBER_OF_NEIGHBOURS) { + maxNeighborCount = characters.size() - 1; + } + + for (int i = 0; i < characters.size(); i++) { + + List candidates = new ArrayList<>(); + + int start = i; + int end = i + 1; + + double distance = Double.POSITIVE_INFINITY; + + for (double searchDistance = 0; searchDistance < distance; ) { + + searchDistance += STEP; + boolean newCandidatesFound = false; + + while (start > 0 && characters.get(i).getX() - characters.get(start - 1).getX() < searchDistance) { + start--; + candidates.add(new Neighbor(characters.get(start), characters.get(i))); + clearLeastDistant(candidates, maxNeighborCount); + newCandidatesFound = true; + } + + while (end < characters.size() && characters.get(end).getX() - characters.get(i).getX() < searchDistance) { + candidates.add(new Neighbor(characters.get(end), characters.get(i))); + clearLeastDistant(candidates, maxNeighborCount); + end++; + newCandidatesFound = true; + } + + if (newCandidatesFound && candidates.size() >= maxNeighborCount) { + distance = candidates.get(maxNeighborCount - 1).getDistance(); + } + } + clearLeastDistant(candidates, maxNeighborCount); + characters.get(i).setNeighbors(new ArrayList<>(candidates)); + } + } + + + private void clearLeastDistant(List candidates, int maxNeighborCount) { + + if (candidates.size() > maxNeighborCount) { + candidates.sort(Comparator.comparingDouble(Neighbor::getDistance)); + candidates.remove(candidates.remove(candidates.size() - 1)); + } + } + +} diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/SpacingService.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/SpacingService.java new file mode 100644 index 0000000..0cb851a --- /dev/null +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/SpacingService.java @@ -0,0 +1,55 @@ +package com.knecon.fforesight.service.layoutparser.processor.services.docstrum; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.Histogram; +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum.Character; +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum.Neighbor; + +@Service +public class SpacingService { + + private static final double SPACING_HISTOGRAM_RESOLUTION = 0.5; + private static final double SPACING_HISTOGRAM_SMOOTHING_LENGTH = 2.5; + private static final double SPACING_HIST_SMOOTHING_STANDARD_DEVIATION = 0.5; + private static final double ANGLE_TOLERANCE = Math.PI / 6; + + + public double computeCharacterSpacing(List components) { + + return computeSpacing(components, 0); + } + + + public double computeLineSpacing(List components) { + + return computeSpacing(components, Math.PI / 2); + } + + + private double computeSpacing(List components, double angle) { + + double maxDistance = Double.NEGATIVE_INFINITY; + + for (Character component : components) { + for (Neighbor neighbor : component.getNeighbors()) { + maxDistance = Math.max(maxDistance, neighbor.getDistance()); + } + } + Histogram histogram = new Histogram(0, maxDistance, SPACING_HISTOGRAM_RESOLUTION); + AngleFilter filter = AngleFilter.newInstance(angle - ANGLE_TOLERANCE, angle + ANGLE_TOLERANCE); + for (Character component : components) { + for (Neighbor neighbor : component.getNeighbors()) { + if (filter.matches(neighbor)) { + histogram.add(neighbor.getDistance()); + } + } + } + + histogram.gaussianSmooth(SPACING_HISTOGRAM_SMOOTHING_LENGTH, SPACING_HIST_SMOOTHING_STANDARD_DEVIATION); + return histogram.getPeakValue(); + } + +} diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/ZoneBuilderService.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/ZoneBuilderService.java new file mode 100644 index 0000000..a560aa4 --- /dev/null +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/ZoneBuilderService.java @@ -0,0 +1,84 @@ +package com.knecon.fforesight.service.layoutparser.processor.services.docstrum; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.DisjointSets; +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum.CharacterLine; +import com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum.CharacterZone; + +@Service +public class ZoneBuilderService { + + private static final double MIN_HORIZONTAL_DISTANCE_MULTIPLIER = -0.5; + private static final double MAX_VERTICAL_DISTANCE_MULTIPLIER = 1.2; + + private static final double MIN_HORIZONTAL_MERGE_DISTANCE_MULTIPLIER = -3.0; + + private static final double MAX_VERTICAL_MERGE_DISTANCE_MULTIPLIER = 0.5; + + private static final double MIN_LINE_SIZE_SCALE = 0.9; + + private static final double MAX_LINE_SIZE_SCALE = 2.5; + + private static final double ANGLE_TOLERANCE = Math.PI / 6; + + + public List buildZones(List lines, double characterSpacing, double lineSpacing) { + + double minHorizontalDistance = characterSpacing * MIN_HORIZONTAL_DISTANCE_MULTIPLIER; + double maxVerticalDistance = lineSpacing * MAX_VERTICAL_DISTANCE_MULTIPLIER; + double minHorizontalMergeDistance = characterSpacing * MIN_HORIZONTAL_MERGE_DISTANCE_MULTIPLIER; + double maxVerticalMergeDistance = lineSpacing * MAX_VERTICAL_MERGE_DISTANCE_MULTIPLIER; + + DisjointSets sets = new DisjointSets<>(lines); + + double meanHeight = calculateMeanHeight(lines); + + lines.forEach(outerLine -> // + lines.forEach(innerLine -> { + double scale = Math.min(outerLine.getHeight(), innerLine.getHeight()) / meanHeight; + scale = Math.max(MIN_LINE_SIZE_SCALE, Math.min(scale, MAX_LINE_SIZE_SCALE)); + + if (!sets.areTogether(outerLine, innerLine) && outerLine.angularDifference(innerLine) <= ANGLE_TOLERANCE) { + + double horizontalDistance = outerLine.horizontalDistance(innerLine) / scale; + double verticalDistance = outerLine.verticalDistance(innerLine) / scale; + + // Line over or above + if (minHorizontalDistance <= horizontalDistance && verticalDistance <= maxVerticalDistance) { + sets.union(outerLine, innerLine); + } + + // Split line that needs later merging + else if (minHorizontalMergeDistance <= horizontalDistance && verticalDistance <= maxVerticalMergeDistance) { + sets.union(outerLine, innerLine); + } + } + })); + + List zones = new ArrayList<>(); + sets.forEach(group -> { + zones.add(new CharacterZone(new ArrayList<>(group))); + }); + + return zones; + } + + + private double calculateMeanHeight(List lines) { + + double meanHeight = 0.0; + double weights = 0.0; + for (CharacterLine line : lines) { + double weight = line.getLength(); + meanHeight += line.getHeight() * weight; + weights += weight; + } + meanHeight /= weights; + return meanHeight; + } + +} diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/Character.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/Character.java index e34e6cc..0c3a0e1 100644 --- a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/Character.java +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/Character.java @@ -1,35 +1,33 @@ package com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum; -import java.util.Arrays; -import java.util.Comparator; +import java.util.ArrayList; import java.util.List; import com.knecon.fforesight.service.layoutparser.processor.model.text.RedTextPosition; import lombok.Data; -import lombok.Getter; @Data public class Character { private final double x; private final double y; - private final RedTextPosition chunk; + private final RedTextPosition textPosition; - private List neighbors; + private List neighbors = new ArrayList<>(); public Character(RedTextPosition chunk) { this.x = chunk.getXDirAdj() + chunk.getWidthDirAdj() / 2; this.y = chunk.getYDirAdj() + chunk.getHeightDir() / 2; - this.chunk = chunk; + this.textPosition = chunk; } public double getHeight() { - return chunk.getHeightDir(); + return textPosition.getHeightDir(); } @@ -68,43 +66,4 @@ public class Character { } } - - public double overlappingDistance(Character other, double orientation) { - - double[] xs = new double[4]; - double s = Math.sin(-orientation), c = Math.cos(-orientation); - xs[0] = c * x - s * y; - xs[1] = c * (x + chunk.getWidthDirAdj()) - s * (y + chunk.getHeightDir()); - xs[2] = c * other.x - s * other.y; - xs[3] = c * (other.x + other.chunk.getWidthDirAdj()) - s * (other.y + other.chunk.getHeightDir()); - boolean overlapping = xs[1] >= xs[2] && xs[3] >= xs[0]; - Arrays.sort(xs); - return Math.abs(xs[2] - xs[1]) * (overlapping ? 1 : -1); - } - - - /** - * Component comparator based on x coordinate of the centroid of component. - *

- * The ordering is not consistent with equals. - */ - public static final class CharacterXComparator implements Comparator { - - private CharacterXComparator() { - - } - - - @Override - public int compare(Character o1, Character o2) { - - return Double.compare(o1.getX(), o2.getX()); - } - - - @Getter - private static final CharacterXComparator instance = new CharacterXComparator(); - - } - } \ No newline at end of file diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterLine.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterLine.java new file mode 100644 index 0000000..3b54287 --- /dev/null +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterLine.java @@ -0,0 +1,111 @@ +package com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum; + +import java.util.Arrays; +import java.util.List; + +import lombok.Data; + +@Data +public class CharacterLine { + + private final double x0; + private final double y0; + + private final double x1; + private final double y1; + + private final double height; + + private final List characters; + + + public CharacterLine(List characters) { + + this.characters = characters; + + if (characters.size() >= 2) { + // Simple linear regression + double sx = 0.0, sxx = 0.0, sxy = 0.0, sy = 0.0; + for (Character component : characters) { + sx += component.getX(); + sxx += component.getX() * component.getX(); + sxy += component.getX() * component.getY(); + sy += component.getY(); + } + double b = (characters.size() * sxy - sx * sy) / (characters.size() * sxx - sx * sx); + double a = (sy - b * sx) / characters.size(); + + this.x0 = characters.get(0).getX(); + this.y0 = a + b * this.x0; + this.x1 = characters.get(characters.size() - 1).getX(); + this.y1 = a + b * this.x1; + } else if (!characters.isEmpty()) { + Character component = characters.get(0); + double dx = component.getTextPosition().getWidthDirAdj() / 3; + double dy = dx * Math.tan(0); + this.x0 = component.getX() - dx; + this.x1 = component.getX() + dx; + this.y0 = component.getY() - dy; + this.y1 = component.getY() + dy; + } else { + throw new IllegalArgumentException("Component list must not be empty"); + } + height = computeHeight(); + } + + + public double getAngle() { + + return Math.atan2(y1 - y0, x1 - x0); + } + + + public double getLength() { + + return Math.sqrt((x0 - x1) * (x0 - x1) + (y0 - y1) * (y0 - y1)); + } + + + private double computeHeight() { + + double sum = 0.0; + for (Character component : characters) { + sum += component.getHeight(); + } + return sum / characters.size(); + } + + + public double angularDifference(CharacterLine j) { + + double diff = Math.abs(getAngle() - j.getAngle()); + if (diff <= Math.PI / 2) { + return diff; + } else { + return Math.PI - diff; + } + } + + + public double horizontalDistance(CharacterLine other) { + + double[] xs = new double[4]; + double s = 0, c = 1; + xs[0] = c * x0 - s * y0; + xs[1] = c * x1 - s * y1; + xs[2] = c * other.x0 - s * other.y0; + xs[3] = c * other.x1 - s * other.y1; + boolean overlapping = xs[1] >= xs[2] && xs[3] >= xs[0]; + Arrays.sort(xs); + return Math.abs(xs[2] - xs[1]) * (overlapping ? 1 : -1); + } + + + public double verticalDistance(CharacterLine other) { + + double xm = (x0 + x1) / 2, ym = (y0 + y1) / 2; + double xn = (other.x0 + other.x1) / 2, yn = (other.y0 + other.y1) / 2; + return Math.abs((xn - xm) + ym - yn) / Math.sqrt(1); + } + +} \ No newline at end of file diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterZone.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterZone.java new file mode 100644 index 0000000..0b19599 --- /dev/null +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/docstrum/model/refactor/docstrum/CharacterZone.java @@ -0,0 +1,17 @@ +package com.knecon.fforesight.service.layoutparser.processor.services.docstrum.model.refactor.docstrum; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CharacterZone { + + private List lines = new ArrayList<>(); + +} diff --git a/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/visualization/PdfDraw.java b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/visualization/PdfDraw.java new file mode 100644 index 0000000..34dd364 --- /dev/null +++ b/layoutparser-service/layoutparser-service-processor/src/main/java/com/knecon/fforesight/service/layoutparser/processor/services/visualization/PdfDraw.java @@ -0,0 +1,270 @@ +package com.knecon.fforesight.service.layoutparser.processor.services.visualization; + +import java.awt.Color; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.util.Matrix; +import org.springframework.core.io.ClassPathResource; + +import com.knecon.fforesight.service.layoutparser.internal.api.data.redaction.NodeType; +import com.knecon.fforesight.service.layoutparser.processor.model.graph.DocumentTree; +import com.knecon.fforesight.service.layoutparser.processor.model.graph.nodes.Document; +import com.knecon.fforesight.service.layoutparser.processor.model.graph.nodes.Page; +import com.knecon.fforesight.service.layoutparser.processor.model.graph.textblock.AtomicTextBlock; +import com.knecon.fforesight.service.layoutparser.processor.model.graph.textblock.TextBlock; +import com.knecon.fforesight.service.layoutparser.processor.model.table.Ruling; +import com.knecon.fforesight.service.layoutparser.processor.utils.PdfVisualisationUtility; +import com.knecon.fforesight.service.layoutparser.processor.utils.RectangleTransformations; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.experimental.FieldDefaults; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class PdfDraw { + + public static void drawRectanglesPerPage(String filename, List> rectanglesPerPage, String tmpFileName) throws IOException { + + ClassPathResource pdfResource = new ClassPathResource(filename); + try (PDDocument pdDocument = Loader.loadPDF(pdfResource.getFile()); var out = new FileOutputStream(tmpFileName)) { + for (int pageNumber = 1; pageNumber < pdDocument.getNumberOfPages() + 1; pageNumber++) { + PdfVisualisationUtility.drawRectangle2DList(pdDocument, + pageNumber, + rectanglesPerPage.get(pageNumber - 1), + PdfVisualisationUtility.Options.builder().stroke(true).build()); + } + pdDocument.save(out); + } + + } + + + public static void drawRectanglesPerPageNumberedByLine(String filename, List>> rectanglesPerPage, String tmpFileName) throws IOException { + + ClassPathResource pdfResource = new ClassPathResource(filename); + try (PDDocument pdDocument = Loader.loadPDF(pdfResource.getFile()); var out = new FileOutputStream(tmpFileName)) { + for (int pageNumber = 1; pageNumber < pdDocument.getNumberOfPages() + 1; pageNumber++) { + var rectanglesOnPage = rectanglesPerPage.get(pageNumber - 1); + for (int lineNumber = 0; lineNumber < rectanglesOnPage.size(); lineNumber++) { + var rectanglesInLine = rectanglesOnPage.get(lineNumber); + PdfVisualisationUtility.drawRectangle2DList(pdDocument, pageNumber, rectanglesInLine, PdfVisualisationUtility.Options.builder().stroke(true).build()); + double y = Math.min(rectanglesInLine.get(0).getMinY(), rectanglesInLine.get(0).getMaxY()); + PdfVisualisationUtility.drawText(String.format("%d", lineNumber), + pdDocument, + new Point2D.Double(rectanglesInLine.get(0).getX() - (5 + (5 * countNumberOfDigits(lineNumber))), y + 2), + pageNumber, + PdfVisualisationUtility.Options.builder().stroke(true).build()); + } + } + pdDocument.save(out); + } + + } + + + private static int countNumberOfDigits(int num) { + + int final_num = num; + if (final_num == 0) { + return 1; + } + int count = 0; + for (; final_num != 0; final_num /= 10) { + count++; + } + return count; + } + + + public static void drawDocumentGraph(PDDocument document, Document documentGraph) { + + documentGraph.getDocumentTree().allEntriesInOrder().forEach(entry -> drawNode(document, entry)); + } + + + public static void drawNode(PDDocument document, DocumentTree.Entry entry) { + + Options options = buildStandardOptionsForNodes(entry); + + drawBBoxAndLabelAndNumberOnPage(document, entry, options); + + } + + + public static void drawTextBlock(PDDocument document, TextBlock textBlock, Options options) { + + textBlock.getAtomicTextBlocks().forEach(atb -> drawAtomicTextBlock(document, atb, options)); + } + + + public static void drawAtomicTextBlock(PDDocument document, AtomicTextBlock atomicTextBlock, Options options) { + + drawRectangle2DList(document, atomicTextBlock.getPage().getNumber(), atomicTextBlock.getPositions().stream().toList(), options); + + } + + + @SneakyThrows + private static void drawText(String string, PDDocument document, Point2D location, Integer pageNumber, Options options, boolean rotate) { + + var pdPage = document.getPage(pageNumber - 1); + var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.APPEND, true); + + contentStream.setNonStrokingColor(options.getStrokeColor()); + contentStream.setLineWidth(options.getStrokeWidth()); + + contentStream.beginText(); + if (rotate) { + contentStream.setTextMatrix(Matrix.getRotateInstance(Math.toRadians(15), (float) location.getX(), (float) location.getY())); + } else { + contentStream.newLineAtOffset((float) location.getX(), (float) location.getY()); + } + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10); + contentStream.showText(string); + contentStream.endText(); + contentStream.close(); + } + + + @SneakyThrows + public static void drawRectangle2DList(PDDocument document, int pageNumber, List rectCollection, Options options) { + + var pdPage = document.getPage(pageNumber - 1); + drawRectangle2DList(document, rectCollection, options, pdPage); + } + + + private static void drawRectangle2DList(PDDocument document, List rectCollection, Options options, PDPage pdPage) throws IOException { + + var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.APPEND, true); + + contentStream.setStrokingColor(options.getStrokeColor()); + contentStream.setNonStrokingColor(options.getFillColor()); + contentStream.setLineWidth(options.getStrokeWidth()); + + for (var r : rectCollection) { + contentStream.addRect((float) r.getMinX(), (float) r.getMinY(), (float) r.getWidth(), (float) r.getHeight()); + + if (options.isStroke() && options.isFill()) { + contentStream.fillAndStroke(); + } else if (options.isStroke()) { + contentStream.stroke(); + } else if (options.isFill()) { + contentStream.fill(); + } + } + contentStream.close(); + } + + + @SneakyThrows + public static void drawRectanglesAndLinesPerPage(String filename, List> list, List> rectanglesPerPage, String tmpFileName) { + + ClassPathResource pdfResource = new ClassPathResource(filename); + try (PDDocument pdDocument = Loader.loadPDF(pdfResource.getFile()); var out = new FileOutputStream(tmpFileName)) { + for (int pageNumber = 1; pageNumber < pdDocument.getNumberOfPages() + 1; pageNumber++) { +// PdfVisualisationUtility.drawLine2DList(pdDocument, +// pageNumber, +// list.get(pageNumber - 1), +// PdfVisualisationUtility.Options.builder().stroke(true).build()); + PdfVisualisationUtility.drawRectangle2DList(pdDocument, + pageNumber, + rectanglesPerPage.get(pageNumber - 1), + PdfVisualisationUtility.Options.builder().stroke(true).build()); + PdfVisualisationUtility.drawRectangle2DList(pdDocument, pageNumber, list.get(pageNumber - 1), PdfVisualisationUtility.Options.builder().stroke(true).build()); + } + pdDocument.save(out); + } + } + + + @SneakyThrows + public static void drawLinesPerPage(String filename, List> linesPerPage, String tmpFileName) { + + ClassPathResource pdfResource = new ClassPathResource(filename); + try (PDDocument pdDocument = Loader.loadPDF(pdfResource.getFile()); var out = new FileOutputStream(tmpFileName)) { + for (int pageNumber = 1; pageNumber < pdDocument.getNumberOfPages() + 1; pageNumber++) { + PdfVisualisationUtility.drawLine2DList(pdDocument, + pageNumber, + linesPerPage.get(pageNumber - 1), + PdfVisualisationUtility.Options.builder().strokeColor(Color.RED).stroke(true).build()); + } + pdDocument.save(out); + } + } + + + @Builder + @AllArgsConstructor + @Getter + @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) + public static class Options { + + boolean stroke; + @Builder.Default + Color strokeColor = Color.BLACK; + @Builder.Default + float strokeWidth = 1f; + + boolean fill; + @Builder.Default + Color fillColor = Color.BLACK; + + } + + + private static Options buildStandardOptionsForNodes(DocumentTree.Entry entry) { + + return Options.builder().stroke(true).strokeColor(switch (entry.getType()) { + case DOCUMENT -> Color.LIGHT_GRAY; + case HEADER, FOOTER -> Color.GREEN; + case PARAGRAPH -> Color.BLUE; + case HEADLINE -> Color.RED; + case SECTION -> Color.BLACK; + case TABLE -> Color.ORANGE; + case TABLE_CELL -> Color.GRAY; + case IMAGE -> Color.MAGENTA; + }).build(); + } + + + private static void drawBBoxAndLabelAndNumberOnPage(PDDocument document, DocumentTree.Entry entry, Options options) { + + Map rectanglesPerPage = entry.getNode().getBBox(); + for (Page page : rectanglesPerPage.keySet()) { + Rectangle2D rectangle2D = rectanglesPerPage.get(page); + if (entry.getType() == NodeType.SECTION) { + rectangle2D = RectangleTransformations.pad(rectangle2D, 10, 10); + } + drawRectangle2DList(document, page.getNumber(), List.of(rectangle2D), options); + drawText(buildString(entry), + document, + new Point2D.Double(rectangle2D.getMinX(), rectangle2D.getMaxY() + 2), + page.getNumber(), + options, + entry.getType() == NodeType.TABLE_CELL); + } + } + + + private static String buildString(DocumentTree.Entry entry) { + + return entry.getNode().getNumberOnPage() + ": " + entry.getTreeId() + ": " + entry.getType(); + } + +} \ No newline at end of file diff --git a/layoutparser-service/layoutparser-service-server/src/test/java/com/knecon/fforesight/service/layoutparser/server/graph/ViewerDocumentTest.java b/layoutparser-service/layoutparser-service-server/src/test/java/com/knecon/fforesight/service/layoutparser/server/graph/ViewerDocumentTest.java index 8874153..e5981d5 100644 --- a/layoutparser-service/layoutparser-service-server/src/test/java/com/knecon/fforesight/service/layoutparser/server/graph/ViewerDocumentTest.java +++ b/layoutparser-service/layoutparser-service-server/src/test/java/com/knecon/fforesight/service/layoutparser/server/graph/ViewerDocumentTest.java @@ -25,6 +25,8 @@ public class ViewerDocumentTest extends BuildDocumentTest { @SneakyThrows public void testViewerDocument() { + System.out.println("<<<<<<<<<<" + Math.sin(-0) + "aaa " + Math.cos(-0)); + String fileName = "files/Plenarprotokoll 1 (keine Druchsache!) (1).pdf"; String tmpFileName = "/tmp/" + Path.of(fileName).getFileName() + "_VIEWER.pdf"; diff --git a/layoutparser-service/layoutparser-service-server/src/test/resources/files/VV-640252-Seite16.pdf b/layoutparser-service/layoutparser-service-server/src/test/resources/files/VV-640252-Seite16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0edf4196fc2cc2bac3ae264df7138efa7622d606 GIT binary patch literal 17184 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 literal 0 HcmV?d00001