vivlio-starter-pdf 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +32 -28
- data/RELEASE_NOTE.md +39 -2
- data/exe/vivlio-starter-pdf +89 -31
- data/lib/vivlio_starter/cli/pdf/enhanced_provider.rb +10 -85
- data/lib/vivlio_starter/cli/pdf/outline_writer.rb +36 -23
- data/lib/vivlio_starter/cli/pdf/reader.rb +856 -232
- data/lib/vivlio_starter/cli/pdf/version.rb +1 -1
- data/vivlio-starter-pdf.gemspec +10 -17
- metadata +51 -17
- data/.ruby-version +0 -1
- data/Rakefile +0 -74
|
@@ -21,8 +21,13 @@ module VivlioStarter
|
|
|
21
21
|
Line = Data.define(:y, :text)
|
|
22
22
|
# PDF 内画像の出現位置(座標・サイズ・XObject 参照)
|
|
23
23
|
ImageOccurrence = Data.define(:x, :top, :bottom, :center_y, :left, :right, :width, :height, :object)
|
|
24
|
-
#
|
|
25
|
-
PageContent = Data.define(:text, :lines, :image_occurrences)
|
|
24
|
+
# ページのテキスト・行・画像出現をまとめたコンテンツ。words は OCR で読んだ語(OCR したページだけ)
|
|
25
|
+
PageContent = Data.define(:text, :lines, :image_occurrences, :words) do
|
|
26
|
+
def initialize(text:, lines:, image_occurrences:, words: []) = super
|
|
27
|
+
end
|
|
28
|
+
# OCR で読んだ 1 語。座標は PDF のページ(左下が原点・pt)。y はベースライン、size は行の高さ。
|
|
29
|
+
# 本体(vivlio-starter)はこの語から見出しや段落を推定する
|
|
30
|
+
OcrWord = Data.define(:text, :x, :y, :width, :size, :conf)
|
|
26
31
|
# 抽出済み画像アセット(ページ番号・ファイル名・座標情報を保持)
|
|
27
32
|
ImageAsset = Data.define(:page, :index, :filename, :output_path, :reference_path, :x, :top, :bottom, :center_y, :left, :right, :width, :height)
|
|
28
33
|
# OCR で検出したイラスト領域(ピクセル座標)
|
|
@@ -34,24 +39,105 @@ module VivlioStarter
|
|
|
34
39
|
# ページ解析の最終結果(コンテンツ + OCR 適用有無 + 一時ディレクトリ)
|
|
35
40
|
ResolvedPage = Data.define(:content, :ocr_applied, :ocr_temp_dir)
|
|
36
41
|
# OCR 実行結果(テキスト・行・ブロック・画像情報を保持)
|
|
37
|
-
OcrResult = Data.define(:text, :lines, :blocks, :source_image_path, :image_width, :image_height, :temp_dir)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
42
|
+
OcrResult = Data.define(:text, :lines, :blocks, :source_image_path, :image_width, :image_height, :temp_dir, :words) do
|
|
43
|
+
def initialize(text:, lines:, blocks:, source_image_path:, image_width:, image_height:, temp_dir:, words: []) = super
|
|
44
|
+
end
|
|
45
|
+
# Tesseract の TSV の 1 行。fields は書き戻すための元の列で、index は列名から列の位置を引く
|
|
46
|
+
TsvRow = Data.define(:fields, :index) do
|
|
47
|
+
def [](name) = fields[index.fetch(name)]
|
|
48
|
+
def level = self["level"]
|
|
49
|
+
def text = self["text"].to_s.strip
|
|
50
|
+
def left = self["left"].to_f
|
|
51
|
+
def top = self["top"].to_f
|
|
52
|
+
def width = self["width"].to_f
|
|
53
|
+
def height = self["height"].to_f
|
|
54
|
+
def right = left + width
|
|
55
|
+
def bottom = top + height
|
|
56
|
+
def center_x = left + (width / 2)
|
|
57
|
+
def center_y = top + (height / 2)
|
|
58
|
+
def conf = self["conf"].to_f
|
|
59
|
+
def line_key = LINE_KEY.map { self[it] }
|
|
60
|
+
# 英字の語か。空の語は、前後の英字の語の連なりを切らないよう英字の側に入れる
|
|
61
|
+
def latin? = text.empty? || text.match?(LATIN_WORD)
|
|
62
|
+
|
|
63
|
+
def with_fields(changes)
|
|
64
|
+
changed = fields.dup
|
|
65
|
+
changes.each { |name, value| changed[index.fetch(name)] = value.to_s }
|
|
66
|
+
with(fields: changed)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# --- 二度読み ---
|
|
71
|
+
# 囲みの地の割合がこれ以上のページだけ二度読みする
|
|
72
|
+
TWO_PASS_TINT = 0.08
|
|
73
|
+
# 読みを選ぶ基準にする確信度
|
|
74
|
+
CONFIDENT_WORD = 80
|
|
75
|
+
|
|
76
|
+
# --- 文字が検出されなかった範囲の読み直し ---
|
|
77
|
+
# 読み直す範囲の大きさの下限(ページの幅を 595 とした単位。A4 なら pt)
|
|
78
|
+
REREAD_MIN_WIDTH = 60
|
|
79
|
+
REREAD_MIN_HEIGHT = 18
|
|
80
|
+
# すでに読めた語がこれを超えて覆う範囲は読み直さない。地の色の付いた囲みの中の文やコードは別の
|
|
81
|
+
# ブロックで読めていて 15〜30% を覆い、表や色の帯の見出しは 0〜9% を覆う
|
|
82
|
+
REREAD_MAX_COVERAGE = 0.1
|
|
83
|
+
# 切り出すときの余白(画像の幅に対する割合。300dpi の A4 で 24px)
|
|
84
|
+
REREAD_PADDING = 24 / 2480.0
|
|
85
|
+
|
|
86
|
+
# --- 傾きの補正 ---
|
|
87
|
+
# 測る傾きの範囲と刻み(度)
|
|
88
|
+
DESKEW_RANGE = 3.0
|
|
89
|
+
DESKEW_STEP = 0.05
|
|
90
|
+
# これより小さい傾きは直さない
|
|
91
|
+
DESKEW_MIN_ANGLE = 0.1
|
|
92
|
+
# 傾きを測る画像の幅(px)。縮めて測っても 0.05° の刻みで決まる
|
|
93
|
+
DESKEW_WORK_WIDTH = 620
|
|
94
|
+
|
|
95
|
+
# --- 斜体の行の読み直し ---
|
|
96
|
+
# 読み直す行の確信度の平均の上限。斜体の行は 0〜40 の意味の無い英字になるか、60〜70 の崩れた和文になる
|
|
97
|
+
SLANT_MAX_CONFIDENCE = 75
|
|
98
|
+
# 読み直す行の字数の下限(図の中の短い札を除く)
|
|
99
|
+
SLANT_MIN_CHARACTERS = 8
|
|
100
|
+
# 戻す傾き(度)。Chrome は斜体の無い和文の書体を傾けて描く
|
|
101
|
+
SLANT_ANGLE = 12
|
|
102
|
+
|
|
103
|
+
# --- 英語での読み直し ---
|
|
104
|
+
# 英字の語がこれ以上あるページだけ、英語の学習データでも読む
|
|
105
|
+
ENGLISH_PAGE_WORDS = 20
|
|
106
|
+
# 英語の読みを採るのに要る確信度。日本語+英語の読みと食い違う語の平均で見る。
|
|
107
|
+
# 「11月」の「11」を英語の読みは「lL」(確信度 35)と読む
|
|
108
|
+
ENGLISH_MIN_CONFIDENCE = 60
|
|
109
|
+
# TSV で 1 行を表す列(ブロック・段落・行の番号)
|
|
110
|
+
LINE_KEY = %w[page_num block_num par_num line_num].freeze
|
|
111
|
+
# 英字の語(ASCII とラテン文字、英文の引用符とダッシュ)
|
|
112
|
+
LATIN_WORD = /\A(?:\p{ASCII}|\p{Latin}|[‘’“”–—])+\z/
|
|
113
|
+
|
|
114
|
+
# --- 図版の検出 ---
|
|
115
|
+
# ページを縮めて調べるときの幅(px)。A4 で約 37dpi、1 画素が約 2pt になる
|
|
116
|
+
FIGURE_WORK_WIDTH = 310
|
|
117
|
+
# 紙の明るさよりこれだけ暗い画素と、彩度(LCh の C)がこれを超える画素を、図の候補にする。
|
|
118
|
+
# 淡い黄色の表紙が候補に入るよう、彩度は低めにする
|
|
119
|
+
FIGURE_DARKNESS = 50
|
|
120
|
+
FIGURE_CHROMA = 15
|
|
121
|
+
# 確信度がこれ以上の語と、語の確信度の平均がこれ以上の行は、文字として図の候補から消す
|
|
122
|
+
FIGURE_TEXT_CONFIDENCE = 60
|
|
123
|
+
# 図とみなす大きさの下限。辺はページの幅に、面積はページの面積に対する割合(A4 で 40pt・6,000pt²)。
|
|
124
|
+
# 見出しの色の札や、会話文の話者のアイコン(32pt 角)を除く
|
|
125
|
+
FIGURE_MIN_SIDE = 40 / 595.0
|
|
126
|
+
FIGURE_MIN_AREA = 6000 / (595.0 * 842.0)
|
|
127
|
+
# 図の枠を削るときに残す、図の画素の埋まった列・行の割合
|
|
128
|
+
FIGURE_DENSE = 0.3
|
|
129
|
+
# 埋まった列・行のあいだで続きとみなす、少しは埋まった列・行の割合の下限と本数の上限(trim_to_dense)。
|
|
130
|
+
# 本数は縮めた画像の画素で、8 本は約 15pt。ほぼ空の列・行(写真の横の字を消した跡・図の題との空き)はつながない
|
|
131
|
+
FIGURE_SPARSE = 0.1
|
|
132
|
+
FIGURE_BRIDGE = 8
|
|
133
|
+
|
|
134
|
+
# --- OCR に回す画像 ---
|
|
135
|
+
# 埋め込みのスキャン画像がこの幅(px)に届かなければ、vips で拡大してから読む。
|
|
136
|
+
# A5 の本を約 230dpi、A4 を約 165dpi で取り込んだ幅にあたる
|
|
137
|
+
MIN_OCR_IMAGE_WIDTH = 1600
|
|
138
|
+
# これを超える解像度のスキャンは、TARGET_OCR_DPI へ縮めてから読む
|
|
139
|
+
MAX_OCR_DPI = 400
|
|
140
|
+
TARGET_OCR_DPI = 300.0
|
|
55
141
|
|
|
56
142
|
# HexaPDF のコンテンツストリームを走査し、版面内のテキスト断片と画像出現を収集する
|
|
57
143
|
class PageTextCollector < HexaPDF::Content::Processor
|
|
@@ -68,6 +154,15 @@ module VivlioStarter
|
|
|
68
154
|
@image_occurrences = []
|
|
69
155
|
end
|
|
70
156
|
|
|
157
|
+
# 1 つの命令の失敗で、ページの読み取りを止めない。HexaPDF は、資源に無い色空間(`/CS0 cs`)や
|
|
158
|
+
# 縦書きの字(「Not yet implemented」)で例外を出す。色空間も縦書きの字の位置も、画像の位置と
|
|
159
|
+
# 横書きの文字の取得には使わないので、失敗した命令だけを飛ばす
|
|
160
|
+
def process(operator, operands = [])
|
|
161
|
+
super
|
|
162
|
+
rescue StandardError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
71
166
|
# PDF オペレータ Tj: テキスト表示
|
|
72
167
|
def show_text(data)
|
|
73
168
|
collect_text_box(decode_text_with_positioning(data))
|
|
@@ -116,7 +211,9 @@ module VivlioStarter
|
|
|
116
211
|
end
|
|
117
212
|
|
|
118
213
|
lines << Line.new(y: current_y, text: normalize_line(buffer)) unless buffer.empty?
|
|
119
|
-
|
|
214
|
+
# 複製して受ける。`+fragment.text` は凍結されていない文字列をそのまま返すので、
|
|
215
|
+
# 複製しないと下の `<<` が断片の文字列自体を書き換える
|
|
216
|
+
buffer = fragment.text.dup
|
|
120
217
|
current_y = fragment.y
|
|
121
218
|
previous_fragment = fragment
|
|
122
219
|
end
|
|
@@ -192,9 +289,10 @@ module VivlioStarter
|
|
|
192
289
|
line
|
|
193
290
|
.to_s
|
|
194
291
|
.gsub(/[ \t\u00A0]+/, " ")
|
|
195
|
-
|
|
196
|
-
.gsub(/(?<=[
|
|
197
|
-
.gsub(/(?<=[
|
|
292
|
+
# ヵヶ は ァ-ヶ の範囲に含まれるので並べない(重複範囲の警告が出る)
|
|
293
|
+
.gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆]) (?=[一-龯ぁ-ゖァ-ヶー々〆])/, "")
|
|
294
|
+
.gsub(/(?<=[一-龯ぁ-ゖァ-ヶー々〆]) (?=[、。,.:;!?)】』」])/, "")
|
|
295
|
+
.gsub(/(?<=[(【『「]) (?=[一-龯ぁ-ゖァ-ヶー々〆A-Za-z0-9])/, "")
|
|
198
296
|
.strip
|
|
199
297
|
end
|
|
200
298
|
|
|
@@ -230,7 +328,11 @@ module VivlioStarter
|
|
|
230
328
|
# @param images_dir [String, nil] 画像の保存先ディレクトリ
|
|
231
329
|
# @param image_reference_dir [String, nil] Markdown 内の画像参照パスの基底
|
|
232
330
|
# @param ocr [Hash, nil] OCR 設定
|
|
233
|
-
|
|
331
|
+
# @param progress [#call, nil] OCR するページごとに (何ページ目か, 読むページの数) で呼ぶ。OCR は 1 ページ数秒かかる
|
|
332
|
+
# @param pages [Array<Integer>, nil] 読むページ(1 から数える)。省けば全ページ。読まないページは、文字も画像も OCR も
|
|
333
|
+
# 取らず、結果の配列には空の値を置く(ページの添字はそのまま)
|
|
334
|
+
def initialize(pdf_path, page_separator: true, text_area: nil, line_merge_tolerance: 2.0, images_dir: nil, image_reference_dir: nil, ocr: nil,
|
|
335
|
+
progress: nil, pages: nil)
|
|
234
336
|
@pdf_path = pdf_path
|
|
235
337
|
@page_separator = page_separator != false
|
|
236
338
|
@text_area = normalize_text_area(text_area)
|
|
@@ -238,18 +340,31 @@ module VivlioStarter
|
|
|
238
340
|
@images_dir = images_dir&.to_s&.strip
|
|
239
341
|
@image_reference_dir = image_reference_dir&.to_s&.strip
|
|
240
342
|
@ocr = normalize_ocr(ocr)
|
|
343
|
+
@progress = progress
|
|
344
|
+
@pages = pages&.map(&:to_i)
|
|
241
345
|
end
|
|
242
346
|
|
|
243
347
|
# PDF を解析し、Markdown テキスト・画像アセット・メタデータを含む Hash を返す
|
|
244
|
-
# @return [Hash] :markdown, :page_texts, :page_chunks, :pages, :images
|
|
348
|
+
# @return [Hash] :markdown, :page_texts, :page_chunks, :pages, :images, :page_words, :page_sizes
|
|
349
|
+
# page_words は、OCR したページなら語の一覧(OcrWord#to_h)、しなかったページなら nil
|
|
245
350
|
def execute
|
|
246
351
|
document = HexaPDF::Document.open(pdf_path)
|
|
247
352
|
page_texts = []
|
|
248
353
|
page_chunks = []
|
|
249
354
|
images = []
|
|
355
|
+
page_words = []
|
|
356
|
+
page_sizes = []
|
|
250
357
|
|
|
358
|
+
@page_count = @pages ? @pages.size : document.pages.count
|
|
251
359
|
document.pages.each_with_index do |page, index|
|
|
252
360
|
resolution = nil
|
|
361
|
+
unless read_page?(index)
|
|
362
|
+
page_texts << ""
|
|
363
|
+
page_chunks << ""
|
|
364
|
+
page_words << nil
|
|
365
|
+
page_sizes << page_size(page)
|
|
366
|
+
next
|
|
367
|
+
end
|
|
253
368
|
|
|
254
369
|
begin
|
|
255
370
|
content = extract_page_content(page, index)
|
|
@@ -261,6 +376,8 @@ module VivlioStarter
|
|
|
261
376
|
page_texts << page_text
|
|
262
377
|
page_chunks << build_page_chunk(page_lines, page_images, page_text, image_captions:)
|
|
263
378
|
images.concat(page_images)
|
|
379
|
+
page_words << (resolution.ocr_applied ? resolution.content.words.map(&:to_h) : nil)
|
|
380
|
+
page_sizes << page_size(page)
|
|
264
381
|
ensure
|
|
265
382
|
cleanup_ocr_temp_dir(resolution&.ocr_temp_dir)
|
|
266
383
|
end
|
|
@@ -271,14 +388,20 @@ module VivlioStarter
|
|
|
271
388
|
page_texts:,
|
|
272
389
|
page_chunks:,
|
|
273
390
|
pages: document.pages.count,
|
|
274
|
-
images: images.map(&:to_h)
|
|
391
|
+
images: images.map(&:to_h),
|
|
392
|
+
page_words:,
|
|
393
|
+
page_sizes:
|
|
275
394
|
}
|
|
395
|
+
ensure
|
|
396
|
+
FileUtils.rm_rf(@poppler_dir) if @poppler_dir
|
|
276
397
|
end
|
|
277
398
|
|
|
278
399
|
private
|
|
279
400
|
|
|
280
401
|
attr_reader :pdf_path
|
|
281
402
|
|
|
403
|
+
def read_page?(index) = @pages.nil? || @pages.include?(index + 1)
|
|
404
|
+
|
|
282
405
|
# OCR 用の一時ディレクトリを安全に削除する
|
|
283
406
|
def cleanup_ocr_temp_dir(path)
|
|
284
407
|
return if path.to_s.empty?
|
|
@@ -307,6 +430,7 @@ module VivlioStarter
|
|
|
307
430
|
def resolve_page_content(page, index, content)
|
|
308
431
|
return ResolvedPage.new(content:, ocr_applied: false, ocr_temp_dir: nil) unless ocr_required_for_page?(page, content)
|
|
309
432
|
|
|
433
|
+
@progress&.call(@pages ? @pages.index(index + 1).to_i + 1 : index + 1, @page_count)
|
|
310
434
|
resolved_content, ocr_temp_dir = extract_page_ocr_content(page, index, content.image_occurrences)
|
|
311
435
|
ResolvedPage.new(content: resolved_content, ocr_applied: true, ocr_temp_dir:)
|
|
312
436
|
rescue StandardError
|
|
@@ -316,9 +440,9 @@ module VivlioStarter
|
|
|
316
440
|
end
|
|
317
441
|
|
|
318
442
|
# OCR を実行してページコンテンツを再構築する
|
|
319
|
-
#
|
|
443
|
+
# テキスト後処理(空白圧縮・括弧正規化・誤認識の補正)を適用する
|
|
320
444
|
def extract_page_ocr_content(page, index, image_occurrences)
|
|
321
|
-
result = ocr_page_result(page, index)
|
|
445
|
+
result = ocr_page_result(page, index, image_occurrences)
|
|
322
446
|
lines = normalize_ocr_lines(result.lines)
|
|
323
447
|
text = if lines.empty?
|
|
324
448
|
postprocess_ocr_text(result.text)
|
|
@@ -328,9 +452,9 @@ module VivlioStarter
|
|
|
328
452
|
|
|
329
453
|
resolved_image_occurrences = ocr_image_occurrences(page, image_occurrences, result)
|
|
330
454
|
page_content = if text.empty?
|
|
331
|
-
PageContent.new(text: "", lines: [], image_occurrences: resolved_image_occurrences)
|
|
455
|
+
PageContent.new(text: "", lines: [], image_occurrences: resolved_image_occurrences, words: result.words)
|
|
332
456
|
else
|
|
333
|
-
PageContent.new(text:, lines:, image_occurrences: resolved_image_occurrences)
|
|
457
|
+
PageContent.new(text:, lines:, image_occurrences: resolved_image_occurrences, words: result.words)
|
|
334
458
|
end
|
|
335
459
|
|
|
336
460
|
[page_content, result.temp_dir]
|
|
@@ -366,7 +490,8 @@ module VivlioStarter
|
|
|
366
490
|
occurrences.each_with_index do |occurrence, image_index|
|
|
367
491
|
filename = format("page-%03d-image-%02d.webp", index + 1, image_index + 1)
|
|
368
492
|
output_path = File.join(@images_dir, filename)
|
|
369
|
-
|
|
493
|
+
next unless write_extracted_image(occurrence.object, index + 1, output_path)
|
|
494
|
+
|
|
370
495
|
assets << build_image_asset(index, image_index + 1, filename, output_path, occurrence)
|
|
371
496
|
rescue StandardError
|
|
372
497
|
next
|
|
@@ -375,6 +500,44 @@ module VivlioStarter
|
|
|
375
500
|
assets
|
|
376
501
|
end
|
|
377
502
|
|
|
503
|
+
# 画像を書き出し、書けたかを返す。HexaPDF で書けない画像(色の表 Indexed を別のオブジェクトに置いた
|
|
504
|
+
# 画像など)は、poppler の pdfimages で同じオブジェクトを書き出す
|
|
505
|
+
def write_extracted_image(object, page_number, output_path)
|
|
506
|
+
write_image_as_webp(object, output_path)
|
|
507
|
+
true
|
|
508
|
+
rescue StandardError
|
|
509
|
+
write_poppler_image(object, page_number, output_path)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def write_poppler_image(object, page_number, output_path)
|
|
513
|
+
return false unless object.respond_to?(:oid) && object.oid.to_i.positive? && command_in_path?("pdfimages")
|
|
514
|
+
|
|
515
|
+
source = poppler_page_images(page_number)[object.oid]
|
|
516
|
+
return false unless source
|
|
517
|
+
|
|
518
|
+
_stdout, status = Open3.capture2e(*image_convert_command(source, output_path))
|
|
519
|
+
status.success?
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# pdfimages で 1 ページの画像を PNG に書き出し、{オブジェクトの番号 => PNG のパス} を返す(ページごとに 1 回)。
|
|
523
|
+
# ファイル名の番号は -list の num と同じ。透明度(smask)の行は除く
|
|
524
|
+
def poppler_page_images(page_number)
|
|
525
|
+
@poppler_images ||= {}
|
|
526
|
+
@poppler_images[page_number] ||= begin
|
|
527
|
+
@poppler_dir ||= Dir.mktmpdir("vivlio-pdf-images")
|
|
528
|
+
prefix = File.join(@poppler_dir, format("page-%03d", page_number))
|
|
529
|
+
range = ["-f", page_number.to_s, "-l", page_number.to_s]
|
|
530
|
+
list, = Open3.capture2e("pdfimages", "-list", *range, pdf_path)
|
|
531
|
+
Open3.capture2e("pdfimages", "-png", *range, pdf_path, prefix)
|
|
532
|
+
list.lines.drop(2).filter_map do |line|
|
|
533
|
+
columns = line.split
|
|
534
|
+
next unless columns[2] == "image"
|
|
535
|
+
|
|
536
|
+
[columns[10].to_i, format("%s-%03d.png", prefix, columns[1].to_i)]
|
|
537
|
+
end.to_h.select { |_oid, file| File.file?(file) }
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
378
541
|
# テキスト行と画像参照を Y 座標順に統合し、ページチャンク文字列を構築する
|
|
379
542
|
def build_page_chunk(lines, images, fallback_text, image_captions: {})
|
|
380
543
|
fallback = fallback_text.to_s.strip
|
|
@@ -498,33 +661,498 @@ module VivlioStarter
|
|
|
498
661
|
end
|
|
499
662
|
|
|
500
663
|
# 1 ページ分の OCR を実行し、OcrResult を返す
|
|
501
|
-
#
|
|
502
|
-
|
|
664
|
+
# ページ画像を用意し(ocr_source_image)、Tesseract でテキストと座標を一度に読む。
|
|
665
|
+
# 囲みの多いページは二値化した画像でも読み、英字の多いページは英語の学習データでも読み直す
|
|
666
|
+
def ocr_page_result(page, index, image_occurrences = [])
|
|
503
667
|
temp_dir = Dir.mktmpdir("vivlio-pdf-ocr")
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
tsv = capture_ocr_output(*ocr_tsv_command(source_image_path))
|
|
512
|
-
image_width, image_height = png_dimensions(source_image_path)
|
|
668
|
+
source_image_path, image_width, image_height = ocr_source_image(page, index, image_occurrences, temp_dir)
|
|
669
|
+
text, tsv = run_tesseract(source_image_path, File.join(temp_dir, "ocr"))
|
|
670
|
+
text, tsv, read_image_path = second_reading(source_image_path, temp_dir, text, tsv)
|
|
671
|
+
tsv = reread_skipped_regions(read_image_path, temp_dir, tsv, page)
|
|
672
|
+
tsv = reread_slanted_lines(read_image_path, temp_dir, tsv)
|
|
673
|
+
tsv = english_reading(read_image_path, temp_dir, tsv)
|
|
674
|
+
source_image_path, tsv = straighten(source_image_path, tsv, temp_dir)
|
|
513
675
|
|
|
514
676
|
OcrResult.new(
|
|
515
|
-
text
|
|
677
|
+
text:,
|
|
516
678
|
lines: extract_ocr_lines(tsv, page),
|
|
517
679
|
blocks: [],
|
|
518
680
|
source_image_path:,
|
|
519
681
|
image_width:,
|
|
520
682
|
image_height:,
|
|
521
|
-
temp_dir
|
|
683
|
+
temp_dir:,
|
|
684
|
+
words: extract_ocr_words(tsv, page)
|
|
522
685
|
)
|
|
523
686
|
rescue StandardError
|
|
524
687
|
cleanup_ocr_temp_dir(temp_dir)
|
|
525
688
|
raise
|
|
526
689
|
end
|
|
527
690
|
|
|
691
|
+
# OCR にかけるページ画像を用意し、[パス, 幅, 高さ] を返す。
|
|
692
|
+
#
|
|
693
|
+
# スキャンしたページは、埋め込みの画像をそのまま書き出す。pdftoppm による描き直しは 300dpi・A4 の
|
|
694
|
+
# JPEG 1 枚で 23 秒かかる(書き出しは 0.05 秒)。埋め込みの画像を使えないページは描き直す
|
|
695
|
+
def ocr_source_image(page, index, image_occurrences, temp_dir)
|
|
696
|
+
embedded = embedded_scan_image(page, image_occurrences)
|
|
697
|
+
if embedded && (embedded.info.width >= MIN_OCR_IMAGE_WIDTH || vips_available?)
|
|
698
|
+
path = File.join(temp_dir, "page.#{embedded.info.extension}")
|
|
699
|
+
embedded.write(path)
|
|
700
|
+
dpi = scan_dpi(page, embedded.info.width)
|
|
701
|
+
return downscale_for_ocr(path, dpi, temp_dir) if dpi && dpi > MAX_OCR_DPI && vips_available?
|
|
702
|
+
return [path, embedded.info.width, embedded.info.height] if embedded.info.width >= MIN_OCR_IMAGE_WIDTH
|
|
703
|
+
|
|
704
|
+
return upscale_for_ocr(path, embedded.info.width, temp_dir)
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
prefix = File.join(temp_dir, "page")
|
|
708
|
+
stdout, status = Open3.capture2e(*ocr_render_command(prefix, index + 1))
|
|
709
|
+
raise Error, stdout.strip unless status.success?
|
|
710
|
+
|
|
711
|
+
path = Dir.glob("#{prefix}-*.png").sort.first
|
|
712
|
+
raise Error, "OCR 用のページ画像を生成できませんでした" unless path
|
|
713
|
+
|
|
714
|
+
[path, *png_dimensions(path)]
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# ページの大きさから見たスキャンの解像度(dpi)。ページを実物より大きく作った PDF では低く出るが、
|
|
718
|
+
# 縮める判定(MAX_OCR_DPI を超えるか)にしか使わないので、そういう PDF は縮めないだけである
|
|
719
|
+
def scan_dpi(page, image_width)
|
|
720
|
+
box = media_box(page)
|
|
721
|
+
return nil unless box && (box[2] - box[0]).positive?
|
|
722
|
+
|
|
723
|
+
image_width / ((box[2] - box[0]) / 72.0)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# 解像度の高すぎるスキャンを 300dpi へ縮める。Tesseract は字が大きすぎても誤認識する
|
|
727
|
+
def downscale_for_ocr(path, dpi, temp_dir)
|
|
728
|
+
image = require_vips!::Image.new_from_file(path).resize(TARGET_OCR_DPI / dpi, kernel: :lanczos3)
|
|
729
|
+
downscaled = File.join(temp_dir, "page-downscaled.jpg")
|
|
730
|
+
# PNG で書くと、書き出しと読み込みで 1 ページ 1 秒ほど余計にかかる
|
|
731
|
+
image.write_to_file(downscaled, Q: 92)
|
|
732
|
+
[downscaled, image.width, image.height]
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# 画素の足りない画像を vips で拡大し、[パス, 幅, 高さ] を返す。
|
|
736
|
+
# pdftoppm で 300dpi に描き直すのとほぼ同じ確信度が、0.5 秒で得られる
|
|
737
|
+
def upscale_for_ocr(path, width, temp_dir)
|
|
738
|
+
scale = (MIN_OCR_IMAGE_WIDTH.to_f / width).clamp(1.0, 3.0)
|
|
739
|
+
image = require_vips!::Image.new_from_file(path).resize(scale, kernel: :lanczos3)
|
|
740
|
+
upscaled = File.join(temp_dir, "page-upscaled.png")
|
|
741
|
+
image.write_to_file(upscaled)
|
|
742
|
+
[upscaled, image.width, image.height]
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# スキャンの傾きを直す。傾いたままだと、切り出した図の縁に三角の余白が付き、コードの行番号の欄や
|
|
746
|
+
# 柱の高さがページの上と下で数 pt ずれる。
|
|
747
|
+
# 読み取りは傾いたままの画像で済ませ、ここでは語の位置だけを回す(回した画像を Tesseract に渡すと、
|
|
748
|
+
# 補間で字がにじんで認識率が下がる)。回した画像は図の検出と切り出しに使う。
|
|
749
|
+
# 大きさは変えない(OCR の座標を、画像がページを覆う前提でページの座標へ換算するため)。
|
|
750
|
+
# 回して空いた隅は紙の明るさで埋める
|
|
751
|
+
# @return [Array(String, String)] [図を切り出す画像のパス, 語の位置を回した TSV]
|
|
752
|
+
def straighten(path, tsv, temp_dir)
|
|
753
|
+
return [path, tsv] unless vips_available?
|
|
754
|
+
|
|
755
|
+
image = require_vips!::Image.new_from_file(path)
|
|
756
|
+
gray = image.colourspace(:b_w).extract_band(0)
|
|
757
|
+
angle = skew_angle(gray)
|
|
758
|
+
return [path, tsv] if angle.abs < DESKEW_MIN_ANGLE
|
|
759
|
+
|
|
760
|
+
rotated = image.similarity(angle:, background: Array.new(image.bands, gray.percent(50)))
|
|
761
|
+
deskewed = File.join(temp_dir, "page-deskewed.jpg")
|
|
762
|
+
rotated.crop((rotated.width - image.width) / 2, (rotated.height - image.height) / 2, image.width, image.height)
|
|
763
|
+
.write_to_file(deskewed, Q: 95)
|
|
764
|
+
[deskewed, rotate_tsv(tsv, angle, image.width, image.height)]
|
|
765
|
+
rescue StandardError
|
|
766
|
+
[path, tsv]
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
# TSV の枠を、画像を angle(度・時計回り)回したときの位置へ移す。枠の中心を画像の中心のまわりに回し、
|
|
770
|
+
# 大きさは保つ。ただし行・段落・ブロックの枠は、傾いた行を囲んだぶん(幅 × sin)だけ高く出ているので縮める
|
|
771
|
+
# (行の高さは字の大きさの見積もりになる)
|
|
772
|
+
def rotate_tsv(tsv, angle, width, height)
|
|
773
|
+
radians = angle * Math::PI / 180
|
|
774
|
+
cos = Math.cos(radians)
|
|
775
|
+
sin = Math.sin(radians)
|
|
776
|
+
rows = parse_tsv_rows(tsv).map do |row|
|
|
777
|
+
next row if row.level == "1"
|
|
778
|
+
|
|
779
|
+
dx = row.center_x - (width / 2.0)
|
|
780
|
+
dy = row.center_y - (height / 2.0)
|
|
781
|
+
box_height = row.level == "5" ? row.height : [row.height - (row.width * sin.abs), row.height / 4].max
|
|
782
|
+
x = (width / 2.0) + (dx * cos) - (dy * sin)
|
|
783
|
+
y = (height / 2.0) + (dx * sin) + (dy * cos)
|
|
784
|
+
row.with_fields("left" => (x - (row.width / 2)).round, "top" => (y - (box_height / 2)).round, "height" => box_height.round)
|
|
785
|
+
end
|
|
786
|
+
"#{[tsv.lines.first.chomp, *rows.map { it.fields.join("\t") }].join("\n")}\n"
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# 傾き(度・時計回りに直す向きが正)。縮めた画像の濃い画素を角度ごとに回して行ごとに足し、行の並びが
|
|
790
|
+
# いちばん鋭く出る(行ごとの和の二乗和が最大の)角度を採る。範囲の端に当たったら、行の並びが
|
|
791
|
+
# 見つからない(絵だけのページなど)とみなして 0 を返す
|
|
792
|
+
def skew_angle(gray)
|
|
793
|
+
factor = [gray.width / DESKEW_WORK_WIDTH, 1].max
|
|
794
|
+
small = gray.shrink(factor, factor)
|
|
795
|
+
ink = (small < small.percent(50) - FIGURE_DARKNESS).ifthenelse(1.0, 0.0).copy_memory
|
|
796
|
+
best = (-DESKEW_RANGE..DESKEW_RANGE).step(DESKEW_STEP).max_by do |angle|
|
|
797
|
+
ink.similarity(angle:).project[1].to_a.flatten.sum { it * it }
|
|
798
|
+
end
|
|
799
|
+
best.abs >= DESKEW_RANGE - (DESKEW_STEP / 2) ? 0.0 : best
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# そのまま OCR に回せる埋め込みの画像を返す(無ければ nil)。
|
|
803
|
+
#
|
|
804
|
+
# OCR の座標は「画像がページ全体に、縦横比を保って広がっている」前提で
|
|
805
|
+
# ページの座標へ換算する(extract_ocr_lines・build_ocr_image_occurrence)。
|
|
806
|
+
# それを満たし、Tesseract が読める形式の画像だけを選ぶ。
|
|
807
|
+
#
|
|
808
|
+
# 解像度はページの寸法から計らない。ページを実物より大きく作ったスキャン PDF を設定の dpi で
|
|
809
|
+
# 描き直すと、情報の増えない引き伸ばしになる。画素の多寡は画像の幅で見る
|
|
810
|
+
# (MIN_OCR_IMAGE_WIDTH・upscale_for_ocr)。
|
|
811
|
+
#
|
|
812
|
+
# 回転の指定があるページは描き直しに任せる。画像を上下逆に置いた PDF は見分けられないが、
|
|
813
|
+
# スキャナが作る PDF では起きない
|
|
814
|
+
def embedded_scan_image(page, image_occurrences)
|
|
815
|
+
return nil unless page[:Rotate].to_i.zero?
|
|
816
|
+
|
|
817
|
+
box = media_box(page)
|
|
818
|
+
return nil unless box
|
|
819
|
+
|
|
820
|
+
page_width = box[2] - box[0]
|
|
821
|
+
page_height = box[3] - box[1]
|
|
822
|
+
|
|
823
|
+
Array(image_occurrences).find do |occurrence|
|
|
824
|
+
object = occurrence.object
|
|
825
|
+
next false unless object.is_a?(HexaPDF::Type::Image)
|
|
826
|
+
|
|
827
|
+
info = object.info
|
|
828
|
+
next false unless info.writable && %i[jpeg png].include?(info.type) && [1, 3].include?(info.components)
|
|
829
|
+
next false unless occurrence.width >= page_width * 0.98 && occurrence.height >= page_height * 0.98
|
|
830
|
+
|
|
831
|
+
# to_f を忘れない。配置を整数で書いた PDF では座標も Integer で来て、割り算が 0 になる
|
|
832
|
+
((info.width.to_f / info.height) - (occurrence.width.to_f / occurrence.height)).abs <= 0.02
|
|
833
|
+
end&.object
|
|
834
|
+
rescue StandardError
|
|
835
|
+
nil
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# 囲みや絵の多いページだけ、大津の二値化をかけた画像でも読み、確信度の高い字の多いほうを選ぶ。
|
|
839
|
+
# 二値化は地の色の付いた囲みのページでは認識率を上げるが、どのページにもかけると下がる。
|
|
840
|
+
# 選ぶ基準は確信度の平均ではなく、確信度の高い語の字数にする。平均で選ぶと、字を落とした読みを選ぶことがある。
|
|
841
|
+
# 選んだ読みの画像も返す。英語での読み直し(english_reading)は同じ画像を読む
|
|
842
|
+
# @return [Array(String, String, String)] [テキスト, TSV, 読んだ画像のパス]
|
|
843
|
+
def second_reading(image_path, temp_dir, text, tsv)
|
|
844
|
+
return [text, tsv, image_path] unless vips_available?
|
|
845
|
+
|
|
846
|
+
image = require_vips!::Image.new_from_file(image_path).colourspace(:b_w).extract_band(0)
|
|
847
|
+
histogram = image.hist_find.to_a.flatten
|
|
848
|
+
return [text, tsv, image_path] if tint_ratio(histogram) < TWO_PASS_TINT
|
|
849
|
+
|
|
850
|
+
binarized = File.join(temp_dir, "page-otsu.png")
|
|
851
|
+
(image > otsu_threshold(histogram)).write_to_file(binarized)
|
|
852
|
+
other_text, other_tsv = run_tesseract(binarized, File.join(temp_dir, "ocr-otsu"))
|
|
853
|
+
confident_characters(other_tsv) > confident_characters(tsv) ? [other_text, other_tsv, binarized] : [text, tsv, image_path]
|
|
854
|
+
rescue StandardError
|
|
855
|
+
[text, tsv, image_path]
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# 囲みの地の割合。紙の明るさ(中央値)より 10〜60 暗い面で、囲みや網掛けの背景がここに入る。
|
|
859
|
+
# スキャンした紙は真っ白ではない(中央値 226〜230)ので、紙を基準にする
|
|
860
|
+
def tint_ratio(histogram)
|
|
861
|
+
total = histogram.sum
|
|
862
|
+
return 0.0 unless total.positive?
|
|
863
|
+
|
|
864
|
+
cumulative = 0
|
|
865
|
+
median = histogram.index { (cumulative += it) * 2 >= total }.to_i
|
|
866
|
+
range = ([median - 60, 0].max..[median - 10, 0].max)
|
|
867
|
+
histogram[range].sum.fdiv(total)
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# 大津の方法で、明るさの分布を 2 つに分けるしきい値を求める
|
|
871
|
+
def otsu_threshold(histogram)
|
|
872
|
+
total = histogram.sum.to_f
|
|
873
|
+
sum_all = histogram.each_with_index.sum { |count, level| count * level }
|
|
874
|
+
background = 0.0
|
|
875
|
+
sum_background = 0.0
|
|
876
|
+
histogram.each_with_index.max_by do |count, level|
|
|
877
|
+
background += count
|
|
878
|
+
sum_background += count * level
|
|
879
|
+
foreground = total - background
|
|
880
|
+
next 0.0 if background.zero? || foreground.zero?
|
|
881
|
+
|
|
882
|
+
mean_background = sum_background / background
|
|
883
|
+
mean_foreground = (sum_all - sum_background) / foreground
|
|
884
|
+
background * foreground * ((mean_background - mean_foreground)**2)
|
|
885
|
+
end.last
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# 確信度が CONFIDENT_WORD 以上の語に含まれる字の数
|
|
889
|
+
def confident_characters(tsv)
|
|
890
|
+
rows = tsv.to_s.lines.map { it.chomp.split("\t", -1) }
|
|
891
|
+
header = rows.shift
|
|
892
|
+
return 0 unless header
|
|
893
|
+
|
|
894
|
+
index = header.each_with_index.to_h
|
|
895
|
+
rows.sum do |row|
|
|
896
|
+
next 0 unless row[index["level"]] == "5" && row[index["conf"]].to_f >= CONFIDENT_WORD
|
|
897
|
+
|
|
898
|
+
row[index["text"]].to_s.strip.length
|
|
899
|
+
end
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
# Tesseract が文字の無い範囲と判定した所を、切り出して読み直す。ページ全体を読むと、Tesseract は先に
|
|
903
|
+
# 紙面の割り付けを推定し、罫線で囲んだ表や色の帯の中の見出しを「文字の無いブロック」として扱う。
|
|
904
|
+
# 切り出して渡すと、その推定の影響を受けずに読める。
|
|
905
|
+
# 読み直しの確信度の高い字が十分にあり、もとの読みより多いときだけ、その範囲の語を置き換える。
|
|
906
|
+
# 図の範囲(extract_illustration_regions_vips)と重なる所は読み直さない。図の中の文字が本文に入り、
|
|
907
|
+
# その字を消して図を探すので図も検出できなくなるため
|
|
908
|
+
def reread_skipped_regions(image_path, temp_dir, tsv, page)
|
|
909
|
+
rows = parse_tsv_rows(tsv)
|
|
910
|
+
words = rows.select { it.level == "5" && !it.text.empty? }
|
|
911
|
+
image = require_vips!::Image.new_from_file(image_path)
|
|
912
|
+
replaced = []
|
|
913
|
+
added = []
|
|
914
|
+
figures = extract_illustration_regions_vips(image_path, extract_ocr_words(tsv, page), page).map { [it.left, it.top, it.left + it.width, it.top + it.height] }
|
|
915
|
+
regions = skipped_regions(rows, words, image.width, image.height).reject do |box|
|
|
916
|
+
figures.any? { |figure| [[box[2], figure[2]].min - [box[0], figure[0]].max, 0].max * [[box[3], figure[3]].min - [box[1], figure[1]].max, 0].max > (box[2] - box[0]) * (box[3] - box[1]) * 0.3 }
|
|
917
|
+
end
|
|
918
|
+
regions.each_with_index do |box, number|
|
|
919
|
+
inside = words.select { it.center_x.between?(box[0], box[2]) && it.center_y.between?(box[1], box[3]) }
|
|
920
|
+
left, top, crop = crop_for_reread(image, box, temp_dir, number)
|
|
921
|
+
_text, region_tsv = run_tesseract(crop, File.join(temp_dir, "reread-#{number}"))
|
|
922
|
+
region_rows = parse_tsv_rows(region_tsv).reject { it.level == "1" }
|
|
923
|
+
next unless better_reading?(region_rows, inside)
|
|
924
|
+
|
|
925
|
+
replaced.concat(inside)
|
|
926
|
+
added.concat(region_rows.map do |row|
|
|
927
|
+
row.with_fields("left" => row.left.round + left, "top" => row.top.round + top, "block_num" => row["block_num"].to_i + (1000 * (number + 1)))
|
|
928
|
+
end)
|
|
929
|
+
end
|
|
930
|
+
return tsv if added.empty?
|
|
931
|
+
|
|
932
|
+
"#{[tsv.lines.first.chomp, *(rows - replaced).map { it.fields.join("\t") }, *added.map { it.fields.join("\t") }].join("\n")}\n"
|
|
933
|
+
rescue StandardError
|
|
934
|
+
tsv
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# 読み直す範囲 [左, 上, 右, 下](画素)。語の無いブロックのうち、接し合うものを 1 つにまとめる(表は
|
|
938
|
+
# 罫線で区切られた幾つものブロックになり、別々に読むと切り口の字を読み違える)。小さいもの、ページの
|
|
939
|
+
# 半分を超えるもの、すでに読めた語が REREAD_MAX_COVERAGE を超えて覆うもの(地の色の付いた囲みの
|
|
940
|
+
# 中の文は別のブロックで読めている)は除く
|
|
941
|
+
def skipped_regions(rows, words, width, height)
|
|
942
|
+
word_blocks = words.map { it["block_num"] }.uniq
|
|
943
|
+
unit = width / 595.0
|
|
944
|
+
boxes = rows.select { it.level == "2" && !word_blocks.include?(it["block_num"]) }.map { [it.left, it.top, it.right, it.bottom] }
|
|
945
|
+
clusters = []
|
|
946
|
+
boxes.each do |box|
|
|
947
|
+
joined = clusters.select { |group| group.any? { touching?(it, box, unit * 2) } }
|
|
948
|
+
clusters = clusters - joined + [joined.flatten(1) + [box]]
|
|
949
|
+
end
|
|
950
|
+
clusters.map { |group| [group.map { it[0] }.min, group.map { it[1] }.min, group.map { it[2] }.max, group.map { it[3] }.max] }.select do |left, top, right, bottom|
|
|
951
|
+
area = (right - left) * (bottom - top)
|
|
952
|
+
covered = words.sum { [[it.right, right].min - [it.left, left].max, 0].max * [[it.bottom, bottom].min - [it.top, top].max, 0].max }
|
|
953
|
+
right - left >= REREAD_MIN_WIDTH * unit && bottom - top >= REREAD_MIN_HEIGHT * unit && area <= width * height * 0.5 && covered <= area * REREAD_MAX_COVERAGE
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def touching?(a, b, gap) = a[0] - gap <= b[2] && b[0] - gap <= a[2] && a[1] - gap <= b[3] && b[1] - gap <= a[3]
|
|
958
|
+
|
|
959
|
+
# 範囲を余白つきで切り出して書き、[左, 上, パス] を返す。余白が狭いと字を誤認識し、広いと周りの本文が入る
|
|
960
|
+
def crop_for_reread(image, box, temp_dir, number)
|
|
961
|
+
padding = (image.width * REREAD_PADDING).round
|
|
962
|
+
left = [box[0].floor - padding, 0].max
|
|
963
|
+
top = [box[1].floor - padding, 0].max
|
|
964
|
+
right = [box[2].ceil + padding, image.width].min
|
|
965
|
+
bottom = [box[3].ceil + padding, image.height].min
|
|
966
|
+
path = File.join(temp_dir, "reread-#{number}.png")
|
|
967
|
+
image.crop(left, top, right - left, bottom - top).write_to_file(path)
|
|
968
|
+
[left, top, path]
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# 読み直しを採るか。確信度 CONFIDENT_WORD 以上の字が 4 字以上で、読んだ字の半分以上を占め、
|
|
972
|
+
# もとの読みのその範囲の確信度の高い字より多いこと
|
|
973
|
+
def better_reading?(region_rows, inside)
|
|
974
|
+
region_words = region_rows.select { it.level == "5" && !it.text.empty? }
|
|
975
|
+
confident = ->(words) { words.select { it.conf >= CONFIDENT_WORD }.sum { it.text.length } }
|
|
976
|
+
confident.(region_words) >= 4 && confident.(region_words) * 2 >= region_words.sum { it.text.length } &&
|
|
977
|
+
confident.(region_words) > confident.(inside)
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# 斜体の行を、傾きを戻して読み直す。和文に斜体の書体は無く、Chrome は字を傾けて描く。
|
|
981
|
+
# Tesseract は傾いた和文を認識できないので、確信度の低い行だけを切り出し、SLANT_ANGLE だけ逆に傾けて読む
|
|
982
|
+
def reread_slanted_lines(image_path, temp_dir, tsv)
|
|
983
|
+
return tsv unless @ocr.languages.include?("jpn")
|
|
984
|
+
|
|
985
|
+
rows = parse_tsv_rows(tsv)
|
|
986
|
+
heights = rows.select { it.level == "4" }.map(&:height).sort
|
|
987
|
+
return tsv if heights.empty?
|
|
988
|
+
|
|
989
|
+
line_height = heights[heights.size / 2]
|
|
990
|
+
candidates = rows.select { it.level == "5" && !it.text.empty? }.group_by(&:line_key).values.select do |words|
|
|
991
|
+
words.size >= 2 && words.sum { it.text.length } >= SLANT_MIN_CHARACTERS && mean_confidence(words) < SLANT_MAX_CONFIDENCE &&
|
|
992
|
+
words.map(&:bottom).max - words.map(&:top).min <= line_height * 2.5
|
|
993
|
+
end
|
|
994
|
+
return tsv if candidates.empty?
|
|
995
|
+
|
|
996
|
+
image = require_vips!::Image.new_from_file(image_path)
|
|
997
|
+
replaced = []
|
|
998
|
+
added = []
|
|
999
|
+
candidates.each_with_index do |words, number|
|
|
1000
|
+
reading = read_unslanted(image, words, temp_dir, number.to_s, SLANT_ANGLE, 100_000 * (number + 1))
|
|
1001
|
+
next unless better_unslanted?(reading, words)
|
|
1002
|
+
next unless better_unslanted?(reading, read_unslanted(image, words, temp_dir, "#{number}-upright", 0, 0))
|
|
1003
|
+
|
|
1004
|
+
replaced.concat(words)
|
|
1005
|
+
added.concat(reading)
|
|
1006
|
+
end
|
|
1007
|
+
return tsv if added.empty?
|
|
1008
|
+
|
|
1009
|
+
"#{[tsv.lines.first.chomp, *(rows - replaced).map { it.fields.join("\t") }, *added.map { it.fields.join("\t") }].join("\n")}\n"
|
|
1010
|
+
rescue StandardError
|
|
1011
|
+
tsv
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# 行を余白つきで切り出し、angle(度)だけ傾きを戻して読む。返す行(TSV の行と語)の位置はページの画素へ戻す。
|
|
1015
|
+
# 傾きを戻すと字は行の高さに応じて右へずれるので、行の中ほどのずれを引く。ブロックの番号は block_offset だけ
|
|
1016
|
+
# ずらす(読み飛ばした範囲の読み直しの 1000 単位と重ならないよう、100000 単位にする)
|
|
1017
|
+
def read_unslanted(image, words, temp_dir, name, angle, block_offset)
|
|
1018
|
+
top = words.map(&:top).min
|
|
1019
|
+
height = words.map(&:bottom).max - top
|
|
1020
|
+
left = [words.map(&:left).min.floor - height.ceil, 0].max
|
|
1021
|
+
crop_top = [top.floor - (height / 3).ceil, 0].max
|
|
1022
|
+
right = [words.map(&:right).max.ceil + height.ceil, image.width].min
|
|
1023
|
+
bottom = [(top + height).ceil + (height / 3).ceil, image.height].min
|
|
1024
|
+
shear = Math.tan(angle * Math::PI / 180)
|
|
1025
|
+
crop = image.crop(left, crop_top, right - left, bottom - crop_top)
|
|
1026
|
+
paper = crop.colourspace(:b_w).extract_band(0).percent(50)
|
|
1027
|
+
path = File.join(temp_dir, "slant-#{name}.png")
|
|
1028
|
+
crop.affine([1, shear, 0, 1], background: Array.new(crop.bands, paper)).write_to_file(path)
|
|
1029
|
+
|
|
1030
|
+
_text, line_tsv = run_tesseract(path, File.join(temp_dir, "slant-#{name}"))
|
|
1031
|
+
parse_tsv_rows(line_tsv).select { %w[4 5].include?(it.level) }.map do |row|
|
|
1032
|
+
row.with_fields("left" => (row.left - (shear * row.center_y) + left).round, "top" => row.top.round + crop_top,
|
|
1033
|
+
"block_num" => row["block_num"].to_i + block_offset)
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
# 傾きを戻した読みを採るか。確信度の高い字が 4 字以上、半分以上が和文で、比べる読み(もとの読みと、同じ所を
|
|
1038
|
+
# 傾けずに切り出した読み)より確信度の高い字が十分に多いこと。読めた字の半分以上が確信度の高い字であるか、
|
|
1039
|
+
# 20 字以上あること(引用の行の後ろに崩れた字が続くことがある)。傾けずに切り出した読みとも比べるのは、
|
|
1040
|
+
# 切り出すだけでよく読める行(和文の文字列を含むコードの行)を、崩れた読みで置き換えないため
|
|
1041
|
+
def better_unslanted?(reading, words)
|
|
1042
|
+
read = reading.select { it.level == "5" && !it.text.empty? }
|
|
1043
|
+
confident = ->(list) { list.select { it.level == "5" && it.conf >= CONFIDENT_WORD }.sum { it.text.length } }
|
|
1044
|
+
total = read.sum { it.text.length }
|
|
1045
|
+
gained = confident.(read)
|
|
1046
|
+
original = confident.(words)
|
|
1047
|
+
japanese = read.sum { |word| word.text.each_char.count { !it.ascii_only? } }
|
|
1048
|
+
gained >= 4 && japanese * 2 >= total && gained > original + [4, original * 0.3].max &&
|
|
1049
|
+
(gained * 2 >= total || gained >= 20)
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# 英字の多いページだけ、英語の学習データでも読み、英字の語を語の位置で突き合わせて採る。
|
|
1053
|
+
# 日本語+英語で読むと、コードの `@param` を `Gparam` のように誤認識する。英語だけで読むと和文を認識できない。
|
|
1054
|
+
# そこで和文の語は日本語+英語の読みのまま残し、英字の語の連なりだけを差し替える
|
|
1055
|
+
def english_reading(image_path, temp_dir, tsv)
|
|
1056
|
+
return tsv unless @ocr.languages.include?("eng") && @ocr.languages.size > 1
|
|
1057
|
+
|
|
1058
|
+
rows = parse_tsv_rows(tsv)
|
|
1059
|
+
return tsv if rows.count { it.level == "5" && it.latin? && it.text.match?(/[A-Za-z]/) } < ENGLISH_PAGE_WORDS
|
|
1060
|
+
|
|
1061
|
+
_text, english_tsv = run_tesseract(image_path, File.join(temp_dir, "ocr-eng"), languages: ["eng"])
|
|
1062
|
+
merge_english_words(tsv, english_tsv)
|
|
1063
|
+
rescue StandardError
|
|
1064
|
+
tsv
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
# 日本語+英語の読みの各行で、英字の語の連なり(和文の語にはさまれた範囲)ごとに、英語の読みの語を
|
|
1068
|
+
# 当てはめる(english_replacement)。差し替えた語には元の行の番号を付けるので、本体に渡す語の
|
|
1069
|
+
# ベースラインと大きさは、日本語+英語の読みの行の枠から取られる(extract_ocr_words)
|
|
1070
|
+
def merge_english_words(tsv, english_tsv)
|
|
1071
|
+
rows = parse_tsv_rows(tsv)
|
|
1072
|
+
english_words = parse_tsv_rows(english_tsv).select { it.level == "5" && !it.text.empty? && it.latin? }
|
|
1073
|
+
lines = rows.select { it.level == "4" }.to_h { [it.line_key, it] }
|
|
1074
|
+
|
|
1075
|
+
merged = rows.slice_when { |a, b| a.level != "5" || b.level != "5" || a.line_key != b.line_key }.flat_map do |group|
|
|
1076
|
+
line = lines[group.first.line_key]
|
|
1077
|
+
next group unless group.first.level == "5" && line
|
|
1078
|
+
|
|
1079
|
+
group.chunk_while { |a, b| a.latin? && b.latin? }.flat_map do |run|
|
|
1080
|
+
replacement = english_replacement(run, group, line, english_words)
|
|
1081
|
+
next run unless replacement
|
|
1082
|
+
|
|
1083
|
+
english_words -= replacement
|
|
1084
|
+
replacement.map { relocate_english_word(it, run) }
|
|
1085
|
+
end
|
|
1086
|
+
end
|
|
1087
|
+
"#{[tsv.lines.first.chomp, *merged.map { it.fields.join("\t") }].join("\n")}\n"
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
# 英字の語の連なり run に当てはめる英語の読みの語を返す(当てはめないなら nil)。
|
|
1091
|
+
# 中心が run の範囲(横は語の両端、縦は行の枠)に入る語を集め、次をすべて満たすときに採る。
|
|
1092
|
+
# - 2 つの読みで食い違う語について、英語の読みの確信度の平均が ENGLISH_MIN_CONFIDENCE 以上で、
|
|
1093
|
+
# 日本語+英語の読みより高い。run 全体の平均で比べると、確信度の高い共通の語(`showcase}`)に
|
|
1094
|
+
# 引っ張られて、「::」を「3:」と読んだ側を採ってしまう
|
|
1095
|
+
# - 英語の読みの語が run の幅の 8 割以上を覆い、字数が 0.6〜1.6 倍に収まる。端の「"」や「:」を
|
|
1096
|
+
# 落とした読みを採らない
|
|
1097
|
+
def english_replacement(run, line_words, line, english_words)
|
|
1098
|
+
return nil unless run.all?(&:latin?) && run.any? { it.text.match?(/[A-Za-z0-9]/) }
|
|
1099
|
+
|
|
1100
|
+
left = run.map(&:left).min
|
|
1101
|
+
right = run.map(&:right).max
|
|
1102
|
+
japanese = line_words.reject(&:latin?)
|
|
1103
|
+
candidates = english_words.select do |word|
|
|
1104
|
+
word.center_x.between?(left, right) && word.center_y.between?(line.top, line.bottom) &&
|
|
1105
|
+
japanese.none? { [word.right, it.right].min - [word.left, it.left].max > word.width / 2 }
|
|
1106
|
+
end
|
|
1107
|
+
return nil if candidates.empty?
|
|
1108
|
+
|
|
1109
|
+
differing = candidates.reject { |word| run.any? { it.text == word.text } }
|
|
1110
|
+
replaced = run.reject { |word| candidates.any? { it.text == word.text } }
|
|
1111
|
+
return nil if differing.empty?
|
|
1112
|
+
return nil unless mean_confidence(differing) >= ENGLISH_MIN_CONFIDENCE && mean_confidence(differing) > mean_confidence(replaced)
|
|
1113
|
+
|
|
1114
|
+
characters = run.sum { it.text.length }
|
|
1115
|
+
covered = [candidates.map(&:right).max, right].min - [candidates.map(&:left).min, left].max
|
|
1116
|
+
return nil unless candidates.sum { it.text.length }.between?(characters * 0.6, characters * 1.6)
|
|
1117
|
+
return nil if covered < (right - left) * 0.8
|
|
1118
|
+
|
|
1119
|
+
candidates
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# 英語の読みの語を、日本語+英語の読みの行へ移す。行の番号を合わせ、枠は run の範囲に収める。
|
|
1123
|
+
# 枠がはみ出すと、隣の和文の語との空白が消える(「方向に delta」→「方向にdelta」)
|
|
1124
|
+
def relocate_english_word(word, run)
|
|
1125
|
+
left = [word.left, run.map(&:left).min].max
|
|
1126
|
+
right = [word.right, run.map(&:right).max].min
|
|
1127
|
+
word.with_fields(LINE_KEY.to_h { [it, run.first[it]] }.merge("left" => left.round, "width" => [right - left, 1].max.round))
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
# 字数で重みを付けた確信度の平均。語が無ければ 0
|
|
1131
|
+
def mean_confidence(words)
|
|
1132
|
+
characters = words.sum { it.text.length }
|
|
1133
|
+
characters.zero? ? 0.0 : words.sum { it.conf * it.text.length } / characters
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
# Tesseract の TSV を TsvRow の並びにする(見出しの行は除く)
|
|
1137
|
+
def parse_tsv_rows(tsv)
|
|
1138
|
+
header, *rows = tsv.to_s.lines.map { it.chomp.split("\t", -1) }
|
|
1139
|
+
return [] unless header
|
|
1140
|
+
|
|
1141
|
+
index = header.each_with_index.to_h
|
|
1142
|
+
rows.map { TsvRow.new(fields: it.fill("", it.length...header.length), index:) }
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
# Tesseract を 1 回だけ起動し、プレーンテキストと TSV(座標)を同時に書き出す。
|
|
1146
|
+
# テキストは TSV から行を組めなかったときの予備
|
|
1147
|
+
# @return [Array(String, String)] [テキスト, TSV]
|
|
1148
|
+
def run_tesseract(image_path, output_base, languages: @ocr.languages)
|
|
1149
|
+
command = ["tesseract", image_path, output_base, "-l", languages.join("+"), "--psm", @ocr.psm.to_s, "txt", "tsv"]
|
|
1150
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
1151
|
+
raise Error, [stderr, stdout].find { !it.to_s.strip.empty? }.to_s.strip unless status.success?
|
|
1152
|
+
|
|
1153
|
+
%w[txt tsv].map { File.read("#{output_base}.#{it}", encoding: "UTF-8") }
|
|
1154
|
+
end
|
|
1155
|
+
|
|
528
1156
|
# pdftoppm によるページ画像レンダリングコマンドを組み立てる
|
|
529
1157
|
def ocr_render_command(prefix, page_number)
|
|
530
1158
|
[
|
|
@@ -538,30 +1166,6 @@ module VivlioStarter
|
|
|
538
1166
|
]
|
|
539
1167
|
end
|
|
540
1168
|
|
|
541
|
-
# Tesseract によるプレーンテキスト出力コマンドを組み立てる
|
|
542
|
-
def ocr_text_command(image_path)
|
|
543
|
-
[
|
|
544
|
-
"tesseract",
|
|
545
|
-
image_path,
|
|
546
|
-
"stdout",
|
|
547
|
-
"-l", @ocr.languages.join("+"),
|
|
548
|
-
"--psm", @ocr.psm.to_s
|
|
549
|
-
]
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
# Tesseract による TSV 出力コマンドを組み立てる(座標情報取得用)
|
|
553
|
-
def ocr_tsv_command(image_path)
|
|
554
|
-
ocr_text_command(image_path) + ["tsv"]
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
# 外部コマンドを実行し、stdout を返す。失敗時は Error を送出する
|
|
558
|
-
def capture_ocr_output(*command)
|
|
559
|
-
stdout, stderr, status = Open3.capture3(*command)
|
|
560
|
-
raise Error, [stderr, stdout].find { !it.to_s.strip.empty? }.to_s.strip unless status.success?
|
|
561
|
-
|
|
562
|
-
stdout
|
|
563
|
-
end
|
|
564
|
-
|
|
565
1169
|
# Tesseract TSV 出力を解析し、行単位の Line 配列を構築する
|
|
566
1170
|
# TSV のワード座標を PDF ページ座標に変換して Y 位置を算出する
|
|
567
1171
|
def extract_ocr_lines(tsv, page)
|
|
@@ -615,6 +1219,51 @@ module VivlioStarter
|
|
|
615
1219
|
[]
|
|
616
1220
|
end
|
|
617
1221
|
|
|
1222
|
+
# Tesseract TSV の語(level 5)を、PDF のページ座標の OcrWord にする。
|
|
1223
|
+
# ベースラインと大きさは、語ではなく行(level 4)の枠から取る。語ごとの枠は字の形
|
|
1224
|
+
# (句読点・小書き)で上下するので、同じ行の語がそろわなくなる
|
|
1225
|
+
def extract_ocr_words(tsv, page)
|
|
1226
|
+
rows = tsv.to_s.lines.map { it.chomp.split("\t", -1) }
|
|
1227
|
+
header = rows.shift
|
|
1228
|
+
box = media_box(page)
|
|
1229
|
+
return [] unless header && box
|
|
1230
|
+
|
|
1231
|
+
index = header.each_with_index.to_h
|
|
1232
|
+
value = ->(row, name) { row[index.fetch(name)] }
|
|
1233
|
+
image = rows.find { value.(it, "level") == "1" }
|
|
1234
|
+
return [] unless image
|
|
1235
|
+
|
|
1236
|
+
scale_x = (box[2] - box[0]) / value.(image, "width").to_f
|
|
1237
|
+
scale_y = (box[3] - box[1]) / value.(image, "height").to_f
|
|
1238
|
+
line_key = ->(row) { %w[page_num block_num par_num line_num].map { value.(row, it) } }
|
|
1239
|
+
lines = rows.select { value.(it, "level") == "4" }.to_h { [line_key.(it), it] }
|
|
1240
|
+
|
|
1241
|
+
rows.filter_map do |row|
|
|
1242
|
+
text = value.(row, "text").to_s.strip
|
|
1243
|
+
line = lines[line_key.(row)]
|
|
1244
|
+
next if value.(row, "level") != "5" || text.empty? || line.nil?
|
|
1245
|
+
|
|
1246
|
+
line_top = value.(line, "top").to_f
|
|
1247
|
+
line_height = value.(line, "height").to_f
|
|
1248
|
+
OcrWord.new(
|
|
1249
|
+
text:,
|
|
1250
|
+
x: box[0] + (value.(row, "left").to_f * scale_x),
|
|
1251
|
+
y: box[3] - ((line_top + line_height) * scale_y),
|
|
1252
|
+
width: value.(row, "width").to_f * scale_x,
|
|
1253
|
+
size: line_height * scale_y,
|
|
1254
|
+
conf: value.(row, "conf").to_f
|
|
1255
|
+
)
|
|
1256
|
+
end
|
|
1257
|
+
rescue KeyError, ZeroDivisionError
|
|
1258
|
+
[]
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
# ページの幅と高さ(pt)
|
|
1262
|
+
def page_size(page)
|
|
1263
|
+
box = media_box(page)
|
|
1264
|
+
box ? [box[2] - box[0], box[3] - box[1]] : nil
|
|
1265
|
+
end
|
|
1266
|
+
|
|
618
1267
|
# PNG ファイルの幅と高さをヘッダから読み取る
|
|
619
1268
|
def png_dimensions(path)
|
|
620
1269
|
header = File.binread(path, 24)
|
|
@@ -630,173 +1279,134 @@ module VivlioStarter
|
|
|
630
1279
|
inline_occurrences = Array(image_occurrences).reject { scanned_page_image_occurrence?(page, it) }
|
|
631
1280
|
return inline_occurrences unless scanned_page_image?(page, image_occurrences) || inline_occurrences.empty?
|
|
632
1281
|
|
|
633
|
-
inline_occurrences + extract_illustration_regions_vips(result.source_image_path).map { build_ocr_image_occurrence(page, it, result) }
|
|
1282
|
+
inline_occurrences + extract_illustration_regions_vips(result.source_image_path, result.words, page).map { build_ocr_image_occurrence(page, it, result) }
|
|
634
1283
|
rescue StandardError
|
|
635
1284
|
inline_occurrences
|
|
636
1285
|
end
|
|
637
1286
|
|
|
638
|
-
#
|
|
639
|
-
#
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
max_gap: [20, (image.height * 0.006).round].max,
|
|
653
|
-
min_height: [100, (image.height * 0.03).round].max
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
regions.filter_map { build_foreground_region(mask, it, image.width, image.height) }
|
|
1287
|
+
# OCR したページの画像から図の範囲を見つける。
|
|
1288
|
+
# ページを縮め、紙より濃い画素と色の付いた画素を拾い、OCR で読めた文字(確信度の高い語と行)を
|
|
1289
|
+
# 消してから、残ったまとまりを図とみなす。白地の線だけの図は、文字を消すと線しか残らず、検出できない
|
|
1290
|
+
# @param words [Array<OcrWord>] このページの OCR の語(PDF のページの座標)
|
|
1291
|
+
# @return [Array<IllustrationRegion>] 元の画像の画素での範囲
|
|
1292
|
+
def extract_illustration_regions_vips(source_image_path, words, page)
|
|
1293
|
+
image = require_vips!::Image.new_from_file(source_image_path)
|
|
1294
|
+
factor = [(image.width / FIGURE_WORK_WIDTH.to_f).round, 1].max
|
|
1295
|
+
small = image.shrink(factor, factor)
|
|
1296
|
+
ink = figure_ink(small)
|
|
1297
|
+
erase_text(ink, small.width, small.height, words, page)
|
|
1298
|
+
figure_boxes(ink, small.width, small.height).map do |left, top, right, bottom|
|
|
1299
|
+
IllustrationRegion.new(left: left * factor, top: top * factor, width: (right - left) * factor, height: (bottom - top) * factor)
|
|
1300
|
+
end
|
|
657
1301
|
end
|
|
658
1302
|
|
|
659
|
-
#
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
1303
|
+
# 図の候補の画素(左上から行ごとに並べた真偽)。紙の明るさ(中央値)より FIGURE_DARKNESS 以上
|
|
1304
|
+
# 暗いか、彩度が FIGURE_CHROMA を超える画素。地の色の付いた囲み(紙より 10〜40 暗い)は入らない
|
|
1305
|
+
def figure_ink(small)
|
|
1306
|
+
small = small.extract_band(0, n: 3) if small.bands > 3
|
|
1307
|
+
gray = small.colourspace(:b_w).extract_band(0)
|
|
1308
|
+
histogram = gray.hist_find.to_a.flatten
|
|
1309
|
+
total = histogram.sum
|
|
1310
|
+
cumulative = 0
|
|
1311
|
+
paper = histogram.index { (cumulative += it) * 2 >= total }
|
|
1312
|
+
mask = gray < (paper - FIGURE_DARKNESS)
|
|
1313
|
+
mask |= small.colourspace(:lch).extract_band(1) > FIGURE_CHROMA if small.bands >= 3
|
|
1314
|
+
mask.cast(:uchar).write_to_memory.unpack("C*").map(&:positive?)
|
|
667
1315
|
end
|
|
668
1316
|
|
|
669
|
-
#
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1317
|
+
# 確信度の高い語と、語の確信度の平均が高い行の範囲を、図の候補から消す。行ごと消すのは、語の
|
|
1318
|
+
# あいだの隙間に残る字の切れ端で、本文の行が図の候補につながらないようにするため
|
|
1319
|
+
def erase_text(ink, width, height, words, page)
|
|
1320
|
+
box = media_box(page)
|
|
1321
|
+
scale_x = width / (box[2] - box[0])
|
|
1322
|
+
scale_y = height / (box[3] - box[1])
|
|
1323
|
+
lines = words.group_by { [it.y, it.size] }.values.select { |line| line.sum(&:conf) / line.size >= FIGURE_TEXT_CONFIDENCE }
|
|
1324
|
+
spans = lines.map { |line| [line.map(&:x).min, line.first.y, line.map { it.x + it.width }.max, line.first.y + line.first.size] } +
|
|
1325
|
+
words.select { it.conf >= FIGURE_TEXT_CONFIDENCE }.map { [it.x, it.y, it.x + it.width, it.y + it.size] }
|
|
1326
|
+
spans.each do |left, bottom, right, top|
|
|
1327
|
+
columns = ((left - box[0]) * scale_x).floor.clamp(0, width)...((right - box[0]) * scale_x).ceil.clamp(0, width)
|
|
1328
|
+
rows = ((box[3] - top) * scale_y).floor.clamp(0, height)...((box[3] - bottom) * scale_y).ceil.clamp(0, height)
|
|
1329
|
+
rows.each { |y| columns.each { |x| ink[(y * width) + x] = false } }
|
|
674
1330
|
end
|
|
675
1331
|
end
|
|
676
1332
|
|
|
677
|
-
#
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1333
|
+
# 図の候補の画素のまとまりを、図の枠 [左, 上, 右, 下](縮めた画像の画素)にする。
|
|
1334
|
+
# 隣り合う画素を 1 画素膨らませてつなぎ、枠は膨らませる前の画素で取る。枠は、埋まった列・行の
|
|
1335
|
+
# 最も長い連なりへ削る(trim_to_dense)。ページの端に接するもの(ページ上端の飾りの帯・色を
|
|
1336
|
+
# 敷いた章扉)は図にしない。図にすると、その上の文字を本体が本文から除いてしまう
|
|
1337
|
+
def figure_boxes(ink, width, height)
|
|
1338
|
+
grown = ink.each_index.map { |cell| ink[cell] || neighbor_cells(cell, width, ink.size).any? { ink[it] } }
|
|
1339
|
+
seen = Array.new(ink.size, false)
|
|
1340
|
+
grown.each_index.filter_map do |start|
|
|
1341
|
+
next unless grown[start] && !seen[start]
|
|
1342
|
+
|
|
1343
|
+
# つながった画素を幅優先でたどる(each は、たどる途中で足した画素も回る)
|
|
1344
|
+
seen[start] = true
|
|
1345
|
+
cells = [start]
|
|
1346
|
+
cells.each do |cell|
|
|
1347
|
+
neighbor_cells(cell, width, ink.size).each do |neighbor|
|
|
1348
|
+
next if seen[neighbor] || !grown[neighbor]
|
|
1349
|
+
|
|
1350
|
+
seen[neighbor] = true
|
|
1351
|
+
cells << neighbor
|
|
1352
|
+
end
|
|
694
1353
|
end
|
|
695
|
-
|
|
696
|
-
|
|
1354
|
+
inked = cells.select { ink[it] }
|
|
1355
|
+
next if inked.empty?
|
|
1356
|
+
|
|
1357
|
+
whole = [inked.map { it % width }.min, inked.map { it / width }.min, inked.map { it % width }.max + 1, inked.map { it / width }.max + 1]
|
|
1358
|
+
box = trim_to_dense(ink, width, whole)
|
|
1359
|
+
next box if box && figure_box?(box, cells.size, width, height)
|
|
1360
|
+
# 白地の多い絵は、埋まった列・行がまばらで、削ると下限より小さくなる。
|
|
1361
|
+
# 削る前の枠が図の条件を満たせば、そちらを採る
|
|
1362
|
+
whole if figure_box?(whole, cells.size, width, height)
|
|
697
1363
|
end
|
|
698
1364
|
end
|
|
699
1365
|
|
|
700
|
-
#
|
|
701
|
-
def
|
|
702
|
-
|
|
1366
|
+
# 上下左右の画素の番号(左右の端では折り返さない)
|
|
1367
|
+
def neighbor_cells(cell, width, size)
|
|
1368
|
+
column = cell % width
|
|
1369
|
+
[cell - width, cell + width, (cell - 1 if column.positive?), (cell + 1 if column < width - 1)].compact.select { it.between?(0, size - 1) }
|
|
703
1370
|
end
|
|
704
1371
|
|
|
705
|
-
#
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
def find_regions_from_profile(profile, image_height, threshold: 0.5, max_gap:, min_height:)
|
|
717
|
-
regions = []
|
|
718
|
-
in_region = false
|
|
719
|
-
start_y = 0
|
|
720
|
-
gap = 0
|
|
721
|
-
|
|
722
|
-
Array(profile).each_with_index do |value, y|
|
|
723
|
-
if value.to_f >= threshold
|
|
724
|
-
start_y = y unless in_region
|
|
725
|
-
in_region = true
|
|
726
|
-
gap = 0
|
|
727
|
-
elsif in_region
|
|
728
|
-
gap += 1
|
|
729
|
-
next unless gap > max_gap
|
|
730
|
-
|
|
731
|
-
height = y - start_y - gap + 1
|
|
732
|
-
regions << { y: start_y, height: } if height >= min_height
|
|
733
|
-
in_region = false
|
|
734
|
-
gap = 0
|
|
735
|
-
end
|
|
1372
|
+
# 枠を、図の画素が FIGURE_DENSE 以上埋まった列の最も長い連なりへ、続いて同じく行の連なりへ削る。
|
|
1373
|
+
# 写真は埋まり、写真の横の消し残した字(小さなルビ・確信度の低い語)はまばらなので、写真に
|
|
1374
|
+
# つながった本文の字の列が枠から外れる。
|
|
1375
|
+
# 埋まった列・行のあいだに、少しは埋まった(FIGURE_SPARSE 以上の)列・行が FIGURE_BRIDGE 本以内はさまる
|
|
1376
|
+
# だけなら、続きとみなす。白地の多い絵や表紙は、区画の境目にそうした行がはさまるため
|
|
1377
|
+
def trim_to_dense(ink, width, box)
|
|
1378
|
+
left, top, right, bottom = box
|
|
1379
|
+
longest = lambda do |ratios|
|
|
1380
|
+
flags = bridge_sparse(ratios.map { it >= FIGURE_DENSE }, ratios)
|
|
1381
|
+
run = flags.each_with_index.chunk_while { |(a, i), (b, j)| a == b && j == i + 1 }.select { it.first.first }.max_by(&:size)
|
|
1382
|
+
run && [run.first.last, run.last.last + 1]
|
|
736
1383
|
end
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
regions
|
|
1384
|
+
column_ratios = (left...right).map { |x| (top...bottom).count { |y| ink[(y * width) + x] }.fdiv(bottom - top) }
|
|
1385
|
+
columns = longest.(column_ratios) or return nil
|
|
1386
|
+
left, right = left + columns[0], left + columns[1]
|
|
1387
|
+
row_ratios = (top...bottom).map { |y| (left...right).count { |x| ink[(y * width) + x] }.fdiv(right - left) }
|
|
1388
|
+
rows = longest.(row_ratios) or return nil
|
|
1389
|
+
[left, top + rows[0], right, top + rows[1]]
|
|
744
1390
|
end
|
|
745
1391
|
|
|
746
|
-
#
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
slice = mask.crop(0, top, image_width, height)
|
|
752
|
-
active_columns = column_activity(slice, image_width, height)
|
|
753
|
-
return nil if active_columns.empty?
|
|
754
|
-
|
|
755
|
-
left = [active_columns.min - 8, 0].max
|
|
756
|
-
right = [active_columns.max + 9, image_width].min
|
|
757
|
-
width = right - left
|
|
758
|
-
candidate = IllustrationRegion.new(left:, top: [top - 8, 0].max, width:, height: [height + 16, image_height - top].min)
|
|
759
|
-
expanded = expand_illustration_region(candidate, image_width, image_height)
|
|
760
|
-
return nil unless illustration_region_candidate?(expanded, image_width, image_height)
|
|
761
|
-
|
|
762
|
-
area_ratio = (expanded.width * expanded.height).to_f / (image_width * image_height)
|
|
763
|
-
return nil if area_ratio < MIN_ILLUSTRATION_AREA_RATIO
|
|
1392
|
+
# 埋まった列・行にはさまれた、FIGURE_BRIDGE 本以内の少しは埋まった列・行を、埋まったものとみなす
|
|
1393
|
+
def bridge_sparse(flags, ratios)
|
|
1394
|
+
flags.each_index.chunk_while { |i, j| flags[i] == flags[j] }.each_with_object(flags.dup) do |run, bridged|
|
|
1395
|
+
next if flags[run.first] || run.size > FIGURE_BRIDGE || run.first.zero? || run.last == flags.size - 1
|
|
1396
|
+
next unless run.all? { ratios[it] >= FIGURE_SPARSE }
|
|
764
1397
|
|
|
765
|
-
|
|
766
|
-
rescue StandardError
|
|
767
|
-
nil
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
# マスク画像のスライスから前景活性のある列インデックスを収集する
|
|
771
|
-
def column_activity(slice, image_width, height)
|
|
772
|
-
(0...image_width).each_with_object([]) do |x, active|
|
|
773
|
-
column = slice.crop(x, 0, 1, height)
|
|
774
|
-
activity = column.avg / 255.0
|
|
775
|
-
active << x if activity >= COLUMN_ACTIVITY_THRESHOLD
|
|
1398
|
+
run.each { bridged[it] = true }
|
|
776
1399
|
end
|
|
777
1400
|
end
|
|
778
1401
|
|
|
779
|
-
#
|
|
780
|
-
def
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
left = [region.left - padding_x, 0].max
|
|
784
|
-
top = [region.top - padding_y, 0].max
|
|
785
|
-
right = [region.left + region.width + padding_x, image_width].min
|
|
786
|
-
bottom = [region.top + region.height + padding_y, image_height].min
|
|
787
|
-
|
|
788
|
-
IllustrationRegion.new(left:, top:, width: [right - left, 1].max, height: [bottom - top, 1].max)
|
|
789
|
-
end
|
|
790
|
-
|
|
791
|
-
# 領域がイラストの候補として妥当か(サイズ・アスペクト比)を判定する
|
|
792
|
-
def illustration_region_candidate?(region, image_width, image_height)
|
|
793
|
-
return false unless region.width.positive? && region.height.positive?
|
|
794
|
-
return false if region.width < image_width * 0.35
|
|
795
|
-
return false if region.height < image_height * 0.08
|
|
796
|
-
return false if region.width * region.height < image_width * image_height * 0.035
|
|
1402
|
+
# 図とみなす枠か。ページの端に接せず、辺と面積が下限を超え、まとまりの画素が枠の半分以上を埋める
|
|
1403
|
+
def figure_box?(box, cells, width, height)
|
|
1404
|
+
left, top, right, bottom = box
|
|
1405
|
+
return false if left <= 1 || top <= 1 || right >= width - 1 || bottom >= height - 1
|
|
797
1406
|
|
|
798
|
-
|
|
799
|
-
|
|
1407
|
+
side = FIGURE_MIN_SIDE * width
|
|
1408
|
+
area = (right - left) * (bottom - top)
|
|
1409
|
+
right - left >= side && bottom - top >= side && area >= FIGURE_MIN_AREA * width * height && cells >= area * 0.5
|
|
800
1410
|
end
|
|
801
1411
|
|
|
802
1412
|
# イラスト領域のピクセル座標を PDF ページ座標に変換し、ImageOccurrence を構築する
|
|
@@ -830,6 +1440,14 @@ module VivlioStarter
|
|
|
830
1440
|
)
|
|
831
1441
|
end
|
|
832
1442
|
|
|
1443
|
+
# ruby-vips と libvips が使えるか。使えなければ、画素の足りない画像は拡大せずに描き直しへ回す
|
|
1444
|
+
def vips_available?
|
|
1445
|
+
require_vips!
|
|
1446
|
+
true
|
|
1447
|
+
rescue Error
|
|
1448
|
+
false
|
|
1449
|
+
end
|
|
1450
|
+
|
|
833
1451
|
# ruby-vips を安全にロードし、Vips モジュールを返す
|
|
834
1452
|
def require_vips!
|
|
835
1453
|
require "vips"
|
|
@@ -1015,7 +1633,7 @@ module VivlioStarter
|
|
|
1015
1633
|
end
|
|
1016
1634
|
|
|
1017
1635
|
# OCR テキストの後処理パイプライン
|
|
1018
|
-
# 日本語スペース除去 → 断片結合 → 括弧正規化 →
|
|
1636
|
+
# 日本語スペース除去 → 断片結合 → 括弧正規化 → 誤認識の補正
|
|
1019
1637
|
def postprocess_ocr_text(text)
|
|
1020
1638
|
sanitized = sanitize(text)
|
|
1021
1639
|
return "" if sanitized.empty?
|
|
@@ -1023,12 +1641,14 @@ module VivlioStarter
|
|
|
1023
1641
|
sanitized = collapse_ocr_japanese_spaces(sanitized)
|
|
1024
1642
|
sanitized = collapse_fragmented_words(sanitized)
|
|
1025
1643
|
sanitized = normalize_ocr_brackets(sanitized)
|
|
1026
|
-
sanitized =
|
|
1644
|
+
sanitized = apply_ocr_corrections(sanitized)
|
|
1027
1645
|
sanitize(sanitized)
|
|
1028
1646
|
end
|
|
1029
1647
|
|
|
1030
1648
|
# --- OCR テキスト補正用の文字クラス正規表現 ---
|
|
1031
|
-
|
|
1649
|
+
# 足すのは `\p{...}` に入らない 2 文字だけ。々 は \p{Han}、ヵヶ は \p{Katakana} に
|
|
1650
|
+
# 含まれるため、並べると「character class has duplicated range」の警告が出る。
|
|
1651
|
+
CJK_CHAR = /[\p{Han}\p{Hiragana}\p{Katakana}ー〆]/.freeze
|
|
1032
1652
|
JP_PUNCT = /[、。,.:;!?!?…]/.freeze
|
|
1033
1653
|
JP_OPEN = /[「『(【《〈]/.freeze
|
|
1034
1654
|
JP_CLOSE = /[」』)】》〉]/.freeze
|
|
@@ -1060,18 +1680,23 @@ module VivlioStarter
|
|
|
1060
1680
|
end
|
|
1061
1681
|
end
|
|
1062
1682
|
|
|
1063
|
-
#
|
|
1064
|
-
|
|
1065
|
-
|
|
1683
|
+
# OCR の誤認識を補正する表を適用する。
|
|
1684
|
+
# 適用するのは OCR したページのテキストだけで、著者が書いた原稿には適用しない
|
|
1685
|
+
def apply_ocr_corrections(text)
|
|
1686
|
+
ocr_corrections.reduce(text.to_s) do |memo, (matcher, expected)|
|
|
1066
1687
|
memo.gsub(matcher, expected)
|
|
1067
1688
|
end
|
|
1068
1689
|
end
|
|
1069
1690
|
|
|
1070
|
-
#
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1691
|
+
# 補正表の既定の置き場。プロジェクトごとに編集するので gem には含めない
|
|
1692
|
+
OCR_CORRECTIONS_FILE = File.join("config", "ocr_corrections.yml")
|
|
1693
|
+
|
|
1694
|
+
# config/ocr_corrections.yml から補正ルールを読み込む(メモ化)。
|
|
1695
|
+
# 各ルールの patterns を Regexp にコンパイルし、[matcher, expected] の配列を返す。
|
|
1696
|
+
# ファイルが無ければ補正しない
|
|
1697
|
+
def ocr_corrections
|
|
1698
|
+
@ocr_corrections ||= begin
|
|
1699
|
+
path = File.join(Dir.pwd, OCR_CORRECTIONS_FILE)
|
|
1075
1700
|
unless File.file?(path)
|
|
1076
1701
|
[]
|
|
1077
1702
|
else
|
|
@@ -1081,7 +1706,7 @@ module VivlioStarter
|
|
|
1081
1706
|
next [] if expected.empty?
|
|
1082
1707
|
|
|
1083
1708
|
Array(rule["patterns"]).filter_map do |pattern|
|
|
1084
|
-
matcher =
|
|
1709
|
+
matcher = compile_correction_pattern(pattern)
|
|
1085
1710
|
matcher ? [matcher, expected] : nil
|
|
1086
1711
|
end
|
|
1087
1712
|
end
|
|
@@ -1091,9 +1716,9 @@ module VivlioStarter
|
|
|
1091
1716
|
end
|
|
1092
1717
|
end
|
|
1093
1718
|
|
|
1094
|
-
#
|
|
1719
|
+
# 補正パターンを Regexp にコンパイルする
|
|
1095
1720
|
# "/pattern/" 形式は正規表現、それ以外はリテラルマッチ
|
|
1096
|
-
def
|
|
1721
|
+
def compile_correction_pattern(pattern)
|
|
1097
1722
|
case pattern
|
|
1098
1723
|
in Regexp then pattern
|
|
1099
1724
|
else
|
|
@@ -1165,25 +1790,26 @@ module VivlioStarter
|
|
|
1165
1790
|
)
|
|
1166
1791
|
end
|
|
1167
1792
|
|
|
1168
|
-
# OCR モードを :auto / :always / :never
|
|
1793
|
+
# OCR モードを :auto / :always / :never に正規化する。本体と book.yml は OCR を無効にする値を disable と書く
|
|
1169
1794
|
def normalize_ocr_mode(value)
|
|
1170
1795
|
case value.to_s.strip.downcase
|
|
1171
1796
|
in "" | "auto" then :auto
|
|
1172
1797
|
in "always" | "force" | "true" then :always
|
|
1173
|
-
in "never" | "false" | "off" then :never
|
|
1798
|
+
in "never" | "disable" | "disabled" | "false" | "off" then :never
|
|
1174
1799
|
else
|
|
1175
1800
|
:auto
|
|
1176
1801
|
end
|
|
1177
1802
|
end
|
|
1178
1803
|
|
|
1179
|
-
# OCR 言語指定を正規化する(配列化 + エイリアス解決 +
|
|
1804
|
+
# OCR 言語指定を正規化する(配列化 + エイリアス解決 + 重複排除、既定は日本語と英語)
|
|
1180
1805
|
def normalize_ocr_languages(value)
|
|
1181
1806
|
raw = case value
|
|
1182
1807
|
in Array then value
|
|
1183
1808
|
else value.to_s.split(/[+,]/)
|
|
1184
1809
|
end
|
|
1185
1810
|
languages = raw.map { normalize_ocr_language_alias(it) }.reject(&:empty?)
|
|
1186
|
-
|
|
1811
|
+
# 技術書はコードや英字の語を含むので、既定は日本語と英語にする
|
|
1812
|
+
languages = %w[jpn eng] if languages.empty?
|
|
1187
1813
|
languages.uniq
|
|
1188
1814
|
end
|
|
1189
1815
|
|
|
@@ -1193,6 +1819,7 @@ module VivlioStarter
|
|
|
1193
1819
|
in "" then ""
|
|
1194
1820
|
in "japanese" then "jpn"
|
|
1195
1821
|
in "japanese_vertical" then "jpn_vert"
|
|
1822
|
+
in "english" then "eng"
|
|
1196
1823
|
else value.to_s.strip
|
|
1197
1824
|
end
|
|
1198
1825
|
end
|
|
@@ -1240,13 +1867,10 @@ module VivlioStarter
|
|
|
1240
1867
|
0.0
|
|
1241
1868
|
end
|
|
1242
1869
|
|
|
1243
|
-
# OCR モード設定値のアクセサ
|
|
1244
1870
|
def ocr_mode = @ocr.mode
|
|
1245
1871
|
|
|
1246
|
-
# イラスト内テキスト処理ポリシーのアクセサ
|
|
1247
1872
|
def inline_image_text = @ocr.inline_image_text
|
|
1248
1873
|
|
|
1249
|
-
# 同一行とみなす Y 座標差の閾値のアクセサ
|
|
1250
1874
|
def line_merge_tolerance = @line_merge_tolerance
|
|
1251
1875
|
end
|
|
1252
1876
|
end
|