Files
orisys-sensor-design/blender/scripts/random_patch_dd02c.py
T
2026-09-16 21:04:28 +08:00

917 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Texture Generator for Arbitrary 3D Surfaces (Blender Script)
- Mode: "patches" (colored patches), "checker" (black/white patches),
or "dots" (circular markers)
- Mesh UVs are filled white so that non‑visible parts become white.
- Texture background (no‑mesh areas) stays black.
"""
import bpy
import numpy as np
import os
import time
from mathutils import Vector
# ----------------------------------------------------------------------
# Common Utilities
# ----------------------------------------------------------------------
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
"""
Draw a cross (plus sign) in pixel space.
cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
length_px: total length of each arm (pixels).
thickness_px: line thickness (odd number gives symmetric result).
"""
# Convert UV centre to pixel coordinates (y from bottom)
cx = int(round(cx_uv * (width - 1)))
cy = int(round(cy_uv * (height - 1)))
half_len = length_px // 2
half_thick = thickness_px // 2
# Horizontal arm
x0 = max(0, cx - half_len)
x1 = min(width - 1, cx + half_len)
y0 = max(0, cy - half_thick)
y1 = min(height - 1, cy + half_thick)
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
# Vertical arm
x0 = max(0, cx - half_thick)
x1 = min(width - 1, cx + half_thick)
y0 = max(0, cy - half_len)
y1 = min(height - 1, cy + half_len)
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
def fill_background(pixels, width, height, color):
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
r, g, b, a = color
for y in range(height):
for x in range(width):
idx = (y * width + x) * 4
pixels[idx] = r
pixels[idx+1] = g
pixels[idx+2] = b
pixels[idx+3] = a
def fill_polygon(image, pixels, width, height, polygon, color):
"""Fill a polygon in the pixel array (flattened, RGBA)."""
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3:
return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width-1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly))
max_y = min(height-1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1):
intersections = []
for i in range(len(poly)):
x1, y1 = poly[i]
x2, y2 = poly[(i+1) % len(poly)]
if y1 == y2:
continue
if (y1 <= y < y2) or (y2 <= y < y1):
t = (y - y1) / (y2 - y1)
x = x1 + t * (x2 - x1)
intersections.append(x)
intersections.sort()
for k in range(0, len(intersections), 2):
if k+1 >= len(intersections):
break
x_start = int(round(intersections[k]))
x_end = int(round(intersections[k+1]))
x_start = max(0, min(width-1, x_start))
x_end = max(0, min(width-1, x_end))
for x in range(x_start, x_end + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
def fill_polygon_value(grid, width, height, polygon, value):
"""Fill a polygon into a 2D scalar grid."""
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3:
return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width - 1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly))
max_y = min(height - 1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1):
intersections = []
for i in range(len(poly)):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % len(poly)]
if y1 == y2:
continue
if (y1 <= y < y2) or (y2 <= y < y1):
t = (y - y1) / (y2 - y1)
x = x1 + t * (x2 - x1)
intersections.append(x)
intersections.sort()
for k in range(0, len(intersections), 2):
if k + 1 >= len(intersections):
break
x_start = int(round(intersections[k]))
x_end = int(round(intersections[k + 1]))
x_start = max(0, min(width - 1, x_start))
x_end = max(0, min(width - 1, x_end))
grid[y, x_start:x_end + 1] = value
def unwrap_periodic_values(values):
"""
Unwrap periodic UV coordinates so points straddling the 0/1 seam stay local.
"""
arr = np.asarray(values, dtype=np.float64).copy()
if arr.size == 0:
return arr
if arr.max() - arr.min() > 0.5:
arr[arr < 0.5] += 1.0
return arr
def wrap_uv_polygon_for_raster(uv_poly):
"""
Return a seam-aware version of a UV polygon.
UV islands may cross the 0/1 seam. If we rasterize those raw coordinates as a
single polygon, Blender's repeated UVs turn into a long diagonal triangle
across the texture. We unwrap seam-crossing polygons first, then draw wrapped
copies in the texture tile(s) they overlap.
"""
if len(uv_poly) < 3:
return []
uv_arr = np.array([(float(u), float(v)) for u, v in uv_poly], dtype=np.float64)
uv_arr[:, 0] = unwrap_periodic_values(uv_arr[:, 0])
uv_arr[:, 1] = unwrap_periodic_values(uv_arr[:, 1])
return [tuple(pt) for pt in uv_arr]
def uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
"""
Convert one UV polygon into one or more pixel polygons, duplicating seam-
crossing polygons into adjacent tiles so rasterization stays local.
"""
unwrapped = wrap_uv_polygon_for_raster(uv_poly)
if len(unwrapped) < 3:
return []
u_vals = [u for u, _ in unwrapped]
v_vals = [v for _, v in unwrapped]
u_shift_start = int(np.floor(min(u_vals)))
u_shift_end = int(np.floor(max(u_vals)))
v_shift_start = int(np.floor(min(v_vals)))
v_shift_end = int(np.floor(max(v_vals)))
pixel_polys = []
for u_shift in range(u_shift_start, u_shift_end + 1):
for v_shift in range(v_shift_start, v_shift_end + 1):
shifted = [(u - u_shift, v - v_shift) for u, v in unwrapped]
shifted_u = [u for u, _ in shifted]
shifted_v = [v for _, v in shifted]
if max(shifted_u) < 0.0 or min(shifted_u) > 1.0:
continue
if max(shifted_v) < 0.0 or min(shifted_v) > 1.0:
continue
pixel_polys.append([
(u * (tex_width - 1), v * (tex_height - 1))
for u, v in shifted
])
return pixel_polys
def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
"""Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
cos_a = np.cos(angle)
sin_a = np.sin(angle)
rx = abs(a * cos_a) + abs(b * sin_a)
ry = abs(a * sin_a) + abs(b * cos_a)
x_min = int(max(0, cx - rx))
x_max = int(min(width-1, cx + rx))
y_min = int(max(0, cy - ry))
y_max = int(min(height-1, cy + ry))
for y in range(y_min, y_max + 1):
for x in range(x_min, x_max + 1):
dx = x - cx
dy = y - cy
u = dx * cos_a + dy * sin_a
v = -dx * sin_a + dy * cos_a
if (u/a)**2 + (v/b)**2 <= 1.0:
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
def get_uv_from_face(obj, face_index, location):
"""World‑space location → UV coordinate on the mesh face."""
me = obj.data
local_loc = obj.matrix_world.inverted() @ location
poly = me.polygons[face_index]
vert_indices = poly.vertices
loop_indices = poly.loop_indices
if len(vert_indices) < 3:
return None
v0 = me.vertices[vert_indices[0]].co
v1 = me.vertices[vert_indices[1]].co
v2 = me.vertices[vert_indices[2]].co
uv_layer = me.uv_layers.active.data
uv0 = uv_layer[loop_indices[0]].uv
uv1 = uv_layer[loop_indices[1]].uv
uv2 = uv_layer[loop_indices[2]].uv
def tri_area(a, b, c):
return 0.5 * ((b - a).cross(c - a)).length
total_area = tri_area(v0, v1, v2)
if total_area == 0:
return None
w0 = tri_area(local_loc, v1, v2) / total_area
w1 = tri_area(local_loc, v2, v0) / total_area
w2 = 1.0 - w0 - w1
uv = w0 * uv0 + w1 * uv1 + w2 * uv2
return Vector((uv.x, uv.y))
def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
"""Camera raycast through normalised sensor coordinates (0‑1)."""
sx = (u_ndc - 0.5) * frame_width
sy = (v_ndc - 0.5) * frame_height
direction_cam = Vector((sx, sy, -1.0))
direction_world = cam.matrix_world.to_quaternion() @ direction_cam
direction_world.normalize()
origin = cam.matrix_world.translation
hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
depsgraph, origin, direction_world)
if hit and hit_obj == obj:
return location, face_index
return None, None
def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
"""Camera raycast through normalized sensor coordinates and return UV."""
loc, face_idx = raycast_from_camera(
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
)
if loc is None:
return None
return get_uv_from_face(obj, face_idx, loc)
def convex_hull_uv(points):
"""Return UV points ordered around their 2D convex hull."""
unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points])
unique = sorted(set(unwrapped))
if len(unique) < 3:
return None
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower = []
for p in unique:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper = []
for p in reversed(unique):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
hull = lower[:-1] + upper[:-1]
if len(hull) < 3:
return None
return [Vector((u, v)) for u, v in hull]
def sample_patch_uv_polygon(
scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end,
frame_width, frame_height,
edge_sample_grid=5
):
"""
Build a UV polygon for one camera-space patch.
Full interior cells use the four corners. Edge cells that only partially hit
the mesh are sampled more densely and converted to a convex hull so they are
not dropped just because some corners miss the silhouette.
"""
corners_ndc = [
(u_start, v_start),
(u_end, v_start),
(u_end, v_end),
(u_start, v_end),
]
corner_uvs = []
for u_ndc, v_ndc in corners_ndc:
uv = raycast_uv_from_camera(
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
)
if uv is not None:
corner_uvs.append(uv)
if len(corner_uvs) >= 3:
return corner_uvs
sampled_uvs = list(corner_uvs)
for sy in range(edge_sample_grid):
v_ndc = v_start + (v_end - v_start) * sy / (edge_sample_grid - 1)
for sx in range(edge_sample_grid):
u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
uv = raycast_uv_from_camera(
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
)
if uv is not None:
sampled_uvs.append(uv)
return convex_hull_uv(sampled_uvs)
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
"""
Fill all triangles of the active UV map with 'color' (default white).
This marks every part of the mesh, visible or not, as white.
"""
me = obj.data
uv_layer = me.uv_layers.active
if not uv_layer:
print("Warning: No active UV layer – mesh rasterization skipped.")
return
uv_data = uv_layer.data
for poly in me.polygons:
# Get loop indices for this polygon
loops = poly.loop_indices
# Build UV polygon
uv_poly = []
for loop_idx in loops:
uv = uv_data[loop_idx].uv
uv_poly.append((uv.x, uv.y))
# Convert UV (0‑1) to pixel coordinates
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
def rasterize_mesh_mask(obj, tex_width, tex_height):
"""Rasterize the active UV map to a boolean mesh mask."""
mask = np.zeros((tex_height, tex_width), dtype=bool)
me = obj.data
uv_layer = me.uv_layers.active
if not uv_layer:
return mask
uv_data = uv_layer.data
for poly in me.polygons:
uv_poly = []
for loop_idx in poly.loop_indices:
uv = uv_data[loop_idx].uv
uv_poly.append((uv.x, uv.y))
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
return mask
def paint_mask_color(pixels, width, height, mask, color):
"""Paint all True pixels of a boolean mask with an RGBA color."""
r, g, b, a = color
ys, xs = np.nonzero(mask)
for y, x in zip(ys, xs):
idx = (y * width + x) * 4
pixels[idx:idx+4] = (r, g, b, a)
def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
"""
Fill small white slivers inside the mesh by expanding nearby checker colors.
`patch_state`: -1 = unassigned, 0 = black patch, 1 = white patch.
"""
for _ in range(max_gap_px):
candidate = mesh_mask & (patch_state < 0)
if not np.any(candidate):
break
black_counts = np.zeros_like(patch_state, dtype=np.uint8)
white_counts = np.zeros_like(patch_state, dtype=np.uint8)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dx == 0 and dy == 0:
continue
src_y0 = max(0, -dy)
src_y1 = height - max(0, dy)
src_x0 = max(0, -dx)
src_x1 = width - max(0, dx)
dst_y0 = max(0, dy)
dst_y1 = height - max(0, -dy)
dst_x0 = max(0, dx)
dst_x1 = width - max(0, -dx)
neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
fillable = candidate & ((black_counts + white_counts) > 0)
if not np.any(fillable):
break
patch_state[fillable] = np.where(
black_counts[fillable] > white_counts[fillable], 0, 1
).astype(np.int8)
paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
# ----------------------------------------------------------------------
# Color generation (for patch mode)
# ----------------------------------------------------------------------
def merge_intervals(intervals):
if not intervals: return []
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for current in intervals[1:]:
last = merged[-1]
if current[0] <= last[1]:
merged[-1] = (last[0], max(last[1], current[1]))
else:
merged.append(current)
return merged
def get_allowed_intervals(neighbor_values, delta):
forbidden = []
for c in neighbor_values:
low = max(0, c - delta)
high = min(1, c + delta)
forbidden.append((low, high))
merged = merge_intervals(forbidden)
allowed = []
if merged:
if merged[0][0] > 0:
allowed.append((0, merged[0][0]))
for i in range(1, len(merged)):
allowed.append((merged[i-1][1], merged[i][0]))
if merged[-1][1] < 1:
allowed.append((merged[-1][1], 1))
else:
allowed.append((0, 1))
return allowed
def sample_from_allowed(allowed):
lengths = [end - start for start, end in allowed]
total_length = sum(lengths)
if total_length <= 0: return None
point = np.random.rand() * total_length
cumulative = 0
for (start, end), length in zip(allowed, lengths):
cumulative += length
if point <= cumulative:
return np.random.uniform(start, end)
return allowed[-1][0]
def sample_channel(neighbor_values, delta):
allowed = get_allowed_intervals(neighbor_values, delta)
if not allowed: return None
return sample_from_allowed(allowed)
def generate_valid_color(neighbors, r):
delta = np.sqrt(r)
color = np.zeros(3)
for channel in range(3):
neighbor_channel = [n[channel] for n in neighbors]
sampled = sample_channel(neighbor_channel, delta)
if sampled is None:
sampled = np.random.rand()
color[channel] = sampled
return color
def checker_patch_color(i, j):
"""Return a black/white checker color for patch index (i, j)."""
if (i + j) % 2 == 0:
return np.array([1.0, 1.0, 1.0], dtype=np.float32)
return np.array([0.0, 0.0, 0.0], dtype=np.float32)
# ----------------------------------------------------------------------
# Mode 1: Colored Patches
# ----------------------------------------------------------------------
def generate_patch_texture(
obj, cam, scene, depsgraph,
nx=40, ny=30, r=0.05,
texture_width=2048, texture_height=2048,
output_path="//patch_texture.png", uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
):
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
if cam_data.sensor_fit == 'HORIZONTAL':
sensor_width = cam_data.sensor_width
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = cam_data.sensor_height
sensor_width = sensor_height * (res_x / res_y)
focal = cam_data.lens
frame_width = sensor_width / focal
frame_height = sensor_height / focal
edge_sample_grid = max(2, int(edge_sample_grid))
patch_uv_polygons = []
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
skipped_patches = 0
print(f"Raycasting {nx*ny} patches...")
for j in range(ny):
v_start = j / ny
v_end = (j+1) / ny
for i in range(nx):
u_start = i / nx
u_end = (i+1) / nx
uv_poly = sample_patch_uv_polygon(
scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end,
frame_width, frame_height,
edge_sample_grid=edge_sample_grid
)
if uv_poly is None:
patch_uv_polygons.append(None)
skipped_patches += 1
continue
patch_uv_polygons.append(uv_poly)
neighbors = []
if j > 0: neighbors.append(patch_colors[j-1, i])
if i > 0: neighbors.append(patch_colors[j, i-1])
color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
patch_colors[j, i] = color
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
# Create image – start with black background
img_name = "PatchTexture"
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
pixels = list(image.pixels)
# Fill entire image with black (no mesh areas)
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
# Rasterize all mesh UVs in white (visible + non‑visible parts)
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
# Now draw the visible patches on top
idx = 0
for j in range(ny):
for i in range(nx):
uv_poly = patch_uv_polygons[idx]; idx += 1
if uv_poly is None: continue
color = patch_colors[j,i]
rgba = (color[0], color[1], color[2], 1.0)
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
# (after drawing all patches)
if uv_centre is not None:
draw_cross(pixels, texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness, cross_color)
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
image.file_format = 'PNG'
image.save()
print(f"Patch texture saved to {abs_path}")
return image
# ----------------------------------------------------------------------
# Mode 2: Black/White Checker Patches
# ----------------------------------------------------------------------
def generate_checker_texture(
obj, cam, scene, depsgraph,
nx=40, ny=40,
texture_width=2048, texture_height=2048,
output_path="//checker_texture.png", uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
gap_fill_px=6
):
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
if cam_data.sensor_fit == 'HORIZONTAL':
sensor_width = cam_data.sensor_width
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = cam_data.sensor_height
sensor_width = sensor_height * (res_x / res_y)
focal = cam_data.lens
frame_width = sensor_width / focal
frame_height = sensor_height / focal
edge_sample_grid = max(2, int(edge_sample_grid))
patch_uv_polygons = []
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
skipped_patches = 0
print(f"Raycasting {nx*ny} checker patches...")
for j in range(ny):
v_start = j / ny
v_end = (j+1) / ny
for i in range(nx):
u_start = i / nx
u_end = (i+1) / nx
uv_poly = sample_patch_uv_polygon(
scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end,
frame_width, frame_height,
edge_sample_grid=edge_sample_grid
)
if uv_poly is None:
patch_uv_polygons.append(None)
skipped_patches += 1
continue
patch_uv_polygons.append(uv_poly)
patch_colors[j, i] = checker_patch_color(i, j)
print(f"Skipped {skipped_patches} checker patches with fewer than 3 mesh samples.")
img_name = "CheckerTexture"
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
pixels = list(image.pixels)
mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
idx = 0
for j in range(ny):
for i in range(nx):
uv_poly = patch_uv_polygons[idx]; idx += 1
if uv_poly is None:
continue
color = patch_colors[j,i]
rgba = (color[0], color[1], color[2], 1.0)
patch_value = 1 if np.mean(color) >= 0.5 else 0
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
fill_polygon_value(
patch_state,
texture_width,
texture_height,
pixel_poly,
patch_value,
)
complete_checker_gaps(
pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
)
if uv_centre is not None:
draw_cross(pixels, texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness, cross_color)
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
image.file_format = 'PNG'
image.save()
print(f"Checker texture saved to {abs_path}")
return image
# ----------------------------------------------------------------------
# Mode 3: Circular Dot Markers
# ----------------------------------------------------------------------
def generate_dot_texture(
obj, cam, scene, depsgraph,
dot_diameter_pixels=12,
spacing_pixels=40,
dot_color=(0,0,0,1), # RGBA 0-1
texture_width=2048, texture_height=2048,
output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
):
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
cam_data = cam.data
if cam_data.sensor_fit == 'HORIZONTAL':
sensor_width = cam_data.sensor_width
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = cam_data.sensor_height
sensor_width = sensor_height * (res_x / res_y)
focal = cam_data.lens
frame_width = sensor_width / focal
frame_height = sensor_height / focal
margin = spacing_pixels // 2
x_centers = np.arange(margin, res_x, spacing_pixels)
y_centers = np.arange(margin, res_y, spacing_pixels)
offset_pix = max(2, dot_diameter_pixels // 4)
du_ndc = offset_pix / res_x
dv_ndc = offset_pix / res_y
dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
for y_pix in y_centers:
v_ndc = y_pix / res_y
for x_pix in x_centers:
u_ndc = x_pix / res_x
center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
u_ndc, v_ndc, frame_width, frame_height)
if center_loc is None: continue
center_uv = get_uv_from_face(obj, face_idx, center_loc)
if center_uv is None: continue
offsets = [
( u_ndc + du_ndc, v_ndc),
( u_ndc - du_ndc, v_ndc),
( u_ndc, v_ndc + dv_ndc),
( u_ndc, v_ndc - dv_ndc)
]
uv_offsets = []
for (u_off, v_off) in offsets:
uv_off = raycast_uv_from_camera(
scene, depsgraph, cam, obj,
u_off, v_off, frame_width, frame_height
)
uv_offsets.append(uv_off)
scale_u = 1.0 / res_x
scale_v = 1.0 / res_y
r_pix = dot_diameter_pixels / 2.0
right_uv, left_uv, up_uv, down_uv = uv_offsets
if right_uv is not None and left_uv is not None:
du_dx = (right_uv - left_uv) / (2 * offset_pix * scale_u)
a_uv = r_pix * du_dx.length * scale_u
elif right_uv is not None:
du_dx = (right_uv - center_uv) / (offset_pix * scale_u)
a_uv = r_pix * du_dx.length * scale_u
elif left_uv is not None:
du_dx = (center_uv - left_uv) / (offset_pix * scale_u)
a_uv = r_pix * du_dx.length * scale_u
else:
du_dx = Vector((1.0, 0.0))
a_uv = r_pix / texture_width
if up_uv is not None and down_uv is not None:
dv_dx = (up_uv - down_uv) / (2 * offset_pix * scale_v)
b_uv = r_pix * dv_dx.length * scale_v
elif up_uv is not None:
dv_dx = (up_uv - center_uv) / (offset_pix * scale_v)
b_uv = r_pix * dv_dx.length * scale_v
elif down_uv is not None:
dv_dx = (center_uv - down_uv) / (offset_pix * scale_v)
b_uv = r_pix * dv_dx.length * scale_v
else:
b_uv = r_pix / texture_height
a_uv = max(a_uv, 1.0 / texture_width)
b_uv = max(b_uv, 1.0 / texture_height)
angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
print(f"Found {len(dots)} valid dots.")
# Create image
img_name = "DotTexture"
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
pixels = list(image.pixels)
# Black background (no mesh)
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
# White for all mesh areas
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
# Draw dots
for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
cx_px = cx_uv * (texture_width - 1)
cy_px = cy_uv * (texture_height - 1)
a_px = a_uv * texture_width
b_px = b_uv * texture_height
draw_ellipse(pixels, texture_width, texture_height,
cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
if uv_centre is not None:
draw_cross(pixels, texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness, cross_color)
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
image.file_format = 'PNG'
image.save()
print(f"Dot texture saved to {abs_path}")
return image
# ----------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------
def main():
# ==================== CONFIGURATION ====================
MODE = "patches" # "patches", "checker", or "dots"
OBJ_NAME = "FingerMesh0525.001"
CAM_NAME = "Camera0607"
TEXTURE_WIDTH = 1640*2
TEXTURE_HEIGHT = 1600*2
OUTPUT_NAMES = {
"patches": "patch_texture.png",
"checker": "checker_texture.png",
"dots": "dot_texture.png",
}
OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png")
# Patch mode
PATCH_NX = 80; PATCH_NY = 45; COLOR_R = 0.05
EDGE_SAMPLE_GRID = 5
# Checker mode
CHECKER_NX = 40; CHECKER_NY = 40; CHECKER_GAP_FILL_PX = 6
# Dot mode
DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
# Cross
CROSS_LENGTH_PX = 40
CROSS_THICKNESS_PX = 3
CROSS_COLOR = (1, 0, 0, 1) # red cross
# ======================================================
start_time = time.time()
scene = bpy.context.scene
depsgraph = bpy.context.evaluated_depsgraph_get()
if OBJ_NAME:
obj = bpy.data.objects.get(OBJ_NAME)
else:
obj = bpy.context.active_object
if obj is None or obj.type != 'MESH':
raise ValueError("No valid mesh object found.")
cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
if cam is None or cam.type != 'CAMERA':
raise ValueError("No camera found.")
if not obj.data.uv_layers.active:
obj.data.uv_layers.new(name="UVMap")
# Compute UV centre
uv_layer = obj.data.uv_layers.active
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
uv_min = uv_coords.min(axis=0)
uv_max = uv_coords.max(axis=0)
uv_centre = (uv_min + uv_max) / 2.0
print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
# Compute UV bounding box centre
me = obj.data
uv_layer = me.uv_layers.active
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
uv_min = uv_coords.min(axis=0)
uv_max = uv_coords.max(axis=0)
uv_centre = (uv_min + uv_max) / 2.0
print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
if MODE == "patches":
generate_patch_texture(
obj, cam, scene, depsgraph,
nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR,
edge_sample_grid=EDGE_SAMPLE_GRID
)
elif MODE == "checker":
generate_checker_texture(
obj, cam, scene, depsgraph,
nx=CHECKER_NX, ny=CHECKER_NY,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR,
edge_sample_grid=EDGE_SAMPLE_GRID,
gap_fill_px=CHECKER_GAP_FILL_PX
)
elif MODE == "dots":
generate_dot_texture(
obj, cam, scene, depsgraph,
dot_diameter_pixels=DOT_DIAMETER_PIX,
spacing_pixels=DOT_SPACING_PIX,
dot_color=DOT_COLOR,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR
)
else:
raise ValueError(f"Unknown MODE: {MODE!r}. Use 'patches', 'checker', or 'dots'.")
print(f"Completed in {time.time()-start_time:.2f} seconds.")
if __name__ == "__main__":
main()