475 lines
16 KiB
Python
475 lines
16 KiB
Python
|
|
"""
|
||
|
|
Utility script to project point clouds or meshes onto the Z plane (XY plane)
|
||
|
|
and compare the overlap between fish point cloud projection and template point cloud projection.
|
||
|
|
|
||
|
|
The overlap is calculated as: intersection_area / fish_projection_area * 100%
|
||
|
|
|
||
|
|
Classification:
|
||
|
|
- High overlap: >= 90%
|
||
|
|
- Medium overlap: 70% - 90%
|
||
|
|
- Low overlap: < 70%
|
||
|
|
"""
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import trimesh
|
||
|
|
import open3d as o3d
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Union, Tuple, Optional
|
||
|
|
from scipy.spatial import ConvexHull
|
||
|
|
|
||
|
|
# Try importing shapely, but provide fallback if not available
|
||
|
|
try:
|
||
|
|
from shapely.geometry import Polygon, Point
|
||
|
|
from shapely.ops import unary_union
|
||
|
|
SHAPELY_AVAILABLE = True
|
||
|
|
except ImportError:
|
||
|
|
SHAPELY_AVAILABLE = False
|
||
|
|
print("Warning: shapely not available. Using fallback method for polygon operations.")
|
||
|
|
|
||
|
|
|
||
|
|
def load_mesh_or_pointcloud(file_path: Union[str, Path]):
|
||
|
|
"""
|
||
|
|
Load a mesh or point cloud from file.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
file_path: Path to PLY, OBJ, or STL file
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
numpy array of shape (N, 3) containing vertices/points
|
||
|
|
"""
|
||
|
|
file_path = Path(file_path)
|
||
|
|
if not file_path.exists():
|
||
|
|
raise FileNotFoundError(f"File not found: {file_path}")
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Try loading as mesh first
|
||
|
|
mesh = trimesh.load(str(file_path))
|
||
|
|
if hasattr(mesh, 'vertices'):
|
||
|
|
return np.asarray(mesh.vertices)
|
||
|
|
elif hasattr(mesh, 'points'):
|
||
|
|
return np.asarray(mesh.points)
|
||
|
|
else:
|
||
|
|
raise ValueError(f"Unable to extract points from {file_path}")
|
||
|
|
except Exception as e:
|
||
|
|
# Fallback to open3d
|
||
|
|
try:
|
||
|
|
mesh_o3d = o3d.io.read_triangle_mesh(str(file_path))
|
||
|
|
if len(mesh_o3d.vertices) > 0:
|
||
|
|
return np.asarray(mesh_o3d.vertices)
|
||
|
|
else:
|
||
|
|
# Try as point cloud
|
||
|
|
pcd = o3d.io.read_point_cloud(str(file_path))
|
||
|
|
if len(pcd.points) > 0:
|
||
|
|
return np.asarray(pcd.points)
|
||
|
|
else:
|
||
|
|
raise ValueError(f"No points found in {file_path}")
|
||
|
|
except Exception as e2:
|
||
|
|
raise ValueError(f"Failed to load {file_path}: {e}, {e2}")
|
||
|
|
|
||
|
|
|
||
|
|
def remove_tail_portion(points: np.ndarray, tail_ratio: float = 0.2) -> np.ndarray:
|
||
|
|
"""
|
||
|
|
Remove the tail portion (negative X direction) from points.
|
||
|
|
Removes points in the tail_ratio (e.g., 0.2 = 20%) of the length in negative X direction.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
points: Nx3 array of 3D points
|
||
|
|
tail_ratio: Ratio of tail to remove (default: 0.2 for 20%)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Filtered Nx3 array of points with tail removed
|
||
|
|
"""
|
||
|
|
if points.shape[1] != 3:
|
||
|
|
raise ValueError(f"Expected Nx3 array, got shape {points.shape}")
|
||
|
|
|
||
|
|
if len(points) == 0:
|
||
|
|
return points
|
||
|
|
|
||
|
|
# Calculate X-axis range
|
||
|
|
x_vals = points[:, 0]
|
||
|
|
min_x = np.min(x_vals)
|
||
|
|
max_x = np.max(x_vals)
|
||
|
|
length = max_x - min_x
|
||
|
|
|
||
|
|
# Calculate threshold: remove points in the tail_ratio portion from the negative X side
|
||
|
|
# Keep points where X >= (min_x + tail_ratio * length)
|
||
|
|
threshold = min_x + tail_ratio * length
|
||
|
|
|
||
|
|
# Filter points
|
||
|
|
mask = x_vals >= threshold
|
||
|
|
filtered_points = points[mask]
|
||
|
|
|
||
|
|
return filtered_points
|
||
|
|
|
||
|
|
|
||
|
|
def project_to_xy_plane(points: np.ndarray) -> np.ndarray:
|
||
|
|
"""
|
||
|
|
Project 3D points onto the XY plane (Z=0).
|
||
|
|
|
||
|
|
Args:
|
||
|
|
points: Nx3 array of 3D points
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Nx2 array of 2D points (X, Y coordinates)
|
||
|
|
"""
|
||
|
|
if points.shape[1] != 3:
|
||
|
|
raise ValueError(f"Expected Nx3 array, got shape {points.shape}")
|
||
|
|
|
||
|
|
return points[:, :2]
|
||
|
|
|
||
|
|
|
||
|
|
def points_to_polygon(points_2d: np.ndarray, method: str = 'convex_hull'):
|
||
|
|
"""
|
||
|
|
Convert 2D points to a polygon (Shapely Polygon if available, otherwise numpy array).
|
||
|
|
|
||
|
|
Args:
|
||
|
|
points_2d: Nx2 array of 2D points
|
||
|
|
method: Method to use ('convex_hull')
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Shapely Polygon object if shapely is available, otherwise numpy array of hull points
|
||
|
|
"""
|
||
|
|
if len(points_2d) < 3:
|
||
|
|
raise ValueError("Need at least 3 points to create a polygon")
|
||
|
|
|
||
|
|
if method == 'convex_hull':
|
||
|
|
# Use convex hull
|
||
|
|
try:
|
||
|
|
hull = ConvexHull(points_2d)
|
||
|
|
hull_points = points_2d[hull.vertices]
|
||
|
|
|
||
|
|
if SHAPELY_AVAILABLE:
|
||
|
|
polygon = Polygon(hull_points)
|
||
|
|
if not polygon.is_valid:
|
||
|
|
polygon = polygon.buffer(0) # Fix invalid polygon
|
||
|
|
return polygon
|
||
|
|
else:
|
||
|
|
# Return hull points as numpy array (closed polygon)
|
||
|
|
return np.vstack([hull_points, hull_points[0]])
|
||
|
|
except Exception as e:
|
||
|
|
# Fallback: create a simple bounding box
|
||
|
|
min_x, min_y = points_2d.min(axis=0)
|
||
|
|
max_x, max_y = points_2d.max(axis=0)
|
||
|
|
bbox_points = np.array([
|
||
|
|
[min_x, min_y],
|
||
|
|
[max_x, min_y],
|
||
|
|
[max_x, max_y],
|
||
|
|
[min_x, max_y],
|
||
|
|
[min_x, min_y] # Close the polygon
|
||
|
|
])
|
||
|
|
|
||
|
|
if SHAPELY_AVAILABLE:
|
||
|
|
return Polygon(bbox_points[:-1]) # Exclude duplicate closing point
|
||
|
|
else:
|
||
|
|
return bbox_points
|
||
|
|
else:
|
||
|
|
raise ValueError(f"Unknown method: {method}")
|
||
|
|
|
||
|
|
|
||
|
|
def polygon_area_numpy(polygon_points: np.ndarray) -> float:
|
||
|
|
"""
|
||
|
|
Calculate polygon area using the shoelace formula.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
polygon_points: Nx2 array of polygon vertices (closed)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Area in square units
|
||
|
|
"""
|
||
|
|
# Remove duplicate closing point if present
|
||
|
|
if len(polygon_points) > 1 and np.allclose(polygon_points[0], polygon_points[-1]):
|
||
|
|
polygon_points = polygon_points[:-1]
|
||
|
|
|
||
|
|
x = polygon_points[:, 0]
|
||
|
|
y = polygon_points[:, 1]
|
||
|
|
# Shoelace formula
|
||
|
|
area = 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
|
||
|
|
return area
|
||
|
|
|
||
|
|
|
||
|
|
def polygon_intersection_area_numpy(poly1: np.ndarray, poly2: np.ndarray) -> float:
|
||
|
|
"""
|
||
|
|
Calculate intersection area of two polygons using a grid-based method.
|
||
|
|
This is a fallback when shapely is not available.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
poly1: Nx2 array of first polygon vertices
|
||
|
|
poly2: Mx2 array of second polygon vertices
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Intersection area
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
from matplotlib.path import Path as MplPath
|
||
|
|
except ImportError:
|
||
|
|
# If matplotlib is not available, use a simple approximation
|
||
|
|
# Calculate bounding box intersection as approximation
|
||
|
|
min1 = poly1.min(axis=0)
|
||
|
|
max1 = poly1.max(axis=0)
|
||
|
|
min2 = poly2.min(axis=0)
|
||
|
|
max2 = poly2.max(axis=0)
|
||
|
|
|
||
|
|
# Intersection of bounding boxes
|
||
|
|
inter_min = np.maximum(min1, min2)
|
||
|
|
inter_max = np.minimum(max1, max2)
|
||
|
|
|
||
|
|
if np.any(inter_min >= inter_max):
|
||
|
|
return 0.0
|
||
|
|
|
||
|
|
return np.prod(inter_max - inter_min)
|
||
|
|
|
||
|
|
# Find bounding box of both polygons
|
||
|
|
all_points = np.vstack([poly1, poly2])
|
||
|
|
min_x, min_y = all_points.min(axis=0)
|
||
|
|
max_x, max_y = all_points.max(axis=0)
|
||
|
|
|
||
|
|
# Create a grid
|
||
|
|
grid_resolution = 200 # Adjust based on desired accuracy
|
||
|
|
x_grid = np.linspace(min_x, max_x, grid_resolution)
|
||
|
|
y_grid = np.linspace(min_y, max_y, grid_resolution)
|
||
|
|
cell_area = ((max_x - min_x) / grid_resolution) * ((max_y - min_y) / grid_resolution)
|
||
|
|
|
||
|
|
# Check which grid points are inside both polygons
|
||
|
|
path1 = MplPath(poly1)
|
||
|
|
path2 = MplPath(poly2)
|
||
|
|
|
||
|
|
intersection_count = 0
|
||
|
|
for x in x_grid:
|
||
|
|
for y in y_grid:
|
||
|
|
if path1.contains_point((x, y)) and path2.contains_point((x, y)):
|
||
|
|
intersection_count += 1
|
||
|
|
|
||
|
|
return intersection_count * cell_area
|
||
|
|
|
||
|
|
|
||
|
|
def calculate_overlap(fish_points: np.ndarray,
|
||
|
|
template_points: np.ndarray,
|
||
|
|
method: str = 'convex_hull',
|
||
|
|
remove_tail: bool = True,
|
||
|
|
tail_ratio: float = 0.2) -> Tuple[float, float, float, str]:
|
||
|
|
"""
|
||
|
|
Calculate overlap between fish point cloud projection and template point cloud projection.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
fish_points: Nx3 array of fish point cloud
|
||
|
|
template_points: Mx3 array of template point cloud
|
||
|
|
method: Method for polygon creation ('convex_hull')
|
||
|
|
remove_tail: Whether to remove tail portion (20% in negative X direction) before calculation
|
||
|
|
tail_ratio: Ratio of tail to remove if remove_tail is True (default: 0.2 for 20%)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
tuple: (overlap_percent, intersection_area, fish_area, template_area, classification)
|
||
|
|
- overlap_percent: Intersection over Fish percentage (0-100)
|
||
|
|
- intersection_area: Area of intersection in mm²
|
||
|
|
- fish_area: Area of fish projection in mm²
|
||
|
|
- template_area: Area of template projection in mm²
|
||
|
|
- classification: 'high', 'medium', or 'low'
|
||
|
|
"""
|
||
|
|
# Remove tail portion if requested
|
||
|
|
if remove_tail:
|
||
|
|
fish_points = remove_tail_portion(fish_points, tail_ratio=tail_ratio)
|
||
|
|
template_points = remove_tail_portion(template_points, tail_ratio=tail_ratio)
|
||
|
|
|
||
|
|
# Project to XY plane
|
||
|
|
fish_2d = project_to_xy_plane(fish_points)
|
||
|
|
template_2d = project_to_xy_plane(template_points)
|
||
|
|
|
||
|
|
# Convert to polygons
|
||
|
|
fish_polygon = points_to_polygon(fish_2d, method=method)
|
||
|
|
template_polygon = points_to_polygon(template_2d, method=method)
|
||
|
|
|
||
|
|
# Calculate areas
|
||
|
|
if SHAPELY_AVAILABLE:
|
||
|
|
fish_area = fish_polygon.area
|
||
|
|
template_area = template_polygon.area
|
||
|
|
|
||
|
|
# Calculate intersection
|
||
|
|
try:
|
||
|
|
intersection = fish_polygon.intersection(template_polygon)
|
||
|
|
if intersection.is_empty:
|
||
|
|
intersection_area = 0.0
|
||
|
|
else:
|
||
|
|
# Handle MultiPolygon or other geometry types
|
||
|
|
if hasattr(intersection, 'area'):
|
||
|
|
intersection_area = intersection.area
|
||
|
|
else:
|
||
|
|
# If it's a collection, sum all areas
|
||
|
|
intersection_area = sum(p.area for p in intersection.geoms) if hasattr(intersection, 'geoms') else 0.0
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Warning: Error calculating intersection: {e}")
|
||
|
|
intersection_area = 0.0
|
||
|
|
else:
|
||
|
|
# Fallback: use numpy-based calculations
|
||
|
|
fish_area = polygon_area_numpy(fish_polygon)
|
||
|
|
template_area = polygon_area_numpy(template_polygon)
|
||
|
|
intersection_area = polygon_intersection_area_numpy(fish_polygon, template_polygon)
|
||
|
|
|
||
|
|
# Calculate overlap percentage using Intersection over Fish
|
||
|
|
if fish_area > 0:
|
||
|
|
overlap_percent = (intersection_area / fish_area) * 100.0
|
||
|
|
else:
|
||
|
|
overlap_percent = 0.0
|
||
|
|
|
||
|
|
# Classify overlap
|
||
|
|
if overlap_percent >= 90.0:
|
||
|
|
classification = 'high'
|
||
|
|
elif overlap_percent >= 70.0:
|
||
|
|
classification = 'medium'
|
||
|
|
else:
|
||
|
|
classification = 'low'
|
||
|
|
|
||
|
|
return overlap_percent, intersection_area, fish_area, template_area, classification
|
||
|
|
|
||
|
|
|
||
|
|
def compare_overlap(fish_file: Union[str, Path],
|
||
|
|
template_file: Union[str, Path],
|
||
|
|
method: str = 'convex_hull',
|
||
|
|
verbose: bool = True,
|
||
|
|
remove_tail: bool = True,
|
||
|
|
tail_ratio: float = 0.2) -> dict:
|
||
|
|
"""
|
||
|
|
Compare overlap between fish and template point clouds/meshes.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
fish_file: Path to fish point cloud/mesh file
|
||
|
|
template_file: Path to template point cloud/mesh file
|
||
|
|
method: Method for polygon creation ('convex_hull')
|
||
|
|
verbose: Print detailed information
|
||
|
|
remove_tail: Whether to remove tail portion (20% in negative X direction) before calculation
|
||
|
|
tail_ratio: Ratio of tail to remove if remove_tail is True (default: 0.2 for 20%)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Dictionary with overlap information:
|
||
|
|
{
|
||
|
|
'overlap_percent': float, # Intersection over Fish percentage
|
||
|
|
'intersection_area_mm2': float,
|
||
|
|
'fish_area_mm2': float,
|
||
|
|
'template_area_mm2': float,
|
||
|
|
'classification': str ('high', 'medium', or 'low'),
|
||
|
|
'fish_file': str,
|
||
|
|
'template_file': str
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
# Load point clouds/meshes
|
||
|
|
fish_points = load_mesh_or_pointcloud(fish_file)
|
||
|
|
template_points = load_mesh_or_pointcloud(template_file)
|
||
|
|
|
||
|
|
if verbose:
|
||
|
|
print(f"Fish point cloud: {len(fish_points)} points")
|
||
|
|
print(f"Template point cloud: {len(template_points)} points")
|
||
|
|
if remove_tail:
|
||
|
|
print(f"Removing tail portion ({tail_ratio*100:.0f}% in negative X direction)")
|
||
|
|
|
||
|
|
# Calculate overlap
|
||
|
|
overlap_percent, intersection_area, fish_area, template_area, classification = calculate_overlap(
|
||
|
|
fish_points, template_points, method=method, remove_tail=remove_tail, tail_ratio=tail_ratio
|
||
|
|
)
|
||
|
|
|
||
|
|
result = {
|
||
|
|
'overlap_percent': overlap_percent,
|
||
|
|
'intersection_area_mm2': intersection_area,
|
||
|
|
'fish_area_mm2': fish_area,
|
||
|
|
'template_area_mm2': template_area,
|
||
|
|
'classification': classification,
|
||
|
|
'fish_file': str(fish_file),
|
||
|
|
'template_file': str(template_file)
|
||
|
|
}
|
||
|
|
|
||
|
|
if verbose:
|
||
|
|
print(f"\n{'='*60}")
|
||
|
|
print("Overlap Analysis")
|
||
|
|
print(f"{'='*60}")
|
||
|
|
print(f"Fish projection area: {fish_area:.2f} mm²")
|
||
|
|
print(f"Template projection area: {template_area:.2f} mm²")
|
||
|
|
print(f"Intersection area: {intersection_area:.2f} mm²")
|
||
|
|
print(f"Overlap (Intersection/Fish): {overlap_percent:.2f}%")
|
||
|
|
print(f"Classification: {classification.upper()} overlap")
|
||
|
|
if classification == 'low':
|
||
|
|
print(f"⚠ Warning: Low overlap detected. Template may need rescaling.")
|
||
|
|
print(f"{'='*60}")
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
"""Command-line interface for overlap comparison."""
|
||
|
|
import argparse
|
||
|
|
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description="Compare overlap between fish and template point cloud projections"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--fish", "-f",
|
||
|
|
type=str,
|
||
|
|
required=True,
|
||
|
|
help="Path to fish point cloud/mesh file"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--template", "-t",
|
||
|
|
type=str,
|
||
|
|
required=True,
|
||
|
|
help="Path to template point cloud/mesh file"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--method",
|
||
|
|
type=str,
|
||
|
|
default="convex_hull",
|
||
|
|
choices=["convex_hull"],
|
||
|
|
help="Method for polygon creation (default: convex_hull)"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--output", "-o",
|
||
|
|
type=str,
|
||
|
|
default=None,
|
||
|
|
help="Output JSON file to save results"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--quiet", "-q",
|
||
|
|
action="store_true",
|
||
|
|
help="Suppress verbose output"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--remove-tail",
|
||
|
|
action="store_true",
|
||
|
|
default=True,
|
||
|
|
help="Remove tail portion (20% in negative X direction) before calculation (default: True)"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--no-remove-tail",
|
||
|
|
action="store_false",
|
||
|
|
dest="remove_tail",
|
||
|
|
help="Do not remove tail portion"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--tail-ratio",
|
||
|
|
type=float,
|
||
|
|
default=0.2,
|
||
|
|
help="Ratio of tail to remove (default: 0.2 for 20%%)"
|
||
|
|
)
|
||
|
|
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
# Compare overlap
|
||
|
|
result = compare_overlap(
|
||
|
|
args.fish,
|
||
|
|
args.template,
|
||
|
|
method=args.method,
|
||
|
|
verbose=not args.quiet,
|
||
|
|
remove_tail=args.remove_tail,
|
||
|
|
tail_ratio=args.tail_ratio
|
||
|
|
)
|
||
|
|
|
||
|
|
# Save to JSON if requested
|
||
|
|
if args.output:
|
||
|
|
import json
|
||
|
|
with open(args.output, 'w') as f:
|
||
|
|
json.dump(result, f, indent=2)
|
||
|
|
print(f"\nResults saved to: {args.output}")
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|
||
|
|
|