diff --git a/apps/web/scripts/train-column-classifier/train_model.py b/apps/web/scripts/train-column-classifier/train_model.py
index f9cd7bb7..57b94b03 100644
--- a/apps/web/scripts/train-column-classifier/train_model.py
+++ b/apps/web/scripts/train-column-classifier/train_model.py
@@ -305,14 +305,16 @@ def main():
print("Install with: pip install tensorflow")
sys.exit(1)
- # Check tensorflowjs is available
+ # Check tensorflowjs is available (optional - can convert later)
+ tfjs_available = False
try:
import tensorflowjs
print(f"TensorFlow.js converter version: {tensorflowjs.__version__}")
- except ImportError:
- print("Error: tensorflowjs not installed")
- print("Install with: pip install tensorflowjs")
- sys.exit(1)
+ tfjs_available = True
+ except (ImportError, AttributeError) as e:
+ print(f"Note: tensorflowjs not available ({type(e).__name__})")
+ print("Model will be saved as Keras format. Convert later with:")
+ print(" tensorflowjs_converter --input_format=keras model.keras output_dir/")
print()
@@ -352,9 +354,14 @@ def main():
# Save Keras model
save_keras_model(model, args.output_dir)
- # Export to TensorFlow.js
- print("\nExporting to TensorFlow.js format...")
- export_to_tfjs(model, args.output_dir)
+ # Export to TensorFlow.js (if available)
+ if tfjs_available:
+ print("\nExporting to TensorFlow.js format...")
+ export_to_tfjs(model, args.output_dir)
+ else:
+ print("\nSkipping TensorFlow.js export (tensorflowjs not available)")
+ print("Convert later with:")
+ print(f" tensorflowjs_converter --input_format=keras {args.output_dir}/column-classifier.keras {args.output_dir}")
print("\nTraining complete!")
print(f"Model files saved to: {args.output_dir}")
diff --git a/apps/web/src/app/remote-camera/[sessionId]/page.tsx b/apps/web/src/app/remote-camera/[sessionId]/page.tsx
index aa7c8fa3..c697bb73 100644
--- a/apps/web/src/app/remote-camera/[sessionId]/page.tsx
+++ b/apps/web/src/app/remote-camera/[sessionId]/page.tsx
@@ -182,8 +182,12 @@ export default function RemoteCameraPage() {
if (isSending) {
updateCalibration(desktopCalibration)
}
+ } else if (usingDesktopCalibration) {
+ // Desktop cleared calibration - go back to auto-detection
+ setUsingDesktopCalibration(false)
+ setCalibration(null)
}
- }, [desktopCalibration, isSending, updateCalibration])
+ }, [desktopCalibration, isSending, updateCalibration, usingDesktopCalibration])
// Auto-detect markers (always runs unless using desktop calibration)
useEffect(() => {
@@ -201,18 +205,28 @@ export default function RemoteCameraPage() {
if (result.allMarkersFound && result.quadCorners) {
// Auto-calibration successful!
+ // NOTE: detectMarkers() returns corners swapped for Desk View camera (180° rotated).
+ // Phone camera is NOT Desk View, so we need to swap corners back to get correct orientation.
+ // detectMarkers maps: marker 2 (physical BR) → topLeft, marker 0 (physical TL) → bottomRight
+ // For phone camera we need: marker 0 (physical TL) → topLeft, marker 2 (physical BR) → bottomRight
+ const phoneCorners = {
+ topLeft: result.quadCorners.bottomRight, // marker 0 (physical TL)
+ topRight: result.quadCorners.bottomLeft, // marker 1 (physical TR)
+ bottomRight: result.quadCorners.topLeft, // marker 2 (physical BR)
+ bottomLeft: result.quadCorners.topRight, // marker 3 (physical BL)
+ }
const grid: CalibrationGrid = {
roi: {
- x: Math.min(result.quadCorners.topLeft.x, result.quadCorners.bottomLeft.x),
- y: Math.min(result.quadCorners.topLeft.y, result.quadCorners.topRight.y),
+ x: Math.min(phoneCorners.topLeft.x, phoneCorners.bottomLeft.x),
+ y: Math.min(phoneCorners.topLeft.y, phoneCorners.topRight.y),
width:
- Math.max(result.quadCorners.topRight.x, result.quadCorners.bottomRight.x) -
- Math.min(result.quadCorners.topLeft.x, result.quadCorners.bottomLeft.x),
+ Math.max(phoneCorners.topRight.x, phoneCorners.bottomRight.x) -
+ Math.min(phoneCorners.topLeft.x, phoneCorners.bottomLeft.x),
height:
- Math.max(result.quadCorners.bottomLeft.y, result.quadCorners.bottomRight.y) -
- Math.min(result.quadCorners.topLeft.y, result.quadCorners.topRight.y),
+ Math.max(phoneCorners.bottomLeft.y, phoneCorners.bottomRight.y) -
+ Math.min(phoneCorners.topLeft.y, phoneCorners.topRight.y),
},
- corners: result.quadCorners,
+ corners: phoneCorners,
columnCount: 13,
columnDividers: Array.from({ length: 12 }, (_, i) => (i + 1) / 13),
rotation: 0,
@@ -221,7 +235,7 @@ export default function RemoteCameraPage() {
// Update the calibration for the sending loop and switch to cropped mode
// BUT: don't switch to cropped if desktop is actively calibrating (they need raw frames)
if (isSending && !desktopIsCalibrating) {
- updateCalibration(result.quadCorners)
+ updateCalibration(phoneCorners)
setFrameMode('cropped')
}
}
diff --git a/apps/web/src/components/vision/AbacusVisionBridge.tsx b/apps/web/src/components/vision/AbacusVisionBridge.tsx
index ffbfbd56..18fafc27 100644
--- a/apps/web/src/components/vision/AbacusVisionBridge.tsx
+++ b/apps/web/src/components/vision/AbacusVisionBridge.tsx
@@ -90,6 +90,7 @@ export function AbacusVisionBridge({
unsubscribe: remoteUnsubscribe,
setPhoneFrameMode: remoteSetPhoneFrameMode,
sendCalibration: remoteSendCalibration,
+ clearCalibration: remoteClearCalibration,
} = useRemoteCameraDesktop()
// Handle switching to phone camera
@@ -129,12 +130,15 @@ export function AbacusVisionBridge({
// Tell phone to use its auto-calibration (cropped frames)
remoteSetPhoneFrameMode('cropped')
setRemoteIsCalibrating(false)
+ // Clear desktop calibration on phone so it goes back to auto-detection
+ remoteClearCalibration()
+ setRemoteCalibration(null)
} else {
// Tell phone to send raw frames for desktop calibration
remoteSetPhoneFrameMode('raw')
}
},
- [remoteSetPhoneFrameMode]
+ [remoteSetPhoneFrameMode, remoteClearCalibration]
)
// Start remote camera calibration
@@ -408,28 +412,94 @@ export function AbacusVisionBridge({
- {/* Camera selector (if multiple cameras and using local) */}
- {cameraSource === 'local' && vision.availableDevices.length > 1 && (
-
+ {/* Camera selector (if multiple cameras) */}
+ {vision.availableDevices.length > 1 && (
+
+ )}
+
+ {/* Flip camera button */}
+
+
+ {/* Torch toggle button (only if available) */}
+ {vision.isTorchAvailable && (
+
+ )}
+
)}
{/* Calibration mode toggle (both local and phone camera) */}
@@ -636,6 +706,7 @@ export function AbacusVisionBridge({
borderRadius: 'lg',
overflow: 'hidden',
minHeight: '200px',
+ userSelect: 'none', // Prevent text selection from spanning into video feed
})}
>
{!remoteCameraSessionId ? (
@@ -652,7 +723,7 @@ export function AbacusVisionBridge({
) : !remoteIsPhoneConnected ? (
- /* Waiting for phone to connect */
+ /* Waiting for phone to connect/reconnect - reuse existing session */
Waiting for phone to connect...
-
+
) : (
/* Show camera frames */
diff --git a/apps/web/src/components/vision/CalibrationOverlay.tsx b/apps/web/src/components/vision/CalibrationOverlay.tsx
index 3f072cbb..30138968 100644
--- a/apps/web/src/components/vision/CalibrationOverlay.tsx
+++ b/apps/web/src/components/vision/CalibrationOverlay.tsx
@@ -284,6 +284,32 @@ export function CalibrationOverlay({
dragStartRef.current = null
}, [])
+ /**
+ * Rotate corners 90° clockwise or counter-clockwise around the quad center
+ * This reassigns corner labels, not their positions
+ */
+ const handleRotate = useCallback((direction: 'left' | 'right') => {
+ setCorners((prev) => {
+ if (direction === 'right') {
+ // Rotate 90° clockwise: TL→TR, TR→BR, BR→BL, BL→TL
+ return {
+ topLeft: prev.bottomLeft,
+ topRight: prev.topLeft,
+ bottomRight: prev.topRight,
+ bottomLeft: prev.bottomRight,
+ }
+ } else {
+ // Rotate 90° counter-clockwise: TL→BL, BL→BR, BR→TR, TR→TL
+ return {
+ topLeft: prev.topRight,
+ topRight: prev.bottomRight,
+ bottomRight: prev.bottomLeft,
+ bottomLeft: prev.topLeft,
+ }
+ }
+ })
+ }, [])
+
// Handle complete
const handleComplete = useCallback(() => {
const grid: CalibrationGrid = {
@@ -547,6 +573,51 @@ export function CalibrationOverlay({
gap: 2,
})}
>
+ {/* Rotation buttons */}
+
+