diff --git a/src/dls_tiled/rendering.py b/src/dls_tiled/rendering.py index ef3ad46..12507c3 100644 --- a/src/dls_tiled/rendering.py +++ b/src/dls_tiled/rendering.py @@ -77,8 +77,11 @@ def _finish(total, weight, coverage): def render_cells(values, x, y, x_edges, y_edges, viewport: Viewport): - """The scan's grid cells (mean of the points in each), sampled at each pixel.""" + """The scan's grid cells (mean of the points in each), sampled at each pixel. + Points without a finite value are left out.""" values = numpy.asarray(values, dtype=float) + finite = numpy.isfinite(values) + values, x, y = values[finite], numpy.asarray(x)[finite], numpy.asarray(y)[finite] counts, _, _ = numpy.histogram2d(x, y, bins=[x_edges, y_edges]) sums, _, _ = numpy.histogram2d(x, y, bins=[x_edges, y_edges], weights=values) xs, ys = viewport.centres() @@ -251,7 +254,14 @@ def to_rows(image) -> list[list[float | None]]: return out.tolist() -CHANNEL_COLOUR = {"RedTotal": 0, "GreenTotal": 1, "BlueTotal": 2} +CHANNEL_COLOUR = { + "RedTotal": 0, + "GreenTotal": 1, + "BlueTotal": 2, + "DiodesRed": 0, + "DiodesGreen": 1, + "DiodesBlue": 2, +} def to_png(image, coverage, colour=0, vmin=None, vmax=None, outline=None) -> bytes: diff --git a/src/dls_tiled/visr.py b/src/dls_tiled/visr.py index 0976cb0..fadbfdd 100644 --- a/src/dls_tiled/visr.py +++ b/src/dls_tiled/visr.py @@ -962,7 +962,8 @@ def current_lengths() -> dict[str, int]: ), ) readbacks = readbacks[:, positioned] - data = {channel: d[positioned] for channel, d in data.items()} + # an optional channel can be shorter than the positions (behind them) + data = {channel: d[positioned[: len(d)]] for channel, d in data.items()} assert isinstance(red_total, (H5Dataset, numpy.ndarray)) logger.debug( @@ -1138,7 +1139,10 @@ async def render( # type: ignore extent) at ``size`` pixels along its longer side. ``format=json`` returns every channel as rows (row 0 at ``y0``, ``null`` - where nothing is drawn) plus a ``coverage`` layer (0 to 1). ``format=png`` + where nothing is drawn) plus the camera's ``coverage`` layer (0 to 1). The + diode channels (``DiodesRed``/``DiodesGreen``/``DiodesBlue``) are ``null`` + for a run without diodes, and ``null`` where they are behind the camera. + ``format=png`` returns one ``channel`` as a tinted RGBA image, transparent where nothing is drawn, scaled to ``vmin``/``vmax`` if given (else to its own range). For ``ovals``, JSON adds an ``outline`` layer (opacity of the white lines) and @@ -1171,10 +1175,31 @@ async def render( # type: ignore async def compute(): points = await load_points( - root, uid, 0, 1, setpoints, slice_dim, await get_scan_grid(root, uid) + root, + uid, + 0, + 1, + setpoints, + slice_dim, + await get_scan_grid(root, uid), + optional_channels=DIODE_CHANNELS, ) # the grid the points were placed on, which may have x and y swapped x, y, offset, grid = points.x, points.y, points.offset, points.grid + if format == "png" and channel not in points.data: + raise HTTPException( + status_code=404, detail=f"run {uid} has no {channel} channel" + ) + + def values_of(name): + """A channel's values, one per point: a diode channel behind the + camera is padded with NaN, which draws nothing.""" + values = numpy.asarray(points.data[name], dtype=float) + if len(values) < len(x): + values = numpy.concatenate( + [values, numpy.full(len(x) - len(values), numpy.nan)] + ) + return values step = _nominal_step(grid) if grid is not None else None if grid is not None: @@ -1264,7 +1289,7 @@ def produce() -> tuple[bytes, str]: """Draw and encode: the CPU-bound part, run off the event loop.""" outline = outline_of() if format == "png": - image, coverage = draw(points.data[channel]) + image, coverage = draw(values_of(channel)) png = rendering.to_png( image, coverage, @@ -1275,11 +1300,15 @@ def produce() -> tuple[bytes, str]: ) return png, "image/png" else: - images = { - name: draw(points.data[name]) - for name in ("RedTotal", "GreenTotal", "BlueTotal") + # each channel's (image, coverage), None for a diode it lacks + images: dict[str, Any] = { + name: draw(values_of(name)) for name in CAMERA_CHANNELS } - coverage = next(iter(images.values()))[1] + coverage = images["RedTotal"][1] + for name in DIODE_CHANNELS: + images[name] = ( + draw(values_of(name)) if name in points.data else None + ) output = { "kind": kind, "viewport": { @@ -1291,8 +1320,8 @@ def produce() -> tuple[bytes, str]: "height": viewport.height, }, "channels": { - name: rendering.to_rows(img) - for name, (img, _) in images.items() + name: None if drawn is None else rendering.to_rows(drawn[0]) + for name, drawn in images.items() }, "coverage": numpy.round(coverage, 3).tolist(), **( diff --git a/tests/test_render.py b/tests/test_render.py index c93d88d..c994e12 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -302,6 +302,89 @@ def test_render_ovals_returns_an_outline_layer(client): assert png.headers["content-type"] == "image/png" +# the RGB diodes + + +def write_diodes(client, uid, values): + primary = client[uid]["primary"] + for colour in ("red", "green", "blue"): + primary.write_array( + numpy.asarray(values, dtype=float), key=f"spectroscopy_diodes-{colour}" + ) + + +def test_render_has_null_diodes_for_a_run_without_them(client): + write_scan(client, "camera-only") + + channels = get(client, "camera-only", size=3).json()["channels"] + + assert channels["RedTotal"][0] == [0.0, 1.0, 2.0] + assert channels["DiodesRed"] is None + assert channels["DiodesGreen"] is None + assert channels["DiodesBlue"] is None + + +@pytest.mark.parametrize( + "params", + [ + {"kind": "cells"}, + {"kind": "rectangles"}, + {"kind": "gaussian"}, + {"kind": "ovals", "beam_x": 1.3, "beam_y": 1.3}, + ], +) +def test_render_draws_the_diodes_like_the_camera(client, params): + write_scan(client, "diodes") + write_diodes(client, "diodes", numpy.arange(9.0)) + + channels = get(client, "diodes", size=30, **params).json()["channels"] + + assert channels["DiodesRed"] == channels["RedTotal"] + assert channels["DiodesGreen"] == channels["GreenTotal"] + assert channels["DiodesBlue"] == channels["BlueTotal"] + + +@pytest.mark.parametrize( + "params", + [{"kind": "cells"}, {"kind": "ovals", "beam_x": 0.9, "beam_y": 0.9}], +) +def test_render_draws_nothing_where_the_diodes_are_behind_the_camera(client, params): + write_scan(client, "lagging") + write_diodes(client, "lagging", numpy.arange(7.0)) + + channels = get(client, "lagging", size=3, **params).json()["channels"] + + assert channels["RedTotal"][2] == [6.0, 7.0, 8.0] + assert channels["DiodesRed"][2] == [6.0, None, None] + assert channels["DiodesRed"][:2] == channels["RedTotal"][:2] + + +def test_render_png_of_a_diode_channel(client): + write_scan(client, "diode-png") + write_diodes(client, "diode-png", numpy.arange(9.0)) + write_scan(client, "no-diode-png") + + found = get(client, "diode-png", format="png", channel="DiodesBlue", size=8) + missing = get(client, "no-diode-png", format="png", channel="DiodesBlue") + + assert found.headers["content-type"] == "image/png" + assert missing.status_code == 404 + + +def test_render_cells_leave_out_points_without_a_value(): + image, coverage = rendering.render_cells( + [1.0, numpy.nan, 3.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + numpy.array([-0.5, 0.5, 1.5]), + numpy.array([-0.5, 0.5]), + Viewport.fit(-0.5, 1.5, -0.5, 0.5, 2), + ) + + assert image.tolist() == [[1.0, 3.0]] # not NaN where a NaN shares the cell + assert coverage.tolist() == [[1.0, 1.0]] + + @pytest.mark.parametrize( "params", [ diff --git a/tests/test_visr.py b/tests/test_visr.py index f26ef45..9e4a25a 100644 --- a/tests/test_visr.py +++ b/tests/test_visr.py @@ -1275,3 +1275,23 @@ def test_binned_diodes_follow_the_scans_grid(client): assert numpy.shape(body["DiodesRed"]) == numpy.shape(body["RedTotal"]) assert body["DiodesRed"] == body["RedTotal"] + + +def test_binned_leaves_out_points_without_a_position_when_diodes_are_behind(client): + # positions for 4 points, the third without one; diodes for only 3 + write_points(client, "gap", [0.0, 1.0, numpy.nan, 1.0], [0.0, 0.0, 1.0, 1.0]) + primary = client["gap"]["primary"] + for colour, values in DIODES.items(): + primary.write_array( + numpy.array(values[:3]), key=f"spectroscopy_diodes-{colour}" + ) + + response = client.context.http_client.get( + "/api/v1/binned/gap", params={"width": 2, "height": 2} + ) + + assert response.status_code == 200, response.text + body = response.json() + assert body["n_points"] == 3 + assert body["RedTotal"] == [[0.0, 1.0], [None, 3.0]] + assert body["DiodesRed"] == [[10.0, 11.0], [None, None]]