diff --git a/.gitignore b/.gitignore index 74a3feeb..7d8dfea1 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,9 @@ scratch/ plugins/ .claude/worktrees/sharp-burnell/ + +# thesis-only generated artifacts (regenerable from validation notebooks) +examples/head_models/thesis_results_out/ + +# machine-local scan-data symlink (points to /home/ma7/BA/PG_Subjects) +examples/head_models/PG_Subjects diff --git a/README.md b/README.md index 369cc7e5..f8788f85 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,111 @@ +# Face Anonymization for Photogrammetry Scans + +M.Sc. thesis at TU Berlin / IBS Lab: "Development of a Landmark-Aware Face-Removal Algorithm for Photogrammetric Head Scans for Data Protection". + +This is a fork of [cedalion](https://github.com/ibs-lab/cedalion) with a geometric face anonymization module for Einstar photogrammetry scans used in fNIRS research. The module lives at: + +``` +src/cedalion/geometry/photogrammetry/anonymization/ +``` + +## Installation + +Requires the cedalion conda environment: + +```bash +conda env create -f environment_dev.yml +conda activate cedalion +pip install -e . +``` + +## Usage + +### Interactive single-scan workflow + +Open `examples/head_models/51_manual_5pt_anonymization.ipynb`. The notebook: +1. Loads an Einstar scan (`cedalion.io.read_einstar_obj`) +2. Picks the five 10-20 landmarks interactively (Nz, Iz, Cz, LPA, RPA) +3. Calls `anonymize_scan(surface, landmarks)` +4. Shows a before/after comparison +5. Saves the anonymized OBJ + sanitized JPG texture bundle via `save_anonymized_scan` + +## Module structure + +``` +src/cedalion/geometry/photogrammetry/anonymization/ +├── __init__.py public API, re-exports all functions listed below +├── pipeline.py anonymize_scan (entry point) +├── preprocessing.py normalize_axes, isolate_head, align_axes_from_landmarks, +│ revert_to_einstar_frame +├── mask.py detect_cap_boundary, face_mask_from_landmarks, +│ delete_masked_vertices, save_anonymized_scan +└── _utils.py private helpers shared by preprocessing and mask + (_rebuild_mesh, _copy_visual, _reindex_faces, + _apply_affine, _transform_labeled_points, + _ear_midpoint, _upper_head_centroid, + _resolve_texture_image) + +examples/head_models/ +└── 51_manual_5pt_anonymization.ipynb interactive workflow notebook + +tests/ +└── test_anonymization.py 26 unit tests +``` + +Pipeline steps inside `anonymize_scan`: + +1. `normalize_axes`: rotate so +Y points anterior (handles arbitrary Einstar orientation) +2. `isolate_head`: remove body, shoulders, and disconnected fragments +3. `align_axes_from_landmarks`: map to CTF frame (+X anterior, +Y left, +Z up) +4. `detect_cap_boundary`: locate the front cap-edge height along Z +5. `face_mask_from_landmarks`: face region union ear spheres, clamped below the cap +6. Landmark preservation: 8 mm spheres around each landmark + midline nasion strip +7. `delete_masked_vertices`: drop triangles touching any masked vertex, UVs in sync +8. `revert_to_einstar_frame`: return to `crs="digitized"` for saving + +## Tests + +```bash +pytest tests/test_anonymization.py -v +``` + +26 tests covering all eight public functions and the end-to-end pipeline. No real scan data needed: all tests build synthetic geometry with `trimesh.creation.icosphere`, the same approach used elsewhere in cedalion (see `test_geodesics.py`, `test_dataclasses_geometry.py`). Three fixtures are shared: + +- `simple_sphere_surface`: unit icosphere for geometry-only checks +- `head_like_surface`: elongated icosphere (X scaled x1.2), giving the masking step a real face region to remove +- `axis_normalized_landmarks`: five `LabeledPoints` (Nz, Iz, Cz, LPA, RPA) on the sphere axes in the post-`normalize_axes` frame + +## Branch layout + +| Branch | Contents | +|--------|---------| +| `feature/face-anonymization` | **This branch**: thesis implementation (anonymization module, notebook 51, test suite) | +| `main` | Upstream cedalion base | +| `validation/face-anonymization` | Measurement notebooks behind the thesis results tables (see below) | +| `auxiliary/mediapipe-nasion` | Experimental automatic nasion detection (MediaPipe) | + +### Validation branch contents + +The notebooks on `validation/face-anonymization` produce the per-subject numbers in the thesis results tables. They run on top of the shipped pipeline's output and do not modify it. + +| Notebook | What it checks | Headline result on the 11-subject cohort | +|---|---|---| +| `64_batch_validation` | Driver that runs the four validators below on every subject and emits per-subject CSVs | per-subject CSVs feeding the thesis tables | +| `66_preservation_check` | 10-20 landmark deviation + bit-exact vertex preservation across the surviving surface | 0.000 mm across all 55 landmark measurements (5 landmarks x 11 subjects) | +| `68_coreg_invariance` | Cedalion `ColoredStickerProcessor` re-run on original vs anonymized mesh (optode-cap subcohort) | 131 matched stickers, 0 mm deviation in sticker centres and scalp-projected optode positions | +| `69_coreg_visual_check` | Side-by-side render of detected stickers on the original and anonymized mesh; any original-side detection without a match on the anonymized side is drawn in red so the operator can spot it | qualitative; the manual visibility check that backs notebook 68 | +| `70_auxiliary_nasion` | MediaPipe Face Landmarker auto-nasion vs the manually picked nasion | 13.68 mm cohort mean offset, 30.78 mm worst case | +| `72_face_detectability_comparison` | MediaPipe Face Detector hit counts under a 21-view sweep: original vs vertex-deletion vs noise-perturbation | 93/231 -> 34/231 (63% reduction); optode-cap subcohort 32/147 -> 4/147 (88%); noise reaches 28/231 (Wilcoxon p=0.375, not distinguishable from deletion) | +| `73_s8_mediapipe_boxes` | BlazeFace bounding box and six-keypoint inspection on a single subject | qualitative figure for the discussion of why post-deletion residual hits are silhouette artefacts | + +Helper modules `_thesis_data.py`, `_thesis_pipeline.py`, `_validator_noise.py`, and `_validator_render.py` hold the cohort walk, pipeline call, noise-perturbation operator, and contact-sheet rendering shared across these notebooks. + +--- + +*Upstream cedalion documentation below.* + +--- + # Cedalion - fNIRS analysis toolbox A python-based framework for the data-driven analysis of multimodal fNIRS and DOT in naturalistic environments. Developed by the [Intelligent Biomedical Sensing (IBS) Lab](https://ibs-lab.com/) with and for the community. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1dca972b..18431423 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,13 @@ # Changelog ## Unreleased changes (available on the `dev` branch) - + +### Additions and Changes + +#### Photogrammetry + +- Added `cedalion.geometry.photogrammetry.anonymization`, a deletion-based face removal pipeline for photogrammetry scans. From the five anatomical landmarks (Nz, Iz, Cz, LPA, RPA), the pipeline normalizes axes, isolates the head, detects the cap boundary, builds a face mask, deletes the masked vertices, and saves the anonymized mesh together with the landmarks. See `examples/head_models/51_manual_5pt_anonymization.ipynb` for an end-to-end walkthrough. + ## Version 26.04.0 (in preparation) ### Additions and Changes diff --git a/examples/head_models/51_manual_5pt_anonymization.ipynb b/examples/head_models/51_manual_5pt_anonymization.ipynb new file mode 100644 index 00000000..79cef4cc --- /dev/null +++ b/examples/head_models/51_manual_5pt_anonymization.ipynb @@ -0,0 +1,779 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e505f83d-79aa-467b-a6f2-44d973491f55", + "metadata": {}, + "source": [ + "# Face Anonymization from 5 Given Landmarks\n", + "\n", + "Geometric face removal for Einstar photogrammetry scans. Given the 5\n", + "landmarks (Nz, Iz, Cz, Lpa, Rpa), `anonymize_scan` deletes the face and\n", + "returns the result in the raw Einstar (`crs=digitized`) frame, so the\n", + "saved `.obj` lines up with the original at co-registration.\n", + "\n", + "Flow: load → pick 5 landmarks → `anonymize_scan` → save.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a916040f-02d3-44e3-a1f4-05188c814d1d", + "metadata": {}, + "outputs": [], + "source": "import logging\nimport os\nfrom pathlib import Path\n\nimport numpy as np\nimport pyvista as pv\nimport xarray as xr\nfrom PIL import Image\nimport trimesh\n\nimport cedalion\nimport cedalion.dataclasses as cdc\nimport cedalion.io\nimport cedalion.vis.blocks\nfrom cedalion.vtktutils import trimesh_to_vtk_polydata\nfrom cedalion.geometry.photogrammetry.anonymization import (\n anonymize_scan,\n save_anonymized_scan,\n)\n\nlogging.getLogger('cedalion').setLevel(logging.WARNING)\npv.set_jupyter_backend('server')\n\n# === Options ===\n# Path to your Einstar .obj scan. The notebook expects a sibling .jpg\n# texture (Einstar exports both).\nSCAN_PATH = './scan.obj' # edit me\nOUT_PATH = None # None -> write '_anon.obj' next to SCAN_PATH\n\n# True: run the picker. False: load landmarks from a TSV sidecar (set below).\nINTERACTIVE = True\nCACHED_LANDMARKS_TSV = None # used only when INTERACTIVE = False\n\n\n# === Mask parameters (edit to adjust the anonymized region) ===\nEAR_DELETE_RADIUS_MM = 40.0 # sphere radius around LPA/RPA included in deletion\nLANDMARK_KEEP_RADIUS_MM = 8.0 # sphere radius around each landmark kept intact\nEYEBROW_OFFSET_MM = 10.0 # failsafe cap height above Nz (flush-cap fallback)\nCAP_Z_CEILING_MM = 40.0 # mm above Nz where cap detection is clamped\nHEAD_ISOLATION_RADIUS_MM = 220.0 # sphere radius used to strip body/shoulders\n" + }, + { + "cell_type": "markdown", + "id": "c7b11266-a722-49ac-9aee-67953820c2ac", + "metadata": {}, + "source": [ + "## Load the Einstar scan\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5c99124b-f88c-4aaf-bff2-124cfadd5da1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded: 943,539 vertices, 1,736,738 faces\n" + ] + } + ], + "source": [ + "surface = cedalion.io.read_einstar_obj(SCAN_PATH)\n", + "print(f'Loaded: {surface.nvertices:,} vertices, {surface.nfaces:,} faces')\n", + "\n", + "if OUT_PATH is None:\n", + " p = Path(SCAN_PATH)\n", + " OUT_PATH = str(p.with_name(p.stem + '_anon' + p.suffix))\n", + "\n", + "# trimesh 4.6 places the texture image on visual.material.image; if neither\n", + "# path has it, attach it from the sibling JPG so save_anonymized_scan can\n", + "# sanitize the final texture.\n", + "visual = surface.mesh.visual\n", + "img = getattr(visual, 'image', None) or getattr(getattr(visual, 'material', None), 'image', None)\n", + "if img is None:\n", + " jpg = SCAN_PATH.replace('.obj', '.jpg')\n", + " uv = getattr(visual, 'uv', None)\n", + " assert os.path.exists(jpg) and uv is not None, 'no texture available'\n", + " surface.mesh.visual = trimesh.visual.TextureVisuals(\n", + " uv=uv, image=Image.open(jpg).convert('RGBA'),\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "id": "5c282588-d37d-4810-a5c1-104b751d2155", + "metadata": {}, + "source": [ + "## Pick the 5 landmarks\n", + "\n", + "Right-click on the mesh to place a sphere; click a sphere to cycle its label\n", + "through `Nz -> Iz -> Cz -> Lpa -> Rpa`. Close the window when all 5 are placed.\n", + "Skip this cell if `INTERACTIVE = False`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4ea3ff12-ca93-4c03-9f50-b84c28c7cfdc", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "8931ae160417497089d51fca6c1cd106", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Widget(value='