LoginSignup
3
2

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

Last updated at Posted at 2024-01-08

0. はじめに

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

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

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

sample.png1より引用)

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

sample.png

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

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

動作環境

  • macOS 12.6.4
  • Blender 2.90.0

注意

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

1. 家系図グラフの作成

1-1. 入力形式

入力はCSV形式とする(サンプルファイル)。

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

image.png

1-2. 人物モデルの定義

人物モデルでは、

  • name: str 人物名
  • male: bool 性別
  • father: Person 父親(のインスタンス)
  • mother: Person 母親(のインスタンス)
  • partner: Person 配偶者(のインスタンス)
  • children: list[Person] 子ども(のインスタンス)リスト

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

また、

  • 配偶者の追加 add_partner
  • 子どもの追加 add_child

メソッドを作成する。

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

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にとる。

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

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 に入れる。
  2. queue の先頭から着目人物を取り出す。
  3. 着目人物に父親と母親が両方存在する場合、2人を queue に入れる。いずれかが存在しない場合、queue には入れず roots に入れる。

person_depth は、起点人物から見た各人物の世代を表す。詳しくは 2-2. で触れる。

2. Blenderでの家系図可視化

2-1. 人物モデルのテクスチャ作成

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

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モデルを回転させた場合に裏側からも名前が見えるように、画像の左右に一文字ずつ書き込むようにした。

書き出される画像例 「縁喜 勇一郎」の場合:

Yuichiro.png

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

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

1-3. で計算したルーツ roots から、それぞれの子孫を辿っていく。roots の各要素 root は、(Personインスタンス, 世代) のタプルとなっている。起点人物の世代 dep が0で、親世代は-1、祖父母世代は-2, ..., となっており、子ども世代は1, 孫世代は2, ..., となっている。実際に家系図にするときには、祖先が上方向、子孫が下方向になるので、この世代値が人物モデルのz座標に関係する。

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

image.png

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

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

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

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

2-3. 連結方法の計算

連結部には円柱を用いる。基本的には、2-2. で配置した球体同士を結ぶように円柱を配置していく。
連結されるのは配偶者・親子関係なので、2-2. の探索部に円柱の中心位置・回転・スケール情報を追加する。
配偶者の連結は比較的単純だが、親子関係の連結には子どもの数を考慮する必要がある。また、球体と異なり回転成分やスケール情報を指定する必要があるので、少々煩雑になる。詳細はGithub上のソースコードを参照されたい。

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

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

2-3. で保存した .pkl ファイルを読み込む。
FamilyTreeオブジェクトに格納されたノード(球体)の配置やテクスチャ情報 family.person_position、連結部(円柱)の配置 family.node_list を取得し、blender上に配置してレンダリングする。

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

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

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

  • Blenderの File > Export > glTF2.0 を選択し、エクスポートする
  • macOSでReality Converterをダウンロード
  • Reality Converterを開いてglTFファイルをドラック&ドロップし、右上の「書き出す」から USDZ 形式で保存する

glb.png

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

demo.gif

4. ソースコード

記事がかなり長くなってしまいましたが、基本的な処理部をかいつまんで説明しました。
全体のソースコードはこちらです。

参考

TODO

  • blender4.0への対応
  • 衝突検知
  • 回転スライドの追加
  1. https://engidou.net/column/knowledge02/1406/

  2. https://jglobal.jst.go.jp/detail?JGLOBAL_ID=201803016660368123

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2