"""Blender headless colored-patch texture generator (YAML-free). The host CLI (orisys-design texture) reads design.yaml, writes a JSON job file, and invokes this script. Blender only needs stdlib json — not PyYAML: blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \ --job Manual overrides (optional, win over job values): --output FILE.png --mesh sensing_surface --cam cam_sensor --width 3280 --height 3200 \ --nx 80 --ny 45 --color-r 0.05 --edge-sample-grid 5 --seed 7 --no-cross Scope: the colored "patches" mode only. checker/dots helpers remain available to callers but are not reachable from the headless entry point. The mesh must already have an active UV map: UV unwrapping is still an in-app Blender step and is never performed here. - Mesh UVs are filled white so that non-visible parts become white. - Texture background (no-mesh areas) stays black. """ import argparse import json import sys import time from pathlib import Path import bpy import numpy as np 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=None, uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5 ): if not output_path: raise ValueError("output_path is required (absolute PNG path)") 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() out_path = Path(output_path) out_path.parent.mkdir(parents=True, exist_ok=True) image.filepath_raw = str(out_path) image.file_format = 'PNG' image.save() print(f"Patch texture saved to {out_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=None, uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5, gap_fill_px=6 ): if not output_path: raise ValueError("output_path is required (absolute PNG path)") 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() out_path = Path(output_path) out_path.parent.mkdir(parents=True, exist_ok=True) image.filepath_raw = str(out_path) image.file_format = 'PNG' image.save() print(f"Checker texture saved to {out_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=None,uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1) ): if not output_path: raise ValueError("output_path is required (absolute PNG path)") 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() out_path = Path(output_path) out_path.parent.mkdir(parents=True, exist_ok=True) image.filepath_raw = str(out_path) image.file_format = 'PNG' image.save() print(f"Dot texture saved to {out_path}") return image # ---------------------------------------------------------------------- # Headless configuration (job file from `orisys-design texture`) # ---------------------------------------------------------------------- _DEFAULT_MESH = "sensing_surface" _DEFAULT_CAMERA = "cam_sensor" class ConfigError(ValueError): """Invalid job/flags (exit code 2).""" class SceneError(RuntimeError): """Missing or unusable scene data (exit code 1).""" def _argv_after_double_dash(): if "--" not in sys.argv: return [] return sys.argv[sys.argv.index("--") + 1:] def parse_args(argv): parser = argparse.ArgumentParser(prog="random_patch_dd02c.py") parser.add_argument( "--job", type=Path, default=None, help="JSON texture job written by orisys-design (output + objects + settings)", ) parser.add_argument("--output", type=Path, default=None, help="Absolute output PNG (overrides job)") parser.add_argument("--mesh", default=None, help="Mesh object name (overrides job)") parser.add_argument("--cam", default=None, help="Camera object name (overrides job)") parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)") parser.add_argument("--height", type=int, default=None, help="Texture height (overrides job)") parser.add_argument("--nx", type=int, default=None, help="Patch columns (overrides job)") parser.add_argument("--ny", type=int, default=None, help="Patch rows (overrides job)") parser.add_argument("--color-r", dest="color_r", type=float, default=None, help="Colour separation") parser.add_argument( "--edge-sample-grid", dest="edge_sample_grid", type=int, default=None, help="Edge sampling grid for partially covered patches", ) parser.add_argument("--seed", type=int, default=None, help="Seed for reproducible colours") parser.add_argument( "--no-cross", dest="no_cross", action="store_true", default=None, help="Do not draw the reference cross", ) parser.add_argument("--cross-length", dest="cross_length", type=int, default=None) parser.add_argument("--cross-thickness", dest="cross_thickness", type=int, default=None) parser.add_argument( "--cross-color", dest="cross_color", type=float, nargs=4, metavar=("R", "G", "B", "A"), default=None, ) return parser.parse_args(argv) def _load_job(path): if not path.is_file(): raise ConfigError(f"Texture job not found: {path}") data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ConfigError(f"Texture job must be a JSON object: {path}") return data def _as_int(value, label, minimum=1): if not isinstance(value, int) or isinstance(value, bool) or value < minimum: raise ConfigError(f"{label}: must be an integer >= {minimum} (got {value!r})") return int(value) def _as_color(value, label): if not isinstance(value, (list, tuple)) or len(value) not in (3, 4): raise ConfigError(f"{label}: must be a list of 3 or 4 numbers in [0, 1] (got {value!r})") for channel in value: if not isinstance(channel, (int, float)) or isinstance(channel, bool): raise ConfigError(f"{label}: every channel must be a number (got {value!r})") if not 0.0 <= float(channel) <= 1.0: raise ConfigError(f"{label}: every channel must be in [0, 1] (got {value!r})") rgba = [float(channel) for channel in value] if len(rgba) == 3: rgba.append(1.0) return tuple(rgba) def _resolve_config(args): """Merge --job JSON with explicit flags. Explicit flags win over job values.""" job = {} if args.job is not None: job = _load_job(args.job.resolve()) output_raw = args.output or job.get("output") if not output_raw: raise ConfigError("Provide --job and/or --output ") output = Path(output_raw).expanduser() if not output.is_absolute(): raise ConfigError(f"output must be an absolute path (got {str(output_raw)!r})") if output.suffix.lower() != ".png": raise ConfigError(f"output must be a .png file (got {str(output_raw)!r})") objects = job.get("objects") or {} if not isinstance(objects, dict): raise ConfigError("job 'objects' must be a mapping") settings = job.get("settings") or {} if not isinstance(settings, dict): raise ConfigError("job 'settings' must be a mapping") job_cross = settings.get("cross") or {} if not isinstance(job_cross, dict): raise ConfigError("job 'settings.cross' must be a mapping") def pick(flag, key, default): if flag is not None: return flag value = settings.get(key, default) return default if value is None else value def pick_cross(flag, key, default): if flag is not None: return flag value = job_cross.get(key, default) return default if value is None else value mesh = args.mesh or objects.get("mesh") or _DEFAULT_MESH camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA width = _as_int(pick(args.width, "width", 2048), "width") height = _as_int(pick(args.height, "height", 2048), "height") nx = _as_int(pick(args.nx, "nx", 80), "nx") ny = _as_int(pick(args.ny, "ny", 45), "ny") edge_sample_grid = _as_int( pick(args.edge_sample_grid, "edge_sample_grid", 5), "edge_sample_grid", minimum=2 ) color_r = pick(args.color_r, "color_r", 0.05) if not isinstance(color_r, (int, float)) or isinstance(color_r, bool): raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})") if not 0.0 <= float(color_r) <= 1.0: raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})") seed = pick(args.seed, "seed", None) if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)): raise ConfigError(f"seed: must be an integer or null (got {seed!r})") cross_enabled = bool(pick_cross(args.no_cross, "enabled", True)) if args.no_cross is not None: cross_enabled = not args.no_cross cross_length = _as_int(pick_cross(args.cross_length, "length_px", 40), "cross.length_px") cross_thickness = _as_int(pick_cross(args.cross_thickness, "thickness_px", 3), "cross.thickness_px") cross_color = _as_color( args.cross_color if args.cross_color is not None else job_cross.get("color", [1.0, 0.0, 0.0, 1.0]), "cross.color", ) return { "output": output, "mesh": str(mesh), "camera": str(camera), "width": width, "height": height, "nx": nx, "ny": ny, "color_r": float(color_r), "edge_sample_grid": edge_sample_grid, "seed": None if seed is None else int(seed), "cross_enabled": cross_enabled, "cross_length": cross_length, "cross_thickness": cross_thickness, "cross_color": cross_color, } def _require_object(name, expected_type, label): obj = bpy.data.objects.get(name) if obj is None: raise SceneError(f"{label} object not found: {name!r}") if obj.type != expected_type: raise SceneError(f"{label} {name!r} has type {obj.type!r}, expected {expected_type!r}") return obj def _uv_centre(obj): """UV bounding-box centre; fails if the mesh was never unwrapped.""" uv_layer = obj.data.uv_layers.active if uv_layer is None or len(uv_layer.data) == 0: raise SceneError( f"Mesh {obj.name!r} has no active UV map with loops. UV unwrapping is still an " "in-app Blender step: unwrap the mesh in the GUI, save the .blend, then re-run " "orisys-design texture." ) uv = np.array( [uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))], dtype=np.float64, ) centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0 print(f"UV centre: ({centre[0]:.4f}, {centre[1]:.4f})") return centre def main(): args = parse_args(_argv_after_double_dash()) try: config = _resolve_config(args) except (ConfigError, json.JSONDecodeError, OSError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 output = config["output"] try: output.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: print(f"ERROR: cannot create output directory {output.parent}: {exc}", file=sys.stderr) return 2 scene = bpy.context.scene depsgraph = bpy.context.evaluated_depsgraph_get() try: obj = _require_object(config["mesh"], "MESH", "Mesh") cam = _require_object(config["camera"], "CAMERA", "Camera") uv_centre = _uv_centre(obj) except SceneError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 if config["seed"] is not None: np.random.seed(config["seed"]) print(f"Using colour seed {config['seed']}") start_time = time.time() try: generate_patch_texture( obj, cam, scene, depsgraph, nx=config["nx"], ny=config["ny"], r=config["color_r"], texture_width=config["width"], texture_height=config["height"], output_path=str(output), uv_centre=uv_centre if config["cross_enabled"] else None, cross_length=config["cross_length"], cross_thickness=config["cross_thickness"], cross_color=config["cross_color"], edge_sample_grid=config["edge_sample_grid"], ) except Exception as exc: print(f"ERROR: texture generation failed: {exc}", file=sys.stderr) return 1 if not output.is_file() or output.stat().st_size == 0: print(f"ERROR: texture was not written: {output}", file=sys.stderr) return 1 print(f"Texture complete: {output}") print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}") print(f"Completed in {time.time() - start_time:.2f} seconds.") return 0 if __name__ == "__main__": raise SystemExit(main())