-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathevaluate.py
More file actions
236 lines (190 loc) · 7.18 KB
/
Copy pathevaluate.py
File metadata and controls
236 lines (190 loc) · 7.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
#!/usr/bin/env python3
"""
Linkage Path Evaluator
Evaluates how well a 4-bar linkage traces a target shape.
Fitness is based on:
1. Path accuracy (primary): How close the traced path is to the target
2. Coverage (secondary): What fraction of the target is traced
3. Validity bonus: Whether the linkage can complete its motion
Higher fitness = better linkage.
"""
import json
import sys
import math
from pathlib import Path
from typing import List, Tuple, Optional
from linkage import Linkage4Bar, load_linkage
from targets import get_target
def hausdorff_distance(path1: List[Tuple[float, float]], path2: List[Tuple[float, float]]) -> float:
"""
Compute the Hausdorff distance between two paths.
This measures the maximum distance from any point on one path to the closest point on the other.
"""
if not path1 or not path2:
return float('inf')
def point_to_path_distance(point: Tuple[float, float], path: List[Tuple[float, float]]) -> float:
px, py = point
min_dist = float('inf')
for qx, qy in path:
dist = math.sqrt((px - qx)**2 + (py - qy)**2)
min_dist = min(min_dist, dist)
return min_dist
# Max distance from path1 to path2
max_dist_1_to_2 = max(point_to_path_distance(p, path2) for p in path1)
# Max distance from path2 to path1
max_dist_2_to_1 = max(point_to_path_distance(p, path1) for p in path2)
return max(max_dist_1_to_2, max_dist_2_to_1)
def average_distance(traced: List[Tuple[float, float]], target: List[Tuple[float, float]]) -> float:
"""
Compute average distance from traced path to target.
For each traced point, find distance to closest target point.
"""
if not traced or not target:
return float('inf')
total_dist = 0.0
for tx, ty in traced:
min_dist = float('inf')
for gx, gy in target:
dist = math.sqrt((tx - gx)**2 + (ty - gy)**2)
min_dist = min(min_dist, dist)
total_dist += min_dist
return total_dist / len(traced)
def coverage_score(traced: List[Tuple[float, float]], target: List[Tuple[float, float]], threshold: float = 0.2) -> float:
"""
Compute what fraction of target points are "covered" by traced path.
A target point is covered if there's a traced point within threshold distance.
"""
if not traced or not target:
return 0.0
covered = 0
for gx, gy in target:
for tx, ty in traced:
dist = math.sqrt((tx - gx)**2 + (ty - gy)**2)
if dist < threshold:
covered += 1
break
return covered / len(target)
def normalize_path(path: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
"""Normalize path to have centroid at origin and unit scale."""
if not path:
return path
# Compute centroid
cx = sum(p[0] for p in path) / len(path)
cy = sum(p[1] for p in path) / len(path)
# Compute scale (max distance from centroid)
max_dist = max(math.sqrt((p[0]-cx)**2 + (p[1]-cy)**2) for p in path)
if max_dist < 0.001:
max_dist = 1.0
# Normalize
return [((p[0]-cx)/max_dist, (p[1]-cy)/max_dist) for p in path]
def evaluate_linkage(linkage: Linkage4Bar, target_name: str = "figure8",
num_samples: int = 200) -> dict:
"""
Evaluate a linkage against a target shape.
Returns dict with:
- valid: bool
- fitness: float (higher is better)
- avg_distance: float (lower is better)
- coverage: float (0-1, higher is better)
- traced_points: int
- error: str (if invalid)
"""
# Check linkage validity
is_valid, msg = linkage.is_valid()
if not is_valid:
return {
"valid": False,
"fitness": 0.0,
"error": f"Invalid linkage: {msg}"
}
# Get target path
try:
target = get_target(target_name, num_points=num_samples)
except Exception as e:
return {
"valid": False,
"fitness": 0.0,
"error": f"Invalid target: {e}"
}
# Trace the linkage path
traced = linkage.trace_path(num_samples=num_samples)
if len(traced) < 10:
return {
"valid": False,
"fitness": 0.0,
"traced_points": len(traced),
"error": "Linkage could not complete enough of its motion"
}
# Normalize both paths for fair comparison
traced_norm = normalize_path(traced)
target_norm = normalize_path(target)
# Compute metrics
avg_dist = average_distance(traced_norm, target_norm)
coverage = coverage_score(traced_norm, target_norm, threshold=0.15)
hausdorff = hausdorff_distance(traced_norm, target_norm)
# Fitness: combine metrics (higher is better)
# - Base fitness from inverse average distance
# - Bonus for coverage
# - Penalty for high Hausdorff (worst-case deviation)
if avg_dist < 0.001:
avg_dist = 0.001 # Prevent division by zero
# Fitness formula:
# - 100 / avg_dist gives high scores for low distances
# - * coverage rewards full coverage
# - / (1 + hausdorff) penalizes worst-case deviations
fitness = (100.0 / (1.0 + avg_dist * 10)) * (0.5 + 0.5 * coverage) / (1.0 + hausdorff * 0.5)
# Bonus for completing full rotation
if linkage.can_complete_rotation():
fitness *= 1.1
return {
"valid": True,
"fitness": round(fitness, 4),
"avg_distance": round(avg_dist, 4),
"coverage": round(coverage, 4),
"hausdorff": round(hausdorff, 4),
"traced_points": len(traced),
"can_rotate": linkage.can_complete_rotation(),
"target": target_name
}
def main():
if len(sys.argv) < 2:
print("Usage: python evaluate.py <linkage.py> [--json] [--target=<name>]", file=sys.stderr)
print("Available targets: heart, figure8, line, circle, ellipse, star, letter_d", file=sys.stderr)
sys.exit(1)
linkage_path = sys.argv[1]
json_output = "--json" in sys.argv
# Parse target name
target_name = "figure8" # default
for arg in sys.argv:
if arg.startswith("--target="):
target_name = arg.split("=")[1]
try:
linkage = load_linkage(linkage_path)
except Exception as e:
result = {
"valid": False,
"fitness": 0.0,
"error": f"Failed to load linkage: {e}"
}
if json_output:
print(json.dumps(result))
else:
print(f"ERROR: {result['error']}")
sys.exit(1)
result = evaluate_linkage(linkage, target_name=target_name)
if json_output:
print(json.dumps(result))
else:
if result["valid"]:
print(f"Linkage evaluation for target '{result.get('target', target_name)}':")
print(f" Fitness: {result['fitness']:.4f}")
print(f" Avg Distance: {result['avg_distance']:.4f}")
print(f" Coverage: {result['coverage']*100:.1f}%")
print(f" Hausdorff: {result['hausdorff']:.4f}")
print(f" Traced Points: {result['traced_points']}")
print(f" Full Rotation: {result['can_rotate']}")
else:
print(f"INVALID: {result['error']}")
sys.exit(1)
if __name__ == "__main__":
main()