Просмотр исходного кода

Add camera image rotation option (#672)

  Per-printer camera rotation (0°/90°/180°/270°) for cameras mounted
  in portrait or upside-down. CSS rotation for live views, Pillow
  rotation for notification snapshots. Setting visible in external
  camera config when enabled.
maziggy 2 месяцев назад
Родитель
Сommit
fe3c1983af

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b1] - Unreleased
 ## [0.2.3b1] - Unreleased
 
 
 ### New Features
 ### New Features
+- **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
 - **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
 - **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
 
 
 ### Fixed
 ### Fixed

+ 7 - 3
backend/app/core/database.py

@@ -1481,13 +1481,17 @@ async def run_migrations(conn):
     # Legacy migration: Add notify_print_stopped column (for any existing partial tables)
     # Legacy migration: Add notify_print_stopped column (for any existing partial tables)
     try:
     try:
         await conn.execute(
         await conn.execute(
-            text(
-                "ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1"
-            )
+            text("ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1")
         )
         )
     except OperationalError:
     except OperationalError:
         pass  # Column already exists or table created with full schema
         pass  # Column already exists or table created with full schema
 
 
+    # Migration: Add camera_rotation column to printers
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
     # Seed default settings keys that must exist on fresh install
     # Seed default settings keys that must exist on fresh install
     default_settings = [
     default_settings = [
         ("advanced_auth_enabled", "false"),
         ("advanced_auth_enabled", "false"),

+ 27 - 3
backend/app/main.py

@@ -1033,7 +1033,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
             frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
             frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
             if frame_data and len(frame_data) <= 2_500_000:
             if frame_data and len(frame_data) <= 2_500_000:
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
-                return frame_data
+                return _apply_camera_rotation(frame_data, printer, logger)
 
 
         # Try buffered frame from active stream
         # Try buffered frame from active stream
         from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame
         from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame
@@ -1045,7 +1045,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
         if (active_for_printer or active_chamber) and buffered_frame:
         if (active_for_printer or active_chamber) and buffered_frame:
             logger.info("[SNAPSHOT] Using buffered frame for printer %s: %s bytes", printer_id, len(buffered_frame))
             logger.info("[SNAPSHOT] Using buffered frame for printer %s: %s bytes", printer_id, len(buffered_frame))
             if len(buffered_frame) <= 2_500_000:
             if len(buffered_frame) <= 2_500_000:
-                return buffered_frame
+                return _apply_camera_rotation(buffered_frame, printer, logger)
 
 
         # Fresh capture from printer camera
         # Fresh capture from printer camera
         logger.info("[SNAPSHOT] Capturing fresh frame for printer %s", printer_id)
         logger.info("[SNAPSHOT] Capturing fresh frame for printer %s", printer_id)
@@ -1056,7 +1056,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
         )
         )
         if frame_data and len(frame_data) <= 2_500_000:
         if frame_data and len(frame_data) <= 2_500_000:
             logger.info("[SNAPSHOT] Fresh camera frame: %s bytes", len(frame_data))
             logger.info("[SNAPSHOT] Fresh camera frame: %s bytes", len(frame_data))
-            return frame_data
+            return _apply_camera_rotation(frame_data, printer, logger)
 
 
     except Exception as e:
     except Exception as e:
         logger.warning("[SNAPSHOT] Failed to capture snapshot for printer %s: %s", printer_id, e)
         logger.warning("[SNAPSHOT] Failed to capture snapshot for printer %s: %s", printer_id, e)
@@ -1064,6 +1064,30 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
     return None
     return None
 
 
 
 
+def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:
+    """Apply camera rotation to snapshot image if configured."""
+    rotation = getattr(printer, "camera_rotation", 0)
+    if not rotation or rotation == 0:
+        return image_data
+
+    try:
+        from io import BytesIO
+
+        from PIL import Image
+
+        img = Image.open(BytesIO(image_data))
+        # PIL rotate is counter-clockwise, so negate for clockwise rotation
+        img = img.rotate(-rotation, expand=True)
+        buf = BytesIO()
+        img.save(buf, format="JPEG", quality=90)
+        rotated = buf.getvalue()
+        logger.info("[SNAPSHOT] Applied %d° rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
+        return rotated
+    except Exception as e:
+        logger.warning("[SNAPSHOT] Failed to apply rotation: %s", e)
+        return image_data
+
+
 async def _send_print_start_notification(
 async def _send_print_start_notification(
     printer_id: int,
     printer_id: int,
     data: dict,
     data: dict,

+ 1 - 0
backend/app/models/printer.py

@@ -28,6 +28,7 @@ class Printer(Base):
     external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True)  # mjpeg, rtsp, snapshot
     external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True)  # mjpeg, rtsp, snapshot
     external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     # Plate detection - check if build plate is empty before starting print
     # Plate detection - check if build plate is empty before starting print
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     # ROI for plate detection (percentages: 0.0-1.0)
     # ROI for plate detection (percentages: 0.0-1.0)

+ 4 - 0
backend/app/schemas/printer.py

@@ -18,6 +18,7 @@ class PrinterBase(BaseModel):
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None  # "mjpeg", "rtsp", "snapshot", "usb"
     external_camera_type: str | None = None  # "mjpeg", "rtsp", "snapshot", "usb"
     external_camera_enabled: bool = False
     external_camera_enabled: bool = False
+    camera_rotation: int = 0  # 0, 90, 180, 270 degrees
 
 
 
 
 class PrinterCreate(PrinterBase):
 class PrinterCreate(PrinterBase):
@@ -49,6 +50,7 @@ class PrinterUpdate(BaseModel):
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool | None = None
     external_camera_enabled: bool | None = None
+    camera_rotation: int | None = None  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool | None = None
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
 
 
@@ -61,6 +63,7 @@ class PrinterResponse(PrinterBase):
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool = False
     external_camera_enabled: bool = False
+    camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool = False
     plate_detection_enabled: bool = False
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
     created_at: datetime
     created_at: datetime
@@ -84,6 +87,7 @@ class PrinterResponse(PrinterBase):
             "external_camera_url": printer.external_camera_url,
             "external_camera_url": printer.external_camera_url,
             "external_camera_type": printer.external_camera_type,
             "external_camera_type": printer.external_camera_type,
             "external_camera_enabled": printer.external_camera_enabled,
             "external_camera_enabled": printer.external_camera_enabled,
+            "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
             "nozzle_count": printer.nozzle_count,
             "print_hours_offset": printer.print_hours_offset,
             "print_hours_offset": printer.print_hours_offset,

+ 2 - 0
frontend/src/api/client.ts

@@ -99,6 +99,7 @@ export interface Printer {
   external_camera_url: string | null;
   external_camera_url: string | null;
   external_camera_type: string | null;  // "mjpeg", "rtsp", "snapshot"
   external_camera_type: string | null;  // "mjpeg", "rtsp", "snapshot"
   external_camera_enabled: boolean;
   external_camera_enabled: boolean;
+  camera_rotation: number;  // 0, 90, 180, 270 degrees
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   created_at: string;
   created_at: string;
@@ -279,6 +280,7 @@ export interface PrinterCreate {
   external_camera_url?: string | null;
   external_camera_url?: string | null;
   external_camera_type?: string | null;
   external_camera_type?: string | null;
   external_camera_enabled?: boolean;
   external_camera_enabled?: boolean;
+  camera_rotation?: number;
   plate_detection_enabled?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
   plate_detection_roi?: PlateDetectionROI;
 }
 }

+ 2 - 1
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -686,7 +686,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             alt="Camera stream"
             alt="Camera stream"
             className="max-w-full max-h-full object-contain select-none"
             className="max-w-full max-h-full object-contain select-none"
             style={{
             style={{
-              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
+              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,
+              ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100%', maxHeight: '100%' } : {}),
               cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
               cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
             }}
             }}
             onError={handleStreamError}
             onError={handleStreamError}

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -1763,6 +1763,7 @@ export default {
     cameraTypeRtsp: 'RTSP-Stream',
     cameraTypeRtsp: 'RTSP-Stream',
     cameraTypeSnapshot: 'HTTP-Snapshot',
     cameraTypeSnapshot: 'HTTP-Snapshot',
     cameraTypeUsb: 'USB-Kamera (V4L2)',
     cameraTypeUsb: 'USB-Kamera (V4L2)',
+    cameraRotation: 'Drehung',
     test: 'Testen',
     test: 'Testen',
     connected: 'Verbunden',
     connected: 'Verbunden',
     disconnected: 'Getrennt',
     disconnected: 'Getrennt',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -1763,6 +1763,7 @@ export default {
     cameraTypeRtsp: 'RTSP Stream',
     cameraTypeRtsp: 'RTSP Stream',
     cameraTypeSnapshot: 'HTTP Snapshot',
     cameraTypeSnapshot: 'HTTP Snapshot',
     cameraTypeUsb: 'USB Camera (V4L2)',
     cameraTypeUsb: 'USB Camera (V4L2)',
+    cameraRotation: 'Rotation',
     test: 'Test',
     test: 'Test',
     connected: 'Connected',
     connected: 'Connected',
     disconnected: 'Disconnected',
     disconnected: 'Disconnected',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -1760,6 +1760,7 @@ export default {
     cameraTypeRtsp: 'Flux RTSP',
     cameraTypeRtsp: 'Flux RTSP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeUsb: 'Caméra USB (V4L2)',
     cameraTypeUsb: 'Caméra USB (V4L2)',
+    cameraRotation: 'Rotation',
     test: 'Tester',
     test: 'Tester',
     connected: 'Connecté',
     connected: 'Connecté',
     disconnected: 'Déconnecté',
     disconnected: 'Déconnecté',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -1759,6 +1759,7 @@ export default {
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeUsb: 'Fotocamera USB (V4L2)',
     cameraTypeUsb: 'Fotocamera USB (V4L2)',
+    cameraRotation: 'Rotazione',
     test: 'Test',
     test: 'Test',
     connected: 'Connesso',
     connected: 'Connesso',
     disconnected: 'Disconnesso',
     disconnected: 'Disconnesso',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -1762,6 +1762,7 @@ export default {
     cameraTypeRtsp: 'RTSPストリーム',
     cameraTypeRtsp: 'RTSPストリーム',
     cameraTypeSnapshot: 'HTTPスナップショット',
     cameraTypeSnapshot: 'HTTPスナップショット',
     cameraTypeUsb: 'USBカメラ (V4L2)',
     cameraTypeUsb: 'USBカメラ (V4L2)',
+    cameraRotation: '回転',
     test: 'テスト',
     test: 'テスト',
     connected: '接続済み',
     connected: '接続済み',
     disconnected: '未接続',
     disconnected: '未接続',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1759,6 +1759,7 @@ export default {
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeUsb: 'Câmera USB (V4L2)',
     cameraTypeUsb: 'Câmera USB (V4L2)',
+    cameraRotation: 'Rotação',
     test: 'Testar',
     test: 'Testar',
     connected: 'Conectado',
     connected: 'Conectado',
     disconnected: 'Desconectado',
     disconnected: 'Desconectado',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1759,6 +1759,7 @@ export default {
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeUsb: 'USB 摄像头 (V4L2)',
     cameraTypeUsb: 'USB 摄像头 (V4L2)',
+    cameraRotation: '旋转',
     test: '测试',
     test: '测试',
     connected: '已连接',
     connected: '已连接',
     disconnected: '未连接',
     disconnected: '未连接',

+ 2 - 1
frontend/src/pages/CameraPage.tsx

@@ -732,7 +732,8 @@ export function CameraPage() {
             alt={t('camera.cameraStream')}
             alt={t('camera.cameraStream')}
             className="max-w-full max-h-full object-contain select-none"
             className="max-w-full max-h-full object-contain select-none"
             style={{
             style={{
-              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
+              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,
+              ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100vh', maxHeight: '100vw' } : {}),
               cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
               cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
             }}
             }}
             onError={currentUrl ? handleStreamError : undefined}
             onError={currentUrl ? handleStreamError : undefined}

+ 17 - 3
frontend/src/pages/SettingsPage.tsx

@@ -673,7 +673,7 @@ export function SettingsPage() {
   });
   });
 
 
   const updatePrinterMutation = useMutation({
   const updatePrinterMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean }> }) =>
+    mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> }) =>
       api.updatePrinter(id, data),
       api.updatePrinter(id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
       queryClient.invalidateQueries({ queryKey: ['printers'] });
@@ -892,10 +892,11 @@ export function SettingsPage() {
     }, 800);
     }, 800);
   };
   };
 
 
-  const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean }) => {
-    const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean }> = {};
+  const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {
+    const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};
     if (updates.type !== undefined) data.external_camera_type = updates.type || null;
     if (updates.type !== undefined) data.external_camera_type = updates.type || null;
     if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
     if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
+    if (updates.rotation !== undefined) data.camera_rotation = updates.rotation;
     updatePrinterMutation.mutate({ id: printerId, data });
     updatePrinterMutation.mutate({ id: printerId, data });
   };
   };
 
 
@@ -1479,6 +1480,19 @@ export function SettingsPage() {
                                 )}
                                 )}
                               </div>
                               </div>
                             )}
                             )}
+                            <div className="flex items-center gap-2">
+                              <label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
+                              <select
+                                value={printer.camera_rotation || 0}
+                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}
+                                className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
+                              >
+                                <option value={0}>0°</option>
+                                <option value={90}>90°</option>
+                                <option value={180}>180°</option>
+                                <option value={270}>270°</option>
+                              </select>
+                            </div>
                           </div>
                           </div>
                         )}
                         )}
                       </div>
                       </div>

+ 1 - 0
frontend/src/pages/StreamOverlayPage.tsx

@@ -202,6 +202,7 @@ export function StreamOverlayPage() {
           src={streamUrl}
           src={streamUrl}
           alt={t('streamOverlay.cameraStream')}
           alt={t('streamOverlay.cameraStream')}
           className="absolute inset-0 w-full h-full object-contain"
           className="absolute inset-0 w-full h-full object-contain"
+          style={printer?.camera_rotation ? { transform: `rotate(${printer.camera_rotation}deg)` } : undefined}
           onError={handleStreamError}
           onError={handleStreamError}
         />
         />
       )}
       )}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-knbatSiK.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CCS49h9H.js"></script>
+    <script type="module" crossorigin src="/assets/index-knbatSiK.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-hKLgfdEQ.css">
     <link rel="stylesheet" crossorigin href="/assets/index-hKLgfdEQ.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов