Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/python-regressions.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 10 additions & 9 deletions simworld/communicator/unrealcv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1110,24 +1113,23 @@ 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.

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)}')
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions tests/test_camera_image_decoding.py
Original file line number Diff line number Diff line change
@@ -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(
'<IiiHHIIiiII', 40, width, -height if top_down else height,
1, bits_per_pixel, 0, len(data), 0, 0, 0, 0,
)
return file_header + info_header + data


@pytest.mark.parametrize('mode', ['RGB', 'RGBA', 'P', 'L'])
def test_png_always_returns_three_bgr_channels(camera, rgb_pixels, mode):
"""Dropping alpha must not discard blue or fail on palette/grayscale PNGs."""
payload, expected = encode_png(rgb_pixels, mode)
actual = camera._decode_png(payload)
assert actual.shape == (2, 3, 3)
assert actual.dtype == np.uint8
np.testing.assert_array_equal(actual, expected)


@pytest.mark.parametrize('bits_per_pixel', [24, 32])
@pytest.mark.parametrize('top_down', [False, True])
def test_bmp_returns_bgr_with_correct_row_order(camera, rgb_pixels, bits_per_pixel, top_down):
"""BMP capture must preserve colors and orientation for either row layout."""
payload = encode_bmp(rgb_pixels, bits_per_pixel, top_down)
actual = camera._decode_bmp(payload)
assert actual.shape == (2, 3, 3)
assert actual.dtype == np.uint8
np.testing.assert_array_equal(actual, rgb_pixels[:, :, ::-1])


@pytest.mark.parametrize('viewmode', ['lit', 'object_mask'])
@pytest.mark.parametrize('mode,encoding', [('direct', 'png'), ('fast', 'bmp')])
def test_camera_modes_preserve_the_same_bgr_colors(camera, rgb_pixels, viewmode, mode, encoding):
"""The public image API must not change colors when switching transport."""
if encoding == 'png':
payload, expected = encode_png(rgb_pixels, 'RGB')
else:
payload = encode_bmp(rgb_pixels, 24, False)
expected = rgb_pixels[:, :, ::-1]
camera.client.request.return_value = payload

actual = camera.get_image(7, viewmode, mode=mode)

camera.client.request.assert_called_once_with(f'vget /camera/7/{viewmode} {encoding}')
np.testing.assert_array_equal(actual, expected)