Skip to content

Commit e03c4dd

Browse files
committed
feat(auto-rotate): detect sparse pages and infer undetected pages from document consensus
Real-world scans surfaced a gap: a uniformly-rotated document where one page (a near-blank cover with just a URL) has too little text for the 30-glyph text floor and too little content for OSD, so it fell through while its text-heavy sibling was corrected. Two additions: - Sparse-page text path: trust the embedded-text direction with as few as 8 glyphs when they are near-unanimous (>=0.99 dominance), so a lone header or URL line decides the page from its own text. - Document-consensus inference: when the decided pages agree on one correction for a given current rotation, apply it to undecided pages sharing that rotation. Handles blank/image-only pages in a uniformly rotated document. Gated by inferUndetected (default true), surfaced as an 'Inferred' method with a 'From document consensus' note so it stays debuggable, and toggleable in tool + automation settings. Verified live end-to-end on the reporting PDF (both pages, one text + one sparse, now corrected) and on a forced-inference fixture. Adds detection + controller tests; regenerates API models for the new flag.
1 parent ff8c9f8 commit e03c4dd

14 files changed

Lines changed: 226 additions & 7 deletions

File tree

app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRotateController.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import java.io.File;
44
import java.io.IOException;
55
import java.util.ArrayList;
6+
import java.util.HashMap;
7+
import java.util.HashSet;
68
import java.util.List;
79
import java.util.Locale;
810
import java.util.Map;
911
import java.util.Optional;
12+
import java.util.Set;
1013

1114
import javax.imageio.ImageIO;
1215

@@ -60,6 +63,7 @@ public class AutoRotateController {
6063

6164
private static final String METHOD_TEXT = "text";
6265
private static final String METHOD_OSD = "osd";
66+
private static final String METHOD_INFERRED = "inferred";
6367
private static final String METHOD_NONE = "none";
6468

6569
private final CustomPDFDocumentFactory pdfDocumentFactory;
@@ -169,9 +173,57 @@ private AutoRotateAnalysisResult analyse(
169173
runOsdOnPages(document, osdCandidates, results, threshold);
170174
}
171175

176+
if (request.isInferUndetected()) {
177+
inferUndetectedPages(results);
178+
}
179+
172180
return summarise(results, pageCount);
173181
}
174182

183+
/**
184+
* Fill in pages that no signal could decide, using the pages that could. When every decided
185+
* page sharing an undecided page's current rotation agrees on one correction, that correction
186+
* is the document's consensus for that rotation and is applied to the undecided page. This is
187+
* the common "whole document rotated uniformly, but a cover or near-blank page has too little
188+
* text to detect on its own" case. If decided pages disagree, nothing is inferred.
189+
*/
190+
private void inferUndetectedPages(List<PageResult> results) {
191+
// rotation -> the single agreed correction, or null once a conflict is seen
192+
Map<Integer, Integer> consensus = new HashMap<>();
193+
Set<Integer> conflicted = new HashSet<>();
194+
for (PageResult result : results) {
195+
if (METHOD_NONE.equals(result.getMethod())) {
196+
continue;
197+
}
198+
int rotation = result.getCurrentRotation();
199+
if (conflicted.contains(rotation)) {
200+
continue;
201+
}
202+
Integer existing = consensus.get(rotation);
203+
if (existing == null) {
204+
consensus.put(rotation, result.getCorrection());
205+
} else if (existing != result.getCorrection()) {
206+
conflicted.add(rotation);
207+
consensus.remove(rotation);
208+
}
209+
}
210+
211+
for (PageResult result : results) {
212+
if (!METHOD_NONE.equals(result.getMethod())) {
213+
continue;
214+
}
215+
Integer correction = consensus.get(result.getCurrentRotation());
216+
if (correction == null) {
217+
continue;
218+
}
219+
result.setMethod(METHOD_INFERRED);
220+
result.setCorrection(correction);
221+
result.setConfidence(null);
222+
result.setApply(correction != 0);
223+
result.setNote("inferredFromDocument");
224+
}
225+
}
226+
175227
private void runOsdOnPages(
176228
PDDocument document,
177229
List<Integer> pageIndexes,
@@ -280,6 +332,7 @@ private AutoRotateAnalysisResult summarise(List<PageResult> results, int pageCou
280332
int toRotate = 0;
281333
int byText = 0;
282334
int byOsd = 0;
335+
int byInference = 0;
283336
int undetected = 0;
284337
for (PageResult result : results) {
285338
if (result.isApply()) {
@@ -288,6 +341,7 @@ private AutoRotateAnalysisResult summarise(List<PageResult> results, int pageCou
288341
switch (result.getMethod()) {
289342
case METHOD_TEXT -> byText++;
290343
case METHOD_OSD -> byOsd++;
344+
case METHOD_INFERRED -> byInference++;
291345
default -> undetected++;
292346
}
293347
}
@@ -297,6 +351,7 @@ private AutoRotateAnalysisResult summarise(List<PageResult> results, int pageCou
297351
.pagesToRotate(toRotate)
298352
.detectedByText(byText)
299353
.detectedByOsd(byOsd)
354+
.inferred(byInference)
300355
.undetected(undetected)
301356
.build();
302357
}

app/core/src/main/java/stirling/software/SPDF/model/api/misc/AutoRotateAnalysisResult.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public class AutoRotateAnalysisResult {
2727

2828
private int detectedByOsd;
2929

30+
@Schema(description = "Pages whose correction was inherited from the document consensus")
31+
private int inferred;
32+
3033
private int undetected;
3134

3235
@Data
@@ -55,7 +58,7 @@ public static class PageResult {
5558

5659
@Schema(
5760
description = "How the orientation was determined",
58-
allowableValues = {"text", "osd", "none"})
61+
allowableValues = {"text", "osd", "inferred", "none"})
5962
private String method;
6063

6164
@Schema(description = "Whether the correction will be (or was) applied")

app/core/src/main/java/stirling/software/SPDF/model/api/misc/AutoRotatePdfRequest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ public class AutoRotatePdfRequest extends PDFFile {
3333
+ " detection results instead of a PDF")
3434
private boolean dryRun;
3535

36+
@Schema(
37+
description =
38+
"When a page cannot be decided on its own but the pages that could be decided"
39+
+ " agree on a single correction for that same current rotation, apply"
40+
+ " that shared correction to the undecided page. Handles documents"
41+
+ " rotated uniformly where some pages are too sparse to detect alone",
42+
defaultValue = "true")
43+
private boolean inferUndetected = true;
44+
3645
@Schema(
3746
description =
3847
"Optional JSON object of pre-computed corrections to apply without running"

app/core/src/main/java/stirling/software/SPDF/utils/AutoRotateDetection.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,22 @@ public final class AutoRotateDetection {
2121

2222
private AutoRotateDetection() {}
2323

24-
/** Minimum glyphs on a page before the embedded-text signal is trusted at all. */
24+
/** Glyphs needed to trust the text signal at the ordinary dominance bar. */
2525
public static final int MIN_GLYPHS = 30;
2626

27-
/** Fraction of glyphs that must share one direction for the text signal to be conclusive. */
27+
/** Fraction of glyphs that must share one direction at the ordinary bar. */
2828
public static final double MIN_DOMINANCE = 0.95;
2929

30+
/**
31+
* Glyphs needed to trust the text signal when the glyphs are near-unanimous. Lets sparse pages
32+
* (a header, a single line, a rotated URL) be decided from their own text instead of falling
33+
* through to OSD, as long as effectively every glyph agrees on the direction.
34+
*/
35+
public static final int MIN_GLYPHS_UNANIMOUS = 8;
36+
37+
/** Dominance required for the sparse-page path — essentially total agreement. */
38+
public static final double UNANIMOUS_DOMINANCE = 0.99;
39+
3040
/**
3141
* Dominant embedded-text direction of one page.
3242
*
@@ -37,7 +47,10 @@ private AutoRotateDetection() {}
3747
public record TextDirection(int dominantDirection, double dominance, int glyphCount) {
3848

3949
public boolean isConclusive() {
40-
return glyphCount >= MIN_GLYPHS && dominance >= MIN_DOMINANCE;
50+
if (glyphCount >= MIN_GLYPHS && dominance >= MIN_DOMINANCE) {
51+
return true;
52+
}
53+
return glyphCount >= MIN_GLYPHS_UNANIMOUS && dominance >= UNANIMOUS_DOMINANCE;
4154
}
4255
}
4356

app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoRotateControllerTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,52 @@ void reportsTesseractUnavailableForTextlessPages() throws Exception {
170170
assertThat(result.getUndetected()).isEqualTo(1);
171171
}
172172

173+
@Test
174+
void infersUndetectedPageFromDocumentConsensus() throws Exception {
175+
// Page 1 has body text and is rotated 90 (-> 270 correction); page 2 is blank and shares
176+
// the same rotation. With OSD unavailable, page 2 can't be detected on its own, so it
177+
// should inherit page 1's 270 correction.
178+
PDDocument document = docWithUprightText(90);
179+
PDPage blank = new PDPage(PDRectangle.LETTER);
180+
blank.setRotation(90);
181+
document.addPage(blank);
182+
AutoRotatePdfRequest request = request(document);
183+
request.setDryRun(true);
184+
request.setDetectionMode("text");
185+
186+
ResponseEntity<?> response = controller.autoRotatePdf(request);
187+
188+
AutoRotateAnalysisResult result = (AutoRotateAnalysisResult) response.getBody();
189+
AutoRotateAnalysisResult.PageResult page2 = result.getPages().get(1);
190+
assertThat(page2.getMethod()).isEqualTo("inferred");
191+
assertThat(page2.getCorrection()).isEqualTo(270);
192+
assertThat(page2.isApply()).isTrue();
193+
assertThat(page2.getNote()).isEqualTo("inferredFromDocument");
194+
assertThat(result.getInferred()).isEqualTo(1);
195+
assertThat(result.getPagesToRotate()).isEqualTo(2);
196+
}
197+
198+
@Test
199+
void doesNotInferWhenDisabled() throws Exception {
200+
PDDocument document = docWithUprightText(90);
201+
PDPage blank = new PDPage(PDRectangle.LETTER);
202+
blank.setRotation(90);
203+
document.addPage(blank);
204+
AutoRotatePdfRequest request = request(document);
205+
request.setDryRun(true);
206+
request.setDetectionMode("text");
207+
request.setInferUndetected(false);
208+
209+
ResponseEntity<?> response = controller.autoRotatePdf(request);
210+
211+
AutoRotateAnalysisResult result = (AutoRotateAnalysisResult) response.getBody();
212+
AutoRotateAnalysisResult.PageResult page2 = result.getPages().get(1);
213+
assertThat(page2.getMethod()).isEqualTo("none");
214+
assertThat(page2.isApply()).isFalse();
215+
assertThat(result.getInferred()).isZero();
216+
assertThat(result.getUndetected()).isEqualTo(1);
217+
}
218+
173219
@Test
174220
void rejectsInvalidDetectionMode() {
175221
AutoRotatePdfRequest request = new AutoRotatePdfRequest();

app/core/src/test/java/stirling/software/SPDF/utils/AutoRotateDetectionTest.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,32 @@ void shortTextIsNotConclusive() throws IOException {
125125
}
126126
}
127127

128+
@Test
129+
void unanimousShortTextIsConclusive() throws IOException {
130+
// Between MIN_GLYPHS_UNANIMOUS (8) and MIN_GLYPHS (30): trusted only because every
131+
// glyph agrees on direction, the sparse-page path (e.g. a lone header or URL line).
132+
PDDocument document = new PDDocument();
133+
PDPage page = new PDPage(PDRectangle.LETTER);
134+
document.addPage(page);
135+
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
136+
content.beginText();
137+
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
138+
content.setTextMatrix(Matrix.getRotateInstance(Math.toRadians(90), 300, 200));
139+
content.showText("york.gov.uk/pay");
140+
content.endText();
141+
}
142+
try (document) {
143+
TextDirection direction = AutoRotateDetection.detectTextDirection(document, 0);
144+
assertThat(direction.glyphCount())
145+
.isBetween(
146+
AutoRotateDetection.MIN_GLYPHS_UNANIMOUS,
147+
AutoRotateDetection.MIN_GLYPHS - 1);
148+
assertThat(direction.dominance()).isEqualTo(1.0);
149+
assertThat(direction.isConclusive()).isTrue();
150+
assertThat(direction.dominantDirection()).isEqualTo(90);
151+
}
152+
}
153+
128154
@Test
129155
void parsesTypicalOsdOutput() {
130156
String output =

engine/src/stirling/models/tool_models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@ class AutoRotatePdfParams(ApiModel):
264264
None,
265265
description="If true, no rotation is applied; returns a JSON report of the per-page detection results instead of a PDF",
266266
)
267+
infer_undetected: bool = Field(
268+
True,
269+
description="When a page cannot be decided on its own but the pages that could be decided agree on a single correction for that same current rotation, apply that shared correction to the undecided page. Handles documents rotated uniformly where some pages are too sparse to detect alone",
270+
)
267271
page_rotations: str | None = Field(
268272
None,
269273
description='Optional JSON object of pre-computed corrections to apply without running detection, mapping 1-based page number to additional clockwise degrees (multiples of 90), e.g. {"1":90,"4":180}. Pages not listed are left unchanged',

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2194,23 +2194,29 @@ title = "Detection method"
21942194
[autoRotate.error]
21952195
failed = "An error occurred while auto-rotating the PDF."
21962196

2197+
[autoRotate.inferUndetected]
2198+
desc = "When the readable pages agree on a rotation, apply it to pages that were too sparse to detect on their own."
2199+
title = "Fill undetected pages from document"
2200+
21972201
[autoRotate.report]
21982202
applied = "{{degrees}}° CW"
21992203
confidence = "Confidence"
22002204
methodHeader = "Method"
22012205
noteHeader = "Note"
22022206
page = "Page"
22032207
rotation = "Rotation"
2204-
summary = "{{rotated}} of {{total}} pages rotated ({{text}} by text, {{osd}} by OCR, {{undetected}} undetected)"
2208+
summary = "{{rotated}} of {{total}} pages rotated ({{text}} by text, {{osd}} by OCR, {{inferred}} inferred, {{undetected}} undetected)"
22052209
title = "Detection report"
22062210

22072211
[autoRotate.report.method]
2212+
inferred = "Inferred"
22082213
none = "Skipped"
22092214
osd = "OCR"
22102215
text = "Text"
22112216

22122217
[autoRotate.report.note]
22132218
belowThreshold = "Below confidence threshold"
2219+
inferredFromDocument = "From document consensus"
22142220
noDominantDirection = "Mixed text directions"
22152221
osdFailed = "No readable text found"
22162222
osdNoVerdict = "OCR gave no verdict"

frontend/editor/src/core/components/tools/autoRotate/AutoRotateAutomationSettings.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AutoRotateDetectionMode,
66
} from "@app/hooks/tools/autoRotate/useAutoRotateParameters";
77
import ButtonSelector from "@app/components/shared/ButtonSelector";
8+
import { Checkbox } from "@app/ui/Checkbox";
89

910
interface AutoRotateAutomationSettingsProps {
1011
parameters: AutoRotateParameters;
@@ -63,6 +64,18 @@ const AutoRotateAutomationSettings = ({
6364
)
6465
}
6566
/>
67+
68+
<Checkbox
69+
label={t(
70+
"autoRotate.inferUndetected.title",
71+
"Fill undetected pages from document",
72+
)}
73+
disabled={disabled}
74+
checked={parameters.inferUndetected}
75+
onChange={(event) =>
76+
onParameterChange("inferUndetected", event.currentTarget.checked)
77+
}
78+
/>
6679
</Stack>
6780
);
6881
};

frontend/editor/src/core/components/tools/autoRotate/AutoRotateReport.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ const methodBadge = (t: TFunction, method: AutoRotatePageResult["method"]) => {
2424
{t("autoRotate.report.method.osd", "OCR")}
2525
</Badge>
2626
);
27+
case "inferred":
28+
return (
29+
<Badge size="sm" variant="light" color="grape">
30+
{t("autoRotate.report.method.inferred", "Inferred")}
31+
</Badge>
32+
);
2733
default:
2834
return (
2935
<Badge size="sm" variant="light" color="gray">
@@ -56,6 +62,11 @@ const noteLabel = (t: TFunction, note: string | null | undefined): string => {
5662
"autoRotate.report.note.belowThreshold",
5763
"Below confidence threshold",
5864
);
65+
case "inferredFromDocument":
66+
return t(
67+
"autoRotate.report.note.inferredFromDocument",
68+
"From document consensus",
69+
);
5970
default:
6071
return note ?? "";
6172
}
@@ -88,11 +99,12 @@ const AutoRotateReport = ({ reports }: AutoRotateReportProps) => {
8899
<Text size="xs" c="dimmed">
89100
{t("autoRotate.report.summary", {
90101
defaultValue:
91-
"{{rotated}} of {{total}} pages rotated ({{text}} by text, {{osd}} by OCR, {{undetected}} undetected)",
102+
"{{rotated}} of {{total}} pages rotated ({{text}} by text, {{osd}} by OCR, {{inferred}} inferred, {{undetected}} undetected)",
92103
rotated: report.pagesToRotate,
93104
total: report.totalPages,
94105
text: report.detectedByText,
95106
osd: report.detectedByOsd,
107+
inferred: report.inferred,
96108
undetected: report.undetected,
97109
})}
98110
</Text>

0 commit comments

Comments
 (0)