Show computed camera 3D position after calibration
- Debug image shows only court quadrilateral + camera XYZ coords - Remove all Hough line debug visualization noise - Simplify _detect_court_corners — returns corners + error only - Display camera positions in calibration card (CAM0/CAM1 X Y Z) - Clean up auto_calibrate flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
178
jetson/main.py
178
jetson/main.py
@@ -35,13 +35,9 @@ _args = None
|
|||||||
|
|
||||||
|
|
||||||
def auto_calibrate():
|
def auto_calibrate():
|
||||||
"""One-click calibration: detect court lines from current frames,
|
"""One-click calibration: detect court rectangle from current frames,
|
||||||
compute camera pose, save to config.
|
compute camera 3D position via solvePnP using known court dimensions.
|
||||||
|
Returns debug images showing detected court quad + computed camera position.
|
||||||
Each camera sees one half of the court from the net position.
|
|
||||||
Detects court lines via Hough transform, finds 4 corners,
|
|
||||||
then uses solvePnP to determine camera position.
|
|
||||||
Returns debug images with detected lines drawn on them.
|
|
||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
@@ -55,74 +51,55 @@ def auto_calibrate():
|
|||||||
side = 'left' if sensor_id == 0 else 'right'
|
side = 'left' if sensor_id == 0 else 'right'
|
||||||
debug_frame = frame.copy()
|
debug_frame = frame.copy()
|
||||||
|
|
||||||
# Detect court lines — returns corners + debug info
|
# Detect court rectangle — find 4 corners of the court
|
||||||
detection = _detect_court_corners(frame, side)
|
detection = _detect_court_corners(frame, side)
|
||||||
|
|
||||||
# Draw all detected Hough lines on debug frame
|
|
||||||
if detection and detection.get('all_lines') is not None:
|
|
||||||
for line in detection['all_lines']:
|
|
||||||
x1, y1, x2, y2 = line[0]
|
|
||||||
cv2.line(debug_frame, (x1, y1), (x2, y2), (50, 50, 50), 1)
|
|
||||||
|
|
||||||
# Draw classified lines
|
|
||||||
if detection and detection.get('horizontals'):
|
|
||||||
for line in detection['horizontals']:
|
|
||||||
x1, y1, x2, y2 = line
|
|
||||||
cv2.line(debug_frame, (x1, y1), (x2, y2), (0, 255, 255), 2) # yellow = horizontal
|
|
||||||
if detection and detection.get('verticals'):
|
|
||||||
for line in detection['verticals']:
|
|
||||||
x1, y1, x2, y2 = line
|
|
||||||
cv2.line(debug_frame, (x1, y1), (x2, y2), (255, 0, 255), 2) # magenta = vertical
|
|
||||||
|
|
||||||
# Draw selected 4 lines (top/bottom/left/right)
|
|
||||||
if detection and detection.get('selected_lines'):
|
|
||||||
sel = detection['selected_lines']
|
|
||||||
colors = {'top': (0, 255, 0), 'bottom': (0, 200, 0),
|
|
||||||
'left': (255, 128, 0), 'right': (200, 100, 0)}
|
|
||||||
for name, line in sel.items():
|
|
||||||
x1, y1, x2, y2 = line
|
|
||||||
cv2.line(debug_frame, (x1, y1), (x2, y2), colors.get(name, (255, 255, 255)), 3)
|
|
||||||
cv2.putText(debug_frame, name, (x1, y1 - 5),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors.get(name, (255, 255, 255)), 1)
|
|
||||||
|
|
||||||
# Encode debug frame
|
|
||||||
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
||||||
debug_b64 = base64.b64encode(jpeg.tobytes()).decode('ascii')
|
|
||||||
|
|
||||||
corners_pixel = detection.get('corners') if detection else None
|
corners_pixel = detection.get('corners') if detection else None
|
||||||
|
|
||||||
if corners_pixel is None:
|
if corners_pixel is None:
|
||||||
error_detail = detection.get('error', 'Unknown') if detection else 'No lines detected at all'
|
error_detail = detection.get('error', 'Unknown') if detection else 'No court detected'
|
||||||
|
# Draw error on debug frame
|
||||||
|
cv2.putText(debug_frame, f"FAILED: {error_detail}", (10, 30),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
||||||
|
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
results[str(sensor_id)] = {
|
results[str(sensor_id)] = {
|
||||||
'ok': False,
|
'ok': False,
|
||||||
'error': f'CAM {sensor_id}: {error_detail}',
|
'error': f'CAM {sensor_id}: {error_detail}',
|
||||||
'debug_image': debug_b64,
|
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Draw corners on debug frame
|
# Draw detected court quadrilateral on debug frame
|
||||||
for i, corner in enumerate(corners_pixel):
|
pts = corners_pixel.astype(int)
|
||||||
pt = (int(corner[0]), int(corner[1]))
|
for i in range(4):
|
||||||
cv2.circle(debug_frame, pt, 8, (0, 0, 255), -1)
|
p1 = tuple(pts[i])
|
||||||
cv2.putText(debug_frame, f'C{i}', (pt[0] + 10, pt[1]),
|
p2 = tuple(pts[(i + 1) % 4])
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
cv2.line(debug_frame, p1, p2, (0, 255, 0), 3)
|
||||||
|
cv2.circle(debug_frame, p1, 8, (0, 0, 255), -1)
|
||||||
|
|
||||||
# Re-encode with corners
|
# Get known 3D coordinates for this half-court
|
||||||
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
||||||
debug_b64 = base64.b64encode(jpeg.tobytes()).decode('ascii')
|
|
||||||
|
|
||||||
# Get known 3D coordinates for this half
|
|
||||||
corners_3d = get_half_court_3d_points(side)
|
corners_3d = get_half_court_3d_points(side)
|
||||||
|
|
||||||
# Calibrate — no try/except, let errors propagate
|
# Calibrate — errors propagate
|
||||||
cal = CameraCalibrator()
|
cal = CameraCalibrator()
|
||||||
cal.calibrate(
|
cal.calibrate(
|
||||||
np.array(corners_pixel, dtype=np.float32),
|
np.array(corners_pixel, dtype=np.float32),
|
||||||
corners_3d,
|
corners_3d, w, h
|
||||||
w, h
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to config
|
# Camera position in world coordinates
|
||||||
|
cam_pos = (-cal.rotation_matrix.T @ cal.translation_vec).flatten()
|
||||||
|
|
||||||
|
# Draw camera position on debug frame
|
||||||
|
cv2.putText(debug_frame,
|
||||||
|
f"Camera: X={cam_pos[0]:.2f}m Y={cam_pos[1]:.2f}m Z={cam_pos[2]:.2f}m",
|
||||||
|
(10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
||||||
|
cv2.putText(debug_frame, f"Court: {side} half",
|
||||||
|
(10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
|
||||||
|
|
||||||
|
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
debug_b64 = base64.b64encode(jpeg.tobytes()).decode('ascii')
|
||||||
|
|
||||||
|
# Save calibration
|
||||||
cal_path = os.path.join(_args.calibration_dir,
|
cal_path = os.path.join(_args.calibration_dir,
|
||||||
f'cam{sensor_id}_calibration.json')
|
f'cam{sensor_id}_calibration.json')
|
||||||
os.makedirs(os.path.dirname(cal_path), exist_ok=True)
|
os.makedirs(os.path.dirname(cal_path), exist_ok=True)
|
||||||
@@ -130,120 +107,81 @@ def auto_calibrate():
|
|||||||
|
|
||||||
state['calibrators'][sensor_id] = cal
|
state['calibrators'][sensor_id] = cal
|
||||||
|
|
||||||
# Get camera position for 3D scene
|
|
||||||
cam_pos = (-cal.rotation_matrix.T @ cal.translation_vec).flatten()
|
|
||||||
|
|
||||||
results[str(sensor_id)] = {
|
results[str(sensor_id)] = {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'camera_position': cam_pos.tolist(),
|
'camera_position': cam_pos.tolist(),
|
||||||
'debug_image': debug_b64,
|
'debug_image': debug_b64,
|
||||||
}
|
}
|
||||||
print(f"[CAM {sensor_id}] Calibrated! Camera at "
|
print(f"[CAM {sensor_id}] Calibrated! Camera at "
|
||||||
f"({cam_pos[0]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})")
|
f"({cam_pos[0]:.2f}, {cam_pos[1]:.2f}, {cam_pos[2]:.2f})")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _detect_court_corners(frame, side):
|
def _detect_court_corners(frame, side):
|
||||||
"""Detect court corners from frame using edge detection.
|
"""Detect 4 corners of the court rectangle from camera frame.
|
||||||
|
|
||||||
|
Uses edge detection + Hough lines to find the court boundaries,
|
||||||
|
then finds 4 intersection points forming the court quadrilateral.
|
||||||
|
|
||||||
Returns dict with:
|
Returns dict with:
|
||||||
corners: 4x2 numpy array or None
|
corners: 4x2 numpy array (TL, TR, BR, BL) or None
|
||||||
all_lines: raw Hough lines
|
error: description string if detection failed
|
||||||
horizontals: classified horizontal lines
|
|
||||||
verticals: classified vertical lines
|
|
||||||
selected_lines: the 4 lines used (top/bottom/left/right)
|
|
||||||
error: description if detection failed
|
|
||||||
"""
|
"""
|
||||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
blur = cv2.GaussianBlur(gray, (5, 5), 0)
|
blur = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||||
edges = cv2.Canny(blur, 50, 150)
|
edges = cv2.Canny(blur, 50, 150)
|
||||||
|
|
||||||
# Detect lines
|
|
||||||
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80,
|
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80,
|
||||||
minLineLength=100, maxLineGap=20)
|
minLineLength=100, maxLineGap=20)
|
||||||
|
|
||||||
if lines is None or len(lines) < 4:
|
if lines is None or len(lines) < 4:
|
||||||
n = 0 if lines is None else len(lines)
|
n = 0 if lines is None else len(lines)
|
||||||
return {
|
return {'corners': None, 'error': f'Found {n} lines, need at least 4'}
|
||||||
'corners': None, 'all_lines': lines,
|
|
||||||
'horizontals': [], 'verticals': [],
|
|
||||||
'selected_lines': {},
|
|
||||||
'error': f'Only {n} Hough lines found (need >= 4)',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Classify lines into horizontal and vertical
|
# Classify into horizontal / vertical
|
||||||
horizontals = []
|
horizontals = []
|
||||||
verticals = []
|
verticals = []
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
x1, y1, x2, y2 = line[0]
|
x1, y1, x2, y2 = line[0]
|
||||||
angle = abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)
|
angle = abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)
|
||||||
|
|
||||||
if angle < 30 or angle > 150:
|
if angle < 30 or angle > 150:
|
||||||
horizontals.append(line[0])
|
horizontals.append(line[0])
|
||||||
elif 60 < angle < 120:
|
elif 60 < angle < 120:
|
||||||
verticals.append(line[0])
|
verticals.append(line[0])
|
||||||
|
|
||||||
if len(horizontals) < 2 or len(verticals) < 2:
|
if len(horizontals) < 2 or len(verticals) < 2:
|
||||||
return {
|
return {'corners': None,
|
||||||
'corners': None, 'all_lines': lines,
|
'error': f'{len(horizontals)} horiz + {len(verticals)} vert lines, need 2+ each'}
|
||||||
'horizontals': [h.tolist() for h in horizontals],
|
|
||||||
'verticals': [v.tolist() for v in verticals],
|
|
||||||
'selected_lines': {},
|
|
||||||
'error': f'{len(horizontals)} horizontal, {len(verticals)} vertical lines (need >= 2 each)',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cluster lines by position to find the dominant ones
|
# Take outermost lines as court boundaries
|
||||||
h_positions = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 2)
|
h_sorted = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 2)
|
||||||
v_positions = sorted(verticals, key=lambda l: (l[0] + l[2]) / 2)
|
v_sorted = sorted(verticals, key=lambda l: (l[0] + l[2]) / 2)
|
||||||
|
|
||||||
top_line = h_positions[0]
|
top, bottom = h_sorted[0], h_sorted[-1]
|
||||||
bottom_line = h_positions[-1]
|
left, right = v_sorted[0], v_sorted[-1]
|
||||||
left_line = v_positions[0]
|
|
||||||
right_line = v_positions[-1]
|
|
||||||
|
|
||||||
selected = {
|
def intersect(l1, l2):
|
||||||
'top': top_line.tolist(), 'bottom': bottom_line.tolist(),
|
|
||||||
'left': left_line.tolist(), 'right': right_line.tolist(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Find intersections as corner points
|
|
||||||
def line_intersection(l1, l2):
|
|
||||||
x1, y1, x2, y2 = l1
|
x1, y1, x2, y2 = l1
|
||||||
x3, y3, x4, y4 = l2
|
x3, y3, x4, y4 = l2
|
||||||
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
|
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
|
||||||
if abs(denom) < 1e-6:
|
if abs(denom) < 1e-6:
|
||||||
return None
|
return None
|
||||||
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
|
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
|
||||||
ix = x1 + t * (x2 - x1)
|
return [x1 + t * (x2 - x1), y1 + t * (y2 - y1)]
|
||||||
iy = y1 + t * (y2 - y1)
|
|
||||||
return [ix, iy]
|
|
||||||
|
|
||||||
corners = [
|
corners = [
|
||||||
line_intersection(top_line, left_line), # TL
|
intersect(top, left),
|
||||||
line_intersection(top_line, right_line), # TR
|
intersect(top, right),
|
||||||
line_intersection(bottom_line, right_line), # BR
|
intersect(bottom, right),
|
||||||
line_intersection(bottom_line, left_line), # BL
|
intersect(bottom, left),
|
||||||
]
|
]
|
||||||
|
|
||||||
if any(c is None for c in corners):
|
if any(c is None for c in corners):
|
||||||
return {
|
return {'corners': None, 'error': 'Court lines are parallel, cannot find intersections'}
|
||||||
'corners': None, 'all_lines': lines,
|
|
||||||
'horizontals': [h.tolist() for h in horizontals],
|
|
||||||
'verticals': [v.tolist() for v in verticals],
|
|
||||||
'selected_lines': selected,
|
|
||||||
'error': 'Lines are parallel — could not find all 4 corner intersections',
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {'corners': np.array(corners, dtype=np.float32), 'error': None}
|
||||||
'corners': np.array(corners, dtype=np.float32),
|
|
||||||
'all_lines': lines,
|
|
||||||
'horizontals': [h.tolist() for h in horizontals],
|
|
||||||
'verticals': [v.tolist() for v in verticals],
|
|
||||||
'selected_lines': selected,
|
|
||||||
'error': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -331,13 +331,18 @@
|
|||||||
<div class="bottom-bar">
|
<div class="bottom-bar">
|
||||||
<div class="bottom-card"><img id="cal-cam1" alt="Camera 1"></div>
|
<div class="bottom-card"><img id="cal-cam1" alt="Camera 1"></div>
|
||||||
<div class="bottom-card"><img id="cal-cam0" alt="Camera 0"></div>
|
<div class="bottom-card"><img id="cal-cam0" alt="Camera 0"></div>
|
||||||
<div class="cam-card">
|
<div class="cam-card" style="width:auto;min-width:160px">
|
||||||
<div class="cc-title">Calibration</div>
|
<div class="cc-title">Calibration</div>
|
||||||
<button class="btn-calibrate" id="btnCalibrate" onclick="doCalibrate()">Calibrate</button>
|
<button class="btn-calibrate" id="btnCalibrate" onclick="doCalibrate()">Calibrate</button>
|
||||||
<div class="calibrate-status" id="calStatus">
|
<div class="calibrate-status" id="calStatus">
|
||||||
<span id="calStatusText">Not calibrated</span>
|
<span id="calStatusText">Not calibrated</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="calError" style="color:#ff4444;font-size:8px;word-break:break-all;display:none"></div>
|
<div id="calError" style="color:#ff4444;font-size:8px;word-break:break-all;display:none"></div>
|
||||||
|
<div id="calPositions" style="display:none">
|
||||||
|
<div class="cc-divider"></div>
|
||||||
|
<div class="cc-item" id="calPos0" style="color:#4ecca3;font-size:8px"></div>
|
||||||
|
<div class="cc-item" id="calPos1" style="color:#ff88cc;font-size:8px"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bottom-card" id="calDebug0" style="display:none"><img id="calDebugImg0" alt="CAM 0 debug"></div>
|
<div class="bottom-card" id="calDebug0" style="display:none"><img id="calDebugImg0" alt="CAM 0 debug"></div>
|
||||||
<div class="bottom-card" id="calDebug1" style="display:none"><img id="calDebugImg1" alt="CAM 1 debug"></div>
|
<div class="bottom-card" id="calDebug1" style="display:none"><img id="calDebugImg1" alt="CAM 1 debug"></div>
|
||||||
@@ -437,6 +442,20 @@ function doCalibrate() {
|
|||||||
errEl.style.display = 'none';
|
errEl.style.display = 'none';
|
||||||
updateCalibrationStatus();
|
updateCalibrationStatus();
|
||||||
|
|
||||||
|
// Show computed camera positions
|
||||||
|
var posEl = document.getElementById('calPositions');
|
||||||
|
if (posEl && data.result) {
|
||||||
|
posEl.style.display = 'block';
|
||||||
|
for (var sid in data.result) {
|
||||||
|
var r = data.result[sid];
|
||||||
|
if (r.ok && r.camera_position) {
|
||||||
|
var p = r.camera_position;
|
||||||
|
var el = document.getElementById('calPos' + sid);
|
||||||
|
if (el) el.textContent = 'CAM' + sid + ': X=' + p[0].toFixed(2) + ' Y=' + p[1].toFixed(2) + ' Z=' + p[2].toFixed(2) + 'm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/api/calibration/data')
|
fetch('/api/calibration/data')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(camData) { addCamerasToScene(camData); });
|
.then(function(camData) { addCamerasToScene(camData); });
|
||||||
|
|||||||
Reference in New Issue
Block a user