2026年6月26日金曜日

Blender3D空間に分布する点群(メッシュ)のCT撮影用Pythonスクリプト

 Python Script for CT-Style Imaging of Point Clouds (Meshes) in Blender 3D Space


I have created a Python script to perform CT-style imaging on point clouds (meshes) distributed within Blender's 3D space. This allows for the generation of data analogous to medical CT scans of internal organs, but applied instead to the spatial distribution of artifacts within a shell midden. This marks the creation of a prototype for a key tool in 3D spatial analysis of artifacts. Moving forward, I plan to utilize this tool in spatial analysis while refining its usability.


Blender3D空間に分布する点群(メッシュ)のCT撮影用Pythonスクリプトを作成しました。体内臓器のCT撮影と同じようなデータを貝塚貝層内遺物分布にあてはめて生成できます。遺物3D空間分析の重要ツールの一つの祖型ができたことになります。これからこのツールを3D空間分析で活用して、その使い勝手を改善していくことにします。

1 点群(メッシュ)のCT撮影用Pythonスクリプトについて

このスクリプトは、Blender3Dビューポートに分布する点群(メッシュ)について、Y軸方向に多数のXZ断面を設定し、各XZ断面に近傍の点群を投影した投影断面図を作成するBlenderPythonスクリプトです。


点群(メッシュ)のCT撮影

断面図の間隔は0.1mとか0.5mとか1mとか自由に設定できます。また画面や点の色、点の大きさも自由に設定できます。


CT撮影結果(点群投影断面図)1枚目ct_slice_0000.png


CT撮影結果(点群投影断面図)2枚目ct_slice_0001.png


CT撮影結果(点群投影断面図)3枚目ct_slice_0002.png

2 使い方

Blender3Dビューポートで選択された点群(メッシュ)についてCT撮影を行います。点群をgeometry nodesで操作してテーマに沿って絞り込んで表現されている場合でも、その絞り込んだ点群を選択すれば、それを対象にCT撮影します。

下のBlenderPythonスクリプトの設定欄を好みに設定して、点群が配置されているBlenderファイルのテクストエディターに貼りつけ、走らせます。

Blenderファイルが存在するフォルダーにct_slices名称のサブフォルダーがつくられ、点群投影断面図がその中に生成します。生成スピートは高速です。(自分のパソコンでは84枚で10秒ほどです。)

3 点群(メッシュ)のCT撮影用Pythonスクリプト


import bpy
import os
import math

# ==========================
# ユーザー設定
# ==========================
# スライス厚み (m) : Y 方向の幅
SLICE_THICKNESS = 0.1

# 出力画像の「基準横幅」(px)
# 高さはバウンディングボックスの XZ 比率を保つように自動計算されます
IMAGE_WIDTH = 1024

# 点の色 (R, G, B, A) 0.0〜1.0
POINT_COLOR = (1.0, 1.0, 0.0, 1.0)  # kiiro
# 背景色 (R, G, B, A)
BACKGROUND_COLOR = (0.027, 0.051, 0.498, 1.0)  # ao

# 何ピクセル四方に塗るか(点の大きさ)
POINT_SIZE = 2  # 1なら1ピクセルのみ,2なら3x3ピクセル,3なら5x5ピクセル相当

# 出力先ディレクトリ(.blendファイルからの相対パス)
OUTPUT_DIR = "//ct_slices"

# 対象オブジェクトを「選択中」に限定するか
# True: 3Dビューポートで選択中のメッシュだけを対象にします
# False: シーン内の表示中メッシュを対象にします
USE_SELECTED_OBJECTS_ONLY = True

# Geometry Nodes / モディファイア適用後の「ビューポート表示結果」から頂点を取るか
# Geometry Nodesで点群を絞って表示している場合は True のまま使ってください
USE_EVALUATED_VIEWPORT_MESH = True

# 非表示オブジェクトを除外するか
EXCLUDE_HIDDEN_OBJECTS = True
# ==========================



def collect_world_vertices():
    """選択中(または表示中)メッシュの頂点をワールド座標で取得。

    USE_EVALUATED_VIEWPORT_MESH=True の場合は、Geometry Nodesやモディファイア適用後の
    ビューポート評価結果から頂点を取得する。
    """
    view_layer = bpy.context.view_layer
    depsgraph = bpy.context.evaluated_depsgraph_get()

    if USE_SELECTED_OBJECTS_ONLY:
        source_objects = list(bpy.context.selected_objects)
    else:
        source_objects = list(bpy.context.scene.objects)

    objects = []
    for obj in source_objects:
        if obj.type != 'MESH':
            continue
        if EXCLUDE_HIDDEN_OBJECTS and (obj.hide_get(view_layer=view_layer) or obj.hide_viewport):
            continue
        objects.append(obj)

    print("対象オブジェクト:", ", ".join(obj.name for obj in objects) if objects else "なし")

    points = []
    for obj in objects:
        mat = obj.matrix_world

        if USE_EVALUATED_VIEWPORT_MESH:
            obj_eval = obj.evaluated_get(depsgraph)
            mesh = None
            try:
                mesh = obj_eval.to_mesh(preserve_all_data_layers=False, depsgraph=depsgraph)
                for v in mesh.vertices:
                    co_world = mat @ v.co
                    points.append((co_world.x, co_world.y, co_world.z))
            finally:
                if mesh is not None:
                    obj_eval.to_mesh_clear()
        else:
            mesh = obj.data
            for v in mesh.vertices:
                co_world = mat @ v.co
                points.append((co_world.x, co_world.y, co_world.z))

    return points


def compute_bounds(points):
    """頂点群の XYZ の最小値・最大値を返す"""
    xs = [p[0] for p in points]
    ys = [p[1] for p in points]
    zs = [p[2] for p in points]

    min_x, max_x = min(xs), max(xs)
    min_y, max_y = min(ys), max(ys)
    min_z, max_z = min(zs), max(zs)

    return (min_x, max_x, min_y, max_y, min_z, max_z)


def bucket_points_by_slice(points, min_y, max_y, thickness):
    """Y方向のスライスごとに頂点をバケツ分けしたリストを返す"""
    if max_y - min_y == 0.0:
        num_slices = 1
    else:
        num_slices = int(math.floor((max_y - min_y) / thickness)) + 1

    slices = [[] for _ in range(num_slices)]

    for (x, y, z) in points:
        idx = int((y - min_y) / thickness)
        # 安全のためクランプ
        idx = max(0, min(num_slices - 1, idx))
        slices[idx].append((x, y, z))

    return slices, num_slices


def create_blank_image(name, width, height, bg_color):
    """背景色で塗られた空画像を作成して返す"""
    img = bpy.data.images.new(name=name, width=width, height=height, alpha=True, float_buffer=True)

    # すべて背景色で初期化
    r, g, b, a = bg_color
    num_pixels = width * height
    pixels = [0.0] * (num_pixels * 4)
    for i in range(num_pixels):
        base = i * 4
        pixels[base]     = r
        pixels[base + 1] = g
        pixels[base + 2] = b
        pixels[base + 3] = a

    img.pixels = pixels
    return img


def draw_points_to_image(img, points_3d, bounds, point_color, point_size):
    """XZ平面に投影した点群を画像に描画する"""
    min_x, max_x, _, _, min_z, max_z = bounds
    width = img.size[0]
    height = img.size[1]

    # 既存のピクセルデータを「リスト」としてコピー(ここが重要)
    px = list(img.pixels)

    # XZ範囲がゼロにならないように
    range_x = max_x - min_x
    range_z = max_z - min_z
    if range_x == 0.0:
        range_x = 1.0
    if range_z == 0.0:
        range_z = 1.0

    scale_x = (width - 1) / range_x
    scale_z = (height - 1) / range_z

    pr, pg, pb, pa = point_color

    # point_size=1 のときは1ピクセルのみ、
    # 2以上のときは +-point_size ピクセルに塗る
    radius = max(0, point_size - 1)

    for (x, y, z) in points_3d:
        u = int((x - min_x) * scale_x)
        v = int((z - min_z) * scale_z)

        # 画像内に収まるチェック
        if u < 0 or u >= width or v < 0 or v >= height:
            continue

        for du in range(-radius, radius + 1):
            for dv in range(-radius, radius + 1):
                uu = u + du
                vv = v + dv
                if 0 <= uu < width and 0 <= vv < height:
                    idx = (vv * width + uu) * 4
                    px[idx]     = pr
                    px[idx + 1] = pg
                    px[idx + 2] = pb
                    px[idx + 3] = pa

    # 修正したリストを pixels に戻す
    img.pixels = px


def main():
    # 頂点収集
    points = collect_world_vertices()
    if not points:
        print("メッシュ頂点が見つかりません。対象オブジェクトを確認してください。")
        return

    # 全体バウンディングボックス
    bounds = compute_bounds(points)
    min_x, max_x, min_y, max_y, min_z, max_z = bounds
    print("Bounds X:[{}, {}], Y:[{}, {}], Z:[{}, {}]".format(min_x, max_x, min_y, max_y, min_z, max_z))

    # XZ方向の実長
    range_x = max_x - min_x
    range_z = max_z - min_z

    # 万が一ゼロにならないように
    if range_x == 0.0:
        range_x = 1.0
    if range_z == 0.0:
        range_z = 1.0

    # 横幅を基準にしてピクセルスケールを決定 (px / m)
    pixels_per_unit = IMAGE_WIDTH / range_x

    # XZ の比率をそのままに縦高さを決める
    img_width = IMAGE_WIDTH
    img_height = max(1, int(round(range_z * pixels_per_unit)))

    print(f"Image size (W x H): {img_width} x {img_height} (px)")

    # スライスごとに頂点を振り分け
    slices, num_slices = bucket_points_by_slice(points, min_y, max_y, SLICE_THICKNESS)
    print("スライス数 :", num_slices)

    # 出力ディレクトリ準備
    out_dir = bpy.path.abspath(OUTPUT_DIR)
    os.makedirs(out_dir, exist_ok=True)
    print("出力先ディレクトリ:", out_dir)

    # スライスごとに画像生成
    for i in range(num_slices):
        slice_points = slices[i]

        # スライス名
        img_name = f"ct_slice_{i:04d}"
        print(f"生成中: {img_name} (頂点数: {len(slice_points)})")

        # 空画像作成(ここで計算した img_width, img_height を使用)
        img = create_blank_image(img_name, img_width, img_height, BACKGROUND_COLOR)

        # 頂点を描画(XZ 投影)
        if slice_points:
            draw_points_to_image(img, slice_points, bounds, POINT_COLOR, POINT_SIZE)

        # PNGで保存
        filepath = os.path.join(out_dir, img_name + ".png")
        img.filepath_raw = filepath
        img.file_format = 'PNG'
        img.save()

    print("完了しました。")


if __name__ == "__main__":
    main()

0 件のコメント:

コメントを投稿