From a2dc56f3b3708521b82f56a67829fefbfee4dd85 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 24 Aug 2026 10:32:20 +0100 Subject: [PATCH 1/2] test: pin the polygon phasor cursor The polygon path has been reachable since #6 and had no tests. These cover selection, coexistence with an ellipse, the photon floor, the vertex minimum, labelling and the reported lifetimes. Co-Authored-By: Claude Opus 5 --- tests/test_phasor.py | 113 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/tests/test_phasor.py b/tests/test_phasor.py index 033b6f5..4afb189 100644 --- a/tests/test_phasor.py +++ b/tests/test_phasor.py @@ -382,3 +382,116 @@ def test_phasor_settings_are_served(serve_state): assert payload['values']['phasor_filter'] == 'none' keys = [entry['key'] for entry in payload['schema']] assert 'phasor_filter' in keys and 'irf' in keys + + +def test_a_polygon_selects_one_population(two_populations): + real, imag, mean = two_populations + + masks = phasor.cursor_masks( + real, imag, mean, + cursors=[{'id': 'p1', 'type': 'polygon', + 'vertices': [[0.25, 0.35], [0.35, 0.35], + [0.35, 0.45], [0.25, 0.45]]}], + min_photons=1.0) + + mask = masks['p1'] + assert mask[:8].all() + assert not mask[8:].any() + + +def test_a_polygon_and_an_ellipse_share_one_request(two_populations): + real, imag, mean = two_populations + + masks = phasor.cursor_masks( + real, imag, mean, + cursors=[ + {'id': 'p1', 'type': 'polygon', + 'vertices': [[0.25, 0.35], [0.35, 0.35], + [0.35, 0.45], [0.25, 0.45]]}, + {'id': 'e1', 'center_g': 0.70, 'center_s': 0.35, 'radius': 0.05}, + ], + min_photons=1.0) + + assert masks['p1'].sum() == 128 + assert masks['e1'].sum() == 128 + assert not (masks['p1'] & masks['e1']).any() + + +def test_a_polygon_drops_low_photon_pixels(two_populations): + real, imag, mean = two_populations + mean = mean.copy() + mean[0, :] = 0.0 + + masks = phasor.cursor_masks( + real, imag, mean, + cursors=[{'id': 'p1', 'type': 'polygon', + 'vertices': [[0.25, 0.35], [0.35, 0.35], + [0.35, 0.45], [0.25, 0.45]]}], + min_photons=10.0) + + assert not masks['p1'][0].any() + assert masks['p1'][1:8].all() + + +def test_a_polygon_needs_three_vertices(two_populations): + real, imag, mean = two_populations + + with pytest.raises(ValueError, match='three vertices'): + phasor.cursor_masks( + real, imag, mean, + cursors=[{'id': 'p1', 'type': 'polygon', + 'vertices': [[0.25, 0.35], [0.35, 0.45]]}], + min_photons=1.0) + + +def test_a_polygon_with_no_vertices_is_rejected(two_populations): + real, imag, mean = two_populations + + with pytest.raises(ValueError, match='three vertices'): + phasor.cursor_masks( + real, imag, mean, + cursors=[{'id': 'p1', 'type': 'polygon'}], + min_photons=1.0) + + +def test_an_unknown_cursor_type_is_rejected(two_populations): + real, imag, mean = two_populations + + with pytest.raises(ValueError, match='unknown cursor type'): + phasor.cursor_masks( + real, imag, mean, + cursors=[{'id': 'x1', 'type': 'freehand'}], + min_photons=1.0) + + +def test_a_polygon_gets_its_own_label(two_populations): + real, imag, mean = two_populations + + labels = phasor.label_image( + real, imag, mean, + cursors=[ + {'id': 'e1', 'center_g': 0.70, 'center_s': 0.35, 'radius': 0.05}, + {'id': 'p1', 'type': 'polygon', + 'vertices': [[0.25, 0.35], [0.35, 0.35], + [0.35, 0.45], [0.25, 0.45]]}, + ], + min_photons=1.0) + + assert set(np.unique(labels)) == {1, 2} + assert (labels[8:] == 1).all() + assert (labels[:8] == 2).all() + + +def test_a_polygon_reports_lifetimes_like_an_ellipse(two_populations): + real, imag, mean = two_populations + cursors = [{'id': 'p1', 'type': 'polygon', + 'vertices': [[0.25, 0.35], [0.35, 0.35], + [0.35, 0.45], [0.25, 0.45]]}] + + masks = phasor.cursor_masks(real, imag, mean, cursors, min_photons=1.0) + found = phasor.cursor_stats(real, imag, mean, masks, 80.0) + + assert len(found) == 1 + assert found[0]['n_pixels'] == 128 + assert found[0]['tau_phi_ns'] > 0 + assert found[0]['mean_g'] == pytest.approx(0.30, abs=0.01) From 20169c2dc73ac787242b3f672cb66e3a4da8323d Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 24 Aug 2026 13:45:40 +0100 Subject: [PATCH 2/2] test: take a polygon cursor through the mask endpoint The polygon path was covered in Python at the function level and in Java at the payload level, with nothing crossing between them. These go over HTTP: a polygon covering the plane selects every valid pixel, two halves partition them, the lifetimes come back, the label image agrees with the reported count, and a two-vertex polygon is a 400. Co-Authored-By: Claude Opus 5 --- tests/test_phasor_endpoints.py | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_phasor_endpoints.py b/tests/test_phasor_endpoints.py index b55830a..f57c580 100644 --- a/tests/test_phasor_endpoints.py +++ b/tests/test_phasor_endpoints.py @@ -161,3 +161,84 @@ def test_mask_options_ride_on_the_body(dataset): }) assert payload['options']['phasor_filter'] == 'median' + + +FULL_PLANE = [[-5.0, -5.0], [5.0, -5.0], [5.0, 5.0], [-5.0, 5.0]] +LEFT_HALF = [[-5.0, -5.0], [0.5, -5.0], [0.5, 5.0], [-5.0, 5.0]] +RIGHT_HALF = [[0.5, -5.0], [5.0, -5.0], [5.0, 5.0], [0.5, 5.0]] + + +def _mask(url, ident, cursors, min_photons=1.0, output=None): + body = {'cursors': cursors, 'min_photons': min_photons} + if output: + body['output'] = output + return _call(url, f'/v1/datasets/{ident}/phasor/mask', 'POST', body) + + +def test_a_polygon_covering_everything_selects_every_valid_pixel(dataset): + url, ident = dataset + points = _call(url, f'/v1/datasets/{ident}/phasor/points?bins=64&min_photons=1.0') + + payload = _mask(url, ident, [ + {'id': 'p1', 'type': 'polygon', 'vertices': FULL_PLANE}]) + + assert payload['cursors'][0]['n_pixels'] == points['n_valid'] + + +def test_two_polygons_partition_the_valid_pixels(dataset): + url, ident = dataset + points = _call(url, f'/v1/datasets/{ident}/phasor/points?bins=64&min_photons=1.0') + + payload = _mask(url, ident, [ + {'id': 'left', 'type': 'polygon', 'vertices': LEFT_HALF}, + {'id': 'right', 'type': 'polygon', 'vertices': RIGHT_HALF}]) + + counts = {entry['id']: entry['n_pixels'] for entry in payload['cursors']} + assert counts['left'] + counts['right'] == points['n_valid'] + + +def test_a_polygon_reports_a_lifetime(dataset): + url, ident = dataset + + payload = _mask(url, ident, [ + {'id': 'p1', 'type': 'polygon', 'vertices': FULL_PLANE}]) + + entry = payload['cursors'][0] + assert entry['n_pixels'] > 0 + assert entry['tau_phi_ns'] is not None + assert entry['photons'] > 0 + + +def test_a_polygon_becomes_a_label_image(dataset): + url, ident = dataset + + payload = _mask(url, ident, [ + {'id': 'p1', 'type': 'polygon', 'vertices': FULL_PLANE}], + output='labels') + + labels = np.frombuffer(base64.b64decode(payload['labels']), dtype=np.uint8) + assert labels.size == payload['width'] * payload['height'] + assert int((labels == 1).sum()) == payload['cursors'][0]['n_pixels'] + + +def test_a_polygon_and_an_ellipse_are_labelled_apart(dataset): + url, ident = dataset + + payload = _mask(url, ident, [ + {'id': 'e1', 'center_g': 0.5, 'center_s': 0.3, 'radius': 0.1}, + {'id': 'p1', 'type': 'polygon', 'vertices': FULL_PLANE}], + output='labels') + + labels = np.frombuffer(base64.b64decode(payload['labels']), dtype=np.uint8) + assert set(np.unique(labels)) <= {0, 1, 2} + assert int((labels == 1).sum()) == payload['cursors'][0]['n_pixels'] + + +def test_a_polygon_with_two_vertices_is_a_client_error(dataset): + url, ident = dataset + + with pytest.raises(HTTPError) as raised: + _mask(url, ident, [ + {'id': 'p1', 'type': 'polygon', 'vertices': [[0.2, 0.3], [0.4, 0.5]]}]) + + assert raised.value.code == 400