diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54b849a..5f4912a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,8 +51,8 @@ jobs: - run: uv sync --frozen - name: Lint and format run: | - uv run ruff check . - uv run ruff format --check . + uv run ruff check . ../tools + uv run ruff format --check . ../tools - name: Type check run: uv run mypy - name: Tests on SQLite @@ -65,6 +65,8 @@ jobs: run: | uv run nevus openapi > /tmp/openapi.json diff -q /tmp/openapi.json ../frontend/openapi.json || { echo "frontend/openapi.json is stale: run 'just openapi'"; exit 1; } + - name: Body map copies are identical + run: cmp src/nevus/bodymap/zones.json ../frontend/src/features/bodymap/zones.json - name: Migrations on SQLite env: NEVUS_DATA_DIR: ${{ runner.temp }}/sqlite diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 136dcd2..b6bbc49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,9 +13,9 @@ repos: hooks: - id: ruff args: [--fix] - files: ^backend/ + files: ^(backend|tools)/ - id: ruff-format - files: ^backend/ + files: ^(backend|tools)/ - repo: https://github.com/gitleaks/gitleaks rev: v8.24.0 hooks: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 010041f..a57f372 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -52,3 +52,7 @@ A fresh instance has no accounts: the first person to open it claims it and beco ## Migrations `just revision "add lesions"` autogenerates a migration from the models; review it (SQLite needs batch mode, which the environment enables automatically), then `just migrate`. Every migration must apply cleanly on both engines; the CI enforces it. + +## Body map + +The silhouette and the zone geometry are generated, not drawn by hand. `uv run tools/bodymap/build.py --preview docs/design/bodymap` rebuilds `zones.json` in both the backend and the frontend (the two copies must stay identical; CI checks it) and refreshes the labelled previews in `docs/design/bodymap/`. The zone identifiers, names and hit polygons come from MoleMapper (see `THIRD_PARTY_NOTICES.md`); the silhouette is neVus artwork built in `tools/bodymap/build.py`. Design notes: `docs/design/BODY_MAP.md`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a19417e..265879d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,8 +8,8 @@ neVus is inspired by MoleMapper, the iOS application released by OHSU. The follo | Component | Origin in MoleMapper | Location in neVus | | --- | --- | --- | -| 61-zone body taxonomy (identifiers and names) | `Source/CoreData/Zone30+CoreDataClass.swift` | planned (body-map data) | -| 32 zone polygons and their per-view origins | `Source/Common/Helpers/VariableStore.m`, `BodyFrontView.m`, `BodyBackView.m`, `HeadDetailView.m` | planned (generated SVG regions) | +| 61-zone body taxonomy (identifiers and names) | `Source/CoreData/Zone30+CoreDataClass.swift` | `tools/bodymap/molemapper_zones.py` | +| 32 zone polygons and their per-view origins | `Source/Common/Helpers/VariableStore.m`, `BodyFrontView.m`, `BodyBackView.m`, `HeadDetailView.m` | `tools/bodymap/molemapper_zones.py`; clipped to the neVus silhouette by `tools/bodymap/build.py` into `backend/src/nevus/bodymap/zones.json` and `frontend/src/features/bodymap/zones.json` | | Reference-coin diameter table | `Source/Common/Helpers/TranslateUtils.swift` | planned (scale references) | | Tap-seeded auto-fit measurement algorithm (structure) | `Source/Common/OpenCVWrapper/Autosize.swift`, `OpenCVWrapper.mm` | planned (Python reimplementation) | diff --git a/backend/src/nevus/api/bodymap.py b/backend/src/nevus/api/bodymap.py new file mode 100644 index 0000000..e495b41 --- /dev/null +++ b/backend/src/nevus/api/bodymap.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""Body map data for API clients and reports; the frontend bundles the same file.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from nevus.bodymap import BodyMap, body_map + +router = APIRouter(prefix="/api/bodymap", tags=["bodymap"]) + + +@router.get("", response_model=BodyMap) +def get_body_map() -> BodyMap: + return body_map() diff --git a/backend/src/nevus/app.py b/backend/src/nevus/app.py index 1875fe1..0c6e0ab 100644 --- a/backend/src/nevus/app.py +++ b/backend/src/nevus/app.py @@ -10,6 +10,7 @@ from nevus import __version__ from nevus.api.auth import router as auth_router +from nevus.api.bodymap import router as bodymap_router from nevus.api.health import router as health_router from nevus.api.images import router as images_router from nevus.api.persons import router as persons_router @@ -76,6 +77,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(users_router) app.include_router(persons_router) app.include_router(images_router) + app.include_router(bodymap_router) mount_frontend(app, _static_dir(settings)) return app diff --git a/backend/src/nevus/bodymap/__init__.py b/backend/src/nevus/bodymap/__init__.py new file mode 100644 index 0000000..35309e0 --- /dev/null +++ b/backend/src/nevus/bodymap/__init__.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""The body map: a versioned static asset shared with the frontend. + +``zones.json`` is generated by ``tools/bodymap/build.py`` and committed in two +identical copies (here and under ``frontend/src/features/bodymap``); CI checks +they match. Lesions store a zone code plus a point normalised to the view box, +together with the map version, so a later map can be reinterpreted without +touching stored locations. +""" + +from __future__ import annotations + +import json +from functools import cache +from importlib.resources import files +from typing import Literal + +from pydantic import BaseModel + +View = Literal["front", "back"] +Side = Literal["left", "right", "midline"] +Region = Literal["head", "trunk", "arm", "leg"] + + +class Zone(BaseModel): + code: str + view: View + side: Side + region: Region + name: str + molemapper_shape: int + path: str + anchor: tuple[float, float] + bbox: tuple[float, float, float, float] + + +class ViewData(BaseModel): + silhouette: str + zones: list[str] + + +class BodyMap(BaseModel): + version: str + viewBox: tuple[float, float, float, float] # noqa: N815 - matches the SVG attribute and the JSON file + attribution: str + views: dict[View, ViewData] + zones: list[Zone] + + def zone(self, code: str) -> Zone | None: + return next((z for z in self.zones if z.code == code), None) + + +@cache +def body_map() -> BodyMap: + raw = files("nevus.bodymap").joinpath("zones.json").read_text(encoding="utf-8") + return BodyMap.model_validate(json.loads(raw)) + + +@cache +def zone_codes() -> frozenset[str]: + return frozenset(z.code for z in body_map().zones) diff --git a/backend/src/nevus/bodymap/zones.json b/backend/src/nevus/bodymap/zones.json new file mode 100644 index 0000000..ca1f326 --- /dev/null +++ b/backend/src/nevus/bodymap/zones.json @@ -0,0 +1,1144 @@ +{ + "version": "nevus-body-map/1", + "viewBox": [ + 0, + 0, + 216.0, + 404.0 + ], + "attribution": "Zone identifiers, names and hit polygons derived from MoleMapper (Oregon Health & Science University, BSD 3-Clause; see THIRD_PARTY_NOTICES.md). Silhouette and zone geometry by the neVus project.", + "views": { + "front": { + "silhouette": "M27.8,192.9 27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 68.0,190.0 66.8,204.8 65.9,208.3 65.5,212.1 72.0,278.6 71.6,313.2 77.3,347.5 70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 93.0,350.1 92.7,346.9 96.4,313.2 98.0,278.8 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 118.0,278.8 119.6,313.2 123.3,346.9 123.0,350.1 124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 138.7,347.5 144.4,313.2 144.0,278.6 150.5,212.1 150.1,208.3 149.2,204.8 148.0,190.0 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 203.2,234.1 203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 174.1,147.6 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 132.0,63.5 121.1,62.7 119.5,61.7 118.3,60.1 118.0,58.4 118.0,50.6 118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 94.9,62.7 84.0,63.5 70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 41.9,147.5Z", + "zones": [ + "1100", + "1200", + "1250", + "1251", + "1300", + "1301", + "1350", + "1351", + "1400", + "1401", + "1450", + "1451", + "1500", + "1501", + "1550", + "1551", + "1600", + "1601", + "1650", + "1651", + "1700", + "1701", + "1750", + "1751", + "1800", + "1801", + "1850", + "1851" + ] + }, + "back": { + "silhouette": "M27.8,192.9 27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 68.0,190.0 66.8,204.8 65.9,208.3 65.5,212.1 72.0,278.6 71.6,313.2 77.3,347.5 70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 93.0,350.1 92.7,346.9 96.4,313.2 98.0,278.8 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 118.0,278.8 119.6,313.2 123.3,346.9 123.0,350.1 124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 138.7,347.5 144.4,313.2 144.0,278.6 150.5,212.1 150.1,208.3 149.2,204.8 148.0,190.0 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 203.2,234.1 203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 174.1,147.6 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 132.0,63.5 121.1,62.7 119.5,61.7 118.3,60.1 118.0,58.4 118.0,50.6 118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 94.9,62.7 84.0,63.5 70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 41.9,147.5Z", + "zones": [ + "2100", + "2200", + "2250", + "2251", + "2300", + "2301", + "2350", + "2351", + "2400", + "2401", + "2450", + "2451", + "2500", + "2501", + "2550", + "2551", + "2600", + "2601", + "2650", + "2651", + "2700", + "2701", + "2750", + "2751", + "2800", + "2801", + "2850", + "2851" + ] + } + }, + "zones": [ + { + "code": "1100", + "view": "front", + "side": "midline", + "region": "head", + "name": "Head", + "molemapper_shape": 10, + "path": "M118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,51.2 98.0,51.2 118.0,51.2 118.0,51.2 118.0,50.6Z", + "anchor": [ + 108.0, + 27.4 + ], + "bbox": [ + 88.0, + 4.5, + 128.0, + 51.2 + ] + }, + { + "code": "1200", + "view": "front", + "side": "midline", + "region": "trunk", + "name": "Neck & Center Chest", + "molemapper_shape": 1200, + "path": "M120.3,62.2 119.5,61.7 118.3,60.1 118.0,58.4 118.0,51.2 98.0,51.2 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 95.7,62.2 104.0,112.1 104.0,112.1 104.0,112.1 108.0,112.1 112.0,112.1 112.1,111.5 112.0,112.1Z", + "anchor": [ + 108.0, + 86.9 + ], + "bbox": [ + 95.7, + 51.2, + 120.3, + 112.1 + ] + }, + { + "code": "1250", + "view": "front", + "side": "right", + "region": "trunk", + "name": "Right Pectoral", + "molemapper_shape": 1250, + "path": "M97.9,75.5 80.1,82.8 80.1,82.9 71.8,131.1 108.0,131.1 108.0,112.1 104.0,112.1 104.0,112.1 104.0,112.1 97.9,75.5Z", + "anchor": [ + 89.6, + 97.5 + ], + "bbox": [ + 71.8, + 75.5, + 108.0, + 131.1 + ] + }, + { + "code": "1251", + "view": "front", + "side": "left", + "region": "trunk", + "name": "Left Pectoral", + "molemapper_shape": 1251, + "path": "M118.1,75.5 112.1,111.5 112.0,112.1 108.0,112.1 108.0,131.1 144.2,131.1 135.9,82.8Z", + "anchor": [ + 126.4, + 97.2 + ], + "bbox": [ + 108.0, + 75.5, + 144.2, + 131.1 + ] + }, + { + "code": "1300", + "view": "front", + "side": "right", + "region": "trunk", + "name": "Right Abdomen", + "molemapper_shape": 300, + "path": "M108.0,131.1 71.8,131.1 71.8,131.1 71.8,131.3 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 73.4,170.1 108.0,170.1Z", + "anchor": [ + 91.5, + 158.2 + ], + "bbox": [ + 71.8, + 131.1, + 108.0, + 170.1 + ] + }, + { + "code": "1301", + "view": "front", + "side": "left", + "region": "trunk", + "name": "Left Abdomen", + "molemapper_shape": 301, + "path": "M108.0,131.1 108.0,131.1 108.0,170.1 142.6,170.1 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 144.2,131.2 144.2,131.1Z", + "anchor": [ + 124.5, + 158.2 + ], + "bbox": [ + 108.0, + 131.1, + 144.2, + 170.1 + ] + }, + { + "code": "1350", + "view": "front", + "side": "right", + "region": "trunk", + "name": "Right Pelvis", + "molemapper_shape": 1350, + "path": "M68.0,190.0 66.8,204.6 66.8,204.6 108.0,220.0 149.2,204.6 149.2,204.6 108.0,220.0 108.0,170.1 108.0,170.1 73.4,170.1Z", + "anchor": [ + 87.7, + 197.3 + ], + "bbox": [ + 66.8, + 170.1, + 149.2, + 220.0 + ] + }, + { + "code": "1351", + "view": "front", + "side": "left", + "region": "trunk", + "name": "Left Pelvis", + "molemapper_shape": 1351, + "path": "M148.0,190.0 142.6,170.1 108.0,170.1 108.0,220.0 149.2,204.6Z", + "anchor": [ + 128.3, + 197.3 + ], + "bbox": [ + 108.0, + 170.1, + 149.2, + 220.0 + ] + }, + { + "code": "1400", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Upper Thigh", + "molemapper_shape": 1400, + "path": "M66.8,204.8 65.9,208.3 65.5,212.1 69.6,254.6 100.4,254.6 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.0,226.1 108.0,220.0 66.8,204.6Z", + "anchor": [ + 85.1, + 229.3 + ], + "bbox": [ + 65.5, + 204.6, + 108.0, + 254.6 + ] + }, + { + "code": "1401", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Upper Thigh", + "molemapper_shape": 1401, + "path": "M108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 115.6,254.6 146.4,254.6 150.5,212.1 150.1,208.3 149.2,204.8 149.2,204.6 108.0,220.0 108.0,226.1Z", + "anchor": [ + 130.9, + 229.1 + ], + "bbox": [ + 108.0, + 204.6, + 150.5, + 254.6 + ] + }, + { + "code": "1450", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M72.0,278.6 71.7,298.6 97.1,298.6 98.0,278.8 100.4,254.6 69.6,254.6Z", + "anchor": [ + 85.0, + 266.6 + ], + "bbox": [ + 69.6, + 254.6, + 100.4, + 298.6 + ] + }, + { + "code": "1451", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M118.0,278.8 118.9,298.6 144.3,298.6 144.0,278.6 146.4,254.6 115.6,254.6Z", + "anchor": [ + 131.0, + 266.6 + ], + "bbox": [ + 115.6, + 254.6, + 146.4, + 298.6 + ] + }, + { + "code": "1500", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Upper Calf", + "molemapper_shape": 50, + "path": "M71.6,313.2 74.2,329.1 94.7,329.1 96.4,313.2 97.1,298.6 71.7,298.6Z", + "anchor": [ + 84.2, + 321.1 + ], + "bbox": [ + 71.6, + 298.6, + 97.1, + 329.1 + ] + }, + { + "code": "1501", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Upper Calf", + "molemapper_shape": 50, + "path": "M119.6,313.2 121.3,329.1 141.8,329.1 144.4,313.2 144.3,298.6 118.9,298.6Z", + "anchor": [ + 131.8, + 321.1 + ], + "bbox": [ + 118.9, + 298.6, + 144.4, + 329.1 + ] + }, + { + "code": "1550", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Lower Calf", + "molemapper_shape": 55, + "path": "M77.3,347.5 75.5,357.1 92.8,357.1 93.0,350.1 92.7,346.9 94.7,329.1 74.2,329.1Z", + "anchor": [ + 84.7, + 338.0 + ], + "bbox": [ + 74.2, + 329.1, + 94.7, + 357.1 + ] + }, + { + "code": "1551", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Lower Calf", + "molemapper_shape": 55, + "path": "M123.3,346.9 123.0,350.1 123.2,357.1 140.5,357.1 138.7,347.5 141.8,329.1 121.3,329.1Z", + "anchor": [ + 131.3, + 338.0 + ], + "bbox": [ + 121.3, + 329.1, + 141.8, + 357.1 + ] + }, + { + "code": "1600", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Ankle & Foot", + "molemapper_shape": 60, + "path": "M70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 92.8,357.1 75.5,357.1Z", + "anchor": [ + 82.6, + 372.2 + ], + "bbox": [ + 70.0, + 357.1, + 92.8, + 400.0 + ] + }, + { + "code": "1601", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Ankle & Foot", + "molemapper_shape": 60, + "path": "M124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 140.5,357.1 123.2,357.1Z", + "anchor": [ + 133.4, + 372.2 + ], + "bbox": [ + 123.2, + 357.1, + 146.0, + 400.0 + ] + }, + { + "code": "1650", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Shoulder", + "molemapper_shape": 650, + "path": "M70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 57.5,99.1 57.5,99.1 76.0,106.5 76.0,106.5 80.1,82.9 80.1,82.8 97.9,75.5 95.7,62.2 95.7,62.2 94.9,62.7 85.2,63.4 84.0,63.5 78.7,65.4Z", + "anchor": [ + 69.4, + 91.0 + ], + "bbox": [ + 57.5, + 62.2, + 97.9, + 106.5 + ] + }, + { + "code": "1651", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Shoulder", + "molemapper_shape": 651, + "path": "M118.1,75.5 135.9,82.8 135.9,82.8 140.0,106.5 140.0,106.5 158.5,99.1 158.5,99.1 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 137.3,65.4 132.0,63.5 130.8,63.4 121.1,62.7 120.3,62.2 120.3,62.2Z", + "anchor": [ + 146.6, + 90.9 + ], + "bbox": [ + 118.1, + 62.2, + 158.5, + 106.5 + ] + }, + { + "code": "1700", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Upper Arm", + "molemapper_shape": 700, + "path": "M57.5,99.1 45.8,135.4 62.6,141.8 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 71.8,131.3 71.8,131.1 71.8,131.1 76.0,106.5Z", + "anchor": [ + 62.6, + 118.6 + ], + "bbox": [ + 45.8, + 99.1, + 76.0, + 141.8 + ] + }, + { + "code": "1701", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Upper Arm", + "molemapper_shape": 701, + "path": "M158.5,99.1 140.0,106.5 144.2,131.1 144.2,131.2 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 153.4,141.8 170.2,135.4Z", + "anchor": [ + 153.4, + 118.7 + ], + "bbox": [ + 140.0, + 99.1, + 170.2, + 141.8 + ] + }, + { + "code": "1750", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Upper Forearm", + "molemapper_shape": 750, + "path": "M37.5,161.7 37.5,161.7 52.3,167.3 52.3,167.3 62.6,141.8 62.6,141.8 62.6,141.8 45.8,135.4 45.8,135.4 41.9,147.5Z", + "anchor": [ + 48.5, + 154.6 + ], + "bbox": [ + 37.5, + 135.4, + 62.6, + 167.3 + ] + }, + { + "code": "1751", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Upper Forearm", + "molemapper_shape": 751, + "path": "M170.2,135.4 153.4,141.8 163.7,167.3 163.7,167.3 178.5,161.7 178.5,161.7 174.1,147.6Z", + "anchor": [ + 167.5, + 154.6 + ], + "bbox": [ + 153.4, + 135.4, + 178.5, + 167.3 + ] + }, + { + "code": "1800", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Lower Forearm", + "molemapper_shape": 800, + "path": "M29.2,188.2 42.0,193.1 52.3,167.3 37.5,161.7Z", + "anchor": [ + 40.3, + 177.8 + ], + "bbox": [ + 29.2, + 161.7, + 52.3, + 193.1 + ] + }, + { + "code": "1801", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Lower Forearm", + "molemapper_shape": 801, + "path": "M178.5,161.7 163.7,167.3 174.0,193.1 186.8,188.2Z", + "anchor": [ + 175.7, + 177.8 + ], + "bbox": [ + 163.7, + 161.7, + 186.8, + 193.1 + ] + }, + { + "code": "1850", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Hand", + "molemapper_shape": 850, + "path": "M27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.1,235.1 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 24.9,239.5 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 42.0,193.1 29.2,188.2 29.2,188.2 27.8,192.9Z", + "anchor": [ + 26.5, + 215.7 + ], + "bbox": [ + 12.5, + 188.2, + 42.0, + 240.4 + ] + }, + { + "code": "1851", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Hand", + "molemapper_shape": 851, + "path": "M203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 186.8,188.2 186.8,188.2 174.0,193.1 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.2,239.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 202.9,235.1 203.2,234.1Z", + "anchor": [ + 189.5, + 215.7 + ], + "bbox": [ + 174.0, + 188.2, + 203.5, + 240.5 + ] + }, + { + "code": "2100", + "view": "back", + "side": "midline", + "region": "head", + "name": "Head", + "molemapper_shape": 10, + "path": "M118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,51.2 98.0,51.2 118.0,51.2 118.0,51.2 118.0,50.6Z", + "anchor": [ + 108.0, + 27.4 + ], + "bbox": [ + 88.0, + 4.5, + 128.0, + 51.2 + ] + }, + { + "code": "2200", + "view": "back", + "side": "midline", + "region": "trunk", + "name": "Neck", + "molemapper_shape": 2200, + "path": "M118.3,60.1 118.0,58.4 118.0,51.2 98.0,51.2 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 95.7,62.2 95.7,62.2 97.9,75.5 80.1,82.8 80.1,82.9 80.1,82.8 97.9,75.5 108.0,75.5 118.1,75.5 118.2,75.2 118.1,75.5 120.3,62.2 119.5,61.7Z", + "anchor": [ + 108.0, + 68.7 + ], + "bbox": [ + 80.1, + 51.2, + 120.3, + 82.9 + ] + }, + { + "code": "2250", + "view": "back", + "side": "left", + "region": "trunk", + "name": "Left Upper Back", + "molemapper_shape": 2250, + "path": "M80.1,82.8 71.8,131.1 108.0,131.1 108.0,75.5 97.9,75.5Z", + "anchor": [ + 92.0, + 106.9 + ], + "bbox": [ + 71.8, + 75.5, + 108.0, + 131.1 + ] + }, + { + "code": "2251", + "view": "back", + "side": "right", + "region": "trunk", + "name": "Right Upper Back", + "molemapper_shape": 2251, + "path": "M144.2,131.1 135.9,82.8 118.1,75.5 108.0,75.5 108.0,131.1Z", + "anchor": [ + 124.0, + 106.9 + ], + "bbox": [ + 108.0, + 75.5, + 144.2, + 131.1 + ] + }, + { + "code": "2300", + "view": "back", + "side": "left", + "region": "trunk", + "name": "Left Lower Back", + "molemapper_shape": 300, + "path": "M108.0,131.1 71.8,131.1 71.8,131.1 71.8,131.3 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 73.4,170.1 108.0,170.1Z", + "anchor": [ + 91.5, + 158.2 + ], + "bbox": [ + 71.8, + 131.1, + 108.0, + 170.1 + ] + }, + { + "code": "2301", + "view": "back", + "side": "right", + "region": "trunk", + "name": "Right Lower Back", + "molemapper_shape": 301, + "path": "M108.0,131.1 108.0,131.1 108.0,170.1 142.6,170.1 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 144.2,131.2 144.2,131.1Z", + "anchor": [ + 124.5, + 158.2 + ], + "bbox": [ + 108.0, + 131.1, + 144.2, + 170.1 + ] + }, + { + "code": "2350", + "view": "back", + "side": "left", + "region": "trunk", + "name": "Left Glute", + "molemapper_shape": 2350, + "path": "M142.6,170.1 142.6,170.1 108.0,170.1 73.4,170.1 73.4,170.1 68.0,190.0 66.8,204.8 65.9,208.3 65.5,212.1 66.2,219.6 108.0,219.6 108.0,170.1Z", + "anchor": [ + 87.7, + 197.4 + ], + "bbox": [ + 65.5, + 170.1, + 142.6, + 219.6 + ] + }, + { + "code": "2351", + "view": "back", + "side": "right", + "region": "trunk", + "name": "Right Glute", + "molemapper_shape": 2351, + "path": "M108.0,219.6 149.8,219.6 150.5,212.1 150.1,208.3 149.2,204.8 148.0,190.0 142.6,170.1 108.0,170.1Z", + "anchor": [ + 128.3, + 197.4 + ], + "bbox": [ + 108.0, + 170.1, + 150.5, + 219.6 + ] + }, + { + "code": "2400", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Upper Thigh", + "molemapper_shape": 40, + "path": "M66.2,219.6 69.6,254.6 100.4,254.6 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.0,226.1 108.0,219.6Z", + "anchor": [ + 85.0, + 242.4 + ], + "bbox": [ + 66.2, + 219.6, + 108.0, + 254.6 + ] + }, + { + "code": "2401", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Upper Thigh", + "molemapper_shape": 40, + "path": "M108.0,226.1 108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 115.6,254.6 146.4,254.6 149.8,219.6 108.0,219.6Z", + "anchor": [ + 131.0, + 242.2 + ], + "bbox": [ + 108.0, + 219.6, + 149.8, + 254.6 + ] + }, + { + "code": "2450", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M72.0,278.6 71.7,298.6 97.1,298.6 98.0,278.8 100.4,254.6 69.6,254.6Z", + "anchor": [ + 85.0, + 266.6 + ], + "bbox": [ + 69.6, + 254.6, + 100.4, + 298.6 + ] + }, + { + "code": "2451", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M118.0,278.8 118.9,298.6 144.3,298.6 144.0,278.6 146.4,254.6 115.6,254.6Z", + "anchor": [ + 131.0, + 266.6 + ], + "bbox": [ + 115.6, + 254.6, + 146.4, + 298.6 + ] + }, + { + "code": "2500", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Upper Calf", + "molemapper_shape": 50, + "path": "M71.6,313.2 74.2,329.1 94.7,329.1 96.4,313.2 97.1,298.6 71.7,298.6Z", + "anchor": [ + 84.2, + 321.1 + ], + "bbox": [ + 71.6, + 298.6, + 97.1, + 329.1 + ] + }, + { + "code": "2501", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Upper Calf", + "molemapper_shape": 50, + "path": "M119.6,313.2 121.3,329.1 141.8,329.1 144.4,313.2 144.3,298.6 118.9,298.6Z", + "anchor": [ + 131.8, + 321.1 + ], + "bbox": [ + 118.9, + 298.6, + 144.4, + 329.1 + ] + }, + { + "code": "2550", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Lower Calf", + "molemapper_shape": 55, + "path": "M77.3,347.5 75.5,357.1 92.8,357.1 93.0,350.1 92.7,346.9 94.7,329.1 74.2,329.1Z", + "anchor": [ + 84.7, + 338.0 + ], + "bbox": [ + 74.2, + 329.1, + 94.7, + 357.1 + ] + }, + { + "code": "2551", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Lower Calf", + "molemapper_shape": 55, + "path": "M123.3,346.9 123.0,350.1 123.2,357.1 140.5,357.1 138.7,347.5 141.8,329.1 121.3,329.1Z", + "anchor": [ + 131.3, + 338.0 + ], + "bbox": [ + 121.3, + 329.1, + 141.8, + 357.1 + ] + }, + { + "code": "2600", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Ankle & Foot", + "molemapper_shape": 60, + "path": "M70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 92.8,357.1 75.5,357.1Z", + "anchor": [ + 82.6, + 372.2 + ], + "bbox": [ + 70.0, + 357.1, + 92.8, + 400.0 + ] + }, + { + "code": "2601", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Ankle & Foot", + "molemapper_shape": 60, + "path": "M124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 140.5,357.1 123.2,357.1Z", + "anchor": [ + 133.4, + 372.2 + ], + "bbox": [ + 123.2, + 357.1, + 146.0, + 400.0 + ] + }, + { + "code": "2650", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Shoulder", + "molemapper_shape": 650, + "path": "M70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 57.5,99.1 57.5,99.1 76.0,106.5 76.0,106.5 80.1,82.9 80.1,82.8 97.9,75.5 95.7,62.2 94.9,62.7 85.2,63.4 84.0,63.5 78.7,65.4Z", + "anchor": [ + 69.4, + 91.0 + ], + "bbox": [ + 57.5, + 62.2, + 97.9, + 106.5 + ] + }, + { + "code": "2651", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Shoulder", + "molemapper_shape": 651, + "path": "M130.8,63.4 121.1,62.7 120.3,62.2 118.2,75.2 118.1,75.5 135.9,82.8 140.0,106.5 140.0,106.5 158.5,99.1 158.5,99.1 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 137.3,65.4 132.0,63.5Z", + "anchor": [ + 146.6, + 90.9 + ], + "bbox": [ + 118.1, + 62.2, + 158.5, + 106.5 + ] + }, + { + "code": "2700", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Upper Arm", + "molemapper_shape": 700, + "path": "M57.5,99.1 45.8,135.4 62.6,141.8 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 71.8,131.3 71.8,131.1 71.8,131.1 76.0,106.5Z", + "anchor": [ + 62.6, + 118.6 + ], + "bbox": [ + 45.8, + 99.1, + 76.0, + 141.8 + ] + }, + { + "code": "2701", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Upper Arm", + "molemapper_shape": 701, + "path": "M158.5,99.1 140.0,106.5 144.2,131.1 144.2,131.1 144.2,131.2 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 153.4,141.8 170.2,135.4Z", + "anchor": [ + 153.4, + 118.7 + ], + "bbox": [ + 140.0, + 99.1, + 170.2, + 141.8 + ] + }, + { + "code": "2750", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Elbow", + "molemapper_shape": 750, + "path": "M37.5,161.7 37.5,161.7 52.3,167.3 52.3,167.3 62.6,141.8 62.6,141.8 62.6,141.8 45.8,135.4 45.8,135.4 41.9,147.5Z", + "anchor": [ + 48.5, + 154.6 + ], + "bbox": [ + 37.5, + 135.4, + 62.6, + 167.3 + ] + }, + { + "code": "2751", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Elbow", + "molemapper_shape": 751, + "path": "M170.2,135.4 153.4,141.8 163.7,167.3 163.7,167.3 178.5,161.7 178.5,161.7 174.1,147.6Z", + "anchor": [ + 167.5, + 154.6 + ], + "bbox": [ + 153.4, + 135.4, + 178.5, + 167.3 + ] + }, + { + "code": "2800", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Lower Forearm", + "molemapper_shape": 800, + "path": "M29.2,188.2 42.0,193.1 52.3,167.3 37.5,161.7Z", + "anchor": [ + 40.3, + 177.8 + ], + "bbox": [ + 29.2, + 161.7, + 52.3, + 193.1 + ] + }, + { + "code": "2801", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Lower Forearm", + "molemapper_shape": 801, + "path": "M178.5,161.7 163.7,167.3 174.0,193.1 186.8,188.2Z", + "anchor": [ + 175.7, + 177.8 + ], + "bbox": [ + 163.7, + 161.7, + 186.8, + 193.1 + ] + }, + { + "code": "2850", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Hand", + "molemapper_shape": 850, + "path": "M27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.1,235.1 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 24.9,239.5 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 42.0,193.1 29.2,188.2 29.2,188.2 27.8,192.9Z", + "anchor": [ + 26.5, + 215.7 + ], + "bbox": [ + 12.5, + 188.2, + 42.0, + 240.4 + ] + }, + { + "code": "2851", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Hand", + "molemapper_shape": 851, + "path": "M203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 186.8,188.2 186.8,188.2 174.0,193.1 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.2,239.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 202.9,235.1 203.2,234.1Z", + "anchor": [ + 189.5, + 215.7 + ], + "bbox": [ + 174.0, + 188.2, + 203.5, + 240.5 + ] + } + ] +} diff --git a/backend/tests/test_bodymap.py b/backend/tests/test_bodymap.py new file mode 100644 index 0000000..6fa2eaa --- /dev/null +++ b/backend/tests/test_bodymap.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""The body map asset: MoleMapper's 56 front/back zones on the neVus silhouette.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from nevus.bodymap import body_map, zone_codes + +FRONTEND_COPY = Path(__file__).resolve().parents[2] / "frontend" / "src" / "features" / "bodymap" / "zones.json" + + +def test_both_views_carry_twenty_eight_zones_with_unique_codes() -> None: + bm = body_map() + assert bm.version == "nevus-body-map/1" + assert len(bm.zones) == 56 + assert len(zone_codes()) == 56 + for view in ("front", "back"): + codes = [z.code for z in bm.zones if z.view == view] + assert len(codes) == 28 + assert bm.views[view].zones == sorted(codes) # type: ignore[index] + assert bm.views[view].silhouette.startswith("M") # type: ignore[index] + + +def test_names_and_sides_follow_the_patient_not_the_screen() -> None: + bm = body_map() + assert bm.zone("1250").name == "Right Pectoral" # type: ignore[union-attr] + assert bm.zone("1250").side == "right" # type: ignore[union-attr] + assert bm.zone("2250").name == "Left Upper Back" # type: ignore[union-attr] + assert bm.zone("2250").side == "left" # type: ignore[union-attr] + assert bm.zone("1200").side == "midline" # type: ignore[union-attr] + regions = {z.region for z in bm.zones} + assert regions == {"head", "trunk", "arm", "leg"} + + +def test_every_zone_has_geometry_inside_the_view_box() -> None: + bm = body_map() + _x0, _y0, width, height = bm.viewBox + for zone in bm.zones: + assert zone.path.startswith("M") and zone.path.endswith("Z") + left, top, right, bottom = zone.bbox + assert 0 <= left < right <= width and 0 <= top < bottom <= height + assert left <= zone.anchor[0] <= right and top <= zone.anchor[1] <= bottom + + +def test_the_frontend_copy_is_identical() -> None: + if not FRONTEND_COPY.exists(): + pytest.skip("frontend checkout not present") + ours = json.loads(Path(__file__).resolve().parents[1].joinpath("src/nevus/bodymap/zones.json").read_text()) + theirs = json.loads(FRONTEND_COPY.read_text()) + assert ours == theirs + + +def test_the_body_map_is_served(client: TestClient) -> None: + response = client.get("/api/bodymap") + assert response.status_code == 200 + payload = response.json() + assert payload["version"] == "nevus-body-map/1" + assert len(payload["zones"]) == 56 + assert "MoleMapper" in payload["attribution"] diff --git a/docs/design/BODY_MAP.md b/docs/design/BODY_MAP.md new file mode 100644 index 0000000..ea2ab5f --- /dev/null +++ b/docs/design/BODY_MAP.md @@ -0,0 +1,27 @@ +# Body map + +The body map is where a person places a mark and where the app shows which marks are due. It has to be tappable on a phone, readable at a glance, neutral about who the person is, and stable over time so that stored locations keep their meaning. + +## What it is + +- **Two views**, front and back, on a 216 × 404 grid inherited from MoleMapper so that the ported zone polygons keep their coordinates. Both views share one silhouette: a body seen from behind is the mirror image of the same outline. +- **56 zones** (28 per view) with MoleMapper's identifiers and names. Names are written from the patient's point of view: on the front view the patient's right is on the left of the screen, on the back view it is on the right. The `side` field is therefore taken from the name, never from the identifier's last digit, which only encodes the screen side. Five head-detail zones (`31xx`) are kept in the ported data for the detail views planned later; they have no silhouette yet and are not served. +- **A location** is a zone code plus a point normalised to the view box (`x`, `y` in 0..1) and the map version (`nevus-body-map/1`). A later map can reinterpret stored points; nothing needs migrating when the artwork changes. + +## How it is drawn + +The silhouette is a flat, gender-neutral pictogram: a skeleton of tapered capsules (head, neck, trunk polygon, arms, hands, legs, feet) unioned and rounded with a morphological closing, then simplified. It is built by `tools/bodymap/build.py`, so proportions are numbers, not brush strokes, and a change is a diff. + +The zone regions are MoleMapper's hit polygons clipped to that silhouette. Where the polygons leave a sliver of the figure uncovered, the sliver goes to the neighbouring zone with the longest shared edge; trunk polygons win where they overlap limbs. The builder asserts that the zones tile the silhouette exactly with no overlaps. Zone boundaries are the straight cuts of the original polygons, which read as the "inner lines" of an anatomical chart. + +Previews with zone codes: [front](bodymap/front.svg), [back](bodymap/back.svg). + +## Interaction + +- Zones are SVG paths exposed as buttons: focusable, named in the account language, `aria-pressed` when selected, activated by tap, Enter or Space. +- A tap inside a zone yields the tapped point (converted through the SVG's screen matrix); the keyboard places at the zone's anchor point, which is guaranteed to lie inside the zone. +- Markers are dots at normalised positions; a due mark uses the attention colour, never red. Clustering of overlapping markers and pinch-zoom are planned for the core-tracking release. + +## Attribution + +Zone identifiers, names and hit polygons: MoleMapper, Oregon Health & Science University, BSD 3-Clause (full text in `THIRD_PARTY_NOTICES.md`). Silhouette and zone geometry: the neVus project, AGPL-3.0-only. diff --git a/docs/design/bodymap/back.svg b/docs/design/bodymap/back.svg new file mode 100644 index 0000000..e424f35 --- /dev/null +++ b/docs/design/bodymap/back.svg @@ -0,0 +1 @@ +2100220022502251230023012350235124002401245024512500250125502551260026012650265127002701275027512800280128502851back diff --git a/docs/design/bodymap/front.svg b/docs/design/bodymap/front.svg new file mode 100644 index 0000000..a798ba7 --- /dev/null +++ b/docs/design/bodymap/front.svg @@ -0,0 +1 @@ +1100120012501251130013011350135114001401145014511500150115501551160016011650165117001701175017511800180118501851front diff --git a/frontend/.prettierignore b/frontend/.prettierignore index c24fbfd..2e15a99 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -5,3 +5,4 @@ pnpm-lock.yaml # Generated from the backend schema; kept byte-identical to the generator output so CI can diff it. openapi.json src/lib/api/schema.d.ts +src/features/bodymap/zones.json diff --git a/frontend/openapi.json b/frontend/openapi.json index a7bdfcf..00f4fa7 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -55,6 +55,67 @@ "title": "AccessOut", "type": "object" }, + "BodyMap": { + "properties": { + "attribution": { + "title": "Attribution", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + }, + "viewBox": { + "maxItems": 4, + "minItems": 4, + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "title": "Viewbox", + "type": "array" + }, + "views": { + "additionalProperties": { + "$ref": "#/components/schemas/ViewData" + }, + "propertyNames": { + "enum": [ + "front", + "back" + ] + }, + "title": "Views", + "type": "object" + }, + "zones": { + "items": { + "$ref": "#/components/schemas/Zone" + }, + "title": "Zones", + "type": "array" + } + }, + "required": [ + "version", + "viewBox", + "attribution", + "views", + "zones" + ], + "title": "BodyMap", + "type": "object" + }, "Body_upload_image_api_persons__person_id__images_post": { "properties": { "captured_at": { @@ -815,6 +876,121 @@ ], "title": "ValidationError", "type": "object" + }, + "ViewData": { + "properties": { + "silhouette": { + "title": "Silhouette", + "type": "string" + }, + "zones": { + "items": { + "type": "string" + }, + "title": "Zones", + "type": "array" + } + }, + "required": [ + "silhouette", + "zones" + ], + "title": "ViewData", + "type": "object" + }, + "Zone": { + "properties": { + "anchor": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "title": "Anchor", + "type": "array" + }, + "bbox": { + "maxItems": 4, + "minItems": 4, + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "title": "Bbox", + "type": "array" + }, + "code": { + "title": "Code", + "type": "string" + }, + "molemapper_shape": { + "title": "Molemapper Shape", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "region": { + "enum": [ + "head", + "trunk", + "arm", + "leg" + ], + "title": "Region", + "type": "string" + }, + "side": { + "enum": [ + "left", + "right", + "midline" + ], + "title": "Side", + "type": "string" + }, + "view": { + "enum": [ + "front", + "back" + ], + "title": "View", + "type": "string" + } + }, + "required": [ + "code", + "view", + "side", + "region", + "name", + "molemapper_shape", + "path", + "anchor", + "bbox" + ], + "title": "Zone", + "type": "object" } } }, @@ -1081,6 +1257,27 @@ ] } }, + "/api/bodymap": { + "get": { + "operationId": "get_body_map_api_bodymap_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BodyMap" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Body Map", + "tags": [ + "bodymap" + ] + } + }, "/api/images/{image_id}": { "delete": { "description": "Moves the image to the trash; the purge and the garbage collection of blobs arrive with data management.", diff --git a/frontend/src/features/bodymap/BodyMap.module.css b/frontend/src/features/bodymap/BodyMap.module.css new file mode 100644 index 0000000..868efb8 --- /dev/null +++ b/frontend/src/features/bodymap/BodyMap.module.css @@ -0,0 +1,72 @@ +.map { + display: block; + width: 100%; + height: auto; + max-height: 70vh; + touch-action: manipulation; +} + +.silhouette { + fill: var(--color-surface-2); + stroke: var(--color-ink-3); + stroke-width: 0.8; + stroke-linejoin: round; +} + +.zone { + fill: transparent; + stroke: var(--color-border); + stroke-width: 0.5; + cursor: pointer; + transition: fill var(--motion-fast) var(--ease); +} + +.zone:hover, +.zone:focus-visible { + fill: var(--color-marker); + fill-opacity: 0.18; + outline: none; +} + +.zone:focus-visible { + stroke: var(--color-accent); + stroke-width: 1.2; +} + +.selected, +.selected:hover { + fill: var(--color-accent); + fill-opacity: 0.28; + stroke: var(--color-accent); + stroke-width: 1; +} + +.marker circle { + fill: var(--color-marker); + stroke: var(--color-paper); + stroke-width: 1; +} + +.marker .markerHalo { + fill: var(--color-marker); + fill-opacity: 0.25; + stroke: none; +} + +.marker[role="button"] { + cursor: pointer; +} + +.due circle { + fill: var(--color-attention); +} + +.markerSelected circle { + fill: var(--color-marker-selected); +} + +.marker:focus-visible circle { + stroke: var(--color-accent); + stroke-width: 1.5; + outline: none; +} diff --git a/frontend/src/features/bodymap/BodyMap.tsx b/frontend/src/features/bodymap/BodyMap.tsx new file mode 100644 index 0000000..dbd494e --- /dev/null +++ b/frontend/src/features/bodymap/BodyMap.tsx @@ -0,0 +1,116 @@ +import { useRef, type KeyboardEvent, type MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { MAP_HEIGHT, MAP_WIDTH, bodyMap, zonesForView, type MapPoint, type View, type Zone } from "./zones"; +import styles from "./BodyMap.module.css"; + +export interface Marker { + id: string; + x: number; + y: number; + label: string; + due?: boolean; + selected?: boolean; +} + +interface Props { + view: View; + markers?: Marker[]; + selectedZone?: string | null; + onSelectZone?: (zone: Zone) => void; + /** When set, a tap inside a zone also yields the tapped point; the keyboard places at the zone's anchor. */ + onPlace?: (point: MapPoint) => void; + onSelectMarker?: (id: string) => void; +} + +/** Flat silhouette divided into named zones. Zones are buttons; markers are dots at normalised positions. */ +export function BodyMap({ view, markers = [], selectedZone, onSelectZone, onPlace, onSelectMarker }: Props) { + const { t } = useTranslation(); + const svg = useRef(null); + const zones = zonesForView(view); + const zoneName = (zone: Zone) => t(`zones.${zone.code}`, { defaultValue: zone.name }); + + function pointFromEvent(event: MouseEvent, zone: Zone): MapPoint { + const element = svg.current; + const ctm = element?.getScreenCTM?.(); + if (element && ctm) { + const inverse = ctm.inverse(); + const px = inverse.a * event.clientX + inverse.c * event.clientY + inverse.e; + const py = inverse.b * event.clientX + inverse.d * event.clientY + inverse.f; + return { zone: zone.code, x: clamp(px / MAP_WIDTH), y: clamp(py / MAP_HEIGHT) }; + } + return anchorPoint(zone); + } + + function activate(zone: Zone, point: MapPoint) { + onSelectZone?.(zone); + onPlace?.(point); + } + + function onKey(event: KeyboardEvent, zone: Zone) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(zone, anchorPoint(zone)); + } + } + + return ( + + + {zones.map((zone) => ( + activate(zone, pointFromEvent(event, zone))} + onKeyDown={(event) => onKey(event, zone)} + /> + ))} + {markers.map((marker) => ( + { + event.stopPropagation(); + onSelectMarker?.(marker.id); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectMarker?.(marker.id); + } + }} + > + + + + ))} + + ); +} + +function anchorPoint(zone: Zone): MapPoint { + return { zone: zone.code, x: zone.anchor[0] / MAP_WIDTH, y: zone.anchor[1] / MAP_HEIGHT }; +} + +function clamp(value: number): number { + return Math.min(1, Math.max(0, Math.round(value * 10_000) / 10_000)); +} diff --git a/frontend/src/features/bodymap/ViewToggle.module.css b/frontend/src/features/bodymap/ViewToggle.module.css new file mode 100644 index 0000000..fad82b5 --- /dev/null +++ b/frontend/src/features/bodymap/ViewToggle.module.css @@ -0,0 +1,23 @@ +.toggle { + display: inline-flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + overflow: hidden; + background: var(--color-surface); +} + +.toggle button { + min-height: var(--touch-target); + padding: 0 var(--space-4); + border: 0; + background: transparent; + color: var(--color-ink-2); + font: inherit; + font-weight: var(--font-weight-emphasis); + cursor: pointer; +} + +.toggle button.active { + background: var(--color-accent); + color: var(--color-paper); +} diff --git a/frontend/src/features/bodymap/ViewToggle.tsx b/frontend/src/features/bodymap/ViewToggle.tsx new file mode 100644 index 0000000..ad00613 --- /dev/null +++ b/frontend/src/features/bodymap/ViewToggle.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import { VIEWS, type View } from "./zones"; +import styles from "./ViewToggle.module.css"; + +export function ViewToggle({ view, onChange }: { view: View; onChange: (view: View) => void }) { + const { t } = useTranslation(); + return ( + + {VIEWS.map((option) => ( + onChange(option)} + > + {t(`bodymap.views.${option}`)} + + ))} + + ); +} diff --git a/frontend/src/features/bodymap/zones.json b/frontend/src/features/bodymap/zones.json new file mode 100644 index 0000000..ca1f326 --- /dev/null +++ b/frontend/src/features/bodymap/zones.json @@ -0,0 +1,1144 @@ +{ + "version": "nevus-body-map/1", + "viewBox": [ + 0, + 0, + 216.0, + 404.0 + ], + "attribution": "Zone identifiers, names and hit polygons derived from MoleMapper (Oregon Health & Science University, BSD 3-Clause; see THIRD_PARTY_NOTICES.md). Silhouette and zone geometry by the neVus project.", + "views": { + "front": { + "silhouette": "M27.8,192.9 27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 68.0,190.0 66.8,204.8 65.9,208.3 65.5,212.1 72.0,278.6 71.6,313.2 77.3,347.5 70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 93.0,350.1 92.7,346.9 96.4,313.2 98.0,278.8 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 118.0,278.8 119.6,313.2 123.3,346.9 123.0,350.1 124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 138.7,347.5 144.4,313.2 144.0,278.6 150.5,212.1 150.1,208.3 149.2,204.8 148.0,190.0 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 203.2,234.1 203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 174.1,147.6 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 132.0,63.5 121.1,62.7 119.5,61.7 118.3,60.1 118.0,58.4 118.0,50.6 118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 94.9,62.7 84.0,63.5 70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 41.9,147.5Z", + "zones": [ + "1100", + "1200", + "1250", + "1251", + "1300", + "1301", + "1350", + "1351", + "1400", + "1401", + "1450", + "1451", + "1500", + "1501", + "1550", + "1551", + "1600", + "1601", + "1650", + "1651", + "1700", + "1701", + "1750", + "1751", + "1800", + "1801", + "1850", + "1851" + ] + }, + "back": { + "silhouette": "M27.8,192.9 27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 68.0,190.0 66.8,204.8 65.9,208.3 65.5,212.1 72.0,278.6 71.6,313.2 77.3,347.5 70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 93.0,350.1 92.7,346.9 96.4,313.2 98.0,278.8 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 118.0,278.8 119.6,313.2 123.3,346.9 123.0,350.1 124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 138.7,347.5 144.4,313.2 144.0,278.6 150.5,212.1 150.1,208.3 149.2,204.8 148.0,190.0 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 203.2,234.1 203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 174.1,147.6 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 132.0,63.5 121.1,62.7 119.5,61.7 118.3,60.1 118.0,58.4 118.0,50.6 118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 94.9,62.7 84.0,63.5 70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 41.9,147.5Z", + "zones": [ + "2100", + "2200", + "2250", + "2251", + "2300", + "2301", + "2350", + "2351", + "2400", + "2401", + "2450", + "2451", + "2500", + "2501", + "2550", + "2551", + "2600", + "2601", + "2650", + "2651", + "2700", + "2701", + "2750", + "2751", + "2800", + "2801", + "2850", + "2851" + ] + } + }, + "zones": [ + { + "code": "1100", + "view": "front", + "side": "midline", + "region": "head", + "name": "Head", + "molemapper_shape": 10, + "path": "M118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,51.2 98.0,51.2 118.0,51.2 118.0,51.2 118.0,50.6Z", + "anchor": [ + 108.0, + 27.4 + ], + "bbox": [ + 88.0, + 4.5, + 128.0, + 51.2 + ] + }, + { + "code": "1200", + "view": "front", + "side": "midline", + "region": "trunk", + "name": "Neck & Center Chest", + "molemapper_shape": 1200, + "path": "M120.3,62.2 119.5,61.7 118.3,60.1 118.0,58.4 118.0,51.2 98.0,51.2 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 95.7,62.2 104.0,112.1 104.0,112.1 104.0,112.1 108.0,112.1 112.0,112.1 112.1,111.5 112.0,112.1Z", + "anchor": [ + 108.0, + 86.9 + ], + "bbox": [ + 95.7, + 51.2, + 120.3, + 112.1 + ] + }, + { + "code": "1250", + "view": "front", + "side": "right", + "region": "trunk", + "name": "Right Pectoral", + "molemapper_shape": 1250, + "path": "M97.9,75.5 80.1,82.8 80.1,82.9 71.8,131.1 108.0,131.1 108.0,112.1 104.0,112.1 104.0,112.1 104.0,112.1 97.9,75.5Z", + "anchor": [ + 89.6, + 97.5 + ], + "bbox": [ + 71.8, + 75.5, + 108.0, + 131.1 + ] + }, + { + "code": "1251", + "view": "front", + "side": "left", + "region": "trunk", + "name": "Left Pectoral", + "molemapper_shape": 1251, + "path": "M118.1,75.5 112.1,111.5 112.0,112.1 108.0,112.1 108.0,131.1 144.2,131.1 135.9,82.8Z", + "anchor": [ + 126.4, + 97.2 + ], + "bbox": [ + 108.0, + 75.5, + 144.2, + 131.1 + ] + }, + { + "code": "1300", + "view": "front", + "side": "right", + "region": "trunk", + "name": "Right Abdomen", + "molemapper_shape": 300, + "path": "M108.0,131.1 71.8,131.1 71.8,131.1 71.8,131.3 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 73.4,170.1 108.0,170.1Z", + "anchor": [ + 91.5, + 158.2 + ], + "bbox": [ + 71.8, + 131.1, + 108.0, + 170.1 + ] + }, + { + "code": "1301", + "view": "front", + "side": "left", + "region": "trunk", + "name": "Left Abdomen", + "molemapper_shape": 301, + "path": "M108.0,131.1 108.0,131.1 108.0,170.1 142.6,170.1 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 144.2,131.2 144.2,131.1Z", + "anchor": [ + 124.5, + 158.2 + ], + "bbox": [ + 108.0, + 131.1, + 144.2, + 170.1 + ] + }, + { + "code": "1350", + "view": "front", + "side": "right", + "region": "trunk", + "name": "Right Pelvis", + "molemapper_shape": 1350, + "path": "M68.0,190.0 66.8,204.6 66.8,204.6 108.0,220.0 149.2,204.6 149.2,204.6 108.0,220.0 108.0,170.1 108.0,170.1 73.4,170.1Z", + "anchor": [ + 87.7, + 197.3 + ], + "bbox": [ + 66.8, + 170.1, + 149.2, + 220.0 + ] + }, + { + "code": "1351", + "view": "front", + "side": "left", + "region": "trunk", + "name": "Left Pelvis", + "molemapper_shape": 1351, + "path": "M148.0,190.0 142.6,170.1 108.0,170.1 108.0,220.0 149.2,204.6Z", + "anchor": [ + 128.3, + 197.3 + ], + "bbox": [ + 108.0, + 170.1, + 149.2, + 220.0 + ] + }, + { + "code": "1400", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Upper Thigh", + "molemapper_shape": 1400, + "path": "M66.8,204.8 65.9,208.3 65.5,212.1 69.6,254.6 100.4,254.6 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.0,226.1 108.0,220.0 66.8,204.6Z", + "anchor": [ + 85.1, + 229.3 + ], + "bbox": [ + 65.5, + 204.6, + 108.0, + 254.6 + ] + }, + { + "code": "1401", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Upper Thigh", + "molemapper_shape": 1401, + "path": "M108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 115.6,254.6 146.4,254.6 150.5,212.1 150.1,208.3 149.2,204.8 149.2,204.6 108.0,220.0 108.0,226.1Z", + "anchor": [ + 130.9, + 229.1 + ], + "bbox": [ + 108.0, + 204.6, + 150.5, + 254.6 + ] + }, + { + "code": "1450", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M72.0,278.6 71.7,298.6 97.1,298.6 98.0,278.8 100.4,254.6 69.6,254.6Z", + "anchor": [ + 85.0, + 266.6 + ], + "bbox": [ + 69.6, + 254.6, + 100.4, + 298.6 + ] + }, + { + "code": "1451", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M118.0,278.8 118.9,298.6 144.3,298.6 144.0,278.6 146.4,254.6 115.6,254.6Z", + "anchor": [ + 131.0, + 266.6 + ], + "bbox": [ + 115.6, + 254.6, + 146.4, + 298.6 + ] + }, + { + "code": "1500", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Upper Calf", + "molemapper_shape": 50, + "path": "M71.6,313.2 74.2,329.1 94.7,329.1 96.4,313.2 97.1,298.6 71.7,298.6Z", + "anchor": [ + 84.2, + 321.1 + ], + "bbox": [ + 71.6, + 298.6, + 97.1, + 329.1 + ] + }, + { + "code": "1501", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Upper Calf", + "molemapper_shape": 50, + "path": "M119.6,313.2 121.3,329.1 141.8,329.1 144.4,313.2 144.3,298.6 118.9,298.6Z", + "anchor": [ + 131.8, + 321.1 + ], + "bbox": [ + 118.9, + 298.6, + 144.4, + 329.1 + ] + }, + { + "code": "1550", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Lower Calf", + "molemapper_shape": 55, + "path": "M77.3,347.5 75.5,357.1 92.8,357.1 93.0,350.1 92.7,346.9 94.7,329.1 74.2,329.1Z", + "anchor": [ + 84.7, + 338.0 + ], + "bbox": [ + 74.2, + 329.1, + 94.7, + 357.1 + ] + }, + { + "code": "1551", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Lower Calf", + "molemapper_shape": 55, + "path": "M123.3,346.9 123.0,350.1 123.2,357.1 140.5,357.1 138.7,347.5 141.8,329.1 121.3,329.1Z", + "anchor": [ + 131.3, + 338.0 + ], + "bbox": [ + 121.3, + 329.1, + 141.8, + 357.1 + ] + }, + { + "code": "1600", + "view": "front", + "side": "right", + "region": "leg", + "name": "Right Ankle & Foot", + "molemapper_shape": 60, + "path": "M70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 92.8,357.1 75.5,357.1Z", + "anchor": [ + 82.6, + 372.2 + ], + "bbox": [ + 70.0, + 357.1, + 92.8, + 400.0 + ] + }, + { + "code": "1601", + "view": "front", + "side": "left", + "region": "leg", + "name": "Left Ankle & Foot", + "molemapper_shape": 60, + "path": "M124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 140.5,357.1 123.2,357.1Z", + "anchor": [ + 133.4, + 372.2 + ], + "bbox": [ + 123.2, + 357.1, + 146.0, + 400.0 + ] + }, + { + "code": "1650", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Shoulder", + "molemapper_shape": 650, + "path": "M70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 57.5,99.1 57.5,99.1 76.0,106.5 76.0,106.5 80.1,82.9 80.1,82.8 97.9,75.5 95.7,62.2 95.7,62.2 94.9,62.7 85.2,63.4 84.0,63.5 78.7,65.4Z", + "anchor": [ + 69.4, + 91.0 + ], + "bbox": [ + 57.5, + 62.2, + 97.9, + 106.5 + ] + }, + { + "code": "1651", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Shoulder", + "molemapper_shape": 651, + "path": "M118.1,75.5 135.9,82.8 135.9,82.8 140.0,106.5 140.0,106.5 158.5,99.1 158.5,99.1 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 137.3,65.4 132.0,63.5 130.8,63.4 121.1,62.7 120.3,62.2 120.3,62.2Z", + "anchor": [ + 146.6, + 90.9 + ], + "bbox": [ + 118.1, + 62.2, + 158.5, + 106.5 + ] + }, + { + "code": "1700", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Upper Arm", + "molemapper_shape": 700, + "path": "M57.5,99.1 45.8,135.4 62.6,141.8 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 71.8,131.3 71.8,131.1 71.8,131.1 76.0,106.5Z", + "anchor": [ + 62.6, + 118.6 + ], + "bbox": [ + 45.8, + 99.1, + 76.0, + 141.8 + ] + }, + { + "code": "1701", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Upper Arm", + "molemapper_shape": 701, + "path": "M158.5,99.1 140.0,106.5 144.2,131.1 144.2,131.2 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 153.4,141.8 170.2,135.4Z", + "anchor": [ + 153.4, + 118.7 + ], + "bbox": [ + 140.0, + 99.1, + 170.2, + 141.8 + ] + }, + { + "code": "1750", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Upper Forearm", + "molemapper_shape": 750, + "path": "M37.5,161.7 37.5,161.7 52.3,167.3 52.3,167.3 62.6,141.8 62.6,141.8 62.6,141.8 45.8,135.4 45.8,135.4 41.9,147.5Z", + "anchor": [ + 48.5, + 154.6 + ], + "bbox": [ + 37.5, + 135.4, + 62.6, + 167.3 + ] + }, + { + "code": "1751", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Upper Forearm", + "molemapper_shape": 751, + "path": "M170.2,135.4 153.4,141.8 163.7,167.3 163.7,167.3 178.5,161.7 178.5,161.7 174.1,147.6Z", + "anchor": [ + 167.5, + 154.6 + ], + "bbox": [ + 153.4, + 135.4, + 178.5, + 167.3 + ] + }, + { + "code": "1800", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Lower Forearm", + "molemapper_shape": 800, + "path": "M29.2,188.2 42.0,193.1 52.3,167.3 37.5,161.7Z", + "anchor": [ + 40.3, + 177.8 + ], + "bbox": [ + 29.2, + 161.7, + 52.3, + 193.1 + ] + }, + { + "code": "1801", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Lower Forearm", + "molemapper_shape": 801, + "path": "M178.5,161.7 163.7,167.3 174.0,193.1 186.8,188.2Z", + "anchor": [ + 175.7, + 177.8 + ], + "bbox": [ + 163.7, + 161.7, + 186.8, + 193.1 + ] + }, + { + "code": "1850", + "view": "front", + "side": "right", + "region": "arm", + "name": "Right Hand", + "molemapper_shape": 850, + "path": "M27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.1,235.1 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 24.9,239.5 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 42.0,193.1 29.2,188.2 29.2,188.2 27.8,192.9Z", + "anchor": [ + 26.5, + 215.7 + ], + "bbox": [ + 12.5, + 188.2, + 42.0, + 240.4 + ] + }, + { + "code": "1851", + "view": "front", + "side": "left", + "region": "arm", + "name": "Left Hand", + "molemapper_shape": 851, + "path": "M203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 186.8,188.2 186.8,188.2 174.0,193.1 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.2,239.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 202.9,235.1 203.2,234.1Z", + "anchor": [ + 189.5, + 215.7 + ], + "bbox": [ + 174.0, + 188.2, + 203.5, + 240.5 + ] + }, + { + "code": "2100", + "view": "back", + "side": "midline", + "region": "head", + "name": "Head", + "molemapper_shape": 10, + "path": "M118.2,49.3 119.0,47.8 121.4,45.4 123.4,42.9 124.6,41.0 125.9,38.5 127.0,35.4 127.6,32.6 128.0,29.2 127.9,26.3 127.5,22.9 126.8,20.1 125.6,16.9 124.1,14.0 122.2,11.4 120.3,9.5 117.9,7.6 115.2,6.1 111.9,4.9 108.0,4.5 104.6,4.8 101.2,5.9 97.7,7.8 94.9,10.2 92.2,13.5 90.4,16.9 89.0,20.6 88.2,24.5 88.0,28.6 88.4,32.6 89.5,37.0 91.4,41.0 93.5,44.2 96.9,47.6 97.7,49.1 98.0,50.4 98.0,51.2 98.0,51.2 118.0,51.2 118.0,51.2 118.0,50.6Z", + "anchor": [ + 108.0, + 27.4 + ], + "bbox": [ + 88.0, + 4.5, + 128.0, + 51.2 + ] + }, + { + "code": "2200", + "view": "back", + "side": "midline", + "region": "trunk", + "name": "Neck", + "molemapper_shape": 2200, + "path": "M118.3,60.1 118.0,58.4 118.0,51.2 98.0,51.2 98.0,58.4 97.7,59.9 97.3,60.9 96.5,61.7 95.7,62.2 95.7,62.2 97.9,75.5 80.1,82.8 80.1,82.9 80.1,82.8 97.9,75.5 108.0,75.5 118.1,75.5 118.2,75.2 118.1,75.5 120.3,62.2 119.5,61.7Z", + "anchor": [ + 108.0, + 68.7 + ], + "bbox": [ + 80.1, + 51.2, + 120.3, + 82.9 + ] + }, + { + "code": "2250", + "view": "back", + "side": "left", + "region": "trunk", + "name": "Left Upper Back", + "molemapper_shape": 2250, + "path": "M80.1,82.8 71.8,131.1 108.0,131.1 108.0,75.5 97.9,75.5Z", + "anchor": [ + 92.0, + 106.9 + ], + "bbox": [ + 71.8, + 75.5, + 108.0, + 131.1 + ] + }, + { + "code": "2251", + "view": "back", + "side": "right", + "region": "trunk", + "name": "Right Upper Back", + "molemapper_shape": 2251, + "path": "M144.2,131.1 135.9,82.8 118.1,75.5 108.0,75.5 108.0,131.1Z", + "anchor": [ + 124.0, + 106.9 + ], + "bbox": [ + 108.0, + 75.5, + 144.2, + 131.1 + ] + }, + { + "code": "2300", + "view": "back", + "side": "left", + "region": "trunk", + "name": "Left Lower Back", + "molemapper_shape": 300, + "path": "M108.0,131.1 71.8,131.1 71.8,131.1 71.8,131.3 72.6,131.7 73.8,132.9 74.4,134.4 76.0,148.5 74.0,167.9 73.4,170.1 108.0,170.1Z", + "anchor": [ + 91.5, + 158.2 + ], + "bbox": [ + 71.8, + 131.1, + 108.0, + 170.1 + ] + }, + { + "code": "2301", + "view": "back", + "side": "right", + "region": "trunk", + "name": "Right Lower Back", + "molemapper_shape": 301, + "path": "M108.0,131.1 108.0,131.1 108.0,170.1 142.6,170.1 142.0,167.9 140.0,148.5 141.6,134.4 142.3,132.7 143.7,131.4 144.2,131.2 144.2,131.1Z", + "anchor": [ + 124.5, + 158.2 + ], + "bbox": [ + 108.0, + 131.1, + 144.2, + 170.1 + ] + }, + { + "code": "2350", + "view": "back", + "side": "left", + "region": "trunk", + "name": "Left Glute", + "molemapper_shape": 2350, + "path": "M142.6,170.1 142.6,170.1 108.0,170.1 73.4,170.1 73.4,170.1 68.0,190.0 66.8,204.8 65.9,208.3 65.5,212.1 66.2,219.6 108.0,219.6 108.0,170.1Z", + "anchor": [ + 87.7, + 197.4 + ], + "bbox": [ + 65.5, + 170.1, + 142.6, + 219.6 + ] + }, + { + "code": "2351", + "view": "back", + "side": "right", + "region": "trunk", + "name": "Right Glute", + "molemapper_shape": 2351, + "path": "M108.0,219.6 149.8,219.6 150.5,212.1 150.1,208.3 149.2,204.8 148.0,190.0 142.6,170.1 108.0,170.1Z", + "anchor": [ + 128.3, + 197.4 + ], + "bbox": [ + 108.0, + 170.1, + 150.5, + 219.6 + ] + }, + { + "code": "2400", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Upper Thigh", + "molemapper_shape": 40, + "path": "M66.2,219.6 69.6,254.6 100.4,254.6 102.8,230.2 103.4,228.4 104.5,227.1 106.1,226.3 108.0,226.1 108.0,219.6Z", + "anchor": [ + 85.0, + 242.4 + ], + "bbox": [ + 66.2, + 219.6, + 108.0, + 254.6 + ] + }, + { + "code": "2401", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Upper Thigh", + "molemapper_shape": 40, + "path": "M108.0,226.1 108.5,226.1 110.3,226.5 111.6,227.2 112.6,228.4 113.1,229.8 115.6,254.6 146.4,254.6 149.8,219.6 108.0,219.6Z", + "anchor": [ + 131.0, + 242.2 + ], + "bbox": [ + 108.0, + 219.6, + 149.8, + 254.6 + ] + }, + { + "code": "2450", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M72.0,278.6 71.7,298.6 97.1,298.6 98.0,278.8 100.4,254.6 69.6,254.6Z", + "anchor": [ + 85.0, + 266.6 + ], + "bbox": [ + 69.6, + 254.6, + 100.4, + 298.6 + ] + }, + { + "code": "2451", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Lower Thigh & Knee", + "molemapper_shape": 45, + "path": "M118.0,278.8 118.9,298.6 144.3,298.6 144.0,278.6 146.4,254.6 115.6,254.6Z", + "anchor": [ + 131.0, + 266.6 + ], + "bbox": [ + 115.6, + 254.6, + 146.4, + 298.6 + ] + }, + { + "code": "2500", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Upper Calf", + "molemapper_shape": 50, + "path": "M71.6,313.2 74.2,329.1 94.7,329.1 96.4,313.2 97.1,298.6 71.7,298.6Z", + "anchor": [ + 84.2, + 321.1 + ], + "bbox": [ + 71.6, + 298.6, + 97.1, + 329.1 + ] + }, + { + "code": "2501", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Upper Calf", + "molemapper_shape": 50, + "path": "M119.6,313.2 121.3,329.1 141.8,329.1 144.4,313.2 144.3,298.6 118.9,298.6Z", + "anchor": [ + 131.8, + 321.1 + ], + "bbox": [ + 118.9, + 298.6, + 144.4, + 329.1 + ] + }, + { + "code": "2550", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Lower Calf", + "molemapper_shape": 55, + "path": "M77.3,347.5 75.5,357.1 92.8,357.1 93.0,350.1 92.7,346.9 94.7,329.1 74.2,329.1Z", + "anchor": [ + 84.7, + 338.0 + ], + "bbox": [ + 74.2, + 329.1, + 94.7, + 357.1 + ] + }, + { + "code": "2551", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Lower Calf", + "molemapper_shape": 55, + "path": "M123.3,346.9 123.0,350.1 123.2,357.1 140.5,357.1 138.7,347.5 141.8,329.1 121.3,329.1Z", + "anchor": [ + 131.3, + 338.0 + ], + "bbox": [ + 121.3, + 329.1, + 141.8, + 357.1 + ] + }, + { + "code": "2600", + "view": "back", + "side": "left", + "region": "leg", + "name": "Left Ankle & Foot", + "molemapper_shape": 60, + "path": "M70.1,387.3 70.0,389.6 70.3,391.7 71.0,393.7 71.9,395.2 73.2,396.7 74.4,397.8 75.9,398.7 77.8,399.5 79.4,399.9 81.1,400.0 83.1,399.8 84.7,399.4 86.2,398.7 87.9,397.5 89.2,396.3 90.4,394.7 91.2,393.2 91.7,391.6 92.0,389.5 92.8,357.1 75.5,357.1Z", + "anchor": [ + 82.6, + 372.2 + ], + "bbox": [ + 70.0, + 357.1, + 92.8, + 400.0 + ] + }, + { + "code": "2601", + "view": "back", + "side": "right", + "region": "leg", + "name": "Right Ankle & Foot", + "molemapper_shape": 60, + "path": "M124.0,389.5 124.2,391.1 125.0,393.7 126.2,395.6 128.0,397.5 130.2,398.9 132.4,399.7 135.1,400.0 137.7,399.7 140.2,398.7 142.4,397.1 144.2,395.1 145.4,392.7 146.0,389.6 145.9,387.3 140.5,357.1 123.2,357.1Z", + "anchor": [ + 133.4, + 372.2 + ], + "bbox": [ + 123.2, + 357.1, + 146.0, + 400.0 + ] + }, + { + "code": "2650", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Shoulder", + "molemapper_shape": 650, + "path": "M70.0,68.5 62.5,77.0 62.9,81.3 62.8,82.7 57.5,99.1 57.5,99.1 76.0,106.5 76.0,106.5 80.1,82.9 80.1,82.8 97.9,75.5 95.7,62.2 94.9,62.7 85.2,63.4 84.0,63.5 78.7,65.4Z", + "anchor": [ + 69.4, + 91.0 + ], + "bbox": [ + 57.5, + 62.2, + 97.9, + 106.5 + ] + }, + { + "code": "2651", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Shoulder", + "molemapper_shape": 651, + "path": "M130.8,63.4 121.1,62.7 120.3,62.2 118.2,75.2 118.1,75.5 135.9,82.8 140.0,106.5 140.0,106.5 158.5,99.1 158.5,99.1 153.2,82.7 153.1,81.3 153.5,77.0 146.0,68.5 137.3,65.4 132.0,63.5Z", + "anchor": [ + 146.6, + 90.9 + ], + "bbox": [ + 118.1, + 62.2, + 158.5, + 106.5 + ] + }, + { + "code": "2700", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Upper Arm", + "molemapper_shape": 700, + "path": "M57.5,99.1 45.8,135.4 62.6,141.8 65.8,133.6 67.0,132.0 68.7,131.0 69.7,130.8 70.8,130.9 71.8,131.3 71.8,131.1 71.8,131.1 76.0,106.5Z", + "anchor": [ + 62.6, + 118.6 + ], + "bbox": [ + 45.8, + 99.1, + 76.0, + 141.8 + ] + }, + { + "code": "2701", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Upper Arm", + "molemapper_shape": 701, + "path": "M158.5,99.1 140.0,106.5 144.2,131.1 144.2,131.1 144.2,131.2 145.4,130.8 147.3,131.0 149.0,132.0 150.2,133.6 153.4,141.8 170.2,135.4Z", + "anchor": [ + 153.4, + 118.7 + ], + "bbox": [ + 140.0, + 99.1, + 170.2, + 141.8 + ] + }, + { + "code": "2750", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Elbow", + "molemapper_shape": 750, + "path": "M37.5,161.7 37.5,161.7 52.3,167.3 52.3,167.3 62.6,141.8 62.6,141.8 62.6,141.8 45.8,135.4 45.8,135.4 41.9,147.5Z", + "anchor": [ + 48.5, + 154.6 + ], + "bbox": [ + 37.5, + 135.4, + 62.6, + 167.3 + ] + }, + { + "code": "2751", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Elbow", + "molemapper_shape": 751, + "path": "M170.2,135.4 153.4,141.8 163.7,167.3 163.7,167.3 178.5,161.7 178.5,161.7 174.1,147.6Z", + "anchor": [ + 167.5, + 154.6 + ], + "bbox": [ + 153.4, + 135.4, + 178.5, + 167.3 + ] + }, + { + "code": "2800", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Lower Forearm", + "molemapper_shape": 800, + "path": "M29.2,188.2 42.0,193.1 52.3,167.3 37.5,161.7Z", + "anchor": [ + 40.3, + 177.8 + ], + "bbox": [ + 29.2, + 161.7, + 52.3, + 193.1 + ] + }, + { + "code": "2801", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Lower Forearm", + "molemapper_shape": 801, + "path": "M178.5,161.7 163.7,167.3 174.0,193.1 186.8,188.2Z", + "anchor": [ + 175.7, + 177.8 + ], + "bbox": [ + 163.7, + 161.7, + 186.8, + 193.1 + ] + }, + { + "code": "2850", + "view": "back", + "side": "left", + "region": "arm", + "name": "Left Hand", + "molemapper_shape": 850, + "path": "M27.3,193.9 25.8,195.8 25.1,197.2 13.0,229.1 12.6,230.7 12.5,232.4 12.7,234.0 13.1,235.1 13.3,235.6 14.1,237.0 15.3,238.3 16.6,239.3 18.1,240.0 19.7,240.4 22.3,240.4 24.9,239.5 25.0,239.5 27.0,238.0 28.7,235.7 39.2,202.2 39.7,198.7 42.0,193.1 29.2,188.2 29.2,188.2 27.8,192.9Z", + "anchor": [ + 26.5, + 215.7 + ], + "bbox": [ + 12.5, + 188.2, + 42.0, + 240.4 + ] + }, + { + "code": "2851", + "view": "back", + "side": "right", + "region": "arm", + "name": "Right Hand", + "molemapper_shape": 851, + "path": "M203.5,231.5 203.0,229.1 190.9,197.2 190.2,195.8 188.7,193.9 188.2,192.9 186.8,188.2 186.8,188.2 174.0,193.1 176.3,198.7 176.8,202.2 186.9,234.4 187.7,236.3 188.5,237.4 189.6,238.5 191.2,239.5 191.8,239.9 193.3,240.3 194.6,240.5 196.2,240.4 197.5,240.1 200.0,238.9 201.0,238.0 202.0,236.8 202.7,235.6 202.9,235.1 203.2,234.1Z", + "anchor": [ + 189.5, + 215.7 + ], + "bbox": [ + 174.0, + 188.2, + 203.5, + 240.5 + ] + } + ] +} diff --git a/frontend/src/features/bodymap/zones.ts b/frontend/src/features/bodymap/zones.ts new file mode 100644 index 0000000..7e4eac1 --- /dev/null +++ b/frontend/src/features/bodymap/zones.ts @@ -0,0 +1,46 @@ +import data from "./zones.json"; + +export type View = "front" | "back"; +export type Side = "left" | "right" | "midline"; +export type Region = "head" | "trunk" | "arm" | "leg"; + +export interface Zone { + code: string; + view: View; + side: Side; + region: Region; + name: string; + molemapper_shape: number; + path: string; + anchor: [number, number]; + bbox: [number, number, number, number]; +} + +interface BodyMapData { + version: string; + viewBox: [number, number, number, number]; + attribution: string; + views: Record; + zones: Zone[]; +} + +export const bodyMap = data as BodyMapData; +export const VIEWS: View[] = ["front", "back"]; +export const [, , MAP_WIDTH, MAP_HEIGHT] = bodyMap.viewBox; + +const byCode = new Map(bodyMap.zones.map((zone) => [zone.code, zone])); + +export function zonesForView(view: View): Zone[] { + return bodyMap.zones.filter((zone) => zone.view === view); +} + +export function zoneByCode(code: string): Zone | undefined { + return byCode.get(code); +} + +/** A location on the map: a zone plus a point normalised to the view box (0..1), as the backend stores it. */ +export interface MapPoint { + zone: string; + x: number; + y: number; +} diff --git a/frontend/src/features/persons/PersonPage.tsx b/frontend/src/features/persons/PersonPage.tsx index ca5d40e..739ec97 100644 --- a/frontend/src/features/persons/PersonPage.tsx +++ b/frontend/src/features/persons/PersonPage.tsx @@ -2,6 +2,9 @@ import { useRef, useState, type ChangeEvent } from "react"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router"; import { Button } from "@/design-system/components/Button"; +import { BodyMap } from "@/features/bodymap/BodyMap"; +import { ViewToggle } from "@/features/bodymap/ViewToggle"; +import { type View, type Zone } from "@/features/bodymap/zones"; import { EmptyState } from "@/design-system/components/EmptyState"; import { Notice } from "@/design-system/components/Notice"; import { @@ -26,6 +29,8 @@ export function PersonPage() { const remove = useDeleteImage(personId); const [role, setRole] = useState("close_up"); const [open, setOpen] = useState(null); + const [view, setView] = useState("front"); + const [zone, setZone] = useState(null); const dialog = useRef(null); const canEdit = person.data?.my_role === "owner" || person.data?.my_role === "manager"; @@ -56,6 +61,28 @@ export function PersonPage() { {person.error && {person.error.message}} + + + {t("bodymap.title")} + { + setView(next); + setZone(null); + }} + /> + + + + {zone + ? t("bodymap.selected", { + zone: t(`zones.${zone.code}`, { defaultValue: zone.name }), + view: t(`bodymap.views.${view}`), + }) + : t("bodymap.hint")} + + + {canEdit && ( {t("images.addPhoto")} diff --git a/frontend/src/features/persons/persons.module.css b/frontend/src/features/persons/persons.module.css index b433b2e..d3a29a4 100644 --- a/frontend/src/features/persons/persons.module.css +++ b/frontend/src/features/persons/persons.module.css @@ -127,3 +127,12 @@ padding: var(--space-3) var(--space-4); color: var(--color-brand-ink); } + +.mapSection { + display: grid; + gap: var(--space-3); +} + +.mapSection h2 { + font-size: var(--text-lg); +} diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index 099805c..d3fd91a 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -146,6 +146,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/bodymap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Body Map */ + get: operations["get_body_map_api_bodymap_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/images/{image_id}": { parameters: { query?: never; @@ -395,6 +412,26 @@ export interface components { /** Username */ username: string; }; + /** BodyMap */ + BodyMap: { + /** Attribution */ + attribution: string; + /** Version */ + version: string; + /** Viewbox */ + viewBox: [ + number, + number, + number, + number + ]; + /** Views */ + views: { + [key: string]: components["schemas"]["ViewData"]; + }; + /** Zones */ + zones: components["schemas"]["Zone"][]; + }; /** Body_upload_image_api_persons__person_id__images_post */ Body_upload_image_api_persons__person_id__images_post: { /** Captured At */ @@ -663,6 +700,51 @@ export interface components { /** Error Type */ type: string; }; + /** ViewData */ + ViewData: { + /** Silhouette */ + silhouette: string; + /** Zones */ + zones: string[]; + }; + /** Zone */ + Zone: { + /** Anchor */ + anchor: [ + number, + number + ]; + /** Bbox */ + bbox: [ + number, + number, + number, + number + ]; + /** Code */ + code: string; + /** Molemapper Shape */ + molemapper_shape: number; + /** Name */ + name: string; + /** Path */ + path: string; + /** + * Region + * @enum {string} + */ + region: "head" | "trunk" | "arm" | "leg"; + /** + * Side + * @enum {string} + */ + side: "left" | "right" | "midline"; + /** + * View + * @enum {string} + */ + view: "front" | "back"; + }; }; responses: never; parameters: never; @@ -893,6 +975,26 @@ export interface operations { }; }; }; + get_body_map_api_bodymap_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BodyMap"]; + }; + }; + }; + }; get_image_api_images__image_id__get: { parameters: { query?: never; diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 328b202..c7e6f24 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -4,9 +4,21 @@ "tagline": "Because 'I think it was smaller' is not data.", "intendedUse": "neVus is a personal documentation and measurement aid. It does not diagnose, screen for, or assess the risk of any disease." }, - "common": { "working": "Working…", "cancel": "Cancel", "close": "Close", "back": "Back" }, - "shell": { "language": "Language", "navigation": "Main", "persons": "Persons" }, - "languages": { "en": "English", "es": "Spanish" }, + "common": { + "working": "Working…", + "cancel": "Cancel", + "close": "Close", + "back": "Back" + }, + "shell": { + "language": "Language", + "navigation": "Main", + "persons": "Persons" + }, + "languages": { + "en": "English", + "es": "Spanish" + }, "auth": { "username": "Username", "password": "Password", @@ -27,7 +39,11 @@ "create": "Create", "emptyTitle": "No persons yet", "emptyText": "A person is somebody whose skin marks you keep a record of: you, or someone you look after.", - "role": { "owner": "Owner", "manager": "Manager", "viewer": "Viewer" } + "role": { + "owner": "Owner", + "manager": "Manager", + "viewer": "Viewer" + } }, "images": { "addPhoto": "Add a photo", @@ -54,9 +70,85 @@ "settings": { "title": "Settings", "theme": "Theme", - "themes": { "system": "Follow the device", "light": "Light", "dark": "Dark" }, + "themes": { + "system": "Follow the device", + "light": "Light", + "dark": "Dark" + }, "showUncertainty": "Show measurement uncertainty (±)", - "roles": { "admin": "administrator", "member": "member" }, + "roles": { + "admin": "administrator", + "member": "member" + }, "signOut": "Sign out" + }, + "bodymap": { + "label": "Body map, {{view}}", + "view": "View", + "views": { + "front": "Front", + "back": "Back" + }, + "title": "Body map", + "hint": "Tap a zone to select it.", + "selected": "{{zone}} · {{view}}" + }, + "zones": { + "1100": "Head", + "1200": "Neck and centre of chest", + "1250": "Right pectoral", + "1251": "Left pectoral", + "1300": "Right abdomen", + "1301": "Left abdomen", + "1350": "Right pelvis", + "1351": "Left pelvis", + "1400": "Right upper thigh", + "1401": "Left upper thigh", + "1450": "Right lower thigh and knee", + "1451": "Left lower thigh and knee", + "1500": "Right upper calf", + "1501": "Left upper calf", + "1550": "Right lower calf", + "1551": "Left lower calf", + "1600": "Right ankle and foot", + "1601": "Left ankle and foot", + "1650": "Right shoulder", + "1651": "Left shoulder", + "1700": "Right upper arm", + "1701": "Left upper arm", + "1750": "Right upper forearm", + "1751": "Left upper forearm", + "1800": "Right lower forearm", + "1801": "Left lower forearm", + "1850": "Right hand", + "1851": "Left hand", + "2100": "Head", + "2200": "Neck", + "2250": "Left upper back", + "2251": "Right upper back", + "2300": "Left lower back", + "2301": "Right lower back", + "2350": "Left buttock", + "2351": "Right buttock", + "2400": "Left upper thigh", + "2401": "Right upper thigh", + "2450": "Left lower thigh and knee", + "2451": "Right lower thigh and knee", + "2500": "Left upper calf", + "2501": "Right upper calf", + "2550": "Left lower calf", + "2551": "Right lower calf", + "2600": "Left ankle and foot", + "2601": "Right ankle and foot", + "2650": "Left shoulder", + "2651": "Right shoulder", + "2700": "Left upper arm", + "2701": "Right upper arm", + "2750": "Left elbow", + "2751": "Right elbow", + "2800": "Left lower forearm", + "2801": "Right lower forearm", + "2850": "Left hand", + "2851": "Right hand" } } diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 93f5d34..1207435 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -4,9 +4,21 @@ "tagline": "Porque «creo que era más pequeño» no es un dato.", "intendedUse": "neVus es una ayuda personal de documentación y medición. No diagnostica, no criba ni evalúa el riesgo de ninguna enfermedad." }, - "common": { "working": "Un momento…", "cancel": "Cancelar", "close": "Cerrar", "back": "Volver" }, - "shell": { "language": "Idioma", "navigation": "Principal", "persons": "Personas" }, - "languages": { "en": "Inglés", "es": "Español" }, + "common": { + "working": "Un momento…", + "cancel": "Cancelar", + "close": "Cerrar", + "back": "Volver" + }, + "shell": { + "language": "Idioma", + "navigation": "Principal", + "persons": "Personas" + }, + "languages": { + "en": "Inglés", + "es": "Español" + }, "auth": { "username": "Usuario", "password": "Contraseña", @@ -27,7 +39,11 @@ "create": "Crear", "emptyTitle": "Todavía no hay personas", "emptyText": "Una persona es alguien de cuyas marcas en la piel llevas registro: tú o alguien a tu cargo.", - "role": { "owner": "Propietario", "manager": "Gestor", "viewer": "Lector" } + "role": { + "owner": "Propietario", + "manager": "Gestor", + "viewer": "Lector" + } }, "images": { "addPhoto": "Añadir una foto", @@ -54,9 +70,85 @@ "settings": { "title": "Ajustes", "theme": "Tema", - "themes": { "system": "Como el dispositivo", "light": "Claro", "dark": "Oscuro" }, + "themes": { + "system": "Como el dispositivo", + "light": "Claro", + "dark": "Oscuro" + }, "showUncertainty": "Mostrar la incertidumbre de las medidas (±)", - "roles": { "admin": "administración", "member": "miembro" }, + "roles": { + "admin": "administración", + "member": "miembro" + }, "signOut": "Cerrar sesión" + }, + "bodymap": { + "label": "Mapa corporal, {{view}}", + "view": "Vista", + "views": { + "front": "Frente", + "back": "Espalda" + }, + "title": "Mapa corporal", + "hint": "Toca una zona para seleccionarla.", + "selected": "{{zone}} · {{view}}" + }, + "zones": { + "1100": "Cabeza", + "1200": "Cuello y centro del pecho", + "1250": "Pectoral derecho", + "1251": "Pectoral izquierdo", + "1300": "Abdomen derecho", + "1301": "Abdomen izquierdo", + "1350": "Pelvis derecha", + "1351": "Pelvis izquierda", + "1400": "Muslo superior derecho", + "1401": "Muslo superior izquierdo", + "1450": "Muslo inferior y rodilla derechos", + "1451": "Muslo inferior y rodilla izquierdos", + "1500": "Pantorrilla superior derecha", + "1501": "Pantorrilla superior izquierda", + "1550": "Pantorrilla inferior derecha", + "1551": "Pantorrilla inferior izquierda", + "1600": "Tobillo y pie derechos", + "1601": "Tobillo y pie izquierdos", + "1650": "Hombro derecho", + "1651": "Hombro izquierdo", + "1700": "Brazo derecho", + "1701": "Brazo izquierdo", + "1750": "Antebrazo superior derecho", + "1751": "Antebrazo superior izquierdo", + "1800": "Antebrazo inferior derecho", + "1801": "Antebrazo inferior izquierdo", + "1850": "Mano derecha", + "1851": "Mano izquierda", + "2100": "Cabeza", + "2200": "Cuello", + "2250": "Espalda superior izquierda", + "2251": "Espalda superior derecha", + "2300": "Espalda inferior izquierda", + "2301": "Espalda inferior derecha", + "2350": "Glúteo izquierdo", + "2351": "Glúteo derecho", + "2400": "Muslo superior izquierdo", + "2401": "Muslo superior derecho", + "2450": "Muslo inferior y rodilla izquierdos", + "2451": "Muslo inferior y rodilla derechos", + "2500": "Pantorrilla superior izquierda", + "2501": "Pantorrilla superior derecha", + "2550": "Pantorrilla inferior izquierda", + "2551": "Pantorrilla inferior derecha", + "2600": "Tobillo y pie izquierdos", + "2601": "Tobillo y pie derechos", + "2650": "Hombro izquierdo", + "2651": "Hombro derecho", + "2700": "Brazo izquierdo", + "2701": "Brazo derecho", + "2750": "Codo izquierdo", + "2751": "Codo derecho", + "2800": "Antebrazo inferior izquierdo", + "2801": "Antebrazo inferior derecho", + "2850": "Mano izquierda", + "2851": "Mano derecha" } } diff --git a/frontend/tests/bodymap.test.tsx b/frontend/tests/bodymap.test.tsx new file mode 100644 index 0000000..53c5e69 --- /dev/null +++ b/frontend/tests/bodymap.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { BodyMap } from "@/features/bodymap/BodyMap"; +import { bodyMap, zoneByCode, zonesForView } from "@/features/bodymap/zones"; +import i18n from "@/lib/i18n"; + +describe("body map data", () => { + it("carries 28 zones per view that tile the same silhouette", () => { + expect(bodyMap.version).toBe("nevus-body-map/1"); + expect(zonesForView("front")).toHaveLength(28); + expect(zonesForView("back")).toHaveLength(28); + expect(bodyMap.views.front.silhouette).toBe(bodyMap.views.back.silhouette); + expect(zoneByCode("1250")?.side).toBe("right"); + expect(zoneByCode("2250")?.side).toBe("left"); + }); +}); + +describe("", () => { + it("offers every zone as a named button and reports the tapped zone with a point inside it", async () => { + await i18n.changeLanguage("en"); + const onSelectZone = vi.fn(); + const onPlace = vi.fn(); + render(); + expect(screen.getAllByRole("button")).toHaveLength(28); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Right pectoral" })); + expect(onSelectZone).toHaveBeenCalledWith(expect.objectContaining({ code: "1250" })); + const point = onPlace.mock.calls[0]?.[0] as { zone: string; x: number; y: number }; + expect(point.zone).toBe("1250"); + const zone = zoneByCode("1250"); + if (!zone) throw new Error("zone 1250 missing"); + const [left, top, right, bottom] = zone.bbox; + expect(point.x * 216).toBeGreaterThanOrEqual(left); + expect(point.x * 216).toBeLessThanOrEqual(right); + expect(point.y * 404).toBeGreaterThanOrEqual(top); + expect(point.y * 404).toBeLessThanOrEqual(bottom); + }); + + it("names zones in the account language and marks the selected one", async () => { + await i18n.changeLanguage("es"); + render(); + expect(screen.getByRole("button", { name: "Glúteo izquierdo" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Hombro derecho" })).toHaveAttribute("aria-pressed", "false"); + }); + + it("renders markers at normalised positions and lets them be chosen", async () => { + await i18n.changeLanguage("en"); + const onSelectMarker = vi.fn(); + render( + , + ); + const marker = screen.getByRole("button", { name: "Chest mark" }); + expect(marker).toHaveAttribute("transform", "translate(108 101)"); + await userEvent.setup().click(marker); + expect(onSelectMarker).toHaveBeenCalledWith("m1"); + }); +}); diff --git a/justfile b/justfile index 487cdeb..a91d0ea 100644 --- a/justfile +++ b/justfile @@ -39,6 +39,10 @@ openapi: cd backend && uv run nevus openapi > ../frontend/openapi.json cd frontend && pnpm openapi +# Regenerate the body map (zones.json in backend and frontend, previews in docs). +bodymap: + uv run tools/bodymap/build.py --preview docs/design/bodymap + # Database migrations. migrate: cd backend && uv run alembic upgrade head diff --git a/tools/bodymap/build.py b/tools/bodymap/build.py new file mode 100644 index 0000000..100006c --- /dev/null +++ b/tools/bodymap/build.py @@ -0,0 +1,229 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = ["shapely>=2.1", "cairosvg>=2.7"] +# /// +# SPDX-License-Identifier: AGPL-3.0-only +"""Build the neVus body map: silhouettes, zone regions and the shared zones.json. + +The silhouette is neVus artwork: a flat, gender-neutral pictogram built from a +skeleton of tapered capsules that is unioned and rounded. The zone regions are +MoleMapper's hit polygons (see molemapper_zones.py) clipped to that silhouette; +any part of the silhouette no polygon covers is given to the neighbouring zone +with the longest shared edge, so the zones tile the figure exactly. + +Usage: uv run tools/bodymap/build.py [--preview DIR] +Writes backend/src/nevus/bodymap/zones.json and frontend/src/features/bodymap/zones.json. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path + +from shapely import affinity +from shapely.geometry import MultiPolygon, Point, Polygon +from shapely.ops import unary_union + +sys.path.insert(0, str(Path(__file__).parent)) +from molemapper_zones import GRID, PLACEMENTS, absolute_polygon, region_of, side_of + +ROOT = Path(__file__).resolve().parents[2] +OUTPUTS = [ + ROOT / "backend" / "src" / "nevus" / "bodymap" / "zones.json", + ROOT / "frontend" / "src" / "features" / "bodymap" / "zones.json", +] +VERSION = "nevus-body-map/1" +W, H = GRID +MID = W / 2 + +# Order matters: trunk before limbs so that overlapping hit polygons favour the trunk. +ORDER = ["head", "trunk", "leg", "arm"] + + +def capsule(a: tuple[float, float], ra: float, b: tuple[float, float], rb: float) -> Polygon: + """Tapered capsule: convex hull of two discs.""" + return unary_union([Point(a).buffer(ra, 32), Point(b).buffer(rb, 32)]).convex_hull + + +def chain(points: list[tuple[tuple[float, float], float]]) -> Polygon: + parts = [capsule(points[i][0], points[i][1], points[i + 1][0], points[i + 1][1]) for i in range(len(points) - 1)] + return unary_union(parts) + + +def ellipse(cx: float, cy: float, rx: float, ry: float) -> Polygon: + return affinity.scale(Point(cx, cy).buffer(1, 64), rx, ry) + + +def mirror(geom: Polygon) -> Polygon: + return affinity.scale(geom, xfact=-1, yfact=1, origin=(MID, 0)) + + +def silhouette() -> Polygon: + head = ellipse(MID, 28.0, 20.0, 23.5) + neck = Polygon([(MID - 10.0, 44), (MID + 10.0, 44), (MID + 10.0, 66), (MID - 10.0, 66)]) + # Right half of the trunk (screen left), mirrored below. Shoulder line, side, hip, crotch. + trunk_right = [ + (MID, 62.0), + (MID - 24.0, 63.5), + (MID - 38.0, 68.5), + (MID - 45.5, 77.0), + (MID - 44.0, 92.0), + (MID - 37.0, 106.0), + (MID - 35.0, 122.0), + (MID - 32.0, 148.0), + (MID - 34.0, 168.0), + (MID - 40.0, 190.0), + (MID - 41.5, 208.0), + (MID - 35.0, 224.0), + (MID - 12.0, 228.0), + (MID, 226.0), + ] + trunk = Polygon(trunk_right + [(W - x, y) for x, y in reversed(trunk_right[1:-1])]) + arm = chain([((MID - 35.5, 88.0), 11.0), ((MID - 58.0, 150.0), 8.5), ((MID - 75.0, 198.0), 6.5)]) + hand = capsule((MID - 76.0, 200.0), 7.5, (MID - 87.0, 232.0), 8.5) + leg = chain( + [ + ((MID - 23.0, 212.0), 19.5), + ((MID - 23.0, 278.0), 13.0), + ((MID - 24.0, 312.0), 12.5), + ((MID - 23.0, 348.0), 7.5), + ] + ) + foot = capsule((MID - 23.0, 350.0), 8.0, (MID - 27.0, 389.0), 11.0) + right_side = unary_union([arm, hand, leg, foot]) + body = unary_union([head, neck, trunk, right_side, mirror(right_side)]) + smooth = body.buffer(4.5, 32).buffer(-4.5, 32) # closing: rounds the armpits, crotch and neck joins + smooth = smooth.simplify(0.12, preserve_topology=True) + assert isinstance(smooth, Polygon), type(smooth) + return smooth + + +def to_path(geom: Polygon | MultiPolygon, precision: int = 1) -> str: + polys = list(geom.geoms) if isinstance(geom, MultiPolygon) else [geom] + parts: list[str] = [] + for poly in polys: + rings = [poly.exterior, *poly.interiors] + for ring in rings: + coords = list(ring.coords)[:-1] + parts.append("M" + " ".join(f"{x:.{precision}f},{y:.{precision}f}" for x, y in coords) + "Z") + return "".join(parts) + + +def build_zones(body: Polygon, view: str) -> list[dict]: + placements = [p for p in PLACEMENTS if p[1] == view] + placements.sort(key=lambda p: ORDER.index(region_of(p[0]))) + taken: Polygon | MultiPolygon = Polygon() + regions: dict[str, Polygon | MultiPolygon] = {} + for code, _v, _ox, _oy, _name, _shape in placements: + hit = Polygon(absolute_polygon(code)).buffer(0) + region = hit.intersection(body).difference(taken) + regions[code] = region + taken = unary_union([taken, region]) + # Give every uncovered sliver to the neighbour that shares the longest edge with it. + leftover = body.difference(taken) + pieces = ( + list(leftover.geoms) if isinstance(leftover, MultiPolygon) else ([leftover] if not leftover.is_empty else []) + ) + for piece in pieces: + best, best_len = None, -1.0 + for code, region in regions.items(): + shared = piece.boundary.intersection(region.boundary.buffer(0.01)).length + if shared > best_len: + best, best_len = code, shared + assert best is not None + regions[best] = unary_union([regions[best], piece]) + result = [] + for code, _v, _ox, _oy, name, shape in placements: + region = regions[code].buffer(0) + rp = region.representative_point() + result.append( + { + "code": code, + "view": view, + "side": side_of(code, view, name), + "region": region_of(code), + "name": name, + "molemapper_shape": shape, + "path": to_path(region), + "anchor": [round(rp.x, 1), round(rp.y, 1)], + "bbox": [round(v, 1) for v in region.bounds], + } + ) + # Sanity: exact tiling, no overlaps. + union = unary_union(list(regions.values())) + assert abs(union.area - body.area) < 1e-6, (union.area, body.area) + codes = list(regions) + for i, a in enumerate(codes): + for b in codes[i + 1 :]: + overlap = regions[a].intersection(regions[b]).area + assert overlap < 1e-6, (a, b, overlap) + result.sort(key=lambda z: z["code"]) + return result + + +def preview_svg(body: Polygon, zones: list[dict], view: str) -> str: + palette = {"head": "#c9b7a3", "trunk": "#9fbfb9", "leg": "#b7c9a3", "arm": "#c9a3b7"} + parts = [ + '', + f'', + ] + for z in zones: + parts.append(f'') + parts.append(f'') + for z in zones: + x, y = z["anchor"] + parts.append( + f'{z["code"]}' + ) + parts.append(f'{view}') + return "".join(parts) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--preview", type=Path, help="directory for preview renders (SVG; PNG with --png)") + ap.add_argument("--png", action="store_true", help="also rasterise the previews") + args = ap.parse_args() + body = silhouette() + body_path = to_path(body) + views = {} + zones = [] + for view in ("front", "back"): + zs = build_zones(body, view) + views[view] = {"silhouette": body_path, "zones": [z["code"] for z in zs]} + zones.extend(zs) + payload = { + "version": VERSION, + "viewBox": [0, 0, W, H], + "attribution": ( + "Zone identifiers, names and hit polygons derived from MoleMapper (Oregon Health & Science University, " + "BSD 3-Clause; see THIRD_PARTY_NOTICES.md). Silhouette and zone geometry by the neVus project." + ), + "views": views, + "zones": zones, + } + text = json.dumps(payload, indent=2, ensure_ascii=False) + "\n" + payload_hash = hashlib.sha256(text.encode()).hexdigest()[:12] + for out in OUTPUTS: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text) + print(f"wrote {len(zones)} zones, silhouette {len(body.exterior.coords)} points, hash {payload_hash}") + if args.preview: + args.preview.mkdir(parents=True, exist_ok=True) + for view in ("front", "back"): + svg = preview_svg(body, [z for z in zones if z["view"] == view], view) + (args.preview / f"{view}.svg").write_text(svg + "\n") + if args.png: + import cairosvg + + cairosvg.svg2png(bytestring=svg.encode(), write_to=str(args.preview / f"{view}.png")) + print(f"previews in {args.preview}") + + +if __name__ == "__main__": + main() diff --git a/tools/bodymap/molemapper_zones.py b/tools/bodymap/molemapper_zones.py new file mode 100644 index 0000000..23ae3ab --- /dev/null +++ b/tools/bodymap/molemapper_zones.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: BSD-3-Clause +"""Zone taxonomy and hit polygons ported from MoleMapper (OHSU). + +Copyright (c) 2017-2022 Oregon Health & Science University. Ported from +``ohsu-molemapper/MoleMapper_Final`` at commit 4f3bd8a: the 32 base shapes in +``Source/Common/Helpers/VariableStore.m`` and their placement in +``Source/BodyMapDrawing/BodyFrontView.m``, ``BodyBackView.m`` and +``HeadDetailView.m``; the 61 identifiers and names in +``Source/CoreData/Zone30+CoreDataClass.swift``. The full BSD 3-Clause licence +text is reproduced in THIRD_PARTY_NOTICES.md at the repository root. + +Coordinates are points on MoleMapper's 216 x 404 drawing grid, kept verbatim. +The ``view``, ``side`` and ``region`` fields are additions for neVus: the last +digit of a MoleMapper identifier encodes the screen side, not the anatomical +side, and the names are written from the patient's point of view. +""" + +from __future__ import annotations + +GRID = (216.0, 404.0) + +# Base shape id -> list of (x, y) vertices, first vertex = moveTo, path closed implicitly. +BASE_SHAPES: dict[int, list[tuple[float, float]]] = { + 10: [(50.0, 50.0), (0.0, 50.0), (0.0, 0.0), (50.0, 0.0)], + 15: [(0.0, 43.0), (42.0, 43.0), (42.0, 0.0), (0.0, 0.0)], + 17: [(35.5, 0.0), (0.0, 0.0), (0.0, 43.0), (35.5, 43.0)], + 40: [(45.5, 35.0), (0.0, 35.0), (0.0, 0.0), (45.5, 0.0)], + 45: [(40.0, 44.0), (0.0, 44.0), (0.0, 0.0), (40.0, 0.0)], + 50: [(35.0, 30.5), (0.0, 30.5), (0.0, 0.0), (35.0, 0.0)], + 55: [(31.0, 28.0), (0.0, 28.0), (0.0, 0.0), (31.0, 0.0)], + 60: [(34.0, 0.0), (0.0, 0.0), (0.0, 45.0), (34.0, 45.0)], + 300: [(0.0, 4.46094), (0.0, 39.0), (37.0, 39.0), (37.0, 0.0), (0.80664, 0.0)], + 301: [(37.0, 4.46094), (37.0, 39.0), (0.0, 39.0), (0.0, 0.0), (36.1934, 0.0)], + 650: [ + (29.8516, 21.9092), + (26.0254, 45.6953), + (0.0, 35.2754), + (9.80566, 10.2275), + (43.2168, 0.0), + (45.2188, 0.0), + (47.6611, 14.6387), + ], + 651: [ + (17.8096, 21.9092), + (21.6357, 45.6953), + (47.6611, 35.2754), + (37.8555, 10.2275), + (4.44434, 0.0), + (2.44238, 0.0), + (0.0, 14.6387), + ], + 700: [(28.7383, 46.6055), (31.0684, 40.4463), (36.7207, 9.23047), (13.6592, 0.0), (0.0, 35.7324)], + 701: [(7.98242, 46.6055), (5.65234, 40.4463), (0.0, 9.23047), (23.0605, 0.0), (36.7207, 35.7324)], + 750: [(28.7383, 36.6104), (0.0, 25.7363), (9.73633, 0.0), (38.4746, 10.873)], + 751: [(9.73633, 36.6104), (38.4746, 25.7363), (28.7383, 0.0), (0.0, 10.873)], + 800: [(28.7383, 36.7227), (0.0, 25.8486), (10.0498, 0.0), (38.7881, 10.874)], + 801: [(10.0498, 36.7227), (38.7881, 25.8486), (28.7383, 0.0), (0.0, 10.874)], + 850: [(46.9912, 30.457), (35.1543, 61.7393), (0.0, 48.4395), (0.0, 31.2852), (11.8389, 0.0), (46.9922, 13.3018)], + 851: [(0.0, 30.457), (11.8379, 61.7393), (46.9922, 48.4395), (46.9912, 31.2852), (35.1533, 0.0), (0.0, 13.3018)], + 1200: [ + (2.00195, 9.65234), + (10.5547, 60.915), + (18.5625, 60.915), + (27.1152, 9.65234), + (29.1172, 9.65234), + (29.1172, 0.0), + (0.0, 0.0), + (0.0, 9.65234), + ], + 1250: [ + (0.0, 55.5898), + (36.1934, 55.5898), + (36.1934, 36.624), + (32.1895, 36.624), + (26.0781, 0.0), + (8.26953, 7.27051), + ], + 1251: [(36.1934, 55.5898), (0.0, 55.5898), (0.0, 36.624), (4.00391, 36.624), (10.1133, 0.0), (27.9238, 7.27051)], + 1350: [(44.5, 49.8569), (0.0, 33.2471), (7.5, 0.0), (44.5, 0.0)], + 1351: [(0.0, 49.8569), (44.5, 33.2471), (37.0, 0.0), (0.0, 0.0)], + 1400: [(46.5, 16.6279), (46.5, 51.2539), (0.0, 51.2539), (0.0, 0.0), (2.0, 0.0)], + 1401: [(0.0, 16.6279), (0.0, 51.2539), (46.5, 51.2539), (46.5, 0.0), (44.5, 0.0)], + 2200: [ + (24.6729, 24.291), + (27.1162, 9.65234), + (29.1182, 9.65234), + (29.1172, 0.0), + (0.0, 0.0), + (0.00098, 9.65234), + (2.00293, 9.65234), + (4.44434, 24.291), + ], + 2250: [(36.1934, 0.0), (26.0791, 0.0), (8.26953, 7.27051), (0.0, 55.5898), (36.1934, 55.5898)], + 2251: [(0.0, 0.0), (10.1133, 0.0), (27.9238, 7.27051), (36.1934, 55.5898), (0.0, 55.5898)], + 2350: [(8.5, 0.0), (45.5, 0.0), (45.5, 49.5), (0.0, 49.5), (0.0, 31.7139)], + 2351: [(37.0, 0.0), (0.0, 0.0), (0.0, 49.5), (45.5, 49.5), (45.5, 31.7139)], +} + +# (code, view, origin x, origin y, MoleMapper name, base shape id) +PLACEMENTS: list[tuple[str, str, float, float, str, int]] = [ + ("1100", "front", 83.0, 1.22705, "Head", 10), + ("1200", "front", 93.4414, 51.2275, "Neck & Center Chest", 1200), + ("1250", "front", 71.8076, 75.5186, "Right Pectoral", 1250), + ("1251", "front", 108.0, 75.5186, "Left Pectoral", 1251), + ("1300", "front", 71.0, 131.1084, "Right Abdomen", 300), + ("1301", "front", 108.0, 131.1084, "Left Abdomen", 301), + ("1350", "front", 63.499, 170.1084, "Right Pelvis", 1350), + ("1351", "front", 108.0, 170.1084, "Left Pelvis", 1351), + ("1400", "front", 61.5, 203.3555, "Right Upper Thigh", 1400), + ("1401", "front", 108.0, 203.3555, "Left Upper Thigh", 1401), + ("1450", "front", 65.5, 254.6094, "Right Lower Thigh & Knee", 45), + ("1451", "front", 110.5, 254.6094, "Left Lower Thigh & Knee", 45), + ("1500", "front", 66.5, 298.6094, "Right Upper Calf", 50), + ("1501", "front", 114.5, 298.6094, "Left Upper Calf", 50), + ("1550", "front", 69.5, 329.1094, "Right Lower Calf", 55), + ("1551", "front", 115.5, 329.1094, "Left Lower Calf", 55), + ("1600", "front", 65.0, 357.1094, "Right Ankle & Foot", 60), + ("1601", "front", 117.0, 357.1094, "Left Ankle & Foot", 60), + ("1650", "front", 50.2246, 60.8799, "Right Shoulder", 650), + ("1651", "front", 118.1143, 60.8799, "Left Shoulder", 651), + ("1700", "front", 39.5293, 97.3447, "Right Upper Arm", 700), + ("1701", "front", 139.75, 97.3447, "Left Upper Arm", 701), + ("1750", "front", 29.793, 133.0771, "Right Upper Forearm", 750), + ("1751", "front", 147.7324, 133.0771, "Left Upper Forearm", 751), + ("1800", "front", 19.7432, 158.8135, "Right Lower Forearm", 800), + ("1801", "front", 157.4688, 158.8135, "Left Lower Forearm", 801), + ("1850", "front", 1.48926, 182.2344, "Right Hand", 850), + ("1851", "front", 167.5186, 182.2344, "Left Hand", 851), + ("2100", "back", 83.0, 1.22705, "Head", 10), + ("2200", "back", 93.4414, 51.2275, "Neck", 2200), + ("2250", "back", 71.8076, 75.5186, "Left Upper Back", 2250), + ("2251", "back", 108.001, 75.5186, "Right Upper Back", 2251), + ("2300", "back", 71.0, 131.1084, "Left Lower Back", 300), + ("2301", "back", 108.0, 131.1084, "Right Lower Back", 301), + ("2350", "back", 62.501, 170.1094, "Left Glute", 2350), + ("2351", "back", 108.001, 170.1094, "Right Glute", 2351), + ("2400", "back", 62.501, 219.6094, "Left Upper Thigh", 40), + ("2401", "back", 108.001, 219.6094, "Right Upper Thigh", 40), + ("2450", "back", 65.5, 254.6094, "Left Lower Thigh & Knee", 45), + ("2451", "back", 110.5, 254.6094, "Right Lower Thigh & Knee", 45), + ("2500", "back", 66.5, 298.6094, "Left Upper Calf", 50), + ("2501", "back", 114.5, 298.6094, "Right Upper Calf", 50), + ("2550", "back", 69.5, 329.1094, "Left Lower Calf", 55), + ("2551", "back", 115.5, 329.1094, "Right Lower Calf", 55), + ("2600", "back", 65.0, 357.1094, "Left Ankle & Foot", 60), + ("2601", "back", 117.0, 357.1094, "Right Ankle & Foot", 60), + ("2650", "back", 50.2246, 60.8799, "Left Shoulder", 650), + ("2651", "back", 118.1143, 60.8799, "Right Shoulder", 651), + ("2700", "back", 39.5293, 97.3447, "Left Upper Arm", 700), + ("2701", "back", 139.75, 97.3447, "Right Upper Arm", 701), + ("2750", "back", 29.793, 133.0771, "Left Elbow", 750), + ("2751", "back", 147.7324, 133.0771, "Right Elbow", 751), + ("2800", "back", 19.7432, 158.8135, "Left Lower Forearm", 800), + ("2801", "back", 157.4688, 158.8135, "Right Lower Forearm", 801), + ("2850", "back", 1.48926, 182.2344, "Left Hand", 850), + ("2851", "back", 167.5186, 182.2344, "Right Hand", 851), + # Head detail view: kept for the detail views planned in later releases; no silhouette yet. + ("3150", "head", 43.25, 102.5, "Face: Left Side", 15), + ("3151", "head", 130.75, 102.5, "Face: Right Side", 15), + ("3170", "head", 90.25, 54.5, "Top of Head", 17), + ("3171", "head", 90.25, 102.5, "Face: Front", 17), + ("3172", "head", 90.25, 150.5, "Back of Head", 17), +] + + +def side_of(code: str, view: str, name: str) -> str: + """Anatomical side from the patient's point of view, taken from the name rather than the id digit.""" + if name.startswith("Right"): + return "right" + if name.startswith("Left"): + return "left" + if "Left" in name: + return "left" + if "Right" in name: + return "right" + return "midline" + + +def region_of(code: str) -> str: + n = int(code) + if n in (1100, 2100) or n >= 3000: + return "head" + hundreds = n % 1000 + if hundreds < 400: + return "trunk" + if hundreds < 650: + return "leg" + return "arm" + + +def absolute_polygon(code: str) -> list[tuple[float, float]]: + """The zone's hit polygon in grid coordinates (base shape translated to its origin).""" + for c, _view, ox, oy, _name, shape in PLACEMENTS: + if c == code: + return [(round(ox + x, 4), round(oy + y, 4)) for x, y in BASE_SHAPES[shape]] + raise KeyError(code)
+ {zone + ? t("bodymap.selected", { + zone: t(`zones.${zone.code}`, { defaultValue: zone.name }), + view: t(`bodymap.views.${view}`), + }) + : t("bodymap.hint")} +