Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
22eea04
Refactor(io): LTA holds an LtaRaw record through LtaMetadata (#415)
claude Oct 10, 2026
44c295b
Docs(io): reword LtaTransformation.from_instance (#415)
claude Oct 10, 2026
8b4eb01
Merge branch 'claude/feat/415-nifti-xforms' into claude/feat/415-lta
claude Oct 10, 2026
24b2e68
Refactor(io): LTA data views never read the private field of the mode…
claude Oct 10, 2026
3a2078e
Fix(io): an LTA without a record holds its own matrix (#415)
claude Oct 10, 2026
23ed9af
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 11, 2026
1f6be75
Refactor(io): LTA follows the hardened smartproperty and store_data_a…
claude Oct 11, 2026
f73fccc
Docs(io): the LTA systems document the parameters of from_raw (#415)
claude Oct 11, 2026
65e03e4
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 11, 2026
df680c3
Refactor(io): another LTA view reads the record through LTA's convert…
claude Oct 11, 2026
bfb21f3
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 11, 2026
289c618
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
d9a4e20
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
3b9a1e2
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
4c7b8cb
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
b841a6a
Refactor(io): an LTA matrix is pending until consolidate(); to_raw is…
claude Oct 12, 2026
75b0e25
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
c65b139
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
b1c885f
Refactor(io): LTA consolidate takes inplace by keyword only (#415)
claude Oct 12, 2026
669fb17
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
374be2c
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
47ad524
Merge branch claude/feat/415-nifti-xforms into claude/feat/415-lta
claude Oct 12, 2026
e4d1118
Merge 415-nifti-xforms (origin/main #428, #416) into 415-lta
claude Oct 12, 2026
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
2 changes: 1 addition & 1 deletion docs/api/io/base/parsers.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ When implementing its own I/O, it can inherit a reader and a writer
independently:

```python
class LtaStruct(TextFileReader, TextFileWriter):
class LtaRaw(TextFileReader, TextFileWriter):
# Native fields and implementations of from_lines / to_lines.
...
```
Expand Down
13 changes: 11 additions & 2 deletions src/brainhops/io/transformations/freesurfer/lta/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
An LTA file stores an affine transformation together with the coordinate
systems that it maps between. The file records these systems through a type
code and through the geometries of the source and destination volumes.

An LTA file is short and is read in one pass. Its whole content is held as
an [`LtaRaw`][] record by [`LtaMetadata`][], the metadata of the file, and
an [`LtaTransformation`][] holds that metadata. The matrix of the affine
lives in the record, so the transformation reads its matrix and its
coordinate systems from the record, and a transformation that is read and
written again without changes is written as its file stored it.
"""

__all__ = [
"LtaType",
"LtaMatrixType",
"LtaValidity",
"LtaStruct",
"LtaRaw",
"LtaMetadata",
"LtaCoordinateSystem",
"LtaVoxelSystem",
"LtaScaledSystem",
Expand All @@ -21,7 +29,8 @@
]

from ._enums import LtaMatrixType, LtaType, LtaValidity
from ._struct import LtaStruct
from ._metadata import LtaMetadata
from ._raw import LtaRaw
from ._systems import (
LtaCoordinateSystem,
LtaPhysicalSystem,
Expand Down
33 changes: 26 additions & 7 deletions src/brainhops/io/transformations/freesurfer/lta/_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
compatible with those of the format. Whether the LTA format can write
those systems is decided when it is written. An `LtaTransformation`
that is converted to its own class is changed by the rules of any
affine, and another variant of the format is relabelled, as any other
transformation is.
affine. Another variant of the format that reads its matrix from a
record reads the same record in its own coordinate systems, which is
exact, and one that holds a matrix of its own is relabelled, as any
other transformation is.
"""

import typing_extensions as tx
Expand All @@ -21,6 +23,7 @@
from brainhops.datamodel._transformations.compute.convert import converter
from brainhops.datamodel._transformations.compute.converters import (
_convert_withlog,
smart_replace,
)
from brainhops.io.transformations.base._converters import (
relabel,
Expand Down Expand Up @@ -50,8 +53,24 @@ def _(
# A transformation that is already in this format is changed by the
# rules of any affine, for example when it is given a new `matrix=`,
# and these rules keep its type. Another variant of the format reads
# its matrix in other systems, so it is relabelled only when those
# systems are compatible.
if not isinstance(t, cls):
return relabel(t, cls, **kwargs)
return _convert_withlog(t, cls, "matrix", **kwargs)
# the record of `t`, when it has one, in its own systems. A matrix of
# its own is relabelled only when its systems are compatible.
if isinstance(t, cls):
return _convert_withlog(t, cls, "matrix", **kwargs)
if t._data is None:
return _view(t, cls, **kwargs)
return relabel(t, cls, **kwargs)


# ----------------------------------------------------------------------
# HELPERS
# ----------------------------------------------------------------------


def _view(
t: LtaTransformation, cls: tx.Type[LtaTransformation], **kwargs
) -> LtaTransformation:
# Another variant of the format that shares the record of `t`, whose
# matrix it decodes rather than copies.
kwargs.setdefault("data", None)
return smart_replace(t, cls, **kwargs)
16 changes: 8 additions & 8 deletions src/brainhops/io/transformations/freesurfer/lta/_matrix_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@
)

from ._enums import LtaType
from ._struct import LtaStruct
from ._raw import LtaRaw


def _get_vox2phys(vol_info: LtaStruct.VolumeInfo) -> np.ndarray:
def _get_vox2phys(vol_info: LtaRaw.VolumeInfo) -> np.ndarray:
return fs_vox2phys(vol_info.volume, vol_info.voxelsize)


def _get_phys2ras(vol_info: LtaStruct.VolumeInfo) -> np.ndarray:
def _get_phys2ras(vol_info: LtaRaw.VolumeInfo) -> np.ndarray:
return fs_phys2ras(
vol_info.xras, vol_info.yras, vol_info.zras, vol_info.cras
)


def _get_vox2ras(vol_info: LtaStruct.VolumeInfo) -> np.ndarray:
def _get_vox2ras(vol_info: LtaRaw.VolumeInfo) -> np.ndarray:
return _get_phys2ras(vol_info) @ _get_vox2phys(vol_info)


Expand All @@ -40,7 +40,7 @@ def _rsa2ras(matrix: np.ndarray) -> np.ndarray:
return matrix[_RAS_RSA, :][:, _RAS_RSA]


def _get_ras2ras(lta: LtaStruct) -> np.ndarray:
def _get_ras2ras(lta: LtaRaw) -> np.ndarray:
matrix = np.asarray(lta.affine.matrix, dtype=np.float64)
if lta.type == LtaType.LINEAR_RAS_TO_RAS:
return matrix
Expand All @@ -61,7 +61,7 @@ def _get_ras2ras(lta: LtaStruct) -> np.ndarray:
raise AssertionError(f"unsupported LTA type: {enum_name(lta.type)}")


def _get_phys2phys(lta: LtaStruct) -> np.ndarray:
def _get_phys2phys(lta: LtaRaw) -> np.ndarray:
matrix = np.asarray(lta.affine.matrix, dtype=np.float64)
if lta.type == LtaType.LINEAR_PHYSVOX_TO_PHYSVOX:
return matrix
Expand All @@ -82,7 +82,7 @@ def _get_phys2phys(lta: LtaStruct) -> np.ndarray:
raise AssertionError(f"unsupported LTA type: {enum_name(lta.type)}")


def _get_vox2vox(lta: LtaStruct) -> np.ndarray:
def _get_vox2vox(lta: LtaRaw) -> np.ndarray:
matrix = np.asarray(lta.affine.matrix, dtype=np.float64)
if lta.type == LtaType.LINEAR_VOX_TO_VOX:
return matrix
Expand All @@ -108,6 +108,6 @@ def _get_vox2vox(lta: LtaStruct) -> np.ndarray:
_mat2orient = mat2orient


def _get_orient(vol_info: LtaStruct.VolumeInfo) -> str:
def _get_orient(vol_info: LtaRaw.VolumeInfo) -> str:
vox2ras = _get_vox2ras(vol_info)
return _mat2orient(vox2ras)
123 changes: 123 additions & 0 deletions src/brainhops/io/transformations/freesurfer/lta/_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""The metadata of an LTA file."""

__all__ = ["LtaMetadata"]

# dependencies
import typing_extensions as tx
from bagof.magic import NoRepr

# internals
from brainhops.io.base._base import register_format
from brainhops.io.base.parsers import TextFileReader, TextFileWriter
from brainhops.io.metadata import MetadataFormat

# this format
from ._raw import LtaRaw


@register_format
class LtaMetadata(MetadataFormat, TextFileReader, TextFileWriter):
"""Metadata of an LTA file, which holds the file as an [`LtaRaw`][].

An LTA file is a short text file that is read in one pass, so its
record holds the whole file, including the matrix of the affine. The
data of an
[`LtaTransformation`][brainhops.io.transformations.freesurfer.lta.LtaTransformation]
therefore lives in the record of its metadata, and reading the metadata
of a file reads the same content as reading the transformation.

The text is read and written by the record: [`from_lines`][] parses an
[`LtaRaw`][] and wraps it, and [`to_lines`][] yields the lines of the
record. The metadata keeps no open file.
"""

EXTENSIONS: tx.ClassVar[tx.Tuple[str, ...]] = (".lta",)
HINTS: tx.ClassVar[tx.Tuple[str, ...]] = ("lta",)

raw: NoRepr[tx.Optional[LtaRaw]] = None
"""The content of the file, or `None` for metadata without a record."""

@classmethod
def sniff_line(
cls,
line: str,
error: tx.Union[bool, tx.Type[Exception]] = False,
**kwargs,
) -> float:
"""Score a line as the first line of an LTA file.

The line is scored by [`LtaRaw.sniff_line`][].

Parameters
----------
line : str
The first line of the file.
error : bool or type[Exception], default=False
Whether to raise an error when the line is not the first line
of an LTA file.
**kwargs : Any
Options of the reader.

Returns
-------
float
The confidence, between 0 and 1.
"""
return LtaRaw.sniff_line(line, error=error, **kwargs)

@classmethod
def from_lines(cls, lines: tx.Iterable[str], **kwargs) -> "LtaMetadata":
"""Read the metadata from the lines of an LTA file.

The lines are parsed by [`LtaRaw.from_lines`][].

Parameters
----------
lines : iterable of str
The lines of the file.
**kwargs : Any
Options of [`LtaRaw.from_lines`][].

Returns
-------
LtaMetadata
The metadata, which holds the record of the file.
"""
return cls.from_raw(LtaRaw.from_lines(lines, **kwargs))

def to_lines(self, **kwargs) -> tx.Iterator[str]:
"""Yield the lines of the LTA file that the record holds.

Metadata without a record is written as a new, empty record.

Parameters
----------
**kwargs : Any
Options of the writer of the record.

Yields
------
str
The lines of the file, without line endings.
"""
raw = LtaRaw() if self.raw is None else self.raw
return raw.to_lines(**kwargs)

def to_text(self, **kwargs) -> str:
"""Return the content of the LTA file, ending with a newline.

The text is the text of the record, as [`LtaRaw.to_text`][] writes
it, so that it matches the files that the transformation writes.

Parameters
----------
**kwargs : Any
Options of the writer of the record.

Returns
-------
str
The content of the file, ending with a newline.
"""
raw = LtaRaw() if self.raw is None else self.raw
return raw.to_text(**kwargs)
48 changes: 6 additions & 42 deletions src/brainhops/io/transformations/freesurfer/lta/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import typing_extensions as tx
from bagof.magic import Magic, fields

from brainhops._core.path import FileOrContentLike, Path, PathLike, exists
from brainhops._core.peek import peekable_lines
from brainhops.io.base.parsers import (
Confidence,
Expand All @@ -20,20 +19,20 @@


# ----------------------------------------------------------------------
# Parser classes with public methods inherited by LtaStruct
# Parser classes with public methods inherited by LtaRaw
# ----------------------------------------------------------------------


class LtaReaderWriter(Magic, TextFileReader, TextFileWriter):
"""Mixin that lets a class be sniffed, read and written as LTA.

`LtaStruct` and its blocks inherit their `sniff*`, `from_*` and `to_*`
`LtaRaw` and its blocks inherit their `sniff*`, `from_*` and `to_*`
methods from this class, which follows the contract of
[`TextFileReader`][] and [`TextFileWriter`][]. These bases provide the
public entry points, such as `load` and `save`, and this class implements
the format-specific steps: `sniff_line`, `from_lines` and `to_lines`.

A struct is read field by field, in declaration order. A field whose type
A record is read field by field, in declaration order. A field whose type
is itself an `LtaReaderWriter` reads its own block of lines. Any other
field reads one line of the form `key = values`, or a line of bare values
in a block without keys. Comments and blank lines are skipped.
Expand Down Expand Up @@ -80,41 +79,6 @@ def sniff_line(

# --- from ---------------------------------------------------------

@classmethod
def from_(cls, other: FileOrContentLike) -> tx.Self:
"""Build an object from a file or from its content.

!!! warning "Deprecated"
Use `load` for a file, a file object or bytes, and `from_text` or
`from_lines` for content in memory. Unlike `load`, `from_` reads a
string that names no existing file as LTA content.

Parameters
----------
other : str, PathLike, IO, bytes or iterable of str
The file, or its content.
"""
warn(
f"{cls.__name__}.from_() is deprecated: use load() for a file, "
f"a file object or bytes, and from_text() or from_lines() for "
f"content held in memory.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(other, str):
# This method is the only one in which a string may hold
# content rather than a path. Multi-line content is usually too
# long to be a file name, so `exists` reports it as missing
# instead of raising an error.
if not exists(other):
return cls.from_text(other)
other = Path(other)
if isinstance(other, (PathLike, bytes, bytearray)) or hasattr(
other, "read"
):
return cls.load(other)
return cls.from_lines(other)

@classmethod
def from_lines(cls, lines: tx.Iterable[str], **kwargs) -> tx.Self:
"""Build an object from an iterable of LTA lines."""
Expand Down Expand Up @@ -237,7 +201,7 @@ def to_lines(self, **kwargs) -> tx.Iterator[str]:


class LtaFieldReader:
"""Reader of a single field of an LTA struct.
"""Reader of a single field of an LTA record.

Calling the reader consumes as many lines as the field needs, which is
one line or a block of lines, and returns the parsed value.
Expand All @@ -263,7 +227,7 @@ def __call__(self, lines: tx.Iterator[str]) -> tx.Any:
lines = peekable_lines(lines)
types = self.type

# A field that is a struct defers to the parser of that struct.
# A field that is a block defers to the parser of that block.
if isinstance(types, type) and issubclass(types, LtaReaderWriter):
value = types.from_lines(lines)
if value is None and not self.optional:
Expand Down Expand Up @@ -307,7 +271,7 @@ def __call__(self, lines: tx.Iterator[str]) -> tx.Any:


class LtaFieldWriter:
"""Writer of a single field of an LTA struct.
"""Writer of a single field of an LTA record.

Calling the writer yields the line, or the block of lines, of a value.
"""
Expand Down
Loading
Loading