テキストデータから3D家系図を作る【Blender × Python】

# 0. はじめに

最近、親族の集まりがあったことをきっかけに、家系図を書いてみることにしました。
iOSアプリを探してみると、「すいすい家系図」というかなり使い勝手の良いアプリを見つけました。

https://apps.apple.com/jp/app/%E3%81%99%E3%81%84%E3%81%99%E3%81%84%E5%AE%B6%E7%B3%BB%E5%9B%B3/id1447075049#?platform=iphone
<!--
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/a8e055f1-8aaa-2e02-c484-e29138f922bf.png)（画像は上のリンク[^1]より引用）
-->

直感的な操作が可能で、生年月日や顔写真等の属性も登録することができます。

[^1]: https://apps.apple.com/jp/app/%E3%81%99%E3%81%84%E3%81%99%E3%81%84%E5%AE%B6%E7%B3%BB%E5%9B%B3/id1447075049#?platform=iphone

しかし、複数の系統（名字）を一つの家系図上に配置すると**線を迂回・交差させる必要**が出てくる場合があり、見づらくなってしまいます。

![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/55014647-5d6d-4c43-8d6b-75c7786a9c66.png)（[^2]より引用）

[^2]: https://engidou.net/column/knowledge02/1406/

そこで、**パーツを3D空間上に配置**することで、複雑な家系図も**視覚的にわかりやすく表現**できるのではないかと考えました（↓完成イメージ）。

![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/c5eea1ef-5176-2803-36e5-888ec0aa6a05.png)

分子構造模型のようなキットで3D家系図を組み立てる方法についてはすでに特許化されているようです[^3]。

[^3]: https://jglobal.jst.go.jp/detail?JGLOBAL_ID=201803016660368123

実際にパーツを用意して組み立てるのは難しいので、ここでは**blender**を用いて親子関係の**テキスト情報から3D家系図モデルを自動生成**するツールを作ります。

:::note
動作環境
- macOS 12.6.4
- Blender 2.90.0
:::

:::note warn
注意
- 現状、再婚等の複雑な家庭事情には対応しきれていません🙇🏻
- 家系図が複雑になると、パーツ同士が干渉してしまうことがあります（今後対応予定）。
:::

# 1. 家系図グラフの作成
# 1-1. 入力形式

入力はCSV形式とする（[サンプルファイル](https://drive.google.com/drive/folders/1O1x_nGjIGkQcmhabONKtKexT-C-WGQY1?usp=drive_link)）。

- 列ラベルは、右から**ID・姓・名・性別・配偶者のID・子どものID**
- IDは重複不可のため、同名が存在する場合には適宜別のIDを割り振る
- 子どもが複数いる場合には、「/（バックスラッシュ）」で区切る
- 冗長な情報は与えなくてもOK（例: 「縁喜 慎」行に配偶者「明子」と子どもが追加されているので、「縁喜 明子」行には配偶者と子どもの情報は省略可能）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/a7a3d5d1-bd85-5fce-e8ef-31675e7e02f6.png)

# 1-2. 人物モデルの定義
人物モデルでは、
- `name: str` 人物名
- `male: bool` 性別
- `father: Person` 父親（のインスタンス）
- `mother: Person` 母親（のインスタンス）
- `partner: Person` 配偶者（のインスタンス）
- `children: list[Person]` 子ども（のインスタンス）リスト

をデータとして保有する。

また、
- 配偶者の追加 `add_partner`
- 子どもの追加 `add_child`

メソッドを作成する。

完成したソースコードは以下の通り。

```py:person.py
class Person:
    def __init__(self, name, male=True):
        self.name = name
        self.male = male
        self.father = None
        self.mother = None
        self.partner = None
        self.children = []
    
    def add_partner(self, partner):
        self.partner = partner
        partner.partner = self
        self.partner.add_children(self.children)
        self.add_children(partner.children)
    
    def add_child(self, child):
        if not child in self.children:
            self.children.append(child)
        if self.male:
            child.father = self
        else:
            child.mother = self
    
    def add_children(self, children):
        for child in children:
            self.add_child(child)
            if self.partner:
                self.partner.add_child(child)
```

配偶者を追加したときに、その配偶者の配偶者に本人を追加し、本人と配偶者の間で子どもの情報が共有されるようにした。これにより、csvファイルでの冗長な情報入力が不要になる。

同様に、子どもを追加したときには、その子どもの父親あるいは母親に本人を追加し、父親と母親の間で子どもの情報が共有されるようにした。

以上により、配偶者を設定 `add_partner` した後に子どもを追加 `add_children` した場合と、子どもを追加 `add_children` した後に配偶者を設定 `add_partner` した場合のどちらでも同様の結果が得られる。

# 1-3. 家系図モデルの定義

傍系も含む家系図を作るには、入力csvファイルの情報からたどり着けるもっとも古い祖先たち（ルーツ, `roots`）を追う必要がある。

ルーツを求めるメソッドを、`compute_root`とする。また、ルーツを求めるにあたり起点とする人物モデルを、家系図モデルの引数`center`にとる。

作成したソースコードは以下の通り。

```py:family_tree.py
def __init__(self, center):
        self.center = center
        self.person_depth = {self.center: 0}
        self.compute_root()
        self.compute_positions()
    
    def compute_root(self):
        queue = [self.center]
        person_depth = self.person_depth
        roots = []
        while queue:
            father = queue[0].father
            mother = queue[0].mother
            if father and mother:
                queue.append(father)
                queue.append(mother)
                person_depth[father] = person_depth[queue[0]] - 1
                person_depth[mother] = person_depth[queue[0]] - 1
            else:
                roots.append((queue[0], person_depth[queue[0]]))
            queue = queue[1:]
        self.person_depth = person_depth
        self.roots = roots
```

ルーツの探索には、幅優先探索を用いる。具体的には以下のステップをキュー (queue) が空になるまで繰り返す。

1. （最初のみ）起点人物を `queue` に入れる。
1. `queue` の先頭から着目人物を取り出す。
1. 着目人物に父親と母親が両方存在する場合、2人を `queue` に入れる。いずれかが存在しない場合、`queue` には入れず `roots` に入れる。

`person_depth` は、起点人物から見た各人物の世代を表す。詳しくは [2-2.](https://qiita.com/astaka/items/c04a120db627e3d01074#2-2-%E4%BA%BA%E7%89%A9%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E9%85%8D%E7%BD%AE%E8%A8%88%E7%AE%97) で触れる。

# 2. Blenderでの家系図可視化
## 2-1. 人物モデルのテクスチャ作成
今回は、人物モデルを球体で表し、それらを円柱型のスティックで連結することで3D家系図を作成する。
そこで、球体で表される人物の判別を容易にするために、名前の先頭1文字の漢字をテクスチャとして張り付けることにする。
なお、男性はシアン系、女性はマゼンタ系、不明の場合は白色で塗りつぶすことにする。

```py:read_csv.py
from PIL import Image, ImageDraw, ImageFont

def make_node_texture(words, sex="Male"):
    f_path = "/System/Library/Fonts/ヒラギノ角ゴシック W7.ttc"
    f_size = 120
    i_size = 400
    font = ImageFont.truetype(f_path, f_size)
    if sex == "Male":
        color = (0, 127, 255)
    elif sex == "Female":
        color = (255, 0, 255)
    else:
        color = (255, 255, 255)
    img = Image.new("RGB", (i_size,i_size), color)
    draw = ImageDraw.Draw(img)
    f_y = (i_size-f_size)//2
    f_x = i_size//4 - f_size//2
    word = words[0]
    draw.text((f_x,f_y), word, font=font, fill=(255,255,255))
    f_x = i_size//4 * 3 - f_size//2
    draw.text((f_x,f_y), word, font=font, fill=(255,255,255))
    return img
```

`make_node_texture` は、名前の文字列を`words`として受け取る関数であり、その先頭文字 `words[0]` を画像に書き込んでいる。また、完成した3Dモデルを回転させた場合に裏側からも名前が見えるように、画像の左右に一文字ずつ書き込むようにした。

<details><summary>書き出される画像例</summary>
「縁喜 勇一郎」の場合：

![Yuichiro.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/c456e3a8-ddc7-54cb-0dd7-f1bb98106e54.png)

</details>

:::note warn
上の例では `f_path` にフォントファイルを直で指定しているので、各自の環境に合わせてパスの修正あるいはフォントのダウンロードをしてください。
:::

## 2-2. 人物モデルの配置計算

[1-3.](https://qiita.com/astaka/items/c04a120db627e3d01074#1-3-%E5%AE%B6%E7%B3%BB%E5%9B%B3%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E5%AE%9A%E7%BE%A9) で計算したルーツ `roots` から、それぞれの子孫を辿っていく。`roots` の各要素 `root` は、`(Personインスタンス, 世代)` のタプルとなっている。起点人物の世代 `dep` が0で、親世代は-1、祖父母世代は-2, ..., となっており、子ども世代は1, 孫世代は2, ..., となっている。実際に家系図にするときには、祖先が上方向、子孫が下方向になるので、この世代値が人物モデルのz座標に関係する。

続いて、(x,y) の軸の定義方法について説明する。3D家系図モデルにおいて、**直接連結されていて同じz値を持つ人物同士の関係性**は、**配偶者**であるか**兄弟姉妹**である。通常の2D家系図と同様に、両親とその子どもたちの人物モデルは（見やすさの観点から）**同一平面上に存在すべき**である。例えば、ある家族（父、母、一郎（長男）、二子（長女）、三郎（次男））が**yz平面に平行な平面上**に存在すると仮定する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/a8671951-0d80-3426-2120-64e3f6be93ca.png)


すると、子どもたちの配偶者を兄弟と重ねずに配置するには、**zx平面に平行な平面上**に配置する必要がある。
つまり、**配偶者と兄弟姉妹の関係性は常に直交関係**にあり、さらにそれらの関係性は**z値が変化するごとに入れ替わる**必要がある（z=0で配偶者が+y方向、兄弟姉妹が+x方向の場合、z=-1では配偶者がx方向、兄弟姉妹が+y方向）。

以上の条件に基づくと、ソースコードは次のようになる。

```py:family_tree.py
C_INTERVAL = 1.7

class FamilyTree:
    ...
    def compute_positions(self):
        roots = self.roots
        person_position_list = []
        for root in roots:
            person, dep = root
            root_position = (person, (0, 0, -dep))
            person_position, nodes = self.compute_descend_positions(root_position)
            person_position_list.append(person_position)
        self.person_position_list = person_position_list
        
        self.person_position = {}
        for i, per_pos in enumerate(person_position_list):
            disp_x = 0
            disp_y = 0
            trans = False
            for per, pos in per_pos.items():
                if per in self.person_position:
                    org_pos = self.person_position[per]
                    disp_x = org_pos[0] - pos[0]
                    disp_y = org_pos[1] - pos[1]
                    trans = True
                    break
            if trans:
                for per, pos in per_pos.items():
                    if not per in self.person_position:
                        self.person_position[per] = (pos[0]+disp_x, pos[1]+disp_y, pos[2])
            else:
                for per, pos in per_pos.items():
                    if not per in self.person_position:
                        self.person_position[per] = pos

     def compute_descend_positions(self, root_position):
        queue = [root_position[0]]
        person_position = {root_position[0]: root_position[1]}
        while queue:
            pos = person_position[queue[0]]
            if queue[0].partner:
                if queue[0].partner.male:
                    res = -1
                else:
                    res = 1                    
                if pos[2] % 2:
                    p_pos = (pos[0]+res, pos[1], pos[2])
                else:
                    p_pos = (pos[0], pos[1]+res, pos[2])
                person_position[queue[0].partner] = p_pos

                queue = queue + queue[0].children
                
                for i, child in enumerate(queue[0].children):
                    c_inteval = C_INTERVAL * i
                    if pos[2] % 2:
                        person_position[child] = ((pos[0]+p_pos[0])/2+c_inteval, pos[1], pos[2]-1)
                    else:
                        person_position[child] = (pos[0], (pos[1]+p_pos[1])/2+c_inteval, pos[2]-1)
            queue = queue[1:]
        return person_position, node_list
```

`compute_descend_positions` では、各祖先 `root` の座標を `(0, 0, -dep)` とおき、子孫の座標を順次計算していく。探索済みの人物とその座標を `person_position` にタプル形式で格納する。子孫の探索には幅優先探索を用い、以下のループを queue が空になるまで繰り返す。

1. （最初のみ）ルートをqueueに入れる。
1. queueから注目人物を取り出す。
1. 注目人物にパートナーがいる場合、その注目人物から平行移動した位置にパートナーを配置する。
    - z値が奇数のときx方向に平行移動し、偶数のときy方向に平行移動する
    - 各軸に対して、夫が負方向、妻が正方向に配置されるようにする
1. 注目人物に子どもがいる場合、子どもたちをqueueに追加し、夫妻の中間座標からz方向に-1平行移動した点に第1子を配置する。第2子以降は、`C_INTERVAL` 間隔で正方向にずらして配置する。

各rootに対して、子孫の座標を計算し終えた段階では、3D家系図がrootごとにバラついている状態（同一人物が複数祖先 root に繋がっていると、各 root から見た位置が異なる）なので、マージする必要がある。そこで、すでに位置が確定している人物が、別の祖先からの探索で登場した場合、確定済みの位置へ家系図を丸ごと平行移動する。

## 2-3. 連結方法の計算

連結部には円柱を用いる。基本的には、[2-2.](https://qiita.com/astaka/items/c04a120db627e3d01074#2-2-%E4%BA%BA%E7%89%A9%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E9%85%8D%E7%BD%AE%E8%A8%88%E7%AE%97) で配置した球体同士を結ぶように円柱を配置していく。
連結されるのは配偶者・親子関係なので、[2-2.](https://qiita.com/astaka/items/c04a120db627e3d01074#2-2-%E4%BA%BA%E7%89%A9%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E9%85%8D%E7%BD%AE%E8%A8%88%E7%AE%97) の探索部に円柱の中心位置・回転・スケール情報を追加する。
配偶者の連結は比較的単純だが、親子関係の連結には子どもの数を考慮する必要がある。また、球体と異なり回転成分やスケール情報を指定する必要があるので、少々煩雑になる。詳細は[Github上のソースコード](https://github.com/astaka-pe/FamilyTree3D/blob/blender-2.90/family_tree.py)を参照されたい。

ここまでで生成された `FamilyTree` オブジェクトを .pkl 形式で保存し、blenderで読み込む。

## 2-4. Blender上でスクリプト実行

[2-3.](https://qiita.com/astaka/items/c04a120db627e3d01074#2-3-%E9%80%A3%E7%B5%90%E6%96%B9%E6%B3%95%E3%81%AE%E8%A8%88%E7%AE%97) で保存した .pkl ファイルを読み込む。
FamilyTreeオブジェクトに格納されたノード（球体）の配置やテクスチャ情報 `family.person_position`、連結部（円柱）の配置 `family.node_list` を取得し、blender上に配置してレンダリングする。


```py:viewer.py
import bpy
import pickle

def set_material(object, material_name, texture_filepath):
    material = bpy.data.materials.new(material_name)
    material.use_nodes = True
    nodes = material.node_tree.nodes
    nodes.clear()
    node_shader = nodes.new(type="ShaderNodeBsdfPrincipled")
    node_texture = nodes.new("ShaderNodeTexImage")
    node_texture.image = bpy.data.images.load(texture_filepath)
    node_output = nodes.new(type="ShaderNodeOutputMaterial")
    links = material.node_tree.links
    link = links.new(node_texture.outputs["Color"], node_shader.inputs["Base Color"])
    link = links.new(node_shader.outputs["BSDF"], node_output.inputs["Surface"])
    bpy.context.view_layer.objects.active = object
    bpy.context.object.data.materials.append(material)

def generate_sphere(person, location):
    mat_male = bpy.data.materials.new("male")
    mat_male.diffuse_color = (0, 0, 1, 1)
    mat_female = bpy.data.materials.new("female")
    mat_female.diffuse_color = (1, 0, 0, 1)
    
    bpy.ops.mesh.primitive_uv_sphere_add(location=location, radius=0.2)
    bpy.context.object.name = person.name
    child = bpy.data.objects[person.name]
    texture_path = WORKDIR / Path("texture/{}.png".format(person.name))
    if texture_path.exists():
        # texture_data = bpy.data.images.load(str(texture_path))
        set_material(bpy.data.objects[person.name], person.name, str(texture_path))
        print(str(texture_path), "exists")
    else:
        print(str(texture_path), "doesn't exists")
        if person.male:
            child.data.materials.append(mat_male)
        else:
            child.data.materials.append(mat_female)

def generate_node(node):
    px, py, pz, rx, ry, rz, sx, sy, sz = node
    bpy.ops.mesh.primitive_cylinder_add(location=(px, py, pz), rotation=(rx, ry, rz), scale=(sx, sy, sz))

def main():
    with open(MODEL_PATH, "rb") as f:
        family = pickle.load(f)

    for per, pos in family.person_position.items():
        print(per.name, pos)
        generate_sphere(per, pos)

    for node in family.node_list:
        generate_node(node)

if __name__ == "__main__":
    main()
```

Blender上部のワークペースから "Scripting" を選び、`viewer.py` をコピーして実行(▶)すると、Viewerに3D家系図モデルが表示される。

![blender.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/f08b51d1-dc95-fc58-22c2-0919c48576b2.png)

# 3. 家系図モデルの可視化・共有方法

Blenderで作成したデータを可視化したり、iPhone等へ送信する方法を説明する。

- Blenderの `File > Export > glTF2.0` を選択し、エクスポートする
- macOSで[Reality Converter](https://developer.apple.com/jp/augmented-reality/tools/)をダウンロード
- Reality Converterを開いてglTFファイルをドラック&ドロップし、右上の「書き出す」から USDZ 形式で保存する

![glb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/0a09b619-c2b5-cb8a-8089-b39c8a4f7371.png)

- USDZファイルはAirDrop等でiPhoneへ転送可能
- iPhoneでは、生成したモデルをARで表示することも可能
- LINEでは、zip形式で圧縮して送ると共有できる


![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/d2f40746-824b-95f0-2362-8e5e684b35aa.gif)


# 4. ソースコード

記事がかなり長くなってしまいましたが、基本的な処理部をかいつまんで説明しました。
全体のソースコードは[こちら](https://github.com/astaka-pe/FamilyTree3D)です。

https://github.com/astaka-pe/FamilyTree3D

# 参考

https://sites.google.com/site/modulofirm/familytree

https://qiita.com/LifeIsMoratorium/items/3aefd4f834dd2446a3b9

https://note.com/cgtest/n/n29d218aef694

https://qiita.com/chat-flip/items/120e042b579c1bbde767

# TODO
<!--
- [x] CSV入力に対応
- [x] argparseの追加
- [x] 漢字テクスチャ画像の自動生成(pillow)
- [x] ソースコード公開
- [ ] (ここまでできたら記事)
-->
- [ ] blender4.0への対応
- [ ] 衝突検知
- [ ] 回転スライドの追加