Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 2 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
15 changes: 15 additions & 0 deletions backend/src/nevus/api/bodymap.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions backend/src/nevus/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
61 changes: 61 additions & 0 deletions backend/src/nevus/bodymap/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading