416 lines
14 KiB
Python
416 lines
14 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
pytest.importorskip("qtpy")
|
|
|
|
from eco.widgets.camserver_stream_qt import (
|
|
COLORMAP_NAMES,
|
|
FrameProcessor,
|
|
_full_range,
|
|
apply_colormap,
|
|
array_to_qimage,
|
|
colormap_gradient_stops,
|
|
compute_fit_scale,
|
|
compute_log_histogram,
|
|
compute_roi_from_drag,
|
|
get_colormap_lut,
|
|
normalize_to_uint8,
|
|
value_to_y,
|
|
y_to_value,
|
|
)
|
|
|
|
|
|
def test_full_range_integer_dtype():
|
|
assert _full_range(np.uint16) == (0.0, 65535.0)
|
|
assert _full_range(np.uint8) == (0.0, 255.0)
|
|
|
|
|
|
def test_full_range_float_dtype_falls_back_to_none():
|
|
assert _full_range(np.float32) == (None, None)
|
|
|
|
|
|
def test_compute_roi_from_drag_normalizes_any_corner_order():
|
|
assert compute_roi_from_drag(10, 20, 30, 50) == (10, 20, 20, 30)
|
|
assert compute_roi_from_drag(30, 50, 10, 20) == (10, 20, 20, 30)
|
|
|
|
|
|
def test_frame_processor_passthrough_grayscale_auto_contrast():
|
|
proc = FrameProcessor()
|
|
frame = np.array([[0, 100], [200, 255]], dtype=np.uint16)
|
|
display, stats = proc.process(frame)
|
|
assert display.dtype == np.uint8
|
|
assert display.min() == 0
|
|
assert display.max() == 255
|
|
assert stats["color"] is False
|
|
assert stats["shape"] == (2, 2)
|
|
|
|
|
|
def test_frame_processor_manual_contrast_clips():
|
|
proc = FrameProcessor()
|
|
proc.contrast_mode = "manual"
|
|
proc.vmin, proc.vmax = 0, 100
|
|
frame = np.array([[-50, 0], [50, 500]], dtype=np.float32)
|
|
display, _ = proc.process(frame)
|
|
assert display[0, 0] == 0
|
|
assert display[1, 1] == 255
|
|
|
|
|
|
def test_frame_processor_full_range_uses_dtype_bounds():
|
|
proc = FrameProcessor()
|
|
proc.contrast_mode = "full"
|
|
frame = np.full((2, 2), 1000, dtype=np.uint16)
|
|
display, _ = proc.process(frame)
|
|
# 1000 / 65535 * 255 ~= 3.9 -> 3 or 4 depending on rounding, but nowhere
|
|
# near saturating like auto-contrast (which would map a flat frame to 0)
|
|
assert 0 < display[0, 0] < 10
|
|
|
|
|
|
def test_frame_processor_averaging_smooths_across_frames():
|
|
proc = FrameProcessor(average_n=2)
|
|
f1 = np.full((2, 2), 0, dtype=np.uint16)
|
|
f2 = np.full((2, 2), 100, dtype=np.uint16)
|
|
proc.process(f1)
|
|
display, stats = proc.process(f2)
|
|
# average of 0 and 100 is 50, well below the raw incoming frame's max
|
|
assert stats["max"] == pytest.approx(50.0)
|
|
|
|
|
|
def test_frame_processor_set_average_n_resizes_ring():
|
|
proc = FrameProcessor(average_n=5)
|
|
for v in (0, 10, 20, 30, 40):
|
|
proc.process(np.full((2, 2), v, dtype=np.uint16))
|
|
proc.set_average_n(1)
|
|
display, stats = proc.process(np.full((2, 2), 100, dtype=np.uint16))
|
|
assert stats["max"] == pytest.approx(100.0)
|
|
|
|
|
|
def test_frame_processor_background_grab_and_subtract():
|
|
proc = FrameProcessor()
|
|
background_frame = np.full((2, 2), 50, dtype=np.uint16)
|
|
proc.process(background_frame)
|
|
proc.grab_background()
|
|
proc.subtract_background = True
|
|
|
|
signal_frame = np.full((2, 2), 80, dtype=np.uint16)
|
|
_, stats = proc.process(signal_frame)
|
|
# 80 - 50 = 30 after subtraction, not 80
|
|
assert stats["max"] == pytest.approx(30.0)
|
|
|
|
|
|
def test_frame_processor_clear_background_disables_subtraction_effect():
|
|
proc = FrameProcessor()
|
|
proc.process(np.full((2, 2), 50, dtype=np.uint16))
|
|
proc.grab_background()
|
|
proc.subtract_background = True
|
|
proc.clear_background()
|
|
|
|
_, stats = proc.process(np.full((2, 2), 80, dtype=np.uint16))
|
|
# background is None again, so subtraction is a no-op even though the
|
|
# checkbox-equivalent flag is still True
|
|
assert stats["max"] == pytest.approx(80.0)
|
|
|
|
|
|
def test_frame_processor_roi_crop():
|
|
proc = FrameProcessor()
|
|
frame = np.arange(100, dtype=np.uint16).reshape(10, 10)
|
|
proc.set_roi((2, 3, 4, 5))
|
|
display, stats = proc.process(frame)
|
|
assert stats["shape"] == (5, 4)
|
|
|
|
|
|
def test_frame_processor_compose_roi_offsets_by_existing_origin():
|
|
assert FrameProcessor.compose_roi(None, (5, 5, 10, 10)) == (5, 5, 10, 10)
|
|
assert FrameProcessor.compose_roi((10, 10, 50, 50), (5, 5, 8, 8)) == (15, 15, 8, 8)
|
|
|
|
|
|
def test_frame_processor_color_image_bypasses_contrast_pipeline():
|
|
proc = FrameProcessor()
|
|
frame = np.zeros((4, 4, 3), dtype=np.uint8)
|
|
frame[..., 0] = 200 # solid red-ish
|
|
display, stats = proc.process(frame)
|
|
assert stats["color"] is True
|
|
assert display.dtype == np.uint8
|
|
assert display.shape == (4, 4, 3)
|
|
assert display[0, 0, 0] == 200
|
|
|
|
|
|
def test_frame_processor_color_image_background_subtract():
|
|
proc = FrameProcessor()
|
|
bg = np.full((3, 3, 3), 30, dtype=np.uint8)
|
|
proc.process(bg)
|
|
proc.grab_background()
|
|
proc.subtract_background = True
|
|
|
|
frame = np.full((3, 3, 3), 80, dtype=np.uint8)
|
|
display, _ = proc.process(frame)
|
|
assert display[0, 0, 0] == 50
|
|
|
|
|
|
def test_array_to_qimage_grayscale():
|
|
arr = np.zeros((4, 6), dtype=np.uint8)
|
|
image = array_to_qimage(arr)
|
|
assert image.width() == 6
|
|
assert image.height() == 4
|
|
|
|
|
|
def test_array_to_qimage_rgb():
|
|
arr = np.zeros((4, 6, 3), dtype=np.uint8)
|
|
image = array_to_qimage(arr)
|
|
assert image.width() == 6
|
|
assert image.height() == 4
|
|
|
|
|
|
def test_array_to_qimage_rejects_bad_shape():
|
|
with pytest.raises(ValueError):
|
|
array_to_qimage(np.zeros((4, 6, 5), dtype=np.uint8))
|
|
|
|
|
|
# -- histogram/colorscale sidebar (_HistogramColorbar) pure-logic core --
|
|
|
|
|
|
def test_value_to_y_maps_high_values_to_top():
|
|
# data_max should land at y=0 (top), data_min at y=height (bottom)
|
|
assert value_to_y(100.0, height=200, data_min=0.0, data_max=100.0) == 0
|
|
assert value_to_y(0.0, height=200, data_min=0.0, data_max=100.0) == 200
|
|
assert value_to_y(50.0, height=200, data_min=0.0, data_max=100.0) == 100
|
|
|
|
|
|
def test_value_to_y_degenerate_span_returns_midpoint():
|
|
assert value_to_y(5.0, height=200, data_min=5.0, data_max=5.0) == 100
|
|
|
|
|
|
def test_y_to_value_is_inverse_of_value_to_y():
|
|
for value in (0.0, 12.5, 50.0, 87.3, 100.0):
|
|
y = value_to_y(value, height=300, data_min=0.0, data_max=100.0)
|
|
roundtripped = y_to_value(y, height=300, data_min=0.0, data_max=100.0)
|
|
assert roundtripped == pytest.approx(value, abs=0.5)
|
|
|
|
|
|
def test_compute_log_histogram_shapes_and_edges():
|
|
arr = np.random.default_rng(0).integers(0, 1000, size=(50, 50)).astype(np.uint16)
|
|
counts, edges = compute_log_histogram(arr, bins=64)
|
|
assert counts.shape == (64,)
|
|
assert edges.shape == (65,)
|
|
assert edges[0] == pytest.approx(float(arr.min()))
|
|
assert edges[-1] == pytest.approx(float(arr.max()))
|
|
assert np.all(counts >= 0)
|
|
|
|
|
|
def test_compute_log_histogram_flat_array_has_nonzero_range():
|
|
arr = np.full((10, 10), 42, dtype=np.uint16)
|
|
counts, edges = compute_log_histogram(arr, bins=8)
|
|
assert edges[-1] > edges[0] # degenerate range was widened, not zero-width
|
|
|
|
|
|
# -- FrameProcessor.process stats consumed by the histogram sidebar --
|
|
|
|
|
|
def test_frame_processor_stats_include_resolved_vmin_vmax_auto():
|
|
proc = FrameProcessor()
|
|
frame = np.array([[10, 20], [30, 40]], dtype=np.uint16)
|
|
_, stats = proc.process(frame)
|
|
assert stats["vmin"] == pytest.approx(10.0)
|
|
assert stats["vmax"] == pytest.approx(40.0)
|
|
|
|
|
|
def test_frame_processor_stats_include_resolved_vmin_vmax_manual():
|
|
proc = FrameProcessor()
|
|
proc.contrast_mode = "manual"
|
|
proc.vmin, proc.vmax = 5, 500
|
|
frame = np.array([[10, 20], [30, 40]], dtype=np.uint16)
|
|
_, stats = proc.process(frame)
|
|
assert stats["vmin"] == 5
|
|
assert stats["vmax"] == 500
|
|
|
|
|
|
def test_frame_processor_stats_include_resolved_vmin_vmax_full():
|
|
proc = FrameProcessor()
|
|
proc.contrast_mode = "full"
|
|
frame = np.array([[10, 20], [30, 40]], dtype=np.uint16)
|
|
_, stats = proc.process(frame)
|
|
assert stats["vmin"] == 0.0
|
|
assert stats["vmax"] == 65535.0
|
|
|
|
|
|
def test_frame_processor_stats_vmin_vmax_none_for_color():
|
|
proc = FrameProcessor()
|
|
frame = np.zeros((3, 3, 3), dtype=np.uint8)
|
|
_, stats = proc.process(frame)
|
|
assert stats["vmin"] is None
|
|
assert stats["vmax"] is None
|
|
|
|
|
|
def test_frame_processor_stats_values_reflects_averaging_and_roi():
|
|
proc = FrameProcessor(average_n=2)
|
|
proc.set_roi((1, 0, 2, 2))
|
|
proc.process(np.zeros((2, 3), dtype=np.uint16))
|
|
_, stats = proc.process(np.full((2, 3), 10, dtype=np.uint16))
|
|
# averaged (0, 10) -> 5, then cropped to the 2-wide ROI
|
|
assert stats["values"].shape == (2, 2)
|
|
assert np.all(stats["values"] == 5.0)
|
|
|
|
|
|
# -- log-scale contrast --
|
|
|
|
|
|
def test_normalize_to_uint8_log_scale_compresses_bright_peak():
|
|
# a narrow bright peak against a broad low background: log scale
|
|
# should raise the background's apparent brightness relative to
|
|
# linear, since low-vs-high differences get compressed more than
|
|
# high-vs-higher ones
|
|
arr = np.array([[1, 1], [1, 1000]], dtype=np.float32)
|
|
linear = normalize_to_uint8(arr, vmin=0, vmax=1000, log_scale=False)
|
|
log = normalize_to_uint8(arr, vmin=0, vmax=1000, log_scale=True)
|
|
assert log[0, 0] > linear[0, 0]
|
|
assert log[1, 1] == 255 # the max should still saturate either way
|
|
|
|
|
|
def test_normalize_to_uint8_log_scale_clips_negative_to_zero():
|
|
arr = np.array([[-50, 0], [50, 100]], dtype=np.float32)
|
|
out = normalize_to_uint8(arr, vmin=0, vmax=100, log_scale=True)
|
|
assert out[0, 0] == 0
|
|
|
|
|
|
def test_frame_processor_log_scale_flag_reaches_normalize():
|
|
proc = FrameProcessor()
|
|
proc.contrast_mode = "manual"
|
|
proc.vmin, proc.vmax = 0, 1000
|
|
proc.log_scale = True
|
|
arr = np.array([[1, 1], [1, 1000]], dtype=np.float32)
|
|
display, _ = proc.process(arr)
|
|
proc_linear = FrameProcessor()
|
|
proc_linear.contrast_mode = "manual"
|
|
proc_linear.vmin, proc_linear.vmax = 0, 1000
|
|
linear_display, _ = proc_linear.process(arr)
|
|
assert display[0, 0] > linear_display[0, 0]
|
|
|
|
|
|
# -- colormaps --
|
|
|
|
|
|
def test_colormap_names_cover_the_requested_set():
|
|
assert set(COLORMAP_NAMES) == {
|
|
"gray",
|
|
"gray_inverted",
|
|
"viridis",
|
|
"diverging_zero",
|
|
"diverging_one",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("name", COLORMAP_NAMES)
|
|
def test_get_colormap_lut_shape_and_dtype(name):
|
|
lut = get_colormap_lut(name)
|
|
assert lut.shape == (256, 3)
|
|
assert lut.dtype == np.uint8
|
|
|
|
|
|
def test_get_colormap_lut_gray_is_identity():
|
|
lut = get_colormap_lut("gray")
|
|
assert np.array_equal(lut[0], [0, 0, 0])
|
|
assert np.array_equal(lut[255], [255, 255, 255])
|
|
assert np.array_equal(lut[128], [128, 128, 128])
|
|
|
|
|
|
def test_get_colormap_lut_gray_inverted_is_reversed():
|
|
lut = get_colormap_lut("gray_inverted")
|
|
assert np.array_equal(lut[0], [255, 255, 255])
|
|
assert np.array_equal(lut[255], [0, 0, 0])
|
|
|
|
|
|
def test_get_colormap_lut_viridis_matches_known_endpoints():
|
|
# matplotlib's viridis: dark purple at 0, yellow at 1 -- confirms
|
|
# we're actually getting real viridis data, not a placeholder
|
|
lut = get_colormap_lut("viridis")
|
|
r0, g0, b0 = lut[0]
|
|
assert r0 < 100 and b0 > 50 # dark purple-ish
|
|
r255, g255, b255 = lut[255]
|
|
assert r255 > 200 and g255 > 200 and b255 < 100 # yellow-ish
|
|
|
|
|
|
def test_apply_colormap_shape():
|
|
gray = np.array([[0, 128], [255, 64]], dtype=np.uint8)
|
|
rgb = apply_colormap(gray, "viridis")
|
|
assert rgb.shape == (2, 2, 3)
|
|
assert rgb.dtype == np.uint8
|
|
|
|
|
|
def test_colormap_gradient_stops_covers_full_range():
|
|
stops = colormap_gradient_stops("viridis", n=8)
|
|
fractions = [f for f, _color in stops]
|
|
assert fractions[0] == 0.0
|
|
assert fractions[-1] == 1.0
|
|
assert len(stops) == 8
|
|
|
|
|
|
def test_frame_processor_viridis_colormap_produces_rgb_display():
|
|
proc = FrameProcessor()
|
|
proc.colormap = "viridis"
|
|
frame = np.array([[0, 100], [200, 255]], dtype=np.uint16)
|
|
display, stats = proc.process(frame)
|
|
assert display.shape == (2, 2, 3)
|
|
assert stats["color"] is False # input was grayscale; only the display is RGB
|
|
|
|
|
|
def test_frame_processor_diverging_zero_centers_reference_at_midpoint():
|
|
proc = FrameProcessor()
|
|
proc.colormap = "diverging_zero"
|
|
# asymmetric raw range (-10..40) should still map 0 to the LUT's exact
|
|
# midpoint (index 127/128) rather than wherever -10..40 would put it
|
|
frame = np.array([[-10, 0], [20, 40]], dtype=np.float32)
|
|
display, stats = proc.process(frame)
|
|
lut = get_colormap_lut("diverging_zero")
|
|
zero_gray = normalize_to_uint8(frame, stats["vmin"], stats["vmax"])[0, 1] # the 0 pixel
|
|
assert 120 <= zero_gray <= 135
|
|
assert np.array_equal(display[0, 1], lut[zero_gray])
|
|
# symmetric bounds around 0, sized by the larger side (40)
|
|
assert stats["vmin"] == pytest.approx(-40.0)
|
|
assert stats["vmax"] == pytest.approx(40.0)
|
|
|
|
|
|
def test_frame_processor_diverging_one_centers_reference_at_midpoint():
|
|
proc = FrameProcessor()
|
|
proc.colormap = "diverging_one"
|
|
frame = np.array([[0.5, 1.0], [1.0, 3.0]], dtype=np.float32)
|
|
_, stats = proc.process(frame)
|
|
# symmetric bounds around 1, sized by the larger side (|3-1|=2)
|
|
assert stats["vmin"] == pytest.approx(-1.0)
|
|
assert stats["vmax"] == pytest.approx(3.0)
|
|
|
|
|
|
def test_frame_processor_diverging_respects_manual_bounds():
|
|
proc = FrameProcessor()
|
|
proc.colormap = "diverging_zero"
|
|
proc.contrast_mode = "manual"
|
|
proc.vmin, proc.vmax = -5, 15
|
|
frame = np.zeros((2, 2), dtype=np.float32)
|
|
_, stats = proc.process(frame)
|
|
# widened symmetrically around 0 from the manual bounds (-5, 15) -> the
|
|
# larger magnitude side (15) sets the half-width
|
|
assert stats["vmin"] == pytest.approx(-15.0)
|
|
assert stats["vmax"] == pytest.approx(15.0)
|
|
|
|
|
|
# -- zoom / fit-to-window --
|
|
|
|
|
|
def test_compute_fit_scale_fits_within_viewport_preserving_aspect():
|
|
# 1000x500 image (w x h) into a 400x400 viewport -> limited by width
|
|
scale = compute_fit_scale(viewport_w=400, viewport_h=400, raw_w=1000, raw_h=500)
|
|
assert scale == pytest.approx(0.4)
|
|
|
|
|
|
def test_compute_fit_scale_limited_by_height():
|
|
scale = compute_fit_scale(viewport_w=1000, viewport_h=200, raw_w=500, raw_h=500)
|
|
assert scale == pytest.approx(0.4)
|
|
|
|
|
|
def test_compute_fit_scale_degenerate_inputs_fall_back_to_one():
|
|
assert compute_fit_scale(0, 400, 100, 100) == 1.0
|
|
assert compute_fit_scale(400, 400, 0, 100) == 1.0
|
|
|
|
|
|
def test_compute_fit_scale_never_collapses_to_zero():
|
|
scale = compute_fit_scale(viewport_w=1, viewport_h=1, raw_w=100000, raw_h=100000)
|
|
assert scale >= 0.02
|