diff --git a/.github/workflows/python-regressions.yml b/.github/workflows/python-regressions.yml new file mode 100644 index 00000000..d2582275 --- /dev/null +++ b/.github/workflows/python-regressions.yml @@ -0,0 +1,37 @@ +name: Python regressions + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ['3.10', '3.12'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: tests/requirements.txt + - name: Install regression test dependencies + run: python -m pip install -r tests/requirements.txt + - name: Run regression tests + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: '1' + run: python -m pytest tests -q --junitxml=regressions.xml --basetemp=test-output + - name: Upload test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: regressions-${{ matrix.os }}-python-${{ matrix.python }} + path: | + regressions.xml + test-output/**/*.mp4 + test-output/**/*.png diff --git a/simworld/communicator/unrealcv.py b/simworld/communicator/unrealcv.py index 77801502..ae0cbe80 100644 --- a/simworld/communicator/unrealcv.py +++ b/simworld/communicator/unrealcv.py @@ -1042,6 +1042,9 @@ def get_image(self, cam_id, viewmode, mode='direct', img_path=None): viewmode: View mode. Possible values are 'lit', 'depth', 'object_mask'. mode: Mode. img_path: Image path. + + Returns: + Image in OpenCV BGR channel order, including colorized depth. """ image = None try: @@ -1110,7 +1113,7 @@ def _decode_npy(self, res): return image def _decode_png(self, res): - """Decode PNG image. + """Decode a PNG image into three OpenCV BGR channels. Args: res: PNG image. @@ -1118,16 +1121,15 @@ def _decode_png(self, res): Returns: Decoded image. """ - img = np.asarray(PIL.Image.open(BytesIO(res))) - img = img[:, :, :-1] # delete alpha channel - img = img[:, :, ::-1] # transpose channel order - return img + with PIL.Image.open(BytesIO(res)) as png: + rgb = np.asarray(png.convert('RGB')) + return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) def _decode_bmp(self, res: bytes): """Robust BMP decoder. Parses header, handles row padding and top-down/bottom-up storage. - Returns an RGB image of shape (H, W, 3). + Returns an OpenCV BGR image of shape (H, W, 3). """ if not isinstance(res, (bytes, bytearray)): raise TypeError(f'BMP decoder expects bytes, got {type(res)}') @@ -1162,9 +1164,8 @@ def _decode_bmp(self, res: bytes): if height_raw > 0: buf = np.flipud(buf) - # Convert BGR(A) -> RGB and drop alpha if present - rgb = buf[:, :, :3][:, :, ::-1] - return rgb + # BMP already stores BGR(A); drop alpha without swapping red and blue. + return np.ascontiguousarray(buf[:, :, :3]) def update_objects(self, object_name): """Update objects. diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..90499606 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,13 @@ +# Dependencies needed by the regression tests and simworld's package imports. +# Asset retrieval models are not exercised by this suite. +pytest +numpy +pandas +pyqtgraph +PyQt5 +unrealcv +opencv-python +Pillow +openai +PyYAML +ipython diff --git a/tests/test_camera_image_decoding.py b/tests/test_camera_image_decoding.py new file mode 100644 index 00000000..9a1977cd --- /dev/null +++ b/tests/test_camera_image_decoding.py @@ -0,0 +1,95 @@ +"""Regression tests for camera image channels, colors, and BMP row layout.""" + +import struct +from io import BytesIO +from threading import Lock +from unittest.mock import Mock + +import numpy as np +import pytest +from PIL import Image + +from simworld.communicator.unrealcv import UnrealCV + + +@pytest.fixture +def rgb_pixels(): + """Use distinct colors and an odd width to expose swaps and row padding.""" + return np.array([ + [[255, 0, 0], [0, 255, 0], [0, 0, 255]], + [[12, 34, 56], [78, 90, 123], [145, 167, 189]], + ], dtype=np.uint8) + + +@pytest.fixture +def camera(): + """Create the real decoder without connecting to an Unreal server.""" + camera = UnrealCV.__new__(UnrealCV) + camera.lock = Lock() + camera.client = Mock() + return camera + + +def encode_png(pixels, mode): + """Encode PNGs with and without an alpha channel or a palette.""" + image = Image.fromarray(pixels).convert(mode) + stream = BytesIO() + image.save(stream, format='PNG') + return stream.getvalue(), np.array(image.convert('RGB'))[:, :, ::-1] + + +def encode_bmp(pixels, bits_per_pixel, top_down): + """Build uncompressed BMP fixtures with explicit orientation and padding.""" + height, width, _ = pixels.shape + bgr = pixels[:, :, ::-1] + if bits_per_pixel == 32: + alpha = np.full((height, width, 1), 173, dtype=np.uint8) + bgr = np.concatenate((bgr, alpha), axis=2) + if not top_down: + bgr = bgr[::-1] + row_padding = b'\0' * ((-width * (bits_per_pixel // 8)) % 4) + data = b''.join(row.tobytes() + row_padding for row in bgr) + file_header = struct.pack('<2sIHHI', b'BM', 54 + len(data), 0, 0, 54) + info_header = struct.pack( + '