From 4bbf77445616d847637456b4cff529a28a0322bd Mon Sep 17 00:00:00 2001 From: d33bs Date: Sat, 4 Jul 2026 22:48:12 -0600 Subject: [PATCH 1/5] add label and shape support --- README.md | 52 ++ docs/src/_static/generate_schema_image.py | 335 ++++++++++++ docs/src/_static/various_ome_arrow_schema.png | Bin 37564 -> 51268 bytes docs/src/index.md | 1 + docs/src/python-api.md | 19 + docs/src/shapes.md | 199 +++++++ src/ome_arrow/__init__.py | 12 + src/ome_arrow/shapes.py | 489 ++++++++++++++++++ tests/test_shapes.py | 152 ++++++ tests/test_shapes_performance.py | 52 ++ 10 files changed, 1311 insertions(+) create mode 100644 docs/src/_static/generate_schema_image.py create mode 100644 docs/src/shapes.md create mode 100644 src/ome_arrow/shapes.py create mode 100644 tests/test_shapes.py create mode 100644 tests/test_shapes_performance.py diff --git a/README.md b/README.md index 4804393..e4a4894 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,58 @@ The writer preserves source pixel dtype by default. To normalize stored pixel buffers explicitly, pass `pixel_dtype`, for example `pixel_dtype="uint16"`. Integer casts clamp by default; pass `clamp=False` to use NumPy casting behavior directly. +## Shape tables + +OME-Zarr and OME-NGFF are strong fits for dense image pixels and label rasters. +The gap is object-level analytics: cells, nuclei, ROIs, detections, tracks, +measurements, and relationships are naturally queried as tables. + +OME-Arrow Shapes fills that gap without replacing OME-Zarr. +Each row is one biological object, geometry is a single logical column, dense +label masks remain canonical label images, and object rows reference labels with +`label_image_id` plus `label_value`. +Measurements remain ordinary Arrow columns for DuckDB, Polars, DataFusion, +PyArrow, and Parquet workflows. + +```python +from ome_arrow import make_relationship_table, make_shape_table + +shapes = make_shape_table( + [ + { + "object_id": "cell-1", + "image_id": "image-1", + "label_image_id": "labels-1", + "label_value": 7, + "geometry": [128.0, 256.0], + "centroid": [128.0, 256.0], + "class": "cell", + "area_um2": 84.2, + } + ], + geometry_encoding="geoarrow.point", + axes=("y", "x"), + units=("pixel", "pixel"), +) + +relationships = make_relationship_table( + [ + { + "parent_id": "cell-1", + "child_id": "nucleus-1", + "relationship_type": "contains", + } + ] +) +``` + +Measurements remain normal Arrow columns (`area_um2` above). +Label masks are represented by reference with `label_image_id` and +`label_value`, keeping the segmentation raster canonical instead of embedding +mask payloads in each object row. + +See the dedicated Shapes docs: [`docs/src/shapes.md`](docs/src/shapes.md). + ## Tensor ingest (PyTorch/JAX) You can ingest torch or JAX arrays directly with `OMEArrow(...)`. diff --git a/docs/src/_static/generate_schema_image.py b/docs/src/_static/generate_schema_image.py new file mode 100644 index 0000000..a1736d5 --- /dev/null +++ b/docs/src/_static/generate_schema_image.py @@ -0,0 +1,335 @@ +"""Generate the README schema example image.""" + +from __future__ import annotations + +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +SCALE = 2 +OUT = Path(__file__).with_name("various_ome_arrow_schema.png") + + +def _font(size: int, *, bold: bool = False) -> ImageFont.FreeTypeFont: + """Load a readable system font with a Pillow fallback.""" + candidates = ( + "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "", + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/Library/Fonts/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ) + for candidate in candidates: + if candidate and Path(candidate).exists(): + return ImageFont.truetype(candidate, size * SCALE) + return ImageFont.load_default(size * SCALE) + + +def _text_center( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + text: str, + font: ImageFont.ImageFont, + *, + fill: str = "#2b2f33", +) -> None: + """Draw centered text inside a box.""" + x0, y0, x1, y1 = (v * SCALE for v in box) + bbox = draw.multiline_textbbox((0, 0), text, font=font, spacing=7 * SCALE) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + draw.multiline_text( + (x0 + (x1 - x0 - tw) / 2, y0 + (y1 - y0 - th) / 2), + text, + font=font, + fill=fill, + align="center", + spacing=7 * SCALE, + ) + + +def _rounded_rect( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + *, + fill: str, + outline: str, + width: int = 2, + radius: int = 8, +) -> None: + """Draw a scaled rounded rectangle.""" + draw.rounded_rectangle( + tuple(v * SCALE for v in box), + radius=radius * SCALE, + fill=fill, + outline=outline, + width=width * SCALE, + ) + + +def _table( + draw: ImageDraw.ImageDraw, + x: int, + y: int, + col_widths: tuple[int, ...], + rows: int, + *, + headers: tuple[str, ...], + row_h: int = 76, + header_h: int = 48, +) -> list[list[tuple[int, int, int, int]]]: + """Draw a light table and return body cell boxes.""" + border = "#cfd5db" + total_w = sum(col_widths) + total_h = header_h + rows * row_h + draw.rectangle( + (x * SCALE, y * SCALE, (x + total_w) * SCALE, (y + total_h) * SCALE), + fill="#ffffff", + outline=border, + width=6 * SCALE, + ) + + cx = x + header_font = _font(17) + for idx, width in enumerate(col_widths): + if idx: + draw.line( + ( + cx * SCALE, + y * SCALE, + cx * SCALE, + (y + total_h) * SCALE, + ), + fill=border, + width=6 * SCALE, + ) + _text_center(draw, (cx, y, cx + width, y + header_h), headers[idx], header_font) + cx += width + + draw.line( + ( + x * SCALE, + (y + header_h) * SCALE, + (x + total_w) * SCALE, + (y + header_h) * SCALE, + ), + fill=border, + width=6 * SCALE, + ) + for row in range(1, rows): + yy = y + header_h + row * row_h + draw.line( + (x * SCALE, yy * SCALE, (x + total_w) * SCALE, yy * SCALE), + fill=border, + width=6 * SCALE, + ) + + cells: list[list[tuple[int, int, int, int]]] = [] + for row in range(rows): + row_cells = [] + cx = x + for width in col_widths: + row_cells.append( + ( + cx, + y + header_h + row * row_h, + cx + width, + y + header_h + (row + 1) * row_h, + ) + ) + cx += width + cells.append(row_cells) + return cells + + +def _draw_measurement( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], +) -> None: + """Draw a compact measurement icon.""" + x0, y0, x1, y1 = box + cx = (x0 + x1) // 2 + cy = (y0 + y1) // 2 + _rounded_rect( + draw, + (cx - 20, cy - 18, cx + 20, cy + 18), + fill="#7ea2c5", + outline="#607f9f", + radius=7, + ) + font = _font(15, bold=True) + _text_center( + draw, + (cx - 20, cy - 19, cx + 20, cy + 18), + "12\n34", + font, + fill="#fff", + ) + + +def _draw_image_tile( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], +) -> None: + """Draw a small microscopy image tile.""" + x0, y0, x1, y1 = box + cx = (x0 + x1) // 2 + cy = (y0 + y1) // 2 + tile = (cx - 23, cy - 23, cx + 23, cy + 23) + draw.rectangle( + tuple(v * SCALE for v in tile), + fill="#f6c23e", + outline="#d98a1a", + width=3 * SCALE, + ) + draw.rectangle( + tuple((v + 5 if i < 2 else v - 5) * SCALE for i, v in enumerate(tile)), + fill="#fdf5c9", + outline="#f0ad2e", + width=2 * SCALE, + ) + points = [] + for step in range(26): + t = step / 25 + px = cx - 14 + int(t * 28) + py = cy + int(math.sin(t * math.tau * 1.5) * 9) + points.append((px * SCALE, py * SCALE)) + draw.line(points, fill="#1fba6d", width=4 * SCALE) + for px, py in ((cx - 9, cy - 5), (cx + 1, cy + 5), (cx + 10, cy - 3)): + draw.ellipse( + ((px - 2) * SCALE, (py - 2) * SCALE, (px + 2) * SCALE, (py + 2) * SCALE), + fill="#229e5a", + ) + + +def _draw_label_tile( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], +) -> None: + """Draw a compact label raster icon.""" + x0, y0, x1, y1 = box + cx = (x0 + x1) // 2 + cy = (y0 + y1) // 2 + tile = (cx - 23, cy - 23, cx + 23, cy + 23) + draw.rectangle( + tuple(v * SCALE for v in tile), + fill="#f6c23e", + outline="#d98a1a", + width=3 * SCALE, + ) + draw.rectangle( + tuple((v + 5 if i < 2 else v - 5) * SCALE for i, v in enumerate(tile)), + fill="#fdf5c9", + outline="#f0ad2e", + width=2 * SCALE, + ) + for dx, dy, color in ((-8, -4, "#56c2a4"), (4, 8, "#7bd389"), (9, -7, "#a5d76e")): + draw.ellipse( + ( + (cx + dx - 10) * SCALE, + (cy + dy - 8) * SCALE, + (cx + dx + 10) * SCALE, + (cy + dy + 8) * SCALE, + ), + fill=color, + outline="#258a5a", + width=2 * SCALE, + ) + + +def _draw_shape_icon( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], +) -> None: + """Draw a vector shape icon.""" + x0, y0, x1, y1 = box + cx = (x0 + x1) // 2 + cy = (y0 + y1) // 2 + polygon = [ + ((cx - 20) * SCALE, (cy - 3) * SCALE), + ((cx - 8) * SCALE, (cy - 19) * SCALE), + ((cx + 16) * SCALE, (cy - 13) * SCALE), + ((cx + 21) * SCALE, (cy + 11) * SCALE), + ((cx - 4) * SCALE, (cy + 19) * SCALE), + ] + draw.polygon(polygon, fill="#d8efff", outline="#2c7fb8") + draw.line(polygon + [polygon[0]], fill="#2c7fb8", width=3 * SCALE) + for px, py in ( + (cx - 20, cy - 3), + (cx - 8, cy - 19), + (cx + 16, cy - 13), + (cx + 21, cy + 11), + (cx - 4, cy + 19), + ): + draw.ellipse( + ((px - 3) * SCALE, (py - 3) * SCALE, (px + 3) * SCALE, (py + 3) * SCALE), + fill="#0f5c99", + ) + + +def main() -> None: + """Generate the image asset.""" + width, height = 1000, 252 + image = Image.new("RGBA", (width * SCALE, height * SCALE), "#ffffff") + draw = ImageDraw.Draw(image) + + left = _table( + draw, + 0, + 6, + (132, 118), + 2, + headers=("Measurements", "Images"), + row_h=80, + header_h=50, + ) + _draw_measurement(draw, left[0][0]) + _draw_image_tile(draw, left[0][1]) + _draw_measurement(draw, left[1][0]) + _draw_image_tile(draw, left[1][1]) + + _text_center(draw, (270, 96, 312, 142), "or", _font(18)) + + center = _table( + draw, + 335, + 6, + (105, 105, 105), + 2, + headers=("Images", "Labels", "Shapes"), + row_h=80, + header_h=50, + ) + for row in center: + _draw_image_tile(draw, row[0]) + _draw_label_tile(draw, row[1]) + _draw_shape_icon(draw, row[2]) + + _text_center(draw, (670, 96, 712, 142), "or", _font(18)) + + draw.rectangle( + (745 * SCALE, 6 * SCALE, 995 * SCALE, 246 * SCALE), + fill="#ffffff", + outline="#cfd5db", + width=6 * SCALE, + ) + _text_center(draw, (768, 44, 972, 126), "Your\nschema\nhere!", _font(23)) + chip_font = _font(12, bold=True) + chips = [ + (770, 155, 828, 181, "images", "#dff2ff", "#2c7fb8"), + (836, 155, 892, 181, "labels", "#eaf8dc", "#4e9a33"), + (900, 155, 962, 181, "shapes", "#e8efff", "#4569b2"), + (804, 192, 930, 218, "measurements", "#edf1f5", "#61707f"), + ] + for x0, y0, x1, y1, label, fill, outline in chips: + _rounded_rect(draw, (x0, y0, x1, y1), fill=fill, outline=outline, radius=6) + _text_center(draw, (x0, y0, x1, y1), label, chip_font, fill="#25313b") + + image = image.resize((width, height), Image.Resampling.LANCZOS) + image.save(OUT) + + +if __name__ == "__main__": + main() diff --git a/docs/src/_static/various_ome_arrow_schema.png b/docs/src/_static/various_ome_arrow_schema.png index 22f1672fe44ba0c6767090e6c567d0a791790e59..12eca9674a46a81133f9e428968afe2228e07a5b 100644 GIT binary patch literal 51268 zcmbrlWl)<@(8t^2?o!;{i(7Cn?oM%cmr$%&v0|mT6(|ziAxME1cXtgMDDF3X{looo z=gwp@gk;Y1ob&A2-Sgky#%ic3V4{(ty?OHnQ%O-)>&+W@2<-1C6eQUDE=JWAF zm1L!K{Burn5rYZr@CWm~VuPd=-OE~?E=U`n9cK#~OG^oswfy}2iq8m+G?>*q{QRO@ z^G0s7+fF=Cl~4%2s=R-_QLJxi>1cs=|Lx?3ei=T#4x~W|r~a>(bb3+E@(lX_d=8h4 zkt9Zt!uiivB()?$`+xs5oEqUR?IhxVe}_PWGTAPk_p=*G3TK?hF&&LHUHRAnh+$wesZq|pN2@whzX-|#k=$^k&&yEY|h5thRpE*k7 z6mT44dc;aP(o0_SEp4&W0dI@l`YAKknR@%=m}of<0gIw}~eB zqy=L~D|PIz@*HYJ{DtgM!!pa`KjoGiHWh}Mg;E!&e6Hp{lDMhYrVmSBY=>@DuC!ehLIYkZH0EEsn5~; zn@nywP%#TtdaMRi-(&D!4;J=#!{6XS{%1+TaS;ALJa?Qh&i}O_sZS{0|6>zUC~&d` z|7%UsIMi^aC?cI^Twnw#Ax$ROThOzLA;0p`cbHXa%)zc1j+L%Fz$JyFHj4_gBl4CN z)2dKSLKilc-=#JG9@(67fwtYgXWJ8nse`{*p7t3X%ATfuU5{`0h=_=* z&1k)tvEoOsl}GW6F!HEkYfQ^Ruq$w*RA3Q;5ydKY!+Dl-FX?qWCibx zW7A7w4d1NeHOnpTizvTKZ4E}UJ(kFI$ZR#Si0^4^Jl8W7;K833-E)sJsME~E zW6a0@aBd+aRK*nk-@CJR-4ek^%TOLTs`q43|S_TxD`V0m$& ztse*QZK8ET`C~huf+_h}lI_c&N)svD1Gsn2x#uTB_hty)6OU;gU^sCJ2=A4j5?8Y2 zkY`5#xBmLQvR6Jq4xl{jOLNNQ^UqUlh zy84R260h4MtwDXyC_LF7GcjcDBjqXO**RbA%>7Y!pl#5|fz@Lk zY1ip|A%J!$i)YL-zzpVXkArGODkSe|#La%biY4#{k_dq?pZUYt-GN_jL^~e_jUg*# z*fn<>Hb9z+0&j~wLqs^yw)z`3s1O6FqVXC2w&M4T4F$OIIXGJlu)ieK{r7fQx3Wzv zFZ9ge+ZXv)J62C*U@O5TURMM~8`spJ`Ss z3mWTyQgQQe_aAzOrGT`XRn`fs&E}rwFyOty!b&SnyycCDCx=XLQ+OUCWLhP3IvzaF@EiJ8DC6qm&im*~|C+onh zFa5Oxqp+}WFAw}9{mq{f=NHqM#NDgW;;U5Axnyn{Fu6NiZPulXD-<7xTbE=*AE zsYgjLTRvL=CtfaT6Dj!VMwE!dtZ%!s>X`j>aqcZ=#2Yqv%$j!Jv&bK+*_&ngWwlKa zNII(7d|ObVYv`en`zFqE&H`FU;O$?kh+hFlfg(g4M&VXNNtYHC0S&g0wGL!v*<`Jo zE2BD3LcMCUi1S)*6R+!ZgDzhR-?P;yG7vZ;N#RjrX)H^WO4RRE-oSwQ;rcWpEv^0B zzkD=g=+F3g?@61NhZqONlx7NKt&&OWXujGbT(;bV0EA`v{5ip(&XPfXBak-(WbkLg zrE69PgXBGqNHrF0w7%5{-2Rhq=o#q=f?i<1iBdHd=(la6VPquj3cRs!Dw_VR zNhUpUF>2V5l9v*8w$@%`+97b~y-oaX5=`}7z~RKr$rrj5FBeZ-Tszqp-WUqLj8{dP zblN+bGib1-aC2JkBzd?w)3rJdHv=gI2a91K$si8TpeOujZAWjc(YhZP8oGy;O*g0V zXFhzA96r(~7?m4h1({dYJj)p}Pi$T^^1ZlHi281x#xbFE2|7at{2vn*T3>J+n?B$2 zlP0*gn|%8_0{T7vXCR>3xX^Ecav;&z%v9^{X#_OcW~IRAF#S$K^*cuNOl;m-N1@NL z9+PU0k|`d{@r)}L7`q72GcXi=>__@05Q4LM&bA)DS}5$1S<1{_Qg$V6M?;dKqrbPtISd}t(=bAL zkGL@^DoX54(Zuvi`zJ%MKo(9XzDYDOKm|5A*k`Jl%lrSceIJoRY<~D9zclxW( z&&N&gb_>5nTv9wwOK?%sE1EZ8S$@H-Y57jXzM#y6X)XIZ9`dK*&3<*jK)cVe$JHWF zm1#>JvhWm@{C0%j#^9~QOJr8VzSdMxK`o9t;09$du`L10AYP0O_$!oy#ROKjQMU0L z)79Tj&^P~^ITp_aj>DFCA)uk535Eh+FS#fSC`7#937QDzB*}r3C_=I4t4v28Q`ojI zxw^tL^FoRAO@J&^(Fo6D#X}aaVG-QlzdT`RlXSiet2iUF2~!hA&Pd@-(*NEFJ`gV{ zwcE^wb{`z@^77)hBy|&Z1zq=qV2fd74n>@tI0s*=ssumI*_EfJ{lYKDKMO_i+8?v) zm7l1cvy{`(p$FWbhg?C{)04nHD*~&5kGYHB*EcOP(9Tzynzkdfr@NU6#h4_!Ftv#b zJM-WHAB9panX;Lpf`ViwyN#d&Oj&u^bDyrK<>R}}V)bVin$wxrr*8k(wHE)c@#QSx z=icGJ?!{bmN^fk!Mu^L+%bH_u^dHi`Za?WfD?3PSrvYnzToZ>ePw2)Ijin^xOt_wr zHX)gIcyD)We_UN$fd}kGnUxLYi3h;~nxN(L1E!d3tr2_c>Admz?oQHO=10|5Xz%-@ z=^)5d7g^>SGF3=8F#}liCqr=UBpG?vnh~p+3zfovg>gx>%d%A z&DAxfX^}}Sv)Uk*OkpjqM*N|`w9Hi~bQ974zDY1S-+Ys}cBR>EsC=rW*s3KDve<-N zLkbi(zDz&9yKa$~Jb02)R98O$r~CBnoOKi9pM~_iiZkPk4&?(sM^grnV`ew@q^e5{ z4G9Hay`v0xQu2nbC4thzheyrN-Rk~)W~(7hqypyr(mP393oI}Ob^HI#I%sVu4SB?D z$H{HK`Gra6Hke2u`g~AgmF7c5ph%9@aiTn$4Su6<62bvpaGON*KX}IWrwv20yIp;J z)BU=|bYpG5+DQm}+`$IiDCB>T-FO>uzkYovfieNULj#qef1%S@D+n^;Vjv8ixI>0{ z+QD|+5uWQ?a|?kiLktCZdCNb2ZFhd_>{mxq5!I)B+(l9cl^}4GtEq_9*(kW~;}}|% zX&c7vx)(9Oh5p1`adkg4U6m=b%j=HQ1z5v_LH=^!y8N|%a>VOpnWRd`h z{R&uC)7Tr9{om6K_}m0IsRRhHB_3KO{E62>NfEe=`KXKqt9o-nU*w~)PW?M#ytbTi zNl1F}6IC*~h9)F{(Yh+cAD?oqF0NZPE_q+aSKKU~f7;wZy8LPbMu#HwCoCVqBv2t_c17ZV|RTqWdn8Xv#$CckJ(Rkl9&ZObNJ#Hhx~?<&0p%UqjGKELR#h9mT&%U z<~zV|Q14A*j&4Yz*qT9ZF$uI5XGpVBzW?S^*cu?o(Jn{Cr?5jRG zmGfQRiN`f;FvkIa*GY_SmDl_~^%2*v-g>sI&;mTIcb!dv3Cmd+Jr!Q z`uh6L7iJxT*~?Z2B6?J_1sjZQx&kZiE>=K>5R|^9a9QLg8SHhzuw!X^1Ln`>UW7rh zCIJI`?x~5zc|}YO;Vej)14+IdNXLcqSdCZ@9aK^&e*# z#U5vOAk%I5#y%BYOero+h9SH)Z-6fWXy5r_9u=L)BlSBg7s$ z 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 IrMwlYj}^hTcPO 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 diff --git a/docs/src/index.md b/docs/src/index.md index cf5e62b..e797295 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -14,6 +14,7 @@ maxdepth: 3 --- python-api dlpack +shapes why-ome-arrow presentations examples/learning_to_fly_with_ome-arrow diff --git a/docs/src/python-api.md b/docs/src/python-api.md index 8e110dd..bea642e 100644 --- a/docs/src/python-api.md +++ b/docs/src/python-api.md @@ -7,6 +7,16 @@ ome_arrow :members: :undoc-members: :show-inheritance: + :exclude-members: OME_ARROW_RELATIONSHIPS_METADATA_KEY, + OME_ARROW_SHAPES_METADATA_KEY, + OMEArrowShapes, + geometry_storage_type, + make_relationship_table, + make_shape_table, + relationship_schema, + shape_schema, + validate_relationship_table, + validate_shape_table ``` ```{eval-rst} @@ -46,6 +56,15 @@ ome_arrow.meta :show-inheritance: ``` +```{eval-rst} +ome_arrow.shapes +------------------- +.. automodule:: ome_arrow.shapes + :members: + :undoc-members: + :show-inheritance: +``` + ```{eval-rst} ome_arrow.tensor ------------------- diff --git a/docs/src/shapes.md b/docs/src/shapes.md new file mode 100644 index 0000000..f40e3dd --- /dev/null +++ b/docs/src/shapes.md @@ -0,0 +1,199 @@ +# OME-Arrow Shapes + +OME-Arrow Shapes is an Arrow-native convention for bioimaging object tables. +It is intended to complement OME-Zarr and OME-NGFF, not replace them. + +The core model is: + +- one row is one biological object +- geometry is one logical column +- measurements are ordinary Arrow columns +- dense label rasters remain canonical OME-Zarr labels +- shape rows reference labels instead of duplicating mask payloads +- coordinate metadata follows OME axis, unit, and coordinate-space concepts + +## The Gap + +OME has strong, mature conventions for image pixels and label images. +OME-Zarr and OME-NGFF are a natural fit for dense multiscale arrays, including +segmentation rasters. + +Object tables sit in a different access pattern. +Common bioimage analysis needs to query, filter, join, and aggregate objects: + +- load all cell centroids +- filter detections by class or confidence +- join cells to nuclei +- compute population summaries +- associate each object with a source image and label value +- use DuckDB, Polars, DataFusion, or PyArrow without unpacking image arrays + +Those workflows are columnar and relational. +They benefit from Arrow and Parquet, but still need OME-compatible semantics for +axes, units, labels, provenance, and relationships. + +OME-Arrow Shapes fills that table-shaped gap. + +## Alignment With OME + +OME-Arrow Shapes stays with OME's existing direction in several ways. + +Images stay dense. +Pixels should remain in OME-Zarr, OME-TIFF, or OME-Arrow image storage. + +Labels stay dense. +Segmentation masks should remain canonical label rasters. +Shape rows can reference label objects with `label_image_id` and `label_value`. + +Coordinates stay explicit. +Shape schemas store axes, units, coordinate space, and geometry encoding in Arrow +schema metadata. +This keeps table values interpretable without inventing a separate coordinate +system model. + +Measurements stay columnar. +Area, volume, intensity, texture, model scores, and morphology features are plain +Arrow columns. +No special nested measurement encoding is required. + +## Shape Schema + +Use `make_shape_table` to create a validated Arrow table. + +```python +from ome_arrow import make_shape_table + +shapes = make_shape_table( + [ + { + "object_id": "cell-1", + "image_id": "image-1", + "label_image_id": "labels-1", + "label_value": 7, + "geometry": [128.0, 256.0], + "centroid": [128.0, 256.0], + "class": "cell", + "confidence": 0.98, + "area_um2": 84.2, + } + ], + geometry_encoding="geoarrow.point", + axes=("y", "x"), + units=("pixel", "pixel"), + coordinate_space="pixel", +) +``` + +The canonical columns are: + +| Column | Purpose | +| --- | --- | +| `object_id` | stable object identifier | +| `image_id` | source image identifier | +| `label_image_id` | source label image identifier | +| `label_value` | integer label value in the label raster | +| `geometry` | one logical geometry value | +| `centroid` | coordinate vector for object center | +| `bbox` | min/max coordinate bounds | +| `class` | object class or annotation category | +| `confidence` | detection or classification confidence | + +Any additional columns are measurements. +They remain ordinary Arrow columns and can be queried directly. + +## Geometry Encodings + +The current registry includes: + +| Encoding | Storage intent | +| --- | --- | +| `geoarrow.point` | point coordinate vector | +| `geoarrow.linestring` | list of coordinate vectors | +| `geoarrow.polygon` | list of rings | +| `geoarrow.multipolygon` | list of polygons | +| `ome.labelmask` | label image reference | +| `ome.pointcloud` | list of coordinate vectors | +| `ome.boundingbox` | min/max coordinate vectors | +| `ome.mesh3d` | vertices and faces | + +The registry lets 2D shapes reuse GeoArrow-like nested Arrow layouts while +leaving room for bioimaging-specific geometry such as label masks, point clouds, +and meshes. + +## Label References + +For segmentation objects, prefer references over embedded masks. + +```python +from ome_arrow import make_shape_table + +objects = make_shape_table( + [ + { + "object_id": "nucleus-1", + "image_id": "image-1", + "label_image_id": "nuclear-labels", + "label_value": 42, + "geometry": { + "label_image_id": "nuclear-labels", + "label_value": 42, + }, + "class": "nucleus", + } + ], + geometry_encoding="ome.labelmask", +) +``` + +This avoids duplicating raster masks in every object row. +The label image remains the canonical segmentation, and the object table remains +small, queryable, and provenance-preserving. + +## Relationships + +Object relationships are also ordinary Arrow rows. + +```python +from ome_arrow import make_relationship_table + +relationships = make_relationship_table( + [ + { + "parent_id": "cell-1", + "child_id": "nucleus-1", + "relationship_type": "contains", + "confidence": 1.0, + } + ] +) +``` + +Supported relationship types are: + +- `contains` +- `adjacent` +- `touches` +- `parent` +- `track` +- `derived_from` + +## Performance Contract + +Shape tables are built on Arrow-native arrays and schema metadata. +The implementation avoids binary geometry blobs where nested Arrow types are +practical, which keeps column projection, filtering, and Parquet serialization +available to Arrow-compatible engines. + +The test suite includes pytest performance canaries for shape table construction +and image-id filtering. +They are intentionally lightweight regression checks rather than rigorous +hardware-specific benchmarks. + +Run them with: + +```sh +python -m pytest tests/test_shapes.py tests/test_shapes_performance.py +``` + +For deeper benchmark work, add workload-specific scripts under `benchmarks/` +that report medians over repeated runs and compare against checked-in baselines. diff --git a/src/ome_arrow/__init__.py b/src/ome_arrow/__init__.py index bd256a0..5f6259e 100644 --- a/src/ome_arrow/__init__.py +++ b/src/ome_arrow/__init__.py @@ -36,6 +36,18 @@ OME_ARROW_TAG_TYPE, OME_ARROW_TAG_VERSION, ) +from ome_arrow.shapes import ( + OME_ARROW_RELATIONSHIPS_METADATA_KEY, + OME_ARROW_SHAPES_METADATA_KEY, + OMEArrowShapes, + geometry_storage_type, + make_relationship_table, + make_shape_table, + relationship_schema, + shape_schema, + validate_relationship_table, + validate_shape_table, +) from ome_arrow.tensor import LazyTensorView, TensorView from ome_arrow.utils import describe_ome_arrow, verify_ome_arrow from ome_arrow.view import view_matplotlib, view_pyvista diff --git a/src/ome_arrow/shapes.py b/src/ome_arrow/shapes.py new file mode 100644 index 0000000..a9b73c6 --- /dev/null +++ b/src/ome_arrow/shapes.py @@ -0,0 +1,489 @@ +"""Arrow-native shape and relationship tables for OME-Arrow.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Iterable, Literal, Sequence + +import pyarrow as pa +import pyarrow.compute as pc + +from ome_arrow.meta import OME_ARROW_TAG_VERSION + +GeometryEncoding = Literal[ + "geoarrow.point", + "geoarrow.linestring", + "geoarrow.polygon", + "geoarrow.multipolygon", + "ome.mesh3d", + "ome.labelmask", + "ome.pointcloud", + "ome.boundingbox", +] + +SUPPORTED_GEOMETRY_ENCODINGS: frozenset[str] = frozenset( + { + "geoarrow.point", + "geoarrow.linestring", + "geoarrow.polygon", + "geoarrow.multipolygon", + "ome.mesh3d", + "ome.labelmask", + "ome.pointcloud", + "ome.boundingbox", + } +) + +OME_ARROW_SHAPES_METADATA_KEY = b"ome.arrow.shapes" +OME_ARROW_RELATIONSHIPS_METADATA_KEY = b"ome.arrow.relationships" + +DEFAULT_SHAPE_COLUMNS = { + "object_id", + "image_id", + "label_image_id", + "label_value", + "geometry", + "centroid", + "bbox", + "class", + "confidence", +} + +RELATIONSHIP_TYPES: frozenset[str] = frozenset( + { + "contains", + "adjacent", + "touches", + "parent", + "track", + "derived_from", + } +) + +RELATIONSHIP_SCHEMA = pa.schema( + [ + pa.field("parent_id", pa.string(), nullable=False), + pa.field("child_id", pa.string(), nullable=False), + pa.field("relationship_type", pa.string(), nullable=False), + pa.field("confidence", pa.float32()), + ] +) + + +def _coordinate_type(dimensions: int) -> pa.FixedSizeListType: + """Return the Arrow coordinate vector type for a geometry dimension.""" + if dimensions < 1: + raise ValueError("geometry dimensions must be at least 1.") + return pa.list_(pa.float64(), list_size=dimensions) + + +def geometry_storage_type( + geometry_encoding: GeometryEncoding | str, + *, + dimensions: int = 2, +) -> pa.DataType: + """Return the Arrow storage type for a registered geometry encoding. + + Args: + geometry_encoding: Registered OME-Arrow geometry encoding name. + dimensions: Number of coordinate dimensions for coordinate vectors. + + Returns: + Arrow data type for the logical geometry column. + + Raises: + ValueError: If the encoding is unknown or dimensions are invalid. + """ + if geometry_encoding not in SUPPORTED_GEOMETRY_ENCODINGS: + raise ValueError(f"Unsupported geometry_encoding: {geometry_encoding!r}.") + + coordinate = _coordinate_type(dimensions) + if geometry_encoding == "geoarrow.point": + return coordinate + if geometry_encoding in {"geoarrow.linestring", "ome.pointcloud"}: + return pa.list_(coordinate) + if geometry_encoding == "geoarrow.polygon": + return pa.list_(pa.list_(coordinate)) + if geometry_encoding == "geoarrow.multipolygon": + return pa.list_(pa.list_(pa.list_(coordinate))) + if geometry_encoding == "ome.boundingbox": + return pa.struct( + [ + pa.field("min", coordinate, nullable=False), + pa.field("max", coordinate, nullable=False), + ] + ) + if geometry_encoding == "ome.labelmask": + return pa.struct( + [ + pa.field("label_image_id", pa.string(), nullable=False), + pa.field("label_value", pa.int64(), nullable=False), + ] + ) + return pa.struct( + [ + pa.field("vertices", pa.list_(_coordinate_type(3)), nullable=False), + pa.field("faces", pa.list_(pa.list_(pa.int32())), nullable=False), + ] + ) + + +def shape_metadata( + *, + geometry_encoding: GeometryEncoding | str, + axes: Sequence[str] = ("y", "x"), + units: Sequence[str] | None = None, + coordinate_space: str = "pixel", + geometry_column: str = "geometry", +) -> dict[str, Any]: + """Build JSON-serializable schema metadata for a shape table. + + Args: + geometry_encoding: Registered OME-Arrow geometry encoding name. + axes: Coordinate axis names for geometry values. + units: Units aligned to axes. Defaults to ``"pixel"`` for each axis. + coordinate_space: Name of the coordinate space for geometry values. + geometry_column: Name of the logical geometry column. + + Returns: + Metadata dictionary stored under ``OME_ARROW_SHAPES_METADATA_KEY``. + + Raises: + ValueError: If the encoding, axes, or units are invalid. + """ + if geometry_encoding not in SUPPORTED_GEOMETRY_ENCODINGS: + raise ValueError(f"Unsupported geometry_encoding: {geometry_encoding!r}.") + if not axes: + raise ValueError("axes must contain at least one axis name.") + if units is None: + units = tuple("pixel" for _ in axes) + if len(units) != len(axes): + raise ValueError("units must have the same length as axes.") + + return { + "type": "ome.arrow.shapes", + "version": str(OME_ARROW_TAG_VERSION), + "geometry_column": geometry_column, + "geometry_encoding": str(geometry_encoding), + "axes": list(axes), + "units": list(units), + "coordinate_space": coordinate_space, + } + + +def _schema_with_json_metadata( + schema: pa.Schema, + *, + key: bytes, + payload: dict[str, Any], +) -> pa.Schema: + """Attach compact JSON metadata to an Arrow schema.""" + metadata = dict(schema.metadata or {}) + metadata[key] = json.dumps(payload, sort_keys=True).encode("utf-8") + return schema.with_metadata(metadata) + + +def shape_schema( + geometry_encoding: GeometryEncoding | str, + *, + axes: Sequence[str] = ("y", "x"), + units: Sequence[str] | None = None, + coordinate_space: str = "pixel", + geometry_column: str = "geometry", + measurement_fields: Iterable[pa.Field] | None = None, +) -> pa.Schema: + """Create an OME-Arrow shape table schema. + + Args: + geometry_encoding: Registered OME-Arrow geometry encoding name. + axes: Coordinate axis names for geometry, centroid, and bounding boxes. + units: Units aligned to axes. Defaults to ``"pixel"`` for each axis. + coordinate_space: Name of the coordinate space for geometry values. + geometry_column: Name of the logical geometry column. + measurement_fields: Extra Arrow fields for ordinary measurement columns. + + Returns: + Arrow schema with OME-Arrow shape metadata attached. + """ + metadata = shape_metadata( + geometry_encoding=geometry_encoding, + axes=axes, + units=units, + coordinate_space=coordinate_space, + geometry_column=geometry_column, + ) + coordinate = _coordinate_type(len(axes)) + fields = [ + pa.field("object_id", pa.string(), nullable=False), + pa.field("image_id", pa.string()), + pa.field("label_image_id", pa.string()), + pa.field("label_value", pa.int64()), + pa.field( + geometry_column, + geometry_storage_type(geometry_encoding, dimensions=len(axes)), + ), + pa.field("centroid", coordinate), + pa.field( + "bbox", + pa.struct( + [ + pa.field("min", coordinate, nullable=False), + pa.field("max", coordinate, nullable=False), + ] + ), + ), + pa.field("class", pa.string()), + pa.field("confidence", pa.float32()), + ] + if measurement_fields is not None: + fields.extend(measurement_fields) + + return _schema_with_json_metadata( + pa.schema(fields), + key=OME_ARROW_SHAPES_METADATA_KEY, + payload=metadata, + ) + + +def _infer_measurement_fields(rows: Sequence[dict[str, Any]]) -> list[pa.Field]: + """Infer measurement fields for columns outside the canonical shape columns.""" + if not rows: + return [] + + fields: list[pa.Field] = [] + row_columns = set().union(*(row.keys() for row in rows)) + for name in sorted(row_columns - DEFAULT_SHAPE_COLUMNS): + values = [row.get(name) for row in rows] + fields.append(pa.field(name, pa.array(values).type)) + return fields + + +def make_shape_table( + rows: Sequence[dict[str, Any]], + *, + geometry_encoding: GeometryEncoding | str, + axes: Sequence[str] = ("y", "x"), + units: Sequence[str] | None = None, + coordinate_space: str = "pixel", + geometry_column: str = "geometry", + validate: bool = True, +) -> pa.Table: + """Create an OME-Arrow shape table from Python row dictionaries. + + Args: + rows: Shape rows, where each row represents one biological object. + geometry_encoding: Registered OME-Arrow geometry encoding name. + axes: Coordinate axis names for geometry, centroid, and bounding boxes. + units: Units aligned to axes. Defaults to ``"pixel"`` for each axis. + coordinate_space: Name of the coordinate space for geometry values. + geometry_column: Name of the logical geometry column. + validate: Validate the table after construction. + + Returns: + Arrow table with OME-Arrow shape schema metadata. + """ + row_list = list(rows) + schema = shape_schema( + geometry_encoding, + axes=axes, + units=units, + coordinate_space=coordinate_space, + geometry_column=geometry_column, + measurement_fields=_infer_measurement_fields(row_list), + ) + table = pa.Table.from_pylist(row_list, schema=schema) + if validate: + validate_shape_table(table) + return table + + +def _shape_metadata_from_schema(schema: pa.Schema) -> dict[str, Any]: + """Read OME-Arrow shape JSON metadata from a schema.""" + raw_metadata = schema.metadata or {} + raw_payload = raw_metadata.get(OME_ARROW_SHAPES_METADATA_KEY) + if raw_payload is None: + raise ValueError("Shape table schema metadata is missing OME-Arrow shapes.") + metadata = json.loads(raw_payload.decode("utf-8")) + if metadata.get("type") != "ome.arrow.shapes": + raise ValueError("Shape table metadata type must be 'ome.arrow.shapes'.") + return metadata + + +def validate_shape_table(table: pa.Table) -> None: + """Validate an OME-Arrow shape table. + + Args: + table: Arrow table to validate. + + Raises: + ValueError: If required metadata, columns, encoding, or IDs are invalid. + """ + metadata = _shape_metadata_from_schema(table.schema) + geometry_column = metadata.get("geometry_column", "geometry") + geometry_encoding = metadata.get("geometry_encoding") + axes = metadata.get("axes", []) + + if geometry_encoding not in SUPPORTED_GEOMETRY_ENCODINGS: + raise ValueError(f"Unsupported geometry_encoding: {geometry_encoding!r}.") + if "object_id" not in table.column_names: + raise ValueError("Shape table must contain an object_id column.") + if geometry_column not in table.column_names: + raise ValueError(f"Shape table must contain {geometry_column!r} column.") + if table.schema.field(geometry_column).type != geometry_storage_type( + geometry_encoding, + dimensions=len(axes), + ): + raise ValueError("Shape table geometry column does not match metadata.") + if pc.any(pc.is_null(table["object_id"])).as_py(): + raise ValueError("Shape table object_id values must not be null.") + + +def relationship_metadata() -> dict[str, Any]: + """Build JSON-serializable schema metadata for object relationships.""" + return { + "type": "ome.arrow.relationships", + "version": str(OME_ARROW_TAG_VERSION), + "relationship_types": sorted(RELATIONSHIP_TYPES), + } + + +def relationship_schema() -> pa.Schema: + """Create an OME-Arrow relationship table schema. + + Returns: + Arrow schema with OME-Arrow relationship metadata attached. + """ + return _schema_with_json_metadata( + RELATIONSHIP_SCHEMA, + key=OME_ARROW_RELATIONSHIPS_METADATA_KEY, + payload=relationship_metadata(), + ) + + +def make_relationship_table( + rows: Sequence[dict[str, Any]], + *, + validate: bool = True, +) -> pa.Table: + """Create an OME-Arrow relationship table from edge rows. + + Args: + rows: Relationship rows with parent, child, and relationship type. + validate: Validate the table after construction. + + Returns: + Arrow table with OME-Arrow relationship metadata. + """ + table = pa.Table.from_pylist(list(rows), schema=relationship_schema()) + if validate: + validate_relationship_table(table) + return table + + +def validate_relationship_table(table: pa.Table) -> None: + """Validate an OME-Arrow relationship table. + + Args: + table: Arrow table to validate. + + Raises: + ValueError: If required metadata, columns, IDs, or relationship types fail. + """ + raw_payload = (table.schema.metadata or {}).get( + OME_ARROW_RELATIONSHIPS_METADATA_KEY + ) + if raw_payload is None: + raise ValueError( + "Relationship table schema metadata is missing OME-Arrow relationships." + ) + metadata = json.loads(raw_payload.decode("utf-8")) + if metadata.get("type") != "ome.arrow.relationships": + raise ValueError( + "Relationship table metadata type must be 'ome.arrow.relationships'." + ) + + for name in ("parent_id", "child_id", "relationship_type"): + if name not in table.column_names: + raise ValueError(f"Relationship table must contain a {name} column.") + if pc.any(pc.is_null(table[name])).as_py(): + raise ValueError(f"Relationship table {name} values must not be null.") + + unknown = set(table["relationship_type"].to_pylist()) - RELATIONSHIP_TYPES + if unknown: + raise ValueError(f"Unsupported relationship_type values: {sorted(unknown)}.") + + +@dataclass(frozen=True) +class OMEArrowShapes: + """Convenience wrapper around a validated OME-Arrow shape table.""" + + table: pa.Table + + def __post_init__(self) -> None: + """Validate the wrapped shape table.""" + validate_shape_table(self.table) + + @classmethod + def from_rows( + cls, + rows: Sequence[dict[str, Any]], + *, + geometry_encoding: GeometryEncoding | str, + axes: Sequence[str] = ("y", "x"), + units: Sequence[str] | None = None, + coordinate_space: str = "pixel", + geometry_column: str = "geometry", + ) -> OMEArrowShapes: + """Create a shape wrapper from Python row dictionaries. + + Args: + rows: Shape rows, where each row represents one biological object. + geometry_encoding: Registered OME-Arrow geometry encoding name. + axes: Coordinate axis names for geometry, centroid, and bounding boxes. + units: Units aligned to axes. Defaults to ``"pixel"`` for each axis. + coordinate_space: Name of the coordinate space for geometry values. + geometry_column: Name of the logical geometry column. + + Returns: + Validated OME-Arrow shapes wrapper. + """ + return cls( + make_shape_table( + rows, + geometry_encoding=geometry_encoding, + axes=axes, + units=units, + coordinate_space=coordinate_space, + geometry_column=geometry_column, + ) + ) + + @property + def metadata(self) -> dict[str, Any]: + """Return decoded OME-Arrow shapes metadata.""" + return _shape_metadata_from_schema(self.table.schema) + + @property + def geometry_encoding(self) -> str: + """Return the registered geometry encoding for the table.""" + return str(self.metadata["geometry_encoding"]) + + @property + def axes(self) -> tuple[str, ...]: + """Return coordinate axis names for the shape table.""" + return tuple(self.metadata["axes"]) + + def for_image(self, image_id: str) -> OMEArrowShapes: + """Return shapes that reference one image ID. + + Args: + image_id: Image identifier to filter on. + + Returns: + New wrapper containing only matching shape rows. + """ + if "image_id" not in self.table.column_names: + return type(self)(self.table.slice(0, 0)) + mask = pc.equal(self.table["image_id"], image_id) + return type(self)(self.table.filter(mask)) diff --git a/tests/test_shapes.py b/tests/test_shapes.py new file mode 100644 index 0000000..d3a17ea --- /dev/null +++ b/tests/test_shapes.py @@ -0,0 +1,152 @@ +"""Tests for Arrow-native OME-Arrow shape tables.""" + +import json + +import pyarrow as pa +import pytest + +from ome_arrow import ( + OME_ARROW_SHAPES_METADATA_KEY, + OMEArrowShapes, + make_relationship_table, + make_shape_table, + shape_schema, + validate_relationship_table, + validate_shape_table, +) +from ome_arrow.shapes import SUPPORTED_GEOMETRY_ENCODINGS + + +def test_make_shape_table_stores_single_geometry_column_with_metadata() -> None: + """Create a shape table with one logical geometry value per object row.""" + table = make_shape_table( + [ + { + "object_id": "cell-1", + "image_id": "image-1", + "label_image_id": "labels-1", + "label_value": 7, + "geometry": [[0.0, 0.0], [2.0, 0.0], [2.0, 3.0]], + "centroid": [1.25, 1.5], + "bbox": {"min": [0.0, 0.0], "max": [2.0, 3.0]}, + "class": "cell", + "confidence": 0.98, + "area": 5.5, + } + ], + geometry_encoding="geoarrow.linestring", + axes=("y", "x"), + units=("pixel", "pixel"), + coordinate_space="pixel", + ) + + metadata = json.loads(table.schema.metadata[OME_ARROW_SHAPES_METADATA_KEY]) + + assert table.num_rows == 1 + assert table.column_names.count("geometry") == 1 + assert table.schema.field("geometry").type == pa.list_( + pa.list_(pa.float64(), list_size=2) + ) + assert table.schema.field("area").type == pa.float64() + assert metadata["type"] == "ome.arrow.shapes" + assert metadata["geometry_encoding"] == "geoarrow.linestring" + assert metadata["axes"] == ["y", "x"] + assert metadata["coordinate_space"] == "pixel" + assert validate_shape_table(table) is None + + +def test_shape_table_supports_labelmask_geometry_references() -> None: + """Represent label masks by reference instead of embedding raster masks.""" + table = make_shape_table( + [ + { + "object_id": "nucleus-1", + "image_id": "image-1", + "label_image_id": "nuclear-labels", + "label_value": 42, + "geometry": { + "label_image_id": "nuclear-labels", + "label_value": 42, + }, + "class": "nucleus", + } + ], + geometry_encoding="ome.labelmask", + ) + + geometry = table["geometry"].to_pylist()[0] + + assert geometry == {"label_image_id": "nuclear-labels", "label_value": 42} + assert validate_shape_table(table) is None + + +def test_shape_table_rejects_invalid_geometry_encoding() -> None: + """Fail before creating tables for unknown geometry encodings.""" + assert "geoarrow.polygon" in SUPPORTED_GEOMETRY_ENCODINGS + + with pytest.raises(ValueError, match="Unsupported geometry_encoding"): + shape_schema("wkb") + + +def test_validate_shape_table_requires_metadata_and_identity() -> None: + """Require shape metadata and object identity columns.""" + table = pa.table({"geometry": [[[0.0, 1.0]]]}) + + with pytest.raises(ValueError, match="schema metadata"): + validate_shape_table(table) + + table = make_shape_table( + [{"geometry": [0.0, 1.0]}], + geometry_encoding="geoarrow.point", + validate=False, + ) + + with pytest.raises(ValueError, match="object_id"): + validate_shape_table(table) + + +def test_ome_arrow_shapes_wrapper_exposes_metadata_and_filtering() -> None: + """Wrap shape tables with small convenience accessors.""" + shapes = OMEArrowShapes.from_rows( + [ + { + "object_id": "cell-1", + "image_id": "image-1", + "label_image_id": "labels", + "label_value": 1, + "geometry": [0.0, 1.0], + }, + { + "object_id": "cell-2", + "image_id": "image-2", + "label_image_id": "labels", + "label_value": 2, + "geometry": [2.0, 3.0], + }, + ], + geometry_encoding="geoarrow.point", + ) + + subset = shapes.for_image("image-1") + + assert shapes.geometry_encoding == "geoarrow.point" + assert shapes.axes == ("y", "x") + assert subset.table["object_id"].to_pylist() == ["cell-1"] + + +def test_relationship_table_models_object_edges() -> None: + """Build object relationship edges as ordinary Arrow rows.""" + table = make_relationship_table( + [ + { + "parent_id": "cell-1", + "child_id": "nucleus-1", + "relationship_type": "contains", + "confidence": 1.0, + } + ] + ) + + assert table.schema.field("relationship_type").type == pa.string() + assert table["parent_id"].to_pylist() == ["cell-1"] + assert validate_relationship_table(table) is None diff --git a/tests/test_shapes_performance.py b/tests/test_shapes_performance.py new file mode 100644 index 0000000..83a15cc --- /dev/null +++ b/tests/test_shapes_performance.py @@ -0,0 +1,52 @@ +"""Performance canaries for Arrow-native OME-Arrow shape tables.""" + +from __future__ import annotations + +import time + +from ome_arrow import OMEArrowShapes, make_shape_table + + +def _shape_rows(count: int) -> list[dict[str, object]]: + """Create deterministic point rows for shape performance tests.""" + return [ + { + "object_id": f"cell-{i}", + "image_id": f"image-{i % 5}", + "label_image_id": "labels", + "label_value": i, + "geometry": [float(i), float(i + 1)], + "centroid": [float(i), float(i + 1)], + "class": "cell", + "area": float(i % 997), + "mean_intensity": float(i % 255), + } + for i in range(count) + ] + + +def test_make_shape_table_handles_large_object_tables_quickly() -> None: + """Keep shape table construction on Arrow's fast path.""" + rows = _shape_rows(20_000) + + start = time.perf_counter() + table = make_shape_table(rows, geometry_encoding="geoarrow.point") + elapsed = time.perf_counter() - start + + assert table.num_rows == 20_000 + assert table.schema.field("area").type.bit_width == 64 + assert elapsed < 1.0 + + +def test_shape_filtering_stays_vectorized_for_image_ids() -> None: + """Keep image-id filtering backed by Arrow compute operations.""" + table = make_shape_table(_shape_rows(20_000), geometry_encoding="geoarrow.point") + shapes = OMEArrowShapes(table) + + start = time.perf_counter() + subset = shapes.for_image("image-3") + elapsed = time.perf_counter() - start + + assert subset.table.num_rows == 4_000 + assert subset.table["image_id"].to_pylist() == ["image-3"] * 4_000 + assert elapsed < 0.25 From 29fb44f9908aaffed8dc4b8819c44238f3cd3ed3 Mon Sep 17 00:00:00 2001 From: d33bs Date: Sat, 4 Jul 2026 23:00:45 -0600 Subject: [PATCH 2/5] optimize shapes with simple benchmarks for io --- README.md | 2 + benchmarks/benchmark_shapes_parquet.py | 320 +++++++++++++++++++++++++ docs/src/python-api.md | 4 +- docs/src/shapes.md | 17 +- src/ome_arrow/__init__.py | 2 + src/ome_arrow/shapes.py | 85 ++++++- tests/test_shapes.py | 84 ++++++- tests/test_shapes_performance.py | 30 ++- 8 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 benchmarks/benchmark_shapes_parquet.py diff --git a/README.md b/README.md index e4a4894..540f818 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,8 @@ Label masks are represented by reference with `label_image_id` and mask payloads in each object row. See the dedicated Shapes docs: [`docs/src/shapes.md`](docs/src/shapes.md). +For local Parquet read/write benchmarks, run: +`python benchmarks/benchmark_shapes_parquet.py --repeats 5 --warmup 1`. ## Tensor ingest (PyTorch/JAX) diff --git a/benchmarks/benchmark_shapes_parquet.py b/benchmarks/benchmark_shapes_parquet.py new file mode 100644 index 0000000..47a908e --- /dev/null +++ b/benchmarks/benchmark_shapes_parquet.py @@ -0,0 +1,320 @@ +"""Benchmark OME-Arrow Shapes Parquet read/write paths. + +The benchmark uses synthetic but biologically interpretable object tables: + +- one row is one segmented object +- each object belongs to one source image +- label-reference rows point at canonical label rasters by label value +- measurement columns model common morphology and intensity features + +The results are directional local signals, not universal format rankings. +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import tempfile +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + +import pyarrow as pa + +from ome_arrow import make_shape_table, read_shape_parquet, write_shape_parquet + + +@dataclass(frozen=True) +class ShapeFixture: + """Synthetic scientific shape workload description.""" + + name: str + geometry_encoding: str + image_count: int + objects_per_image: int + measurement_count: int + + @property + def object_count(self) -> int: + """Return the total number of object rows.""" + return self.image_count * self.objects_per_image + + +@dataclass(frozen=True) +class ShapeBenchmarkResult: + """One shape Parquet benchmark result.""" + + fixture: str + geometry_encoding: str + compression: str | None + image_count: int + object_count: int + measurement_count: int + write_ms: float + full_read_ms: float + projected_read_ms: float + filtered_read_ms: float + file_size_mb: float + projected_columns: int + filtered_rows: int + + +def _time(fn: Callable[[], object], *, repeats: int, warmup: int) -> float: + """Return median runtime in milliseconds.""" + for _ in range(warmup): + fn() + times_ms = [] + for _ in range(repeats): + start = time.perf_counter() + fn() + times_ms.append((time.perf_counter() - start) * 1000.0) + return statistics.median(times_ms) + + +def _measurement_payload(index: int) -> dict[str, float]: + """Create deterministic morphology and intensity measurements.""" + return { + "area_um2": float(50.0 + (index % 200) * 0.5), + "perimeter_um": float(20.0 + (index % 120) * 0.25), + "mean_intensity_dna": float(100.0 + (index % 4096)), + "mean_intensity_mito": float(80.0 + ((index * 3) % 2048)), + "eccentricity": float((index % 100) / 100.0), + "solidity": float(0.75 + (index % 25) / 100.0), + } + + +def _point_rows(fixture: ShapeFixture) -> list[dict[str, object]]: + """Build centroid-like point geometry rows.""" + rows: list[dict[str, object]] = [] + for image_idx in range(fixture.image_count): + image_id = f"image-{image_idx:03d}" + label_image_id = f"{image_id}-labels" + for object_idx in range(fixture.objects_per_image): + index = image_idx * fixture.objects_per_image + object_idx + y = float((object_idx // 100) * 12 + 6) + x = float((object_idx % 100) * 12 + 6) + rows.append( + { + "object_id": f"{image_id}-cell-{object_idx:05d}", + "image_id": image_id, + "label_image_id": label_image_id, + "label_value": object_idx + 1, + "geometry": [y, x], + "centroid": [y, x], + "class": "cell", + "confidence": 0.95, + **_measurement_payload(index), + } + ) + return rows + + +def _labelmask_rows(fixture: ShapeFixture) -> list[dict[str, object]]: + """Build rows that reference canonical label rasters.""" + rows: list[dict[str, object]] = [] + for image_idx in range(fixture.image_count): + image_id = f"image-{image_idx:03d}" + label_image_id = f"{image_id}-nuclear-labels" + for object_idx in range(fixture.objects_per_image): + index = image_idx * fixture.objects_per_image + object_idx + label_value = object_idx + 1 + rows.append( + { + "object_id": f"{image_id}-nucleus-{object_idx:05d}", + "image_id": image_id, + "label_image_id": label_image_id, + "label_value": label_value, + "geometry": { + "label_image_id": label_image_id, + "label_value": label_value, + }, + "class": "nucleus", + "confidence": 0.98, + **_measurement_payload(index), + } + ) + return rows + + +def _shape_table(fixture: ShapeFixture) -> pa.Table: + """Create one benchmark shape table.""" + if fixture.geometry_encoding == "ome.labelmask": + rows = _labelmask_rows(fixture) + else: + rows = _point_rows(fixture) + return make_shape_table( + rows, + geometry_encoding=fixture.geometry_encoding, + axes=("y", "x"), + units=("pixel", "pixel"), + ) + + +def _benchmark_fixture( + fixture: ShapeFixture, + *, + compression: str | None, + repeats: int, + warmup: int, + workdir: Path, +) -> ShapeBenchmarkResult: + """Run write/read benchmark cases for one shape fixture.""" + table = _shape_table(fixture) + out = workdir / f"{fixture.name}.{compression or 'none'}.ome-shapes.parquet" + projected_columns = [ + "object_id", + "image_id", + "label_value", + "area_um2", + "mean_intensity_dna", + "eccentricity", + ] + filtered_image_id = f"image-{fixture.image_count // 2:03d}" + + def write() -> None: + write_shape_parquet( + table, + out, + compression=compression, + row_group_size=fixture.objects_per_image, + use_dictionary=[ + "object_id", + "image_id", + "label_image_id", + "class", + ], + ) + + write() + write_ms = _time(write, repeats=repeats, warmup=warmup) + + def full_read() -> pa.Table: + return read_shape_parquet(out) + + def projected_read() -> pa.Table: + return read_shape_parquet(out, columns=projected_columns) + + def filtered_read() -> pa.Table: + return read_shape_parquet( + out, + columns=projected_columns, + filters=[("image_id", "==", filtered_image_id)], + ) + + full = full_read() + projected = projected_read() + filtered = filtered_read() + if full.num_rows != fixture.object_count: + raise AssertionError(f"full read returned {full.num_rows} rows") + if projected.num_columns != len(projected_columns): + raise AssertionError("projected read returned unexpected columns") + if filtered.num_rows != fixture.objects_per_image: + raise AssertionError(f"filtered read returned {filtered.num_rows} rows") + + return ShapeBenchmarkResult( + fixture=fixture.name, + geometry_encoding=fixture.geometry_encoding, + compression=compression, + image_count=fixture.image_count, + object_count=fixture.object_count, + measurement_count=fixture.measurement_count, + write_ms=write_ms, + full_read_ms=_time(full_read, repeats=repeats, warmup=warmup), + projected_read_ms=_time(projected_read, repeats=repeats, warmup=warmup), + filtered_read_ms=_time(filtered_read, repeats=repeats, warmup=warmup), + file_size_mb=out.stat().st_size / (1024 * 1024), + projected_columns=len(projected_columns), + filtered_rows=filtered.num_rows, + ) + + +def run( + *, + repeats: int, + warmup: int, + image_count: int, + objects_per_image: int, +) -> list[ShapeBenchmarkResult]: + """Run shape Parquet benchmarks.""" + fixtures = [ + ShapeFixture( + name="cell-centroids", + geometry_encoding="geoarrow.point", + image_count=image_count, + objects_per_image=objects_per_image, + measurement_count=6, + ), + ShapeFixture( + name="nucleus-label-refs", + geometry_encoding="ome.labelmask", + image_count=image_count, + objects_per_image=objects_per_image, + measurement_count=6, + ), + ] + results: list[ShapeBenchmarkResult] = [] + with tempfile.TemporaryDirectory(prefix="ome_arrow_shapes_parquet_") as tmp: + workdir = Path(tmp) + for fixture in fixtures: + for compression in (None, "zstd"): + results.append( + _benchmark_fixture( + fixture, + compression=compression, + repeats=repeats, + warmup=warmup, + workdir=workdir, + ) + ) + return results + + +def _print_results(results: list[ShapeBenchmarkResult]) -> None: + """Print benchmark results as a compact scientific table.""" + print("") + print("OME-Arrow Shapes Parquet benchmark") + print( + f"{'fixture':20} {'geometry':16} {'codec':8} " + f"{'images':>6} {'objects':>8} {'meas':>5} " + f"{'write':>9} {'full':>9} {'project':>9} {'filter':>9} " + f"{'size MB':>9}" + ) + print("-" * 118) + for result in results: + codec = result.compression or "none" + print( + f"{result.fixture:20} {result.geometry_encoding:16} {codec:8} " + f"{result.image_count:6d} {result.object_count:8d} " + f"{result.measurement_count:5d} {result.write_ms:9.2f} " + f"{result.full_read_ms:9.2f} {result.projected_read_ms:9.2f} " + f"{result.filtered_read_ms:9.2f} {result.file_size_mb:9.2f}" + ) + + +def main() -> None: + """Run the command-line benchmark.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--warmup", type=int, default=1) + parser.add_argument("--image-count", type=int, default=24) + parser.add_argument("--objects-per-image", type=int, default=2_000) + parser.add_argument("--json-out", type=Path, default=None) + args = parser.parse_args() + + results = run( + repeats=args.repeats, + warmup=args.warmup, + image_count=args.image_count, + objects_per_image=args.objects_per_image, + ) + _print_results(results) + if args.json_out is not None: + args.json_out.write_text( + json.dumps({"results": [asdict(result) for result in results]}, indent=2) + ) + + +if __name__ == "__main__": + main() diff --git a/docs/src/python-api.md b/docs/src/python-api.md index bea642e..c3f243f 100644 --- a/docs/src/python-api.md +++ b/docs/src/python-api.md @@ -13,10 +13,12 @@ ome_arrow geometry_storage_type, make_relationship_table, make_shape_table, + read_shape_parquet, relationship_schema, shape_schema, validate_relationship_table, - validate_shape_table + validate_shape_table, + write_shape_parquet ``` ```{eval-rst} diff --git a/docs/src/shapes.md b/docs/src/shapes.md index f40e3dd..74dae2a 100644 --- a/docs/src/shapes.md +++ b/docs/src/shapes.md @@ -195,5 +195,18 @@ Run them with: python -m pytest tests/test_shapes.py tests/test_shapes_performance.py ``` -For deeper benchmark work, add workload-specific scripts under `benchmarks/` -that report medians over repeated runs and compare against checked-in baselines. +For Parquet read/write performance, run: + +```sh +python benchmarks/benchmark_shapes_parquet.py --repeats 5 --warmup 1 +``` + +This benchmark reports scientifically interpretable workload dimensions: +image count, object count, measurement count, geometry encoding, projected +column count, filtered row count, file size, and read/write timings. +It includes both centroid-like `geoarrow.point` rows and `ome.labelmask` rows +that reference canonical label rasters. + +Projected and filtered reads exercise the performance path most analysis tools +need: load only the object IDs, image IDs, label values, and measurements needed +for a specific query. diff --git a/src/ome_arrow/__init__.py b/src/ome_arrow/__init__.py index 5f6259e..e5acfbb 100644 --- a/src/ome_arrow/__init__.py +++ b/src/ome_arrow/__init__.py @@ -43,10 +43,12 @@ geometry_storage_type, make_relationship_table, make_shape_table, + read_shape_parquet, relationship_schema, shape_schema, validate_relationship_table, validate_shape_table, + write_shape_parquet, ) from ome_arrow.tensor import LazyTensorView, TensorView from ome_arrow.utils import describe_ome_arrow, verify_ome_arrow diff --git a/src/ome_arrow/shapes.py b/src/ome_arrow/shapes.py index a9b73c6..040e733 100644 --- a/src/ome_arrow/shapes.py +++ b/src/ome_arrow/shapes.py @@ -4,10 +4,12 @@ import json from dataclasses import dataclass +from os import PathLike from typing import Any, Iterable, Literal, Sequence import pyarrow as pa import pyarrow.compute as pc +import pyarrow.parquet as pq from ome_arrow.meta import OME_ARROW_TAG_VERSION @@ -71,11 +73,11 @@ ) -def _coordinate_type(dimensions: int) -> pa.FixedSizeListType: +def _coordinate_type(dimensions: int) -> pa.ListType: """Return the Arrow coordinate vector type for a geometry dimension.""" if dimensions < 1: raise ValueError("geometry dimensions must be at least 1.") - return pa.list_(pa.float64(), list_size=dimensions) + return pa.list_(pa.float64()) def geometry_storage_type( @@ -339,6 +341,85 @@ def validate_shape_table(table: pa.Table) -> None: raise ValueError("Shape table object_id values must not be null.") +def write_shape_parquet( + table: pa.Table, + path: str | PathLike[str], + *, + compression: str | None = "zstd", + row_group_size: int | None = 65_536, + use_dictionary: bool | list[str] = True, + validate: bool = True, +) -> None: + """Write an OME-Arrow shape table to Parquet. + + Args: + table: OME-Arrow shape table to write. + path: Output Parquet path. + compression: Parquet compression codec, or ``None`` for uncompressed. + row_group_size: Number of rows per Parquet row group. + use_dictionary: Dictionary-encode eligible columns. This is useful for + repeated scientific labels such as image IDs, label image IDs, and + object classes. + validate: Validate the table before writing. + + Raises: + ValueError: If validation fails. + """ + if validate: + validate_shape_table(table) + pq.write_table( + table, + path, + compression=compression, + row_group_size=row_group_size, + use_dictionary=use_dictionary, + ) + + +def read_shape_parquet( + path: str | PathLike[str], + *, + columns: Sequence[str] | None = None, + filters: Any | None = None, + memory_map: bool = True, + validate: bool = True, +) -> pa.Table: + """Read an OME-Arrow shape Parquet table. + + Args: + path: Input Parquet path. + columns: Optional column projection for analytical reads. + filters: Optional PyArrow Parquet filters for predicate pushdown. + memory_map: Use memory mapping where supported. + validate: Validate complete shape tables after reading. Projected reads + that omit required columns still validate schema metadata but skip + full table validation. + + Returns: + Arrow table read from Parquet. + + Raises: + ValueError: If OME-Arrow shape metadata or complete-table validation + fails. + """ + schema = pq.read_schema(path, memory_map=memory_map) + metadata = _shape_metadata_from_schema(schema) + table = pq.read_table( + path, + columns=columns, + filters=filters, + memory_map=memory_map, + ) + if not validate: + return table + + geometry_column = metadata.get("geometry_column", "geometry") + required = {"object_id", geometry_column} + if required.issubset(table.column_names): + validate_shape_table(table) + return table + + def relationship_metadata() -> dict[str, Any]: """Build JSON-serializable schema metadata for object relationships.""" return { diff --git a/tests/test_shapes.py b/tests/test_shapes.py index d3a17ea..57d6f62 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -3,6 +3,7 @@ import json import pyarrow as pa +import pyarrow.parquet as pq import pytest from ome_arrow import ( @@ -10,9 +11,11 @@ OMEArrowShapes, make_relationship_table, make_shape_table, + read_shape_parquet, shape_schema, validate_relationship_table, validate_shape_table, + write_shape_parquet, ) from ome_arrow.shapes import SUPPORTED_GEOMETRY_ENCODINGS @@ -44,9 +47,7 @@ def test_make_shape_table_stores_single_geometry_column_with_metadata() -> None: assert table.num_rows == 1 assert table.column_names.count("geometry") == 1 - assert table.schema.field("geometry").type == pa.list_( - pa.list_(pa.float64(), list_size=2) - ) + assert table.schema.field("geometry").type == pa.list_(pa.list_(pa.float64())) assert table.schema.field("area").type == pa.float64() assert metadata["type"] == "ome.arrow.shapes" assert metadata["geometry_encoding"] == "geoarrow.linestring" @@ -150,3 +151,80 @@ def test_relationship_table_models_object_edges() -> None: assert table.schema.field("relationship_type").type == pa.string() assert table["parent_id"].to_pylist() == ["cell-1"] assert validate_relationship_table(table) is None + + +def test_shape_parquet_roundtrip_preserves_metadata(tmp_path) -> None: + """Write and read complete shape tables with schema metadata intact.""" + path = tmp_path / "cells.ome-shapes.parquet" + table = make_shape_table( + [ + { + "object_id": "cell-1", + "image_id": "image-1", + "label_image_id": "labels-1", + "label_value": 1, + "geometry": [10.0, 20.0], + "area_um2": 42.5, + } + ], + geometry_encoding="geoarrow.point", + axes=("y", "x"), + units=("micrometer", "micrometer"), + coordinate_space="physical", + ) + + write_shape_parquet(table, path) + roundtrip = read_shape_parquet(path) + + assert roundtrip.schema.metadata[OME_ARROW_SHAPES_METADATA_KEY] + assert roundtrip["object_id"].to_pylist() == ["cell-1"] + assert roundtrip["area_um2"].to_pylist() == [42.5] + assert pq.ParquetFile(path).metadata.num_rows == 1 + + +def test_shape_parquet_projection_and_filters_support_label_references( + tmp_path, +) -> None: + """Read only needed label-reference columns with Parquet predicate filters.""" + path = tmp_path / "label_refs.ome-shapes.parquet" + table = make_shape_table( + [ + { + "object_id": "nucleus-1", + "image_id": "image-1", + "label_image_id": "nuclear-labels", + "label_value": 1, + "geometry": { + "label_image_id": "nuclear-labels", + "label_value": 1, + }, + "class": "nucleus", + }, + { + "object_id": "nucleus-2", + "image_id": "image-2", + "label_image_id": "nuclear-labels", + "label_value": 2, + "geometry": { + "label_image_id": "nuclear-labels", + "label_value": 2, + }, + "class": "nucleus", + }, + ], + geometry_encoding="ome.labelmask", + ) + + write_shape_parquet(table, path, row_group_size=1) + projected = read_shape_parquet( + path, + columns=["object_id", "image_id", "label_value"], + filters=[("image_id", "==", "image-2")], + ) + + assert projected.column_names == ["object_id", "image_id", "label_value"] + assert projected.to_pydict() == { + "object_id": ["nucleus-2"], + "image_id": ["image-2"], + "label_value": [2], + } diff --git a/tests/test_shapes_performance.py b/tests/test_shapes_performance.py index 83a15cc..19d818d 100644 --- a/tests/test_shapes_performance.py +++ b/tests/test_shapes_performance.py @@ -4,7 +4,12 @@ import time -from ome_arrow import OMEArrowShapes, make_shape_table +from ome_arrow import ( + OMEArrowShapes, + make_shape_table, + read_shape_parquet, + write_shape_parquet, +) def _shape_rows(count: int) -> list[dict[str, object]]: @@ -50,3 +55,26 @@ def test_shape_filtering_stays_vectorized_for_image_ids() -> None: assert subset.table.num_rows == 4_000 assert subset.table["image_id"].to_pylist() == ["image-3"] * 4_000 assert elapsed < 0.25 + + +def test_shape_parquet_projection_stays_fast(tmp_path) -> None: + """Keep projected shape reads on Parquet's columnar fast path.""" + path = tmp_path / "cells.ome-shapes.parquet" + table = make_shape_table(_shape_rows(20_000), geometry_encoding="geoarrow.point") + + start = time.perf_counter() + write_shape_parquet(table, path, row_group_size=5_000) + write_elapsed = time.perf_counter() - start + + start = time.perf_counter() + projected = read_shape_parquet( + path, + columns=["object_id", "image_id", "area", "mean_intensity"], + filters=[("image_id", "==", "image-3")], + ) + read_elapsed = time.perf_counter() - start + + assert projected.num_rows == 4_000 + assert projected.column_names == ["object_id", "image_id", "area", "mean_intensity"] + assert write_elapsed < 1.5 + assert read_elapsed < 0.5 From 5b401fd4b1c7f0c65995c9506a3ad03cac41896d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:07:08 +0000 Subject: [PATCH 3/5] [pre-commit.ci lite] apply automatic fixes --- docs/src/_static/generate_schema_image.py | 1 - docs/src/shapes.md | 42 +++++++++++------------ 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/docs/src/_static/generate_schema_image.py b/docs/src/_static/generate_schema_image.py index a1736d5..7c18475 100644 --- a/docs/src/_static/generate_schema_image.py +++ b/docs/src/_static/generate_schema_image.py @@ -7,7 +7,6 @@ from PIL import Image, ImageDraw, ImageFont - SCALE = 2 OUT = Path(__file__).with_name("various_ome_arrow_schema.png") diff --git a/docs/src/shapes.md b/docs/src/shapes.md index 74dae2a..62cb08b 100644 --- a/docs/src/shapes.md +++ b/docs/src/shapes.md @@ -86,17 +86,17 @@ shapes = make_shape_table( The canonical columns are: -| Column | Purpose | -| --- | --- | -| `object_id` | stable object identifier | -| `image_id` | source image identifier | -| `label_image_id` | source label image identifier | -| `label_value` | integer label value in the label raster | -| `geometry` | one logical geometry value | -| `centroid` | coordinate vector for object center | -| `bbox` | min/max coordinate bounds | -| `class` | object class or annotation category | -| `confidence` | detection or classification confidence | +| Column | Purpose | +| ---------------- | --------------------------------------- | +| `object_id` | stable object identifier | +| `image_id` | source image identifier | +| `label_image_id` | source label image identifier | +| `label_value` | integer label value in the label raster | +| `geometry` | one logical geometry value | +| `centroid` | coordinate vector for object center | +| `bbox` | min/max coordinate bounds | +| `class` | object class or annotation category | +| `confidence` | detection or classification confidence | Any additional columns are measurements. They remain ordinary Arrow columns and can be queried directly. @@ -105,16 +105,16 @@ They remain ordinary Arrow columns and can be queried directly. The current registry includes: -| Encoding | Storage intent | -| --- | --- | -| `geoarrow.point` | point coordinate vector | -| `geoarrow.linestring` | list of coordinate vectors | -| `geoarrow.polygon` | list of rings | -| `geoarrow.multipolygon` | list of polygons | -| `ome.labelmask` | label image reference | -| `ome.pointcloud` | list of coordinate vectors | -| `ome.boundingbox` | min/max coordinate vectors | -| `ome.mesh3d` | vertices and faces | +| Encoding | Storage intent | +| ----------------------- | -------------------------- | +| `geoarrow.point` | point coordinate vector | +| `geoarrow.linestring` | list of coordinate vectors | +| `geoarrow.polygon` | list of rings | +| `geoarrow.multipolygon` | list of polygons | +| `ome.labelmask` | label image reference | +| `ome.pointcloud` | list of coordinate vectors | +| `ome.boundingbox` | min/max coordinate vectors | +| `ome.mesh3d` | vertices and faces | The registry lets 2D shapes reuse GeoArrow-like nested Arrow layouts while leaving room for bioimaging-specific geometry such as label masks, point clouds, From cb50d4361858fecf54f28a339e5a7f63bbde9e4e Mon Sep 17 00:00:00 2001 From: d33bs Date: Sun, 5 Jul 2026 07:16:01 -0600 Subject: [PATCH 4/5] linting --- docs/src/_static/generate_schema_image.py | 3 +- docs/src/shapes.md | 42 +++++++++++------------ src/ome_arrow/shapes.py | 36 ++++++++++--------- tests/test_shapes.py | 7 ++-- tests/test_shapes_performance.py | 3 +- 5 files changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/src/_static/generate_schema_image.py b/docs/src/_static/generate_schema_image.py index a1736d5..d76109e 100644 --- a/docs/src/_static/generate_schema_image.py +++ b/docs/src/_static/generate_schema_image.py @@ -7,7 +7,6 @@ from PIL import Image, ImageDraw, ImageFont - SCALE = 2 OUT = Path(__file__).with_name("various_ome_arrow_schema.png") @@ -255,7 +254,7 @@ def _draw_shape_icon( ((cx - 4) * SCALE, (cy + 19) * SCALE), ] draw.polygon(polygon, fill="#d8efff", outline="#2c7fb8") - draw.line(polygon + [polygon[0]], fill="#2c7fb8", width=3 * SCALE) + draw.line([*polygon, polygon[0]], fill="#2c7fb8", width=3 * SCALE) for px, py in ( (cx - 20, cy - 3), (cx - 8, cy - 19), diff --git a/docs/src/shapes.md b/docs/src/shapes.md index 74dae2a..62cb08b 100644 --- a/docs/src/shapes.md +++ b/docs/src/shapes.md @@ -86,17 +86,17 @@ shapes = make_shape_table( The canonical columns are: -| Column | Purpose | -| --- | --- | -| `object_id` | stable object identifier | -| `image_id` | source image identifier | -| `label_image_id` | source label image identifier | -| `label_value` | integer label value in the label raster | -| `geometry` | one logical geometry value | -| `centroid` | coordinate vector for object center | -| `bbox` | min/max coordinate bounds | -| `class` | object class or annotation category | -| `confidence` | detection or classification confidence | +| Column | Purpose | +| ---------------- | --------------------------------------- | +| `object_id` | stable object identifier | +| `image_id` | source image identifier | +| `label_image_id` | source label image identifier | +| `label_value` | integer label value in the label raster | +| `geometry` | one logical geometry value | +| `centroid` | coordinate vector for object center | +| `bbox` | min/max coordinate bounds | +| `class` | object class or annotation category | +| `confidence` | detection or classification confidence | Any additional columns are measurements. They remain ordinary Arrow columns and can be queried directly. @@ -105,16 +105,16 @@ They remain ordinary Arrow columns and can be queried directly. The current registry includes: -| Encoding | Storage intent | -| --- | --- | -| `geoarrow.point` | point coordinate vector | -| `geoarrow.linestring` | list of coordinate vectors | -| `geoarrow.polygon` | list of rings | -| `geoarrow.multipolygon` | list of polygons | -| `ome.labelmask` | label image reference | -| `ome.pointcloud` | list of coordinate vectors | -| `ome.boundingbox` | min/max coordinate vectors | -| `ome.mesh3d` | vertices and faces | +| Encoding | Storage intent | +| ----------------------- | -------------------------- | +| `geoarrow.point` | point coordinate vector | +| `geoarrow.linestring` | list of coordinate vectors | +| `geoarrow.polygon` | list of rings | +| `geoarrow.multipolygon` | list of polygons | +| `ome.labelmask` | label image reference | +| `ome.pointcloud` | list of coordinate vectors | +| `ome.boundingbox` | min/max coordinate vectors | +| `ome.mesh3d` | vertices and faces | The registry lets 2D shapes reuse GeoArrow-like nested Arrow layouts while leaving room for bioimaging-specific geometry such as label masks, point clouds, diff --git a/src/ome_arrow/shapes.py b/src/ome_arrow/shapes.py index 040e733..6f3bdfb 100644 --- a/src/ome_arrow/shapes.py +++ b/src/ome_arrow/shapes.py @@ -102,33 +102,35 @@ def geometry_storage_type( coordinate = _coordinate_type(dimensions) if geometry_encoding == "geoarrow.point": - return coordinate - if geometry_encoding in {"geoarrow.linestring", "ome.pointcloud"}: - return pa.list_(coordinate) - if geometry_encoding == "geoarrow.polygon": - return pa.list_(pa.list_(coordinate)) - if geometry_encoding == "geoarrow.multipolygon": - return pa.list_(pa.list_(pa.list_(coordinate))) - if geometry_encoding == "ome.boundingbox": - return pa.struct( + storage_type = coordinate + elif geometry_encoding in {"geoarrow.linestring", "ome.pointcloud"}: + storage_type = pa.list_(coordinate) + elif geometry_encoding == "geoarrow.polygon": + storage_type = pa.list_(pa.list_(coordinate)) + elif geometry_encoding == "geoarrow.multipolygon": + storage_type = pa.list_(pa.list_(pa.list_(coordinate))) + elif geometry_encoding == "ome.boundingbox": + storage_type = pa.struct( [ pa.field("min", coordinate, nullable=False), pa.field("max", coordinate, nullable=False), ] ) - if geometry_encoding == "ome.labelmask": - return pa.struct( + elif geometry_encoding == "ome.labelmask": + storage_type = pa.struct( [ pa.field("label_image_id", pa.string(), nullable=False), pa.field("label_value", pa.int64(), nullable=False), ] ) - return pa.struct( - [ - pa.field("vertices", pa.list_(_coordinate_type(3)), nullable=False), - pa.field("faces", pa.list_(pa.list_(pa.int32())), nullable=False), - ] - ) + else: + storage_type = pa.struct( + [ + pa.field("vertices", pa.list_(_coordinate_type(3)), nullable=False), + pa.field("faces", pa.list_(pa.list_(pa.int32())), nullable=False), + ] + ) + return storage_type def shape_metadata( diff --git a/tests/test_shapes.py b/tests/test_shapes.py index 57d6f62..ea5ebec 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -1,6 +1,7 @@ """Tests for Arrow-native OME-Arrow shape tables.""" import json +import pathlib import pyarrow as pa import pyarrow.parquet as pq @@ -153,7 +154,9 @@ def test_relationship_table_models_object_edges() -> None: assert validate_relationship_table(table) is None -def test_shape_parquet_roundtrip_preserves_metadata(tmp_path) -> None: +def test_shape_parquet_roundtrip_preserves_metadata( + tmp_path: pathlib.Path, +) -> None: """Write and read complete shape tables with schema metadata intact.""" path = tmp_path / "cells.ome-shapes.parquet" table = make_shape_table( @@ -183,7 +186,7 @@ def test_shape_parquet_roundtrip_preserves_metadata(tmp_path) -> None: def test_shape_parquet_projection_and_filters_support_label_references( - tmp_path, + tmp_path: pathlib.Path, ) -> None: """Read only needed label-reference columns with Parquet predicate filters.""" path = tmp_path / "label_refs.ome-shapes.parquet" diff --git a/tests/test_shapes_performance.py b/tests/test_shapes_performance.py index 19d818d..7dce02f 100644 --- a/tests/test_shapes_performance.py +++ b/tests/test_shapes_performance.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pathlib import time from ome_arrow import ( @@ -57,7 +58,7 @@ def test_shape_filtering_stays_vectorized_for_image_ids() -> None: assert elapsed < 0.25 -def test_shape_parquet_projection_stays_fast(tmp_path) -> None: +def test_shape_parquet_projection_stays_fast(tmp_path: pathlib.Path) -> None: """Keep projected shape reads on Parquet's columnar fast path.""" path = tmp_path / "cells.ome-shapes.parquet" table = make_shape_table(_shape_rows(20_000), geometry_encoding="geoarrow.point") From 82fe40077f05a23e8e11b8b1c88ed7767abe2ff8 Mon Sep 17 00:00:00 2001 From: d33bs Date: Mon, 6 Jul 2026 20:04:33 +0100 Subject: [PATCH 5/5] coderabbitai review changes --- docs/src/_static/generate_schema_image.py | 2 +- src/ome_arrow/shapes.py | 130 +++++++++++++++++++--- tests/test_shapes.py | 126 ++++++++++++++++++++- tests/test_shapes_performance.py | 42 +++++-- 4 files changed, 268 insertions(+), 32 deletions(-) diff --git a/docs/src/_static/generate_schema_image.py b/docs/src/_static/generate_schema_image.py index d76109e..f9252f5 100644 --- a/docs/src/_static/generate_schema_image.py +++ b/docs/src/_static/generate_schema_image.py @@ -11,7 +11,7 @@ OUT = Path(__file__).with_name("various_ome_arrow_schema.png") -def _font(size: int, *, bold: bool = False) -> ImageFont.FreeTypeFont: +def _font(size: int, *, bold: bool = False) -> ImageFont.ImageFont: """Load a readable system font with a Pillow fallback.""" candidates = ( "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "", diff --git a/src/ome_arrow/shapes.py b/src/ome_arrow/shapes.py index 6f3bdfb..6704a0a 100644 --- a/src/ome_arrow/shapes.py +++ b/src/ome_arrow/shapes.py @@ -5,7 +5,7 @@ import json from dataclasses import dataclass from os import PathLike -from typing import Any, Iterable, Literal, Sequence +from typing import Any, Iterable, Literal, Sequence, get_args import pyarrow as pa import pyarrow.compute as pc @@ -24,18 +24,7 @@ "ome.boundingbox", ] -SUPPORTED_GEOMETRY_ENCODINGS: frozenset[str] = frozenset( - { - "geoarrow.point", - "geoarrow.linestring", - "geoarrow.polygon", - "geoarrow.multipolygon", - "ome.mesh3d", - "ome.labelmask", - "ome.pointcloud", - "ome.boundingbox", - } -) +SUPPORTED_GEOMETRY_ENCODINGS: frozenset[str] = frozenset(get_args(GeometryEncoding)) OME_ARROW_SHAPES_METADATA_KEY = b"ome.arrow.shapes" OME_ARROW_RELATIONSHIPS_METADATA_KEY = b"ome.arrow.relationships" @@ -250,14 +239,19 @@ def shape_schema( ) -def _infer_measurement_fields(rows: Sequence[dict[str, Any]]) -> list[pa.Field]: +def _infer_measurement_fields( + rows: Sequence[dict[str, Any]], + *, + geometry_column: str = "geometry", +) -> list[pa.Field]: """Infer measurement fields for columns outside the canonical shape columns.""" if not rows: return [] fields: list[pa.Field] = [] row_columns = set().union(*(row.keys() for row in rows)) - for name in sorted(row_columns - DEFAULT_SHAPE_COLUMNS): + reserved_columns = DEFAULT_SHAPE_COLUMNS | {geometry_column} + for name in sorted(row_columns - reserved_columns): values = [row.get(name) for row in rows] fields.append(pa.field(name, pa.array(values).type)) return fields @@ -294,7 +288,10 @@ def make_shape_table( units=units, coordinate_space=coordinate_space, geometry_column=geometry_column, - measurement_fields=_infer_measurement_fields(row_list), + measurement_fields=_infer_measurement_fields( + row_list, + geometry_column=geometry_column, + ), ) table = pa.Table.from_pylist(row_list, schema=schema) if validate: @@ -314,6 +311,106 @@ def _shape_metadata_from_schema(schema: pa.Schema) -> dict[str, Any]: return metadata +def _is_coordinate(value: Any, dimensions: int) -> bool: + """Return whether a Python value is a coordinate of the expected arity.""" + return isinstance(value, list) and len(value) == dimensions + + +def _validate_coordinate(value: Any, dimensions: int, path: str) -> None: + """Validate one coordinate vector. + + Args: + value: Python value to validate. + dimensions: Expected coordinate length. + path: Human-readable location used in error messages. + + Raises: + ValueError: If the value is not a coordinate of the expected arity. + """ + if not _is_coordinate(value, dimensions): + raise ValueError(f"{path} must contain {dimensions} coordinates.") + + +def _validate_geometry_value( + value: Any, + *, + geometry_encoding: str, + dimensions: int, + row_index: int, +) -> None: + """Validate coordinate arity for one geometry value.""" + if value is None or geometry_encoding == "ome.labelmask": + return + if geometry_encoding == "geoarrow.point": + _validate_coordinate(value, dimensions, f"geometry row {row_index}") + elif geometry_encoding in {"geoarrow.linestring", "ome.pointcloud"}: + for point_index, point in enumerate(value): + _validate_coordinate( + point, + dimensions, + f"geometry row {row_index} point {point_index}", + ) + elif geometry_encoding == "geoarrow.polygon": + for ring_index, ring in enumerate(value): + for point_index, point in enumerate(ring): + _validate_coordinate( + point, + dimensions, + f"geometry row {row_index} ring {ring_index} point {point_index}", + ) + elif geometry_encoding == "geoarrow.multipolygon": + for polygon_index, polygon in enumerate(value): + for ring_index, ring in enumerate(polygon): + for point_index, point in enumerate(ring): + _validate_coordinate( + point, + dimensions, + "geometry row " + f"{row_index} polygon {polygon_index} " + f"ring {ring_index} point {point_index}", + ) + elif geometry_encoding == "ome.boundingbox": + _validate_coordinate(value.get("min"), dimensions, f"bbox row {row_index} min") + _validate_coordinate(value.get("max"), dimensions, f"bbox row {row_index} max") + elif geometry_encoding == "ome.mesh3d": + for vertex_index, vertex in enumerate(value.get("vertices", [])): + _validate_coordinate( + vertex, 3, f"mesh row {row_index} vertex {vertex_index}" + ) + + +def _validate_coordinate_columns(table: pa.Table, metadata: dict[str, Any]) -> None: + """Validate coordinate arity for geometry, centroid, and bounding boxes.""" + dimensions = len(metadata.get("axes", [])) + if dimensions < 1: + raise ValueError("Shape table axes metadata must contain at least one axis.") + + geometry_column = metadata.get("geometry_column", "geometry") + geometry_encoding = str(metadata.get("geometry_encoding")) + for row_index, value in enumerate(table[geometry_column].to_pylist()): + _validate_geometry_value( + value, + geometry_encoding=geometry_encoding, + dimensions=dimensions, + row_index=row_index, + ) + + if "centroid" in table.column_names: + for row_index, value in enumerate(table["centroid"].to_pylist()): + if value is not None: + _validate_coordinate(value, dimensions, f"centroid row {row_index}") + + if "bbox" in table.column_names: + for row_index, value in enumerate(table["bbox"].to_pylist()): + if value is not None: + _validate_coordinate( + value.get("min"), dimensions, f"bbox row {row_index} min" + ) + _validate_coordinate( + value.get("max"), dimensions, f"bbox row {row_index} max" + ) + + def validate_shape_table(table: pa.Table) -> None: """Validate an OME-Arrow shape table. @@ -339,6 +436,7 @@ def validate_shape_table(table: pa.Table) -> None: dimensions=len(axes), ): raise ValueError("Shape table geometry column does not match metadata.") + _validate_coordinate_columns(table, metadata) if pc.any(pc.is_null(table["object_id"])).as_py(): raise ValueError("Shape table object_id values must not be null.") diff --git a/tests/test_shapes.py b/tests/test_shapes.py index ea5ebec..a917686 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -97,16 +97,83 @@ def test_validate_shape_table_requires_metadata_and_identity() -> None: with pytest.raises(ValueError, match="schema metadata"): validate_shape_table(table) - table = make_shape_table( + schema = shape_schema("geoarrow.point").remove(0) + table = pa.Table.from_pylist( [{"geometry": [0.0, 1.0]}], - geometry_encoding="geoarrow.point", - validate=False, + schema=schema, ) with pytest.raises(ValueError, match="object_id"): validate_shape_table(table) +def test_validate_shape_table_rejects_wrong_coordinate_arity() -> None: + """Reject geometry, centroid, and bbox coordinates that do not match axes.""" + with pytest.raises(ValueError, match="geometry row 0"): + make_shape_table( + [ + { + "object_id": "cell-1", + "geometry": [0.0, 1.0, 2.0], + } + ], + geometry_encoding="geoarrow.point", + ) + + with pytest.raises(ValueError, match="centroid row 0"): + make_shape_table( + [ + { + "object_id": "cell-1", + "geometry": [0.0, 1.0], + "centroid": [0.0, 1.0, 2.0], + } + ], + geometry_encoding="geoarrow.point", + ) + + with pytest.raises(ValueError, match="bbox row 0"): + make_shape_table( + [ + { + "object_id": "cell-1", + "geometry": [0.0, 1.0], + "bbox": {"min": [0.0], "max": [1.0, 2.0]}, + } + ], + geometry_encoding="geoarrow.point", + ) + + +def test_make_shape_table_reserves_custom_geometry_column() -> None: + """Do not infer custom geometry columns as measurement fields.""" + table = make_shape_table( + [ + { + "object_id": "cell-1", + "shape": [0.0, 1.0], + "area": 12.5, + } + ], + geometry_encoding="geoarrow.point", + geometry_column="shape", + ) + + assert table.column_names == [ + "object_id", + "image_id", + "label_image_id", + "label_value", + "shape", + "centroid", + "bbox", + "class", + "confidence", + "area", + ] + assert validate_shape_table(table) is None + + def test_ome_arrow_shapes_wrapper_exposes_metadata_and_filtering() -> None: """Wrap shape tables with small convenience accessors.""" shapes = OMEArrowShapes.from_rows( @@ -154,6 +221,59 @@ def test_relationship_table_models_object_edges() -> None: assert validate_relationship_table(table) is None +def test_validate_relationship_table_rejects_invalid_tables() -> None: + """Reject missing metadata, missing IDs, null IDs, and unknown relationships.""" + table = pa.table( + { + "parent_id": ["cell-1"], + "child_id": ["nucleus-1"], + "relationship_type": ["contains"], + } + ) + with pytest.raises(ValueError, match="schema metadata"): + validate_relationship_table(table) + + missing_parent = make_relationship_table( + [{"child_id": "nucleus-1", "relationship_type": "contains"}], + validate=False, + ).drop(["parent_id"]) + with pytest.raises(ValueError, match="parent_id"): + validate_relationship_table(missing_parent) + + null_child = make_relationship_table( + [ + { + "parent_id": "cell-1", + "child_id": None, + "relationship_type": "contains", + } + ], + validate=False, + ) + with pytest.raises(ValueError, match="child_id values"): + validate_relationship_table(null_child) + + missing_relationship_type = make_relationship_table( + [{"parent_id": "cell-1", "child_id": "nucleus-1"}], + validate=False, + ).drop(["relationship_type"]) + with pytest.raises(ValueError, match="relationship_type"): + validate_relationship_table(missing_relationship_type) + + unsupported_relationship = make_relationship_table( + [ + { + "parent_id": "cell-1", + "child_id": "nucleus-1", + "relationship_type": "overlaps", + } + ], + validate=False, + ) + with pytest.raises(ValueError, match="Unsupported relationship_type"): + validate_relationship_table(unsupported_relationship) + + def test_shape_parquet_roundtrip_preserves_metadata( tmp_path: pathlib.Path, ) -> None: diff --git a/tests/test_shapes_performance.py b/tests/test_shapes_performance.py index 7dce02f..04f6e61 100644 --- a/tests/test_shapes_performance.py +++ b/tests/test_shapes_performance.py @@ -3,7 +3,9 @@ from __future__ import annotations import pathlib +import statistics import time +from collections.abc import Callable from ome_arrow import ( OMEArrowShapes, @@ -31,17 +33,28 @@ def _shape_rows(count: int) -> list[dict[str, object]]: ] +def _median_seconds(fn: Callable[[], object], repeats: int = 3) -> float: + """Return median runtime across repeated calls.""" + times = [] + for _ in range(repeats): + start = time.perf_counter() + fn() + times.append(time.perf_counter() - start) + return statistics.median(times) + + def test_make_shape_table_handles_large_object_tables_quickly() -> None: """Keep shape table construction on Arrow's fast path.""" rows = _shape_rows(20_000) - start = time.perf_counter() table = make_shape_table(rows, geometry_encoding="geoarrow.point") - elapsed = time.perf_counter() - start + elapsed = _median_seconds( + lambda: make_shape_table(rows, geometry_encoding="geoarrow.point") + ) assert table.num_rows == 20_000 assert table.schema.field("area").type.bit_width == 64 - assert elapsed < 1.0 + assert elapsed < 5.0 def test_shape_filtering_stays_vectorized_for_image_ids() -> None: @@ -49,13 +62,12 @@ def test_shape_filtering_stays_vectorized_for_image_ids() -> None: table = make_shape_table(_shape_rows(20_000), geometry_encoding="geoarrow.point") shapes = OMEArrowShapes(table) - start = time.perf_counter() subset = shapes.for_image("image-3") - elapsed = time.perf_counter() - start + elapsed = _median_seconds(lambda: shapes.for_image("image-3")) assert subset.table.num_rows == 4_000 assert subset.table["image_id"].to_pylist() == ["image-3"] * 4_000 - assert elapsed < 0.25 + assert elapsed < 2.0 def test_shape_parquet_projection_stays_fast(tmp_path: pathlib.Path) -> None: @@ -63,19 +75,25 @@ def test_shape_parquet_projection_stays_fast(tmp_path: pathlib.Path) -> None: path = tmp_path / "cells.ome-shapes.parquet" table = make_shape_table(_shape_rows(20_000), geometry_encoding="geoarrow.point") - start = time.perf_counter() write_shape_parquet(table, path, row_group_size=5_000) - write_elapsed = time.perf_counter() - start + write_elapsed = _median_seconds( + lambda: write_shape_parquet(table, path, row_group_size=5_000) + ) - start = time.perf_counter() projected = read_shape_parquet( path, columns=["object_id", "image_id", "area", "mean_intensity"], filters=[("image_id", "==", "image-3")], ) - read_elapsed = time.perf_counter() - start + read_elapsed = _median_seconds( + lambda: read_shape_parquet( + path, + columns=["object_id", "image_id", "area", "mean_intensity"], + filters=[("image_id", "==", "image-3")], + ) + ) assert projected.num_rows == 4_000 assert projected.column_names == ["object_id", "image_id", "area", "mean_intensity"] - assert write_elapsed < 1.5 - assert read_elapsed < 0.5 + assert write_elapsed < 5.0 + assert read_elapsed < 2.0