/src/tesseract/src/textord/tablefind.cpp
Line | Count | Source |
1 | | /////////////////////////////////////////////////////////////////////// |
2 | | // File: tablefind.cpp |
3 | | // Description: Helper classes to find tables from ColPartitions. |
4 | | // Author: Faisal Shafait (faisal.shafait@dfki.de) |
5 | | // |
6 | | // (C) Copyright 2009, Google Inc. |
7 | | // Licensed under the Apache License, Version 2.0 (the "License"); |
8 | | // you may not use this file except in compliance with the License. |
9 | | // You may obtain a copy of the License at |
10 | | // http://www.apache.org/licenses/LICENSE-2.0 |
11 | | // Unless required by applicable law or agreed to in writing, software |
12 | | // distributed under the License is distributed on an "AS IS" BASIS, |
13 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
14 | | // See the License for the specific language governing permissions and |
15 | | // limitations under the License. |
16 | | // |
17 | | /////////////////////////////////////////////////////////////////////// |
18 | | |
19 | | #ifdef HAVE_CONFIG_H |
20 | | # include "config_auto.h" |
21 | | #endif |
22 | | |
23 | | #include <algorithm> |
24 | | #include <cmath> |
25 | | #include <utility> |
26 | | #include "tablefind.h" |
27 | | |
28 | | #include "colpartitionset.h" |
29 | | #include "tablerecog.h" |
30 | | |
31 | | namespace tesseract { |
32 | | |
33 | | // These numbers are used to calculate the global median stats. |
34 | | // They just set an upper bound on the stats objects. |
35 | | // Maximum vertical spacing between neighbor partitions. |
36 | | const int kMaxVerticalSpacing = 500; |
37 | | // Maximum width of a blob in a partition. |
38 | | const int kMaxBlobWidth = 500; |
39 | | |
40 | | // Minimum whitespace size to split a partition (measured as a multiple |
41 | | // of a partition's median width). |
42 | | const double kSplitPartitionSize = 2.0; |
43 | | // To insert text, the partition must satisfy these size constraints |
44 | | // in AllowTextPartition(). The idea is to filter noise partitions |
45 | | // determined by the size compared to the global medians. |
46 | | // TODO(nbeato): Need to find good numbers again. |
47 | | const double kAllowTextHeight = 0.5; |
48 | | const double kAllowTextWidth = 0.6; |
49 | | const double kAllowTextArea = 0.8; |
50 | | // The same thing applies to blobs (to filter noise). |
51 | | // TODO(nbeato): These numbers are a shot in the dark... |
52 | | // height and width are 0.5 * gridsize() in colfind.cpp |
53 | | // area is a rough guess for the size of a period. |
54 | | const double kAllowBlobHeight = 0.3; |
55 | | const double kAllowBlobWidth = 0.4; |
56 | | const double kAllowBlobArea = 0.05; |
57 | | |
58 | | // Minimum number of components in a text partition. A partition having fewer |
59 | | // components than that is more likely a data partition and is a candidate |
60 | | // table cell. |
61 | | const int kMinBoxesInTextPartition = 10; |
62 | | |
63 | | // Maximum number of components that a data partition can have |
64 | | const int kMaxBoxesInDataPartition = 20; |
65 | | |
66 | | // Maximum allowed gap in a text partitions as a multiple of its median size. |
67 | | const double kMaxGapInTextPartition = 4.0; |
68 | | |
69 | | // Minimum value that the maximum gap in a text partition should have as a |
70 | | // factor of its median size. |
71 | | const double kMinMaxGapInTextPartition = 0.5; |
72 | | |
73 | | // The amount of overlap that is "normal" for adjacent blobs in a text |
74 | | // partition. This is used to calculate gap between overlapping blobs. |
75 | | const double kMaxBlobOverlapFactor = 4.0; |
76 | | |
77 | | // Maximum x-height a table partition can have as a multiple of global |
78 | | // median x-height |
79 | | const double kMaxTableCellXheight = 2.0; |
80 | | |
81 | | // Maximum line spacing between a table column header and column contents |
82 | | // for merging the two (as a multiple of the partition's median_height). |
83 | | const int kMaxColumnHeaderDistance = 4; |
84 | | |
85 | | // Minimum ratio of num_table_partitions to num_text_partitions in a column |
86 | | // block to be called it a table column |
87 | | const double kTableColumnThreshold = 3.0; |
88 | | |
89 | | // Search for horizontal ruling lines within the vertical margin as a |
90 | | // multiple of grid size |
91 | | // const int kRulingVerticalMargin = 3; |
92 | | |
93 | | // Minimum overlap that a colpartition must have with a table region |
94 | | // to become part of that table |
95 | | const double kMinOverlapWithTable = 0.6; |
96 | | |
97 | | // Maximum side space (distance from column boundary) that a typical |
98 | | // text-line in flowing text should have as a multiple of its x-height |
99 | | // (Median size). |
100 | | const int kSideSpaceMargin = 10; |
101 | | |
102 | | // Fraction of the peak of x-projection of a table region to set the |
103 | | // threshold for the x-projection histogram |
104 | | const double kSmallTableProjectionThreshold = 0.35; |
105 | | const double kLargeTableProjectionThreshold = 0.45; |
106 | | // Minimum number of rows required to look for more rows in the projection. |
107 | | const int kLargeTableRowCount = 6; |
108 | | |
109 | | // Minimum number of rows in a table |
110 | | const int kMinRowsInTable = 3; |
111 | | |
112 | | // The amount of padding (multiplied by global_median_xheight_ during use) |
113 | | // that is vertically added to the search adjacent leader search during |
114 | | // ColPartition marking. |
115 | | const int kAdjacentLeaderSearchPadding = 2; |
116 | | |
117 | | // Used when filtering false positives. When finding the last line |
118 | | // of a paragraph (typically left-aligned), the previous line should have |
119 | | // its center to the right of the last line by this scaled amount. |
120 | | const double kParagraphEndingPreviousLineRatio = 1.3; |
121 | | |
122 | | // The maximum amount of whitespace allowed left of a paragraph ending. |
123 | | // Do not filter a ColPartition with more than this space left of it. |
124 | | const double kMaxParagraphEndingLeftSpaceMultiple = 3.0; |
125 | | |
126 | | // Used when filtering false positives. The last line of a paragraph |
127 | | // should be preceded by a line that is predominantly text. This is the |
128 | | // ratio of text to whitespace (to the right of the text) that is required |
129 | | // for the previous line to be a text. |
130 | | const double kMinParagraphEndingTextToWhitespaceRatio = 3.0; |
131 | | |
132 | | // When counting table columns, this is the required gap between two columns |
133 | | // (it is multiplied by global_median_xheight_). |
134 | | const double kMaxXProjectionGapFactor = 2.0; |
135 | | |
136 | | // Used for similarity in partitions using stroke width. Values copied |
137 | | // from ColFind.cpp in Ray's CL. |
138 | | const double kStrokeWidthFractionalTolerance = 0.25; |
139 | | const double kStrokeWidthConstantTolerance = 2.0; |
140 | | |
141 | | #ifndef GRAPHICS_DISABLED |
142 | | static BOOL_VAR(textord_show_tables, false, "Show table regions (ScrollView)"); |
143 | | static BOOL_VAR(textord_tablefind_show_mark, false, |
144 | | "Debug table marking steps in detail (ScrollView)"); |
145 | | static BOOL_VAR(textord_tablefind_show_stats, false, |
146 | | "Show page stats used in table finding (ScrollView)"); |
147 | | #endif |
148 | | static BOOL_VAR(textord_tablefind_recognize_tables, false, |
149 | | "Enables the table recognizer for table layout and filtering."); |
150 | | |
151 | | // Templated helper function used to create destructor callbacks for the |
152 | | // BBGrid::ClearGridData() method. |
153 | | template <typename T> |
154 | 0 | void DeleteObject(T *object) { |
155 | 0 | delete object; |
156 | 0 | } Unexecuted instantiation: void tesseract::DeleteObject<tesseract::ColPartition>(tesseract::ColPartition*) Unexecuted instantiation: void tesseract::DeleteObject<tesseract::ColSegment>(tesseract::ColSegment*) |
157 | | |
158 | | TableFinder::TableFinder() |
159 | 0 | : resolution_(0), |
160 | 0 | global_median_xheight_(0), |
161 | 0 | global_median_blob_width_(0), |
162 | 0 | global_median_ledding_(0), |
163 | 0 | left_to_right_language_(true) {} |
164 | | |
165 | 0 | TableFinder::~TableFinder() { |
166 | | // ColPartitions and ColSegments created by this class for storage in grids |
167 | | // need to be deleted explicitly. |
168 | 0 | clean_part_grid_.ClearGridData(&DeleteObject<ColPartition>); |
169 | 0 | leader_and_ruling_grid_.ClearGridData(&DeleteObject<ColPartition>); |
170 | 0 | fragmented_text_grid_.ClearGridData(&DeleteObject<ColPartition>); |
171 | 0 | col_seg_grid_.ClearGridData(&DeleteObject<ColSegment>); |
172 | 0 | table_grid_.ClearGridData(&DeleteObject<ColSegment>); |
173 | 0 | } |
174 | | |
175 | 0 | void TableFinder::set_left_to_right_language(bool order) { |
176 | 0 | left_to_right_language_ = order; |
177 | 0 | } |
178 | | |
179 | | void TableFinder::Init(int grid_size, const ICOORD &bottom_left, |
180 | 0 | const ICOORD &top_right) { |
181 | | // Initialize clean partitions list and grid |
182 | 0 | clean_part_grid_.Init(grid_size, bottom_left, top_right); |
183 | 0 | leader_and_ruling_grid_.Init(grid_size, bottom_left, top_right); |
184 | 0 | fragmented_text_grid_.Init(grid_size, bottom_left, top_right); |
185 | 0 | col_seg_grid_.Init(grid_size, bottom_left, top_right); |
186 | 0 | table_grid_.Init(grid_size, bottom_left, top_right); |
187 | 0 | } |
188 | | |
189 | | // Copy cleaned partitions from part_grid_ to clean_part_grid_ and |
190 | | // insert leaders and rulers into the leader_and_ruling_grid_ |
191 | 0 | void TableFinder::InsertCleanPartitions(ColPartitionGrid *grid) { |
192 | | // Calculate stats. This lets us filter partitions in AllowTextPartition() |
193 | | // and filter blobs in AllowBlob(). |
194 | 0 | SetGlobalSpacings(grid); |
195 | | |
196 | | // Iterate the ColPartitions in the grid. |
197 | 0 | ColPartitionGridSearch gsearch(grid); |
198 | 0 | gsearch.SetUniqueMode(true); |
199 | 0 | gsearch.StartFullSearch(); |
200 | 0 | ColPartition *part = nullptr; |
201 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
202 | | // Reject partitions with nothing useful inside of them. |
203 | 0 | if (part->blob_type() == BRT_NOISE || part->bounding_box().area() <= 0) { |
204 | 0 | continue; |
205 | 0 | } |
206 | 0 | ColPartition *clean_part = part->ShallowCopy(); |
207 | 0 | ColPartition *leader_part = nullptr; |
208 | 0 | if (part->IsLineType()) { |
209 | 0 | InsertRulingPartition(clean_part); |
210 | 0 | continue; |
211 | 0 | } |
212 | | // Insert all non-text partitions to clean_parts |
213 | 0 | if (!part->IsTextType()) { |
214 | 0 | InsertImagePartition(clean_part); |
215 | 0 | continue; |
216 | 0 | } |
217 | | // Insert text colpartitions after removing noisy components from them |
218 | | // The leaders are split into a separate grid. |
219 | 0 | BLOBNBOX_CLIST *part_boxes = part->boxes(); |
220 | 0 | BLOBNBOX_C_IT pit(part_boxes); |
221 | 0 | for (pit.mark_cycle_pt(); !pit.cycled_list(); pit.forward()) { |
222 | 0 | BLOBNBOX *pblob = pit.data(); |
223 | | // Bad blobs... happens in UNLV set. |
224 | | // news.3G1, page 17 (around x=6) |
225 | 0 | if (!AllowBlob(*pblob)) { |
226 | 0 | continue; |
227 | 0 | } |
228 | 0 | if (pblob->flow() == BTFT_LEADER) { |
229 | 0 | if (leader_part == nullptr) { |
230 | 0 | leader_part = part->ShallowCopy(); |
231 | 0 | leader_part->set_flow(BTFT_LEADER); |
232 | 0 | } |
233 | 0 | leader_part->AddBox(pblob); |
234 | 0 | } else if (pblob->region_type() != BRT_NOISE) { |
235 | 0 | clean_part->AddBox(pblob); |
236 | 0 | } |
237 | 0 | } |
238 | 0 | clean_part->ComputeLimits(); |
239 | 0 | ColPartition *fragmented = clean_part->CopyButDontOwnBlobs(); |
240 | 0 | InsertTextPartition(clean_part); |
241 | 0 | SplitAndInsertFragmentedTextPartition(fragmented); |
242 | 0 | if (leader_part != nullptr) { |
243 | | // TODO(nbeato): Note that ComputeLimits does not update the column |
244 | | // information. So the leader may appear to span more columns than it |
245 | | // really does later on when IsInSameColumnAs gets called to test |
246 | | // for adjacent leaders. |
247 | 0 | leader_part->ComputeLimits(); |
248 | 0 | InsertLeaderPartition(leader_part); |
249 | 0 | } |
250 | 0 | } |
251 | | |
252 | | // Make the partition partners better for upper and lower neighbors. |
253 | 0 | clean_part_grid_.FindPartitionPartners(); |
254 | 0 | clean_part_grid_.RefinePartitionPartners(false); |
255 | 0 | } |
256 | | |
257 | | // High level function to perform table detection |
258 | | void TableFinder::LocateTables(ColPartitionGrid *grid, |
259 | | ColPartitionSet **all_columns, |
260 | 0 | WidthCallback width_cb) { |
261 | | // initialize spacing, neighbors, and columns |
262 | 0 | InitializePartitions(all_columns); |
263 | |
|
264 | | #ifndef GRAPHICS_DISABLED |
265 | | if (textord_show_tables) { |
266 | | ScrollView *table_win = MakeWindow(0, 300, "Column Partitions & Neighbors"); |
267 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE); |
268 | | DisplayColPartitions(table_win, &leader_and_ruling_grid_, |
269 | | ScrollView::AQUAMARINE); |
270 | | DisplayColPartitionConnections(table_win, &clean_part_grid_, |
271 | | ScrollView::ORANGE); |
272 | | |
273 | | table_win = MakeWindow(100, 300, "Fragmented Text"); |
274 | | DisplayColPartitions(table_win, &fragmented_text_grid_, ScrollView::BLUE); |
275 | | } |
276 | | #endif // !GRAPHICS_DISABLED |
277 | | |
278 | | // mark, filter, and smooth candidate table partitions |
279 | 0 | MarkTablePartitions(); |
280 | | |
281 | | // Make single-column blocks from good_columns_ partitions. col_segments are |
282 | | // moved to a grid later which takes the ownership |
283 | 0 | ColSegment_LIST column_blocks; |
284 | 0 | GetColumnBlocks(all_columns, &column_blocks); |
285 | | // Set the ratio of candidate table partitions in each column |
286 | 0 | SetColumnsType(&column_blocks); |
287 | | |
288 | | // Move column segments to col_seg_grid_ |
289 | 0 | MoveColSegmentsToGrid(&column_blocks, &col_seg_grid_); |
290 | | |
291 | | // Detect split in column layout that might have occurred due to the |
292 | | // presence of a table. In such a case, merge the corresponding columns. |
293 | 0 | GridMergeColumnBlocks(); |
294 | | |
295 | | // Group horizontally overlapping table partitions into table columns. |
296 | | // table_columns created here get deleted at the end of this method. |
297 | 0 | ColSegment_LIST table_columns; |
298 | 0 | GetTableColumns(&table_columns); |
299 | | |
300 | | // Within each column, mark the range table regions occupy based on the |
301 | | // table columns detected. table_regions are moved to a grid later which |
302 | | // takes the ownership |
303 | 0 | ColSegment_LIST table_regions; |
304 | 0 | GetTableRegions(&table_columns, &table_regions); |
305 | |
|
306 | | #ifndef GRAPHICS_DISABLED |
307 | | if (textord_tablefind_show_mark) { |
308 | | ScrollView *table_win = MakeWindow(1200, 300, "Table Columns and Regions"); |
309 | | DisplayColSegments(table_win, &table_columns, ScrollView::DARK_TURQUOISE); |
310 | | DisplayColSegments(table_win, &table_regions, ScrollView::YELLOW); |
311 | | } |
312 | | #endif // !GRAPHICS_DISABLED |
313 | | |
314 | | // Merge table regions across columns for tables spanning multiple |
315 | | // columns |
316 | 0 | MoveColSegmentsToGrid(&table_regions, &table_grid_); |
317 | 0 | GridMergeTableRegions(); |
318 | | |
319 | | // Adjust table boundaries by including nearby horizontal lines and left |
320 | | // out column headers |
321 | 0 | AdjustTableBoundaries(); |
322 | 0 | GridMergeTableRegions(); |
323 | |
|
324 | 0 | if (textord_tablefind_recognize_tables) { |
325 | | // Remove false alarms consisting of a single column |
326 | 0 | DeleteSingleColumnTables(); |
327 | |
|
328 | | #ifndef GRAPHICS_DISABLED |
329 | | if (textord_show_tables) { |
330 | | ScrollView *table_win = MakeWindow(1200, 300, "Detected Table Locations"); |
331 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE); |
332 | | DisplayColSegments(table_win, &table_columns, ScrollView::KHAKI); |
333 | | table_grid_.DisplayBoxes(table_win); |
334 | | } |
335 | | #endif // !GRAPHICS_DISABLED |
336 | | |
337 | | // Find table grid structure and reject tables that are malformed. |
338 | 0 | RecognizeTables(); |
339 | 0 | GridMergeTableRegions(); |
340 | 0 | RecognizeTables(); |
341 | |
|
342 | | #ifndef GRAPHICS_DISABLED |
343 | | if (textord_show_tables) { |
344 | | ScrollView *table_win = MakeWindow(1400, 600, "Recognized Tables"); |
345 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE, |
346 | | ScrollView::BLUE); |
347 | | table_grid_.DisplayBoxes(table_win); |
348 | | } |
349 | | #endif // !GRAPHICS_DISABLED |
350 | 0 | } else { |
351 | | // Remove false alarms consisting of a single column |
352 | | // TODO(nbeato): verify this is a NOP after structured table rejection. |
353 | | // Right now it isn't. If the recognize function is doing what it is |
354 | | // supposed to do, this function is obsolete. |
355 | 0 | DeleteSingleColumnTables(); |
356 | |
|
357 | | #ifndef GRAPHICS_DISABLED |
358 | | if (textord_show_tables) { |
359 | | ScrollView *table_win = MakeWindow(1500, 300, "Detected Tables"); |
360 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE, |
361 | | ScrollView::BLUE); |
362 | | table_grid_.DisplayBoxes(table_win); |
363 | | } |
364 | | #endif // !GRAPHICS_DISABLED |
365 | 0 | } |
366 | | |
367 | | // Merge all colpartitions in table regions to make them a single |
368 | | // colpartition and revert types of isolated table cells not |
369 | | // assigned to any table to their original types. |
370 | 0 | MakeTableBlocks(grid, all_columns, width_cb); |
371 | 0 | } |
372 | | // All grids have the same dimensions. The clean_part_grid_ sizes are set from |
373 | | // the part_grid_ that is passed to InsertCleanPartitions, which was the same as |
374 | | // the grid that is the base of ColumnFinder. Just return the clean_part_grid_ |
375 | | // dimensions instead of duplicated memory. |
376 | 0 | int TableFinder::gridsize() const { |
377 | 0 | return clean_part_grid_.gridsize(); |
378 | 0 | } |
379 | 0 | int TableFinder::gridwidth() const { |
380 | 0 | return clean_part_grid_.gridwidth(); |
381 | 0 | } |
382 | 0 | int TableFinder::gridheight() const { |
383 | 0 | return clean_part_grid_.gridheight(); |
384 | 0 | } |
385 | 0 | const ICOORD &TableFinder::bleft() const { |
386 | 0 | return clean_part_grid_.bleft(); |
387 | 0 | } |
388 | 0 | const ICOORD &TableFinder::tright() const { |
389 | 0 | return clean_part_grid_.tright(); |
390 | 0 | } |
391 | | |
392 | 0 | void TableFinder::InsertTextPartition(ColPartition *part) { |
393 | 0 | ASSERT_HOST(part != nullptr); |
394 | 0 | if (AllowTextPartition(*part)) { |
395 | 0 | clean_part_grid_.InsertBBox(true, true, part); |
396 | 0 | } else { |
397 | 0 | delete part; |
398 | 0 | } |
399 | 0 | } |
400 | 0 | void TableFinder::InsertFragmentedTextPartition(ColPartition *part) { |
401 | 0 | ASSERT_HOST(part != nullptr); |
402 | 0 | if (AllowTextPartition(*part)) { |
403 | 0 | fragmented_text_grid_.InsertBBox(true, true, part); |
404 | 0 | } else { |
405 | 0 | delete part; |
406 | 0 | } |
407 | 0 | } |
408 | 0 | void TableFinder::InsertLeaderPartition(ColPartition *part) { |
409 | 0 | ASSERT_HOST(part != nullptr); |
410 | 0 | if (!part->IsEmpty() && part->bounding_box().area() > 0) { |
411 | 0 | leader_and_ruling_grid_.InsertBBox(true, true, part); |
412 | 0 | } else { |
413 | 0 | delete part; |
414 | 0 | } |
415 | 0 | } |
416 | 0 | void TableFinder::InsertRulingPartition(ColPartition *part) { |
417 | 0 | leader_and_ruling_grid_.InsertBBox(true, true, part); |
418 | 0 | } |
419 | 0 | void TableFinder::InsertImagePartition(ColPartition *part) { |
420 | | // NOTE: If images are placed into a different grid in the future, |
421 | | // the function SetPartitionSpacings needs to be updated. It should |
422 | | // be the only thing that cares about image partitions. |
423 | 0 | clean_part_grid_.InsertBBox(true, true, part); |
424 | 0 | } |
425 | | |
426 | | // Splits a partition into its "words". The splits happen |
427 | | // at locations with wide inter-blob spacing. This is useful |
428 | | // because it allows the table recognize to "cut through" the |
429 | | // text lines on the page. The assumption is that a table |
430 | | // will have several lines with similar overlapping whitespace |
431 | | // whereas text will not have this type of property. |
432 | | // Note: The code assumes that blobs are sorted by the left side x! |
433 | | // This will not work (as well) if the blobs are sorted by center/right. |
434 | 0 | void TableFinder::SplitAndInsertFragmentedTextPartition(ColPartition *part) { |
435 | 0 | ASSERT_HOST(part != nullptr); |
436 | | // Bye bye empty partitions! |
437 | 0 | if (part->boxes()->empty()) { |
438 | 0 | delete part; |
439 | 0 | return; |
440 | 0 | } |
441 | | |
442 | | // The AllowBlob function prevents this. |
443 | 0 | ASSERT_HOST(part->median_width() > 0); |
444 | 0 | const double kThreshold = part->median_width() * kSplitPartitionSize; |
445 | |
|
446 | 0 | ColPartition *right_part = part; |
447 | 0 | bool found_split = true; |
448 | 0 | while (found_split) { |
449 | 0 | found_split = false; |
450 | 0 | BLOBNBOX_C_IT box_it(right_part->boxes()); |
451 | | // Blobs are sorted left side first. If blobs overlap, |
452 | | // the previous blob may have a "more right" right side. |
453 | | // Account for this by always keeping the largest "right" |
454 | | // so far. |
455 | 0 | int previous_right = INT32_MIN; |
456 | | |
457 | | // Look for the next split in the partition. |
458 | 0 | for (box_it.mark_cycle_pt(); !box_it.cycled_list(); box_it.forward()) { |
459 | 0 | const TBOX &box = box_it.data()->bounding_box(); |
460 | 0 | if (previous_right != INT32_MIN && |
461 | 0 | box.left() - previous_right > kThreshold) { |
462 | | // We have a split position. Split the partition in two pieces. |
463 | | // Insert the left piece in the grid and keep processing the right. |
464 | 0 | int mid_x = (box.left() + previous_right) / 2; |
465 | 0 | ColPartition *left_part = right_part; |
466 | 0 | right_part = left_part->SplitAt(mid_x); |
467 | |
|
468 | 0 | InsertFragmentedTextPartition(left_part); |
469 | 0 | found_split = true; |
470 | 0 | break; |
471 | 0 | } |
472 | | |
473 | | // The right side of the previous blobs. |
474 | 0 | previous_right = std::max(previous_right, static_cast<int>(box.right())); |
475 | 0 | } |
476 | 0 | } |
477 | | // When a split is not found, the right part is minimized |
478 | | // as much as possible, so process it. |
479 | 0 | InsertFragmentedTextPartition(right_part); |
480 | 0 | } |
481 | | |
482 | | // Some simple criteria to filter out now. We want to make sure the |
483 | | // average blob size in the partition is consistent with the |
484 | | // global page stats. |
485 | | // The area metric will almost always pass for multi-blob partitions. |
486 | | // It is useful when filtering out noise caused by an isolated blob. |
487 | 0 | bool TableFinder::AllowTextPartition(const ColPartition &part) const { |
488 | 0 | const double kHeightRequired = global_median_xheight_ * kAllowTextHeight; |
489 | 0 | const double kWidthRequired = global_median_blob_width_ * kAllowTextWidth; |
490 | 0 | const int median_area = global_median_xheight_ * global_median_blob_width_; |
491 | 0 | const double kAreaPerBlobRequired = median_area * kAllowTextArea; |
492 | | // Keep comparisons strictly greater to disallow 0! |
493 | 0 | return part.median_height() > kHeightRequired && |
494 | 0 | part.median_width() > kWidthRequired && |
495 | 0 | part.bounding_box().area() > kAreaPerBlobRequired * part.boxes_count(); |
496 | 0 | } |
497 | | |
498 | | // Same as above, applied to blobs. Keep in mind that |
499 | | // leaders, commas, and periods are important in tables. |
500 | 0 | bool TableFinder::AllowBlob(const BLOBNBOX &blob) const { |
501 | 0 | const TBOX &box = blob.bounding_box(); |
502 | 0 | const double kHeightRequired = global_median_xheight_ * kAllowBlobHeight; |
503 | 0 | const double kWidthRequired = global_median_blob_width_ * kAllowBlobWidth; |
504 | 0 | const int median_area = global_median_xheight_ * global_median_blob_width_; |
505 | 0 | const double kAreaRequired = median_area * kAllowBlobArea; |
506 | | // Keep comparisons strictly greater to disallow 0! |
507 | 0 | return box.height() > kHeightRequired && box.width() > kWidthRequired && |
508 | 0 | box.area() > kAreaRequired; |
509 | 0 | } |
510 | | |
511 | | // TODO(nbeato): The grid that makes the window doesn't seem to matter. |
512 | | // The only downside is that window messages will be caught by |
513 | | // clean_part_grid_ instead of a useful object. This is a temporary solution |
514 | | // for the debug windows created by the TableFinder. |
515 | | #ifndef GRAPHICS_DISABLED |
516 | | ScrollView *TableFinder::MakeWindow(int x, int y, const char *window_name) { |
517 | | return clean_part_grid_.MakeWindow(x, y, window_name); |
518 | | } |
519 | | #endif |
520 | | |
521 | | // Make single-column blocks from good_columns_ partitions. |
522 | | void TableFinder::GetColumnBlocks(ColPartitionSet **all_columns, |
523 | 0 | ColSegment_LIST *column_blocks) { |
524 | 0 | for (int i = 0; i < gridheight(); ++i) { |
525 | 0 | ColPartitionSet *columns = all_columns[i]; |
526 | 0 | if (columns != nullptr) { |
527 | 0 | ColSegment_LIST new_blocks; |
528 | | // Get boxes from the current vertical position on the grid |
529 | 0 | columns->GetColumnBoxes(i * gridsize(), (i + 1) * gridsize(), |
530 | 0 | &new_blocks); |
531 | | // Merge the new_blocks boxes into column_blocks if they are well-aligned |
532 | 0 | GroupColumnBlocks(&new_blocks, column_blocks); |
533 | 0 | } |
534 | 0 | } |
535 | 0 | } |
536 | | |
537 | | // Merge column segments into the current list if they are well aligned. |
538 | | void TableFinder::GroupColumnBlocks(ColSegment_LIST *new_blocks, |
539 | 0 | ColSegment_LIST *column_blocks) { |
540 | 0 | ColSegment_IT src_it(new_blocks); |
541 | 0 | ColSegment_IT dest_it(column_blocks); |
542 | | // iterate through the source list |
543 | 0 | for (src_it.mark_cycle_pt(); !src_it.cycled_list(); src_it.forward()) { |
544 | 0 | ColSegment *src_seg = src_it.data(); |
545 | 0 | const TBOX &src_box = src_seg->bounding_box(); |
546 | 0 | bool match_found = false; |
547 | | // iterate through the destination list to find a matching column block |
548 | 0 | for (dest_it.mark_cycle_pt(); !dest_it.cycled_list(); dest_it.forward()) { |
549 | 0 | ColSegment *dest_seg = dest_it.data(); |
550 | 0 | TBOX dest_box = dest_seg->bounding_box(); |
551 | 0 | if (ConsecutiveBoxes(src_box, dest_box)) { |
552 | | // If matching block is found, insert the current block into it |
553 | | // and delete the source block. |
554 | 0 | dest_seg->InsertBox(src_box); |
555 | 0 | match_found = true; |
556 | 0 | delete src_it.extract(); |
557 | 0 | break; |
558 | 0 | } |
559 | 0 | } |
560 | | // If no match is found, just append the source block to column_blocks |
561 | 0 | if (!match_found) { |
562 | 0 | dest_it.add_after_then_move(src_it.extract()); |
563 | 0 | } |
564 | 0 | } |
565 | 0 | } |
566 | | |
567 | | // are the two boxes immediate neighbors along the vertical direction |
568 | 0 | bool TableFinder::ConsecutiveBoxes(const TBOX &b1, const TBOX &b2) { |
569 | 0 | int x_margin = 20; |
570 | 0 | int y_margin = 5; |
571 | 0 | return (abs(b1.left() - b2.left()) < x_margin) && |
572 | 0 | (abs(b1.right() - b2.right()) < x_margin) && |
573 | 0 | (abs(b1.top() - b2.bottom()) < y_margin || |
574 | 0 | abs(b2.top() - b1.bottom()) < y_margin); |
575 | 0 | } |
576 | | |
577 | | // Set up info for clean_part_grid_ partitions to be valid during detection |
578 | | // code. |
579 | 0 | void TableFinder::InitializePartitions(ColPartitionSet **all_columns) { |
580 | 0 | FindNeighbors(); |
581 | 0 | SetPartitionSpacings(&clean_part_grid_, all_columns); |
582 | 0 | SetGlobalSpacings(&clean_part_grid_); |
583 | 0 | } |
584 | | |
585 | | // Set left, right and top, bottom spacings of each colpartition. |
586 | | void TableFinder::SetPartitionSpacings(ColPartitionGrid *grid, |
587 | 0 | ColPartitionSet **all_columns) { |
588 | | // Iterate the ColPartitions in the grid. |
589 | 0 | ColPartitionGridSearch gsearch(grid); |
590 | 0 | gsearch.StartFullSearch(); |
591 | 0 | ColPartition *part = nullptr; |
592 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
593 | 0 | ColPartitionSet *columns = all_columns[gsearch.GridY()]; |
594 | 0 | TBOX box = part->bounding_box(); |
595 | 0 | int y = part->MidY(); |
596 | 0 | ColPartition *left_column = columns->ColumnContaining(box.left(), y); |
597 | 0 | ColPartition *right_column = columns->ColumnContaining(box.right(), y); |
598 | | // set distance from left column as space to the left |
599 | 0 | if (left_column) { |
600 | 0 | int left_space = std::max(0, box.left() - left_column->LeftAtY(y)); |
601 | 0 | part->set_space_to_left(left_space); |
602 | 0 | } |
603 | | // set distance from right column as space to the right |
604 | 0 | if (right_column) { |
605 | 0 | int right_space = std::max(0, right_column->RightAtY(y) - box.right()); |
606 | 0 | part->set_space_to_right(right_space); |
607 | 0 | } |
608 | | |
609 | | // Look for images that may be closer. |
610 | | // NOTE: used to be part_grid_, might cause issues now |
611 | 0 | ColPartitionGridSearch hsearch(grid); |
612 | 0 | hsearch.StartSideSearch(box.left(), box.bottom(), box.top()); |
613 | 0 | ColPartition *neighbor = nullptr; |
614 | 0 | while ((neighbor = hsearch.NextSideSearch(true)) != nullptr) { |
615 | 0 | if (neighbor->type() == PT_PULLOUT_IMAGE || |
616 | 0 | neighbor->type() == PT_FLOWING_IMAGE || |
617 | 0 | neighbor->type() == PT_HEADING_IMAGE) { |
618 | 0 | int right = neighbor->bounding_box().right(); |
619 | 0 | if (right < box.left()) { |
620 | 0 | int space = std::min(box.left() - right, part->space_to_left()); |
621 | 0 | part->set_space_to_left(space); |
622 | 0 | } |
623 | 0 | } |
624 | 0 | } |
625 | 0 | hsearch.StartSideSearch(box.left(), box.bottom(), box.top()); |
626 | 0 | neighbor = nullptr; |
627 | 0 | while ((neighbor = hsearch.NextSideSearch(false)) != nullptr) { |
628 | 0 | if (neighbor->type() == PT_PULLOUT_IMAGE || |
629 | 0 | neighbor->type() == PT_FLOWING_IMAGE || |
630 | 0 | neighbor->type() == PT_HEADING_IMAGE) { |
631 | 0 | int left = neighbor->bounding_box().left(); |
632 | 0 | if (left > box.right()) { |
633 | 0 | int space = std::min(left - box.right(), part->space_to_right()); |
634 | 0 | part->set_space_to_right(space); |
635 | 0 | } |
636 | 0 | } |
637 | 0 | } |
638 | |
|
639 | 0 | ColPartition *upper_part = part->SingletonPartner(true); |
640 | 0 | if (upper_part) { |
641 | 0 | int space = |
642 | 0 | std::max(0, static_cast<int>(upper_part->bounding_box().bottom() - |
643 | 0 | part->bounding_box().bottom())); |
644 | 0 | part->set_space_above(space); |
645 | 0 | } else { |
646 | | // TODO(nbeato): What constitutes a good value? |
647 | | // 0 is the default value when not set, explicitly noting it needs to |
648 | | // be something else. |
649 | 0 | part->set_space_above(INT32_MAX); |
650 | 0 | } |
651 | |
|
652 | 0 | ColPartition *lower_part = part->SingletonPartner(false); |
653 | 0 | if (lower_part) { |
654 | 0 | int space = |
655 | 0 | std::max(0, static_cast<int>(part->bounding_box().bottom() - |
656 | 0 | lower_part->bounding_box().bottom())); |
657 | 0 | part->set_space_below(space); |
658 | 0 | } else { |
659 | | // TODO(nbeato): What constitutes a good value? |
660 | | // 0 is the default value when not set, explicitly noting it needs to |
661 | | // be something else. |
662 | 0 | part->set_space_below(INT32_MAX); |
663 | 0 | } |
664 | 0 | } |
665 | 0 | } |
666 | | |
667 | | // Set spacing and closest neighbors above and below a given colpartition. |
668 | 0 | void TableFinder::SetVerticalSpacing(ColPartition *part) { |
669 | 0 | TBOX box = part->bounding_box(); |
670 | 0 | int top_range = |
671 | 0 | std::min(box.top() + kMaxVerticalSpacing, static_cast<int>(tright().y())); |
672 | 0 | int bottom_range = std::max(box.bottom() - kMaxVerticalSpacing, |
673 | 0 | static_cast<int>(bleft().y())); |
674 | 0 | box.set_top(top_range); |
675 | 0 | box.set_bottom(bottom_range); |
676 | |
|
677 | 0 | TBOX part_box = part->bounding_box(); |
678 | | // Start a rect search |
679 | 0 | ColPartitionGridSearch rectsearch(&clean_part_grid_); |
680 | 0 | rectsearch.StartRectSearch(box); |
681 | 0 | ColPartition *neighbor; |
682 | 0 | int min_space_above = kMaxVerticalSpacing; |
683 | 0 | int min_space_below = kMaxVerticalSpacing; |
684 | 0 | ColPartition *above_neighbor = nullptr; |
685 | 0 | ColPartition *below_neighbor = nullptr; |
686 | 0 | while ((neighbor = rectsearch.NextRectSearch()) != nullptr) { |
687 | 0 | if (neighbor == part) { |
688 | 0 | continue; |
689 | 0 | } |
690 | 0 | TBOX neighbor_box = neighbor->bounding_box(); |
691 | 0 | if (neighbor_box.major_x_overlap(part_box)) { |
692 | 0 | int gap = abs(part->median_bottom() - neighbor->median_bottom()); |
693 | | // If neighbor is below current partition |
694 | 0 | if (neighbor_box.top() < part_box.bottom() && gap < min_space_below) { |
695 | 0 | min_space_below = gap; |
696 | 0 | below_neighbor = neighbor; |
697 | 0 | } // If neighbor is above current partition |
698 | 0 | else if (part_box.top() < neighbor_box.bottom() && |
699 | 0 | gap < min_space_above) { |
700 | 0 | min_space_above = gap; |
701 | 0 | above_neighbor = neighbor; |
702 | 0 | } |
703 | 0 | } |
704 | 0 | } |
705 | 0 | part->set_space_above(min_space_above); |
706 | 0 | part->set_space_below(min_space_below); |
707 | 0 | part->set_nearest_neighbor_above(above_neighbor); |
708 | 0 | part->set_nearest_neighbor_below(below_neighbor); |
709 | 0 | } |
710 | | |
711 | | // Set global spacing and x-height estimates |
712 | 0 | void TableFinder::SetGlobalSpacings(ColPartitionGrid *grid) { |
713 | 0 | STATS xheight_stats(0, kMaxVerticalSpacing); |
714 | 0 | STATS width_stats(0, kMaxBlobWidth); |
715 | 0 | STATS ledding_stats(0, kMaxVerticalSpacing); |
716 | | // Iterate the ColPartitions in the grid. |
717 | 0 | ColPartitionGridSearch gsearch(grid); |
718 | 0 | gsearch.SetUniqueMode(true); |
719 | 0 | gsearch.StartFullSearch(); |
720 | 0 | ColPartition *part = nullptr; |
721 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
722 | | // TODO(nbeato): HACK HACK HACK! medians are equal to partition length. |
723 | | // ComputeLimits needs to get called somewhere outside of TableFinder |
724 | | // to make sure the partitions are properly initialized. |
725 | | // When this is called, SmoothPartitionPartners dies in an assert after |
726 | | // table find runs. Alternative solution. |
727 | | // part->ComputeLimits(); |
728 | 0 | if (part->IsTextType()) { |
729 | | // xheight_stats.add(part->median_height(), part->boxes_count()); |
730 | | // width_stats.add(part->median_width(), part->boxes_count()); |
731 | | |
732 | | // This loop can be removed when above issues are fixed. |
733 | | // Replace it with the 2 lines commented out above. |
734 | 0 | BLOBNBOX_C_IT it(part->boxes()); |
735 | 0 | for (it.mark_cycle_pt(); !it.cycled_list(); it.forward()) { |
736 | 0 | xheight_stats.add(it.data()->bounding_box().height(), 1); |
737 | 0 | width_stats.add(it.data()->bounding_box().width(), 1); |
738 | 0 | } |
739 | |
|
740 | 0 | ledding_stats.add(part->space_above(), 1); |
741 | 0 | ledding_stats.add(part->space_below(), 1); |
742 | 0 | } |
743 | 0 | } |
744 | | // Set estimates based on median of statistics obtained |
745 | 0 | set_global_median_xheight(static_cast<int>(xheight_stats.median() + 0.5)); |
746 | 0 | set_global_median_blob_width(static_cast<int>(width_stats.median() + 0.5)); |
747 | 0 | set_global_median_ledding(static_cast<int>(ledding_stats.median() + 0.5)); |
748 | | #ifndef GRAPHICS_DISABLED |
749 | | if (textord_tablefind_show_stats) { |
750 | | const char *kWindowName = "X-height (R), X-width (G), and ledding (B)"; |
751 | | ScrollView *stats_win = MakeWindow(500, 10, kWindowName); |
752 | | xheight_stats.plot(stats_win, 10, 200, 2, 15, ScrollView::RED); |
753 | | width_stats.plot(stats_win, 10, 200, 2, 15, ScrollView::GREEN); |
754 | | ledding_stats.plot(stats_win, 10, 200, 2, 15, ScrollView::BLUE); |
755 | | } |
756 | | #endif // !GRAPHICS_DISABLED |
757 | 0 | } |
758 | | |
759 | 0 | void TableFinder::set_global_median_xheight(int xheight) { |
760 | 0 | global_median_xheight_ = xheight; |
761 | 0 | } |
762 | 0 | void TableFinder::set_global_median_blob_width(int width) { |
763 | 0 | global_median_blob_width_ = width; |
764 | 0 | } |
765 | 0 | void TableFinder::set_global_median_ledding(int ledding) { |
766 | 0 | global_median_ledding_ = ledding; |
767 | 0 | } |
768 | | |
769 | 0 | void TableFinder::FindNeighbors() { |
770 | 0 | ColPartitionGridSearch gsearch(&clean_part_grid_); |
771 | 0 | gsearch.StartFullSearch(); |
772 | 0 | ColPartition *part = nullptr; |
773 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
774 | | // TODO(nbeato): Rename this function, meaning is different now. |
775 | | // IT is finding nearest neighbors its own way |
776 | | // SetVerticalSpacing(part); |
777 | |
|
778 | 0 | ColPartition *upper = part->SingletonPartner(true); |
779 | 0 | if (upper) { |
780 | 0 | part->set_nearest_neighbor_above(upper); |
781 | 0 | } |
782 | |
|
783 | 0 | ColPartition *lower = part->SingletonPartner(false); |
784 | 0 | if (lower) { |
785 | 0 | part->set_nearest_neighbor_below(lower); |
786 | 0 | } |
787 | 0 | } |
788 | 0 | } |
789 | | |
790 | | // High level interface. Input is an unmarked ColPartitionGrid |
791 | | // (namely, clean_part_grid_). Partitions are identified using local |
792 | | // information and filter/smoothed. The function exit should contain |
793 | | // a good sampling of the table partitions. |
794 | 0 | void TableFinder::MarkTablePartitions() { |
795 | 0 | MarkPartitionsUsingLocalInformation(); |
796 | | #ifndef GRAPHICS_DISABLED |
797 | | if (textord_tablefind_show_mark) { |
798 | | ScrollView *table_win = MakeWindow(300, 300, "Initial Table Partitions"); |
799 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE); |
800 | | DisplayColPartitions(table_win, &leader_and_ruling_grid_, |
801 | | ScrollView::AQUAMARINE); |
802 | | } |
803 | | #endif |
804 | 0 | FilterFalseAlarms(); |
805 | | #ifndef GRAPHICS_DISABLED |
806 | | if (textord_tablefind_show_mark) { |
807 | | ScrollView *table_win = MakeWindow(600, 300, "Filtered Table Partitions"); |
808 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE); |
809 | | DisplayColPartitions(table_win, &leader_and_ruling_grid_, |
810 | | ScrollView::AQUAMARINE); |
811 | | } |
812 | | #endif |
813 | 0 | SmoothTablePartitionRuns(); |
814 | | #ifndef GRAPHICS_DISABLED |
815 | | if (textord_tablefind_show_mark) { |
816 | | ScrollView *table_win = MakeWindow(900, 300, "Smoothed Table Partitions"); |
817 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE); |
818 | | DisplayColPartitions(table_win, &leader_and_ruling_grid_, |
819 | | ScrollView::AQUAMARINE); |
820 | | } |
821 | | #endif |
822 | 0 | FilterFalseAlarms(); |
823 | | #ifndef GRAPHICS_DISABLED |
824 | | if (textord_tablefind_show_mark || textord_show_tables) { |
825 | | ScrollView *table_win = MakeWindow(900, 300, "Final Table Partitions"); |
826 | | DisplayColPartitions(table_win, &clean_part_grid_, ScrollView::BLUE); |
827 | | DisplayColPartitions(table_win, &leader_and_ruling_grid_, |
828 | | ScrollView::AQUAMARINE); |
829 | | } |
830 | | #endif |
831 | 0 | } |
832 | | |
833 | | // These types of partitions are marked as table partitions: |
834 | | // 1- Partitions that have at lease one large gap between words |
835 | | // 2- Partitions that consist of only one word (no significant gap |
836 | | // between components) |
837 | | // 3- Partitions that vertically overlap with other partitions within the |
838 | | // same column. |
839 | | // 4- Partitions with leaders before/after them. |
840 | 0 | void TableFinder::MarkPartitionsUsingLocalInformation() { |
841 | | // Iterate the ColPartitions in the grid. |
842 | 0 | ColPartitionGridSearch gsearch(&clean_part_grid_); |
843 | 0 | gsearch.StartFullSearch(); |
844 | 0 | ColPartition *part = nullptr; |
845 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
846 | 0 | if (!part->IsTextType()) { // Only consider text partitions |
847 | 0 | continue; |
848 | 0 | } |
849 | | // Only consider partitions in dominant font size or smaller |
850 | 0 | if (part->median_height() > kMaxTableCellXheight * global_median_xheight_) { |
851 | 0 | continue; |
852 | 0 | } |
853 | | // Mark partitions with a large gap, or no significant gap as |
854 | | // table partitions. |
855 | | // Comments: It produces several false alarms at: |
856 | | // - last line of a paragraph (fixed) |
857 | | // - single word section headings |
858 | | // - page headers and footers |
859 | | // - numbered equations |
860 | | // - line drawing regions |
861 | | // TODO(faisal): detect and fix above-mentioned cases |
862 | 0 | if (HasWideOrNoInterWordGap(part) || HasLeaderAdjacent(*part)) { |
863 | 0 | part->set_table_type(); |
864 | 0 | } |
865 | 0 | } |
866 | 0 | } |
867 | | |
868 | | // Check if the partition has at least one large gap between words or no |
869 | | // significant gap at all |
870 | 0 | bool TableFinder::HasWideOrNoInterWordGap(ColPartition *part) const { |
871 | | // Should only get text partitions. |
872 | 0 | ASSERT_HOST(part->IsTextType()); |
873 | | // Blob access |
874 | 0 | BLOBNBOX_CLIST *part_boxes = part->boxes(); |
875 | 0 | BLOBNBOX_C_IT it(part_boxes); |
876 | | // Check if this is a relatively small partition (such as a single word) |
877 | 0 | if (part->bounding_box().width() < |
878 | 0 | kMinBoxesInTextPartition * part->median_height() && |
879 | 0 | part_boxes->length() < kMinBoxesInTextPartition) { |
880 | 0 | return true; |
881 | 0 | } |
882 | | |
883 | | // Variables used to compute inter-blob spacing. |
884 | 0 | int previous_x1 = -1; |
885 | | // Stores the maximum gap detected. |
886 | 0 | int largest_partition_gap_found = -1; |
887 | | // Text partition gap limits. If this is text (and not a table), |
888 | | // there should be at least one gap larger than min_gap and no gap |
889 | | // larger than max_gap. |
890 | 0 | const double max_gap = kMaxGapInTextPartition * part->median_height(); |
891 | 0 | const double min_gap = kMinMaxGapInTextPartition * part->median_height(); |
892 | |
|
893 | 0 | for (it.mark_cycle_pt(); !it.cycled_list(); it.forward()) { |
894 | 0 | BLOBNBOX *blob = it.data(); |
895 | 0 | int current_x0 = blob->bounding_box().left(); |
896 | 0 | int current_x1 = blob->bounding_box().right(); |
897 | 0 | if (previous_x1 != -1) { |
898 | 0 | int gap = current_x0 - previous_x1; |
899 | | |
900 | | // TODO(nbeato): Boxes may overlap? Huh? |
901 | | // For example, mag.3B 8003_033.3B.tif in UNLV data. The titles/authors |
902 | | // on the top right of the page are filtered out with this line. |
903 | | // Note 2: Iterating over blobs in a partition, so we are looking for |
904 | | // spacing between the words. |
905 | 0 | if (gap < 0) { |
906 | | // More likely case, the blobs slightly overlap. This can happen |
907 | | // with diacritics (accents) or broken alphabet symbols (characters). |
908 | | // Merge boxes together by taking max of right sides. |
909 | 0 | if (-gap < part->median_height() * kMaxBlobOverlapFactor) { |
910 | 0 | previous_x1 = std::max(previous_x1, current_x1); |
911 | 0 | continue; |
912 | 0 | } |
913 | | // Extreme case, blobs overlap significantly in the same partition... |
914 | | // This should not happen often (if at all), but it does. |
915 | | // TODO(nbeato): investigate cases when this happens. |
916 | 0 | else { |
917 | | // The behavior before was to completely ignore this case. |
918 | 0 | } |
919 | 0 | } |
920 | | |
921 | | // If a large enough gap is found, mark it as a table cell (return true) |
922 | 0 | if (gap > max_gap) { |
923 | 0 | return true; |
924 | 0 | } |
925 | 0 | if (gap > largest_partition_gap_found) { |
926 | 0 | largest_partition_gap_found = gap; |
927 | 0 | } |
928 | 0 | } |
929 | 0 | previous_x1 = current_x1; |
930 | 0 | } |
931 | | // Since no large gap was found, return false if the partition is too |
932 | | // long to be a data cell |
933 | 0 | if (part->bounding_box().width() > |
934 | 0 | kMaxBoxesInDataPartition * part->median_height() || |
935 | 0 | part_boxes->length() > kMaxBoxesInDataPartition) { |
936 | 0 | return false; |
937 | 0 | } |
938 | | |
939 | | // A partition may be a single blob. In this case, it's an isolated symbol |
940 | | // or non-text (such as a ruling or image). |
941 | | // Detect these as table partitions? Shouldn't this be case by case? |
942 | | // The behavior before was to ignore this, making max_partition_gap < 0 |
943 | | // and implicitly return true. Just making it explicit. |
944 | 0 | if (largest_partition_gap_found == -1) { |
945 | 0 | return true; |
946 | 0 | } |
947 | | |
948 | | // return true if the maximum gap found is smaller than the minimum allowed |
949 | | // max_gap in a text partition. This indicates that there is no significant |
950 | | // space in the partition, hence it is likely a single word. |
951 | 0 | return largest_partition_gap_found < min_gap; |
952 | 0 | } |
953 | | |
954 | | // A criteria for possible tables is that a table may have leaders |
955 | | // between data cells. An aggressive solution to find such tables is to |
956 | | // explicitly mark partitions that have adjacent leaders. |
957 | | // Note that this includes overlapping leaders. However, it does not |
958 | | // include leaders in different columns on the page. |
959 | | // Possible false-positive will include lists, such as a table of contents. |
960 | | // As these arise, the aggressive nature of this search may need to be |
961 | | // trimmed down. |
962 | 0 | bool TableFinder::HasLeaderAdjacent(const ColPartition &part) { |
963 | 0 | if (part.flow() == BTFT_LEADER) { |
964 | 0 | return true; |
965 | 0 | } |
966 | | // Search range is left and right bounded by an offset of the |
967 | | // median xheight. This offset is to allow some tolerance to the |
968 | | // the leaders on the page in the event that the alignment is still |
969 | | // a bit off. |
970 | 0 | const TBOX &box = part.bounding_box(); |
971 | 0 | const int search_size = kAdjacentLeaderSearchPadding * global_median_xheight_; |
972 | 0 | const int top = box.top() + search_size; |
973 | 0 | const int bottom = box.bottom() - search_size; |
974 | 0 | ColPartitionGridSearch hsearch(&leader_and_ruling_grid_); |
975 | 0 | for (int direction = 0; direction < 2; ++direction) { |
976 | 0 | bool right_to_left = (direction == 0); |
977 | 0 | int x = right_to_left ? box.right() : box.left(); |
978 | 0 | hsearch.StartSideSearch(x, bottom, top); |
979 | 0 | ColPartition *leader = nullptr; |
980 | 0 | while ((leader = hsearch.NextSideSearch(right_to_left)) != nullptr) { |
981 | | // The leader could be a horizontal ruling in the grid. |
982 | | // Make sure it is actually a leader. |
983 | 0 | if (leader->flow() != BTFT_LEADER) { |
984 | 0 | continue; |
985 | 0 | } |
986 | | // This should not happen, they are in different grids. |
987 | 0 | ASSERT_HOST(&part != leader); |
988 | | // Make sure the leader shares a page column with the partition, |
989 | | // otherwise we are spreading across columns. |
990 | 0 | if (!part.IsInSameColumnAs(*leader)) { |
991 | 0 | break; |
992 | 0 | } |
993 | | // There should be a significant vertical overlap |
994 | 0 | if (!leader->VSignificantCoreOverlap(part)) { |
995 | 0 | continue; |
996 | 0 | } |
997 | | // Leader passed all tests, so it is adjacent. |
998 | 0 | return true; |
999 | 0 | } |
1000 | 0 | } |
1001 | | // No leaders are adjacent to the given partition. |
1002 | 0 | return false; |
1003 | 0 | } |
1004 | | |
1005 | | // Filter individual text partitions marked as table partitions |
1006 | | // consisting of paragraph endings, small section headings, and |
1007 | | // headers and footers. |
1008 | 0 | void TableFinder::FilterFalseAlarms() { |
1009 | 0 | FilterParagraphEndings(); |
1010 | 0 | FilterHeaderAndFooter(); |
1011 | | // TODO(nbeato): Fully justified text as non-table? |
1012 | 0 | } |
1013 | | |
1014 | 0 | void TableFinder::FilterParagraphEndings() { |
1015 | | // Detect last line of paragraph |
1016 | | // Iterate the ColPartitions in the grid. |
1017 | 0 | ColPartitionGridSearch gsearch(&clean_part_grid_); |
1018 | 0 | gsearch.StartFullSearch(); |
1019 | 0 | ColPartition *part = nullptr; |
1020 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1021 | 0 | if (part->type() != PT_TABLE) { |
1022 | 0 | continue; // Consider only table partitions |
1023 | 0 | } |
1024 | | |
1025 | | // Paragraph ending should have flowing text above it. |
1026 | 0 | ColPartition *upper_part = part->nearest_neighbor_above(); |
1027 | 0 | if (!upper_part) { |
1028 | 0 | continue; |
1029 | 0 | } |
1030 | 0 | if (upper_part->type() != PT_FLOWING_TEXT) { |
1031 | 0 | continue; |
1032 | 0 | } |
1033 | 0 | if (upper_part->bounding_box().width() < 2 * part->bounding_box().width()) { |
1034 | 0 | continue; |
1035 | 0 | } |
1036 | | // Check if its the last line of a paragraph. |
1037 | | // In most cases, a paragraph ending should be left-aligned to text line |
1038 | | // above it. Sometimes, it could be a 2 line paragraph, in which case |
1039 | | // the line above it is indented. |
1040 | | // To account for that, check if the partition center is to |
1041 | | // the left of the one above it. |
1042 | 0 | int mid = (part->bounding_box().left() + part->bounding_box().right()) / 2; |
1043 | 0 | int upper_mid = (upper_part->bounding_box().left() + |
1044 | 0 | upper_part->bounding_box().right()) / |
1045 | 0 | 2; |
1046 | 0 | int current_spacing = 0; // spacing of the current line to margin |
1047 | 0 | int upper_spacing = 0; // spacing of the previous line to the margin |
1048 | 0 | if (left_to_right_language_) { |
1049 | | // Left to right languages, use mid - left to figure out the distance |
1050 | | // the middle is from the left margin. |
1051 | 0 | int left = std::min(part->bounding_box().left(), |
1052 | 0 | upper_part->bounding_box().left()); |
1053 | 0 | current_spacing = mid - left; |
1054 | 0 | upper_spacing = upper_mid - left; |
1055 | 0 | } else { |
1056 | | // Right to left languages, use right - mid to figure out the distance |
1057 | | // the middle is from the right margin. |
1058 | 0 | int right = std::max(part->bounding_box().right(), |
1059 | 0 | upper_part->bounding_box().right()); |
1060 | 0 | current_spacing = right - mid; |
1061 | 0 | upper_spacing = right - upper_mid; |
1062 | 0 | } |
1063 | 0 | if (current_spacing * kParagraphEndingPreviousLineRatio > upper_spacing) { |
1064 | 0 | continue; |
1065 | 0 | } |
1066 | | |
1067 | | // Paragraphs should have similar fonts. |
1068 | 0 | if (!part->MatchingSizes(*upper_part) || |
1069 | 0 | !part->MatchingStrokeWidth(*upper_part, kStrokeWidthFractionalTolerance, |
1070 | 0 | kStrokeWidthConstantTolerance)) { |
1071 | 0 | continue; |
1072 | 0 | } |
1073 | | |
1074 | | // The last line of a paragraph should be left aligned. |
1075 | | // TODO(nbeato): This would be untrue if the text was right aligned. |
1076 | | // How often is that? |
1077 | 0 | if (part->space_to_left() > |
1078 | 0 | kMaxParagraphEndingLeftSpaceMultiple * part->median_height()) { |
1079 | 0 | continue; |
1080 | 0 | } |
1081 | | // The line above it should be right aligned (assuming justified format). |
1082 | | // Since we can't assume justified text, we compare whitespace to text. |
1083 | | // The above line should have majority spanning text (or the current |
1084 | | // line could have fit on the previous line). So compare |
1085 | | // whitespace to text. |
1086 | 0 | if (upper_part->bounding_box().width() < |
1087 | 0 | kMinParagraphEndingTextToWhitespaceRatio * |
1088 | 0 | upper_part->space_to_right()) { |
1089 | 0 | continue; |
1090 | 0 | } |
1091 | | |
1092 | | // Ledding above the line should be less than ledding below |
1093 | 0 | if (part->space_above() >= part->space_below() || |
1094 | 0 | part->space_above() > 2 * global_median_ledding_) { |
1095 | 0 | continue; |
1096 | 0 | } |
1097 | | |
1098 | | // If all checks failed, it is probably text. |
1099 | 0 | part->clear_table_type(); |
1100 | 0 | } |
1101 | 0 | } |
1102 | | |
1103 | 0 | void TableFinder::FilterHeaderAndFooter() { |
1104 | | // Consider top-most text colpartition as header and bottom most as footer |
1105 | 0 | ColPartition *header = nullptr; |
1106 | 0 | ColPartition *footer = nullptr; |
1107 | 0 | int max_top = INT32_MIN; |
1108 | 0 | int min_bottom = INT32_MAX; |
1109 | 0 | ColPartitionGridSearch gsearch(&clean_part_grid_); |
1110 | 0 | gsearch.StartFullSearch(); |
1111 | 0 | ColPartition *part = nullptr; |
1112 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1113 | 0 | if (!part->IsTextType()) { |
1114 | 0 | continue; // Consider only text partitions |
1115 | 0 | } |
1116 | 0 | int top = part->bounding_box().top(); |
1117 | 0 | int bottom = part->bounding_box().bottom(); |
1118 | 0 | if (top > max_top) { |
1119 | 0 | max_top = top; |
1120 | 0 | header = part; |
1121 | 0 | } |
1122 | 0 | if (bottom < min_bottom) { |
1123 | 0 | min_bottom = bottom; |
1124 | 0 | footer = part; |
1125 | 0 | } |
1126 | 0 | } |
1127 | 0 | if (header) { |
1128 | 0 | header->clear_table_type(); |
1129 | 0 | } |
1130 | 0 | if (footer) { |
1131 | 0 | footer->clear_table_type(); |
1132 | 0 | } |
1133 | 0 | } |
1134 | | |
1135 | | // Mark all ColPartitions as table cells that have a table cell above |
1136 | | // and below them |
1137 | | // TODO(faisal): This is too aggressive at the moment. The method needs to |
1138 | | // consider spacing and alignment as well. Detection of false alarm table cells |
1139 | | // should also be done as part of it. |
1140 | 0 | void TableFinder::SmoothTablePartitionRuns() { |
1141 | | // Iterate the ColPartitions in the grid. |
1142 | 0 | ColPartitionGridSearch gsearch(&clean_part_grid_); |
1143 | 0 | gsearch.StartFullSearch(); |
1144 | 0 | ColPartition *part = nullptr; |
1145 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1146 | 0 | if (part->type() >= PT_TABLE || part->type() == PT_UNKNOWN) { |
1147 | 0 | continue; // Consider only text partitions |
1148 | 0 | } |
1149 | 0 | ColPartition *upper_part = part->nearest_neighbor_above(); |
1150 | 0 | ColPartition *lower_part = part->nearest_neighbor_below(); |
1151 | 0 | if (!upper_part || !lower_part) { |
1152 | 0 | continue; |
1153 | 0 | } |
1154 | 0 | if (upper_part->type() == PT_TABLE && lower_part->type() == PT_TABLE) { |
1155 | 0 | part->set_table_type(); |
1156 | 0 | } |
1157 | 0 | } |
1158 | | |
1159 | | // Pass 2, do the opposite. If both the upper and lower neighbors |
1160 | | // exist and are not tables, this probably shouldn't be a table. |
1161 | 0 | gsearch.StartFullSearch(); |
1162 | 0 | part = nullptr; |
1163 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1164 | 0 | if (part->type() != PT_TABLE) { |
1165 | 0 | continue; // Consider only text partitions |
1166 | 0 | } |
1167 | 0 | ColPartition *upper_part = part->nearest_neighbor_above(); |
1168 | 0 | ColPartition *lower_part = part->nearest_neighbor_below(); |
1169 | | |
1170 | | // table can't be by itself |
1171 | 0 | if ((upper_part && upper_part->type() != PT_TABLE) && |
1172 | 0 | (lower_part && lower_part->type() != PT_TABLE)) { |
1173 | 0 | part->clear_table_type(); |
1174 | 0 | } |
1175 | 0 | } |
1176 | 0 | } |
1177 | | |
1178 | | // Set the type of a column segment based on the ratio of table to text cells |
1179 | 0 | void TableFinder::SetColumnsType(ColSegment_LIST *column_blocks) { |
1180 | 0 | ColSegment_IT it(column_blocks); |
1181 | 0 | for (it.mark_cycle_pt(); !it.cycled_list(); it.forward()) { |
1182 | 0 | ColSegment *seg = it.data(); |
1183 | 0 | TBOX box = seg->bounding_box(); |
1184 | 0 | int num_table_cells = 0; |
1185 | 0 | int num_text_cells = 0; |
1186 | 0 | ColPartitionGridSearch rsearch(&clean_part_grid_); |
1187 | 0 | rsearch.SetUniqueMode(true); |
1188 | 0 | rsearch.StartRectSearch(box); |
1189 | 0 | ColPartition *part = nullptr; |
1190 | 0 | while ((part = rsearch.NextRectSearch()) != nullptr) { |
1191 | 0 | if (part->type() == PT_TABLE) { |
1192 | 0 | num_table_cells++; |
1193 | 0 | } else if (part->type() == PT_FLOWING_TEXT) { |
1194 | 0 | num_text_cells++; |
1195 | 0 | } |
1196 | 0 | } |
1197 | | // If a column block has no text or table partition in it, it is not needed |
1198 | | // for table detection. |
1199 | 0 | if (!num_table_cells && !num_text_cells) { |
1200 | 0 | delete it.extract(); |
1201 | 0 | } else { |
1202 | 0 | seg->set_num_table_cells(num_table_cells); |
1203 | 0 | seg->set_num_text_cells(num_text_cells); |
1204 | | // set column type based on the ratio of table to text cells |
1205 | 0 | seg->set_type(); |
1206 | 0 | } |
1207 | 0 | } |
1208 | 0 | } |
1209 | | |
1210 | | // Move column blocks to grid |
1211 | | void TableFinder::MoveColSegmentsToGrid(ColSegment_LIST *segments, |
1212 | 0 | ColSegmentGrid *col_seg_grid) { |
1213 | 0 | ColSegment_IT it(segments); |
1214 | 0 | for (it.mark_cycle_pt(); !it.cycled_list(); it.forward()) { |
1215 | 0 | ColSegment *seg = it.extract(); |
1216 | 0 | col_seg_grid->InsertBBox(true, true, seg); |
1217 | 0 | } |
1218 | 0 | } |
1219 | | |
1220 | | // Merge column blocks if a split is detected due to the presence of a |
1221 | | // table. A text block is considered split if it has multiple |
1222 | | // neighboring blocks above/below it, and at least one of the |
1223 | | // neighboring blocks is of table type (has a high density of table |
1224 | | // partitions). In this case neighboring blocks in the direction |
1225 | | // (above/below) of the table block are merged with the text block. |
1226 | | |
1227 | | // Comment: This method does not handle split due to a full page table |
1228 | | // since table columns in this case do not have a text column on which |
1229 | | // split decision can be based. |
1230 | 0 | void TableFinder::GridMergeColumnBlocks() { |
1231 | 0 | int margin = gridsize(); |
1232 | | |
1233 | | // Iterate the Column Blocks in the grid. |
1234 | 0 | GridSearch<ColSegment, ColSegment_CLIST, ColSegment_C_IT> gsearch( |
1235 | 0 | &col_seg_grid_); |
1236 | 0 | gsearch.StartFullSearch(); |
1237 | 0 | ColSegment *seg; |
1238 | 0 | while ((seg = gsearch.NextFullSearch()) != nullptr) { |
1239 | 0 | if (seg->type() != COL_TEXT) { |
1240 | 0 | continue; // only consider text blocks for split detection |
1241 | 0 | } |
1242 | 0 | bool neighbor_found = false; |
1243 | 0 | bool modified = false; // Modified at least once |
1244 | | // keep expanding current box as long as neighboring table columns |
1245 | | // are found above or below it. |
1246 | 0 | do { |
1247 | 0 | TBOX box = seg->bounding_box(); |
1248 | | // slightly expand the search region vertically |
1249 | 0 | int top_range = |
1250 | 0 | std::min(box.top() + margin, static_cast<int>(tright().y())); |
1251 | 0 | int bottom_range = |
1252 | 0 | std::max(box.bottom() - margin, static_cast<int>(bleft().y())); |
1253 | 0 | box.set_top(top_range); |
1254 | 0 | box.set_bottom(bottom_range); |
1255 | 0 | neighbor_found = false; |
1256 | 0 | GridSearch<ColSegment, ColSegment_CLIST, ColSegment_C_IT> rectsearch( |
1257 | 0 | &col_seg_grid_); |
1258 | 0 | rectsearch.StartRectSearch(box); |
1259 | 0 | ColSegment *neighbor = nullptr; |
1260 | 0 | while ((neighbor = rectsearch.NextRectSearch()) != nullptr) { |
1261 | 0 | if (neighbor == seg) { |
1262 | 0 | continue; |
1263 | 0 | } |
1264 | 0 | const TBOX &neighbor_box = neighbor->bounding_box(); |
1265 | | // If the neighbor box significantly overlaps with the current |
1266 | | // box (due to the expansion of the current box in the |
1267 | | // previous iteration of this loop), remove the neighbor box |
1268 | | // and expand the current box to include it. |
1269 | 0 | if (neighbor_box.overlap_fraction(box) >= 0.9) { |
1270 | 0 | seg->InsertBox(neighbor_box); |
1271 | 0 | modified = true; |
1272 | 0 | rectsearch.RemoveBBox(); |
1273 | 0 | gsearch.RepositionIterator(); |
1274 | 0 | delete neighbor; |
1275 | 0 | continue; |
1276 | 0 | } |
1277 | | // Only expand if the neighbor box is of table type |
1278 | 0 | if (neighbor->type() != COL_TABLE) { |
1279 | 0 | continue; |
1280 | 0 | } |
1281 | | // Insert the neighbor box into the current column block |
1282 | 0 | if (neighbor_box.major_x_overlap(box) && !box.contains(neighbor_box)) { |
1283 | 0 | seg->InsertBox(neighbor_box); |
1284 | 0 | neighbor_found = true; |
1285 | 0 | modified = true; |
1286 | 0 | rectsearch.RemoveBBox(); |
1287 | 0 | gsearch.RepositionIterator(); |
1288 | 0 | delete neighbor; |
1289 | 0 | } |
1290 | 0 | } |
1291 | 0 | } while (neighbor_found); |
1292 | 0 | if (modified) { |
1293 | | // Because the box has changed, it has to be removed first. |
1294 | 0 | gsearch.RemoveBBox(); |
1295 | 0 | col_seg_grid_.InsertBBox(true, true, seg); |
1296 | 0 | gsearch.RepositionIterator(); |
1297 | 0 | } |
1298 | 0 | } |
1299 | 0 | } |
1300 | | |
1301 | | // Group horizontally overlapping table partitions into table columns. |
1302 | | // TODO(faisal): This is too aggressive at the moment. The method should |
1303 | | // consider more attributes to group table partitions together. Some common |
1304 | | // errors are: |
1305 | | // 1- page number is merged with a table column above it even |
1306 | | // if there is a large vertical gap between them. |
1307 | | // 2- column headers go on to catch one of the columns arbitrarily |
1308 | | // 3- an isolated noise blob near page top or bottom merges with the table |
1309 | | // column below/above it |
1310 | | // 4- cells from two vertically adjacent tables merge together to make a |
1311 | | // single column resulting in merging of the two tables |
1312 | 0 | void TableFinder::GetTableColumns(ColSegment_LIST *table_columns) { |
1313 | 0 | ColSegment_IT it(table_columns); |
1314 | | // Iterate the ColPartitions in the grid. |
1315 | 0 | ColPartitionGridSearch gsearch(&clean_part_grid_); |
1316 | 0 | gsearch.StartFullSearch(); |
1317 | 0 | ColPartition *part; |
1318 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1319 | 0 | if (part->inside_table_column() || part->type() != PT_TABLE) { |
1320 | 0 | continue; // prevent a partition to be assigned to multiple columns |
1321 | 0 | } |
1322 | 0 | const TBOX &box = part->bounding_box(); |
1323 | 0 | auto *col = new ColSegment(); |
1324 | 0 | col->InsertBox(box); |
1325 | 0 | part->set_inside_table_column(true); |
1326 | | // Start a search below the current cell to find bottom neighbours |
1327 | | // Note: a full search will always process things above it first, so |
1328 | | // this should be starting at the highest cell and working its way down. |
1329 | 0 | ColPartitionGridSearch vsearch(&clean_part_grid_); |
1330 | 0 | vsearch.StartVerticalSearch(box.left(), box.right(), box.bottom()); |
1331 | 0 | ColPartition *neighbor = nullptr; |
1332 | 0 | bool found_neighbours = false; |
1333 | 0 | while ((neighbor = vsearch.NextVerticalSearch(true)) != nullptr) { |
1334 | | // only consider neighbors not assigned to any column yet |
1335 | 0 | if (neighbor->inside_table_column()) { |
1336 | 0 | continue; |
1337 | 0 | } |
1338 | | // Horizontal lines should not break the flow |
1339 | 0 | if (neighbor->IsHorizontalLine()) { |
1340 | 0 | continue; |
1341 | 0 | } |
1342 | | // presence of a non-table neighbor marks the end of current |
1343 | | // table column |
1344 | 0 | if (neighbor->type() != PT_TABLE) { |
1345 | 0 | break; |
1346 | 0 | } |
1347 | | // add the neighbor partition to the table column |
1348 | 0 | const TBOX &neighbor_box = neighbor->bounding_box(); |
1349 | 0 | col->InsertBox(neighbor_box); |
1350 | 0 | neighbor->set_inside_table_column(true); |
1351 | 0 | found_neighbours = true; |
1352 | 0 | } |
1353 | 0 | if (found_neighbours) { |
1354 | 0 | it.add_after_then_move(col); |
1355 | 0 | } else { |
1356 | 0 | part->set_inside_table_column(false); |
1357 | 0 | delete col; |
1358 | 0 | } |
1359 | 0 | } |
1360 | 0 | } |
1361 | | |
1362 | | // Mark regions in a column that are x-bounded by the column boundaries and |
1363 | | // y-bounded by the table columns' projection on the y-axis as table regions |
1364 | | void TableFinder::GetTableRegions(ColSegment_LIST *table_columns, |
1365 | 0 | ColSegment_LIST *table_regions) { |
1366 | 0 | ColSegment_IT cit(table_columns); |
1367 | 0 | ColSegment_IT rit(table_regions); |
1368 | | // Iterate through column blocks |
1369 | 0 | GridSearch<ColSegment, ColSegment_CLIST, ColSegment_C_IT> gsearch( |
1370 | 0 | &col_seg_grid_); |
1371 | 0 | gsearch.StartFullSearch(); |
1372 | 0 | ColSegment *part; |
1373 | 0 | int page_height = tright().y() - bleft().y(); |
1374 | 0 | ASSERT_HOST(page_height > 0); |
1375 | | // create a bool array to hold projection on y-axis |
1376 | 0 | bool *table_region = new bool[page_height]; |
1377 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1378 | 0 | const TBOX &part_box = part->bounding_box(); |
1379 | | // reset the projection array |
1380 | 0 | for (int i = 0; i < page_height; i++) { |
1381 | 0 | table_region[i] = false; |
1382 | 0 | } |
1383 | | // iterate through all table columns to find regions in the current |
1384 | | // page column block |
1385 | 0 | cit.move_to_first(); |
1386 | 0 | for (cit.mark_cycle_pt(); !cit.cycled_list(); cit.forward()) { |
1387 | 0 | TBOX col_box = cit.data()->bounding_box(); |
1388 | | // find intersection region of table column and page column |
1389 | 0 | TBOX intersection_box = col_box.intersection(part_box); |
1390 | | // project table column on the y-axis |
1391 | 0 | for (int i = intersection_box.bottom(); i < intersection_box.top(); i++) { |
1392 | 0 | table_region[i - bleft().y()] = true; |
1393 | 0 | } |
1394 | 0 | } |
1395 | | // set x-limits of table regions to page column width |
1396 | 0 | TBOX current_table_box; |
1397 | 0 | current_table_box.set_left(part_box.left()); |
1398 | 0 | current_table_box.set_right(part_box.right()); |
1399 | | // go through the y-axis projection to find runs of table |
1400 | | // regions. Each run makes one table region. |
1401 | 0 | for (int i = 1; i < page_height; i++) { |
1402 | | // detect start of a table region |
1403 | 0 | if (!table_region[i - 1] && table_region[i]) { |
1404 | 0 | current_table_box.set_bottom(i + bleft().y()); |
1405 | 0 | } |
1406 | | // TODO(nbeato): Is it guaranteed that the last row is not a table region? |
1407 | | // detect end of a table region |
1408 | 0 | if (table_region[i - 1] && !table_region[i]) { |
1409 | 0 | current_table_box.set_top(i + bleft().y()); |
1410 | 0 | if (!current_table_box.null_box()) { |
1411 | 0 | auto *seg = new ColSegment(); |
1412 | 0 | seg->InsertBox(current_table_box); |
1413 | 0 | rit.add_after_then_move(seg); |
1414 | 0 | } |
1415 | 0 | } |
1416 | 0 | } |
1417 | 0 | } |
1418 | 0 | delete[] table_region; |
1419 | 0 | } |
1420 | | |
1421 | | // Merge table regions corresponding to tables spanning multiple columns if |
1422 | | // there is a colpartition (horizontal ruling line or normal text) that |
1423 | | // touches both regions. |
1424 | | // TODO(faisal): A rare error occurs if there are two horizontally adjacent |
1425 | | // tables with aligned ruling lines. In this case, line finder returns a |
1426 | | // single line and hence the tables get merged together |
1427 | 0 | void TableFinder::GridMergeTableRegions() { |
1428 | | // Iterate the table regions in the grid. |
1429 | 0 | GridSearch<ColSegment, ColSegment_CLIST, ColSegment_C_IT> gsearch( |
1430 | 0 | &table_grid_); |
1431 | 0 | gsearch.StartFullSearch(); |
1432 | 0 | ColSegment *seg = nullptr; |
1433 | 0 | while ((seg = gsearch.NextFullSearch()) != nullptr) { |
1434 | 0 | bool neighbor_found = false; |
1435 | 0 | bool modified = false; // Modified at least once |
1436 | 0 | do { |
1437 | | // Start a rectangle search x-bounded by the image and y by the table |
1438 | 0 | const TBOX &box = seg->bounding_box(); |
1439 | 0 | TBOX search_region(box); |
1440 | 0 | search_region.set_left(bleft().x()); |
1441 | 0 | search_region.set_right(tright().x()); |
1442 | 0 | neighbor_found = false; |
1443 | 0 | GridSearch<ColSegment, ColSegment_CLIST, ColSegment_C_IT> rectsearch( |
1444 | 0 | &table_grid_); |
1445 | 0 | rectsearch.StartRectSearch(search_region); |
1446 | 0 | ColSegment *neighbor = nullptr; |
1447 | 0 | while ((neighbor = rectsearch.NextRectSearch()) != nullptr) { |
1448 | 0 | if (neighbor == seg) { |
1449 | 0 | continue; |
1450 | 0 | } |
1451 | 0 | const TBOX &neighbor_box = neighbor->bounding_box(); |
1452 | | // Check if a neighbor box has a large overlap with the table |
1453 | | // region. This may happen as a result of merging two table |
1454 | | // regions in the previous iteration. |
1455 | 0 | if (neighbor_box.overlap_fraction(box) >= 0.9) { |
1456 | 0 | seg->InsertBox(neighbor_box); |
1457 | 0 | rectsearch.RemoveBBox(); |
1458 | 0 | gsearch.RepositionIterator(); |
1459 | 0 | delete neighbor; |
1460 | 0 | modified = true; |
1461 | 0 | continue; |
1462 | 0 | } |
1463 | | // Check if two table regions belong together based on a common |
1464 | | // horizontal ruling line |
1465 | 0 | if (BelongToOneTable(box, neighbor_box)) { |
1466 | 0 | seg->InsertBox(neighbor_box); |
1467 | 0 | neighbor_found = true; |
1468 | 0 | modified = true; |
1469 | 0 | rectsearch.RemoveBBox(); |
1470 | 0 | gsearch.RepositionIterator(); |
1471 | 0 | delete neighbor; |
1472 | 0 | } |
1473 | 0 | } |
1474 | 0 | } while (neighbor_found); |
1475 | 0 | if (modified) { |
1476 | | // Because the box has changed, it has to be removed first. |
1477 | 0 | gsearch.RemoveBBox(); |
1478 | 0 | table_grid_.InsertBBox(true, true, seg); |
1479 | 0 | gsearch.RepositionIterator(); |
1480 | 0 | } |
1481 | 0 | } |
1482 | 0 | } |
1483 | | |
1484 | | // Decide if two table regions belong to one table based on a common |
1485 | | // horizontal ruling line or another colpartition |
1486 | 0 | bool TableFinder::BelongToOneTable(const TBOX &box1, const TBOX &box2) { |
1487 | | // Check the obvious case. Most likely not true because overlapping boxes |
1488 | | // should already be merged, but seems like a good thing to do in case things |
1489 | | // change. |
1490 | 0 | if (box1.overlap(box2)) { |
1491 | 0 | return true; |
1492 | 0 | } |
1493 | | // Check for ColPartitions spanning both table regions |
1494 | 0 | TBOX bbox = box1.bounding_union(box2); |
1495 | | // Start a rect search on bbox |
1496 | 0 | ColPartitionGridSearch rectsearch(&clean_part_grid_); |
1497 | 0 | rectsearch.StartRectSearch(bbox); |
1498 | 0 | ColPartition *part = nullptr; |
1499 | 0 | while ((part = rectsearch.NextRectSearch()) != nullptr) { |
1500 | 0 | const TBOX &part_box = part->bounding_box(); |
1501 | | // return true if a colpartition spanning both table regions is found |
1502 | 0 | if (part_box.overlap(box1) && part_box.overlap(box2) && |
1503 | 0 | !part->IsImageType()) { |
1504 | 0 | return true; |
1505 | 0 | } |
1506 | 0 | } |
1507 | 0 | return false; |
1508 | 0 | } |
1509 | | |
1510 | | // Adjust table boundaries by: |
1511 | | // - building a tight bounding box around all ColPartitions contained in it. |
1512 | | // - expanding table boundaries to include all colpartitions that overlap the |
1513 | | // table by more than half of their area |
1514 | | // - expanding table boundaries to include nearby horizontal rule lines |
1515 | | // - expanding table vertically to include left out column headers |
1516 | | // TODO(faisal): Expansion of table boundaries is quite aggressive. It usually |
1517 | | // makes following errors: |
1518 | | // 1- horizontal lines consisting of underlines are included in the table if |
1519 | | // they are close enough |
1520 | | // 2- horizontal lines originating from noise tend to get merged with a table |
1521 | | // near the top of the page |
1522 | | // 3- the criteria for including horizontal lines is very generous. Many times |
1523 | | // horizontal lines separating headers and footers get merged with a |
1524 | | // single-column table in a multi-column page thereby including text |
1525 | | // from the neighboring column inside the table |
1526 | | // 4- the criteria for including left out column headers also tends to |
1527 | | // occasionally include text-lines above the tables, typically from |
1528 | | // table caption |
1529 | 0 | void TableFinder::AdjustTableBoundaries() { |
1530 | | // Iterate the table regions in the grid |
1531 | 0 | ColSegment_CLIST adjusted_tables; |
1532 | 0 | ColSegment_C_IT it(&adjusted_tables); |
1533 | 0 | ColSegmentGridSearch gsearch(&table_grid_); |
1534 | 0 | gsearch.StartFullSearch(); |
1535 | 0 | ColSegment *table = nullptr; |
1536 | 0 | while ((table = gsearch.NextFullSearch()) != nullptr) { |
1537 | 0 | const TBOX &table_box = table->bounding_box(); |
1538 | 0 | TBOX grown_box = table_box; |
1539 | 0 | GrowTableBox(table_box, &grown_box); |
1540 | | // To prevent a table from expanding again, do not insert the |
1541 | | // modified box back to the grid. Instead move it to a list and |
1542 | | // and remove it from the grid. The list is moved later back to the grid. |
1543 | 0 | if (!grown_box.null_box()) { |
1544 | 0 | auto *col = new ColSegment(); |
1545 | 0 | col->InsertBox(grown_box); |
1546 | 0 | it.add_after_then_move(col); |
1547 | 0 | } |
1548 | 0 | gsearch.RemoveBBox(); |
1549 | 0 | delete table; |
1550 | 0 | } |
1551 | | // clear table grid to move final tables in it |
1552 | | // TODO(nbeato): table_grid_ should already be empty. The above loop |
1553 | | // removed everything. Maybe just assert it is empty? |
1554 | 0 | table_grid_.Clear(); |
1555 | 0 | it.move_to_first(); |
1556 | | // move back final tables to table_grid_ |
1557 | 0 | for (it.mark_cycle_pt(); !it.cycled_list(); it.forward()) { |
1558 | 0 | ColSegment *seg = it.extract(); |
1559 | 0 | table_grid_.InsertBBox(true, true, seg); |
1560 | 0 | } |
1561 | 0 | } |
1562 | | |
1563 | 0 | void TableFinder::GrowTableBox(const TBOX &table_box, TBOX *result_box) { |
1564 | | // TODO(nbeato): The growing code is a bit excessive right now. |
1565 | | // By removing these lines, the partitions considered need |
1566 | | // to have some overlap or be special cases. These lines could |
1567 | | // be added again once a check is put in place to make sure that |
1568 | | // growing tables don't stomp on a lot of non-table partitions. |
1569 | | |
1570 | | // search for horizontal ruling lines within the vertical margin |
1571 | | // int vertical_margin = kRulingVerticalMargin * gridsize(); |
1572 | 0 | TBOX search_box = table_box; |
1573 | | // int top = MIN(search_box.top() + vertical_margin, tright().y()); |
1574 | | // int bottom = MAX(search_box.bottom() - vertical_margin, bleft().y()); |
1575 | | // search_box.set_top(top); |
1576 | | // search_box.set_bottom(bottom); |
1577 | |
|
1578 | 0 | GrowTableToIncludePartials(table_box, search_box, result_box); |
1579 | 0 | GrowTableToIncludeLines(table_box, search_box, result_box); |
1580 | 0 | IncludeLeftOutColumnHeaders(result_box); |
1581 | 0 | } |
1582 | | |
1583 | | // Grow a table by increasing the size of the box to include |
1584 | | // partitions with significant overlap with the table. |
1585 | | void TableFinder::GrowTableToIncludePartials(const TBOX &table_box, |
1586 | | const TBOX &search_range, |
1587 | 0 | TBOX *result_box) { |
1588 | | // Rulings are in a different grid, so search 2 grids for rulings, text, |
1589 | | // and table partitions that are not entirely within the new box. |
1590 | 0 | for (int i = 0; i < 2; ++i) { |
1591 | 0 | ColPartitionGrid *grid = |
1592 | 0 | (i == 0) ? &fragmented_text_grid_ : &leader_and_ruling_grid_; |
1593 | 0 | ColPartitionGridSearch rectsearch(grid); |
1594 | 0 | rectsearch.StartRectSearch(search_range); |
1595 | 0 | ColPartition *part = nullptr; |
1596 | 0 | while ((part = rectsearch.NextRectSearch()) != nullptr) { |
1597 | | // Only include text and table types. |
1598 | 0 | if (part->IsImageType()) { |
1599 | 0 | continue; |
1600 | 0 | } |
1601 | 0 | const TBOX &part_box = part->bounding_box(); |
1602 | | // Include partition in the table if more than half of it |
1603 | | // is covered by the table |
1604 | 0 | if (part_box.overlap_fraction(table_box) > kMinOverlapWithTable) { |
1605 | 0 | *result_box = result_box->bounding_union(part_box); |
1606 | 0 | continue; |
1607 | 0 | } |
1608 | 0 | } |
1609 | 0 | } |
1610 | 0 | } |
1611 | | |
1612 | | // Grow a table by expanding to the extents of significantly |
1613 | | // overlapping lines. |
1614 | | void TableFinder::GrowTableToIncludeLines(const TBOX &table_box, |
1615 | | const TBOX &search_range, |
1616 | 0 | TBOX *result_box) { |
1617 | 0 | ColPartitionGridSearch rsearch(&leader_and_ruling_grid_); |
1618 | 0 | rsearch.SetUniqueMode(true); |
1619 | 0 | rsearch.StartRectSearch(search_range); |
1620 | 0 | ColPartition *part = nullptr; |
1621 | 0 | while ((part = rsearch.NextRectSearch()) != nullptr) { |
1622 | | // TODO(nbeato) This should also do vertical, but column |
1623 | | // boundaries are breaking things. This function needs to be |
1624 | | // updated to allow vertical lines as well. |
1625 | 0 | if (!part->IsLineType()) { |
1626 | 0 | continue; |
1627 | 0 | } |
1628 | | // Avoid the following function call if the result of the |
1629 | | // function is irrelevant. |
1630 | 0 | const TBOX &part_box = part->bounding_box(); |
1631 | 0 | if (result_box->contains(part_box)) { |
1632 | 0 | continue; |
1633 | 0 | } |
1634 | | // Include a partially overlapping horizontal line only if the |
1635 | | // extra ColPartitions that will be included due to expansion |
1636 | | // have large side spacing w.r.t. columns containing them. |
1637 | 0 | if (HLineBelongsToTable(*part, table_box)) { |
1638 | 0 | *result_box = result_box->bounding_union(part_box); |
1639 | 0 | } |
1640 | | // TODO(nbeato): Vertical |
1641 | 0 | } |
1642 | 0 | } |
1643 | | |
1644 | | // Checks whether the horizontal line belong to the table by looking at the |
1645 | | // side spacing of extra ColPartitions that will be included in the table |
1646 | | // due to expansion |
1647 | | bool TableFinder::HLineBelongsToTable(const ColPartition &part, |
1648 | 0 | const TBOX &table_box) { |
1649 | 0 | if (!part.IsHorizontalLine()) { |
1650 | 0 | return false; |
1651 | 0 | } |
1652 | 0 | const TBOX &part_box = part.bounding_box(); |
1653 | 0 | if (!part_box.major_x_overlap(table_box)) { |
1654 | 0 | return false; |
1655 | 0 | } |
1656 | | // Do not consider top-most horizontal line since it usually |
1657 | | // originates from noise. |
1658 | | // TODO(nbeato): I had to comment this out because the ruling grid doesn't |
1659 | | // have neighbors solved. |
1660 | | // if (!part.nearest_neighbor_above()) |
1661 | | // return false; |
1662 | 0 | const TBOX bbox = part_box.bounding_union(table_box); |
1663 | | // In the "unioned table" box (the table extents expanded by the line), |
1664 | | // keep track of how many partitions have significant padding to the left |
1665 | | // and right. If more than half of the partitions covered by the new table |
1666 | | // have significant spacing, the line belongs to the table and the table |
1667 | | // grows to include all of the partitions. |
1668 | 0 | int num_extra_partitions = 0; |
1669 | 0 | int extra_space_to_right = 0; |
1670 | 0 | int extra_space_to_left = 0; |
1671 | | // Rulings are in a different grid, so search 2 grids for rulings, text, |
1672 | | // and table partitions that are introduced by the new box. |
1673 | 0 | for (int i = 0; i < 2; ++i) { |
1674 | 0 | ColPartitionGrid *grid = |
1675 | 0 | (i == 0) ? &clean_part_grid_ : &leader_and_ruling_grid_; |
1676 | | // Start a rect search on bbox |
1677 | 0 | ColPartitionGridSearch rectsearch(grid); |
1678 | 0 | rectsearch.SetUniqueMode(true); |
1679 | 0 | rectsearch.StartRectSearch(bbox); |
1680 | 0 | ColPartition *extra_part = nullptr; |
1681 | 0 | while ((extra_part = rectsearch.NextRectSearch()) != nullptr) { |
1682 | | // ColPartition already in table |
1683 | 0 | const TBOX &extra_part_box = extra_part->bounding_box(); |
1684 | 0 | if (extra_part_box.overlap_fraction(table_box) > kMinOverlapWithTable) { |
1685 | 0 | continue; |
1686 | 0 | } |
1687 | | // Non-text ColPartitions do not contribute |
1688 | 0 | if (extra_part->IsImageType()) { |
1689 | 0 | continue; |
1690 | 0 | } |
1691 | | // Consider this partition. |
1692 | 0 | num_extra_partitions++; |
1693 | | // presence of a table cell is a strong hint, so just increment the scores |
1694 | | // without looking at the spacing. |
1695 | 0 | if (extra_part->type() == PT_TABLE || extra_part->IsLineType()) { |
1696 | 0 | extra_space_to_right++; |
1697 | 0 | extra_space_to_left++; |
1698 | 0 | continue; |
1699 | 0 | } |
1700 | 0 | int space_threshold = kSideSpaceMargin * part.median_height(); |
1701 | 0 | if (extra_part->space_to_right() > space_threshold) { |
1702 | 0 | extra_space_to_right++; |
1703 | 0 | } |
1704 | 0 | if (extra_part->space_to_left() > space_threshold) { |
1705 | 0 | extra_space_to_left++; |
1706 | 0 | } |
1707 | 0 | } |
1708 | 0 | } |
1709 | | // tprintf("%d %d %d\n", |
1710 | | // num_extra_partitions,extra_space_to_right,extra_space_to_left); |
1711 | 0 | return (extra_space_to_right > num_extra_partitions / 2) || |
1712 | 0 | (extra_space_to_left > num_extra_partitions / 2); |
1713 | 0 | } |
1714 | | |
1715 | | // Look for isolated column headers above the given table box and |
1716 | | // include them in the table |
1717 | 0 | void TableFinder::IncludeLeftOutColumnHeaders(TBOX *table_box) { |
1718 | | // Start a search above the current table to look for column headers |
1719 | 0 | ColPartitionGridSearch vsearch(&clean_part_grid_); |
1720 | 0 | vsearch.StartVerticalSearch(table_box->left(), table_box->right(), |
1721 | 0 | table_box->top()); |
1722 | 0 | ColPartition *neighbor = nullptr; |
1723 | 0 | ColPartition *previous_neighbor = nullptr; |
1724 | 0 | while ((neighbor = vsearch.NextVerticalSearch(false)) != nullptr) { |
1725 | | // Max distance to find a table heading. |
1726 | 0 | const int max_distance = |
1727 | 0 | kMaxColumnHeaderDistance * neighbor->median_height(); |
1728 | 0 | int table_top = table_box->top(); |
1729 | 0 | const TBOX &box = neighbor->bounding_box(); |
1730 | | // Do not continue if the next box is way above |
1731 | 0 | if (box.bottom() - table_top > max_distance) { |
1732 | 0 | break; |
1733 | 0 | } |
1734 | | // Unconditionally include partitions of type TABLE or LINE |
1735 | | // TODO(faisal): add some reasonable conditions here |
1736 | 0 | if (neighbor->type() == PT_TABLE || neighbor->IsLineType()) { |
1737 | 0 | table_box->set_top(box.top()); |
1738 | 0 | previous_neighbor = nullptr; |
1739 | 0 | continue; |
1740 | 0 | } |
1741 | | // If there are two text partitions, one above the other, without a table |
1742 | | // cell on their left or right side, consider them a barrier and quit |
1743 | 0 | if (previous_neighbor == nullptr) { |
1744 | 0 | previous_neighbor = neighbor; |
1745 | 0 | } else { |
1746 | 0 | const TBOX &previous_box = previous_neighbor->bounding_box(); |
1747 | 0 | if (!box.major_y_overlap(previous_box)) { |
1748 | 0 | break; |
1749 | 0 | } |
1750 | 0 | } |
1751 | 0 | } |
1752 | 0 | } |
1753 | | |
1754 | | // Remove false alarms consisting of a single column based on their |
1755 | | // projection on the x-axis. Projection of a real table on the x-axis |
1756 | | // should have at least one zero-valley larger than the global median |
1757 | | // x-height of the page. |
1758 | 0 | void TableFinder::DeleteSingleColumnTables() { |
1759 | 0 | int page_width = tright().x() - bleft().x(); |
1760 | 0 | ASSERT_HOST(page_width > 0); |
1761 | | // create an integer array to hold projection on x-axis |
1762 | 0 | int *table_xprojection = new int[page_width]; |
1763 | | // Iterate through all tables in the table grid |
1764 | 0 | GridSearch<ColSegment, ColSegment_CLIST, ColSegment_C_IT> table_search( |
1765 | 0 | &table_grid_); |
1766 | 0 | table_search.StartFullSearch(); |
1767 | 0 | ColSegment *table; |
1768 | 0 | while ((table = table_search.NextFullSearch()) != nullptr) { |
1769 | 0 | TBOX table_box = table->bounding_box(); |
1770 | | // reset the projection array |
1771 | 0 | for (int i = 0; i < page_width; i++) { |
1772 | 0 | table_xprojection[i] = 0; |
1773 | 0 | } |
1774 | | // Start a rect search on table_box |
1775 | 0 | ColPartitionGridSearch rectsearch(&clean_part_grid_); |
1776 | 0 | rectsearch.SetUniqueMode(true); |
1777 | 0 | rectsearch.StartRectSearch(table_box); |
1778 | 0 | ColPartition *part; |
1779 | 0 | while ((part = rectsearch.NextRectSearch()) != nullptr) { |
1780 | 0 | if (!part->IsTextType()) { |
1781 | 0 | continue; // Do not consider non-text partitions |
1782 | 0 | } |
1783 | 0 | if (part->flow() == BTFT_LEADER) { |
1784 | 0 | continue; // Assume leaders are in tables |
1785 | 0 | } |
1786 | 0 | TBOX part_box = part->bounding_box(); |
1787 | | // Do not consider partitions partially covered by the table |
1788 | 0 | if (part_box.overlap_fraction(table_box) < kMinOverlapWithTable) { |
1789 | 0 | continue; |
1790 | 0 | } |
1791 | 0 | BLOBNBOX_CLIST *part_boxes = part->boxes(); |
1792 | 0 | BLOBNBOX_C_IT pit(part_boxes); |
1793 | | |
1794 | | // Make sure overlapping blobs don't artificially inflate the number |
1795 | | // of rows in the table. This happens frequently with things such as |
1796 | | // decimals and split characters. Do this by assuming the column |
1797 | | // partition is sorted mostly left to right and just clip |
1798 | | // bounding boxes by the previous box's extent. |
1799 | 0 | int next_position_to_write = 0; |
1800 | |
|
1801 | 0 | for (pit.mark_cycle_pt(); !pit.cycled_list(); pit.forward()) { |
1802 | 0 | BLOBNBOX *pblob = pit.data(); |
1803 | | // ignore blob height for the purpose of projection since we |
1804 | | // are only interested in finding valleys |
1805 | 0 | int xstart = pblob->bounding_box().left(); |
1806 | 0 | int xend = pblob->bounding_box().right(); |
1807 | |
|
1808 | 0 | xstart = std::max(xstart, next_position_to_write); |
1809 | 0 | for (int i = xstart; i < xend; i++) { |
1810 | 0 | table_xprojection[i - bleft().x()]++; |
1811 | 0 | } |
1812 | 0 | next_position_to_write = xend; |
1813 | 0 | } |
1814 | 0 | } |
1815 | | // Find largest valley between two reasonable peaks in the table |
1816 | 0 | if (!GapInXProjection(table_xprojection, page_width)) { |
1817 | 0 | table_search.RemoveBBox(); |
1818 | 0 | delete table; |
1819 | 0 | } |
1820 | 0 | } |
1821 | 0 | delete[] table_xprojection; |
1822 | 0 | } |
1823 | | |
1824 | | // Return true if at least one gap larger than the global x-height |
1825 | | // exists in the horizontal projection |
1826 | 0 | bool TableFinder::GapInXProjection(int *xprojection, int length) { |
1827 | | // Find peak value of the histogram |
1828 | 0 | int peak_value = 0; |
1829 | 0 | for (int i = 0; i < length; i++) { |
1830 | 0 | if (xprojection[i] > peak_value) { |
1831 | 0 | peak_value = xprojection[i]; |
1832 | 0 | } |
1833 | 0 | } |
1834 | | // Peak value represents the maximum number of horizontally |
1835 | | // overlapping colpartitions, so this can be considered as the |
1836 | | // number of rows in the table |
1837 | 0 | if (peak_value < kMinRowsInTable) { |
1838 | 0 | return false; |
1839 | 0 | } |
1840 | 0 | double projection_threshold = kSmallTableProjectionThreshold * peak_value; |
1841 | 0 | if (peak_value >= kLargeTableRowCount) { |
1842 | 0 | projection_threshold = kLargeTableProjectionThreshold * peak_value; |
1843 | 0 | } |
1844 | | // Threshold the histogram |
1845 | 0 | for (int i = 0; i < length; i++) { |
1846 | 0 | xprojection[i] = (xprojection[i] >= projection_threshold) ? 1 : 0; |
1847 | 0 | } |
1848 | | // Find the largest run of zeros between two ones |
1849 | 0 | int largest_gap = 0; |
1850 | 0 | int run_start = -1; |
1851 | 0 | for (int i = 1; i < length; i++) { |
1852 | | // detect start of a run of zeros |
1853 | 0 | if (xprojection[i - 1] && !xprojection[i]) { |
1854 | 0 | run_start = i; |
1855 | 0 | } |
1856 | | // detect end of a run of zeros and update the value of largest gap |
1857 | 0 | if (run_start != -1 && !xprojection[i - 1] && xprojection[i]) { |
1858 | 0 | int gap = i - run_start; |
1859 | 0 | if (gap > largest_gap) { |
1860 | 0 | largest_gap = gap; |
1861 | 0 | } |
1862 | 0 | run_start = -1; |
1863 | 0 | } |
1864 | 0 | } |
1865 | 0 | return largest_gap > kMaxXProjectionGapFactor * global_median_xheight_; |
1866 | 0 | } |
1867 | | |
1868 | | // Given the location of a table "guess", try to overlay a cellular |
1869 | | // grid in the location, adjusting the boundaries. |
1870 | | // TODO(nbeato): Falsely introduces: |
1871 | | // -headers/footers (not any worse, too much overlap destroys cells) |
1872 | | // -page numbers (not worse, included because maximize margins) |
1873 | | // -equations (nicely fit into a celluar grid, but more sparsely) |
1874 | | // -figures (random text box, also sparse) |
1875 | | // -small left-aligned text areas with overlapping positioned whitespace |
1876 | | // (rejected before) |
1877 | | // Overall, this just needs some more work. |
1878 | 0 | void TableFinder::RecognizeTables() { |
1879 | | #ifndef GRAPHICS_DISABLED |
1880 | | ScrollView *table_win = nullptr; |
1881 | | if (textord_show_tables) { |
1882 | | table_win = MakeWindow(0, 0, "Table Structure"); |
1883 | | DisplayColPartitions(table_win, &fragmented_text_grid_, ScrollView::BLUE, |
1884 | | ScrollView::LIGHT_BLUE); |
1885 | | // table_grid_.DisplayBoxes(table_win); |
1886 | | } |
1887 | | #endif |
1888 | |
|
1889 | 0 | TableRecognizer recognizer; |
1890 | 0 | recognizer.Init(); |
1891 | 0 | recognizer.set_line_grid(&leader_and_ruling_grid_); |
1892 | 0 | recognizer.set_text_grid(&fragmented_text_grid_); |
1893 | 0 | recognizer.set_max_text_height(global_median_xheight_ * 2.0); |
1894 | 0 | recognizer.set_min_height(1.5 * gridheight()); |
1895 | | // Loop over all of the tables and try to fit them. |
1896 | | // Store the good tables here. |
1897 | 0 | ColSegment_CLIST good_tables; |
1898 | 0 | ColSegment_C_IT good_it(&good_tables); |
1899 | |
|
1900 | 0 | ColSegmentGridSearch gsearch(&table_grid_); |
1901 | 0 | gsearch.StartFullSearch(); |
1902 | 0 | ColSegment *found_table = nullptr; |
1903 | 0 | while ((found_table = gsearch.NextFullSearch()) != nullptr) { |
1904 | 0 | gsearch.RemoveBBox(); |
1905 | | |
1906 | | // The goal is to make the tables persistent in a list. |
1907 | | // When that happens, this will move into the search loop. |
1908 | 0 | const TBOX &found_box = found_table->bounding_box(); |
1909 | 0 | StructuredTable *table_structure = recognizer.RecognizeTable(found_box); |
1910 | | |
1911 | | // Process a table. Good tables are inserted into the grid again later on |
1912 | | // We can't change boxes in the grid while it is running a search. |
1913 | 0 | if (table_structure != nullptr) { |
1914 | | #ifndef GRAPHICS_DISABLED |
1915 | | if (textord_show_tables) { |
1916 | | table_structure->Display(table_win, ScrollView::LIME_GREEN); |
1917 | | } |
1918 | | #endif |
1919 | 0 | found_table->set_bounding_box(table_structure->bounding_box()); |
1920 | 0 | delete table_structure; |
1921 | 0 | good_it.add_after_then_move(found_table); |
1922 | 0 | } else { |
1923 | 0 | delete found_table; |
1924 | 0 | } |
1925 | 0 | } |
1926 | | // TODO(nbeato): MERGE!! There is awesome info now available for merging. |
1927 | | |
1928 | | // At this point, the grid is empty. We can safely insert the good tables |
1929 | | // back into grid. |
1930 | 0 | for (good_it.mark_cycle_pt(); !good_it.cycled_list(); good_it.forward()) { |
1931 | 0 | table_grid_.InsertBBox(true, true, good_it.extract()); |
1932 | 0 | } |
1933 | 0 | } |
1934 | | |
1935 | | #ifndef GRAPHICS_DISABLED |
1936 | | |
1937 | | // Displays the column segments in some window. |
1938 | | void TableFinder::DisplayColSegments(ScrollView *win, ColSegment_LIST *segments, |
1939 | | ScrollView::Color color) { |
1940 | | win->Pen(color); |
1941 | | win->Brush(ScrollView::NONE); |
1942 | | ColSegment_IT it(segments); |
1943 | | for (it.mark_cycle_pt(); !it.cycled_list(); it.forward()) { |
1944 | | ColSegment *col = it.data(); |
1945 | | const TBOX &box = col->bounding_box(); |
1946 | | int left_x = box.left(); |
1947 | | int right_x = box.right(); |
1948 | | int top_y = box.top(); |
1949 | | int bottom_y = box.bottom(); |
1950 | | win->Rectangle(left_x, bottom_y, right_x, top_y); |
1951 | | } |
1952 | | win->UpdateWindow(); |
1953 | | } |
1954 | | |
1955 | | // Displays the colpartitions using a new coloring on an existing window. |
1956 | | // Note: This method is only for debug purpose during development and |
1957 | | // would not be part of checked in code |
1958 | | void TableFinder::DisplayColPartitions(ScrollView *win, ColPartitionGrid *grid, |
1959 | | ScrollView::Color default_color, |
1960 | | ScrollView::Color table_color) { |
1961 | | ScrollView::Color color = default_color; |
1962 | | // Iterate the ColPartitions in the grid. |
1963 | | ColPartitionGridSearch gsearch(grid); |
1964 | | gsearch.StartFullSearch(); |
1965 | | ColPartition *part = nullptr; |
1966 | | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1967 | | color = default_color; |
1968 | | if (part->type() == PT_TABLE) { |
1969 | | color = table_color; |
1970 | | } |
1971 | | |
1972 | | const TBOX &box = part->bounding_box(); |
1973 | | int left_x = box.left(); |
1974 | | int right_x = box.right(); |
1975 | | int top_y = box.top(); |
1976 | | int bottom_y = box.bottom(); |
1977 | | win->Brush(ScrollView::NONE); |
1978 | | win->Pen(color); |
1979 | | win->Rectangle(left_x, bottom_y, right_x, top_y); |
1980 | | } |
1981 | | win->UpdateWindow(); |
1982 | | } |
1983 | | |
1984 | | void TableFinder::DisplayColPartitions(ScrollView *win, ColPartitionGrid *grid, |
1985 | | ScrollView::Color default_color) { |
1986 | | DisplayColPartitions(win, grid, default_color, ScrollView::YELLOW); |
1987 | | } |
1988 | | |
1989 | | void TableFinder::DisplayColPartitionConnections(ScrollView *win, |
1990 | | ColPartitionGrid *grid, |
1991 | | ScrollView::Color color) { |
1992 | | // Iterate the ColPartitions in the grid. |
1993 | | ColPartitionGridSearch gsearch(grid); |
1994 | | gsearch.StartFullSearch(); |
1995 | | ColPartition *part = nullptr; |
1996 | | while ((part = gsearch.NextFullSearch()) != nullptr) { |
1997 | | const TBOX &box = part->bounding_box(); |
1998 | | int left_x = box.left(); |
1999 | | int right_x = box.right(); |
2000 | | int top_y = box.top(); |
2001 | | int bottom_y = box.bottom(); |
2002 | | |
2003 | | ColPartition *upper_part = part->nearest_neighbor_above(); |
2004 | | if (upper_part) { |
2005 | | const TBOX &upper_box = upper_part->bounding_box(); |
2006 | | int mid_x = (left_x + right_x) / 2; |
2007 | | int mid_y = (top_y + bottom_y) / 2; |
2008 | | int other_x = (upper_box.left() + upper_box.right()) / 2; |
2009 | | int other_y = (upper_box.top() + upper_box.bottom()) / 2; |
2010 | | win->Brush(ScrollView::NONE); |
2011 | | win->Pen(color); |
2012 | | win->Line(mid_x, mid_y, other_x, other_y); |
2013 | | } |
2014 | | ColPartition *lower_part = part->nearest_neighbor_below(); |
2015 | | if (lower_part) { |
2016 | | const TBOX &lower_box = lower_part->bounding_box(); |
2017 | | int mid_x = (left_x + right_x) / 2; |
2018 | | int mid_y = (top_y + bottom_y) / 2; |
2019 | | int other_x = (lower_box.left() + lower_box.right()) / 2; |
2020 | | int other_y = (lower_box.top() + lower_box.bottom()) / 2; |
2021 | | win->Brush(ScrollView::NONE); |
2022 | | win->Pen(color); |
2023 | | win->Line(mid_x, mid_y, other_x, other_y); |
2024 | | } |
2025 | | } |
2026 | | win->UpdateWindow(); |
2027 | | } |
2028 | | |
2029 | | #endif |
2030 | | |
2031 | | // Merge all colpartitions in table regions to make them a single |
2032 | | // colpartition and revert types of isolated table cells not |
2033 | | // assigned to any table to their original types. |
2034 | | void TableFinder::MakeTableBlocks(ColPartitionGrid *grid, |
2035 | | ColPartitionSet **all_columns, |
2036 | 0 | const WidthCallback &width_cb) { |
2037 | | // Since we have table blocks already, remove table tags from all |
2038 | | // colpartitions |
2039 | 0 | ColPartitionGridSearch gsearch(grid); |
2040 | 0 | gsearch.StartFullSearch(); |
2041 | 0 | ColPartition *part = nullptr; |
2042 | |
|
2043 | 0 | while ((part = gsearch.NextFullSearch()) != nullptr) { |
2044 | 0 | if (part->type() == PT_TABLE) { |
2045 | 0 | part->clear_table_type(); |
2046 | 0 | } |
2047 | 0 | } |
2048 | | // Now make a single colpartition out of each table block and remove |
2049 | | // all colpartitions contained within a table |
2050 | 0 | GridSearch<ColSegment, ColSegment_CLIST, ColSegment_C_IT> table_search( |
2051 | 0 | &table_grid_); |
2052 | 0 | table_search.StartFullSearch(); |
2053 | 0 | ColSegment *table; |
2054 | 0 | while ((table = table_search.NextFullSearch()) != nullptr) { |
2055 | 0 | const TBOX &table_box = table->bounding_box(); |
2056 | | // Start a rect search on table_box |
2057 | 0 | ColPartitionGridSearch rectsearch(grid); |
2058 | 0 | rectsearch.StartRectSearch(table_box); |
2059 | 0 | ColPartition *part; |
2060 | 0 | ColPartition *table_partition = nullptr; |
2061 | 0 | while ((part = rectsearch.NextRectSearch()) != nullptr) { |
2062 | | // Do not consider image partitions |
2063 | 0 | if (!part->IsTextType()) { |
2064 | 0 | continue; |
2065 | 0 | } |
2066 | 0 | TBOX part_box = part->bounding_box(); |
2067 | | // Include partition in the table if more than half of it |
2068 | | // is covered by the table |
2069 | 0 | if (part_box.overlap_fraction(table_box) > kMinOverlapWithTable) { |
2070 | 0 | rectsearch.RemoveBBox(); |
2071 | 0 | if (table_partition) { |
2072 | 0 | table_partition->Absorb(part, width_cb); |
2073 | 0 | } else { |
2074 | 0 | table_partition = part; |
2075 | 0 | } |
2076 | 0 | } |
2077 | 0 | } |
2078 | | // Insert table colpartition back to part_grid_ |
2079 | 0 | if (table_partition) { |
2080 | | // To match the columns used when transforming to blocks, the new table |
2081 | | // partition must have its first and last column set at the grid y that |
2082 | | // corresponds to its bottom. |
2083 | 0 | const TBOX &table_box = table_partition->bounding_box(); |
2084 | 0 | int grid_x, grid_y; |
2085 | 0 | grid->GridCoords(table_box.left(), table_box.bottom(), &grid_x, &grid_y); |
2086 | 0 | table_partition->SetPartitionType(resolution_, all_columns[grid_y]); |
2087 | 0 | table_partition->set_table_type(); |
2088 | 0 | table_partition->set_blob_type(BRT_TEXT); |
2089 | 0 | table_partition->set_flow(BTFT_CHAIN); |
2090 | 0 | table_partition->SetBlobTypes(); |
2091 | 0 | grid->InsertBBox(true, true, table_partition); |
2092 | 0 | } |
2093 | 0 | } |
2094 | 0 | } |
2095 | | |
2096 | | //////// ColSegment code |
2097 | | //////// |
2098 | | ColSegment::ColSegment() |
2099 | 0 | : ELIST<ColSegment>::LINK(), |
2100 | 0 | num_table_cells_(0), |
2101 | 0 | num_text_cells_(0), |
2102 | 0 | type_(COL_UNKNOWN) {} |
2103 | | |
2104 | | // Provides a color for BBGrid to draw the rectangle. |
2105 | 0 | ScrollView::Color ColSegment::BoxColor() const { |
2106 | 0 | const ScrollView::Color kBoxColors[PT_COUNT] = { |
2107 | 0 | ScrollView::YELLOW, |
2108 | 0 | ScrollView::BLUE, |
2109 | 0 | ScrollView::YELLOW, |
2110 | 0 | ScrollView::MAGENTA, |
2111 | 0 | }; |
2112 | 0 | return kBoxColors[type_]; |
2113 | 0 | } |
2114 | | |
2115 | | // Insert a box into this column segment |
2116 | 0 | void ColSegment::InsertBox(const TBOX &other) { |
2117 | 0 | bounding_box_ = bounding_box_.bounding_union(other); |
2118 | 0 | } |
2119 | | |
2120 | | // Set column segment type based on the ratio of text and table partitions |
2121 | | // in it. |
2122 | 0 | void ColSegment::set_type() { |
2123 | 0 | if (num_table_cells_ > kTableColumnThreshold * num_text_cells_) { |
2124 | 0 | type_ = COL_TABLE; |
2125 | 0 | } else if (num_text_cells_ > num_table_cells_) { |
2126 | 0 | type_ = COL_TEXT; |
2127 | 0 | } else { |
2128 | 0 | type_ = COL_MIXED; |
2129 | 0 | } |
2130 | 0 | } |
2131 | | |
2132 | | } // namespace tesseract. |