Rustで学術論文からテキストを抽出する #3 - テキスト属性判定チャレンジ

:::note
## Summary
- pdftotextから得られる情報だけではテキストの属性は推定できない
- 問題の難易度を少し下げて，まずは本文が書かれているエリアを特定する

GiHub -> https://github.com/akitenkrad/rsrpp
crates.io -> https://crates.io/crates/rsrpp

## ToDo
- テキストの属性 (本文, タイトル, 脚注, etc.) を判定する
- 図表に含まれるテキストを除外する
## 今日のファイル
```text
rsrpp
├── Cargo.toml
├── rsrpp
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── parser
│           ├── mod.rs  <-- today
│           └── tests.rs
└── rsrpp-cli
    ├── Cargo.toml
    └── src
        └── main.rs
```
:::

## 前回までのあらすじ

[前回：Rustで学術論文からテキストを抽出する #2](https://qiita.com/akitenkrad/items/a61c51f8dabccc16d150)

論文のPDFを `pdftotext` に通すことで，単語レベルでテキストの位置を取得できるようになりました．
次は取得したテキストが本文なのか，タイトルなのかといったテキストの属性の判定を考えます．

```mermaid
graph LR
    A[要件定義]

    subgraph Popplerの観察
    B[Popperの観察]
    B1[pdftotext]
    end
    
    subgraph 今回はここ
    C[テキストの属性判定]
    end
    
    D[テキスト抽出PoC]
    
    A-->B
    B1-->C-->D
```

## フォントサイズを眺める

まずはシンプルに，単語のフォントサイズを使った判定を考えてみました．
一般的にはフォントの大きさは `タイトル > 本文 > 脚注` のように属性によって大小関係がある程度決まっているような気がしたためです．
が，残念ながらこの方法での属性判定は断念しました．
具体的にみてみます．

各単語の位置 $(x_{\text{min}}, y_{\text{min}}, x_{\text{max}}, y_{\text{max}})$ がわかっているので，単純に単語の高さをフォントサイズと考えてみます．

| テキスト | フォントサイズ | 属性 |
|:-------:|:-----------:|:---:|
| Introduction | 10.748 | Section Title |
| Reccurent | 8.907 | Normal Text |
| illustrate | 8.016 | Footnote |

一応仮説通りフォントサイズには大小関係がありそうかなと思いましたが，フォントサイズの分布をヒストグラムで眺めてみると以下のようになります．$x$軸，$y$軸とも$\log$スケールです．

思っていたよりもフォントサイズのばらつきが大きい...！
一番数が多いのはNormal Textのフォントサイズ=9前後かと思いきや，8〜11くらいまで結構なばらつきがあります．
この分布の中から特定の属性のフォントサイズを推定するのはかなりキツイのでは．
しかも，この分布はPDFによって全く異なる可能性があります．
無理だと思いました．

![newplot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/1a2f7bb7-c2db-5ac6-5bdb-90e1cad644fc.png)

`pdftotext` で出力される情報から得られるのは位置情報と構造の情報だけです．boxやlineなどの構造の情報に関してもタイトルなど特定のテキストを抜き出す役には立ちません．

## 本文のテキストエリアを絞る
テキストの属性を直接推定しに行くのは困難だとわかったので，少し問題を緩和します．
まずは，脚注など本文エリアの外側にある抽出対象外の文字列を除外します．

ついでに，段組の問題も解決します．

### テキストエリアを抽出するための準備

下図の赤枠のようにテキストエリアを抽出します[^1][^2]．
前述のように論文には2段組みのパターンがあり，これを素直にpdftotextでテキスト抽出すると上から順にテキストが出力されてしまい，文章の順序が滅茶苦茶になります．そこで，2段組みの論文に対しては左側と右側のエリアを別々に抽出し，テキストの順序が文章の順序 (左側→右側) に一致するようにテキストのテキストの位置関係を調整してやります．

<img style="float:left" width=47.5% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/868be3f7-8888-788e-2ce0-7ce64d9bb118.png"/>
<img style="float:right" width=47.5% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/5b5a64b7-8899-a261-7fb9-62053595ea2d.png" />

位置情報を扱うためのデータ構造を準備します．
また，構造体が増えてきたので，ファイルを分割しました．

```text
rsrpp
├── Cargo.toml
├── rsrpp
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── parser
│           ├── mod.rs
│           ├── structs.rs <-- ここに構造体を集約
│           └── tests.rs
└── rsrpp-cli
    ├── Cargo.toml
    └── src
        └── main.rs
```

新しく実装するデータ構造は2つです．

`Point`はその名の通り，点の座標を扱うための構造体です．次に実装する`Coordinate`のパーツになります．

```rust: rsrpp > rsrpp > src > parser > structs.rs
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Point {
    pub x: f32,
    pub y: f32,
}

impl Point {
    pub fn new(x: f32, y: f32) -> Point {
        Point { x: x, y: y }
    }
}
```

エリアの座標を管理するデータ構造として`Coordinate`を実装します．`Coordinate`は左上，右上，右下，左下の4つの座標で四角形のエリアを表現します．
一応矩形以外の四角形にも対応できるようにしていますが，論文のテキストエリアを扱うのであれば左上と右下だけでも良かったかも．

コンストラクタとして，左上と右下の座標を与える`from_rect()`と，幅と高さを与える`from_object()`を用意しました．
`Block`などの構造体を`Coordinate`に変換するときには`from_object()`が楽そうだったので．

また，この後テキストエリアの判定ロジックを実装するにあたって，「あるエリアが他のエリアと重なっているか」ということを判定する必要が出てきます．
この辺りを扱うために，`is_intercept()`，`intersection()`，`iou()`，`is_contained_in()`といった関数群を用意しました．

`iou()`だけ少し説明しておくと，これは元々画像処理の文脈で使われる指標です．
2つの領域がどれくらい重なっているかを $0 \leq \text{IoU} \leq$ で表現します．
ちなみにIntersection over UnionでIoUです．
矩形が2つの場合，それぞれを$A$，$B$とすると，以下のように表せます．

$$
IoU=\frac{A \cap B}{A \cup B}
$$

分子は2つの矩形が重なっている領域，分母は2つの矩形を合わせた全体の領域です．
IoUは矩形の重なり部分が減るにつれて割と急激に減少していくので，テキストエリアのようにほぼ重なっているか，ほとんど重なっていないか，といったケースでは重宝します．

```rust: rsrpp > rsrpp > src > parser > structs.rs
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Coordinate {
    pub top_left: Point,
    pub top_right: Point,
    pub bottom_left: Point,
    pub bottom_right: Point,
}

impl Coordinate {
    pub fn from_rect(x1: f32, y1: f32, x2: f32, y2: f32) -> Coordinate {
        Coordinate {
            top_left: Point { x: x1, y: y1 },
            top_right: Point { x: x2, y: y1 },
            bottom_left: Point { x: x1, y: y2 },
            bottom_right: Point { x: x2, y: y2 },
        }
    }

    pub fn from_object(x: f32, y: f32, width: f32, height: f32) -> Coordinate {
        Coordinate {
            top_left: Point { x: x, y: y },
            top_right: Point { x: x + width, y: y },
            bottom_left: Point {
                x: x,
                y: y + height,
            },
            bottom_right: Point {
                x: x + width,
                y: y + height,
            },
        }
    }

    pub fn width(&self) -> f32 {
        return self.top_right.x - self.top_left.x;
    }

    pub fn height(&self) -> f32 {
        return self.bottom_left.y - self.top_left.y;
    }

    pub fn is_intercept(&self, other: &Coordinate) -> bool {
        if self.top_left.x >= other.bottom_right.x || self.bottom_right.x <= other.top_left.x {
            return false;
        }
        if self.top_left.y >= other.bottom_right.y || self.bottom_right.y <= other.top_left.y {
            return false;
        }
        return true;
    }

    pub fn intersection(&self, other: &Coordinate) -> Coordinate {
        let x1 = f32::max(self.top_left.x, other.top_left.x);
        let y1 = f32::max(self.top_left.y, other.top_left.y);
        let x2 = f32::min(self.bottom_right.x, other.bottom_right.x);
        let y2 = f32::min(self.bottom_right.y, other.bottom_right.y);
        return Coordinate::from_rect(x1, y1, x2, y2);
    }

    pub fn iou(&self, other: &Coordinate) -> f32 {
        let dx = f32::min(self.bottom_right.x, other.bottom_right.x)
            - f32::max(self.top_left.x, other.top_left.x);
        let dy = f32::min(self.bottom_right.y, other.bottom_right.y)
            - f32::max(self.top_left.y, other.top_left.y);

        if dx <= 0.0 || dy <= 0.0 {
            return 0.0;
        } else {
            let area1 = self.width() * self.height();
            let area2 = other.width() * other.height();
            let inter_area = dx * dy;
            return inter_area / (area1 + area2 - inter_area);
        }
    }

    pub fn is_contained_in(&self, other: &Coordinate) -> bool {
        let iou = self.iou(other);
        let intersection = self.intersection(other).get_area();
        let self_area = self.get_area();
        return iou > 0.0 && intersection / self_area > 0.3;
    }
}
```

## 次回

次回は今回準備した`Coordinate`を使ってテキストエリアを判定するロジックを組み立てていきます．

[次回：Rustで学術論文からテキストを抽出する #4](https://qiita.com/akitenkrad/items/dda6999edf766da78ca7)

[^1]: [Attention Is All You Need (Vaswani et al., 2017)](https://arxiv.org/abs/1706.03762)
[^2]: [Cross-modal Information Flow in Multimodal Large Language Models (Zhang et al., 2024)](https://arxiv.org/pdf/2411.18620)