some new things, mostly not working or finished
This commit is contained in:
143
key/key.py
Normal file
143
key/key.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import trimesh
|
||||
from shapely.geometry import Polygon
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
from PIL import Image, ImageTk
|
||||
import os
|
||||
|
||||
class KeyForge3DApp:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("KeyForge3D - Key Shape Extractor and 3D Model Generator")
|
||||
self.root.geometry("600x400")
|
||||
|
||||
# Variables
|
||||
self.image_path = None
|
||||
self.scale_factor = 0.1 # Default scale: 1 pixel = 0.1 mm (adjust with a reference object if needed)
|
||||
self.key_thickness = 2.0 # Thickness of the key in mm
|
||||
self.num_cuts = 5 # Number of bitting cuts (adjust based on key type)
|
||||
self.cut_depth_increment = 0.33 # Depth increment per bitting value in mm (e.g., Schlage standard)
|
||||
|
||||
# GUI Elements
|
||||
self.label = tk.Label(root, text="KeyForge3D: Extract and 3D Print Keys", font=("Arial", 16))
|
||||
self.label.pack(pady=10)
|
||||
|
||||
self.upload_button = tk.Button(root, text="Upload Key Image", command=self.upload_image)
|
||||
self.upload_button.pack(pady=5)
|
||||
|
||||
self.image_label = tk.Label(root)
|
||||
self.image_label.pack(pady=5)
|
||||
|
||||
self.process_button = tk.Button(root, text="Process Key and Generate 3D Model", command=self.process_key, state=tk.DISABLED)
|
||||
self.process_button.pack(pady=5)
|
||||
|
||||
self.result_label = tk.Label(root, text="", font=("Arial", 12))
|
||||
self.result_label.pack(pady=5)
|
||||
|
||||
def upload_image(self):
|
||||
"""Allow the user to upload an image of a key."""
|
||||
self.image_path = filedialog.askopenfilename(filetypes=[("Image Files", "*.jpg *.jpeg *.png")])
|
||||
if self.image_path:
|
||||
# Display the uploaded image
|
||||
img = Image.open(self.image_path)
|
||||
img = img.resize((300, 150), Image.Resampling.LANCZOS) # Resize for display
|
||||
img_tk = ImageTk.PhotoImage(img)
|
||||
self.image_label.config(image=img_tk)
|
||||
self.image_label.image = img_tk # Keep a reference to avoid garbage collection
|
||||
self.process_button.config(state=tk.NORMAL)
|
||||
self.result_label.config(text="Image uploaded. Click 'Process Key' to generate the 3D model.")
|
||||
|
||||
def process_key(self):
|
||||
"""Process the key image, extract the shape, and generate a 3D model."""
|
||||
if not self.image_path:
|
||||
messagebox.showerror("Error", "Please upload an image first.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Load the image
|
||||
image = cv2.imread(self.image_path)
|
||||
if image is None:
|
||||
raise ValueError("Could not load the image.")
|
||||
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Apply Gaussian blur and edge detection
|
||||
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
edges = cv2.Canny(blurred, 50, 150)
|
||||
|
||||
# Find contours
|
||||
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
# Filter contours to find the key (long, thin shape)
|
||||
key_contour = None
|
||||
for contour in contours:
|
||||
perimeter = cv2.arcLength(contour, True)
|
||||
approx = cv2.approxPolyDP(contour, 0.02 * perimeter, True)
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
aspect_ratio = w / float(h)
|
||||
if 2 < aspect_ratio < 5 and w > 100: # Adjust these values based on your image
|
||||
key_contour = contour
|
||||
break
|
||||
|
||||
if key_contour is None:
|
||||
raise ValueError("Could not detect a key in the image.")
|
||||
|
||||
# Extract the key region
|
||||
x, y, w, h = cv2.boundingRect(key_contour)
|
||||
key_region = gray[y:y+h, x:x+w]
|
||||
|
||||
# Convert contour to a 2D polygon
|
||||
points = key_contour.reshape(-1, 2) * self.scale_factor
|
||||
key_polygon = Polygon(points)
|
||||
|
||||
# Extrude the polygon to create a 3D model
|
||||
key_mesh = trimesh.creation.extrude_polygon(key_polygon, height=self.key_thickness)
|
||||
|
||||
# Analyze the bitting
|
||||
blade = key_region[h//2:h, :] # Focus on the lower half (blade)
|
||||
height, width = blade.shape
|
||||
segment_width = width // self.num_cuts
|
||||
bitting = []
|
||||
|
||||
for i in range(self.num_cuts):
|
||||
segment = blade[:, i * segment_width:(i + 1) * segment_width]
|
||||
# Find the highest point (shallowest cut) in the segment
|
||||
cut_depth = np.argmax(segment, axis=0).mean()
|
||||
# Scale the depth to real-world dimensions
|
||||
depth_value = (cut_depth / height) * self.cut_depth_increment * 9
|
||||
bitting.append(depth_value)
|
||||
|
||||
# Apply bitting cuts to the 3D model
|
||||
for i, depth in enumerate(bitting):
|
||||
cut_x = (i * segment_width * self.scale_factor) + (segment_width * self.scale_factor / 2)
|
||||
cut_y = 0 # Adjust based on blade position
|
||||
cut_width = segment_width * self.scale_factor
|
||||
cut_height = depth
|
||||
# Create a box for the cut and subtract it from the key mesh
|
||||
cut_box = trimesh.creation.box(
|
||||
extents=[cut_width, cut_height, self.key_thickness + 1],
|
||||
transform=trimesh.transformations.translation_matrix([cut_x, cut_y, 0])
|
||||
)
|
||||
key_mesh = key_mesh.difference(cut_box)
|
||||
|
||||
# Export the 3D model as STL
|
||||
output_path = "key_model.stl"
|
||||
key_mesh.export(output_path)
|
||||
|
||||
# Display the results
|
||||
bitting_code = [int(d / self.cut_depth_increment) for d in bitting]
|
||||
self.result_label.config(
|
||||
text=f"Success! Bitting Code: {bitting_code}\n3D Model saved as '{output_path}'"
|
||||
)
|
||||
messagebox.showinfo("Success", f"3D model generated and saved as '{output_path}'.")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to process the key: {str(e)}")
|
||||
self.result_label.config(text="Error processing the key. See error message.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
root = tk.Tk()
|
||||
app = KeyForge3DApp(root)
|
||||
root.mainloop()
|
||||
847
key/main.py
Normal file
847
key/main.py
Normal file
@@ -0,0 +1,847 @@
|
||||
"""
|
||||
Key to STL Generator - Educational Tool
|
||||
Analyzes key images and creates 3D STL models of the key teeth profile
|
||||
Enhanced version with manual selection tools
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageTk, ImageEnhance
|
||||
import numpy as np
|
||||
from scipy import ndimage, signal
|
||||
from scipy.ndimage import median_filter, gaussian_filter
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
import struct
|
||||
|
||||
class KeyToSTL:
|
||||
def __init__(self):
|
||||
self.original_image = None
|
||||
self.image = None
|
||||
self.processed_image = None
|
||||
self.key_profile = []
|
||||
self.key_bounds = None
|
||||
self.manual_bounds = None
|
||||
self.groove_regions = []
|
||||
|
||||
def load_image(self, filepath):
|
||||
"""Load and preprocess the key image"""
|
||||
self.original_image = Image.open(filepath)
|
||||
self.original_image = self.original_image.convert('RGB')
|
||||
self.image = self.original_image.convert('L')
|
||||
print(f"Image loaded: {self.image.size}")
|
||||
return self.image
|
||||
|
||||
def set_manual_bounds(self, x1, y1, x2, y2):
|
||||
"""Set manual key bounds from user selection"""
|
||||
self.manual_bounds = {
|
||||
'left': min(x1, x2),
|
||||
'right': max(x1, x2),
|
||||
'top': min(y1, y2),
|
||||
'bottom': max(y1, y2)
|
||||
}
|
||||
print(f"Manual bounds set: {self.manual_bounds}")
|
||||
|
||||
def add_groove_region(self, x1, y1, x2, y2):
|
||||
"""Add a groove region that should be emphasized"""
|
||||
region = {
|
||||
'left': min(x1, x2),
|
||||
'right': max(x1, x2),
|
||||
'top': min(y1, y2),
|
||||
'bottom': max(y1, y2)
|
||||
}
|
||||
self.groove_regions.append(region)
|
||||
print(f"Groove region added: {region}")
|
||||
|
||||
def clear_groove_regions(self):
|
||||
"""Clear all groove regions"""
|
||||
self.groove_regions = []
|
||||
print("Groove regions cleared")
|
||||
|
||||
def denoise_image(self, median_size=3, gaussian_sigma=1.5):
|
||||
"""Apply strong denoising to handle static/noise in images"""
|
||||
if self.image is None:
|
||||
raise ValueError("No image loaded")
|
||||
|
||||
img_array = np.array(self.image)
|
||||
denoised = median_filter(img_array, size=median_size)
|
||||
denoised = gaussian_filter(denoised, sigma=gaussian_sigma)
|
||||
|
||||
return denoised
|
||||
|
||||
def auto_detect_key(self, noise_handling=True):
|
||||
"""Automatically detect the key region with noise handling"""
|
||||
if self.image is None:
|
||||
raise ValueError("No image loaded")
|
||||
|
||||
if noise_handling:
|
||||
img_array = self.denoise_image(median_size=5, gaussian_sigma=2.0)
|
||||
else:
|
||||
img_array = np.array(self.image)
|
||||
|
||||
img_pil = Image.fromarray(img_array.astype(np.uint8))
|
||||
enhancer = ImageEnhance.Contrast(img_pil)
|
||||
enhanced = enhancer.enhance(2.5)
|
||||
|
||||
img_array = np.array(enhanced)
|
||||
img_array = gaussian_filter(img_array, sigma=1.0)
|
||||
|
||||
sobel_x = ndimage.sobel(img_array, axis=1)
|
||||
sobel_y = ndimage.sobel(img_array, axis=0)
|
||||
edge_magnitude = np.hypot(sobel_x, sobel_y)
|
||||
|
||||
edge_magnitude = (edge_magnitude / edge_magnitude.max() * 255).astype(np.uint8)
|
||||
|
||||
threshold = np.percentile(edge_magnitude[edge_magnitude > 0], 90)
|
||||
binary_edges = edge_magnitude > threshold
|
||||
|
||||
from scipy.ndimage import binary_closing, binary_opening, binary_dilation, binary_erosion
|
||||
|
||||
binary_edges = binary_opening(binary_edges, structure=np.ones((2, 2)))
|
||||
binary_edges = binary_closing(binary_edges, structure=np.ones((3, 3)))
|
||||
binary_edges = binary_erosion(binary_edges, structure=np.ones((2, 2)))
|
||||
binary_edges = binary_dilation(binary_edges, structure=np.ones((2, 2)))
|
||||
|
||||
self.processed_image = Image.fromarray((binary_edges * 255).astype(np.uint8))
|
||||
return self.processed_image
|
||||
|
||||
def find_key_bounds(self, margin_percent=0.1):
|
||||
"""Find the bounding box of the key with noise-resistant algorithm"""
|
||||
if self.processed_image is None:
|
||||
raise ValueError("Process image first")
|
||||
|
||||
# Use manual bounds if available
|
||||
if self.manual_bounds:
|
||||
self.key_bounds = self.manual_bounds.copy()
|
||||
print(f"Using manual bounds: {self.key_bounds}")
|
||||
return self.key_bounds
|
||||
|
||||
edges_array = np.array(self.processed_image)
|
||||
height, width = edges_array.shape
|
||||
|
||||
row_content = np.sum(edges_array, axis=1)
|
||||
col_content = np.sum(edges_array, axis=0)
|
||||
|
||||
row_smooth = gaussian_filter(row_content.astype(float), sigma=height * 0.02)
|
||||
col_smooth = gaussian_filter(col_content.astype(float), sigma=width * 0.01)
|
||||
|
||||
row_threshold = np.percentile(row_smooth[row_smooth > 0], 25)
|
||||
col_threshold = np.percentile(col_smooth[col_smooth > 0], 15)
|
||||
|
||||
rows_with_content = np.where(row_smooth > row_threshold)[0]
|
||||
cols_with_content = np.where(col_smooth > col_threshold)[0]
|
||||
|
||||
if len(rows_with_content) > 0 and len(cols_with_content) > 0:
|
||||
row_margin = int(height * margin_percent)
|
||||
col_margin = int(width * margin_percent * 0.5)
|
||||
|
||||
self.key_bounds = {
|
||||
'top': max(0, rows_with_content[0] - row_margin),
|
||||
'bottom': min(height - 1, rows_with_content[-1] + row_margin),
|
||||
'left': max(0, cols_with_content[0] - col_margin),
|
||||
'right': min(width - 1, cols_with_content[-1] + col_margin)
|
||||
}
|
||||
print(f"Key bounds detected: {self.key_bounds}")
|
||||
else:
|
||||
self.key_bounds = {
|
||||
'top': height // 4,
|
||||
'bottom': 3 * height // 4,
|
||||
'left': width // 10,
|
||||
'right': 9 * width // 10
|
||||
}
|
||||
print("Using fallback bounds")
|
||||
|
||||
return self.key_bounds
|
||||
|
||||
def extract_key_profile(self, use_top=True, consensus_window=5):
|
||||
"""Extract key profile with noise-resistant consensus approach"""
|
||||
if self.processed_image is None or self.key_bounds is None:
|
||||
raise ValueError("Process image and find bounds first")
|
||||
|
||||
edges_array = np.array(self.processed_image)
|
||||
bounds = self.key_bounds
|
||||
|
||||
roi = edges_array[bounds['top']:bounds['bottom'],
|
||||
bounds['left']:bounds['right']]
|
||||
|
||||
height, width = roi.shape
|
||||
profile = []
|
||||
|
||||
for x in range(width):
|
||||
start_col = max(0, x - consensus_window // 2)
|
||||
end_col = min(width, x + consensus_window // 2 + 1)
|
||||
|
||||
window = roi[:, start_col:end_col]
|
||||
|
||||
edge_positions = []
|
||||
for col in range(window.shape[1]):
|
||||
column = window[:, col]
|
||||
edge_pixels = np.where(column > 128)[0]
|
||||
|
||||
if len(edge_pixels) > 0:
|
||||
if use_top:
|
||||
edge_positions.append(edge_pixels[0])
|
||||
else:
|
||||
edge_positions.append(edge_pixels[-1])
|
||||
|
||||
if len(edge_positions) > 0:
|
||||
y_pos = int(np.median(edge_positions))
|
||||
actual_y = y_pos + bounds['top']
|
||||
actual_x = x + bounds['left']
|
||||
profile.append((actual_x, actual_y))
|
||||
else:
|
||||
if len(profile) > 0:
|
||||
profile.append((x + bounds['left'], profile[-1][1]))
|
||||
else:
|
||||
profile.append((x + bounds['left'], bounds['top'] + height // 2))
|
||||
|
||||
profile = self._fill_profile_gaps(profile)
|
||||
|
||||
# Apply groove emphasis
|
||||
if self.groove_regions:
|
||||
profile = self._emphasize_grooves(profile)
|
||||
|
||||
self.key_profile = profile
|
||||
return profile
|
||||
|
||||
def _emphasize_grooves(self, profile, emphasis_factor=1.5):
|
||||
"""Emphasize groove regions in the profile"""
|
||||
emphasized = []
|
||||
|
||||
for x, y in profile:
|
||||
# Check if point is in any groove region
|
||||
in_groove = False
|
||||
for groove in self.groove_regions:
|
||||
if groove['left'] <= x <= groove['right']:
|
||||
in_groove = True
|
||||
break
|
||||
|
||||
if in_groove:
|
||||
# Find average Y in this region
|
||||
region_ys = [py for px, py in profile if groove['left'] <= px <= groove['right']]
|
||||
if region_ys:
|
||||
avg_y = np.mean(region_ys)
|
||||
# Emphasize deviation from average
|
||||
deviation = y - avg_y
|
||||
new_y = avg_y + deviation * emphasis_factor
|
||||
emphasized.append((x, int(new_y)))
|
||||
else:
|
||||
emphasized.append((x, y))
|
||||
else:
|
||||
emphasized.append((x, y))
|
||||
|
||||
return emphasized
|
||||
|
||||
def _fill_profile_gaps(self, profile, max_gap=10):
|
||||
"""Fill gaps in profile with interpolation"""
|
||||
if len(profile) < 2:
|
||||
return profile
|
||||
|
||||
filled = [profile[0]]
|
||||
|
||||
for i in range(1, len(profile)):
|
||||
prev_x, prev_y = filled[-1]
|
||||
curr_x, curr_y = profile[i]
|
||||
|
||||
if abs(curr_y - prev_y) > max_gap:
|
||||
steps = int(abs(curr_x - prev_x))
|
||||
if steps > 1:
|
||||
for j in range(1, steps):
|
||||
interp_x = prev_x + j
|
||||
interp_y = int(prev_y + (curr_y - prev_y) * j / steps)
|
||||
filled.append((interp_x, interp_y))
|
||||
|
||||
filled.append((curr_x, curr_y))
|
||||
|
||||
return filled
|
||||
|
||||
def smooth_profile_advanced(self, window_size=11, poly_order=3):
|
||||
"""Apply Savitzky-Golay filter with noise handling"""
|
||||
if not self.key_profile:
|
||||
raise ValueError("Extract profile first")
|
||||
|
||||
profile_array = np.array(self.key_profile)
|
||||
x_coords = profile_array[:, 0]
|
||||
y_coords = profile_array[:, 1]
|
||||
|
||||
y_median = median_filter(y_coords, size=5)
|
||||
|
||||
window_size = min(window_size, len(y_median))
|
||||
if window_size % 2 == 0:
|
||||
window_size -= 1
|
||||
window_size = max(window_size, poly_order + 2)
|
||||
if window_size % 2 == 0:
|
||||
window_size += 1
|
||||
|
||||
try:
|
||||
smoothed_y = signal.savgol_filter(y_median, window_size, poly_order)
|
||||
except:
|
||||
smoothed_y = gaussian_filter(y_median, sigma=window_size / 3.0)
|
||||
|
||||
self.key_profile = [(int(x), int(y)) for x, y in zip(x_coords, smoothed_y)]
|
||||
return self.key_profile
|
||||
|
||||
def remove_outliers(self, threshold=3.0, window_size=10):
|
||||
"""Remove outlier points using local statistics"""
|
||||
if not self.key_profile:
|
||||
return
|
||||
|
||||
profile_array = np.array(self.key_profile)
|
||||
y_coords = profile_array[:, 1].astype(float)
|
||||
|
||||
cleaned_y = y_coords.copy()
|
||||
|
||||
for i in range(len(y_coords)):
|
||||
start = max(0, i - window_size // 2)
|
||||
end = min(len(y_coords), i + window_size // 2 + 1)
|
||||
window = y_coords[start:end]
|
||||
|
||||
local_median = np.median(window)
|
||||
mad = np.median(np.abs(window - local_median))
|
||||
|
||||
if mad > 0:
|
||||
z_score = abs(y_coords[i] - local_median) / (1.4826 * mad)
|
||||
if z_score > threshold:
|
||||
cleaned_y[i] = local_median
|
||||
|
||||
self.key_profile = [(int(x), int(y)) for x, y in zip(profile_array[:, 0], cleaned_y)]
|
||||
return self.key_profile
|
||||
|
||||
def normalize_profile(self):
|
||||
"""Normalize profile to 0-1 range for depth"""
|
||||
if not self.key_profile:
|
||||
raise ValueError("Extract profile first")
|
||||
|
||||
profile_array = np.array(self.key_profile)
|
||||
y_coords = profile_array[:, 1]
|
||||
|
||||
y_min, y_max = y_coords.min(), y_coords.max()
|
||||
|
||||
if y_max - y_min < 1:
|
||||
return [(x, 0.5) for x, _ in self.key_profile]
|
||||
|
||||
normalized = []
|
||||
for x, y in self.key_profile:
|
||||
normalized_depth = (y - y_min) / (y_max - y_min)
|
||||
normalized.append((x, normalized_depth))
|
||||
|
||||
return normalized
|
||||
|
||||
def generate_stl(self, output_path, depth_scale=2.0, base_thickness=1.0):
|
||||
"""Generate STL file from the key profile"""
|
||||
if not self.key_profile:
|
||||
raise ValueError("Extract profile first")
|
||||
|
||||
normalized_profile = self.normalize_profile()
|
||||
|
||||
profile_array = np.array(self.key_profile)
|
||||
x_coords = profile_array[:, 0]
|
||||
x_min, x_max = x_coords.min(), x_coords.max()
|
||||
|
||||
key_length = float(x_max - x_min)
|
||||
key_width = 10.0
|
||||
|
||||
scaled_profile = []
|
||||
for (x, _), (_, normalized_depth) in zip(self.key_profile, normalized_profile):
|
||||
scaled_x = float(x - x_min)
|
||||
actual_depth = normalized_depth * depth_scale
|
||||
scaled_profile.append((scaled_x, actual_depth))
|
||||
|
||||
vertices, faces = self._create_mesh(scaled_profile, key_width, base_thickness)
|
||||
self._write_stl(output_path, vertices, faces)
|
||||
|
||||
print(f"STL file saved: {output_path}")
|
||||
print(f"Dimensions: {key_length:.1f} x {key_width:.1f} x {base_thickness + depth_scale:.1f} units")
|
||||
|
||||
def _create_mesh(self, profile, width, base_thickness):
|
||||
"""Create 3D mesh from 2D profile"""
|
||||
vertices = []
|
||||
faces = []
|
||||
|
||||
n = len(profile)
|
||||
|
||||
for x, depth in profile:
|
||||
z_top = base_thickness + depth
|
||||
vertices.append([x, 0.0, z_top])
|
||||
vertices.append([x, width, z_top])
|
||||
vertices.append([x, 0.0, 0.0])
|
||||
vertices.append([x, width, 0.0])
|
||||
|
||||
for i in range(n - 1):
|
||||
base = i * 4
|
||||
next_base = (i + 1) * 4
|
||||
|
||||
v1, v2 = base, base + 1
|
||||
v3, v4 = next_base, next_base + 1
|
||||
faces.append([v1, v3, v2])
|
||||
faces.append([v2, v3, v4])
|
||||
|
||||
v1, v2 = base + 2, base + 3
|
||||
v3, v4 = next_base + 2, next_base + 3
|
||||
faces.append([v1, v2, v3])
|
||||
faces.append([v2, v4, v3])
|
||||
|
||||
v1, v2 = base, base + 2
|
||||
v3, v4 = next_base, next_base + 2
|
||||
faces.append([v1, v3, v2])
|
||||
faces.append([v2, v3, v4])
|
||||
|
||||
v1, v2 = base + 1, base + 3
|
||||
v3, v4 = next_base + 1, next_base + 3
|
||||
faces.append([v1, v2, v3])
|
||||
faces.append([v2, v4, v3])
|
||||
|
||||
faces.append([0, 2, 1])
|
||||
faces.append([1, 2, 3])
|
||||
|
||||
last = (n - 1) * 4
|
||||
faces.append([last, last + 1, last + 2])
|
||||
faces.append([last + 1, last + 3, last + 2])
|
||||
|
||||
return vertices, faces
|
||||
|
||||
def _write_stl(self, filepath, vertices, faces):
|
||||
"""Write vertices and faces to binary STL file"""
|
||||
with open(filepath, 'wb') as f:
|
||||
header = b'Key STL - Python Generated' + b' ' * (80 - 26)
|
||||
f.write(header)
|
||||
f.write(struct.pack('<I', len(faces)))
|
||||
|
||||
for face in faces:
|
||||
v1 = vertices[face[0]]
|
||||
v2 = vertices[face[1]]
|
||||
v3 = vertices[face[2]]
|
||||
|
||||
normal = self._calculate_normal(v1, v2, v3)
|
||||
|
||||
f.write(struct.pack('<fff', *normal))
|
||||
f.write(struct.pack('<fff', *v1))
|
||||
f.write(struct.pack('<fff', *v2))
|
||||
f.write(struct.pack('<fff', *v3))
|
||||
f.write(struct.pack('<H', 0))
|
||||
|
||||
def _calculate_normal(self, v1, v2, v3):
|
||||
"""Calculate face normal vector"""
|
||||
v1, v2, v3 = np.array(v1), np.array(v2), np.array(v3)
|
||||
edge1 = v2 - v1
|
||||
edge2 = v3 - v1
|
||||
normal = np.cross(edge1, edge2)
|
||||
|
||||
length = np.linalg.norm(normal)
|
||||
if length > 0:
|
||||
normal = normal / length
|
||||
else:
|
||||
normal = np.array([0.0, 0.0, 1.0])
|
||||
|
||||
return normal.tolist()
|
||||
|
||||
|
||||
class KeySTLGUI:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("Key to STL Generator - Manual Selection Tools")
|
||||
self.root.geometry("1200x800")
|
||||
|
||||
self.key_processor = KeyToSTL()
|
||||
self.canvas_image = None
|
||||
self.photo_image = None
|
||||
|
||||
# Selection state
|
||||
self.selection_mode = None # 'key_bounds' or 'groove'
|
||||
self.selection_start = None
|
||||
self.current_rect = None
|
||||
self.drawn_rectangles = []
|
||||
|
||||
self._create_widgets()
|
||||
self._bind_canvas_events()
|
||||
|
||||
def _create_widgets(self):
|
||||
main_container = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
|
||||
main_container.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Control panel
|
||||
control_frame = ttk.Frame(main_container, padding="10")
|
||||
main_container.add(control_frame, weight=0)
|
||||
|
||||
ttk.Label(control_frame, text="Key to STL Generator",
|
||||
font=('Arial', 14, 'bold')).pack(pady=10)
|
||||
|
||||
# Step 1
|
||||
ttk.Label(control_frame, text="Step 1: Load Image",
|
||||
font=('Arial', 10, 'bold')).pack(pady=(10, 5))
|
||||
ttk.Button(control_frame, text="📁 Load Image",
|
||||
command=self.load_image, width=22).pack(pady=5)
|
||||
|
||||
ttk.Separator(control_frame, orient='horizontal').pack(fill='x', pady=10)
|
||||
|
||||
# Step 2 - Manual Selection Tools
|
||||
ttk.Label(control_frame, text="Step 2: Manual Selection (Optional)",
|
||||
font=('Arial', 10, 'bold')).pack(pady=(5, 5))
|
||||
|
||||
tool_frame = ttk.LabelFrame(control_frame, text="Selection Tools", padding="5")
|
||||
tool_frame.pack(fill='x', pady=5)
|
||||
|
||||
ttk.Button(tool_frame, text="🔲 Select Key Area",
|
||||
command=self.start_key_selection, width=20).pack(pady=2)
|
||||
ttk.Label(tool_frame, text="Drag rectangle around key",
|
||||
font=('Arial', 8), foreground='gray').pack()
|
||||
|
||||
ttk.Separator(tool_frame, orient='horizontal').pack(fill='x', pady=5)
|
||||
|
||||
ttk.Button(tool_frame, text="🎯 Mark Groove Region",
|
||||
command=self.start_groove_selection, width=20).pack(pady=2)
|
||||
ttk.Label(tool_frame, text="Mark areas with teeth cuts",
|
||||
font=('Arial', 8), foreground='gray').pack()
|
||||
|
||||
ttk.Button(tool_frame, text="🗑️ Clear Grooves",
|
||||
command=self.clear_grooves, width=20).pack(pady=2)
|
||||
|
||||
ttk.Button(tool_frame, text="↩️ Cancel Selection",
|
||||
command=self.cancel_selection, width=20).pack(pady=2)
|
||||
|
||||
self.selection_status = ttk.Label(tool_frame, text="No active tool",
|
||||
font=('Arial', 8, 'italic'), foreground='blue')
|
||||
self.selection_status.pack(pady=5)
|
||||
|
||||
ttk.Separator(control_frame, orient='horizontal').pack(fill='x', pady=10)
|
||||
|
||||
# Step 3 - Auto Process
|
||||
ttk.Label(control_frame, text="Step 3: Auto Process",
|
||||
font=('Arial', 10, 'bold')).pack(pady=(5, 5))
|
||||
|
||||
self.noise_handling_var = tk.BooleanVar(value=True)
|
||||
ttk.Checkbutton(control_frame, text="Enable noise reduction",
|
||||
variable=self.noise_handling_var).pack(pady=2)
|
||||
|
||||
ttk.Button(control_frame, text="🔍 Auto Detect Key",
|
||||
command=self.auto_process, width=22).pack(pady=5)
|
||||
|
||||
self.edge_top_var = tk.BooleanVar(value=True)
|
||||
ttk.Checkbutton(control_frame, text="Use top edge",
|
||||
variable=self.edge_top_var).pack(pady=2)
|
||||
|
||||
ttk.Separator(control_frame, orient='horizontal').pack(fill='x', pady=10)
|
||||
|
||||
# Step 4 - Refine
|
||||
ttk.Label(control_frame, text="Step 4: Refine Profile",
|
||||
font=('Arial', 10, 'bold')).pack(pady=(5, 5))
|
||||
|
||||
ttk.Label(control_frame, text="Smoothing:").pack()
|
||||
self.smooth_var = tk.IntVar(value=11)
|
||||
ttk.Scale(control_frame, from_=5, to=21, variable=self.smooth_var,
|
||||
orient=tk.HORIZONTAL).pack()
|
||||
|
||||
ttk.Button(control_frame, text="✨ Smooth",
|
||||
command=self.smooth_profile, width=22).pack(pady=2)
|
||||
ttk.Button(control_frame, text="🧹 Remove Outliers",
|
||||
command=self.remove_outliers, width=22).pack(pady=2)
|
||||
|
||||
ttk.Separator(control_frame, orient='horizontal').pack(fill='x', pady=10)
|
||||
|
||||
# Step 5 - Export
|
||||
ttk.Label(control_frame, text="Step 5: Export STL",
|
||||
font=('Arial', 10, 'bold')).pack(pady=(5, 5))
|
||||
|
||||
ttk.Label(control_frame, text="Teeth Depth (mm):").pack()
|
||||
self.depth_var = tk.DoubleVar(value=2.0)
|
||||
ttk.Entry(control_frame, textvariable=self.depth_var, width=15).pack()
|
||||
|
||||
ttk.Label(control_frame, text="Base (mm):").pack()
|
||||
self.base_var = tk.DoubleVar(value=1.0)
|
||||
ttk.Entry(control_frame, textvariable=self.base_var, width=15).pack()
|
||||
|
||||
ttk.Button(control_frame, text="💾 Generate STL",
|
||||
command=self.generate_stl, width=22).pack(pady=10)
|
||||
|
||||
# Canvas
|
||||
canvas_frame = ttk.Frame(main_container)
|
||||
main_container.add(canvas_frame, weight=1)
|
||||
|
||||
self.canvas = tk.Canvas(canvas_frame, bg='#f0f0f0', highlightthickness=0, cursor='crosshair')
|
||||
|
||||
h_scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.HORIZONTAL, command=self.canvas.xview)
|
||||
v_scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL, command=self.canvas.yview)
|
||||
|
||||
self.canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set)
|
||||
|
||||
h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Status
|
||||
self.status_label = ttk.Label(self.root, text="Ready - Load a key image to start",
|
||||
relief=tk.SUNKEN, padding=(5, 2))
|
||||
self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
def _bind_canvas_events(self):
|
||||
"""Bind mouse events for manual selection"""
|
||||
self.canvas.bind('<Button-1>', self.on_canvas_click)
|
||||
self.canvas.bind('<B1-Motion>', self.on_canvas_drag)
|
||||
self.canvas.bind('<ButtonRelease-1>', self.on_canvas_release)
|
||||
|
||||
def start_key_selection(self):
|
||||
"""Start key bounds selection mode"""
|
||||
if self.photo_image is None:
|
||||
messagebox.showwarning("Warning", "Please load an image first")
|
||||
return
|
||||
|
||||
self.selection_mode = 'key_bounds'
|
||||
self.selection_status.config(text="✓ Drag rectangle around key area")
|
||||
self.canvas.config(cursor='crosshair')
|
||||
self.status_label.config(text="SELECTION MODE: Draw rectangle around the key")
|
||||
|
||||
def start_groove_selection(self):
|
||||
"""Start groove region selection mode"""
|
||||
if self.photo_image is None:
|
||||
messagebox.showwarning("Warning", "Please load an image first")
|
||||
return
|
||||
|
||||
self.selection_mode = 'groove'
|
||||
self.selection_status.config(text="✓ Drag rectangle over groove area")
|
||||
self.canvas.config(cursor='crosshair')
|
||||
self.status_label.config(text="GROOVE MODE: Draw rectangles over areas with teeth cuts")
|
||||
|
||||
def cancel_selection(self):
|
||||
"""Cancel current selection mode"""
|
||||
self.selection_mode = None
|
||||
self.selection_start = None
|
||||
if self.current_rect:
|
||||
self.canvas.delete(self.current_rect)
|
||||
self.current_rect = None
|
||||
self.selection_status.config(text="No active tool")
|
||||
self.canvas.config(cursor='')
|
||||
self.status_label.config(text="Selection cancelled")
|
||||
|
||||
def clear_grooves(self):
|
||||
"""Clear all groove regions"""
|
||||
self.key_processor.clear_groove_regions()
|
||||
# Redraw without groove rectangles
|
||||
self._redraw_with_annotations()
|
||||
self.status_label.config(text="✓ Groove regions cleared")
|
||||
|
||||
def on_canvas_click(self, event):
|
||||
"""Handle canvas click for selection start"""
|
||||
if self.selection_mode:
|
||||
self.selection_start = (self.canvas.canvasx(event.x),
|
||||
self.canvas.canvasy(event.y))
|
||||
if self.current_rect:
|
||||
self.canvas.delete(self.current_rect)
|
||||
|
||||
def on_canvas_drag(self, event):
|
||||
"""Handle canvas drag for drawing selection rectangle"""
|
||||
if self.selection_mode and self.selection_start:
|
||||
x1, y1 = self.selection_start
|
||||
x2 = self.canvas.canvasx(event.x)
|
||||
y2 = self.canvas.canvasy(event.y)
|
||||
|
||||
if self.current_rect:
|
||||
self.canvas.delete(self.current_rect)
|
||||
|
||||
# Draw rectangle with different colors for different modes
|
||||
color = 'green' if self.selection_mode == 'key_bounds' else 'orange'
|
||||
self.current_rect = self.canvas.create_rectangle(
|
||||
x1, y1, x2, y2,
|
||||
outline=color, width=3, dash=(5, 5)
|
||||
)
|
||||
|
||||
def on_canvas_release(self, event):
|
||||
"""Handle canvas release to finalize selection"""
|
||||
if self.selection_mode and self.selection_start:
|
||||
x1, y1 = self.selection_start
|
||||
x2 = self.canvas.canvasx(event.x)
|
||||
y2 = self.canvas.canvasy(event.y)
|
||||
|
||||
# Ensure we have a valid rectangle
|
||||
if abs(x2 - x1) > 5 and abs(y2 - y1) > 5:
|
||||
if self.selection_mode == 'key_bounds':
|
||||
self.key_processor.set_manual_bounds(int(x1), int(y1), int(x2), int(y2))
|
||||
self.status_label.config(text="✓ Key area selected")
|
||||
# Redraw with permanent rectangle
|
||||
if self.current_rect:
|
||||
self.canvas.delete(self.current_rect)
|
||||
self.current_rect = self.canvas.create_rectangle(
|
||||
x1, y1, x2, y2,
|
||||
outline='green', width=2
|
||||
)
|
||||
self.selection_mode = None
|
||||
self.selection_status.config(text="Key area set")
|
||||
|
||||
elif self.selection_mode == 'groove':
|
||||
self.key_processor.add_groove_region(int(x1), int(y1), int(x2), int(y2))
|
||||
self.status_label.config(text=f"✓ Groove region added (Total: {len(self.key_processor.groove_regions)})")
|
||||
# Draw permanent groove rectangle
|
||||
if self.current_rect:
|
||||
self.canvas.delete(self.current_rect)
|
||||
groove_rect = self.canvas.create_rectangle(
|
||||
x1, y1, x2, y2,
|
||||
outline='orange', width=2, dash=(3, 3)
|
||||
)
|
||||
self.drawn_rectangles.append(groove_rect)
|
||||
# Stay in groove mode for multiple selections
|
||||
|
||||
self.selection_start = None
|
||||
self.current_rect = None
|
||||
|
||||
def _redraw_with_annotations(self):
|
||||
"""Redraw image with all annotations"""
|
||||
if self.key_processor.original_image:
|
||||
self._display_image(self.key_processor.original_image.convert('L'))
|
||||
|
||||
# Redraw key bounds if set
|
||||
if self.key_processor.manual_bounds:
|
||||
bounds = self.key_processor.manual_bounds
|
||||
self.canvas.create_rectangle(
|
||||
bounds['left'], bounds['top'],
|
||||
bounds['right'], bounds['bottom'],
|
||||
outline='green', width=2
|
||||
)
|
||||
|
||||
# Redraw groove regions
|
||||
self.drawn_rectangles = []
|
||||
for groove in self.key_processor.groove_regions:
|
||||
rect = self.canvas.create_rectangle(
|
||||
groove['left'], groove['top'],
|
||||
groove['right'], groove['bottom'],
|
||||
outline='orange', width=2, dash=(3, 3)
|
||||
)
|
||||
self.drawn_rectangles.append(rect)
|
||||
|
||||
def load_image(self):
|
||||
filepath = filedialog.askopenfilename(
|
||||
title="Select Key Image",
|
||||
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif"), ("All files", "*.*")]
|
||||
)
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
self.key_processor.load_image(filepath)
|
||||
self._display_image(self.key_processor.image)
|
||||
self.status_label.config(text=f"✓ Loaded: {filepath.split('/')[-1]}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to load image: {str(e)}")
|
||||
|
||||
def auto_process(self):
|
||||
try:
|
||||
noise_handling = self.noise_handling_var.get()
|
||||
|
||||
self.status_label.config(text="Processing... applying noise reduction" if noise_handling else "Processing... detecting edges")
|
||||
self.root.update()
|
||||
|
||||
self.key_processor.auto_detect_key(noise_handling=noise_handling)
|
||||
|
||||
self.status_label.config(text="Processing... finding key bounds")
|
||||
self.root.update()
|
||||
|
||||
self.key_processor.find_key_bounds()
|
||||
|
||||
self.status_label.config(text="Processing... extracting profile")
|
||||
self.root.update()
|
||||
|
||||
use_top = self.edge_top_var.get()
|
||||
profile = self.key_processor.extract_key_profile(use_top=use_top, consensus_window=7)
|
||||
|
||||
self._display_image_with_profile()
|
||||
|
||||
groove_info = f" (with {len(self.key_processor.groove_regions)} groove regions)" if self.key_processor.groove_regions else ""
|
||||
self.status_label.config(text=f"✓ Profile extracted: {len(profile)} points{groove_info}")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Auto processing failed: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def smooth_profile(self):
|
||||
try:
|
||||
window = self.smooth_var.get()
|
||||
if window % 2 == 0:
|
||||
window += 1
|
||||
self.key_processor.smooth_profile_advanced(window_size=window)
|
||||
self._display_image_with_profile()
|
||||
self.status_label.config(text="✓ Profile smoothed")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to smooth: {str(e)}")
|
||||
|
||||
def remove_outliers(self):
|
||||
try:
|
||||
self.key_processor.remove_outliers()
|
||||
self._display_image_with_profile()
|
||||
self.status_label.config(text="✓ Outliers removed")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to remove outliers: {str(e)}")
|
||||
|
||||
def generate_stl(self):
|
||||
if not self.key_processor.key_profile:
|
||||
messagebox.showwarning("Warning", "Please extract key profile first (Step 3)")
|
||||
return
|
||||
|
||||
filepath = filedialog.asksaveasfilename(
|
||||
title="Save STL File",
|
||||
defaultextension=".stl",
|
||||
filetypes=[("STL files", "*.stl"), ("All files", "*.*")]
|
||||
)
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
self.key_processor.generate_stl(
|
||||
filepath,
|
||||
depth_scale=self.depth_var.get(),
|
||||
base_thickness=self.base_var.get()
|
||||
)
|
||||
messagebox.showinfo("Success",
|
||||
f"✓ STL file generated!\n\n{filepath}\n\nYou can now scale it in your 3D slicer.")
|
||||
self.status_label.config(text=f"✓ STL saved: {filepath.split('/')[-1]}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to generate STL: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _display_image(self, image):
|
||||
if image is None:
|
||||
return
|
||||
|
||||
self.photo_image = ImageTk.PhotoImage(image)
|
||||
self.canvas.delete("all")
|
||||
self.canvas.create_image(0, 0, image=self.photo_image, anchor=tk.NW)
|
||||
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
|
||||
|
||||
def _display_image_with_profile(self):
|
||||
if self.key_processor.processed_image and self.key_processor.key_profile:
|
||||
display_img = self.key_processor.processed_image.convert('RGB')
|
||||
draw = ImageDraw.Draw(display_img)
|
||||
|
||||
# Draw key bounds
|
||||
if self.key_processor.key_bounds:
|
||||
bounds = self.key_processor.key_bounds
|
||||
draw.rectangle(
|
||||
[bounds['left'], bounds['top'], bounds['right'], bounds['bottom']],
|
||||
outline='green', width=3
|
||||
)
|
||||
|
||||
# Draw groove regions
|
||||
for groove in self.key_processor.groove_regions:
|
||||
draw.rectangle(
|
||||
[groove['left'], groove['top'], groove['right'], groove['bottom']],
|
||||
outline='orange', width=2
|
||||
)
|
||||
|
||||
# Draw profile
|
||||
if len(self.key_processor.key_profile) > 1:
|
||||
draw.line(self.key_processor.key_profile, fill='red', width=3)
|
||||
|
||||
for x, y in self.key_processor.key_profile[::10]:
|
||||
draw.ellipse([x-2, y-2, x+2, y+2], fill='yellow', outline='red')
|
||||
|
||||
self._display_image(display_img)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Key to STL Generator - Manual Selection Tools")
|
||||
print("Requirements: pip install scipy pillow numpy")
|
||||
print("\nHow to use manual selection:")
|
||||
print("1. Load your key image")
|
||||
print("2. Click 'Select Key Area' and drag rectangle around key")
|
||||
print("3. Click 'Mark Groove Region' and drag rectangles over teeth cuts")
|
||||
print("4. Click 'Auto Detect Key' to process with your selections")
|
||||
print("5. Refine and export STL")
|
||||
|
||||
root = tk.Tk()
|
||||
app = KeySTLGUI(root)
|
||||
root.mainloop()
|
||||
Reference in New Issue
Block a user