From 1bb1d429e33bc9b8d20ad8aacb69b52305a3aefc Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Tue, 28 Apr 2026 21:07:23 +0800 Subject: [PATCH] finalize color pattern gen script --- patch_generation/patchgen_DD02C.py | 407 +++++++++-------------------- 1 file changed, 120 insertions(+), 287 deletions(-) diff --git a/patch_generation/patchgen_DD02C.py b/patch_generation/patchgen_DD02C.py index 9214f85..ef124f3 100755 --- a/patch_generation/patchgen_DD02C.py +++ b/patch_generation/patchgen_DD02C.py @@ -1,23 +1,12 @@ -#%% import numpy as np import cv2 +from PIL import Image, ImageDraw -# Flat pattern scale before warping to a dome: 1 mm == 100 pixels. -PX_PER_MM = 100.0 - - -def mm_to_px(mm, scale_px_per_mm=PX_PER_MM): - """Convert millimetres to pixels (default 1 mm = 100 px).""" - return mm * scale_px_per_mm - - -def px_to_mm(px, scale_px_per_mm=PX_PER_MM): - """Convert pixels to millimetres (default 1 mm = 100 px).""" - return px / scale_px_per_mm - - +# ---------------------------------------------------------------------- +# Utility functions from the original flat pattern generator +# (unchanged, except we now work with 0‑1 floats) +# ---------------------------------------------------------------------- def merge_intervals(intervals): - # Merge overlapping intervals. if not intervals: return [] intervals.sort(key=lambda x: x[0]) @@ -31,23 +20,18 @@ def merge_intervals(intervals): return merged def get_allowed_intervals(neighbor_values, delta): - # Compute forbidden intervals for this channel from neighbor values. forbidden = [] for c in neighbor_values: low = max(0, c - delta) high = min(1, c + delta) forbidden.append((low, high)) - # Merge overlapping forbidden intervals. merged = merge_intervals(forbidden) allowed = [] - # Allowed region before the first forbidden interval. if merged: if merged[0][0] > 0: allowed.append((0, merged[0][0])) - # Allowed regions between forbidden intervals. for i in range(1, len(merged)): allowed.append((merged[i-1][1], merged[i][0])) - # Allowed region after the last forbidden interval. if merged[-1][1] < 1: allowed.append((merged[-1][1], 1)) else: @@ -55,7 +39,6 @@ def get_allowed_intervals(neighbor_values, delta): return allowed def sample_from_allowed(allowed): - # Choose a random value from the allowed intervals with probability proportional to interval length. lengths = [end - start for start, end in allowed] total_length = sum(lengths) if total_length <= 0: @@ -69,316 +52,166 @@ def sample_from_allowed(allowed): return allowed[-1][0] def sample_channel(neighbor_values, delta): - # Compute allowed intervals and sample a value. allowed = get_allowed_intervals(neighbor_values, delta) if not allowed: return None return sample_from_allowed(allowed) def generate_valid_color(neighbors, r): - """ - Generate a random color that satisfies, for each neighbor, - |R - R_neighbor| >= sqrt(r), and similarly for G and B. - neighbors: list of neighbor colors (each is an array of shape (3,)) - r: threshold for squared difference. - """ 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 the allowed intervals are empty, use a fallback. if sampled is None: sampled = np.random.rand() color[channel] = sampled return color - -def _easing_progress(t, easing, gamma): - """ - Map normalized position t in [0, 1] along an axis to a progress u in [0, 1] - for patch-size interpolation: size = start + (end - start) * u. - """ - t = float(np.clip(t, 0.0, 1.0)) - e = (easing or "linear").lower() - if e == "linear": - return t - if e == "power": - g = float(gamma) - return t**g - if e == "cosine": - return 0.5 * (1.0 - np.cos(np.pi * t)) - if e in ("dome", "dome_sin2"): - # Shallow rise from left (t=0) to right (t=1); slows change near the left - # (useful when the left is farther on a dome and needs larger flat patches). - return np.sin(0.5 * np.pi * t) ** 2 - raise ValueError( - f"Unknown easing {e!r}; use 'linear', 'power', 'cosine', or 'dome'" - ) - - -def patch_sizes_nonlinear_along_length( - total_len_px, - patch_px_start, - patch_px_end, - *, - easing="power", - gamma=2.0, +# ---------------------------------------------------------------------- +# Cylinder‑specific generator +# ---------------------------------------------------------------------- +def generate_color_pattern_cylinder( + R=9.0, # cylinder radius (mm) + H=20.0, # cylinder height (mm) + fov_deg=150.0, # vertical FOV (degrees) + pixels_per_mm=100, # texture resolution + n_azimuth=36, # number of patches around the circumference + n_radial=20, # number of patches vertically (visible part) + r=0.2, # color randomness regulation factor + output_path='cylinder_pattern.png' ): """ - Integer segment lengths along an axis of length ``total_len_px``, with **nonlinear** - nominal patch size from ``patch_px_start`` (at the beginning of the axis, e.g. left) - to ``patch_px_end`` (at the end, e.g. right). - - At cumulative pixel position ``pos`` (start of a segment), let ``t = pos / total_len_px``. - Nominal size is:: - - patch_px_start + (patch_px_end - patch_px_start) * easing(t) - - Segments are at least 1 pixel wide; the sum of segment lengths equals ``total_len_px``. - - Parameters - ---------- - total_len_px : int - Full extent in pixels (e.g. image width after ``mm_to_px(length_mm)``). - patch_px_start, patch_px_end : int - Patch size in pixels at the start vs end of the axis. - easing : str - - ``'linear'``: same as uniform interpolation in ``t`` - - ``'power'``: ``t ** gamma`` (gamma > 1 keeps sizes nearer ``patch_px_start`` longer) - - ``'cosine'``: smooth cosine half-period - - ``'dome'``: ``sin(pi/2 * t) ** 2`` (gentle dome-style progression) - gamma : float - Exponent for ``easing='power'`` (ignored otherwise). - - See Also - -------- - patch_sizes_from_mm : same logic with inputs in millimetres. + Generate a texture map for the inner cylinder wall. + Patches are arranged so that, from a camera at the bottom + looking upward with perspective projection, they appear + uniform in angular and radial extent. """ - if total_len_px <= 0: - return [] - a = max(1, int(round(float(patch_px_start)))) - b = max(1, int(round(float(patch_px_end)))) - tl = float(total_len_px) - sizes = [] - pos = 0 - while pos < total_len_px: - t = pos / tl - u = _easing_progress(t, easing, gamma) - nominal = a + (b - a) * u - seg = int(max(1, round(float(nominal)))) - seg = min(seg, total_len_px - pos) - sizes.append(seg) - pos += seg - diff = total_len_px - sum(sizes) - if diff != 0 and sizes: - sizes[-1] += diff - sizes[-1] = max(1, sizes[-1]) - return sizes + # Camera half‑angle (rad) + half_fov = np.radians(fov_deg / 2.0) -def patch_sizes_from_mm( - length_mm, - patch_mm_start, - patch_mm_end, - *, - px_per_mm=PX_PER_MM, - **kwargs, -): - """ - Same as ``patch_sizes_nonlinear_along_length`` but axis length and patch bounds - are given in **millimetres**; converted with ``1 mm = px_per_mm`` pixels (default 100). - """ - L = int(round(float(length_mm) * float(px_per_mm))) - a = max(1, int(round(float(patch_mm_start) * float(px_per_mm)))) - b = max(1, int(round(float(patch_mm_end) * float(px_per_mm)))) - return patch_sizes_nonlinear_along_length(L, a, b, **kwargs) + # Visible height range (z_min = lowest point seen by camera) + z_min = R / np.tan(half_fov) if half_fov < np.pi/2 else 0.0 + z_max = H + # Image plane radial coordinate r' = R / z + r_top = R / z_max # smallest r' (top) + r_bottom = R / z_min # largest r' (bottom) -def patch_sizes_along_length(total_len, size_at_start, size_at_end): - """ - Integer segment lengths along an axis of length `total_len`, varying **linearly** - from `size_at_start` (at the beginning of the axis) to `size_at_end` (at the end). + # Define patch grid in image space + theta_step = 2 * np.pi / n_azimuth + r_step = (r_bottom - r_top) / n_radial - Equivalent to ``patch_sizes_nonlinear_along_length(..., easing='linear')``. - """ - return patch_sizes_nonlinear_along_length( - total_len, size_at_start, size_at_end, easing="linear", gamma=1.0 - ) + # Texture dimensions + width_px = int(np.round(2 * np.pi * R * pixels_per_mm)) + height_px = int(np.round(H * pixels_per_mm)) + print(f"Texture size: {width_px} x {height_px} pixels") + # Store patch colors in a 2D array (rows = radial index, columns = azimuth) + patch_colors = np.zeros((n_radial, n_azimuth, 3), dtype=np.float32) -def generate_color_pattern_varying_horizontal( - h, - w, - patch_w_max_left, - patch_w_min_right, - r, - *, - easing="linear", - gamma=2.0, -): - """ - Like ``generate_color_pattern``, but patch **size** varies along x (largest on the - left, smallest on the right). Each patch is **square**: side length equals that - column's width, and the same step is used vertically within the column. + # Generate colors row by row (top to bottom) + for j in range(n_radial): # j = 0 → top, j = n_radial-1 → bottom + r_start = r_top + j * r_step + r_end = r_start + r_step + # Corresponding z values (note: r increases → z decreases) + z_start = R / r_start # larger z (higher up) + z_end = R / r_end # smaller z (lower down) + v_start = z_start / H + v_end = z_end / H + # Ensure v_start > v_end (top has larger v) + if v_start < v_end: + v_start, v_end = v_end, v_start - Parameters - ---------- - h, w : int - Output image size. - patch_w_max_left : int - Square side length at the left edge (maximum). - patch_w_min_right : int - Square side length toward the right edge (minimum). - r : float - Same neighbor color constraint as ``generate_color_pattern``. - easing : str - Passed to ``patch_sizes_nonlinear_along_length`` (e.g. ``'dome'`` for dome warp). - gamma : float - Exponent when ``easing='power'``. + for i in range(n_azimuth): + theta_start = i * theta_step + theta_end = theta_start + theta_step + u_start = theta_start / (2 * np.pi) + u_end = theta_end / (2 * np.pi) - Note - ---- - If ``h`` is not a multiple of a column's side length ``s``, the last row in that - column is shorter than ``s`` pixels tall while the width stays ``s`` (a thin strip). - """ - pattern = np.zeros((h, w, 3), dtype=np.float32) - - col_widths = patch_sizes_nonlinear_along_length( - w, patch_w_max_left, patch_w_min_right, easing=easing, gamma=gamma - ) - x_edges = np.concatenate([[0], np.cumsum(col_widths, dtype=np.int64)]) - num_cols = len(col_widths) - - for j in range(num_cols): - s = int(col_widths[j]) - x0 = int(x_edges[j]) - x1 = int(x_edges[j + 1]) - y = 0 - while y < h: - y1 = min(y + s, h) + # Collect neighbor colors (above and left) neighbors = [] - if y >= s: - patch_above = pattern[y - s : y, x0:x1] - if patch_above.size: - neighbors.append(np.mean(patch_above, axis=(0, 1))) + # Above neighbor (previous row, same column) if j > 0: - xl0 = int(x_edges[j - 1]) - patch_left = pattern[y:y1, xl0:x0] - if patch_left.size: - neighbors.append(np.mean(patch_left, axis=(0, 1))) + neighbors.append(patch_colors[j-1, i]) + # Left neighbor (same row, previous column) – wrap around + left_i = (i - 1) % n_azimuth + if i > 0 or j > 0: # at least one neighbor exists + neighbors.append(patch_colors[j, left_i]) if len(neighbors) == 0: new_color = np.random.rand(3) else: new_color = generate_valid_color(neighbors, r) - pattern[y:y1, x0:x1] = new_color - y = y1 + patch_colors[j, i] = new_color - return pattern + # Render the texture image + img = Image.new('RGB', (width_px, height_px), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + for j in range(n_radial): + r_start = r_top + j * r_step + r_end = r_start + r_step + z_start = R / r_start + z_end = R / r_end -def generate_color_pattern(h, w, d, r): - """ - Generate a dense color pattern. - h, w: height and width of the output image in pixels. - d: patch size in pixels (each patch is d x d). - r: randomness regulation factor. For each neighbor the condition is: - (|channel_new - channel_neighbor|)^2 >= r. - """ - pattern = np.zeros((h, w, 3), dtype=np.float32) - num_patches_x = h // d - num_patches_y = w // d + # Determine v for top and bottom edges + v_top = max(z_start, z_end) / H # larger z -> higher on cylinder + v_bottom = min(z_start, z_end) / H # smaller z -> lower on cylinder - for i in range(num_patches_x): - for j in range(num_patches_y): - # Collect neighbor patch colors (using mean because patches have uniform color). - neighbors = [] - if i > 0: - patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d] - neighbors.append(np.mean(patch_above, axis=(0, 1))) - if j > 0: - patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d] - neighbors.append(np.mean(patch_left, axis=(0, 1))) - - if len(neighbors) == 0: - # For the first patch use a random color. - new_color = np.random.rand(3) - else: - new_color = generate_valid_color(neighbors, r) - - pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color - print('Patch ({}, {})'.format(i, j)) - return pattern + for i in range(n_azimuth): + u_start = i * theta_step / (2 * np.pi) + u_end = (i+1) * theta_step / (2 * np.pi) + # Pixel coordinates (ensure integer ordering) + x0 = int(u_start * width_px) + x1 = int(u_end * width_px) + y0 = int(v_bottom * height_px) + y1 = int(v_top * height_px) -def draw_center_cross_bgr( - img, - *, - color=(0, 0, 0), - arm_half_len=None, - thickness=3, -): - """ - Draw a black (+) cross centered on the image for print / camera alignment. - `img` is modified in place (BGR uint8). - """ - h, w = img.shape[:2] - cx, cy = w // 2, h // 2 - if arm_half_len is None: - arm_half_len = max(8, min(h, w) // 40) - half = int(arm_half_len) - cv2.line( - img, - (cx - half, cy), - (cx + half, cy), - color, - thickness, - lineType=cv2.LINE_AA, - ) - cv2.line( - img, - (cx, cy - half), - (cx, cy + half), - color, - thickness, - lineType=cv2.LINE_AA, - ) + # Sanity: x0 < x1, y0 < y1 + if x0 > x1: + x0, x1 = x1, x0 + if y0 > y1: + y0, y1 = y1, y0 + # Avoid degenerate rectangles (patch may span seam) + if x1 <= x0: + x1 = x0 + 1 + if y1 <= y0: + y1 = y0 + 1 -def main(): - # Parameters for the pattern. - # For example, using 100x100 pixels image with patches of 3x3 pixels. - # height = 2750 - # width = 1950 - height = 1502 - width = 1738 - patch_size = 12 - # Set r to a value that gives sufficient difference. - # Note: r is the threshold for squared difference per channel, and the condition is - # |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy. - r = 1000 + color = tuple((patch_colors[j, i] * 255).astype(np.uint8)) + draw.rectangle([x0, y0, x1, y1], fill=color) - # 1 mm = 100 px: e.g. width_mm = px_to_mm(width); patch sizes in mm -> px with mm_to_px. - # Column widths only (nonlinear for dome — left farther): patch_sizes_from_mm( - # length_mm=px_to_mm(width), patch_mm_start=0.30, patch_mm_end=0.06, easing="dome" - # ) - pattern = generate_color_pattern_varying_horizontal( - height, width, patch_w_max_left=30, patch_w_min_right=6, r=r, easing="linear" - ) - # pattern = generate_color_pattern(height, width, patch_size, r) - # Scale to 0-255 for display and saving. - pattern_display = (pattern * 255).astype(np.uint8) - draw_center_cross_bgr(pattern_display) + img.save(output_path) + print(f"Saved texture to {output_path}") - # Display the pattern with OpenCV. - cv2.imshow('Pattern', pattern_display) - cv2.waitKey(0) - cv2.destroyAllWindows() - - # Save the pattern as an image file. - cv2.imwrite('color_pattern.png', pattern_display) + # Also return as numpy array (BGR for OpenCV) + img_np = np.array(img)[:, :, ::-1] # RGB -> BGR + return img_np +# ---------------------------------------------------------------------- +# Example usage +# ---------------------------------------------------------------------- if __name__ == '__main__': - main() + # Generate pattern with 36 azimuth patches and 20 radial patches + patch_size = 12 + R = 9.0 + H = 20.0 + fov_deg = 150.0 + pixels_per_mm = 100 + n_azimuth = int(2*np.pi*R*pixels_per_mm / patch_size) + n_radial = int(H*pixels_per_mm / patch_size) + r = 0.05 + pattern = generate_color_pattern_cylinder( + R=9.0, H=20.0, fov_deg=150.0, pixels_per_mm=100, + n_azimuth=n_azimuth, n_radial=n_radial, r=0.05 + ) + + # Display with OpenCV + cv2.imshow('Cylinder Color Pattern', pattern) + cv2.waitKey(0) + cv2.destroyAllWindows() \ No newline at end of file