Remove all fallbacks: show errors, draw debug lines on calibration
- Remove try-except in CameraCalibrator — errors propagate to UI - Remove auto-load of saved calibrations — always start uncalibrated - Remove hardcoded "Base Setup" card with fake values - Remove addStereocameras/getCamParams dead code - Draw all detected Hough lines + corners on debug frame during calibration - Show debug images in calibration tab after attempt - Show error messages in UI instead of swallowing them Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
158
jetson/main.py
158
jetson/main.py
@@ -39,11 +39,9 @@ def auto_calibrate():
|
|||||||
compute camera pose, save to config.
|
compute camera pose, save to config.
|
||||||
|
|
||||||
Each camera sees one half of the court from the net position.
|
Each camera sees one half of the court from the net position.
|
||||||
We use the court lines visible in each frame to build correspondences.
|
Detects court lines via Hough transform, finds 4 corners,
|
||||||
|
then uses solvePnP to determine camera position.
|
||||||
For now: use a simple approach — detect the 4 most prominent lines
|
Returns debug images with detected lines drawn on them.
|
||||||
(baseline, two sidelines, kitchen line) and map to known 3D coords.
|
|
||||||
Returns error if court lines cannot be detected (no fallbacks).
|
|
||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
@@ -55,51 +53,93 @@ def auto_calibrate():
|
|||||||
|
|
||||||
h, w = frame.shape[:2]
|
h, w = frame.shape[:2]
|
||||||
side = 'left' if sensor_id == 0 else 'right'
|
side = 'left' if sensor_id == 0 else 'right'
|
||||||
|
debug_frame = frame.copy()
|
||||||
|
|
||||||
# Try to detect court lines using edge detection + Hough
|
# Detect court lines — returns corners + debug info
|
||||||
corners_pixel = _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
|
||||||
|
|
||||||
if corners_pixel is None:
|
if corners_pixel is None:
|
||||||
|
error_detail = detection.get('error', 'Unknown') if detection else 'No lines detected at all'
|
||||||
results[str(sensor_id)] = {
|
results[str(sensor_id)] = {
|
||||||
'ok': False,
|
'ok': False,
|
||||||
'error': f'Could not detect court lines for CAM {sensor_id}. '
|
'error': f'CAM {sensor_id}: {error_detail}',
|
||||||
'Ensure court lines are clearly visible.'
|
'debug_image': debug_b64,
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Draw corners on debug frame
|
||||||
|
for i, corner in enumerate(corners_pixel):
|
||||||
|
pt = (int(corner[0]), int(corner[1]))
|
||||||
|
cv2.circle(debug_frame, pt, 8, (0, 0, 255), -1)
|
||||||
|
cv2.putText(debug_frame, f'C{i}', (pt[0] + 10, pt[1]),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
||||||
|
|
||||||
|
# Re-encode with corners
|
||||||
|
_, 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
|
# Get known 3D coordinates for this half
|
||||||
corners_3d = get_half_court_3d_points(side)
|
corners_3d = get_half_court_3d_points(side)
|
||||||
|
|
||||||
# Calibrate
|
# Calibrate — no try/except, let errors propagate
|
||||||
cal = CameraCalibrator()
|
cal = CameraCalibrator()
|
||||||
ok = 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
|
||||||
)
|
)
|
||||||
|
|
||||||
if ok:
|
# Save to config
|
||||||
# Save to config
|
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)
|
cal.save(cal_path)
|
||||||
cal.save(cal_path)
|
|
||||||
|
|
||||||
state['calibrators'][sensor_id] = cal
|
state['calibrators'][sensor_id] = cal
|
||||||
|
|
||||||
# Get camera position for 3D scene
|
# Get camera position for 3D scene
|
||||||
cam_pos = (-cal.rotation_matrix.T @ cal.translation_vec).flatten()
|
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(),
|
||||||
'corners_pixel': corners_pixel.tolist() if isinstance(corners_pixel, np.ndarray)
|
'debug_image': debug_b64,
|
||||||
else corners_pixel,
|
}
|
||||||
}
|
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]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})")
|
|
||||||
else:
|
|
||||||
results[str(sensor_id)] = {'ok': False, 'error': 'Calibration failed'}
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -107,7 +147,13 @@ def auto_calibrate():
|
|||||||
def _detect_court_corners(frame, side):
|
def _detect_court_corners(frame, side):
|
||||||
"""Detect court corners from frame using edge detection.
|
"""Detect court corners from frame using edge detection.
|
||||||
|
|
||||||
Returns 4 corner points as numpy array, or None if detection fails.
|
Returns dict with:
|
||||||
|
corners: 4x2 numpy array or None
|
||||||
|
all_lines: raw Hough lines
|
||||||
|
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)
|
||||||
@@ -118,7 +164,13 @@ def _detect_court_corners(frame, side):
|
|||||||
minLineLength=100, maxLineGap=20)
|
minLineLength=100, maxLineGap=20)
|
||||||
|
|
||||||
if lines is None or len(lines) < 4:
|
if lines is None or len(lines) < 4:
|
||||||
return None
|
n = 0 if lines is None else len(lines)
|
||||||
|
return {
|
||||||
|
'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 lines into horizontal and vertical
|
||||||
horizontals = []
|
horizontals = []
|
||||||
@@ -134,20 +186,28 @@ def _detect_court_corners(frame, side):
|
|||||||
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 None
|
return {
|
||||||
|
'corners': None, 'all_lines': lines,
|
||||||
|
'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
|
# Cluster lines by position to find the dominant ones
|
||||||
h_positions = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 2)
|
h_positions = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 2)
|
||||||
v_positions = sorted(verticals, key=lambda l: (l[0] + l[2]) / 2)
|
v_positions = sorted(verticals, key=lambda l: (l[0] + l[2]) / 2)
|
||||||
|
|
||||||
# Take the most separated horizontal pair (top and bottom court lines)
|
|
||||||
top_line = h_positions[0]
|
top_line = h_positions[0]
|
||||||
bottom_line = h_positions[-1]
|
bottom_line = h_positions[-1]
|
||||||
|
|
||||||
# Take the most separated vertical pair (left and right sidelines)
|
|
||||||
left_line = v_positions[0]
|
left_line = v_positions[0]
|
||||||
right_line = v_positions[-1]
|
right_line = v_positions[-1]
|
||||||
|
|
||||||
|
selected = {
|
||||||
|
'top': top_line.tolist(), 'bottom': bottom_line.tolist(),
|
||||||
|
'left': left_line.tolist(), 'right': right_line.tolist(),
|
||||||
|
}
|
||||||
|
|
||||||
# Find intersections as corner points
|
# Find intersections as corner points
|
||||||
def line_intersection(l1, l2):
|
def line_intersection(l1, l2):
|
||||||
x1, y1, x2, y2 = l1
|
x1, y1, x2, y2 = l1
|
||||||
@@ -168,9 +228,22 @@ def _detect_court_corners(frame, side):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if any(c is None for c in corners):
|
if any(c is None for c in corners):
|
||||||
return None
|
return {
|
||||||
|
'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 np.array(corners, dtype=np.float32)
|
return {
|
||||||
|
'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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -324,15 +397,10 @@ def main():
|
|||||||
|
|
||||||
ring_buffer = FrameRingBuffer(max_seconds=args.buffer_seconds, fps=args.fps)
|
ring_buffer = FrameRingBuffer(max_seconds=args.buffer_seconds, fps=args.fps)
|
||||||
|
|
||||||
# Load calibrations if available
|
# Start with empty calibrators — user must calibrate via UI
|
||||||
os.makedirs(args.calibration_dir, exist_ok=True)
|
os.makedirs(args.calibration_dir, exist_ok=True)
|
||||||
for sensor_id in [0, 1]:
|
for sensor_id in [0, 1]:
|
||||||
cal = CameraCalibrator()
|
state['calibrators'][sensor_id] = CameraCalibrator()
|
||||||
cal_path = os.path.join(args.calibration_dir, f'cam{sensor_id}_calibration.json')
|
|
||||||
if os.path.exists(cal_path):
|
|
||||||
if cal.load(cal_path):
|
|
||||||
print(f"[CAM {sensor_id}] Loaded calibration from {cal_path}")
|
|
||||||
state['calibrators'][sensor_id] = cal
|
|
||||||
|
|
||||||
# Start camera readers
|
# Start camera readers
|
||||||
cam_readers = {}
|
cam_readers = {}
|
||||||
|
|||||||
@@ -50,31 +50,27 @@ class CameraCalibrator:
|
|||||||
[0, 0, 1]
|
[0, 0, 1]
|
||||||
], dtype=np.float32)
|
], dtype=np.float32)
|
||||||
|
|
||||||
try:
|
success, self.rotation_vec, self.translation_vec = cv2.solvePnP(
|
||||||
success, self.rotation_vec, self.translation_vec = cv2.solvePnP(
|
court_corners_3d.astype(np.float32),
|
||||||
court_corners_3d.astype(np.float32),
|
court_corners_pixel.astype(np.float32),
|
||||||
court_corners_pixel.astype(np.float32),
|
self.camera_matrix,
|
||||||
self.camera_matrix,
|
None,
|
||||||
None,
|
flags=cv2.SOLVEPNP_ITERATIVE
|
||||||
flags=cv2.SOLVEPNP_ITERATIVE
|
)
|
||||||
)
|
if not success:
|
||||||
if not success:
|
raise RuntimeError("solvePnP failed to find camera pose")
|
||||||
return False
|
|
||||||
|
|
||||||
self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec)
|
self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec)
|
||||||
|
|
||||||
# Build ground plane homography (Z=0)
|
# Build ground plane homography (Z=0)
|
||||||
ground_2d = court_corners_3d[:, :2].astype(np.float32)
|
ground_2d = court_corners_3d[:, :2].astype(np.float32)
|
||||||
self.homography = cv2.getPerspectiveTransform(
|
self.homography = cv2.getPerspectiveTransform(
|
||||||
court_corners_pixel[:4].astype(np.float32),
|
court_corners_pixel[:4].astype(np.float32),
|
||||||
ground_2d[:4]
|
ground_2d[:4]
|
||||||
)
|
)
|
||||||
|
|
||||||
self.calibrated = True
|
self.calibrated = True
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
|
||||||
print(f"Calibration failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def pixel_to_ground(self, px: float, py: float) -> Optional[Tuple[float, float]]:
|
def pixel_to_ground(self, px: float, py: float) -> Optional[Tuple[float, float]]:
|
||||||
"""Project pixel to ground plane (Z=0) using homography."""
|
"""Project pixel to ground plane (Z=0) using homography."""
|
||||||
@@ -150,20 +146,16 @@ class CameraCalibrator:
|
|||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
def load(self, filepath: str) -> bool:
|
def load(self, filepath: str) -> bool:
|
||||||
try:
|
with open(filepath, 'r') as f:
|
||||||
with open(filepath, 'r') as f:
|
data = json.load(f)
|
||||||
data = json.load(f)
|
self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32)
|
||||||
self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32)
|
self.rotation_vec = np.array(data['rotation_vector'], dtype=np.float32).reshape(3, 1)
|
||||||
self.rotation_vec = np.array(data['rotation_vector'], dtype=np.float32).reshape(3, 1)
|
self.translation_vec = np.array(data['translation_vector'], dtype=np.float32).reshape(3, 1)
|
||||||
self.translation_vec = np.array(data['translation_vector'], dtype=np.float32).reshape(3, 1)
|
self.rotation_matrix = np.array(data['rotation_matrix'], dtype=np.float32)
|
||||||
self.rotation_matrix = np.array(data['rotation_matrix'], dtype=np.float32)
|
if data.get('homography'):
|
||||||
if data.get('homography'):
|
self.homography = np.array(data['homography'], dtype=np.float32)
|
||||||
self.homography = np.array(data['homography'], dtype=np.float32)
|
self.calibrated = True
|
||||||
self.calibrated = True
|
return True
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to load calibration: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_half_court_3d_points(side: str) -> np.ndarray:
|
def get_half_court_3d_points(side: str) -> np.ndarray:
|
||||||
|
|||||||
@@ -112,11 +112,16 @@ def api_calibration_trigger():
|
|||||||
if fn is None:
|
if fn is None:
|
||||||
return jsonify({'ok': False, 'error': 'Calibration not available'}), 500
|
return jsonify({'ok': False, 'error': 'Calibration not available'}), 500
|
||||||
|
|
||||||
|
import traceback
|
||||||
try:
|
try:
|
||||||
result = fn()
|
result = fn()
|
||||||
return jsonify({'ok': True, 'result': result})
|
# Check if any camera failed
|
||||||
|
any_ok = any(r.get('ok') for r in result.values())
|
||||||
|
return jsonify({'ok': any_ok, 'result': result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
tb = traceback.format_exc()
|
||||||
|
print(f"[CALIBRATION ERROR]\n{tb}")
|
||||||
|
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {e}'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/calibration/data')
|
@app.route('/api/calibration/data')
|
||||||
|
|||||||
@@ -332,19 +332,15 @@
|
|||||||
<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">
|
||||||
<div class="cc-title">Base Setup</div>
|
<div class="cc-title">Calibration</div>
|
||||||
<div class="cc-item">Distance <b id="paramPosY">1.0</b>m</div>
|
|
||||||
<div class="cc-item">Height <b id="paramPosZ">1.0</b>m</div>
|
|
||||||
<div class="cc-item">Stereo <b id="paramStereo">6</b>cm</div>
|
|
||||||
<div class="cc-item">Rotation <b id="paramAngle">15</b>°</div>
|
|
||||||
<div class="cc-item">HFOV <b id="paramHfov">128</b>°</div>
|
|
||||||
<div class="cc-item">Sensor <b>IMX219</b></div>
|
|
||||||
<div class="cc-divider"></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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,15 +407,34 @@ if (activeTab !== 'camera') switchTab(activeTab);
|
|||||||
// ===================== Calibration =====================
|
// ===================== Calibration =====================
|
||||||
function doCalibrate() {
|
function doCalibrate() {
|
||||||
var btn = document.getElementById('btnCalibrate');
|
var btn = document.getElementById('btnCalibrate');
|
||||||
|
var errEl = document.getElementById('calError');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Calibrating...';
|
btn.textContent = 'Calibrating...';
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
fetch('/api/calibration/trigger', { method: 'POST' })
|
fetch('/api/calibration/trigger', { method: 'POST' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|
||||||
|
// Show debug images from calibration
|
||||||
|
if (data.result) {
|
||||||
|
for (var sid in data.result) {
|
||||||
|
var r = data.result[sid];
|
||||||
|
if (r.debug_image) {
|
||||||
|
var dbgEl = document.getElementById('calDebug' + sid);
|
||||||
|
var imgEl = document.getElementById('calDebugImg' + sid);
|
||||||
|
if (dbgEl && imgEl) {
|
||||||
|
imgEl.src = 'data:image/jpeg;base64,' + r.debug_image;
|
||||||
|
dbgEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
btn.textContent = 'Re-calibrate';
|
btn.textContent = 'Re-calibrate';
|
||||||
|
errEl.style.display = 'none';
|
||||||
updateCalibrationStatus();
|
updateCalibrationStatus();
|
||||||
|
|
||||||
fetch('/api/calibration/data')
|
fetch('/api/calibration/data')
|
||||||
@@ -427,19 +442,23 @@ function doCalibrate() {
|
|||||||
.then(function(camData) { addCamerasToScene(camData); });
|
.then(function(camData) { addCamerasToScene(camData); });
|
||||||
} else {
|
} else {
|
||||||
btn.textContent = 'Calibrate';
|
btn.textContent = 'Calibrate';
|
||||||
|
// Show errors
|
||||||
var errors = [];
|
var errors = [];
|
||||||
if (data.result) {
|
if (data.result) {
|
||||||
for (var sid in data.result) {
|
for (var sid in data.result) {
|
||||||
if (!data.result[sid].ok) errors.push('CAM ' + sid + ': ' + data.result[sid].error);
|
if (!data.result[sid].ok) errors.push(data.result[sid].error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alert('Calibration failed:\n' + (errors.join('\n') || data.error || 'Unknown error'));
|
var msg = errors.join(' | ') || data.error || 'Unknown error';
|
||||||
|
errEl.textContent = msg;
|
||||||
|
errEl.style.display = 'block';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(e) {
|
.catch(function(e) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Calibrate';
|
btn.textContent = 'Calibrate';
|
||||||
alert('Error: ' + e);
|
errEl.textContent = String(e);
|
||||||
|
errEl.style.display = 'block';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,9 +601,6 @@ function initCourtScene() {
|
|||||||
|
|
||||||
courtScene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
courtScene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
||||||
|
|
||||||
// Physical camera marker: 1m from net, center, 1m height
|
|
||||||
addStereocameras(courtScene, getCamParams());
|
|
||||||
|
|
||||||
// Load existing calibration cameras
|
// Load existing calibration cameras
|
||||||
fetch('/api/calibration/data')
|
fetch('/api/calibration/data')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -683,9 +699,6 @@ function initTrajectoryScene() {
|
|||||||
|
|
||||||
trajScene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
trajScene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
||||||
|
|
||||||
// Physical camera marker: 1m from net, center, 1m height
|
|
||||||
addStereocameras(trajScene, getCamParams());
|
|
||||||
|
|
||||||
function animateTraj() {
|
function animateTraj() {
|
||||||
requestAnimationFrame(animateTraj);
|
requestAnimationFrame(animateTraj);
|
||||||
controls.update();
|
controls.update();
|
||||||
@@ -702,115 +715,6 @@ function initTrajectoryScene() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== Stereo camera rig with court coverage =====================
|
|
||||||
// Track camera overlay objects for live update
|
|
||||||
var camOverlayObjects = [];
|
|
||||||
|
|
||||||
function addStereocameras(scene, params) {
|
|
||||||
params = params || {};
|
|
||||||
var baseX = 6.7;
|
|
||||||
var baseY = -(params.posY || 1);
|
|
||||||
var baseZ = params.posZ || 1;
|
|
||||||
var stereoGap = (params.stereo || 6) / 100;
|
|
||||||
var camAngle = params.angle || 15;
|
|
||||||
var hfov = params.hfov || 128;
|
|
||||||
|
|
||||||
// Remove old overlay objects
|
|
||||||
camOverlayObjects.forEach(function(obj) { scene.remove(obj); });
|
|
||||||
camOverlayObjects = [];
|
|
||||||
|
|
||||||
function addObj(obj) {
|
|
||||||
scene.add(obj);
|
|
||||||
camOverlayObjects.push(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
var cam0x = baseX - stereoGap / 2;
|
|
||||||
var cam1x = baseX + stereoGap / 2;
|
|
||||||
|
|
||||||
// Small camera bodies
|
|
||||||
function drawCamBody(cx, color) {
|
|
||||||
var body = new THREE.Mesh(
|
|
||||||
new THREE.BoxGeometry(0.12, 0.08, 0.08),
|
|
||||||
new THREE.MeshBasicMaterial({ color: color })
|
|
||||||
);
|
|
||||||
body.position.set(cx, baseY, baseZ);
|
|
||||||
addObj(body);
|
|
||||||
}
|
|
||||||
drawCamBody(cam0x, 0x44aaff);
|
|
||||||
drawCamBody(cam1x, 0xff44aa);
|
|
||||||
|
|
||||||
// Pole
|
|
||||||
var poleGeo = new THREE.BufferGeometry().setFromPoints([
|
|
||||||
new THREE.Vector3(baseX, baseY, 0),
|
|
||||||
new THREE.Vector3(baseX, baseY, baseZ)
|
|
||||||
]);
|
|
||||||
addObj(new THREE.Line(poleGeo, new THREE.LineBasicMaterial({ color: 0x666666 })));
|
|
||||||
|
|
||||||
// Court boundaries for clipping
|
|
||||||
var courtMinX = 0, courtMaxX = 13.4, courtMinY = 0, courtMaxY = 6.1;
|
|
||||||
var deg2rad = Math.PI / 180;
|
|
||||||
var halfFov = hfov / 2;
|
|
||||||
|
|
||||||
function drawCoverage(cx, angleDeg, color) {
|
|
||||||
var centerAngle = 90 + angleDeg;
|
|
||||||
var leftAngle = centerAngle + halfFov;
|
|
||||||
var rightAngle = centerAngle - halfFov;
|
|
||||||
var ox = cx, oy = baseY;
|
|
||||||
|
|
||||||
// Cast ray, return farthest point on court boundary
|
|
||||||
function castRay(aDeg) {
|
|
||||||
var rad = aDeg * deg2rad;
|
|
||||||
var dx = Math.cos(rad);
|
|
||||||
var dy = Math.sin(rad);
|
|
||||||
if (dy <= 0) return null;
|
|
||||||
|
|
||||||
// t where ray enters court (Y=0)
|
|
||||||
var tEnter = (courtMinY - oy) / dy;
|
|
||||||
// t where ray exits court
|
|
||||||
var tExit = (courtMaxY - oy) / dy;
|
|
||||||
// clip X bounds
|
|
||||||
if (dx > 1e-9) tExit = Math.min(tExit, (courtMaxX - ox) / dx);
|
|
||||||
else if (dx < -1e-9) tExit = Math.min(tExit, (courtMinX - ox) / dx);
|
|
||||||
|
|
||||||
if (tExit <= tEnter) return null;
|
|
||||||
|
|
||||||
var px = ox + dx * tExit;
|
|
||||||
var py = oy + dy * tExit;
|
|
||||||
px = Math.max(courtMinX, Math.min(courtMaxX, px));
|
|
||||||
py = Math.max(courtMinY, Math.min(courtMaxY, py));
|
|
||||||
return new THREE.Vector3(px, py, 0.015);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Camera actual position (behind court at Y=-1)
|
|
||||||
var camOnCourt = new THREE.Vector3(cx, oy, 0.015);
|
|
||||||
|
|
||||||
// Far edge points
|
|
||||||
var farPoints = [];
|
|
||||||
var steps = 64;
|
|
||||||
for (var i = 0; i <= steps; i++) {
|
|
||||||
var a = rightAngle + (leftAngle - rightAngle) * (i / steps);
|
|
||||||
var pt = castRay(a);
|
|
||||||
if (pt) farPoints.push(pt);
|
|
||||||
}
|
|
||||||
if (farPoints.length < 2) return;
|
|
||||||
|
|
||||||
// Draw triangle fan from camera court position to far edge
|
|
||||||
var mat = new THREE.MeshBasicMaterial({
|
|
||||||
color: color, transparent: true, opacity: 0.35,
|
|
||||||
side: THREE.DoubleSide, depthWrite: false
|
|
||||||
});
|
|
||||||
for (var i = 0; i < farPoints.length - 1; i++) {
|
|
||||||
var triGeo = new THREE.BufferGeometry().setFromPoints([
|
|
||||||
camOnCourt, farPoints[i], farPoints[i + 1]
|
|
||||||
]);
|
|
||||||
addObj(new THREE.Mesh(triGeo, mat));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawCoverage(cam0x, -camAngle, 0x4488ff); // blue
|
|
||||||
drawCoverage(cam1x, camAngle, 0xff44aa); // pink
|
|
||||||
// overlap blends to purple naturally
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== Draw court lines =====================
|
// ===================== Draw court lines =====================
|
||||||
function drawCourtLines(scene) {
|
function drawCourtLines(scene) {
|
||||||
@@ -983,26 +887,6 @@ setInterval(function() {
|
|||||||
}).catch(function() {});
|
}).catch(function() {});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// ===================== Camera params live update =====================
|
|
||||||
function getCamParams() {
|
|
||||||
return {
|
|
||||||
posY: parseFloat(document.getElementById('paramPosY').textContent) || 1,
|
|
||||||
posZ: parseFloat(document.getElementById('paramPosZ').textContent) || 1,
|
|
||||||
stereo: parseFloat(document.getElementById('paramStereo').textContent) || 6,
|
|
||||||
angle: parseFloat(document.getElementById('paramAngle').textContent) || 15,
|
|
||||||
hfov: parseFloat(document.getElementById('paramHfov').textContent) || 128
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCamParams(p) {
|
|
||||||
document.getElementById('paramPosY').textContent = p.posY;
|
|
||||||
document.getElementById('paramPosZ').textContent = p.posZ;
|
|
||||||
document.getElementById('paramStereo').textContent = p.stereo;
|
|
||||||
document.getElementById('paramAngle').textContent = p.angle;
|
|
||||||
document.getElementById('paramHfov').textContent = p.hfov;
|
|
||||||
if (courtSceneInitialized) addStereocameras(courtScene, p);
|
|
||||||
if (trajSceneInitialized) addStereocameras(trajScene, p);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user