Skip to content
Draft
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
148 changes: 148 additions & 0 deletions IMPLEMENTATION_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Summary: Flexible Template Logic for Arbitrary Patches

## Issue
The original issue requested extending the template logic to:
1. Allow arbitrary patches/regions beyond the standard 5-cut template
2. Support flexible cut configurations (e.g., occipital pole only, temporal pole only)
3. Enable users to create custom patches on fsaverage that can be applied to any subject
4. Not hardcode expected cuts or medial wall
5. Add topology checks on patches

## Implementation

### Changes Made

#### 1. Core Module (`autoflatten/core.py`)
- **Dynamic cut extraction**: Replaced hardcoded `cut_names = ["calcarine", "medial1", "medial2", "medial3", "temporal"]` with dynamic extraction from `vertex_dict.keys()`
- **New function**: Added `validate_patch_topology()` to check:
- Single connected component
- Disk topology (one boundary loop)
- Reasonable patch size
- **Updated docstrings**: Documented support for arbitrary cut names

#### 2. Template Module (`autoflatten/template.py`)
- **Flexible merging**: Made `merge_small_components()` accept optional `max_cuts` parameter (default None = keep all)
- **Generic classification**: Updated `classify_cuts_anatomically()` to fall back to generic names (cut1, cut2, etc.) when not applicable
- **Updated `identify_surface_components()`**: Added `max_cuts` and `classify_anatomically` parameters for flexibility

#### 3. Tests
- Updated `test_merge_small_components()` to test both old (max_cuts=5) and new (max_cuts=None) behavior
- Added `test_validate_patch_topology()` to test topology validation
- All 151 tests pass

#### 4. Documentation & Examples
- Created `examples/custom_patch_example.py` demonstrating:
- Custom cut names
- Arbitrary number of cuts
- Isolated patches without medial wall
- Template creation
- Topology validation
- Added `examples/README.md` with comprehensive usage guide
- Updated main `README.md` with flexible patch section

#### 5. API Exports
- Updated `autoflatten/__init__.py` to export key functions for programmatic use:
- `ensure_continuous_cuts`
- `fill_holes_in_patch`
- `map_cuts_to_subject`
- `refine_cuts_with_geodesic`
- `validate_patch_topology`
- `identify_surface_components`

## Key Design Decisions

### 1. Convention: 'mwall' is Reserved
- The `mwall` key is reserved for medial wall vertices (can be empty)
- All other keys are treated as cuts and processed dynamically
- This provides a clear, simple convention for users

### 2. Backward Compatibility
- Default behavior for standard 5-cut template unchanged
- `max_cuts=None` enables new flexible behavior
- `classify_anatomically=True` maintains anatomical naming when appropriate

### 3. Topology Validation
- New optional validation step to catch topology issues early
- Provides clear feedback on what's wrong (disconnected components, multiple loops, etc.)
- Users can validate before attempting potentially slow flattening operations

## Usage Examples

### Example 1: Custom Patch with Arbitrary Cuts
```python
from autoflatten.utils import save_json

# Create a template with custom regions
template_dict = {
"lh_mwall": [100, 101, 102],
"lh_occipital_boundary": [1, 2, 3, 4, 5],
"lh_ventral_boundary": [10, 11, 12, 13],
"rh_mwall": [100, 101, 102],
"rh_occipital_boundary": [1, 2, 3, 4, 5],
"rh_ventral_boundary": [10, 11, 12, 13],
}

save_json("occipital_patch_template.json", template_dict)
```

Then use: `autoflatten subject --template-file occipital_patch_template.json`

### Example 2: Validate Before Flattening
```python
from autoflatten import validate_patch_topology

is_valid, issues, info = validate_patch_topology(
vertex_dict, subject="sub-01", hemi="lh"
)

if not is_valid:
print("Topology issues:", issues)
print("Info:", info)
```

## Testing

All tests pass (151 passed, 6 skipped):
```bash
cd /home/runner/work/autoflatten/autoflatten
python -m pytest autoflatten/tests/ -v
```

Specific test coverage:
- `test_merge_small_components`: Tests flexible merging with and without max_cuts
- `test_validate_patch_topology`: Tests topology validation
- `test_classify_cuts_anatomically`: Tests fallback to generic naming
- All existing tests: Ensure backward compatibility

## Benefits

1. **Flexibility**: Users can create patches for any brain region
2. **Simplicity**: No need to conform to specific cut names or counts
3. **Validation**: Can catch topology issues before expensive flattening
4. **Reusability**: Custom templates can be applied across subjects
5. **Backward Compatible**: Existing workflows continue to work unchanged

## Future Enhancements

Potential future improvements:
1. Add CLI command to create template from manual vertex selection
2. Add visualization of custom patches before flattening
3. Support for multiple disconnected patches in a single template
4. Automatic topology fixing for common issues

## Files Modified

- `autoflatten/core.py`: Dynamic cut handling, topology validation
- `autoflatten/template.py`: Flexible merging and classification
- `autoflatten/__init__.py`: Export key functions
- `autoflatten/tests/test_core.py`: Added topology validation test
- `autoflatten/tests/test_template.py`: Updated merging test
- `README.md`: Documented flexible patches
- `examples/custom_patch_example.py`: Comprehensive examples (new)
- `examples/README.md`: Usage guide (new)

## Commits

1. `255800b`: Make template logic flexible for arbitrary patches
2. `03f895f`: Add topology validation and examples for flexible patches
3. `9737251`: Update README with flexible patch documentation
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,42 @@ The package includes a built-in template in `autoflatten/default_templates/`:

Use a custom template with `--template-file /path/to/template.json`.

### Custom Patches and Arbitrary Regions

**NEW**: autoflatten now supports flexible patch configurations beyond the standard 5-cut template:

- **Arbitrary number of cuts**: Not limited to the 5 standard cuts (calcarine, medial1-3, temporal)
- **Custom cut names**: Use any names you want (e.g., `occipital`, `temporal`, `custom_region`)
- **Isolated patches**: Create patches for specific regions (e.g., just the occipital pole or temporal lobe)
- **No medial wall required**: The `mwall` key can be empty for isolated patches

**Example: Create a custom template**
```python
from autoflatten.utils import save_json

template_dict = {
"lh_mwall": [100, 101, 102], # Optional medial wall
"lh_occipital_boundary": [1, 2, 3, 4, 5], # Custom cut
"lh_temporal_boundary": [10, 11, 12, 13], # Custom cut
"rh_mwall": [100, 101, 102],
"rh_occipital_boundary": [1, 2, 3, 4, 5],
"rh_temporal_boundary": [10, 11, 12, 13],
}

save_json("my_custom_template.json", template_dict)
```

**Validate topology** before flattening:
```python
from autoflatten import validate_patch_topology

is_valid, issues, info = validate_patch_topology(
vertex_dict, subject="your_subject", hemi="lh"
)
```

See `examples/custom_patch_example.py` for detailed examples and `examples/README.md` for more information.

## Development

```bash
Expand Down
20 changes: 20 additions & 0 deletions autoflatten/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,23 @@
from ._version import version as __version__
except ImportError:
__version__ = "unknown"

# Export key functions for programmatic use
from .core import (
ensure_continuous_cuts,
fill_holes_in_patch,
map_cuts_to_subject,
refine_cuts_with_geodesic,
validate_patch_topology,
)
from .template import identify_surface_components

__all__ = [
"__version__",
"ensure_continuous_cuts",
"fill_holes_in_patch",
"map_cuts_to_subject",
"refine_cuts_with_geodesic",
"validate_patch_topology",
"identify_surface_components",
]
145 changes: 140 additions & 5 deletions autoflatten/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,130 @@
HOLE_FILL_MAX_ITERATIONS = 10


def validate_patch_topology(vertex_dict, subject, hemi, verbose=True):
"""
Validate the topology of a patch defined by cuts and medial wall.

Checks for:
1. Single connected component (no isolated regions)
2. Disk topology (exactly one boundary loop after excluding vertices)
3. Reasonable patch size

Parameters
----------
vertex_dict : dict
Dictionary containing medial wall and cut vertices. Keys should include
'mwall' and any cut names. All vertices in these keys will be excluded
from the patch.
subject : str
Subject identifier.
hemi : str
Hemisphere identifier ('lh' or 'rh').
verbose : bool, optional
If True, prints detailed validation results. Default is True.

Returns
-------
is_valid : bool
True if patch has valid topology for flattening.
issues : list of str
List of topology issues found (empty if valid).
info : dict
Dictionary with topology information:
- 'n_components': Number of connected components in patch
- 'n_boundary_loops': Number of boundary loops
- 'patch_size': Number of vertices in patch
- 'excluded_size': Number of excluded vertices
"""
issues = []

# Load surface data
pts, polys = load_surface(subject, "smoothwm", hemi)
n_vertices = len(pts)

# Collect all excluded vertices (medial wall + all cuts)
excluded = set()
for key, vertices in vertex_dict.items():
excluded.update(vertices)

# Create mapping from old to new vertex indices (excluding excluded vertices)
included_vertices = [v for v in range(n_vertices) if v not in excluded]
if not included_vertices:
issues.append("No vertices remain after exclusions")
return False, issues, {
'n_components': 0,
'n_boundary_loops': 0,
'patch_size': 0,
'excluded_size': len(excluded)
}

old_to_new = {old_v: new_v for new_v, old_v in enumerate(included_vertices)}

# Filter faces to only include triangles with all vertices in the patch
valid_faces = []
for face in polys:
if all(v in old_to_new for v in face):
# Remap to new indices
new_face = [old_to_new[v] for v in face]
valid_faces.append(new_face)

if not valid_faces:
issues.append("No valid faces remain after exclusions")
return False, issues, {
'n_components': 0,
'n_boundary_loops': 0,
'patch_size': len(included_vertices),
'excluded_size': len(excluded)
}

valid_faces = np.array(valid_faces)

# Check 1: Single connected component
G = nx.Graph()
for face in valid_faces:
G.add_edges_from([(face[0], face[1]), (face[1], face[2]), (face[2], face[0])])

n_components = nx.number_connected_components(G)
if n_components > 1:
issues.append(f"Patch has {n_components} disconnected components (should be 1)")

# Check 2: Count boundary loops
n_loops, loops = count_boundary_loops(valid_faces)
if n_loops != 1:
issues.append(
f"Patch has {n_loops} boundary loops (should be 1 for disk topology)"
)

# Check 3: Reasonable patch size
patch_size = len(included_vertices)
if patch_size < 100:
issues.append(f"Patch is very small ({patch_size} vertices)")

info = {
'n_components': n_components,
'n_boundary_loops': n_loops,
'patch_size': patch_size,
'excluded_size': len(excluded)
}

is_valid = len(issues) == 0

if verbose:
print("\n=== Patch Topology Validation ===")
print(f"Patch size: {patch_size} vertices")
print(f"Excluded: {len(excluded)} vertices")
print(f"Connected components: {n_components}")
print(f"Boundary loops: {n_loops}")
if is_valid:
print("✓ Patch has valid disk topology")
else:
print("✗ Topology issues found:")
for issue in issues:
print(f" - {issue}")

return is_valid, issues, info


def _find_geometric_endpoints(cut_vertices, pts):
"""Find the two most geometrically distant vertices in a cut.

Expand Down Expand Up @@ -65,11 +189,16 @@ def _find_geometric_endpoints(cut_vertices, pts):
def ensure_continuous_cuts(vertex_dict, subject, hemi):
"""
Make cuts continuous using Euclidean distances on the inflated surface for speed.

Works with arbitrary patch configurations - processes all cuts dynamically
based on the keys present in vertex_dict (excluding 'mwall').

Parameters
----------
vertex_dict : dict
Dictionary containing medial wall and cut vertices.
Dictionary containing medial wall and cut vertices. Keys can be arbitrary
cut names (e.g., 'calcarine', 'cut1', 'occipital', etc.). The special key
'mwall' is reserved for medial wall vertices.
subject : str
Subject identifier.
hemi : str
Expand Down Expand Up @@ -108,10 +237,11 @@ def ensure_continuous_cuts(vertex_dict, subject, hemi):
weight = np.linalg.norm(pts_fiducial[v1] - pts_fiducial[v2])
G.add_edge(v1, v2, weight=weight)

# Process each cut (using anatomical names from template)
cut_names = ["calcarine", "medial1", "medial2", "medial3", "temporal"]
# Process each cut (extract cut names dynamically from vertex_dict)
# Skip the medial wall ('mwall') key, process all other keys as cuts
cut_names = [key for key in vertex_dict.keys() if key != "mwall"]
for cut_key in cut_names:
if cut_key not in vertex_dict or len(vertex_dict[cut_key]) == 0:
if len(vertex_dict[cut_key]) == 0:
continue

print(f"Processing {cut_key}...")
Expand Down Expand Up @@ -513,11 +643,16 @@ def refine_cuts_with_geodesic(vertex_dict, subject, hemi, medial_wall_vertices=N
and replaces them with the shortest geodesic path on the target surface between
the cut endpoints. This should produce more anatomically direct cuts and reduce
distortion during flattening.

Works with arbitrary patch configurations - processes all cuts dynamically
based on the keys present in vertex_dict (excluding 'mwall').

Parameters
----------
vertex_dict : dict
Dictionary containing medial wall and cut vertices.
Dictionary containing medial wall and cut vertices. Keys can be arbitrary
cut names (e.g., 'calcarine', 'cut1', 'occipital', etc.). The special key
'mwall' is reserved for medial wall vertices.
subject : str
Subject identifier.
hemi : str
Expand Down
Loading