diff --git a/scripts/qt_viewer_2.py b/scripts/qt_viewer_2.py index 8ad37e3..f918a6a 100644 --- a/scripts/qt_viewer_2.py +++ b/scripts/qt_viewer_2.py @@ -35,6 +35,8 @@ PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ # 4 s window # Minimum y-axis span so autoscale does not over-zoom on small fluctuations. Y_AXIS_MIN_SPAN = 20000 CURVE_THEME_COLOR = '#00E5FF' +NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05 +NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535 from PyQt5.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, @@ -125,6 +127,11 @@ def lowpass_filter(data, cutoff_hz, sample_rate_hz=FORCE_SAMPLE_RATE_HZ, order=2 return filtfilt(b, a, y) +def calibrate_normal_force(raw_value): + """Convert raw normal-force reading to Newtons using y = mx + c.""" + return NORMAL_FORCE_CAL_SLOPE * raw_value + NORMAL_FORCE_CAL_OFFSET + + class TeeStream: """同时写入多个流""" def __init__(self, *streams): @@ -167,7 +174,7 @@ QMainWindow { } /* ---- 图像面板 ---- */ -QLabel#arrowsLabel, QLabel#magLabel { +QLabel#arrowsLabel { border: 1px solid #262626; border-radius: 4px; background-color: #0b0b0b; @@ -401,13 +408,6 @@ class MainWindow(QMainWindow): self.arrows_label.setAlignment(Qt.AlignCenter) left_layout.addWidget(self.arrows_label) - self.mag_label = QLabel() - self.mag_label.setObjectName("magLabel") - self.mag_label.setMinimumSize(160, 160) - self.mag_label.setScaledContents(True) - self.mag_label.setAlignment(Qt.AlignCenter) - left_layout.addWidget(self.mag_label) - h_splitter.addWidget(left) # 右侧力曲线 @@ -437,7 +437,7 @@ class MainWindow(QMainWindow): root_layout.addWidget(v_splitter) def _create_plots(self): - titles = ["法向力", "切向力 X", "切向力 Y"] + titles = ["法向力 (N)", "切向力 X", "切向力 Y"] self.plots = [] self.curves = [] self.dot_glows = [] @@ -496,13 +496,13 @@ class MainWindow(QMainWindow): self.graph_widget.nextRow() def adjust_square_images(self): - """根据窗口高度调整左侧面板宽度,使两张图保持正方形""" + """根据窗口高度调整左侧面板宽度,使左侧图像保持正方形""" top_bar_h = 48 log_h = self.log_widget.height() spacing = 20 available = self.height() - top_bar_h - log_h - spacing - each_h = max(available // 2, 160) - self.h_splitter.setSizes([each_h, self.width() - each_h]) + image_w = max(available, 160) + self.h_splitter.setSizes([image_w, self.width() - image_w]) # ---- 帧处理 ---- @@ -534,13 +534,14 @@ class MainWindow(QMainWindow): is_touched = sensor.compute_deformation(check_motion=True, threshold=1) sensor.compute_contact() - fps, fn, fx, fy, flow, vnormal, vshear, depth_map = sensor.read_info( + fps, fn_raw, fx, fy, flow, vnormal, vshear, _ = sensor.read_info( sensor.info.FPS, sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY, sensor.info.VRAW, sensor.info.VNORMAL, sensor.info.VSHEAR, sensor.info.DEPTH ) + fn = calibrate_normal_force(fn_raw) print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}") @@ -564,19 +565,6 @@ class MainWindow(QMainWindow): arrows = cv2.resize(arrows, (400, 400), interpolation=cv2.INTER_CUBIC) self.arrows_label.setPixmap(cv2_to_pixmap(arrows)) - # 显示深度图 - div_abs = depth_map - if div_abs.max() > div_abs.min(): - div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8) - else: - div_normalized = np.zeros_like(div_abs, dtype=np.uint8) - - # 应用颜色映射(使用JET:蓝色=低散度,红色=高散度) - depth_colored = cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET) - - # mag = draw_magnitude_map(flow) - self.mag_label.setPixmap(cv2_to_pixmap(depth_colored)) - # 曲线:先顺序写入 npoints 个真实样本,满后再左移滚动 current_time = time.time() - self.start_time if self._plot_sample_count < self.npoints: diff --git a/tools/3d_patch_generation_blender/dd02c_v2.blend b/tools/3d_patch_generation_blender/dd02c_v2.blend deleted file mode 100644 index 692b1aa..0000000 Binary files a/tools/3d_patch_generation_blender/dd02c_v2.blend and /dev/null differ diff --git a/tools/3d_patch_generation_blender/dd02c_v2.blend1 b/tools/3d_patch_generation_blender/dd02c_v2.blend1 deleted file mode 100644 index fbac531..0000000 Binary files a/tools/3d_patch_generation_blender/dd02c_v2.blend1 and /dev/null differ diff --git a/tools/3d_patch_generation_blender/dd02c_v3.blend b/tools/3d_patch_generation_blender/dd02c_v3.blend new file mode 100644 index 0000000..e7c4e00 Binary files /dev/null and b/tools/3d_patch_generation_blender/dd02c_v3.blend differ diff --git a/tools/3d_patch_generation_blender/dd02c_v3.blend1 b/tools/3d_patch_generation_blender/dd02c_v3.blend1 new file mode 100644 index 0000000..725a69b Binary files /dev/null and b/tools/3d_patch_generation_blender/dd02c_v3.blend1 differ diff --git a/tools/3d_patch_generation_blender/random_patch_dd02c.py b/tools/3d_patch_generation_blender/random_patch_dd02c.py index 8a130b5..c3a282b 100644 --- a/tools/3d_patch_generation_blender/random_patch_dd02c.py +++ b/tools/3d_patch_generation_blender/random_patch_dd02c.py @@ -122,6 +122,66 @@ def fill_polygon_value(grid, width, height, polygon, value): 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) @@ -194,7 +254,8 @@ def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width def convex_hull_uv(points): """Return UV points ordered around their 2D convex hull.""" - unique = sorted({(float(p.x), float(p.y)) for p in points}) + unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points]) + unique = sorted(set(unwrapped)) if len(unique) < 3: return None @@ -282,8 +343,8 @@ def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)): uv = uv_data[loop_idx].uv uv_poly.append((uv.x, uv.y)) # Convert UV (0‑1) to pixel coordinates - pixel_poly = [(u * (tex_width - 1), v * (tex_height - 1)) for u, v in uv_poly] - fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color) + 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.""" @@ -298,8 +359,8 @@ def rasterize_mesh_mask(obj, tex_width, tex_height): for loop_idx in poly.loop_indices: uv = uv_data[loop_idx].uv uv_poly.append((uv.x, uv.y)) - pixel_poly = [(u * (tex_width - 1), v * (tex_height - 1)) for u, v in uv_poly] - fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True) + 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): @@ -495,10 +556,10 @@ def generate_patch_texture( for i in range(nx): uv_poly = patch_uv_polygons[idx]; idx += 1 if uv_poly is None: continue - pixel_poly = [(u*(texture_width-1), v*(texture_height-1)) for u,v in uv_poly] color = patch_colors[j,i] rgba = (color[0], color[1], color[2], 1.0) - fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba) + 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: @@ -583,17 +644,18 @@ def generate_checker_texture( uv_poly = patch_uv_polygons[idx]; idx += 1 if uv_poly is None: continue - pixel_poly = [(u*(texture_width-1), v*(texture_height-1)) for u,v in uv_poly] color = patch_colors[j,i] rgba = (color[0], color[1], color[2], 1.0) - fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba) - fill_polygon_value( - patch_state, - texture_width, - texture_height, - pixel_poly, - 1 if np.mean(color) >= 0.5 else 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 @@ -748,10 +810,10 @@ def generate_dot_texture( def main(): # ==================== CONFIGURATION ==================== MODE = "patches" # "patches", "checker", or "dots" - OBJ_NAME = "FingerMesh" - CAM_NAME = "Camera" - TEXTURE_WIDTH = 4096 - TEXTURE_HEIGHT = 4096 + 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", @@ -760,7 +822,7 @@ def main(): OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png") # Patch mode - PATCH_NX = 60; PATCH_NY = 50; COLOR_R = 0.05 + 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 diff --git a/tools/3d_patch_generation_blender/random_patch_dd02c_fucidals.py b/tools/3d_patch_generation_blender/random_patch_dd02c_fucidals.py new file mode 100644 index 0000000..06ce878 --- /dev/null +++ b/tools/3d_patch_generation_blender/random_patch_dd02c_fucidals.py @@ -0,0 +1,1196 @@ +""" +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 wrap_uv_near_reference(uv, reference_uv): + """Wrap a UV so it stays closest to a reference UV across periodic seams.""" + du = uv.x - reference_uv.x + dv = uv.y - reference_uv.y + if du > 0.5: + du -= 1.0 + elif du < -0.5: + du += 1.0 + if dv > 0.5: + dv -= 1.0 + elif dv < -0.5: + dv += 1.0 + return Vector((reference_uv.x + du, reference_uv.y + dv)) + +def wrapped_uv_distance_sq(uv_a, uv_b): + """Squared UV distance on a periodic 0..1 texture domain.""" + du = abs((uv_a.x % 1.0) - (uv_b.x % 1.0)) + dv = abs((uv_a.y % 1.0) - (uv_b.y % 1.0)) + du = min(du, 1.0 - du) + dv = min(dv, 1.0 - dv) + return du * du + dv * dv + +def draw_wrapped_ellipse_uv( + pixels, width, height, + cx_uv, cy_uv, a_uv, b_uv, angle, color +): + """ + Draw an ellipse in UV space, duplicating it across the texture seam if needed. + """ + base_u = cx_uv % 1.0 + base_v = cy_uv % 1.0 + for u_shift in (-1, 0, 1): + for v_shift in (-1, 0, 1): + shifted_u = base_u + u_shift + shifted_v = base_v + v_shift + if shifted_u + a_uv < 0.0 or shifted_u - a_uv > 1.0: + continue + if shifted_v + b_uv < 0.0 or shifted_v - b_uv > 1.0: + continue + cx_px = shifted_u * (width - 1) + cy_px = shifted_v * (height - 1) + a_px = a_uv * width + b_px = b_uv * height + draw_ellipse(pixels, width, height, cx_px, cy_px, a_px, b_px, angle, color) + +def draw_wrapped_outlined_ellipse_uv( + pixels, width, height, + cx_uv, cy_uv, a_uv, b_uv, angle, + fill_color, outline_color, outline_px=2 +): + """ + Draw a seam-safe ellipse with an outline for visibility on bright textures. + """ + outline_au = a_uv + (outline_px / width) + outline_bv = b_uv + (outline_px / height) + draw_wrapped_ellipse_uv( + pixels, width, height, + cx_uv, cy_uv, outline_au, outline_bv, angle, outline_color + ) + draw_wrapped_ellipse_uv( + pixels, width, height, + cx_uv, cy_uv, a_uv, b_uv, angle, fill_color + ) + +def get_camera_frame(cam, scene): + """Return camera frame dimensions and render resolution.""" + 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 + return frame_width, frame_height, res_x, res_y + +def estimate_dot_uv_parameters( + scene, depsgraph, cam, obj, + u_ndc, v_ndc, center_uv, + frame_width, frame_height, res_x, res_y, + texture_width, texture_height, dot_diameter_pixels +): + """ + Estimate a UV-space dot size for a screen-space circular marker. + + We still sample local UV distortion from camera-space offsets, but collapse + the result to one isotropic UV radius so the marker stays circular on the + texture map instead of turning into an ellipse near the sides. + """ + offset_pix = max(2, dot_diameter_pixels // 4) + du_ndc = offset_pix / res_x + dv_ndc = offset_pix / res_y + 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 + ) + if uv_off is not None: + uv_off = wrap_uv_near_reference(uv_off, center_uv) + 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) + + # Keep the marker circular in the texture domain, but let its radius grow + # with the stronger of the local UV-space scales. + radius_uv = max(a_uv, b_uv) + return radius_uv, radius_uv, 0.0 + +def has_visible_margin( + scene, depsgraph, cam, obj, + u_ndc, v_ndc, frame_width, frame_height, + radius_ndc_u, radius_ndc_v, + extra_margin_px_x=0.0, extra_margin_px_y=0.0, + res_x=1, res_y=1, + angular_samples=12 +): + """ + Check whether a candidate center has enough visible-mesh clearance around it. + + We probe a ring around the candidate in camera space. If those neighboring + rays also hit the mesh, the dot is less likely to cross or touch the visible + silhouette. + """ + test_radius_u = radius_ndc_u + (extra_margin_px_x / max(res_x, 1)) + test_radius_v = radius_ndc_v + (extra_margin_px_y / max(res_y, 1)) + for k in range(max(angular_samples, 4)): + angle = 2.0 * np.pi * k / angular_samples + test_u = u_ndc + test_radius_u * np.cos(angle) + test_v = v_ndc + test_radius_v * np.sin(angle) + hit_loc, _ = raycast_from_camera( + scene, depsgraph, cam, obj, test_u, test_v, frame_width, frame_height + ) + if hit_loc is None: + return False + return True + +def generate_visible_fiducial_dots( + scene, depsgraph, cam, obj, + frame_width, frame_height, res_x, res_y, + texture_width, texture_height, + grid_nx, grid_ny, dot_diameter_pixels, + visibility_sample_nx=96, visibility_sample_ny=96, + edge_margin_pixels=0.0 +): + """ + Build an approximately even grid of fiducials over the visible mesh area. + + The grid is spaced in camera space over the visible mesh bounding box, then + snapped to the nearest visible raycast sample so every fiducial lands on the + actually visible surface. + """ + if grid_nx <= 0 or grid_ny <= 0: + return [] + + visibility_sample_nx = max(int(visibility_sample_nx), grid_nx * 8, 16) + visibility_sample_ny = max(int(visibility_sample_ny), grid_ny * 8, 16) + radius_ndc_u = (dot_diameter_pixels * 0.5) / res_x + radius_ndc_v = (dot_diameter_pixels * 0.5) / res_y + inset_ndc_u = radius_ndc_u + (edge_margin_pixels / max(res_x, 1)) + inset_ndc_v = radius_ndc_v + (edge_margin_pixels / max(res_y, 1)) + + all_visible_samples = [] + interior_visible_samples = [] + for j in range(visibility_sample_ny): + v_ndc = (j + 0.5) / visibility_sample_ny + for i in range(visibility_sample_nx): + u_ndc = (i + 0.5) / visibility_sample_nx + 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 + all_visible_samples.append((u_ndc, v_ndc, center_uv)) + if not has_visible_margin( + scene, depsgraph, cam, obj, + u_ndc, v_ndc, frame_width, frame_height, + radius_ndc_u, radius_ndc_v, + extra_margin_px_x=edge_margin_pixels, + extra_margin_px_y=edge_margin_pixels, + res_x=res_x, + res_y=res_y + ): + continue + interior_visible_samples.append((u_ndc, v_ndc, center_uv)) + + if not all_visible_samples: + print("Warning: No visible mesh samples found for fiducials.") + return [] + if not interior_visible_samples: + print("Warning: No interior visible mesh samples found for fiducials.") + return [] + + all_u_values = np.array([sample[0] for sample in all_visible_samples], dtype=np.float64) + all_v_values = np.array([sample[1] for sample in all_visible_samples], dtype=np.float64) + u_min = float(all_u_values.min() + inset_ndc_u) + u_max = float(all_u_values.max() - inset_ndc_u) + v_min = float(all_v_values.min() + inset_ndc_v) + v_max = float(all_v_values.max() - inset_ndc_v) + + interior_u_values = np.array([sample[0] for sample in interior_visible_samples], dtype=np.float64) + interior_v_values = np.array([sample[1] for sample in interior_visible_samples], dtype=np.float64) + u_min = max(u_min, float(interior_u_values.min())) + u_max = min(u_max, float(interior_u_values.max())) + v_min = max(v_min, float(interior_v_values.min())) + v_max = min(v_max, float(interior_v_values.max())) + + if u_min > u_max: + mid_u = float(interior_u_values.mean()) + u_min = mid_u + u_max = mid_u + if v_min > v_max: + mid_v = float(interior_v_values.mean()) + v_min = mid_v + v_max = mid_v + + if grid_nx == 1: + target_us = np.array([(u_min + u_max) * 0.5], dtype=np.float64) + else: + target_us = np.linspace(u_min, u_max, grid_nx) + + if grid_ny == 1: + target_vs = np.array([(v_min + v_max) * 0.5], dtype=np.float64) + else: + target_vs = np.linspace(v_min, v_max, grid_ny) + + dots = [] + selected_uvs = [] + used_indices = set() + min_sep_u = max(2.0 * dot_diameter_pixels / texture_width, 1.0 / visibility_sample_nx) + min_sep_v = max(2.0 * dot_diameter_pixels / texture_height, 1.0 / visibility_sample_ny) + min_sep_sq = min_sep_u * min_sep_u + min_sep_v * min_sep_v + for target_v in target_vs: + for target_u in target_us: + candidate_indices = [] + for idx, (u_ndc, v_ndc, center_uv) in enumerate(interior_visible_samples): + if idx in used_indices: + continue + dist = (u_ndc - target_u) ** 2 + (v_ndc - target_v) ** 2 + candidate_indices.append((dist, idx)) + candidate_indices.sort(key=lambda item: item[0]) + + chosen_idx = None + fallback_idx = None + for _, idx in candidate_indices: + if fallback_idx is None: + fallback_idx = idx + _, _, center_uv = interior_visible_samples[idx] + if all(wrapped_uv_distance_sq(center_uv, prev_uv) >= min_sep_sq for prev_uv in selected_uvs): + chosen_idx = idx + break + + if chosen_idx is None: + chosen_idx = fallback_idx + if chosen_idx is None: + continue + + used_indices.add(chosen_idx) + u_ndc, v_ndc, center_uv = interior_visible_samples[chosen_idx] + a_uv, b_uv, angle = estimate_dot_uv_parameters( + scene, depsgraph, cam, obj, + u_ndc, v_ndc, center_uv, + frame_width, frame_height, res_x, res_y, + texture_width, texture_height, dot_diameter_pixels + ) + dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle)) + selected_uvs.append(center_uv) + + print( + f"Generated {len(dots)} fiducial dots from a {grid_nx}x{grid_ny} target grid " + f"using {len(interior_visible_samples)}/{len(all_visible_samples)} interior samples." + ) + return dots + +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, + fiducial_nx=0, fiducial_ny=0, fiducial_diameter_pixels=18, + fiducial_color=(1,1,1,1), fiducial_outline_color=(0,0,0,1), + fiducial_outline_px=2, fiducial_edge_margin_pixels=0.0 +): + frame_width, frame_height, res_x, res_y = get_camera_frame(cam, scene) + + 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) + + if fiducial_nx > 0 and fiducial_ny > 0: + fiducial_dots = generate_visible_fiducial_dots( + scene, depsgraph, cam, obj, + frame_width, frame_height, res_x, res_y, + texture_width, texture_height, + fiducial_nx, fiducial_ny, fiducial_diameter_pixels, + edge_margin_pixels=fiducial_edge_margin_pixels + ) + for cx_uv, cy_uv, a_uv, b_uv, angle_uv in fiducial_dots: + draw_wrapped_outlined_ellipse_uv( + pixels, texture_width, texture_height, + cx_uv, cy_uv, a_uv, b_uv, angle_uv, + fiducial_color, fiducial_outline_color, fiducial_outline_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"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) +): + frame_width, frame_height, res_x, res_y = get_camera_frame(cam, scene) + + margin = spacing_pixels // 2 + x_centers = np.arange(margin, res_x, spacing_pixels) + y_centers = np.arange(margin, res_y, spacing_pixels) + + 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 + + a_uv, b_uv, angle = estimate_dot_uv_parameters( + scene, depsgraph, cam, obj, + u_ndc, v_ndc, center_uv, + frame_width, frame_height, res_x, res_y, + texture_width, texture_height, dot_diameter_pixels + ) + 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: + draw_wrapped_ellipse_uv( + pixels, texture_width, texture_height, + cx_uv, cy_uv, a_uv, b_uv, 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 = "FingerMesh0607.001" + CAM_NAME = "ov9734-114.001" + 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 + FIDUCIAL_NX = 3; FIDUCIAL_NY = 3 + FIDUCIAL_DIAMETER_PIX = 24 + FIDUCIAL_COLOR = (1, 1, 1, 1) + FIDUCIAL_OUTLINE_COLOR = (0, 0, 0, 1) + FIDUCIAL_OUTLINE_PX = 2 + FIDUCIAL_EDGE_MARGIN_PIXELS = 12 + # 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, + fiducial_nx=FIDUCIAL_NX, + fiducial_ny=FIDUCIAL_NY, + fiducial_diameter_pixels=FIDUCIAL_DIAMETER_PIX, + fiducial_color=FIDUCIAL_COLOR, + fiducial_outline_color=FIDUCIAL_OUTLINE_COLOR, + fiducial_outline_px=FIDUCIAL_OUTLINE_PX, + fiducial_edge_margin_pixels=FIDUCIAL_EDGE_MARGIN_PIXELS + ) + 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() \ No newline at end of file diff --git a/tools/3d_patch_generation_blender/captured_image_1.jpg b/tools/3d_patch_generation_blender/real_image.jpg similarity index 100% rename from tools/3d_patch_generation_blender/captured_image_1.jpg rename to tools/3d_patch_generation_blender/real_image.jpg diff --git a/tools/3d_patch_generation_blender/render_image.png b/tools/3d_patch_generation_blender/render_image.png new file mode 100644 index 0000000..0ef5309 Binary files /dev/null and b/tools/3d_patch_generation_blender/render_image.png differ diff --git a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N120.png b/tools/3d_patch_generation_blender/texture/patch_texture_0525_N120.png deleted file mode 100644 index 769f728..0000000 Binary files a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N120.png and /dev/null differ diff --git a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N60.png b/tools/3d_patch_generation_blender/texture/patch_texture_0525_N60.png deleted file mode 100644 index bf4adc6..0000000 Binary files a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N60.png and /dev/null differ diff --git a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N75.png b/tools/3d_patch_generation_blender/texture/patch_texture_0525_N75.png deleted file mode 100644 index 5356d64..0000000 Binary files a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N75.png and /dev/null differ diff --git a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N90.png b/tools/3d_patch_generation_blender/texture/patch_texture_0525_N90.png deleted file mode 100644 index 5775e41..0000000 Binary files a/tools/3d_patch_generation_blender/texture/patch_texture_0525_N90.png and /dev/null differ