Skip to content

Commit a89d901

Browse files
author
Wood Cell Dev
committed
Add ring boundary summary analytics
1 parent faef092 commit a89d901

3 files changed

Lines changed: 146 additions & 0 deletions

File tree

ENGINEERING_LOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,8 @@
400400

401401
## 2025-09-22 20:48 UTC
402402
- Documented dendro format coverage and reader locations in `docs/reference/dendro_format_support.md` for quick parity auditing.
403+
404+
## 2025-09-22 21:05 UTC
405+
- Shipped LiPD reader capable of extracting measurement tables and wired it into the dendro importer (`utils/dendro/formats/lipd.py`, `utils/dendro/importer.py`).
406+
- Extended dendro import API with binary support plus regression for CaTRAS and LiPD (`tests/test_api_v2.py`), added LiPD fixture (`tests/data/lipd_sample.b64`).
407+
- Delivered ring-boundary metrics endpoint with per-ring area/perimeter summaries to close WinCELL analytics gap (`scripts/api_v2.py`, `tests/test_api_v2.py::test_ring_boundary_summary_endpoint`).

scripts/api_v2.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3096,6 +3096,34 @@ class RingBoundaryResponse(BaseModel):
30963096
metadata: Dict[str, Any] = Field(default_factory=dict)
30973097

30983098

3099+
class RingBoundaryMetricsEntry(BaseModel):
3100+
ring_id: str
3101+
ring_index: int
3102+
area_pixels: Optional[float] = None
3103+
area_square_microns: Optional[float] = None
3104+
perimeter_pixels: Optional[float] = None
3105+
perimeter_microns: Optional[float] = None
3106+
radius_pixels: Optional[float] = None
3107+
radius_microns: Optional[float] = None
3108+
metadata: Dict[str, Any] = Field(default_factory=dict)
3109+
3110+
3111+
class RingBoundaryMetricsSummary(BaseModel):
3112+
total_boundaries: int
3113+
boundaries_with_geometry: int
3114+
average_area_square_microns: Optional[float] = None
3115+
average_radius_microns: Optional[float] = None
3116+
average_perimeter_microns: Optional[float] = None
3117+
total_area_square_microns: Optional[float] = None
3118+
3119+
3120+
class RingBoundaryMetricsResponse(BaseModel):
3121+
image_id: str
3122+
pixels_per_micron: Optional[float] = None
3123+
boundaries: List[RingBoundaryMetricsEntry] = Field(default_factory=list)
3124+
summary: RingBoundaryMetricsSummary
3125+
3126+
30993127
class RingDetectionRequest(BaseModel):
31003128
min_area: float = Field(500.0, ge=10.0)
31013129
max_area: Optional[float] = Field(default=None, ge=10.0)
@@ -9218,6 +9246,81 @@ async def list_ring_boundaries(project_id: str, image_id: str):
92189246
return responses
92199247

92209248

9249+
@app.get(
9250+
"/api/v2/projects/{project_id}/images/{image_id}/ring-boundaries/summary",
9251+
response_model=RingBoundaryMetricsResponse
9252+
)
9253+
async def summarize_ring_boundaries(project_id: str, image_id: str):
9254+
image_record = await _get_project_image(project_id, image_id)
9255+
boundaries = await ring_boundary_store.list(image_id=image_id)
9256+
9257+
filtered = [
9258+
boundary for boundary in boundaries
9259+
if boundary.get('project_id') == project_id
9260+
]
9261+
9262+
pixels_per_micron = await _pixels_per_micron_for_image(image_record)
9263+
width, height = _resolve_image_dimensions(image_record)
9264+
9265+
entries: List[RingBoundaryMetricsEntry] = []
9266+
geometry_count = 0
9267+
area_microns_sum = 0.0
9268+
radius_microns_sum = 0.0
9269+
perimeter_microns_sum = 0.0
9270+
9271+
for record in filtered:
9272+
original_points = record.get('boundary_path') or []
9273+
absolute_points = _denormalize_points(original_points, width, height)
9274+
metric_payload = {
9275+
'boundary_path': [{'x': x, 'y': y} for x, y in absolute_points]
9276+
}
9277+
metrics = _ring_boundary_metrics(metric_payload, pixels_per_micron=pixels_per_micron)
9278+
9279+
area_pixels = metrics.get('area_pixels')
9280+
area_square_microns = metrics.get('area_square_microns')
9281+
radius_microns = metrics.get('radius_microns')
9282+
perimeter_microns = metrics.get('perimeter_microns')
9283+
9284+
if area_pixels:
9285+
geometry_count += 1
9286+
if area_square_microns:
9287+
area_microns_sum += area_square_microns
9288+
if radius_microns:
9289+
radius_microns_sum += radius_microns
9290+
if perimeter_microns:
9291+
perimeter_microns_sum += perimeter_microns
9292+
9293+
entries.append(
9294+
RingBoundaryMetricsEntry(
9295+
ring_id=record['id'],
9296+
ring_index=record['ring_index'],
9297+
area_pixels=area_pixels,
9298+
area_square_microns=area_square_microns,
9299+
perimeter_pixels=metrics.get('perimeter_pixels'),
9300+
perimeter_microns=perimeter_microns,
9301+
radius_pixels=metrics.get('radius_pixels'),
9302+
radius_microns=radius_microns,
9303+
metadata=record.get('metadata') or {}
9304+
)
9305+
)
9306+
9307+
summary = RingBoundaryMetricsSummary(
9308+
total_boundaries=len(filtered),
9309+
boundaries_with_geometry=geometry_count,
9310+
average_area_square_microns=(area_microns_sum / geometry_count if geometry_count and area_microns_sum else None),
9311+
average_radius_microns=(radius_microns_sum / geometry_count if geometry_count and radius_microns_sum else None),
9312+
average_perimeter_microns=(perimeter_microns_sum / geometry_count if geometry_count and perimeter_microns_sum else None),
9313+
total_area_square_microns=area_microns_sum if geometry_count and area_microns_sum else None,
9314+
)
9315+
9316+
return RingBoundaryMetricsResponse(
9317+
image_id=image_id,
9318+
pixels_per_micron=pixels_per_micron,
9319+
boundaries=entries,
9320+
summary=summary,
9321+
)
9322+
9323+
92219324
@app.post(
92229325
"/api/v2/projects/{project_id}/images/{image_id}/ring-boundaries/detect",
92239326
response_model=RingDetectionResponse

tests/test_api_v2.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from fastapi.testclient import TestClient
1919
from PIL import Image, ImageDraw
2020
import zipfile
21+
import pytest
2122
import jwt
2223

2324
from scripts import api_v2
@@ -252,6 +253,43 @@ def test_dendro_import_endpoint_parses_lipd():
252253
assert payload['series']
253254

254255

256+
def test_ring_boundary_summary_endpoint():
257+
project_response = client.post('/api/v2/projects', json={'name': 'Ring Metrics Project'})
258+
assert project_response.status_code == 200
259+
project = project_response.json()
260+
261+
link_response = client.post(
262+
f"/api/v2/projects/{project['id']}/images/link-dataset",
263+
json={'dataset_id': 'test-dataset', 'split': 'train', 'image_name': 'sample.png'}
264+
)
265+
assert link_response.status_code == 201
266+
project_image = link_response.json()
267+
268+
boundary_payload = {
269+
'ring_index': 0,
270+
'boundary_path': [
271+
{'x': 0.0, 'y': 0.0},
272+
{'x': 1.0, 'y': 0.0},
273+
{'x': 1.0, 'y': 1.0},
274+
{'x': 0.0, 'y': 1.0},
275+
]
276+
}
277+
create_response = client.post(
278+
f"/api/v2/projects/{project['id']}/images/{project_image['id']}/ring-boundaries",
279+
json=boundary_payload
280+
)
281+
assert create_response.status_code == 201
282+
283+
summary_response = client.get(
284+
f"/api/v2/projects/{project['id']}/images/{project_image['id']}/ring-boundaries/summary"
285+
)
286+
assert summary_response.status_code == 200
287+
payload = summary_response.json()
288+
assert payload['summary']['total_boundaries'] == 1
289+
metrics = payload['boundaries'][0]
290+
assert pytest.approx(metrics['area_pixels'], rel=1e-3) == 16384.0
291+
292+
255293
def test_measurement_path_update_flow():
256294
project_response = client.post('/api/v2/projects', json={'name': 'Measurement Project'})
257295
assert project_response.status_code == 200

0 commit comments

Comments
 (0)