0. はじめに
最近、親族の集まりがあったことをきっかけに、家系図を書いてみることにしました。
iOSアプリを探してみると、「すいすい家系図」というかなり使い勝手の良いアプリを見つけました。
直感的な操作が可能で、生年月日や顔写真等の属性も登録することができます。
しかし、複数の系統(名字)を一つの家系図上に配置すると線を迂回・交差させる必要が出てくる場合があり、見づらくなってしまいます。
(1より引用)
そこで、パーツを3D空間上に配置することで、複雑な家系図も視覚的にわかりやすく表現できるのではないかと考えました(↓完成イメージ)。
分子構造模型のようなキットで3D家系図を組み立てる方法についてはすでに特許化されているようです2。
実際にパーツを用意して組み立てるのは難しいので、ここではblenderを用いて親子関係のテキスト情報から3D家系図モデルを自動生成するツールを作ります。
動作環境
- macOS 12.6.4
- Blender 2.90.0
注意
- 現状、再婚等の複雑な家庭事情には対応しきれていません🙇🏻
- 家系図が複雑になると、パーツ同士が干渉してしまうことがあります(今後対応予定)。
1. 家系図グラフの作成
1-1. 入力形式
入力はCSV形式とする(サンプルファイル)。
- 列ラベルは、右からID・姓・名・性別・配偶者のID・子どものID
- IDは重複不可のため、同名が存在する場合には適宜別のIDを割り振る
- 子どもが複数いる場合には、「/(バックスラッシュ)」で区切る
- 冗長な情報は与えなくてもOK(例: 「縁喜 慎」行に配偶者「明子」と子どもが追加されているので、「縁喜 明子」行には配偶者と子どもの情報は省略可能)
1-2. 人物モデルの定義
人物モデルでは、
-
name: str
人物名 -
male: bool
性別 -
father: Person
父親(のインスタンス) -
mother: Person
母親(のインスタンス) -
partner: Person
配偶者(のインスタンス) -
children: list[Person]
子ども(のインスタンス)リスト
をデータとして保有する。
また、
- 配偶者の追加
add_partner
- 子どもの追加
add_child
メソッドを作成する。
完成したソースコードは以下の通り。
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
にとる。
作成したソースコードは以下の通り。
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) が空になるまで繰り返す。
- (最初のみ)起点人物を
queue
に入れる。 -
queue
の先頭から着目人物を取り出す。 - 着目人物に父親と母親が両方存在する場合、2人を
queue
に入れる。いずれかが存在しない場合、queue
には入れずroots
に入れる。
person_depth
は、起点人物から見た各人物の世代を表す。詳しくは 2-2. で触れる。
2. Blenderでの家系図可視化
2-1. 人物モデルのテクスチャ作成
今回は、人物モデルを球体で表し、それらを円柱型のスティックで連結することで3D家系図を作成する。
そこで、球体で表される人物の判別を容易にするために、名前の先頭1文字の漢字をテクスチャとして張り付けることにする。
なお、男性はシアン系、女性はマゼンタ系、不明の場合は白色で塗りつぶすことにする。
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モデルを回転させた場合に裏側からも名前が見えるように、画像の左右に一文字ずつ書き込むようにした。
上の例では f_path
にフォントファイルを直で指定しているので、各自の環境に合わせてパスの修正あるいはフォントのダウンロードをしてください。
2-2. 人物モデルの配置計算
1-3. で計算したルーツ roots
から、それぞれの子孫を辿っていく。roots
の各要素 root
は、(Personインスタンス, 世代)
のタプルとなっている。起点人物の世代 dep
が0で、親世代は-1、祖父母世代は-2, ..., となっており、子ども世代は1, 孫世代は2, ..., となっている。実際に家系図にするときには、祖先が上方向、子孫が下方向になるので、この世代値が人物モデルのz座標に関係する。
続いて、(x,y) の軸の定義方法について説明する。3D家系図モデルにおいて、直接連結されていて同じz値を持つ人物同士の関係性は、配偶者であるか兄弟姉妹である。通常の2D家系図と同様に、両親とその子どもたちの人物モデルは(見やすさの観点から)同一平面上に存在すべきである。例えば、ある家族(父、母、一郎(長男)、二子(長女)、三郎(次男))がyz平面に平行な平面上に存在すると仮定する。
すると、子どもたちの配偶者を兄弟と重ねずに配置するには、zx平面に平行な平面上に配置する必要がある。
つまり、配偶者と兄弟姉妹の関係性は常に直交関係にあり、さらにそれらの関係性はz値が変化するごとに入れ替わる必要がある(z=0で配偶者が+y方向、兄弟姉妹が+x方向の場合、z=-1では配偶者がx方向、兄弟姉妹が+y方向)。
以上の条件に基づくと、ソースコードは次のようになる。
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 が空になるまで繰り返す。
- (最初のみ)ルートをqueueに入れる。
- queueから注目人物を取り出す。
- 注目人物にパートナーがいる場合、その注目人物から平行移動した位置にパートナーを配置する。
- z値が奇数のときx方向に平行移動し、偶数のときy方向に平行移動する
- 各軸に対して、夫が負方向、妻が正方向に配置されるようにする
- 注目人物に子どもがいる場合、子どもたちを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上に配置してレンダリングする。
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家系図モデルが表示される。
3. 家系図モデルの可視化・共有方法
Blenderで作成したデータを可視化したり、iPhone等へ送信する方法を説明する。
- Blenderの
File > Export > glTF2.0
を選択し、エクスポートする - macOSでReality Converterをダウンロード
- Reality Converterを開いてglTFファイルをドラック&ドロップし、右上の「書き出す」から USDZ 形式で保存する
- USDZファイルはAirDrop等でiPhoneへ転送可能
- iPhoneでは、生成したモデルをARで表示することも可能
- LINEでは、zip形式で圧縮して送ると共有できる
4. ソースコード
記事がかなり長くなってしまいましたが、基本的な処理部をかいつまんで説明しました。
全体のソースコードはこちらです。
参考
TODO
- blender4.0への対応
- 衝突検知
- 回転スライドの追加