class ShapeAnalyzer:
"""Comprehensive shape analysis tool combining gradients and morphology"""
def __init__(self):
self.shapes_data = []
self.shape_descriptors = {}
def preprocess_image(self, image):
"""Preprocess image for shape analysis"""
# Convert to grayscale
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
# Enhance contrast
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
enhanced = clahe.apply(gray)
# Reduce noise
denoised = cv2.bilateralFilter(enhanced, 9, 75, 75)
return denoised
def detect_edges_gradient(self, image):
"""Edge detection using gradient analysis"""
# Calculate gradients
grad_x = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=3)
grad_y = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=3)
gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2)
gradient_direction = np.arctan2(grad_y, grad_x)
# Non-maximum suppression (simplified Canny approach)
suppressed = self.non_max_suppression(gradient_magnitude, gradient_direction)
# Threshold to get edges
edge_threshold = np.percentile(suppressed, 85)
edges = suppressed > edge_threshold
return edges.astype(np.uint8) * 255, gradient_magnitude, gradient_direction
def non_max_suppression(self, magnitude, direction):
"""Apply non-maximum suppression to thin edges"""
h, w = magnitude.shape
suppressed = np.zeros_like(magnitude)
# Convert angle to 0-180 degrees
angle = direction * 180.0 / np.pi
angle[angle < 0] += 180
for i in range(1, h-1):
for j in range(1, w-1):
angle_val = angle[i, j]
# Determine neighboring pixels based on gradient direction
if (0 <= angle_val < 22.5) or (157.5 <= angle_val <= 180):
# Horizontal edge
neighbors = [magnitude[i, j-1], magnitude[i, j+1]]
elif (22.5 <= angle_val < 67.5):
# Diagonal edge (/)
neighbors = [magnitude[i-1, j+1], magnitude[i+1, j-1]]
elif (67.5 <= angle_val < 112.5):
# Vertical edge
neighbors = [magnitude[i-1, j], magnitude[i+1, j]]
else:
# Diagonal edge ()
neighbors = [magnitude[i-1, j-1], magnitude[i+1, j+1]]
# Suppress if not local maximum
if magnitude[i, j] >= max(neighbors):
suppressed[i, j] = magnitude[i, j]
return suppressed
def extract_shapes(self, edges):
"""Extract and clean shapes using morphological operations"""
# Clean edges
kernel = np.ones((3,3), np.uint8)
# Close small gaps in edges
closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=2)
# Fill enclosed regions
filled = closed.copy()
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.fillPoly(filled, contours, 255)
# Remove small artifacts
opened = cv2.morphologyEx(filled, cv2.MORPH_OPEN,
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5,5)),
iterations=1)
return opened, contours
def calculate_shape_descriptors(self, contour):
"""Calculate comprehensive shape descriptors"""
if len(contour) < 5:
return None
# Basic geometric properties
area = cv2.contourArea(contour)
perimeter = cv2.arcLength(contour, True)
if area == 0 or perimeter == 0:
return None
# Bounding rectangle
x, y, w, h = cv2.boundingRect(contour)
# Minimum enclosing circle
(center_x, center_y), radius = cv2.minEnclosingCircle(contour)
# Fitted ellipse (if enough points)
if len(contour) >= 5:
ellipse = cv2.fitEllipse(contour)
ellipse_area = np.pi * ellipse[1][0] * ellipse[1][1] / 4
else:
ellipse_area = 0
# Convex hull
hull = cv2.convexHull(contour)
hull_area = cv2.contourArea(hull)
# Shape descriptors
descriptors = {
# Basic properties
'area': area,
'perimeter': perimeter,
'centroid': (center_x, center_y),
# Derived measures
'circularity': 4 * np.pi * area / (perimeter**2) if perimeter > 0 else 0,
'aspect_ratio': float(w) / h if h > 0 else 0,
'extent': area / (w * h) if (w * h) > 0 else 0,
'solidity': area / hull_area if hull_area > 0 else 0,
'compactness': perimeter**2 / area if area > 0 else 0,
# Shape complexity
'convexity_defects': len(cv2.convexityDefects(contour, cv2.convexHull(contour, returnPoints=False))) if len(contour) > 3 else 0,
'eccentricity': self.calculate_eccentricity(contour),
# Bounding shape comparisons
'rectangularity': area / (w * h) if (w * h) > 0 else 0,
'circularity_radius': area / (np.pi * radius**2) if radius > 0 else 0,
'ellipticity': area / ellipse_area if ellipse_area > 0 else 0,
}
return descriptors
def calculate_eccentricity(self, contour):
"""Calculate eccentricity using moments"""
if len(contour) < 5:
return 0
moments = cv2.moments(contour)
if moments['m00'] == 0:
return 0
# Central moments
mu20 = moments['mu20'] / moments['m00']
mu02 = moments['mu02'] / moments['m00']
mu11 = moments['mu11'] / moments['m00']
# Calculate eccentricity
lambda1 = (mu20 + mu02 + np.sqrt((mu20 - mu02)**2 + 4*mu11**2)) / 2
lambda2 = (mu20 + mu02 - np.sqrt((mu20 - mu02)**2 + 4*mu11**2)) / 2
if lambda1 == 0:
return 0
eccentricity = np.sqrt(1 - lambda2/lambda1) if lambda1 > lambda2 else 0
return eccentricity
def classify_shape(self, descriptors):
"""Simple shape classification based on descriptors"""
if descriptors is None:
return 'unknown'
circularity = descriptors['circularity']
aspect_ratio = descriptors['aspect_ratio']
rectangularity = descriptors['rectangularity']
solidity = descriptors['solidity']
# Classification rules
if circularity > 0.7:
return 'circle'
elif rectangularity > 0.8 and solidity > 0.95:
if 0.8 < aspect_ratio < 1.2:
return 'square'
else:
return 'rectangle'
elif solidity > 0.95 and 3 <= descriptors.get('convexity_defects', 0) <= 6:
return 'triangle'
elif solidity < 0.7:
return 'complex_shape'
else:
return 'irregular'
def analyze_image(self, image):
"""Complete shape analysis pipeline"""
# Preprocess
processed = self.preprocess_image(image)
# Edge detection
edges, grad_mag, grad_dir = self.detect_edges_gradient(processed)
# Shape extraction
shape_mask, contours = self.extract_shapes(edges)
# Analyze each shape
analysis_results = {
'processed_image': processed,
'edges': edges,
'gradient_magnitude': grad_mag,
'shape_mask': shape_mask,
'shapes': []
}
for i, contour in enumerate(contours):
if cv2.contourArea(contour) > 100: # Filter small contours
descriptors = self.calculate_shape_descriptors(contour)
if descriptors:
shape_class = self.classify_shape(descriptors)
analysis_results['shapes'].append({
'contour': contour,
'descriptors': descriptors,
'classification': shape_class,
'id': i
})
return analysis_results
# Create and test the shape analyzer
shape_analyzer = ShapeAnalyzer()
# Create test image with various shapes
test_image = np.zeros((400, 600, 3), dtype=np.uint8)
# Add various shapes with different intensities
cv2.rectangle(test_image, (50, 50), (150, 150), (200, 200, 200), -1) # Square
cv2.circle(test_image, (300, 100), 50, (180, 180, 180), -1) # Circle
cv2.ellipse(test_image, (450, 100), (60, 30), 0, 0, 360, (160, 160, 160), -1) # Ellipse
# Triangle
triangle_pts = np.array([[100, 250], [150, 350], [50, 350]], np.int32)
cv2.fillPoly(test_image, [triangle_pts], (140, 140, 140))
# Complex shape (L-shape)
cv2.rectangle(test_image, (250, 250), (350, 300), (120, 120, 120), -1)
cv2.rectangle(test_image, (250, 300), (300, 350), (120, 120, 120), -1)
# Star shape (complex)
center = (450, 300)
outer_radius = 40
inner_radius = 20
star_points = []
for i in range(10):
angle = i * np.pi / 5
if i % 2 == 0:
radius = outer_radius
else:
radius = inner_radius
x = int(center[0] + radius * np.cos(angle))
y = int(center[1] + radius * np.sin(angle))
star_points.append([x, y])
cv2.fillPoly(test_image, [np.array(star_points, np.int32)], (100, 100, 100))
# Analyze the shapes
results = shape_analyzer.analyze_image(test_image)
# Visualize results
fig, axes = plt.subplots(2, 4, figsize=(20, 10))
# Row 1: Processing steps
axes[0,0].imshow(cv2.cvtColor(test_image, cv2.COLOR_BGR2RGB))
axes[0,0].set_title('Original Image')
axes[0,1].imshow(results['processed_image'], cmap='gray')
axes[0,1].set_title('Preprocessed')
axes[0,2].imshow(results['gradient_magnitude'], cmap='hot')
axes[0,2].set_title('Gradient Magnitude')
axes[0,3].imshow(results['edges'], cmap='gray')
axes[0,3].set_title('Detected Edges')
# Row 2: Shape analysis
axes[1,0].imshow(results['shape_mask'], cmap='gray')
axes[1,0].set_title('Extracted Shapes')
# Draw classified shapes with labels
classified_image = cv2.cvtColor(test_image, cv2.COLOR_BGR2RGB).copy()
colors = [(255,0,0), (0,255,0), (0,0,255), (255,255,0), (255,0,255), (0,255,255)]
for i, shape_data in enumerate(results['shapes']):
contour = shape_data['contour']
classification = shape_data['classification']
descriptors = shape_data['descriptors']
# Draw contour
color = colors[i % len(colors)]
cv2.drawContours(classified_image, [contour], -1, color, 2)
# Add label
moments = cv2.moments(contour)
if moments['m00'] != 0:
cx = int(moments['m10'] / moments['m00'])
cy = int(moments['m01'] / moments['m00'])
cv2.putText(classified_image, f"{classification}",
(cx-30, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
axes[1,1].imshow(classified_image)
axes[1,1].set_title('Classified Shapes')
# Shape descriptor comparison
if results['shapes']:
shape_names = [shape['classification'] for shape in results['shapes']]
circularities = [shape['descriptors']['circularity'] for shape in results['shapes']]
aspect_ratios = [shape['descriptors']['aspect_ratio'] for shape in results['shapes']]
axes[1,2].scatter(circularities, aspect_ratios, c=range(len(shape_names)), cmap='tab10')
axes[1,2].set_xlabel('Circularity')
axes[1,2].set_ylabel('Aspect Ratio')
axes[1,2].set_title('Shape Feature Space')
# Add shape labels
for i, name in enumerate(shape_names):
axes[1,2].annotate(name, (circularities[i], aspect_ratios[i]),
xytext=(5, 5), textcoords='offset points', fontsize=8)
# Shape statistics table
if results['shapes']:
stats_text = "Shape Analysis Results:
"
for i, shape_data in enumerate(results['shapes']):
desc = shape_data['descriptors']
classification = shape_data['classification']
stats_text += f"Shape {i+1} ({classification}):
"
stats_text += f" Area: {desc['area']:.0f}
"
stats_text += f" Circularity: {desc['circularity']:.3f}
"
stats_text += f" Aspect Ratio: {desc['aspect_ratio']:.3f}
"
stats_text += f" Solidity: {desc['solidity']:.3f}
"
stats_text += f" Eccentricity: {desc['eccentricity']:.3f}
"
axes[1,3].text(0.05, 0.95, stats_text, transform=axes[1,3].transAxes,
verticalalignment='top', fontfamily='monospace', fontsize=8)
axes[1,3].set_xlim(0, 1)
axes[1,3].set_ylim(0, 1)
axes[1,3].axis('off')
axes[1,3].set_title('Shape Descriptors')
for ax in axes.flat[:-1]:
ax.axis('off')
plt.tight_layout()
plt.show()
# Print detailed analysis
print("Detailed Shape Analysis:")
print("=" * 50)
for i, shape_data in enumerate(results['shapes']):
desc = shape_data['descriptors']
classification = shape_data['classification']
print(f"
Shape {i+1}: {classification.upper()}")
print(f"Area: {desc['area']:.1f} pixels")
print(f"Perimeter: {desc['perimeter']:.1f} pixels")
print(f"Centroid: ({desc['centroid'][0]:.1f}, {desc['centroid'][1]:.1f})")
print(f"Circularity: {desc['circularity']:.3f} (1.0 = perfect circle)")
print(f"Aspect Ratio: {desc['aspect_ratio']:.3f} (1.0 = square)")
print(f"Rectangularity: {desc['rectangularity']:.3f}")
print(f"Solidity: {desc['solidity']:.3f} (convexity measure)")
print(f"Eccentricity: {desc['eccentricity']:.3f} (0 = circle, 1 = line)")
print(f"Compactness: {desc['compactness']:.3f}")
# Shape classification explanation
if classification == 'circle':
print("→ High circularity indicates this is likely a circle")
elif classification == 'square':
print("→ High rectangularity + aspect ratio ≈ 1.0 indicates a square")
elif classification == 'rectangle':
print("→ High rectangularity + aspect ratio ≠ 1.0 indicates a rectangle")
elif classification == 'triangle':
print("→ High solidity + specific convexity defects indicate a triangle")
elif classification == 'complex_shape':
print("→ Low solidity indicates a complex, non-convex shape")
else:
print("→ Properties don't match common geometric shapes")
print("-" * 30)