Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fca39728b | ||
|
|
cd6390fde1 | ||
|
|
bc459ee966 | ||
|
|
47e7f8b297 | ||
|
|
22392e083d | ||
|
|
52a1fb4a05 | ||
|
|
378436cb2f | ||
|
|
f1204acc60 | ||
|
|
998755c3e3 | ||
|
|
c598f62633 | ||
|
|
2e25ee2155 | ||
|
|
7f04fb3c6f | ||
|
|
ff32f016eb | ||
|
|
821ef265fe | ||
|
|
7fcb6652ef | ||
|
|
61b1010e24 | ||
|
|
7b5a175440 | ||
|
|
18ba1daaef | ||
|
|
c61f71871e | ||
|
|
cc2937d0d2 | ||
|
|
71255d9fc9 | ||
|
|
1f9dac17e3 | ||
|
|
5712292698 | ||
|
|
1395318e18 | ||
|
|
842b794153 | ||
|
|
4b3ccc28e2 | ||
|
|
b469ea4174 | ||
|
|
253bb70519 | ||
|
|
d55f245c5e | ||
|
|
7ed1632c6f | ||
|
|
6be5dcf305 | ||
|
|
7f0fb149a9 | ||
|
|
6cab77c5c1 | ||
|
|
1e3dc3df24 | ||
|
|
f54f526f44 | ||
|
|
d8011bdba5 | ||
|
|
ea11013132 | ||
|
|
6d69b783f1 | ||
|
|
5b3261d229 | ||
|
|
b5a78a4396 | ||
|
|
3b320bfb00 | ||
|
|
021b18ada3 | ||
|
|
23bc84bd98 | ||
|
|
2e37b8eec9 | ||
|
|
c4c20d15ae | ||
|
|
d2f2def1c2 | ||
|
|
2bbc3775c5 | ||
|
|
2aaa53f441 | ||
|
|
724bb58969 | ||
|
|
1e08405082 | ||
|
|
150f2153c0 | ||
|
|
ca9e22b190 | ||
|
|
0c6ce2d77b | ||
|
|
b48db538fd | ||
|
|
14cc9941ff | ||
|
|
fdb3f3476b | ||
|
|
75bd2142ec | ||
|
|
9010ee8691 | ||
|
|
eaa6973a1f | ||
|
|
74d5f8d8e0 | ||
|
|
fb1fe35bc1 | ||
|
|
912f00aa84 | ||
|
|
bab16ad9b2 | ||
|
|
be4656189b | ||
|
|
8944b57344 | ||
|
|
67540950b8 | ||
|
|
6f29270e66 | ||
|
|
b961c9e324 | ||
|
|
4b6411161e | ||
|
|
14982eae7c | ||
|
|
99fc16130b | ||
|
|
80d38fb785 | ||
|
|
c06974ce69 |
@ -1,3 +1,7 @@
|
||||
variables:
|
||||
# SONAR_PROJECT_KEY: 'ocr-service:ocr-service-server'
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
GIT_SUBMODULE_FORCE_HTTPS: 'true'
|
||||
include:
|
||||
- project: 'gitlab/gitlab'
|
||||
ref: 'main'
|
||||
|
||||
8
.gitmodules
vendored
Normal file
8
.gitmodules
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
[submodule "ocr-service-v1/ocr-service-server/src/test/resources/files/syngenta"]
|
||||
path = ocr-service-v1/ocr-service-server/src/test/resources/files/syngenta
|
||||
url = https://gitlab.knecon.com/fforesight/documents/syngenta.git
|
||||
update = merge
|
||||
[submodule "ocr-service-v1/ocr-service-server/src/test/resources/files/basf"]
|
||||
path = ocr-service-v1/ocr-service-server/src/test/resources/files/basf
|
||||
url = https://gitlab.knecon.com/fforesight/documents/basf.git
|
||||
update = merge
|
||||
24
README.md
24
README.md
@ -15,11 +15,17 @@ The service uses PDFTron to attempt the removal of invisible elements and waterm
|
||||
Extracts all images from the PDF using PDFBox
|
||||
3. Striped Image Detection and Stitching
|
||||
Detects if images are striped and stitches them together using Ghostscript.
|
||||
4. Binarization
|
||||
Binarizes the resulting images using Leptonica and the Otsu thresholding algorithm.
|
||||
4. Image Processing
|
||||
- Convert to grayscale
|
||||
- Upscale to target DPI
|
||||
- Filter using Gauss kernel
|
||||
- Binarizes the resulting images using Leptonica and the Otsu thresholding algorithm.
|
||||
- Despeckle using various morphological operations
|
||||
5. OCR Processing
|
||||
Runs Tesseract on the images to extract text.
|
||||
6. Text Integration
|
||||
6. Font style detection
|
||||
Detection of bold text using stroke width estimation
|
||||
7. Text Integration
|
||||
Draws the resulting text onto the original PDF using PDFBox.
|
||||
|
||||
Steps 2.-5. happen in parallel and communicate via a blocking queue to limit RAM usage.
|
||||
@ -68,12 +74,14 @@ String languages = "deu+eng"; // Defines languages loaded into Tesseract as 3-ch
|
||||
```
|
||||
## Integration
|
||||
|
||||
The OCR-service communicates via RabbitMQ and uses the queues `ocrQueue`, `ocrDLQ`, and `ocr_status_update_response_queue`.
|
||||
The OCR-service communicates via RabbitMQ and uses the queues `ocr_request_queue`, `ocr_response_queue`,
|
||||
`ocr_dead_letter_queue`, and `ocr_status_update_response_queue`.
|
||||
|
||||
### ocrQueue
|
||||
### ocr_request_queue
|
||||
This queue is used to start the OCR process, a DocumentRequest must be passed as a message. The service will then download the PDF from the provided cloud storage.
|
||||
### ocr_response_queue
|
||||
This queue is also used to signal the end of processing.
|
||||
### ocr_dead_letter_queue
|
||||
This queue is used to signal an error has occurred during processing.
|
||||
### ocr_status_update_response_queue
|
||||
This queue is used by the OCR service to give updates about the progress of the ongoing OCR on a image per image basis. The total amount may change, when less images are found than initially assumed.
|
||||
This queue is also used to signal the end of processing.
|
||||
### ocrDLQ
|
||||
This queue is used to signal an error has occurred during processing.
|
||||
|
||||
@ -12,6 +12,10 @@ group = "com.knecon.fforesight.service"
|
||||
java.sourceCompatibility = JavaVersion.VERSION_17
|
||||
java.targetCompatibility = JavaVersion.VERSION_17
|
||||
|
||||
pmd {
|
||||
isConsoleOutput = true
|
||||
}
|
||||
|
||||
tasks.pmdMain {
|
||||
pmd.ruleSetFiles = files("${rootDir}/config/pmd/pmd.xml")
|
||||
}
|
||||
@ -25,6 +29,8 @@ tasks.named<Test>("test") {
|
||||
reports {
|
||||
junitXml.outputLocation.set(layout.buildDirectory.dir("reports/junit"))
|
||||
}
|
||||
minHeapSize = "512m"
|
||||
maxHeapSize = "8192m"
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
|
||||
@ -9,12 +9,12 @@
|
||||
</description>
|
||||
|
||||
<rule ref="category/java/errorprone.xml">
|
||||
<exclude name="DataflowAnomalyAnalysis"/>
|
||||
<exclude name="MissingSerialVersionUID"/>
|
||||
<exclude name="NullAssignment"/>
|
||||
<exclude name="AvoidLiteralsInIfCondition"/>
|
||||
<exclude name="AvoidDuplicateLiterals"/>
|
||||
<exclude name="AvoidFieldNameMatchingMethodName"/>
|
||||
<exclude name="NullAssignment"/>
|
||||
<exclude name="AssignmentInOperand"/>
|
||||
<exclude name="BeanMembersShouldSerialize"/>
|
||||
</rule>
|
||||
|
||||
</ruleset>
|
||||
</ruleset>
|
||||
|
||||
@ -10,14 +10,13 @@
|
||||
|
||||
|
||||
<rule ref="category/java/errorprone.xml">
|
||||
<exclude name="DataflowAnomalyAnalysis"/>
|
||||
<exclude name="MissingSerialVersionUID"/>
|
||||
<exclude name="NullAssignment"/>
|
||||
<exclude name="AvoidLiteralsInIfCondition"/>
|
||||
<exclude name="AvoidDuplicateLiterals"/>
|
||||
<exclude name="AvoidFieldNameMatchingMethodName"/>
|
||||
<exclude name="AvoidFieldNameMatchingTypeName"/>
|
||||
<exclude name="NullAssignment"/>
|
||||
<exclude name="AssignmentInOperand"/>
|
||||
<exclude name="TestClassWithoutTestCases"/>
|
||||
<exclude name="BeanMembersShouldSerialize"/>
|
||||
</rule>
|
||||
|
||||
</ruleset>
|
||||
</ruleset>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
`maven-publish`
|
||||
id("com.iqser.red.service.java-conventions")
|
||||
id("io.freefair.lombok") version "8.2.2"
|
||||
id("io.freefair.lombok") version "8.4"
|
||||
}
|
||||
|
||||
publishing {
|
||||
|
||||
@ -13,5 +13,12 @@ public class DocumentRequest {
|
||||
|
||||
protected String dossierId;
|
||||
protected String fileId;
|
||||
protected boolean removeWatermark;
|
||||
|
||||
public DocumentRequest(String dossierId, String fileId) {
|
||||
|
||||
this.dossierId = dossierId;
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -15,5 +15,6 @@ public class OCRStatusUpdateResponse {
|
||||
private int numberOfPagesToOCR;
|
||||
private int numberOfOCRedPages;
|
||||
private boolean ocrFinished;
|
||||
private boolean ocrStarted;
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.iqser.red.service.java-conventions")
|
||||
id("io.freefair.lombok") version "8.2.2"
|
||||
id("io.freefair.lombok") version "8.4"
|
||||
}
|
||||
|
||||
configurations {
|
||||
@ -14,15 +14,18 @@ dependencies {
|
||||
api("net.sourceforge.tess4j:tess4j:5.8.0")
|
||||
api("com.iqser.red.commons:metric-commons:2.1.0")
|
||||
api("com.iqser.red.commons:storage-commons:2.45.0")
|
||||
api("com.knecon.fforesight:tenant-commons:0.14.0")
|
||||
api("com.knecon.fforesight:tenant-commons:0.21.0")
|
||||
api("com.knecon.fforesight:lifecycle-commons:0.6.0")
|
||||
api("com.pdftron:PDFNet:10.5.0")
|
||||
api("org.apache.pdfbox:pdfbox:3.0.0")
|
||||
api("org.apache.pdfbox:jbig2-imageio:3.0.4")
|
||||
api("com.github.jai-imageio:jai-imageio-core:1.4.0")
|
||||
api("com.github.jai-imageio:jai-imageio-jpeg2000:1.4.0")
|
||||
api("io.github.karols:hocr4j:0.1.2")
|
||||
api("org.apache.commons:commons-math3:3.6.1")
|
||||
api("io.github.karols:hocr4j:0.2.0")
|
||||
api("com.amazonaws:aws-java-sdk-kms:1.12.440")
|
||||
api("com.google.guava:guava:31.1-jre")
|
||||
api("com.iqser.red.commons:pdftron-logic-commons:2.20.0")
|
||||
api("com.iqser.red.commons:pdftron-logic-commons:2.32.0")
|
||||
api("com.knecon.fforesight:viewer-doc-processor:0.125.0")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.8.1")
|
||||
}
|
||||
|
||||
@ -1,14 +1,26 @@
|
||||
package com.knecon.fforesight.service.ocr.processor;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.settings.OcrServiceSettings;
|
||||
import com.knecon.fforesight.service.viewerdoc.service.ViewerDocumentService;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
|
||||
@Configuration
|
||||
@ComponentScan
|
||||
@EnableConfigurationProperties(OcrServiceSettings.class)
|
||||
public class OcrServiceProcessorConfiguration {
|
||||
|
||||
@Bean
|
||||
@Autowired
|
||||
public ViewerDocumentService viewerDocumentService(ObservationRegistry registry) {
|
||||
|
||||
return new ViewerDocumentService(registry);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.initializer;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.pdftron.pdf.PDFNet;
|
||||
import com.sun.jna.NativeLibrary;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PDFNetInitializer {
|
||||
public class NativeLibrariesInitializer {
|
||||
|
||||
@Value("${pdftron.license:}")
|
||||
private String pdftronLicense;
|
||||
@ -22,8 +25,25 @@ public class PDFNetInitializer {
|
||||
// Do not change back to application runner, if it is application runner it takes messages from the queue before PDFNet is initialized, that leads to UnsatisfiedLinkError.
|
||||
public void init() {
|
||||
|
||||
log.info("Initializing Native Libraries");
|
||||
log.info("Setting pdftron license: {}", pdftronLicense);
|
||||
PDFNet.setTempPath("/tmp/pdftron");
|
||||
PDFNet.initialize(pdftronLicense);
|
||||
|
||||
log.info("Setting jna.library.path: {}", System.getenv("VCPKG_DYNAMIC_LIB"));
|
||||
System.setProperty("jna.library.path", System.getenv("VCPKG_DYNAMIC_LIB"));
|
||||
|
||||
log.info("Asserting Native Libraries loaded");
|
||||
|
||||
try (NativeLibrary leptonicaLib = NativeLibrary.getInstance("leptonica")) {
|
||||
assert leptonicaLib != null;
|
||||
log.info("Leptonica library loaded from {}", leptonicaLib.getFile().getAbsolutePath());
|
||||
}
|
||||
|
||||
try (NativeLibrary tesseractLib = NativeLibrary.getInstance("tesseract")) {
|
||||
assert tesseractLib != null;
|
||||
log.info("Tesseract library loaded from {}", tesseractLib.getFile().getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -58,11 +58,4 @@ public class ExtractedOcrImage implements OcrImage {
|
||||
return affineTransform;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int getOptimalPageSegmentationMode() {
|
||||
|
||||
return ITessAPI.TessPageSegMode.PSM_SINGLE_BLOCK;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import lombok.SneakyThrows;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.lept4j.util.LeptUtils;
|
||||
import net.sourceforge.tess4j.ITessAPI;
|
||||
|
||||
public interface OcrImage {
|
||||
|
||||
@ -30,9 +31,19 @@ public interface OcrImage {
|
||||
int getNumberOnPage();
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the height of the original image (not necessarily in pdf coordinates).
|
||||
*
|
||||
* @return the height of the image
|
||||
*/
|
||||
int getHeight();
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the width of the original image (not necessarily in pdf coordinates).
|
||||
*
|
||||
* @return the width of the image
|
||||
*/
|
||||
int getWidth();
|
||||
|
||||
|
||||
@ -43,7 +54,7 @@ public interface OcrImage {
|
||||
*/
|
||||
default QuadPoint getImageBounds() {
|
||||
|
||||
// cannot be solved with a nice rotation matrix, since the after rotating the text coordinates in the image will always start at (0,0) and will therefore always start at (0,0) in the PDF.
|
||||
// cannot be solved with a nice rotation matrix. After rotating the text coordinates in the image will always start at (0,0) and will therefore always start at (0,0) in the PDF.
|
||||
// So in order to mimic this behavior we need to start with (0,0) coordinates always.
|
||||
if (getRotationDegrees() == 90 || getRotationDegrees() == 270) {
|
||||
return new QuadPoint(new Point2D.Double(0, 0), new Point2D.Double(0, getWidth()), new Point2D.Double(getHeight(), getWidth()), new Point2D.Double(getHeight(), 0));
|
||||
@ -64,13 +75,6 @@ public interface OcrImage {
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
default BufferedImage getBufferedImage() {
|
||||
|
||||
return LeptUtils.convertPixToImage(getPix());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the rotation degree of the OCR image.
|
||||
*
|
||||
@ -84,7 +88,13 @@ public interface OcrImage {
|
||||
*
|
||||
* @return The optimal page segmentation mode.
|
||||
*/
|
||||
int getOptimalPageSegmentationMode(); // TODO: evaluate if PSM can be dynamically chosen to increase performance
|
||||
default int getOptimalPageSegmentationMode() {
|
||||
|
||||
if (getWidth() < 200 || getHeight() < 200) {
|
||||
return ITessAPI.TessPageSegMode.PSM_SINGLE_BLOCK;
|
||||
}
|
||||
return ITessAPI.TessPageSegMode.PSM_AUTO;
|
||||
} // TODO: evaluate if PSM can be dynamically chosen to increase performance
|
||||
|
||||
|
||||
/**
|
||||
@ -109,17 +119,6 @@ public interface OcrImage {
|
||||
AffineTransform getImageCTM();
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the size (width * height) of the image.
|
||||
*
|
||||
* @return The size of the image.
|
||||
*/
|
||||
default int getImageSize() {
|
||||
|
||||
return getHeight() * getWidth();
|
||||
}
|
||||
|
||||
|
||||
default void destroyPix() {
|
||||
|
||||
LeptUtils.disposePix(getPix());
|
||||
|
||||
@ -7,27 +7,17 @@ import com.knecon.fforesight.service.ocr.processor.service.HOcrPageParser;
|
||||
|
||||
import io.github.karols.hocr4j.Word;
|
||||
|
||||
public record OcrResult(Image image, String hOcrPageAbsolutePath) {
|
||||
public record OcrResult(OcrImage image, String tesseractOutputFilePath) {
|
||||
|
||||
public static OcrResult create(OcrImage image, String tesseractResult) {
|
||||
|
||||
return new OcrResult(Image.fromOcrImage(image), tesseractResult);
|
||||
return new OcrResult(image, tesseractResult);
|
||||
}
|
||||
|
||||
|
||||
public List<Word> getAllWords() {
|
||||
|
||||
return HOcrPageParser.extractHocrPage(hOcrPageAbsolutePath).getAllWords();
|
||||
}
|
||||
|
||||
|
||||
public record Image(Integer pageNumber, AffineTransform ctm, QuadPoint position) {
|
||||
|
||||
public static Image fromOcrImage(OcrImage image) {
|
||||
|
||||
return new Image(image.getPageNumber(), image.getImageCTM(), image.getImageCoordinatesInInitialUserSpace());
|
||||
}
|
||||
|
||||
return HOcrPageParser.extractHocrPage(tesseractOutputFilePath).getAllWords();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.scriptdetection.FontStyleDetectionModel;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontStyle;
|
||||
|
||||
public record OcrResultToWrite(List<TextPositionInImage> textPositionInImage, QuadPoint imageBoundingBox) {
|
||||
|
||||
public static OcrResultToWrite fromFontStyleDetectionModel(FontStyleDetectionModel fontStyleDetectionModel) {
|
||||
|
||||
return new OcrResultToWrite(fontStyleDetectionModel.getTextPositionInImages(), fontStyleDetectionModel.getImageBounds());
|
||||
}
|
||||
|
||||
|
||||
public static Map<Integer, List<OcrResultToWrite>> buildOcrResultsToWrite(List<OcrResult> ocrResults, FontMetricsFactory fontMetricsFactory) {
|
||||
|
||||
return ocrResults.stream()
|
||||
.collect(Collectors.groupingBy(ocrResult -> ocrResult.image().getPageNumber()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey,
|
||||
entry -> entry.getValue()
|
||||
.stream()
|
||||
.map(ocrResult -> new OcrResultToWrite(toTextPositionInImage(ocrResult, fontMetricsFactory), ocrResult.image().getImageCoordinatesInInitialUserSpace()))
|
||||
.toList()));
|
||||
}
|
||||
|
||||
|
||||
private static List<TextPositionInImage> toTextPositionInImage(OcrResult ocrResult, FontMetricsFactory fontMetricsFactory) {
|
||||
|
||||
return ocrResult.getAllWords()
|
||||
.stream()
|
||||
.filter(word -> !word.isBlank())
|
||||
.map(word -> new TextPositionInImage(word, ocrResult.image().getImageCTM(), fontMetricsFactory, FontStyle.REGULAR))
|
||||
.toList();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,12 +1,42 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.model;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
|
||||
public record PageInformation(int height, int width, int number, int rotationDegrees) {
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
|
||||
public record PageInformation(Rectangle2D mediabox, int number, int rotationDegrees) {
|
||||
|
||||
public static PageInformation fromPDPage(int pageNum, PDPage page) {
|
||||
|
||||
return new PageInformation((int) page.getCropBox().getHeight(), (int) page.getCropBox().getWidth(), pageNum, page.getRotation());
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
return new PageInformation(new Rectangle2D.Double(mediaBox.getLowerLeftX(), mediaBox.getLowerLeftY(), mediaBox.getWidth(), mediaBox.getHeight()),
|
||||
pageNum,
|
||||
page.getRotation());
|
||||
}
|
||||
|
||||
|
||||
public double height() {
|
||||
|
||||
return mediabox.getHeight();
|
||||
}
|
||||
|
||||
|
||||
public double width() {
|
||||
|
||||
return mediabox.getWidth();
|
||||
}
|
||||
|
||||
|
||||
public double minX() {
|
||||
|
||||
return mediabox.getX();
|
||||
}
|
||||
|
||||
|
||||
public double minY() {
|
||||
|
||||
return mediabox.getY();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.model;
|
||||
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.awt.geom.Line2D;
|
||||
import java.awt.geom.Point2D;
|
||||
@ -34,6 +35,16 @@ public record QuadPoint(Point2D a, Point2D b, Point2D c, Point2D d) {
|
||||
new Point2D.Double(bounds.getRight(), bounds.getBottom()));
|
||||
}
|
||||
|
||||
public Rectangle2D getBounds2D() {
|
||||
|
||||
double minX = Math.min(Math.min(Math.min(a.getX(), b.getX()), c.getX()), d.getX());
|
||||
double minY = Math.min(Math.min(Math.min(a.getY(), b.getY()), c.getY()), d.getY());
|
||||
double maxX = Math.max(Math.max(Math.max(a.getX(), b.getX()), c.getX()), d.getX());
|
||||
double maxY = Math.max(Math.max(Math.max(a.getY(), b.getY()), c.getY()), d.getY());
|
||||
|
||||
return new Rectangle2D.Double(minX, minY, maxX - minX, maxY - minY);
|
||||
}
|
||||
|
||||
|
||||
public QuadPoint getTransformed(AffineTransform at) {
|
||||
|
||||
|
||||
@ -1,20 +1,12 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.model;
|
||||
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.awt.geom.Point2D;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.tess4j.ITessAPI;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
@ -28,13 +20,6 @@ public class RenderedPageOcrImage implements OcrImage {
|
||||
int rotationDegrees;
|
||||
|
||||
|
||||
@Override
|
||||
public int getOptimalPageSegmentationMode() {
|
||||
|
||||
return ITessAPI.TessPageSegMode.PSM_SINGLE_BLOCK;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public AffineTransform getImageCTM() {
|
||||
|
||||
@ -67,17 +52,6 @@ public class RenderedPageOcrImage implements OcrImage {
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public QuadPoint getImageBounds() {
|
||||
|
||||
if (rotationDegrees == 90 || rotationDegrees == 270) {
|
||||
return new QuadPoint(new Point2D.Double(0, 0), new Point2D.Double(0, width), new Point2D.Double(height, width), new Point2D.Double(height, 0));
|
||||
} else {
|
||||
return new QuadPoint(new Point2D.Double(0, 0), new Point2D.Double(0, height), new Point2D.Double(width, height), new Point2D.Double(width, 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int getPageNumber() {
|
||||
|
||||
|
||||
@ -7,29 +7,35 @@ import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontStyle;
|
||||
|
||||
import io.github.karols.hocr4j.Bounds;
|
||||
import io.github.karols.hocr4j.Word;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
|
||||
@Getter
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class TextPositionInImage {
|
||||
|
||||
QuadPoint position;
|
||||
String text;
|
||||
AffineTransform imageCTM;
|
||||
final QuadPoint position;
|
||||
final String text;
|
||||
final AffineTransform imageCTM;
|
||||
|
||||
@Setter
|
||||
FontMetricsFactory fontMetricsFactory;
|
||||
@Setter
|
||||
FontStyle fontStyle;
|
||||
|
||||
|
||||
public TextPositionInImage(Word word, AffineTransform imageCTM, FontMetricsFactory fontMetricsFactory) {
|
||||
public TextPositionInImage(Word word, AffineTransform imageCTM, FontMetricsFactory fontMetricsFactory, FontStyle fontStyle) {
|
||||
|
||||
this.position = QuadPoint.fromBounds(word.getBounds());
|
||||
this.text = word.getText();
|
||||
this.imageCTM = imageCTM;
|
||||
this.fontMetricsFactory = fontMetricsFactory;
|
||||
this.fontStyle = fontStyle;
|
||||
}
|
||||
|
||||
|
||||
@ -90,6 +96,13 @@ public class TextPositionInImage {
|
||||
}
|
||||
|
||||
|
||||
public double getTextHeight() {
|
||||
|
||||
var metrics = fontMetricsFactory.calculateMetrics(text, getTransformedWidth(), getTransformedHeight());
|
||||
return fontMetricsFactory.calculateFontSize(text, getTransformedWidth()) * metrics.getHeightScaling();
|
||||
}
|
||||
|
||||
|
||||
public double getHeight() {
|
||||
|
||||
return position.a().distance(position.b());
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.model.scriptdetection;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResult;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.QuadPoint;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.TextPositionInImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.settings.OcrServiceSettings;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.lept4j.util.LeptUtils;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public final class FontStyleDetectionModel {
|
||||
|
||||
QuadPoint imageBounds;
|
||||
Pix image;
|
||||
List<TextPositionAndWordImage> textPositionsAndWordImages;
|
||||
|
||||
|
||||
public static FontStyleDetectionModel fromOcrResult(OcrResult ocrResult, FontMetricsFactory fontMetricsFactory, OcrServiceSettings settings) {
|
||||
|
||||
var image = Leptonica1.pixRead(ocrResult.tesseractOutputFilePath() + ".tiff");
|
||||
var wordPixes = ocrResult.getAllWords().stream().filter(word -> !word.isBlank()).map(word -> TextPositionAndWordImage.create(ocrResult.image().getImageCTM(), word, image, settings, fontMetricsFactory)).toList();
|
||||
|
||||
return new FontStyleDetectionModel(ocrResult.image().getImageCoordinatesInInitialUserSpace(), image, wordPixes);
|
||||
}
|
||||
|
||||
|
||||
public List<TextPositionInImage> getTextPositionInImages() {
|
||||
|
||||
return textPositionsAndWordImages.stream().map(TextPositionAndWordImage::getTextPositionInImage).toList();
|
||||
}
|
||||
|
||||
|
||||
public List<WordImage> getWordImages() {
|
||||
|
||||
return textPositionsAndWordImages.stream().map(TextPositionAndWordImage::getWordImage).toList();
|
||||
}
|
||||
|
||||
|
||||
public void dispose() {
|
||||
|
||||
LeptUtils.disposePix(image);
|
||||
getWordImages().forEach(WordImage::dispose);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.model.scriptdetection;
|
||||
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.apache.commons.math3.ml.clustering.Clusterable;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResult;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.TextPositionInImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontStyle;
|
||||
import com.knecon.fforesight.service.ocr.processor.settings.OcrServiceSettings;
|
||||
|
||||
import io.github.karols.hocr4j.Word;
|
||||
import lombok.Getter;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
|
||||
@Getter
|
||||
public final class TextPositionAndWordImage implements Clusterable {
|
||||
|
||||
private final TextPositionInImage textPositionInImage;
|
||||
private final WordImage wordImage;
|
||||
|
||||
|
||||
public TextPositionAndWordImage(TextPositionInImage textPositionInImage, WordImage wordImage) {
|
||||
|
||||
this.textPositionInImage = textPositionInImage;
|
||||
this.wordImage = wordImage;
|
||||
}
|
||||
|
||||
|
||||
public static TextPositionAndWordImage create(AffineTransform imageCTM, Word word, Pix image, OcrServiceSettings settings, FontMetricsFactory fontMetricsFactory) {
|
||||
|
||||
TextPositionInImage textPositionInImage = new TextPositionInImage(word, imageCTM, fontMetricsFactory, FontStyle.REGULAR);
|
||||
WordImage wordImage = new WordImage(textPositionInImage.getTextHeight(), word, image, settings);
|
||||
return new TextPositionAndWordImage(textPositionInImage, wordImage);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public double[] getPoint() {
|
||||
|
||||
return wordImage.getPoint();
|
||||
}
|
||||
|
||||
|
||||
public double getTextHeight() {
|
||||
|
||||
return wordImage.getTextHeight();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.model.scriptdetection;
|
||||
|
||||
import org.apache.commons.math3.ml.clustering.Clusterable;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.service.scriptdetection.StrokeWidthCalculator;
|
||||
import com.knecon.fforesight.service.ocr.processor.settings.OcrServiceSettings;
|
||||
import com.knecon.fforesight.service.ocr.processor.utils.ImageProcessingUtils;
|
||||
|
||||
import io.github.karols.hocr4j.Word;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import net.sourceforge.lept4j.Box;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.lept4j.util.LeptUtils;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class WordImage implements Clusterable {
|
||||
|
||||
Pix image;
|
||||
String text;
|
||||
double textHeight;
|
||||
OcrServiceSettings settings;
|
||||
|
||||
|
||||
public WordImage(double textHeight, Word word, Pix originalImage, OcrServiceSettings settings) {
|
||||
|
||||
Box box = new Box(word.getBounds().getLeft(), word.getBounds().getTop(), word.getBounds().getWidth(), word.getBounds().getHeight(), 1);
|
||||
this.image = Leptonica1.pixClipRectangle(originalImage, box, null);
|
||||
box.clear();
|
||||
this.text = word.getText();
|
||||
this.textHeight = textHeight;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
|
||||
public boolean hasLargerStrokeWidth(double strokeWidth) {
|
||||
|
||||
int roundedStrokeWidth = (int) Math.round(strokeWidth);
|
||||
double roundingError = (roundedStrokeWidth - strokeWidth) / strokeWidth;
|
||||
|
||||
// add 1 to open a bit bigger than the estimated regular stroke width
|
||||
Pix openedPix = Leptonica1.pixOpenBrick(null, image, roundedStrokeWidth + 1, roundedStrokeWidth + 1);
|
||||
|
||||
double openedPixelDensity = ImageProcessingUtils.calculatePixelDensity(openedPix);
|
||||
|
||||
double pixelDensity = ImageProcessingUtils.calculatePixelDensity(image);
|
||||
|
||||
LeptUtils.disposePix(openedPix);
|
||||
|
||||
return (openedPixelDensity * (1 + roundingError)) / pixelDensity > (settings.getBoldThreshold());
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public double[] getPoint() {
|
||||
|
||||
return new double[]{textHeight};
|
||||
}
|
||||
|
||||
|
||||
public void dispose() {
|
||||
|
||||
LeptUtils.disposePix(image);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,13 +1,11 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.iqser.red.service.persistence.service.v1.api.shared.model.dossiertemplate.dossier.file.FileType;
|
||||
@ -31,47 +29,38 @@ public class FileStorageService {
|
||||
return dossierId + "/" + fileId + "." + fileType.name() + fileType.getExtension();
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public byte[] getOriginalFile(String dossierId, String fileId) {
|
||||
|
||||
try (InputStream inputStream = getInputStream(getStorageId(dossierId, fileId, FileType.ORIGIN))) {
|
||||
return IOUtils.toByteArray(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public InputStream getOriginalFileAsStream(String dossierId, String fileId) {
|
||||
|
||||
return getInputStream(getStorageId(dossierId, fileId, FileType.ORIGIN));
|
||||
}
|
||||
|
||||
|
||||
public void storeOriginalFile(String dossierId, String fileId, InputStream stream) {
|
||||
|
||||
storageService.storeObject(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.ORIGIN), stream);
|
||||
}
|
||||
|
||||
|
||||
public boolean untouchedFileExists(String dossierId, String fileId) {
|
||||
|
||||
return storageService.objectExists(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.UNTOUCHED));
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public void storeFiles(String dossierId, String fileId, File documentFile, File viewerDocumentFile) {
|
||||
|
||||
public void storeUntouchedFile(String dossierId, String fileId, byte[] data) {
|
||||
|
||||
storageService.storeObject(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.UNTOUCHED), new ByteArrayInputStream(data));
|
||||
try (var in = new FileInputStream(documentFile)) {
|
||||
storageService.storeObject(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.ORIGIN), in);
|
||||
}
|
||||
try (var in = new FileInputStream(viewerDocumentFile)) {
|
||||
storageService.storeObject(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.VIEWER_DOCUMENT), in);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private InputStream getInputStream(String storageId) {
|
||||
public void downloadFiles(String dossierId, String fileId, File documentFile, File viewerDocumentFile) {
|
||||
|
||||
File tempFile = File.createTempFile("temp", ".data");
|
||||
storageService.downloadTo(TenantContext.getTenantId(), storageId, tempFile);
|
||||
return Files.newInputStream(Paths.get(tempFile.getPath()), StandardOpenOption.DELETE_ON_CLOSE);
|
||||
storageService.downloadTo(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.ORIGIN), documentFile);
|
||||
if (storageService.objectExists(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.VIEWER_DOCUMENT))) {
|
||||
storageService.downloadTo(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.VIEWER_DOCUMENT), viewerDocumentFile);
|
||||
} else {
|
||||
Files.copy(documentFile.toPath(), viewerDocumentFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
if (!untouchedFileExists(dossierId, fileId)) {
|
||||
try (var in = new FileInputStream(documentFile)) {
|
||||
storageService.storeObject(TenantContext.getTenantId(), getStorageId(dossierId, fileId, FileType.UNTOUCHED), in);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -3,24 +3,18 @@ package com.knecon.fforesight.service.ocr.processor.service;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingDeque;
|
||||
import java.util.concurrent.LinkedTransferQueue;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.azure.core.implementation.GeoObjectHelper;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.PageInformation;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.RenderedPageImageFile;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.RenderedPageOcrImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.UnprocessedImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.threads.BlockingQueueFiller;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.threads.GhostScriptOutputHandler;
|
||||
@ -32,7 +26,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
|
||||
@ -7,7 +7,10 @@ public interface IOcrMessageSender {
|
||||
|
||||
void sendUpdate(String fileId, int finishedImages, int totalImages);
|
||||
|
||||
void sendOCRStarted(String fileId);
|
||||
|
||||
void sendOcrFinished(String fileId, int totalImages);
|
||||
|
||||
void sendOcrResponse(String dossierId, String fileId);
|
||||
|
||||
}
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.stream.IntStream;
|
||||
@ -20,11 +21,16 @@ import org.springframework.util.FileSystemUtils;
|
||||
|
||||
import com.iqser.red.pdftronlogic.commons.InvisibleElementRemovalService;
|
||||
import com.iqser.red.pdftronlogic.commons.WatermarkRemovalService;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResultToWrite;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResult;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.scriptdetection.FontStyleDetector;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.threads.OCRThread;
|
||||
import com.knecon.fforesight.service.ocr.processor.settings.OcrServiceSettings;
|
||||
import com.pdftron.pdf.PDFDoc;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import io.micrometer.observation.annotation.Observed;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
@ -44,6 +50,8 @@ public class OCRService {
|
||||
InvisibleElementRemovalService invisibleElementRemovalService;
|
||||
OcrResultWriter ocrResultWriter;
|
||||
GhostScriptService ghostScriptService;
|
||||
FontStyleDetector boldDetector;
|
||||
ObservationRegistry registry;
|
||||
|
||||
|
||||
/**
|
||||
@ -51,54 +59,66 @@ public class OCRService {
|
||||
* looking for stitchedImages (if so converting the current page to an image with ghostscript and work on this instead),
|
||||
* perform tesseract-ocr on these images (via threads) and write the generated ocr-text as invisible elements.
|
||||
*
|
||||
* @param dossierId Id of dossier
|
||||
* @param fileId Id of file
|
||||
* @param out OutputStream where to write to
|
||||
* @param dossierId Id of dossier
|
||||
* @param fileId Id of file
|
||||
* @param tmpDir working directory for all files
|
||||
* @param documentFile the file to perform ocr on, results are written invisibly
|
||||
* @param viewerDocumentFile debugging file, results are written visibly in an optional content group
|
||||
*/
|
||||
@Observed(name = "OCRService", contextualName = "run-ocr-on-document")
|
||||
@SneakyThrows
|
||||
public void runOcrOnDocument(String dossierId, String fileId, OutputStream out) {
|
||||
public void runOcrOnDocument(String dossierId, String fileId, boolean removeWatermark, Path tmpDir, File documentFile, File viewerDocumentFile) {
|
||||
|
||||
try (InputStream fileStream = removeWatermarkIfEnabled(dossierId, fileId); ByteArrayOutputStream transferOutputStream = new ByteArrayOutputStream()) {
|
||||
|
||||
invisibleElementRemovalService.removeInvisibleElements(fileStream, transferOutputStream, false);
|
||||
|
||||
try (InputStream transferInputStream = new ByteArrayInputStream(transferOutputStream.toByteArray())) {
|
||||
log.info("Starting OCR for file {}", fileId);
|
||||
long ocrStart = System.currentTimeMillis();
|
||||
Statistics stats = runOcr(transferInputStream, out, fileId, dossierId);
|
||||
long ocrEnd = System.currentTimeMillis();
|
||||
log.info("ocr successful for file with dossierId {} and fileId {}, took {}s", dossierId, fileId, String.format("%.1f", (ocrEnd - ocrStart) / 1000.0));
|
||||
log.info("Runtime breakdown: {}", stats);
|
||||
}
|
||||
if (removeWatermark) {
|
||||
removeWatermarkIfEnabled(documentFile);
|
||||
}
|
||||
}
|
||||
removeInvisibleElements(documentFile);
|
||||
|
||||
log.info("Starting OCR for file {}", fileId);
|
||||
long ocrStart = System.currentTimeMillis();
|
||||
Statistics stats = runOcr(tmpDir, documentFile, viewerDocumentFile, fileId, dossierId);
|
||||
long ocrEnd = System.currentTimeMillis();
|
||||
log.info("ocr successful for file with dossierId {} and fileId {}, took {}s", dossierId, fileId, String.format("%.1f", (ocrEnd - ocrStart) / 1000.0));
|
||||
log.info("Runtime breakdown: {}", stats);
|
||||
|
||||
private InputStream removeWatermarkIfEnabled(String dossierId, String fileId) throws IOException {
|
||||
|
||||
if (settings.isRemoveWatermark()) {
|
||||
try (var in = fileStorageService.getOriginalFileAsStream(dossierId, fileId); var transferOutputStream = new ByteArrayOutputStream()) {
|
||||
watermarkRemovalService.removeWatermarks(in, transferOutputStream);
|
||||
return new ByteArrayInputStream(transferOutputStream.toByteArray());
|
||||
}
|
||||
}
|
||||
return fileStorageService.getOriginalFileAsStream(dossierId, fileId);
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public Statistics runOcr(InputStream in, OutputStream out, String fileId, String dossierId) {
|
||||
private void removeInvisibleElements(File originFile) {
|
||||
|
||||
Path tmpFile = Files.createTempFile("invisibleElements", ".pdf");
|
||||
try (var in = new FileInputStream(originFile); var out = new FileOutputStream(tmpFile.toFile())) {
|
||||
invisibleElementRemovalService.removeInvisibleElements(in, out, false, false);
|
||||
}
|
||||
Files.copy(tmpFile, originFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
assert tmpFile.toFile().delete();
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private void removeWatermarkIfEnabled(File originFile) {
|
||||
|
||||
Path tmpFile = Files.createTempFile("removeWatermarks", ".pdf");
|
||||
try (var in = new FileInputStream(originFile); var out = new FileOutputStream(tmpFile.toFile())) {
|
||||
watermarkRemovalService.removeWatermarks(in, out);
|
||||
}
|
||||
Files.copy(tmpFile, originFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
assert tmpFile.toFile().delete();
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public Statistics runOcr(Path tmpDir, File documentFile, File viewerDocumentFile, String fileId, String dossierId) {
|
||||
|
||||
long timestamp;
|
||||
Path tmpDir = Path.of(OsUtils.getTemporaryDirectory()).resolve(dossierId + "-" + fileId);
|
||||
|
||||
Path tmpImageDir = tmpDir.resolve("images");
|
||||
Path tesseractOutputDir = tmpDir.resolve("tesseract_output");
|
||||
|
||||
tesseractOutputDir.toFile().mkdirs();
|
||||
tmpImageDir.toFile().mkdirs();
|
||||
|
||||
File documentFile = OsUtils.writeFileToTmpFolder(in, tmpDir);
|
||||
|
||||
Statistics stats;
|
||||
try (PDDocument document = Loader.loadPDF(documentFile)) {
|
||||
OcrProgressLogger logger = new OcrProgressLogger(document.getNumberOfPages(), ocrMessageSender, fileId);
|
||||
@ -135,14 +155,18 @@ public class OCRService {
|
||||
ocrThread.join();
|
||||
}
|
||||
|
||||
log.info("OCR processing has finished, writing results");
|
||||
log.info("Tesseract OCR has finished for file {} and dossier {}", fileId, dossierId);
|
||||
|
||||
timestamp = System.currentTimeMillis();
|
||||
var dictionariesToUpdate = ocrResultWriter.drawOcrResultsToPdf(document, ocrResults);
|
||||
Map<Integer, List<OcrResultToWrite>> imageWithTextPositionsPerPage = boldDetector.detectBold(ocrResults, document);
|
||||
stats.increaseFontStyleDetectionDuration(System.currentTimeMillis() - timestamp);
|
||||
|
||||
timestamp = System.currentTimeMillis();
|
||||
ocrResultWriter.drawOcrResultsToPdf(documentFile, viewerDocumentFile, imageWithTextPositionsPerPage);
|
||||
|
||||
log.info("Saving document");
|
||||
document.saveIncremental(out, dictionariesToUpdate);
|
||||
stats.increaseWritingTextDuration(System.currentTimeMillis() - timestamp);
|
||||
|
||||
FileSystemUtils.deleteRecursively(tmpDir);
|
||||
logger.sendFinished();
|
||||
return stats;
|
||||
}
|
||||
|
||||
@ -96,6 +96,7 @@ public class OcrImageFactory {
|
||||
}
|
||||
|
||||
imageProcessingThread.setAllImagesExtracted(true);
|
||||
imageProcessingThread.interrupt();
|
||||
|
||||
imageProcessingThread.join();
|
||||
|
||||
|
||||
@ -1,31 +1,38 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.geom.Line2D;
|
||||
import java.awt.geom.Point2D;
|
||||
import java.util.HashSet;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
|
||||
import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResult;
|
||||
import com.iqser.red.pdftronlogic.commons.Converter;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResultToWrite;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.QuadPoint;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.TextPositionInImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.Type0FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.settings.OcrServiceSettings;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontStyle;
|
||||
import com.knecon.fforesight.service.viewerdoc.ContentStreams;
|
||||
import com.knecon.fforesight.service.viewerdoc.model.ColoredLine;
|
||||
import com.knecon.fforesight.service.viewerdoc.model.PlacedText;
|
||||
import com.knecon.fforesight.service.viewerdoc.model.Visualizations;
|
||||
import com.knecon.fforesight.service.viewerdoc.model.VisualizationsOnPage;
|
||||
import com.knecon.fforesight.service.viewerdoc.service.ViewerDocumentService;
|
||||
import com.pdftron.pdf.PDFDoc;
|
||||
import com.pdftron.pdf.Page;
|
||||
import com.pdftron.pdf.TextExtractor;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -39,185 +46,179 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class OcrResultWriter {
|
||||
|
||||
static String ocrLayerName = "knecon OCR";
|
||||
OcrServiceSettings settings;
|
||||
public static final Color REGULAR_TEXT_COLOR = Color.BLUE;
|
||||
public static final Color BOLD_TEXT_COLOR = Color.CYAN;
|
||||
|
||||
public static final Color REGULAR_TEXT_IN_IGNORE_ZONE = Color.RED;
|
||||
public static final Color BOLD_TEXT_IN_IGNORE_ZONE = Color.RED;
|
||||
|
||||
ViewerDocumentService viewerDocumentService;
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public Set<COSDictionary> drawOcrResultsToPdf(PDDocument document, List<OcrResult> ocrResults) {
|
||||
public void drawOcrResultsToPdf(File document, File viewerDocument, Map<Integer, List<OcrResultToWrite>> imagesWithResultsPerPage) {
|
||||
|
||||
FontMetricsFactory fontMetricsFactory = new Type0FontMetricsFactory(document);
|
||||
Set<COSDictionary> dictionariesToUpdate = new HashSet<>();
|
||||
Map<Integer, List<OcrResult>> resultsPerPage = ocrResults.stream().collect(Collectors.groupingBy(result -> result.image().pageNumber()));
|
||||
resultsPerPage.keySet().forEach(pageNumber -> drawResultsPerPage(document, pageNumber, resultsPerPage, dictionariesToUpdate, fontMetricsFactory));
|
||||
dictionariesToUpdate.add(document.getDocumentInformation().getCOSObject());
|
||||
return dictionariesToUpdate;
|
||||
}
|
||||
Map<Integer, VisualizationsOnPage> ocrVisualizationsOnPages = new HashMap<>();
|
||||
Map<Integer, VisualizationsOnPage> ocrTextDebugVisualizationsOnPages = new HashMap<>();
|
||||
Map<Integer, VisualizationsOnPage> ocrBBoxDebugVisualizationsOnPages = new HashMap<>();
|
||||
|
||||
try (var in = new FileInputStream(document); PDFDoc doc = new PDFDoc(in)) {
|
||||
|
||||
@SneakyThrows
|
||||
private void drawResultsPerPage(PDDocument document, Integer pageNumber, Map<Integer, List<OcrResult>> resultsPerPage, Set<COSDictionary> dictionariesToUpdate, FontMetricsFactory fontMetricsFactory) {
|
||||
for (Integer pageNumber : imagesWithResultsPerPage.keySet()) {
|
||||
|
||||
var pdPage = document.getPage(pageNumber - 1);
|
||||
List<Rectangle2D> textBBoxes = getTextBBoxes(doc.getPage(pageNumber));
|
||||
|
||||
PDOptionalContentGroup textDebugLayer = new PDOptionalContentGroup(ocrLayerName);
|
||||
PDOptionalContentGroup bBoxDebugLayer = new PDOptionalContentGroup(ocrLayerName + "BBox");
|
||||
if (settings.isDebug()) {
|
||||
textDebugLayer = addOptionalGroup(ocrLayerName, document, pdPage, dictionariesToUpdate);
|
||||
bBoxDebugLayer = addOptionalGroup(ocrLayerName + " BBox", document, pdPage, dictionariesToUpdate);
|
||||
}
|
||||
|
||||
escapeContentStreams(document, pdPage);
|
||||
|
||||
List<TextPositionInImage> words = buildTextPositionsOnPage(pageNumber, resultsPerPage, fontMetricsFactory);
|
||||
try (var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.APPEND, true)) {
|
||||
|
||||
// write invisible ocr text inside tagged content
|
||||
contentStream.beginMarkedContent(settings.getOcrMarkedContentTag());
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.setNonStrokingColor(Color.BLUE);
|
||||
contentStream.setStrokingColor(Color.BLUE);
|
||||
contentStream.setLineWidth(1);
|
||||
words.forEach(word -> drawInvisibleWord(word, contentStream));
|
||||
contentStream.restoreGraphicsState();
|
||||
contentStream.endMarkedContent();
|
||||
|
||||
if (settings.isDebug()) { // must not be written, as it will interfere with layout parsing
|
||||
// write visible ocr text inside optional group
|
||||
contentStream.beginMarkedContent(COSName.OC, textDebugLayer);
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.setNonStrokingColor(Color.BLUE);
|
||||
words.forEach(word -> drawVisibleWord(word, contentStream));
|
||||
contentStream.restoreGraphicsState();
|
||||
contentStream.endMarkedContent();
|
||||
|
||||
// write word bounding boxes (tesseract output) inside optional group
|
||||
contentStream.beginMarkedContent(COSName.OC, bBoxDebugLayer);
|
||||
contentStream.saveGraphicsState();
|
||||
resultsPerPage.get(pageNumber).stream().map(OcrResult::image).forEach(image -> drawGrid(contentStream, image.position()));
|
||||
words.stream().map(TextPositionInImage::getTransformedTextBBox).forEach(word -> drawRectangle(contentStream, word));
|
||||
contentStream.restoreGraphicsState();
|
||||
contentStream.endMarkedContent();
|
||||
ocrVisualizationsOnPages.put(pageNumber - 1, createVisualizations(imagesWithResultsPerPage.get(pageNumber), textBBoxes));
|
||||
ocrTextDebugVisualizationsOnPages.put(pageNumber - 1, createDebugTextVisualizations(imagesWithResultsPerPage.get(pageNumber), textBBoxes));
|
||||
ocrBBoxDebugVisualizationsOnPages.put(pageNumber - 1, createDebugBBoxVisualizations(imagesWithResultsPerPage.get(pageNumber)));
|
||||
}
|
||||
}
|
||||
dictionariesToUpdate.add(pdPage.getCOSObject());
|
||||
dictionariesToUpdate.add(pdPage.getResources().getCOSObject());
|
||||
|
||||
Visualizations visualizations = new Visualizations(ContentStreams.KNECON_OCR, ocrVisualizationsOnPages, false);
|
||||
|
||||
List<Visualizations> debugVisualizations = List.of(visualizations,
|
||||
new Visualizations(ContentStreams.KNECON_OCR_TEXT_DEBUG, ocrTextDebugVisualizationsOnPages, false),
|
||||
new Visualizations(ContentStreams.KNECON_OCR_BBOX_DEBUG, ocrBBoxDebugVisualizationsOnPages, false));
|
||||
|
||||
viewerDocumentService.addVisualizationsOnPage(document, document, List.of(visualizations));
|
||||
viewerDocumentService.addVisualizationsOnPage(viewerDocument, viewerDocument, debugVisualizations);
|
||||
}
|
||||
|
||||
|
||||
private static List<TextPositionInImage> buildTextPositionsOnPage(Integer pageNumber, Map<Integer, List<OcrResult>> resultsPerPage, FontMetricsFactory fontMetricsFactory) {
|
||||
@SuppressWarnings("PMD")
|
||||
private List<Rectangle2D> getTextBBoxes(Page page) {
|
||||
|
||||
return resultsPerPage.get(pageNumber)
|
||||
.stream()
|
||||
.flatMap(result -> result.getAllWords().stream().filter(word -> !word.isBlank()).map(word -> new TextPositionInImage(word, result.image().ctm(), fontMetricsFactory)))
|
||||
List<Rectangle2D> textBBoxes = new ArrayList<>();
|
||||
try (var textExtractor = new TextExtractor()) {
|
||||
textExtractor.begin(page);
|
||||
try {
|
||||
|
||||
for (TextExtractor.Line line = textExtractor.getFirstLine(); line.isValid(); line = getNextLine(line)) {
|
||||
for (TextExtractor.Word word = line.getFirstWord(); word.isValid(); word = getNextWord(word)) {
|
||||
textBBoxes.add(Converter.toRectangle2D(word.getBBox()));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not get word dimension, {}", e.getMessage());
|
||||
}
|
||||
return textBBoxes;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static TextExtractor.Word getNextWord(TextExtractor.Word word) {
|
||||
|
||||
TextExtractor.Word nextWord = word.getNextWord();
|
||||
word.close();
|
||||
return nextWord;
|
||||
}
|
||||
|
||||
|
||||
private static TextExtractor.Line getNextLine(TextExtractor.Line line) {
|
||||
|
||||
TextExtractor.Line newLine = line.getNextLine();
|
||||
line.close();
|
||||
return newLine;
|
||||
}
|
||||
|
||||
|
||||
private VisualizationsOnPage createVisualizations(List<OcrResultToWrite> ocrResultsToWrite, List<Rectangle2D> ignoreZones) {
|
||||
|
||||
List<TextPositionInImage> words = ocrResultsToWrite.stream()
|
||||
.map(OcrResultToWrite::textPositionInImage)
|
||||
.flatMap(Collection::stream)
|
||||
.filter(word -> ignoreZones.stream()
|
||||
.noneMatch(ignoreZone -> word.getTransformedTextBBox().getBounds2D().intersects(ignoreZone)))
|
||||
.toList();
|
||||
|
||||
List<PlacedText> placedTexts = words.stream()
|
||||
.map(word -> new PlacedText(word.getText(),
|
||||
null,
|
||||
Color.BLACK,
|
||||
(float) word.getFontSize(),
|
||||
word.getFontMetricsFactory(),
|
||||
Optional.of(word.getTextMatrix()),
|
||||
Optional.of(RenderingMode.NEITHER)))
|
||||
.toList();
|
||||
return VisualizationsOnPage.builder().placedTexts(placedTexts).build();
|
||||
}
|
||||
|
||||
|
||||
private VisualizationsOnPage createDebugTextVisualizations(List<OcrResultToWrite> ocrResultsToWrite, List<Rectangle2D> textBBoxes) {
|
||||
|
||||
List<TextPositionInImage> wordsToDraw = new ArrayList<>();
|
||||
List<TextPositionInImage> ignoredWords = new ArrayList<>();
|
||||
|
||||
for (OcrResultToWrite ocrResultToWrite : ocrResultsToWrite) {
|
||||
for (TextPositionInImage textPositionInImage : ocrResultToWrite.textPositionInImage()) {
|
||||
if (textBBoxes.stream()
|
||||
.anyMatch(ignoreZone -> textPositionInImage.getTransformedTextBBox().getBounds2D().intersects(ignoreZone))) {
|
||||
ignoredWords.add(textPositionInImage);
|
||||
} else {
|
||||
wordsToDraw.add(textPositionInImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
Stream<PlacedText> placedTexts = wordsToDraw.stream()
|
||||
.map(word -> new PlacedText(word.getText(),
|
||||
null,
|
||||
word.getFontStyle().equals(FontStyle.REGULAR) ? REGULAR_TEXT_COLOR : BOLD_TEXT_COLOR,
|
||||
(float) word.getFontSize(),
|
||||
word.getFontMetricsFactory(),
|
||||
Optional.of(word.getTextMatrix()),
|
||||
Optional.of(RenderingMode.FILL)));
|
||||
|
||||
Stream<PlacedText> placedTexts2 = ignoredWords.stream()
|
||||
.map(word -> new PlacedText(word.getText(),
|
||||
null,
|
||||
word.getFontStyle().equals(FontStyle.REGULAR) ? REGULAR_TEXT_IN_IGNORE_ZONE : BOLD_TEXT_IN_IGNORE_ZONE,
|
||||
(float) word.getFontSize(),
|
||||
word.getFontMetricsFactory(),
|
||||
Optional.of(word.getTextMatrix()),
|
||||
Optional.of(RenderingMode.FILL)));
|
||||
|
||||
return VisualizationsOnPage.builder()
|
||||
.placedTexts(Stream.of(placedTexts, placedTexts2)
|
||||
.flatMap(Function.identity())
|
||||
.toList())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
private VisualizationsOnPage createDebugBBoxVisualizations(List<OcrResultToWrite> ocrResultsToWrite) {
|
||||
|
||||
List<TextPositionInImage> words = ocrResultsToWrite.stream()
|
||||
.map(OcrResultToWrite::textPositionInImage)
|
||||
.flatMap(Collection::stream)
|
||||
.toList();
|
||||
List<ColoredLine> coloredLines = Stream.concat(//
|
||||
words.stream()
|
||||
.map(TextPositionInImage::getTransformedTextBBox)
|
||||
.map(this::quadPointAsLines),//
|
||||
ocrResultsToWrite.stream()
|
||||
.map(OcrResultToWrite::imageBoundingBox)
|
||||
.map(this::createGrid)//
|
||||
)
|
||||
.flatMap(Collection::stream)
|
||||
.toList();
|
||||
return VisualizationsOnPage.builder().coloredLines(coloredLines).build();
|
||||
}
|
||||
|
||||
|
||||
private List<ColoredLine> quadPointAsLines(QuadPoint rect) {
|
||||
|
||||
return List.of(new ColoredLine(new Line2D.Double(rect.a(), rect.b()), Color.ORANGE, 1),
|
||||
new ColoredLine(new Line2D.Double(rect.b(), rect.c()), Color.BLUE, 1),
|
||||
new ColoredLine(new Line2D.Double(rect.c(), rect.d()), Color.GREEN, 1),
|
||||
new ColoredLine(new Line2D.Double(rect.d(), rect.a()), Color.MAGENTA, 1));
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private static void escapeContentStreams(PDDocument document, PDPage pdPage) {
|
||||
// We need to append to the contentstream, otherwise the content could be overlapped by images
|
||||
// But we also need to save the graphics state before, such that our appended content cannot be affected by previous contentstreams with side-effects, such as not escaped matrix transformations
|
||||
try (var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.PREPEND, false)) {
|
||||
contentStream.saveGraphicsState();
|
||||
}
|
||||
try (var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.APPEND, false)) {
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
}
|
||||
private List<ColoredLine> createGrid(QuadPoint rect) {
|
||||
|
||||
List<ColoredLine> lines = new LinkedList<>(quadPointAsLines(rect));
|
||||
|
||||
private PDOptionalContentGroup addOptionalGroup(String ocrLayerName, PDDocument document, PDPage pdPage, Set<COSDictionary> dictionariesToUpdate) {
|
||||
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
PDOptionalContentProperties ocprops = catalog.getOCProperties();
|
||||
if (ocprops == null) {
|
||||
ocprops = new PDOptionalContentProperties();
|
||||
catalog.setOCProperties(ocprops);
|
||||
}
|
||||
PDOptionalContentGroup layer = null;
|
||||
if (ocprops.hasGroup(ocrLayerName)) {
|
||||
layer = ocprops.getGroup(ocrLayerName);
|
||||
} else {
|
||||
layer = new PDOptionalContentGroup(ocrLayerName);
|
||||
ocprops.addGroup(layer);
|
||||
}
|
||||
|
||||
// enable debug layers by default only when DEBUG flag is set.
|
||||
ocprops.setGroupEnabled(layer, settings.isDebug());
|
||||
PDResources resources = pdPage.getResources();
|
||||
if (resources == null) {
|
||||
resources = new PDResources();
|
||||
pdPage.setResources(resources);
|
||||
}
|
||||
dictionariesToUpdate.add(catalog.getCOSObject());
|
||||
return layer;
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private void drawRectangle(PDPageContentStream contentStream, QuadPoint rect) {
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.setLineWidth(1);
|
||||
contentStream.moveTo((float) rect.a().getX(), (float) rect.a().getY());
|
||||
contentStream.lineTo((float) rect.b().getX(), (float) rect.b().getY());
|
||||
contentStream.setStrokingColor(Color.ORANGE);
|
||||
contentStream.stroke();
|
||||
contentStream.moveTo((float) rect.b().getX(), (float) rect.b().getY());
|
||||
contentStream.lineTo((float) rect.c().getX(), (float) rect.c().getY());
|
||||
contentStream.setStrokingColor(Color.BLUE);
|
||||
contentStream.stroke();
|
||||
contentStream.moveTo((float) rect.c().getX(), (float) rect.c().getY());
|
||||
contentStream.lineTo((float) rect.d().getX(), (float) rect.d().getY());
|
||||
contentStream.setStrokingColor(Color.GREEN);
|
||||
contentStream.stroke();
|
||||
contentStream.moveTo((float) rect.d().getX(), (float) rect.d().getY());
|
||||
contentStream.lineTo((float) rect.a().getX(), (float) rect.a().getY());
|
||||
contentStream.setStrokingColor(Color.MAGENTA);
|
||||
contentStream.stroke();
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
|
||||
private void drawInvisibleWord(TextPositionInImage word, PDPageContentStream contentStream) {
|
||||
|
||||
drawWord(word, contentStream, RenderingMode.NEITHER);
|
||||
}
|
||||
|
||||
|
||||
private void drawVisibleWord(TextPositionInImage word, PDPageContentStream contentStream) {
|
||||
|
||||
drawWord(word, contentStream, RenderingMode.FILL);
|
||||
}
|
||||
|
||||
|
||||
// @SneakyThrows
|
||||
private void drawWord(TextPositionInImage position, PDPageContentStream contentStream, RenderingMode renderingMode) {
|
||||
|
||||
try {
|
||||
contentStream.beginText();
|
||||
contentStream.setRenderingMode(renderingMode);
|
||||
contentStream.setFont(position.getFont(), (float) position.getFontSize());
|
||||
contentStream.setTextMatrix(position.getTextMatrix());
|
||||
contentStream.showText(position.getText());
|
||||
contentStream.endText();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to write text {}", position.getText());
|
||||
log.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private void drawGrid(PDPageContentStream contentStream, QuadPoint rect) {
|
||||
|
||||
drawRectangle(contentStream, rect);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
contentStream.setLineWidth(0.2F);
|
||||
int nRows = 8;
|
||||
int nCols = 8;
|
||||
|
||||
@ -225,7 +226,7 @@ public class OcrResultWriter {
|
||||
Point2D start = add(rect.a(), abStep);
|
||||
Point2D end = add(rect.d(), abStep);
|
||||
for (int row = 0; row < nRows; ++row) {
|
||||
drawLine(start, end, contentStream);
|
||||
lines.add(new ColoredLine(new Line2D.Double(start, end), Color.BLACK, 0.2f));
|
||||
start = add(start, abStep);
|
||||
end = add(end, abStep);
|
||||
}
|
||||
@ -233,21 +234,12 @@ public class OcrResultWriter {
|
||||
start = add(rect.a(), adStep);
|
||||
end = add(rect.b(), adStep);
|
||||
for (int col = 0; col < nCols; ++col) {
|
||||
drawLine(start, end, contentStream);
|
||||
lines.add(new ColoredLine(new Line2D.Double(start, end), Color.BLACK, 0.2f));
|
||||
start = add(start, adStep);
|
||||
end = add(end, adStep);
|
||||
}
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private void drawLine(Point2D a, Point2D b, PDPageContentStream contentStream) {
|
||||
|
||||
contentStream.moveTo((float) a.getX(), (float) a.getY());
|
||||
contentStream.lineTo((float) b.getX(), (float) b.getY());
|
||||
contentStream.stroke();
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -16,12 +16,14 @@ public class Statistics {
|
||||
AtomicLong pdf2ImgDuration;
|
||||
AtomicLong writingTextDuration;
|
||||
AtomicLong imageProcessingDuration;
|
||||
AtomicLong fontStyleDetectionDuration;
|
||||
|
||||
|
||||
public Statistics(int numberOfExtractThreads, int numberOfOcrThreads) {
|
||||
|
||||
this.imageExtraction = Collections.synchronizedList(new ArrayList<>(Collections.nCopies(numberOfExtractThreads, 0L)));
|
||||
this.tesseractDuration = Collections.synchronizedList(new ArrayList<>(Collections.nCopies(numberOfOcrThreads, 0L)));
|
||||
this.fontStyleDetectionDuration = new AtomicLong(0);
|
||||
this.pdf2ImgDuration = new AtomicLong(0);
|
||||
this.writingTextDuration = new AtomicLong(0);
|
||||
this.imageProcessingDuration = new AtomicLong(0);
|
||||
@ -57,12 +59,17 @@ public class Statistics {
|
||||
writingTextDuration.addAndGet(duration);
|
||||
}
|
||||
|
||||
public void increaseFontStyleDetectionDuration(long duration) {
|
||||
|
||||
fontStyleDetectionDuration.addAndGet(duration);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return String.format(
|
||||
"imageExtraction: mean %.2f s, max %.2f s, min %.2f, tesseract: mean %.2f s, max %.2f s, min %.2f, ImageProcessing=%.2f s, PDF2Img=%.2f s, writingText=%.2f s",
|
||||
"imageExtraction: mean %.2f s, max %.2f s, min %.2f, tesseract: mean %.2f s, max %.2f s, min %.2f, ImageProcessing=%.2f s, PDF2Img=%.2f s, writingText=%.2f s, FontstyleDetection=%.2f s",
|
||||
((float) imageExtraction.stream().mapToLong(Long::longValue).average().orElse(0) / 1000),
|
||||
((float) imageExtraction.stream().mapToLong(Long::longValue).max().orElse(0) / 1000),
|
||||
((float) imageExtraction.stream().mapToLong(Long::longValue).min().orElse(0) / 1000),
|
||||
@ -71,7 +78,8 @@ public class Statistics {
|
||||
((float) tesseractDuration.stream().mapToLong(Long::longValue).min().orElse(0) / 1000),
|
||||
(float) imageProcessingDuration.get() / 1000,
|
||||
(float) pdf2ImgDuration.get() / 1000,
|
||||
(float) writingTextDuration.get() / 1000);
|
||||
(float) writingTextDuration.get() / 1000,
|
||||
(float) fontStyleDetectionDuration.get() / 1000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -4,11 +4,12 @@ import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.FontMetrics;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.HeightAndDescent;
|
||||
import com.knecon.fforesight.service.viewerdoc.model.EmbeddableFont;
|
||||
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
public interface FontMetricsFactory {
|
||||
public interface FontMetricsFactory extends EmbeddableFont {
|
||||
|
||||
default FontMetrics calculateMetrics(String text, double textWidth, double textHeight) {
|
||||
|
||||
@ -36,6 +37,7 @@ public interface FontMetricsFactory {
|
||||
|
||||
PDFont getFont();
|
||||
|
||||
|
||||
HeightAndDescent calculateHeightAndDescent(String text);
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service.fonts;
|
||||
|
||||
public enum FontStyle {
|
||||
REGULAR, BOLD, ITALIC
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service.fonts;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.fontbox.ttf.GlyphData;
|
||||
import org.apache.fontbox.ttf.TTFParser;
|
||||
@ -12,26 +13,63 @@ import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.HeightAndDescent;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Type0FontMetricsFactory implements FontMetricsFactory {
|
||||
|
||||
private final PDType0Font type0Font;
|
||||
private final TrueTypeFont trueTypeFont;
|
||||
private final String resourcePath;
|
||||
private PDType0Font type0Font;
|
||||
private TrueTypeFont trueTypeFont;
|
||||
private PDDocument documentThisIsEmbeddedIn;
|
||||
|
||||
// for this specific font back-/forward-slashes have a lot of descent screwing up the font size and therefore bold detection. So if we find such a character we ignore its descent.
|
||||
private static final Set<Integer> slashGlyphIds = Set.of(18, 63);
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public Type0FontMetricsFactory(PDDocument document) {
|
||||
public static Type0FontMetricsFactory regular(PDDocument document) {
|
||||
|
||||
try (var in = Thread.currentThread().getContextClassLoader().getResourceAsStream("fonts/cmu-regular.ttf"); var buffer = new RandomAccessReadBuffer(in)) {
|
||||
this.trueTypeFont = new TTFParser().parse(buffer); // since Type0Font can be descendant from any font, we need to remember the original TrueTypeFont for the glyph information
|
||||
this.type0Font = PDType0Font.load(document, this.trueTypeFont, false); // use Type0Font for unicode support
|
||||
String resourcePath = "fonts/cmu-regular.ttf";
|
||||
return createFromResourcePath(resourcePath, document);
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public static Type0FontMetricsFactory bold(PDDocument document) {
|
||||
|
||||
String resourcePath = "fonts/cmu-bold.ttf";
|
||||
return createFromResourcePath(resourcePath, document);
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
@SuppressWarnings("PMD.CloseResource")
|
||||
private static TrueTypeFont readFromResourcePath(String resourcePath) {
|
||||
|
||||
// The ttf is closed with the document, see PDType0Font line 134
|
||||
try (var in = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath); var buffer = new RandomAccessReadBuffer(in)) {
|
||||
return new TTFParser().parse(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
@SuppressWarnings("PMD.CloseResource")
|
||||
private static Type0FontMetricsFactory createFromResourcePath(String resourcePath, PDDocument document) {
|
||||
|
||||
TrueTypeFont trueTypeFont = readFromResourcePath(resourcePath);
|
||||
// since Type0Font can be descendant from any font, we need to remember the original TrueTypeFont for the glyph information
|
||||
return new Type0FontMetricsFactory(resourcePath, PDType0Font.load(document, trueTypeFont, true), trueTypeFont, document); // use Type0Font for unicode support)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public HeightAndDescent calculateHeightAndDescent(String text) {
|
||||
|
||||
@ -55,8 +93,9 @@ public class Type0FontMetricsFactory implements FontMetricsFactory {
|
||||
if (glyph == null || glyph.getBoundingBox() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
descent = Math.min(descent, glyph.getYMinimum());
|
||||
if (!slashGlyphIds.contains(glyphId)) {
|
||||
descent = Math.min(descent, glyph.getYMinimum());
|
||||
}
|
||||
height = Math.max(height, glyph.getYMaximum());
|
||||
} catch (Exception e) {
|
||||
log.warn("descent and height of string {} could not be parsed, using average fallback value!", text);
|
||||
@ -74,4 +113,28 @@ public class Type0FontMetricsFactory implements FontMetricsFactory {
|
||||
return type0Font;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public PDFont embed(PDDocument document) {
|
||||
|
||||
if (documentThisIsEmbeddedIn.equals(document)) {
|
||||
return getFont();
|
||||
}
|
||||
|
||||
// no need to close, the font will be closed with the document it is embedded in
|
||||
|
||||
this.trueTypeFont = readFromResourcePath(resourcePath);
|
||||
this.type0Font = PDType0Font.load(document, trueTypeFont, true);
|
||||
this.documentThisIsEmbeddedIn = document;
|
||||
return getFont();
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public void close() {
|
||||
|
||||
trueTypeFont.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service.scriptdetection;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.commons.math3.ml.clustering.Cluster;
|
||||
import org.apache.commons.math3.ml.clustering.DBSCANClusterer;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResult;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.OcrResultToWrite;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.scriptdetection.FontStyleDetectionModel;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.scriptdetection.TextPositionAndWordImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.model.scriptdetection.WordImage;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.FontStyle;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.fonts.Type0FontMetricsFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.settings.OcrServiceSettings;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class FontStyleDetector {
|
||||
|
||||
OcrServiceSettings settings;
|
||||
StrokeWidthCalculator strokeWidthCalculator;
|
||||
|
||||
|
||||
/**
|
||||
* Implementation of the MOBDoB algorithm, refer to the paper here:
|
||||
* <a href="http://mile.ee.iisc.ac.in/publications/softCopy/DocumentAnalysis/Sai_NCVPRIPG2013.pdf">Script Independent Detection of Bold Words in Multi Font-size Documents</a>
|
||||
* <p>
|
||||
* As a high level overview: We cluster all text based on its font size. We determine the cluster with the most words. This is assumed to be regular text.
|
||||
* We then estimate the average stroke width of that cluster by thinning all text to a single pixel and calculating the ratio of remaining pixels.
|
||||
* (<a href="http://www.leptonica.org/papers/conn.pdf">Leptonica Documentation on thinning</a>)
|
||||
* For each word we scale this average strokewidth based on its fontsize compared to the most common fontsize.
|
||||
* Using the scaled strokewidth we do an opening operation.
|
||||
* (<a href="https://en.wikipedia.org/wiki/Opening_(morphology)">Opening (Morphology)</a>).
|
||||
* We then threshold the ratio of remaining pixels to determine whether a word is bold or not.
|
||||
* <p>
|
||||
* I did take some liberties though. Firstly, the paper uses text height without ascender/descender height for the clustering. I'm using the previously implemented font size estimation.
|
||||
* But that is calculated based on text width. Thus, I'm also using the height scaling factor to scale the font size by the text height.
|
||||
* The paper does not describe its clustering algorithm, so I've decided on DBSCAN due to its good runtime and readily available implementation by apache commons math.
|
||||
* Moreover, the paper states that stroke width scales linearly with text height. I've come to the conclusion this is not the case.
|
||||
* It seems it scales with the square root of the text height. Or at least this seemed to give the best results for me.
|
||||
*/
|
||||
public Map<Integer, List<OcrResultToWrite>> detectBold(List<OcrResult> ocrResults, PDDocument document) {
|
||||
|
||||
FontMetricsFactory fontMetricsFactory = Type0FontMetricsFactory.regular(document);
|
||||
if (!settings.isBoldDetection()) {
|
||||
return OcrResultToWrite.buildOcrResultsToWrite(ocrResults, fontMetricsFactory);
|
||||
}
|
||||
|
||||
Map<Integer, List<OcrResultToWrite>> ocrResultToWritePerPage = new HashMap<>();
|
||||
|
||||
DBSCANClusterer<TextPositionAndWordImage> clusterer = new DBSCANClusterer<>(0.5, 1);
|
||||
|
||||
FontMetricsFactory boldFontMetricsFactory = Type0FontMetricsFactory.bold(document);
|
||||
|
||||
for (OcrResult result : ocrResults) {
|
||||
FontStyleDetectionModel fontStyleDetectionModel = FontStyleDetectionModel.fromOcrResult(result, fontMetricsFactory, settings);
|
||||
|
||||
List<Cluster<TextPositionAndWordImage>> clusters = clusterer.cluster(fontStyleDetectionModel.getTextPositionsAndWordImages());
|
||||
Optional<Cluster<TextPositionAndWordImage>> largestCluster = clusters.stream().max(Comparator.comparingInt(cluster -> cluster.getPoints().size()));
|
||||
|
||||
if (largestCluster.isEmpty()) {
|
||||
insertResultIntoMap(result.image().getPageNumber(), ocrResultToWritePerPage, fontStyleDetectionModel);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<TextPositionAndWordImage> wordsWithMostCommonTextHeight = largestCluster.get().getPoints();
|
||||
|
||||
double standardTextHeight = calculateStandardTextheight(wordsWithMostCommonTextHeight);
|
||||
double regularStrokeWidth = calculateRegularStrokeWidth(wordsWithMostCommonTextHeight);
|
||||
|
||||
for (TextPositionAndWordImage textPositionsAndWordImage : fontStyleDetectionModel.getTextPositionsAndWordImages()) {
|
||||
decideOnFontStyle(textPositionsAndWordImage, regularStrokeWidth, standardTextHeight, boldFontMetricsFactory);
|
||||
}
|
||||
|
||||
insertResultIntoMap(result.image().getPageNumber(), ocrResultToWritePerPage, fontStyleDetectionModel);
|
||||
fontStyleDetectionModel.dispose();
|
||||
}
|
||||
|
||||
log.info("Finished bold detection");
|
||||
return ocrResultToWritePerPage;
|
||||
}
|
||||
|
||||
|
||||
private static double calculateStandardTextheight(List<TextPositionAndWordImage> wordsWithMostCommonTextHeight) {
|
||||
|
||||
return wordsWithMostCommonTextHeight.stream()
|
||||
.map(TextPositionAndWordImage::getWordImage)
|
||||
.mapToDouble(WordImage::getTextHeight)
|
||||
.filter(Double::isFinite)
|
||||
.average()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
|
||||
private double calculateRegularStrokeWidth(List<TextPositionAndWordImage> wordsWithMostCommonTextHeight) {
|
||||
|
||||
return wordsWithMostCommonTextHeight.stream()
|
||||
.mapToDouble(textPositionAndWordImage -> strokeWidthCalculator.calculate(textPositionAndWordImage.getWordImage().getImage()))
|
||||
.filter(Double::isFinite)
|
||||
.average()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
|
||||
private static void insertResultIntoMap(int pageNumber, Map<Integer, List<OcrResultToWrite>> ocrResultToWritePerPage, FontStyleDetectionModel fontStyleDetectionModel) {
|
||||
|
||||
OcrResultToWrite ocrResult = OcrResultToWrite.fromFontStyleDetectionModel(fontStyleDetectionModel);
|
||||
|
||||
ocrResultToWritePerPage.compute(pageNumber, (key, existingList) -> {
|
||||
if (existingList == null) {
|
||||
return List.of(ocrResult);
|
||||
} else {
|
||||
return Stream.concat(existingList.stream(), Stream.of(ocrResult)).toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void decideOnFontStyle(TextPositionAndWordImage textPositionsAndWordImage,
|
||||
double standardStrokeWidth,
|
||||
double standardTextHeight,
|
||||
FontMetricsFactory boldFontMetricsFactory) {
|
||||
|
||||
double scaledStrokeWidth = scaleStrokeWidthByFontSize(textPositionsAndWordImage, standardStrokeWidth, standardTextHeight);
|
||||
|
||||
if (textPositionsAndWordImage.getWordImage().hasLargerStrokeWidth(scaledStrokeWidth)) {
|
||||
textPositionsAndWordImage.getTextPositionInImage().setFontMetricsFactory(boldFontMetricsFactory);
|
||||
textPositionsAndWordImage.getTextPositionInImage().setFontStyle(FontStyle.BOLD);
|
||||
} else {
|
||||
textPositionsAndWordImage.getTextPositionInImage().setFontStyle(FontStyle.REGULAR);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static double scaleStrokeWidthByFontSize(TextPositionAndWordImage textPositionsAndWordImage, double standardStrokeWidth, double standardFontSize) {
|
||||
|
||||
double influenceOfFontSize = 1.0; // the paper states that stroke width scales exactly linearly with font size. This did not seem to be true for me. Maybe some of the preprocessing steps are affecting this.
|
||||
double fontsizeScalingFactor = Math.sqrt(textPositionsAndWordImage.getWordImage().getTextHeight() / standardFontSize);
|
||||
return standardStrokeWidth + (influenceOfFontSize * (fontsizeScalingFactor - 1) * standardStrokeWidth);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service.scriptdetection;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.utils.ImageProcessingUtils;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.lept4j.Sel;
|
||||
import net.sourceforge.lept4j.util.LeptUtils;
|
||||
|
||||
/**
|
||||
* This code is a good start for detecting italic text, although it has a few issues especially with glyphs which are naturally slanted. E.g. z, 2, 7, /
|
||||
* If we want this maybe we should exclude these glyphs and then it might have less false positives. But in its current state i don't recommend using it.
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class ItalicDetector {
|
||||
|
||||
|
||||
static String italicKernel = "ooxxooxxooxxoxxooXxooxxoxxooxxooxxoo";
|
||||
Sel italicSel = Leptonica1.selCreateFromString(italicKernel, 9, 4, "italicKernel");
|
||||
Sel brickSel = Leptonica1.selCreateBrick(3, 4, 1, 2, 1);
|
||||
|
||||
|
||||
public boolean isItalic(Pix pix) {
|
||||
|
||||
Pix preprocessed = preprocess(pix);
|
||||
Pix flipped = Leptonica1.pixFlipLR(null, pix);
|
||||
Pix flippedPreprocessed = preprocess(flipped);
|
||||
Leptonica1.pixFlipLR(flippedPreprocessed, flippedPreprocessed);
|
||||
double pixelDensity = ImageProcessingUtils.calculatePixelDensity(preprocessed);
|
||||
double flippedPixelDensity = ImageProcessingUtils.calculatePixelDensity(flippedPreprocessed);
|
||||
LeptUtils.disposePix(preprocessed);
|
||||
LeptUtils.disposePix(flipped);
|
||||
LeptUtils.disposePix(flippedPreprocessed);
|
||||
return flippedPixelDensity / pixelDensity < 0.85;
|
||||
}
|
||||
|
||||
|
||||
private Pix preprocess(Pix pix) {
|
||||
|
||||
Pix eroded = Leptonica1.pixErode(null, pix, italicSel.getPointer());
|
||||
Pix dilated = Leptonica1.pixDilate(null, eroded, brickSel.getPointer());
|
||||
LeptUtils.disposePix(eroded);
|
||||
return dilated;
|
||||
}
|
||||
|
||||
|
||||
public void dispose() {
|
||||
|
||||
LeptUtils.dispose(italicSel);
|
||||
LeptUtils.dispose(brickSel);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.service.scriptdetection;
|
||||
|
||||
import static net.sourceforge.lept4j.ILeptonica.L_THIN_FG;
|
||||
|
||||
import java.nio.IntBuffer;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.lept4j.Sela;
|
||||
import net.sourceforge.lept4j.util.LeptUtils;
|
||||
|
||||
@Service
|
||||
@NoArgsConstructor
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
public class StrokeWidthCalculator {
|
||||
|
||||
Sela thinningSel;
|
||||
|
||||
|
||||
/**
|
||||
* Uses a series of sels to thin all connected lines to a single pixel. Then the pixel ratio is a good estimation of the stroke width in pixels.
|
||||
* <a href="http://www.leptonica.org/papers/conn.pdf">Leptonica Documentation on thinning</a>
|
||||
* Since the baseline is a strokewidth of exactly one, we need to add 1 to the result.
|
||||
*
|
||||
* @param input binarized pix with text on it
|
||||
* @return estimated stroke width in pixels
|
||||
*/
|
||||
public double calculate(Pix input) {
|
||||
|
||||
init();
|
||||
|
||||
Pix thinned = Leptonica1.pixThinConnectedBySet(input, L_THIN_FG, thinningSel, 0);
|
||||
|
||||
IntBuffer thinnedPixelCount = IntBuffer.allocate(1);
|
||||
Leptonica1.pixCountPixels(thinned, thinnedPixelCount, null);
|
||||
|
||||
IntBuffer pixelCount = IntBuffer.allocate(1);
|
||||
Leptonica1.pixCountPixels(input, pixelCount, null);
|
||||
|
||||
LeptUtils.disposePix(thinned);
|
||||
|
||||
return (double) pixelCount.get() / thinnedPixelCount.get() + 1;
|
||||
}
|
||||
|
||||
|
||||
private void init() {
|
||||
|
||||
if (thinningSel == null) {
|
||||
thinningSel = Leptonica1.selaMakeThinSets(1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -17,7 +17,6 @@ import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sourceforge.tess4j.TessAPI1;
|
||||
|
||||
|
||||
/*
|
||||
This just moves the Elements from the GhostScriptOutputListener into the ImageProcessing queue asynchronously
|
||||
*/
|
||||
@ -38,9 +37,17 @@ public class BlockingQueueFiller extends Thread {
|
||||
public void run() {
|
||||
|
||||
// Interrupting signals that the image extraction has finished
|
||||
while (!allImagesQueued) {
|
||||
try {
|
||||
while (!allImagesQueued) {
|
||||
final UnprocessedImage image = imageInputQueue.take();
|
||||
imageOutputQueue.put(image);
|
||||
try {
|
||||
imageOutputQueue.put(image);
|
||||
} catch (InterruptedException e) {
|
||||
imageOutputQueue.put(image);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
log.info("All images extracted, emptying processing queue and stopping");
|
||||
}
|
||||
|
||||
// empty the queue
|
||||
@ -54,4 +61,5 @@ public class BlockingQueueFiller extends Thread {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -109,7 +109,6 @@ public class GhostScriptOutputHandler extends Thread {
|
||||
if (imageFile == null) {
|
||||
throw new IllegalArgumentException(String.format("Page number %d does not exist in this thread. It only has pagenumbers %s", pageNumber, pagesToProcess.keySet()));
|
||||
}
|
||||
assert new File(imageFile.absoluteFilePath()).isFile();
|
||||
renderedPageImageFileOutput.add(imageFile);
|
||||
}
|
||||
|
||||
|
||||
@ -4,8 +4,6 @@ import static net.sourceforge.tess4j.ITessAPI.TRUE;
|
||||
|
||||
import java.nio.FloatBuffer;
|
||||
import java.nio.IntBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
|
||||
@ -29,6 +27,8 @@ import lombok.Setter;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sourceforge.lept4j.L_Kernel;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.lept4j.util.LeptUtils;
|
||||
import net.sourceforge.tess4j.ITessAPI;
|
||||
@ -45,6 +45,7 @@ public class ImageProcessingThread extends Thread {
|
||||
final BlockingQueue<UnprocessedImage> imageInputQueue;
|
||||
final BlockingQueue<OcrImage> imageOutputQueue;
|
||||
final ITessAPI.TessBaseAPI detectionScriptHandle = initDetectionScriptHandle();
|
||||
final L_Kernel gaussianKernel = Leptonica1.makeGaussianKernel(2, 2, 1.0f, 1);
|
||||
final Statistics stats;
|
||||
final OcrServiceSettings settings;
|
||||
final PDDocument document;
|
||||
@ -57,13 +58,20 @@ public class ImageProcessingThread extends Thread {
|
||||
@Override
|
||||
public void run() {
|
||||
|
||||
while (!allImagesExtracted) {
|
||||
final UnprocessedImage image = imageInputQueue.take();
|
||||
var ocrImage = this.process(image);
|
||||
imageOutputQueue.put(ocrImage);
|
||||
try {
|
||||
while (!allImagesExtracted) {
|
||||
final UnprocessedImage image = imageInputQueue.take();
|
||||
var ocrImage = this.process(image);
|
||||
try {
|
||||
imageOutputQueue.put(ocrImage);
|
||||
} catch (InterruptedException e) {
|
||||
imageOutputQueue.put(ocrImage);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
log.info("All images extracted, emptying processing queue and stopping");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
final UnprocessedImage image = imageInputQueue.remove();
|
||||
@ -74,7 +82,9 @@ public class ImageProcessingThread extends Thread {
|
||||
log.debug("No images left in processing queue, stopping.");
|
||||
}
|
||||
|
||||
TessAPI1.TessBaseAPIEnd(this.detectionScriptHandle);
|
||||
TessAPI1.TessBaseAPIDelete(this.detectionScriptHandle);
|
||||
LeptUtils.dispose(gaussianKernel);
|
||||
}
|
||||
|
||||
|
||||
@ -97,9 +107,10 @@ public class ImageProcessingThread extends Thread {
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("PMD.CompareObjectsWithEquals")
|
||||
private OcrImage processRenderedPageImageFile(RenderedPageImageFile renderedPageImageFile) {
|
||||
|
||||
Pix pix = binarize(renderedPageImageFile.asPix(), settings.getDpi(), settings.getDpi());
|
||||
Pix pix = processPix(renderedPageImageFile.asPix(), settings.getDpi(), settings.getDpi());
|
||||
|
||||
int orientDegree = detectOrientation(pix, settings.getDpi(), detectionScriptHandle);
|
||||
Pix rotatedPix = ImageProcessingUtils.deRotatePix(orientDegree, pix);
|
||||
@ -117,12 +128,12 @@ public class ImageProcessingThread extends Thread {
|
||||
return ocrImage;
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("PMD.CompareObjectsWithEquals")
|
||||
private OcrImage processExtractedImage(ExtractedImage extractedImage) {
|
||||
|
||||
float imageDPI = Math.abs(extractedImage.image().getWidth() / (extractedImage.ctm().getScalingFactorX() / 72));
|
||||
|
||||
Pix pix = binarize(extractedImage.asPix(), imageDPI, settings.getDpi());
|
||||
Pix pix = processPix(extractedImage.asPix(), imageDPI, settings.getDpi());
|
||||
|
||||
int orientDegree = detectOrientation(pix, settings.getDpi(), detectionScriptHandle);
|
||||
Pix rotatedPix = ImageProcessingUtils.deRotatePix(orientDegree, pix);
|
||||
@ -156,7 +167,7 @@ public class ImageProcessingThread extends Thread {
|
||||
|
||||
orientationDegreeResultBuffer = IntBuffer.allocate(1);
|
||||
orientationDegreeConfidenceBuffer = FloatBuffer.allocate(1);
|
||||
scriptureNameBuffer = new PointerByReference();
|
||||
scriptureNameBuffer = new PointerByReference(); // Is this memory being freed?
|
||||
scriptureConfidenceBuffer = FloatBuffer.allocate(1);
|
||||
|
||||
int orientationDegree = 0;
|
||||
@ -176,15 +187,58 @@ public class ImageProcessingThread extends Thread {
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private Pix binarize(Pix pix, float imageDpi, int targetDpi) {
|
||||
private Pix processPix(Pix pix, float imageDpi, int targetDpi) {
|
||||
|
||||
Pix grayScale = ImageProcessingUtils.convertToGrayScale(pix);
|
||||
Pix scaledUp = ImageProcessingUtils.scaleToTargetDpi(imageDpi, targetDpi, grayScale);
|
||||
return ImageProcessingUtils.despecklePix(scaledUp);
|
||||
Pix grayScale;
|
||||
Pix scaledUp;
|
||||
Pix gaussian;
|
||||
Pix binarized;
|
||||
|
||||
//convert to grayscale
|
||||
if (pix.d == 8) {
|
||||
grayScale = pix;
|
||||
} else if (pix.d == 32) {
|
||||
grayScale = Leptonica1.pixConvertRGBToGrayFast(pix);
|
||||
LeptUtils.disposePix(pix);
|
||||
} else if (pix.d == 1) {
|
||||
grayScale = Leptonica1.pixConvert1To8(null, pix, (byte) 0, (byte) 255);
|
||||
LeptUtils.disposePix(pix);
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown pix format with bpp of %d", pix.d));
|
||||
}
|
||||
|
||||
// scale up
|
||||
float targetFactor = targetDpi / imageDpi;
|
||||
if (targetFactor > 2.1) {
|
||||
scaledUp = Leptonica1.pixScaleGray4xLI(grayScale);
|
||||
LeptUtils.disposePix(grayScale);
|
||||
} else if (targetFactor > 1.1) {
|
||||
scaledUp = Leptonica1.pixScaleGray2xLI(grayScale);
|
||||
LeptUtils.disposePix(grayScale);
|
||||
} else {
|
||||
scaledUp = grayScale;
|
||||
}
|
||||
|
||||
// remove noise and prep for Otsu
|
||||
gaussian = Leptonica1.pixConvolve(scaledUp, gaussianKernel, 8, 1);
|
||||
LeptUtils.disposePix(scaledUp);
|
||||
|
||||
// Threshold to binary
|
||||
if (pix.w < 100 || pix.h < 100) {
|
||||
binarized = Leptonica1.pixThresholdToBinary(gaussian, 170);
|
||||
} else {
|
||||
binarized = Leptonica1.pixOtsuThreshOnBackgroundNorm(gaussian, null, 50, 50, 165, 10, 100, 5, 5, 0.1f, null);
|
||||
if (binarized == null) { // Sometimes Otsu just fails, then we binarize directly
|
||||
binarized = Leptonica1.pixThresholdToBinary(gaussian, 170);
|
||||
}
|
||||
}
|
||||
LeptUtils.disposePix(gaussian);
|
||||
|
||||
return binarized;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static ITessAPI.TessBaseAPI initDetectionScriptHandle() {
|
||||
|
||||
ITessAPI.TessBaseAPI handle = TessAPI1.TessBaseAPICreate();
|
||||
|
||||
@ -116,16 +116,11 @@ public class OCRThread extends Thread {
|
||||
@SneakyThrows
|
||||
public void executeTesseract(int psm, int dpi, Pix pix, String tesseractOutputFileName) {
|
||||
|
||||
if (settings.isDebug()) {
|
||||
String[] a = tesseractOutputFileName.split("/");
|
||||
String folder = "/tmp/pixs/" + a[a.length - 3];
|
||||
new File(folder).mkdirs();
|
||||
Leptonica1.pixWrite(folder + "/pix_" + a[a.length - 1] + ".png", pix, 3);
|
||||
}
|
||||
|
||||
Leptonica1.pixWrite(tesseractOutputFileName + ".tiff", pix, 5); // write the used image for later bold detection
|
||||
instance.setVariable("user_defined_dpi", String.valueOf(dpi));
|
||||
instance.setPageSegMode(psm);
|
||||
instance.createDocumentsWithResults(pix, null, tesseractOutputFileName, List.of(ITesseract.RenderedFormat.HOCR), ITessAPI.TessPageIteratorLevel.RIL_BLOCK);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -19,10 +19,10 @@ public class OcrServiceSettings {
|
||||
int psmOverride = -1; // Overrides the page segmentation mode if > 0
|
||||
int minImageHeight = 20; // Minimum height for images to be processed
|
||||
int minImageWidth = 20; // Minimum width for images to be processed
|
||||
float minRotationConfidence = 2; //
|
||||
float minRotationConfidence = 2; // Sets a lower bound for the confidence rating for rotated pages.
|
||||
boolean debug; // If true, overlays OCR images with a grid and draws word bounding boxes
|
||||
boolean removeWatermark; // If true, watermarks will be removed
|
||||
String languages = "deu+eng"; // Defines languages loaded into Tesseract as 3-char codes, additional languages must also be installed in the docker environment
|
||||
COSName ocrMarkedContentTag = COSName.getPDFName("KNECON_OCR");
|
||||
|
||||
boolean boldDetection = true; // if true, bold detection will be attempted
|
||||
double boldThreshold = 0.5; // Words are opened with a brick of average stroke width, if the ratio of remaining pixels is higher the word is determined bold.
|
||||
}
|
||||
|
||||
@ -6,14 +6,17 @@ import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Transparency;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.IntBuffer;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceGray;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.model.ExtractedImage;
|
||||
import com.sun.jna.ptr.PointerByReference;
|
||||
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import net.sourceforge.lept4j.L_Kernel;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
import net.sourceforge.lept4j.Pix;
|
||||
import net.sourceforge.lept4j.util.LeptUtils;
|
||||
@ -37,67 +40,6 @@ public class ImageProcessingUtils {
|
||||
}
|
||||
|
||||
|
||||
public static Pix despecklePix(Pix pix) {
|
||||
|
||||
assert pix.d == 8;
|
||||
Pix despeckled;
|
||||
if (pix.w < 100 || pix.h < 100) {
|
||||
// too small to properly despeckle, just binarize instead.
|
||||
despeckled = Leptonica1.pixThresholdToBinary(pix, 180);
|
||||
} else {
|
||||
despeckled = LeptUtils.despeckle(pix,
|
||||
LeptUtils.SEL_STR3,
|
||||
3); // sometimes this fails and I can't figure out why. Then we skip the despeckling and just simply convert to binary. Might have something to do with Imagesize, not sure though...
|
||||
if (despeckled == null) {
|
||||
despeckled = Leptonica1.pixThresholdToBinary(pix, 180);
|
||||
}
|
||||
}
|
||||
if (pix != despeckled) {
|
||||
LeptUtils.disposePix(pix);
|
||||
}
|
||||
return despeckled;
|
||||
}
|
||||
|
||||
|
||||
public static Pix scaleToTargetDpi(float imageDpi, int targetDpi, Pix grayScale) {
|
||||
|
||||
float targetFactor = targetDpi / imageDpi;
|
||||
|
||||
if (targetFactor > 3) {
|
||||
Pix scaledUp;
|
||||
scaledUp = Leptonica1.pixScaleGray4xLI(grayScale);
|
||||
LeptUtils.disposePix(grayScale);
|
||||
return scaledUp;
|
||||
} else if (targetFactor > 1.9) {
|
||||
Pix scaledUp;
|
||||
scaledUp = Leptonica1.pixScaleGray2xLI(grayScale);
|
||||
LeptUtils.disposePix(grayScale);
|
||||
return scaledUp;
|
||||
} else {
|
||||
return grayScale;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public static Pix convertToGrayScale(Pix pix) {
|
||||
|
||||
if (pix.d == 8) {
|
||||
return pix;
|
||||
} else if (pix.d == 32) {
|
||||
Pix grayScale = Leptonica1.pixConvertRGBToGrayFast(pix);
|
||||
LeptUtils.disposePix(pix);
|
||||
return grayScale;
|
||||
} else if (pix.d == 1) {
|
||||
Pix grayScale = Leptonica1.pixConvert1To8(null, pix, (byte) 0, (byte) 255);
|
||||
LeptUtils.disposePix(pix);
|
||||
return grayScale;
|
||||
} else {
|
||||
throw new UnsupportedOperationException(String.format("Unknown pix format with bpp of %d", pix.d));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Pix deRotatePix(int orientDegree, Pix pix) {
|
||||
|
||||
return switch (360 - orientDegree) {
|
||||
@ -128,4 +70,16 @@ public class ImageProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static double calculatePixelDensity(Pix pix) {
|
||||
|
||||
IntBuffer pixelCount = IntBuffer.allocate(1);
|
||||
int result = Leptonica1.pixCountPixels(pix, pixelCount, null);
|
||||
if (result == 0) {
|
||||
return (double) pixelCount.get() / (pix.h * pix.w);
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package com.knecon.fforesight.service.ocr.processor.utils;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import net.sourceforge.lept4j.L_Kernel;
|
||||
import net.sourceforge.lept4j.Leptonica1;
|
||||
|
||||
@UtilityClass
|
||||
public class KernelUtils {
|
||||
|
||||
/*
|
||||
-1, -1, -1
|
||||
-1, 8, -1
|
||||
-1, -1, -1
|
||||
*/
|
||||
public L_Kernel createFullLaplacianKernel() {
|
||||
|
||||
L_Kernel laplacianKernel = Leptonica1.kernelCreate(3, 3);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 0, 0, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 0, 1, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 0, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 0, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 0, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 1, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 1, 8);
|
||||
return laplacianKernel;
|
||||
}
|
||||
|
||||
/*
|
||||
0, 0, -1, 0, 0
|
||||
0, -1, -1, -1, 0
|
||||
-1, -1, 12, -1, -1
|
||||
0, -1, -1, -1, 0
|
||||
0, 0, -1, 0, 0
|
||||
*/
|
||||
public L_Kernel createLaplacianKernel5x5() {
|
||||
|
||||
L_Kernel laplacianKernel = Leptonica1.kernelCreate(5, 5);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 0, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 1, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 3, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 0, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 1, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 3, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 4, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 3, 1, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 3, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 3, 3, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 4, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 2, 12);
|
||||
return laplacianKernel;
|
||||
}
|
||||
|
||||
/*
|
||||
0, -1, 0
|
||||
-1, 4, -1
|
||||
0, -1, 0
|
||||
*/
|
||||
public L_Kernel createLaplacianKernel() {
|
||||
|
||||
L_Kernel laplacianKernel = Leptonica1.kernelCreate(3, 3);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 0, 1, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 0, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 2, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 2, 1, -1);
|
||||
Leptonica1.kernelSetElement(laplacianKernel, 1, 1, 4);
|
||||
return laplacianKernel;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -17,58 +17,57 @@ public class PdfDraw {
|
||||
@SneakyThrows
|
||||
public static void drawGrid(ElementWriter writer, Page page) {
|
||||
|
||||
ElementBuilder eb = new ElementBuilder();
|
||||
double dX = 15;
|
||||
double dY = 15;
|
||||
int nRows = (int) (page.getPageHeight() / dY) + 1;
|
||||
int nCols = (int) (page.getPageWidth() / dX) + 1;
|
||||
for (int row = 0; row < nRows; ++row) {
|
||||
for (int col = 0; col < nCols; ++col) {
|
||||
Element cell = eb.createRect(col * dX, row * dY, dX, dY);
|
||||
cell.setPathStroke(true);
|
||||
cell.getGState().setLineWidth(1);
|
||||
cell.getGState().setStrokeOpacity(0.1);
|
||||
cell.getGState().setStrokeColorSpace(ColorSpace.createDeviceRGB());
|
||||
if (row == 0 && col == 0) {
|
||||
cell.getGState().setStrokeColor(new ColorPt(0, 0, 1));
|
||||
cell.setPathFill(true);
|
||||
cell.getGState().setFillOpacity(0.8);
|
||||
cell.getGState().setFillColorSpace(ColorSpace.createDeviceRGB());
|
||||
cell.getGState().setFillColor(new ColorPt(0, 0, 1));
|
||||
} else {
|
||||
cell.setPathFill(false);
|
||||
cell.getGState().setStrokeColor(new ColorPt(0.1, 0.1, 0.1));
|
||||
try (var eb = new ElementBuilder()) {
|
||||
double dX = 15;
|
||||
double dY = 15;
|
||||
int nRows = (int) (page.getPageHeight() / dY) + 1;
|
||||
int nCols = (int) (page.getPageWidth() / dX) + 1;
|
||||
for (int row = 0; row < nRows; ++row) {
|
||||
for (int col = 0; col < nCols; ++col) {
|
||||
Element cell = eb.createRect(col * dX, row * dY, dX, dY);
|
||||
cell.setPathStroke(true);
|
||||
cell.getGState().setLineWidth(1);
|
||||
cell.getGState().setStrokeOpacity(0.1);
|
||||
cell.getGState().setStrokeColorSpace(ColorSpace.createDeviceRGB());
|
||||
if (row == 0 && col == 0) {
|
||||
cell.getGState().setStrokeColor(new ColorPt(0, 0, 1));
|
||||
cell.setPathFill(true);
|
||||
cell.getGState().setFillOpacity(0.8);
|
||||
cell.getGState().setFillColorSpace(ColorSpace.createDeviceRGB());
|
||||
cell.getGState().setFillColor(new ColorPt(0, 0, 1));
|
||||
} else {
|
||||
cell.setPathFill(false);
|
||||
cell.getGState().setStrokeColor(new ColorPt(0.1, 0.1, 0.1));
|
||||
}
|
||||
writer.writePlacedElement(cell);
|
||||
}
|
||||
writer.writePlacedElement(cell);
|
||||
}
|
||||
}
|
||||
eb.destroy();
|
||||
}
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
public static void drawRectCollection(ElementWriter writer, RectCollection rectCollection) {
|
||||
|
||||
ColorPt colorPt = new ColorPt(1, 0, 0);
|
||||
ElementBuilder eb = new ElementBuilder();
|
||||
for (int i = 0; i < rectCollection.getNumRects(); ++i) {
|
||||
Rect r = rectCollection.getRectAt(i);
|
||||
Element rect = eb.createRect(r.getX1(), r.getY1(), r.getWidth(), r.getHeight());
|
||||
try (var colorPt = new ColorPt(1, 0, 0); var eb = new ElementBuilder()) {
|
||||
for (int i = 0; i < rectCollection.getNumRects(); ++i) {
|
||||
try(var r = rectCollection.getRectAt(i)) {
|
||||
Element rect = eb.createRect(r.getX1(), r.getY1(), r.getWidth(), r.getHeight());
|
||||
|
||||
rect.setPathStroke(true);
|
||||
rect.getGState().setLineWidth(5);
|
||||
rect.getGState().setStrokeColorSpace(ColorSpace.createDeviceRGB());
|
||||
rect.getGState().setStrokeColor(colorPt);
|
||||
rect.setPathStroke(true);
|
||||
rect.getGState().setLineWidth(5);
|
||||
rect.getGState().setStrokeColorSpace(ColorSpace.createDeviceRGB());
|
||||
rect.getGState().setStrokeColor(colorPt);
|
||||
|
||||
rect.setPathFill(true);
|
||||
rect.getGState().setFillColorSpace(ColorSpace.createDeviceRGB());
|
||||
rect.getGState().setFillColor(colorPt);
|
||||
rect.getGState().setFillOpacity(0.5);
|
||||
rect.setPathFill(true);
|
||||
rect.getGState().setFillColorSpace(ColorSpace.createDeviceRGB());
|
||||
rect.getGState().setFillColor(colorPt);
|
||||
rect.getGState().setFillOpacity(0.5);
|
||||
|
||||
writer.writePlacedElement(rect);
|
||||
writer.writePlacedElement(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
colorPt.destroy();
|
||||
eb.destroy();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ class Type0FontMetricsFactoryTest {
|
||||
public void testStringWidth() {
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(new File(Type0FontMetricsFactoryTest.class.getClassLoader().getResource("InvisibleText.pdf").getPath()))) {
|
||||
Type0FontMetricsFactory metricsFactory = new Type0FontMetricsFactory(document);
|
||||
Type0FontMetricsFactory metricsFactory = Type0FontMetricsFactory.regular(document);
|
||||
FontMetrics fontMetrics = metricsFactory.calculateMetrics("deine mutter", 100, 50);
|
||||
}
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
|
||||
plugins {
|
||||
application
|
||||
id("com.iqser.red.service.java-conventions")
|
||||
id("org.springframework.boot") version "3.1.5"
|
||||
id("org.springframework.boot") version "3.2.3"
|
||||
id("io.spring.dependency-management") version "1.1.3"
|
||||
id("org.sonarqube") version "4.3.0.3225"
|
||||
id("io.freefair.lombok") version "8.2.2"
|
||||
id("io.freefair.lombok") version "8.4"
|
||||
}
|
||||
|
||||
configurations {
|
||||
@ -17,14 +17,14 @@ configurations {
|
||||
}
|
||||
}
|
||||
|
||||
val springBootStarterVersion = "3.1.5"
|
||||
val springBootStarterVersion = "3.2.3"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":ocr-service-processor"))
|
||||
implementation(project(":ocr-service-api"))
|
||||
|
||||
implementation("com.knecon.fforesight:tracing:0.5.0")
|
||||
implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.0.4")
|
||||
implementation("com.knecon.fforesight:tracing-commons:0.7.0")
|
||||
implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.1.1")
|
||||
implementation("org.springframework.boot:spring-boot-starter-amqp:${springBootStarterVersion}")
|
||||
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
|
||||
@ -39,7 +39,7 @@ tasks.named<BootBuildImage>("bootBuildImage") {
|
||||
|
||||
environment.put("BPE_DELIM_JAVA_TOOL_OPTIONS", " ")
|
||||
environment.put("BPE_APPEND_JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8")
|
||||
environment.put("BPE_GS_LIB", "/layers/fagiani_apt/apt/usr/share/ghostscript/9.26/Resource/Init/") // set ghostscript lib path
|
||||
environment.put("BPE_GS_LIB", "/layers/fagiani_apt/apt/usr/share/ghostscript/9.55.0/Resource/Init/") // set ghostscript lib path, version in path must match version in Aptfile
|
||||
environment.put("BPE_FONTCONFIG_PATH", "/layers/fagiani_apt/apt/etc/fonts/") // set ghostscript fontconfig path
|
||||
|
||||
var aptfile = layout.projectDirectory.file("src/main/resources/Aptfile").toString()
|
||||
@ -53,7 +53,7 @@ tasks.named<BootBuildImage>("bootBuildImage") {
|
||||
|
||||
buildpacks.set(
|
||||
listOf(
|
||||
"ghcr.io/fagiani/buildpacks/fagiani_apt@sha256:6471c8c70f32b749e29f65ae562ac0339fecad26aa9217628c00a6c31f197dae",
|
||||
"ghcr.io/knsita/buildpacks/fagiani_apt@sha256:9771d4d27d8050aee62769490b8882fffc794745c129fb98e1f33196e2c93504",
|
||||
"ghcr.io/kschuettler/knecon-vcpkg@sha256:ba5e967b124de4865ff7e8f565684f752dd6e97b302e2dcf651283f6a19b98b9",
|
||||
"ghcr.io/kschuettler/knecon-tessdata@sha256:9062f728aa0340ac963bcdd6f5e740d683823a81d3f480db894da15bff72691a",
|
||||
"urn:cnb:builder:paketo-buildpacks/java"
|
||||
|
||||
@ -5,27 +5,28 @@ import org.springframework.boot.actuate.autoconfigure.security.servlet.Managemen
|
||||
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
import com.iqser.red.pdftronlogic.commons.InvisibleElementRemovalService;
|
||||
import com.iqser.red.pdftronlogic.commons.WatermarkRemovalService;
|
||||
import com.knecon.fforesight.lifecyclecommons.LifecycleAutoconfiguration;
|
||||
import com.knecon.fforesight.service.ocr.processor.OcrServiceProcessorConfiguration;
|
||||
import com.knecon.fforesight.service.ocr.v1.server.client.FileStatusProcessingUpdateClient;
|
||||
import com.knecon.fforesight.service.ocr.v1.server.queue.MessagingConfiguration;
|
||||
import com.iqser.red.storage.commons.StorageAutoConfiguration;
|
||||
import com.knecon.fforesight.tenantcommons.MultiTenancyAutoConfiguration;
|
||||
import com.knecon.fforesight.tracing.OpenTelemetryConfig;
|
||||
|
||||
import io.micrometer.core.aop.TimedAspect;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
@EnableAsync
|
||||
@ImportAutoConfiguration({MultiTenancyAutoConfiguration.class})
|
||||
@ImportAutoConfiguration({MultiTenancyAutoConfiguration.class, LifecycleAutoconfiguration.class})
|
||||
@SpringBootApplication(exclude = {SecurityAutoConfiguration.class, ManagementWebSecurityAutoConfiguration.class})
|
||||
@Import({MessagingConfiguration.class, StorageAutoConfiguration.class, OcrServiceProcessorConfiguration.class})
|
||||
@EnableFeignClients(basePackageClasses = FileStatusProcessingUpdateClient.class)
|
||||
@Import({MessagingConfiguration.class, StorageAutoConfiguration.class, OcrServiceProcessorConfiguration.class, OpenTelemetryConfig.class})
|
||||
@EnableAspectJAutoProxy
|
||||
public class Application {
|
||||
|
||||
/**
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
package com.knecon.fforesight.service.ocr.v1.server.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
|
||||
import com.iqser.red.service.persistence.service.v1.api.internal.resources.FileStatusProcessingUpdateResource;
|
||||
|
||||
@FeignClient(name = "FileStatusProcessingUpdateResource", url = "${persistence-service.url}")
|
||||
public interface FileStatusProcessingUpdateClient extends FileStatusProcessingUpdateResource {
|
||||
|
||||
}
|
||||
@ -11,35 +11,10 @@ import lombok.RequiredArgsConstructor;
|
||||
@RequiredArgsConstructor
|
||||
public class MessagingConfiguration {
|
||||
|
||||
public static final String OCR_QUEUE = "ocrQueue";
|
||||
public static final String OCR_DLQ = "ocrDLQ";
|
||||
|
||||
public static final String X_DEAD_LETTER_EXCHANGE = "x-dead-letter-exchange";
|
||||
public static final String X_DEAD_LETTER_ROUTING_KEY = "x-dead-letter-routing-key";
|
||||
public static final String X_MAX_PRIORITY = "x-max-priority";
|
||||
|
||||
public static final String OCR_REQUEST_QUEUE = "ocr_request_queue";
|
||||
public static final String OCR_RESPONSE_QUEUE = "ocr_response_queue";
|
||||
public static final String OCR_STATUS_UPDATE_RESPONSE_QUEUE = "ocr_status_update_response_queue";
|
||||
|
||||
public static final String X_ERROR_INFO_HEADER = "x-error-message";
|
||||
public static final String X_ERROR_INFO_TIMESTAMP_HEADER = "x-error-message-timestamp";
|
||||
|
||||
|
||||
@Bean
|
||||
public Queue ocrQueue() {
|
||||
|
||||
return QueueBuilder.durable(OCR_QUEUE)
|
||||
.withArgument(X_DEAD_LETTER_EXCHANGE, "")
|
||||
.withArgument(X_DEAD_LETTER_ROUTING_KEY, OCR_DLQ)
|
||||
.withArgument(X_MAX_PRIORITY, 2)
|
||||
.maxPriority(2)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public Queue ocrDeadLetterQueue() {
|
||||
|
||||
return QueueBuilder.durable(OCR_DLQ).build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package com.knecon.fforesight.service.ocr.v1.server.queue;
|
||||
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.service.IOcrMessageSender;
|
||||
import com.knecon.fforesight.service.ocr.v1.api.model.DocumentRequest;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
@ConditionalOnProperty(value = "ocrService.sendStatusUpdates", havingValue = "false")
|
||||
public class NoStatusUpdateOcrMessageSender implements IOcrMessageSender {
|
||||
|
||||
RabbitTemplate rabbitTemplate;
|
||||
|
||||
|
||||
public void sendOcrFinished(String fileId, int totalImages) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void sendOCRStarted(String fileId) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void sendUpdate(String fileId, int finishedImages, int totalImages) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void sendOcrResponse(String dossierId, String fileId) {
|
||||
|
||||
rabbitTemplate.convertAndSend(MessagingConfiguration.OCR_RESPONSE_QUEUE, new DocumentRequest(dossierId, fileId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
package com.knecon.fforesight.service.ocr.v1.server.queue;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
@ -10,17 +10,15 @@ import org.springframework.amqp.AmqpRejectAndDontRequeueException;
|
||||
import org.springframework.amqp.core.Message;
|
||||
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
|
||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.knecon.fforesight.service.ocr.v1.server.client.FileStatusProcessingUpdateClient;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.FileStorageService;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.IOcrMessageSender;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.OCRService;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.OsUtils;
|
||||
import com.knecon.fforesight.service.ocr.v1.api.model.DocumentRequest;
|
||||
import com.iqser.red.service.persistence.service.v1.api.shared.model.dossiertemplate.dossier.file.FileErrorInfo;
|
||||
|
||||
import feign.FeignException;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
@ -32,71 +30,49 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class OcrMessageReceiver {
|
||||
|
||||
FileStorageService fileStorageService;
|
||||
ObjectMapper objectMapper;
|
||||
FileStatusProcessingUpdateClient fileStatusProcessingUpdateClient;
|
||||
OCRService ocrService;
|
||||
FileStorageService fileStorageService;
|
||||
ObjectMapper objectMapper;
|
||||
OCRService ocrService;
|
||||
IOcrMessageSender ocrMessageSender;
|
||||
|
||||
|
||||
@RabbitHandler
|
||||
@RabbitListener(queues = MessagingConfiguration.OCR_QUEUE, concurrency = "1")
|
||||
@RabbitListener(queues = MessagingConfiguration.OCR_REQUEST_QUEUE, concurrency = "1")
|
||||
public void receiveOcr(Message in) throws IOException {
|
||||
|
||||
if (in.getMessageProperties().isRedelivered()) {
|
||||
throw new AmqpRejectAndDontRequeueException("Redelivered OCR Request, aborting...");
|
||||
}
|
||||
|
||||
DocumentRequest ocrRequestMessage = objectMapper.readValue(in.getBody(), DocumentRequest.class);
|
||||
log.info("--------------------------------------------------------------------------");
|
||||
log.info("Start ocr for file with dossierId {} and fileId {}", ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId());
|
||||
String dossierId = ocrRequestMessage.getDossierId();
|
||||
String fileId = ocrRequestMessage.getFileId();
|
||||
Path tmpDir = Path.of(OsUtils.getTemporaryDirectory()).resolve(dossierId + "-" + fileId);
|
||||
|
||||
try {
|
||||
setStatusOcrProcessing(ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId());
|
||||
log.info("--------------------------------------------------------------------------");
|
||||
log.info("Start ocr for file with dossierId {} and fileId {}", dossierId, fileId);
|
||||
|
||||
if (!fileStorageService.untouchedFileExists(ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId())) {
|
||||
byte[] originalFile = fileStorageService.getOriginalFile(ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId());
|
||||
fileStorageService.storeUntouchedFile(ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId(), originalFile);
|
||||
}
|
||||
ocrMessageSender.sendOCRStarted(fileId);
|
||||
|
||||
try (var transferStream = new ByteArrayOutputStream()) {
|
||||
ocrService.runOcrOnDocument(ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId(), transferStream);
|
||||
try (var inputStream = new ByteArrayInputStream(transferStream.toByteArray())) {
|
||||
fileStorageService.storeOriginalFile(ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId(), inputStream);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to store file with dossierId {} and fileId {}", ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId());
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
tmpDir.toFile().mkdirs();
|
||||
File documentFile = tmpDir.resolve("document.pdf").toFile();
|
||||
File viewerDocumentFile = tmpDir.resolve("viewerDocument.pdf").toFile();
|
||||
|
||||
fileStatusProcessingUpdateClient.ocrSuccessful(ocrRequestMessage.getDossierId(), ocrRequestMessage.getFileId());
|
||||
fileStorageService.downloadFiles(dossierId, fileId, documentFile, viewerDocumentFile);
|
||||
|
||||
ocrService.runOcrOnDocument(dossierId, fileId, ocrRequestMessage.isRemoveWatermark(), tmpDir, documentFile, viewerDocumentFile);
|
||||
|
||||
fileStorageService.storeFiles(dossierId, fileId, documentFile, viewerDocumentFile);
|
||||
|
||||
ocrMessageSender.sendOcrResponse(dossierId, fileId);
|
||||
} catch (Exception e) {
|
||||
log.warn("An exception occurred in ocr file stage: {}", e.getMessage());
|
||||
in.getMessageProperties().getHeaders().put(MessagingConfiguration.X_ERROR_INFO_HEADER, e.getMessage());
|
||||
in.getMessageProperties().getHeaders().put(MessagingConfiguration.X_ERROR_INFO_TIMESTAMP_HEADER, OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS));
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@RabbitHandler
|
||||
@RabbitListener(queues = MessagingConfiguration.OCR_DLQ, concurrency = "1")
|
||||
public void receiveOcrDLQ(Message failedMessage) throws IOException {
|
||||
|
||||
DocumentRequest ocrRequestMessage = objectMapper.readValue(failedMessage.getBody(), DocumentRequest.class);
|
||||
log.info("OCR DQL received: {}", ocrRequestMessage);
|
||||
String errorMessage = failedMessage.getMessageProperties().getHeader(MessagingConfiguration.X_ERROR_INFO_HEADER);
|
||||
OffsetDateTime timestamp = failedMessage.getMessageProperties().getHeader(MessagingConfiguration.X_ERROR_INFO_TIMESTAMP_HEADER);
|
||||
timestamp = timestamp != null ? timestamp : OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
fileStatusProcessingUpdateClient.ocrFailed(ocrRequestMessage.getDossierId(),
|
||||
ocrRequestMessage.getFileId(),
|
||||
new FileErrorInfo(errorMessage, MessagingConfiguration.OCR_DLQ, "ocr-service", timestamp));
|
||||
}
|
||||
|
||||
|
||||
private void setStatusOcrProcessing(String dossierId, String fileId) {
|
||||
|
||||
try {
|
||||
fileStatusProcessingUpdateClient.ocrProcessing(dossierId, fileId);
|
||||
} catch (FeignException e) {
|
||||
if (e.status() == HttpStatus.CONFLICT.value()) {
|
||||
throw new AmqpRejectAndDontRequeueException(e.getMessage());
|
||||
}
|
||||
} finally {
|
||||
FileSystemUtils.deleteRecursively(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
package com.knecon.fforesight.service.ocr.v1.server.queue;
|
||||
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.knecon.fforesight.service.ocr.processor.service.IOcrMessageSender;
|
||||
import com.knecon.fforesight.service.ocr.v1.api.model.DocumentRequest;
|
||||
import com.knecon.fforesight.service.ocr.v1.api.model.OCRStatusUpdateResponse;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(value = "ocrService.sendStatusUpdates", havingValue = "true")
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
public class OcrMessageSender implements IOcrMessageSender {
|
||||
|
||||
@ -25,6 +31,14 @@ public class OcrMessageSender implements IOcrMessageSender {
|
||||
}
|
||||
|
||||
|
||||
public void sendOCRStarted(String fileId) {
|
||||
|
||||
rabbitTemplate.convertAndSend(MessagingConfiguration.OCR_STATUS_UPDATE_RESPONSE_QUEUE,
|
||||
OCRStatusUpdateResponse.builder().fileId(fileId).ocrStarted(true).build());
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void sendUpdate(String fileId, int finishedImages, int totalImages) {
|
||||
|
||||
rabbitTemplate.convertAndSend(MessagingConfiguration.OCR_STATUS_UPDATE_RESPONSE_QUEUE,
|
||||
@ -32,4 +46,10 @@ public class OcrMessageSender implements IOcrMessageSender {
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void sendOcrResponse(String dossierId, String fileId) {
|
||||
|
||||
rabbitTemplate.convertAndSend(MessagingConfiguration.OCR_RESPONSE_QUEUE, new DocumentRequest(dossierId, fileId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# you can list packages
|
||||
ghostscript
|
||||
ghostscript=9.55.0~dfsg1-0ubuntu5.9
|
||||
pkg-config
|
||||
zip
|
||||
unzip
|
||||
@ -11,6 +11,7 @@ libk5crypto3
|
||||
libkrb5support0
|
||||
libkeyutils1
|
||||
libkrb5-3
|
||||
libbrotli1
|
||||
|
||||
# or include links to specific .deb files
|
||||
# http://ftp.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8_all.deb
|
||||
|
||||
@ -5,8 +5,6 @@ persistence-service.url: "http://persistence-service-v1:8080"
|
||||
tenant-user-management-service.url: "http://tenant-user-management-service:8080/internal"
|
||||
fforesight.tenants.remote: true
|
||||
|
||||
logging.pattern.level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"
|
||||
|
||||
logging.type: ${LOGGING_TYPE:CONSOLE}
|
||||
kubernetes.namespace: ${NAMESPACE:default}
|
||||
project.version: 1.0-SNAPSHOT
|
||||
@ -14,6 +12,9 @@ project.version: 1.0-SNAPSHOT
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
lifecycle:
|
||||
base-package: com.knecon.fforesight.service.ocr
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: ocr-service
|
||||
@ -41,6 +42,7 @@ fforesight:
|
||||
ignored-endpoints: [ '/actuator/health', '/actuator/health/**' ]
|
||||
enabled: true
|
||||
|
||||
logging.pattern.level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"
|
||||
|
||||
management:
|
||||
endpoint:
|
||||
@ -58,3 +60,6 @@ management:
|
||||
endpoint: ${OTLP_ENDPOINT:http://otel-collector-opentelemetry-collector.otel-collector:4318/v1/traces}
|
||||
|
||||
pdftron.license: ${PDFTRON_LICENSE}
|
||||
|
||||
ocrService:
|
||||
sendStatusUpdates: true
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"overrides": [
|
||||
{
|
||||
"name": "tesseract",
|
||||
"version": "5.3.2"
|
||||
"version": "5.3.3"
|
||||
},
|
||||
{
|
||||
"name": "leptonica",
|
||||
|
||||
@ -24,10 +24,10 @@ import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
|
||||
import com.iqser.red.commons.jackson.ObjectMapperFactory;
|
||||
import com.knecon.fforesight.service.ocr.processor.initializer.PDFNetInitializer;
|
||||
import com.iqser.red.storage.commons.StorageAutoConfiguration;
|
||||
import com.iqser.red.storage.commons.service.StorageService;
|
||||
import com.iqser.red.storage.commons.utils.FileSystemBackedStorageService;
|
||||
import com.knecon.fforesight.service.ocr.processor.initializer.NativeLibrariesInitializer;
|
||||
import com.knecon.fforesight.tenantcommons.TenantsClient;
|
||||
import com.pdftron.pdf.PDFNet;
|
||||
|
||||
@ -36,7 +36,7 @@ import lombok.SneakyThrows;
|
||||
|
||||
@ExtendWith({SpringExtension.class, MockitoExtension.class})
|
||||
@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@Import({AbstractTest.TestConfiguration.class, PDFNetInitializer.class})
|
||||
@Import({AbstractTest.TestConfiguration.class, NativeLibrariesInitializer.class})
|
||||
@AutoConfigureObservability
|
||||
public class AbstractTest {
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@ -25,6 +26,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.FileStorageService;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.OCRService;
|
||||
import com.iqser.red.service.persistence.service.v1.api.shared.model.dossiertemplate.dossier.file.FileType;
|
||||
import com.knecon.fforesight.service.ocr.processor.service.OsUtils;
|
||||
import com.knecon.fforesight.tenantcommons.TenantContext;
|
||||
|
||||
import io.micrometer.prometheus.PrometheusMeterRegistry;
|
||||
@ -48,9 +50,9 @@ public class OcrServiceIntegrationTest extends AbstractTest {
|
||||
@Test
|
||||
public void testOCRMetrics() {
|
||||
|
||||
testOCR("files/Watermark.pdf");
|
||||
testOCR("files/Watermark.pdf");
|
||||
testOCR("files/Watermark.pdf");
|
||||
testOCR("files/syngenta/CustomerFiles/SinglePages/Watermark_Page1_10.SYN524464 FS (A16148C) - Absorção cutânea.pdf");
|
||||
testOCR("files/syngenta/CustomerFiles/SinglePages/Watermark_Page1_10.SYN524464 FS (A16148C) - Absorção cutânea.pdf");
|
||||
testOCR("files/syngenta/CustomerFiles/SinglePages/Watermark_Page1_10.SYN524464 FS (A16148C) - Absorção cutânea.pdf");
|
||||
|
||||
var ocrOnDocumentMeter = registry.getMeters().stream().filter(m -> m.getId().getName().equalsIgnoreCase("redactmanager_runOcrOnDocument")).findAny();
|
||||
assertThat(ocrOnDocumentMeter.isPresent()).isTrue();
|
||||
@ -64,7 +66,7 @@ public class OcrServiceIntegrationTest extends AbstractTest {
|
||||
@SneakyThrows
|
||||
public void testOcr() {
|
||||
|
||||
String text = testOCR("files/2009-1048395_50pages_tables.pdf");
|
||||
String text = testOCR("files/402Study.pdf");
|
||||
}
|
||||
|
||||
|
||||
@ -79,7 +81,7 @@ public class OcrServiceIntegrationTest extends AbstractTest {
|
||||
@Test
|
||||
public void testMergeImages() {
|
||||
// check visually for most of the images containing text, the resulting text is kind of nonsense, just ensure it is there
|
||||
String text = testOCR("files/merge_images.pdf");
|
||||
String text = testOCR("files/syngenta/CustomerFiles/SinglePages/merge_images - Page241_18 Chlorothalonil RAR 08 Volume 3CA B 6a Oct 2017.pdf");
|
||||
assertThat(text).contains("Bodyweight change of dams with live young - group mean values",
|
||||
"Control",
|
||||
"mg/g day",
|
||||
@ -99,7 +101,7 @@ public class OcrServiceIntegrationTest extends AbstractTest {
|
||||
@Test
|
||||
public void testOCRWatermark() {
|
||||
|
||||
assertThat(testOCR("files/Watermark.pdf")).contains("syngenta");
|
||||
assertThat(testOCR("files/syngenta/CustomerFiles/SinglePages/Watermark_Page1_10.SYN524464 FS (A16148C) - Absorção cutânea.pdf")).contains("syngenta");
|
||||
}
|
||||
|
||||
|
||||
@ -116,18 +118,17 @@ public class OcrServiceIntegrationTest extends AbstractTest {
|
||||
private String testOCR(String fileName) {
|
||||
|
||||
ClassPathResource pdfFileResource = new ClassPathResource(fileName);
|
||||
var originId = FileStorageService.getStorageId(TEST_DOSSIER_ID, "file", FileType.ORIGIN);
|
||||
try (var fileStream = pdfFileResource.getInputStream()) {
|
||||
storageService.storeObject(TenantContext.getTenantId(), originId, fileStream);
|
||||
}
|
||||
Path tmpDir = Path.of(OsUtils.getTemporaryDirectory()).resolve("OCR_TEST").resolve(Path.of(fileName).getFileName());
|
||||
tmpDir.toFile().mkdirs();
|
||||
var documentFile = tmpDir.resolve(Path.of("document.pdf"));
|
||||
var viewerDocumentFile = tmpDir.resolve(Path.of("viewerDocument.pdf"));
|
||||
Files.copy(pdfFileResource.getFile().toPath(), documentFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.copy(pdfFileResource.getFile().toPath(), viewerDocumentFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
Path tmpFileName = Path.of(getTemporaryDirectory()).resolve(Path.of(fileName).getFileName());
|
||||
try (var out = new FileOutputStream(tmpFileName.toFile())) {
|
||||
ocrService.runOcrOnDocument(TEST_DOSSIER_ID, "file", out);
|
||||
System.out.println("File:" + tmpFileName);
|
||||
}
|
||||
ocrService.runOcrOnDocument(TEST_DOSSIER_ID, "file", false, tmpDir, documentFile.toFile(), viewerDocumentFile.toFile());
|
||||
System.out.println("File:" + documentFile);
|
||||
|
||||
try (var fileStream = new FileInputStream(tmpFileName.toFile())) {
|
||||
try (var fileStream = new FileInputStream(documentFile.toFile())) {
|
||||
return extractAllTextFromDocument(fileStream);
|
||||
}
|
||||
}
|
||||
@ -162,30 +163,22 @@ public class OcrServiceIntegrationTest extends AbstractTest {
|
||||
@SneakyThrows
|
||||
public void testOcrForSpecificFile() {
|
||||
|
||||
testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/syn-dm-testfiles/F.2. A16003E - Acute Inhalation Study.pdf"));
|
||||
// testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/syn-dm-testfiles/A23220A - 404 - Skin Irritation in vivo.pdf"));
|
||||
// testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/OcrMassTest/G.1.2 - 1768300_MMNA_A13617AV_report.pdf"));
|
||||
// testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/OcrMassTest/SOLICITA_VICTRATO-GOLD-II_Item 17_Toxicidade Inalatoria Aguda.pdf"));
|
||||
// testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/OcrMassTest/SOLICITA_VICTRATO-GOLD-II_Item 20_Sensibilizacao_02.pdf"));
|
||||
// testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/OcrMassTest/ITEM 23_A15149W - Dermal absorption of formulated product.pdf"));
|
||||
// testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/OcrMassTest/SOLICITA_VICTRATO-GOLD-II_Item 16_Toxicidade Cutanea Aguda.pdf"));
|
||||
testOCRForFile(new File("/home/kschuettler/Dokumente/TestFiles/syn-dm-testfiles2/A16361B - Acute Dermal Toxicity Study in Rats.pdf"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@SneakyThrows
|
||||
private void testOCRForFile(File file) {
|
||||
|
||||
var originId = FileStorageService.getStorageId(TEST_DOSSIER_ID, "file", FileType.ORIGIN);
|
||||
try (var fileStream = new FileInputStream(file)) {
|
||||
storageService.storeObject(TenantContext.getTenantId(), originId, fileStream);
|
||||
}
|
||||
Path tmpDir = Path.of(OsUtils.getTemporaryDirectory()).resolve("OCR_TEST").resolve(file.toPath().getFileName());
|
||||
tmpDir.toFile().mkdirs();
|
||||
var documentFile = tmpDir.resolve(Path.of("document.pdf"));
|
||||
var viewerDocumentFile = tmpDir.resolve(Path.of("viewerDocument.pdf"));
|
||||
Files.copy(file.toPath(), documentFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.copy(file.toPath(), viewerDocumentFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
Path tmpFileName = Path.of(getTemporaryDirectory()).resolve(Path.of(file.getAbsolutePath()).getFileName());
|
||||
try (var out = new FileOutputStream(tmpFileName.toFile())) {
|
||||
ocrService.runOcrOnDocument(TEST_DOSSIER_ID, "file", out);
|
||||
System.out.println("File:" + tmpFileName);
|
||||
}
|
||||
ocrService.runOcrOnDocument(TEST_DOSSIER_ID, "file", false, tmpDir, documentFile.toFile(), viewerDocumentFile.toFile());
|
||||
System.out.println("File:" + documentFile);
|
||||
System.out.println("\n\n");
|
||||
}
|
||||
|
||||
|
||||
@ -15,3 +15,10 @@ management:
|
||||
health.enabled: true
|
||||
endpoints.web.exposure.include: prometheus, health, metrics
|
||||
metrics.export.prometheus.enabled: true
|
||||
tracing:
|
||||
enabled: ${TRACING_ENABLED:false}
|
||||
sampling:
|
||||
probability: ${TRACING_PROBABILITY:1.0}
|
||||
otlp:
|
||||
tracing:
|
||||
endpoint: ${OTLP_ENDPOINT:http://otel-collector-opentelemetry-collector.otel-collector:4318/v1/traces}
|
||||
@ -0,0 +1 @@
|
||||
Subproject commit 9dc6c2337dea32e63aef53271dba0692537c6605
|
||||
@ -0,0 +1 @@
|
||||
Subproject commit 21fefb64bf27ca2b3329a6c69d90a27450b17930
|
||||
@ -1,5 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
dir=${PWD##*/}
|
||||
|
||||
gradle assemble
|
||||
|
||||
# Get the current Git branch
|
||||
@ -11,5 +15,32 @@ commit_hash=$(git rev-parse --short=5 HEAD)
|
||||
# Combine branch and commit hash
|
||||
buildName="${USER}-${branch}-${commit_hash}"
|
||||
|
||||
gradle bootBuildImage --publishImage -PbuildbootDockerHostNetwork=true -Pversion=$buildName --no-build-cache
|
||||
echo "nexus.knecon.com:5001/ff/${dir}-server:$buildName"
|
||||
gradle bootBuildImage --publishImage -PbuildbootDockerHostNetwork=true -Pversion=${buildName}
|
||||
|
||||
newImageName="nexus.knecon.com:5001/ff/ocr-service-server:$buildName"
|
||||
|
||||
echo "full image name:"
|
||||
echo ${newImageName}
|
||||
echo ""
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
namespace=${1}
|
||||
deployment_name="ocr-service-v1"
|
||||
|
||||
echo "deploying to ${namespace}"
|
||||
|
||||
oldImageName=$(rancher kubectl -n ${namespace} get deployment ${deployment_name} -o=jsonpath='{.spec.template.spec.containers[*].image}')
|
||||
|
||||
if [ "${newImageName}" = "${oldImageName}" ]; then
|
||||
echo "Image tag did not change, redeploying..."
|
||||
rancher kubectl rollout restart deployment ${deployment_name} -n ${namespace}
|
||||
else
|
||||
echo "upgrading the image tag..."
|
||||
rancher kubectl set image deployment/${deployment_name} ${deployment_name}=${newImageName} -n ${namespace}
|
||||
fi
|
||||
rancher kubectl rollout status deployment ${deployment_name} -n ${namespace}
|
||||
echo "Built ${deployment_name}:${buildName} and deployed to ${namespace}"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user