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

fix(camera): transcode non-JPEG external snapshots to JPEG (#1902)

External cameras in HTTP-snapshot mode failed to load with a repeating
"connection lost" when the endpoint served PNG/WebP/BMP stills instead of
JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
every part in a hard-coded Content-Type: image/jpeg boundary, so a
non-JPEG payload labelled as JPEG made the browser reject the frame and
tear down the whole multipart/x-mixed-replace stream.

_capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
(already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
path; truly undecodable responses (HTML error pages, auth redirects) fall
back to the previous raw-return behaviour with a single clear warning
instead of a per-frame log flood.
maziggy 2 месяцев назад
Родитель
Сommit
379765a46a

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 61 - 8
backend/app/services/external_camera.py

@@ -461,6 +461,39 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
             await proxy_server.wait_closed()
 
 
+def _transcode_to_jpeg(data: bytes) -> bytes | None:
+    """Decode an arbitrary still image (PNG/WebP/BMP/GIF/...) and re-encode as JPEG.
+
+    Some camera/proxy snapshot endpoints serve stills as PNG or WebP rather than
+    JPEG. A browser opened directly at the URL renders those fine, but our MJPEG
+    ``multipart/x-mixed-replace`` stream hard-labels every part
+    ``Content-Type: image/jpeg`` — so a non-JPEG payload makes the browser reject
+    the frame and drop the whole stream ("connection lost", #1902). Transcoding to
+    JPEG keeps the stream genuinely MJPEG and also keeps the JPEG-only downstream
+    (plate detection, Obico, finish photo) working.
+
+    Returns None if the bytes are not a decodable image (e.g. an HTML error page)
+    or if the imaging libraries are unavailable — callers fall back to the raw
+    bytes so behaviour is never worse than before.
+    """
+    try:
+        import cv2
+        import numpy as np
+    except ImportError:
+        return None
+    try:
+        img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
+        if img is None:
+            return None
+        ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
+        if not ok:
+            return None
+        return buf.tobytes()
+    except Exception as e:  # cv2 raises cv2.error (a subclass of Exception) on bad input
+        logger.debug("Snapshot transcode to JPEG failed: %s", e)
+        return None
+
+
 async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
     """Fetch snapshot from HTTP URL.
 
@@ -484,14 +517,6 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
                 return None
 
             data = await response.read()
-
-            # Validate it looks like JPEG
-            if not data.startswith(b"\xff\xd8"):
-                logger.warning("Snapshot does not appear to be JPEG")
-                # Still return it - might be valid with different header
-
-            return data
-
     except TimeoutError:
         logger.warning("Snapshot capture timed out after %ss", timeout)
         return None
@@ -499,6 +524,34 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
         logger.error("Snapshot capture failed: %s", e)
         return None
 
+    # Fast path: already JPEG (SOI marker), stream it as-is (no decode/re-encode).
+    if data.startswith(b"\xff\xd8"):
+        return data
+
+    # Not JPEG. Many snapshot endpoints serve PNG/WebP/BMP — transcode to JPEG so
+    # the browser's MJPEG stream (and JPEG-only downstream) keep working instead of
+    # dropping the connection (#1902). Run off the event loop: cv2 decode/encode is
+    # CPU-bound and this can be polled at up to 15 fps while a camera view is open.
+    transcoded = await asyncio.to_thread(_transcode_to_jpeg, data)
+    if transcoded is not None:
+        logger.debug(
+            "Transcoded non-JPEG snapshot (%d bytes, header %s) to JPEG",
+            len(data),
+            data[:4].hex(),
+        )
+        return transcoded
+
+    # Couldn't decode it as an image at all — most likely not an image response
+    # (HTML error page, auth redirect, wrong URL). Return the raw bytes as a last
+    # resort (unchanged behaviour) but log enough to debug.
+    logger.warning(
+        "External camera snapshot is not a decodable image "
+        "(%d bytes, header %s) — verify the camera URL returns an image",
+        len(data),
+        data[:4].hex(),
+    )
+    return data
+
 
 async def test_connection(url: str, camera_type: str) -> dict:
     """Test camera connection.

+ 114 - 0
backend/tests/unit/services/test_external_camera.py

@@ -591,3 +591,117 @@ class TestUsbCameraHandling:
 
         result = await capture_frame("http://example.com", "usb", timeout=1)
         assert result is None
+
+
+def _encode_image(ext: str) -> bytes:
+    """Encode a small solid test image to the given container (.png/.webp/.jpg)."""
+    import cv2
+    import numpy as np
+
+    img = np.zeros((16, 24, 3), dtype=np.uint8)
+    img[:, :12] = (0, 0, 255)  # half red so the frame isn't uniformly black
+    ok, buf = cv2.imencode(ext, img)
+    assert ok, f"failed to encode {ext}"
+    return buf.tobytes()
+
+
+def _fake_snapshot_session(body: bytes, status: int = 200):
+    """Build an aiohttp.ClientSession stand-in whose GET yields `body`.
+
+    Matches the `async with ClientSession(...) as session, session.get(url) as
+    response` usage inside `_capture_snapshot`.
+    """
+
+    class _Resp:
+        def __init__(self):
+            self.status = status
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *a):
+            return False
+
+        async def read(self):
+            return body
+
+    class _Session:
+        def __init__(self, *a, **k):
+            pass
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *a):
+            return False
+
+        def get(self, _url):
+            return _Resp()
+
+    return _Session
+
+
+class TestSnapshotTranscode:
+    """Regression for #1902. Snapshot endpoints that serve PNG/WebP (not JPEG)
+    broke the browser MJPEG stream, because every multipart part is hard-labelled
+    ``Content-Type: image/jpeg`` — the browser rejected the non-JPEG payload and
+    dropped the whole stream ("connection lost"). ``_capture_snapshot`` now
+    transcodes non-JPEG stills to JPEG; only genuinely undecodable payloads fall
+    through to the raw bytes (unchanged last-resort behaviour)."""
+
+    def test_transcode_png_to_jpeg(self):
+        from backend.app.services.external_camera import _transcode_to_jpeg
+
+        png = _encode_image(".png")
+        assert not png.startswith(JPEG_START)  # sanity: input really is PNG
+        out = _transcode_to_jpeg(png)
+        assert out is not None and out.startswith(JPEG_START)
+
+    def test_transcode_webp_to_jpeg(self):
+        from backend.app.services.external_camera import _transcode_to_jpeg
+
+        webp = _encode_image(".webp")
+        assert not webp.startswith(JPEG_START)
+        out = _transcode_to_jpeg(webp)
+        assert out is not None and out.startswith(JPEG_START)
+
+    def test_transcode_returns_none_for_non_image(self):
+        """HTML error pages / auth redirects / empty bodies aren't images —
+        transcode returns None so the caller can log and fall back."""
+        from backend.app.services.external_camera import _transcode_to_jpeg
+
+        assert _transcode_to_jpeg(b"<html><body>404 Not Found</body></html>") is None
+        assert _transcode_to_jpeg(b"") is None
+
+    @pytest.mark.asyncio
+    async def test_capture_snapshot_transcodes_png_response(self):
+        """The reported case: a snapshot URL returning PNG yields JPEG bytes."""
+        from backend.app.services import external_camera as ec
+
+        png = _encode_image(".png")
+        with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(png)):
+            out = await ec._capture_snapshot("http://192.168.50.50/snapshot.png", 10)
+        assert out is not None and out.startswith(JPEG_START)
+
+    @pytest.mark.asyncio
+    async def test_capture_snapshot_jpeg_passthrough_unchanged(self):
+        """A JPEG snapshot must be returned byte-for-byte (fast path, no
+        re-encode) so we don't degrade quality or waste CPU on JPEG cameras."""
+        from backend.app.services import external_camera as ec
+
+        jpeg = _encode_image(".jpg")
+        assert jpeg.startswith(JPEG_START)
+        with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(jpeg)):
+            out = await ec._capture_snapshot("http://192.168.50.50/snapshot.jpg", 10)
+        assert out == jpeg  # identical object bytes — proves no transcode ran
+
+    @pytest.mark.asyncio
+    async def test_capture_snapshot_non_image_falls_back_to_raw(self):
+        """Undecodable (non-image) responses return the raw bytes unchanged, so
+        behaviour is never worse than before the fix."""
+        from backend.app.services import external_camera as ec
+
+        html = b"<html><body>unauthorized</body></html>"
+        with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(html)):
+            out = await ec._capture_snapshot("http://192.168.50.50/snapshot", 10)
+        assert out == html

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