Initial commit: FishServer monorepo (FishAction, FishMeasure, fish_api)
Made-with: Cursor
This commit is contained in:
474
FishMeasure/utils/project_and_compare_overlap.py
Executable file
474
FishMeasure/utils/project_and_compare_overlap.py
Executable file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user