# brainsynth -- SimNIBS's brain-scan synthesizer: a torch pipeline that turns # label maps (FreeSurfer-style segmentations) into synthetic images of the brain # and head, used to train contrast- and resolution-agnostic networks. # # WHY IT IS HERE: the middle link of the chain that unblocks SimNIBS's `charm`. # python-pytorch-ignite -> python-brainsynth -> python-brainnet -> simnibs # charm, mesh_tools.meshing and optimization.opt_struct # Without brainnet, `charm --help` dies with # ModuleNotFoundError: No module named 'brainnet' # and brainnet needs brainsynth (brainnet.mesh.surface, brainnet.datasets, # brainnet.helpers.{topofit,trega} and brainnet.modules.brainnet all import it # at module scope). So SimNIBS users cannot build a head model from their own # MRI and are limited to the pre-built ones. # # Class 1 under docs/PACKAGING-POLICY.md: Fedora ships no package by this source # name. Verified 2026-09-28: # dnf repoquery --repo=fedora --repo=updates 'python*brainsynth*' -> empty # dnf repoquery --whatprovides 'python3dist(brainsynth)' -> empty # # *** DO NOT PACKAGE THIS FROM PyPI. *** # There is no `brainsynth` on PyPI at all, and the neighbouring `brainnet` on # PyPI is UNRELATED software (v0.0.01, MIT, github.com/CryptoNyxz). Both of # these packages come from git tags in github.com/simnibs, nowhere else. %global srcname brainsynth # Upstream's [project] name is the mixed-case "BrainSynth"; the import name and # the GitHub archive's top directory are both lowercase. %global distname BrainSynth Name: python-%{srcname} Version: 0.1 Release: 1%{?dist} Summary: Synthetic brain and head image generation for SimNIBS # Repository licence, GPL-3.0, verbatim boilerplate with the # " Copyright (C) " placeholders unfilled and no # per-file headers anywhere in the tree (checked: no source file contains # "GNU General Public" or an SPDX tag). There is no "or any later version" # statement in the repository, so this is -only rather than -or-later, the same # call python-cortech.spec makes for the sibling project. For the record, # SimNIBS's own 3RD-PARTY.md describes both brainsynth and cortech as # "GNU General Public License (GPL), Version 3 or later". License: GPL-3.0-only URL: https://github.com/simnibs/%{srcname} # TAG, not PyPI, and not master. v0.1 (daaa07b) is what SimNIBS 4.6.0's # tools/environment.py pins alongside brainnet v0.2. Source0: %{url}/archive/v%{version}/%{srcname}-%{version}.tar.gz # Pure Python plus data files: the template surfaces and the two colour # lookup tables. torch is arch-specific, but that is a dependency, not content. BuildArch: noarch BuildRequires: python3-devel BuildRequires: pyproject-rpm-macros # Both are here so that %%check's negative control is about UPSTREAM's bug # rather than about what happens to be in this build root. Without them, # brainsynth.prepare_freesurfer_data fails on `No module named 'sklearn'` and # brainsynth.config.utilities on `No module named 'yaml'`, which would hide the # real and more serious defect underneath: both then fail on # `from brainsynth import root_dir`, a name brainsynth/__init__.py does not # define. An exclusion justified by the wrong reason is an exclusion nobody can # ever retire. BuildRequires: python3-scikit-learn BuildRequires: python3-pyyaml %global _description %{expand: BrainSynth generates synthetic images of the brain and head from segmentations. A Synthesizer applies a configurable pipeline of transforms -- intensity and contrast randomisation, resolution sampling, spatial deformation, label handling -- to images and surfaces, so that a network can be trained to be agnostic to scanner contrast and voxel size rather than to one acquisition. It is developed alongside SimNIBS, and is what brainnet (and therefore SimNIBS's charm head-model pipeline) is built on.} %description %_description %package -n python3-%{srcname} Summary: %{summary} # Upstream's declared runtime dependencies, from [project] dependencies. Requires: python3-nibabel >= 5.1 Requires: python3-numpy >= 1.26 Requires: python3-scipy >= 1.11 Requires: python3-torch >= 2.1 # FOUR SHIPPED MODULES ARE UNIMPORTABLE UPSTREAM, and that is what decides the # dependencies below rather than a reading of pyproject.toml. Measured, not # inferred -- %%check proves each failure and fails the build if upstream fixes # one without the exclusion being retired: # # brainsynth.config.utilities ImportError: cannot import name # brainsynth.prepare_freesurfer_data 'root_dir' from 'brainsynth' # brainsynth.process_additional_images (via prepare_freesurfer_data) # brainsynth.synthesizer_test ModuleNotFoundError: 'brainnet' # # brainsynth/__init__.py exports config, dataset, resources and Synthesizer. # It does NOT define root_dir, which three of those modules import by name. # # Consequences for dependencies: # # * PyYAML is declared by upstream NOWHERE -- not in [project] dependencies and # not in an extra -- although config/utilities.py does a module-scope # `import yaml` on line 4. That is a real missing dependency, but the module # it serves cannot be imported anyway (root_dir, two lines later), so a hard # Requires would pull in a package for dead code. Recommends, so the package # is right the day upstream fixes root_dir. # * scikit-learn and surfa are upstream's [project.optional-dependencies] # "generate" extra. sklearn is imported at module scope by # prepare_freesurfer_data.py -- unimportable. surfa is never imported at all: # prepare_freesurfer_data.py:108 calls surfa.load_mesh() with no import # statement anywhere in the package, so even with root_dir fixed that line is # a NameError. Neither is required; python3-surfa exists in this COPR if # upstream repairs the module. # * tqdm is imported by process_additional_images.py, whose first line already # fails through prepare_freesurfer_data. Not required. # * brainnet is imported by synthesizer_test.py. Requiring it would be a # dependency CYCLE (brainnet requires brainsynth), and the name it wants, # get_recursively_subdivided_topology, does not exist in brainnet v0.2, so # the module is dead on both counts. # # All four are in docs/upstream/brainsynth-brainnet-packaging.md. Recommends: python3-pyyaml %description -n python3-%{srcname} %_description %prep %autosetup -n %{srcname}-%{version} # Guard the premise the packaging rests on: that the four template surfaces and # the two LUTs are the package data, and that they came in the tarball rather # than being something we have to fetch. Written as a positive test on purpose: # `test -s X || exit 1`, not `test ! -s X && exit 1`. for f in brainsynth/resources/template/surf/lh.white \ brainsynth/resources/template/surf/lh.sphere.reg \ brainsynth/resources/template/surf/rh.white \ brainsynth/resources/template/surf/rh.sphere.reg \ brainsynth/constants/lut/brainseg.lut \ brainsynth/constants/lut/brainseg_with_extracerebral.lut; do test -s "$f" || { echo "ERROR: missing or empty package data: $f"; exit 1; } done %generate_buildrequires # The "generate" extra is deliberately not requested: see the Requires comment. %pyproject_buildrequires %build %pyproject_wheel %install %pyproject_install %pyproject_save_files -l %{srcname} %check # --------------------------------------------------------------------------- # 1. GUARD THE MODULE LIST. %%pyproject_check_import derives the list it # imports from %%pyproject_save_files. If that list ever shrank -- say someone # added -t, or the wheel stopped shipping a subpackage -- the check would still # report success while inspecting almost nothing. Assert the modules that carry # this package's risk are actually in it. Positive test (`grep -qxF || exit`), # deliberately not `grep -q && exit`, which inverts the test it looks like. for m in %{srcname} \ %{srcname}.config \ %{srcname}.config.utilities \ %{srcname}.constants \ %{srcname}.constants.lut.freesurfer \ %{srcname}.dataset \ %{srcname}.resources \ %{srcname}.synthesizer \ %{srcname}.transforms \ %{srcname}.transforms.spatial \ %{srcname}.transforms.contrast \ %{srcname}.utilities; do grep -qxF "$m" "%{_pyproject_modules}" || { echo "ERROR: $m is not in the %%pyproject_check_import module list." echo "The import check would pass without ever looking at it." echo "--- list as generated ---" cat "%{_pyproject_modules}" exit 1 } done # --------------------------------------------------------------------------- # 2. Import every installed module EXCEPT the four that upstream ships broken. # Nothing else is excluded. The exclusions are not taken on trust -- step 3 # below proves each one fails for the stated reason, and that the list is # exactly four, so it cannot silently grow into "exclude whatever fails". # # All the -e flags MUST stay on ONE line. rpm reads a macro's arguments to the # end of the LINE, so a backslash-continued invocation silently passes only the # first flag and drops the rest into the shell as stray text -- which is how the # first attempt at this excluded one module out of four and failed the build. %pyproject_check_import -e %{srcname}.config.utilities -e %{srcname}.prepare_freesurfer_data -e %{srcname}.process_additional_images -e %{srcname}.synthesizer_test # --------------------------------------------------------------------------- # 3. NEGATIVE CONTROL for the three exclusions, plus the real assertions about # the data files. Every check here either compares against a value computed # independently, or is shown to FAIL against a deliberately corrupted input. # NOTE FOR ANYONE EDITING THE SCRIPT BELOW: rpm expands macros inside a # heredoc body even with a quoted delimiter -- the quotes stop the SHELL, not # rpm. So no %%-prefixed token may appear between here and the EOF, not even in # a comment or a docstring. Writing %%%% does not help; it emits a literal # %%%% into the generated file. The first draft of this said # "%%pyproject_check_import" in a comment and rpm spliced the macro's body into # the Python source, which then died with "SyntaxError: invalid decimal # literal". This is the heredoc half of the rule in docs/COORDINATION.md. cat > %{_builddir}/brainsynth-check.py <<'EOF' """Prove three things that an import check cannot. (a) The four excluded modules fail, and fail for the documented reason. If upstream fixes them, this check FAILS and the exclusion must be removed -- an exclusion that outlives its cause is how a check stops checking. (b) The template surfaces are really installed, parse as FreeSurfer binary surfaces, and carry the topology the package is supposed to carry. The vertex count is not copied from the file: it is computed from the DeepSurfer 120-face base mesh, so a swapped or truncated file cannot pass. (c) The CPU path works, on the CPU, with every accelerator masked off. Note what is NOT asserted here: torch.cuda.is_available() being False. On Fedora that is not a CUDA test at all. python3-torch 2.9.1 is a ROCm/HIP build -- torch.version.cuda is None, torch.version.hip is 7.1.52802, and its libraries are libamdhip64/libMIOpen/librocblas, not one NVIDIA library -- and HIP reports itself through the torch.cuda namespace. Measured on a Fedora 44 machine with an AMD GPU and no NVIDIA hardware: torch.cuda.is_available() is True. An assertion on that flag would pass on a GPU-less COPR builder and false-fail on fedora-imac, with a message sending the reader to hunt for a CUDA install that cannot exist. So the accelerators are masked in the environment instead, and every tensor here is asserted to be on the CPU device, which is the thing actually meant. """ import importlib import struct import sys from pathlib import Path import numpy as np import torch CPU = torch.device("cpu") torch.set_num_threads(1) import brainsynth import brainsynth.transforms as T print("brainsynth imported from", brainsynth.__file__) print( f"torch {torch.__version__} " f"(version.cuda={torch.version.cuda}, version.hip={getattr(torch.version, 'hip', None)})" ) # --- (a) the three broken modules, each for its own stated reason ----------- EXPECTED = { # module: (exception type, a substring the message must contain) "brainsynth.config.utilities": (ImportError, "root_dir"), "brainsynth.prepare_freesurfer_data": (ImportError, "root_dir"), "brainsynth.process_additional_images": (ImportError, "root_dir"), "brainsynth.synthesizer_test": (ModuleNotFoundError, "brainnet"), } for name, (exc_type, needle) in EXPECTED.items(): try: importlib.import_module(name) except exc_type as e: assert needle in str(e), f"{name}: expected {needle!r} in {e!r}" print(f"excluded as documented: {name} -> {type(e).__name__}: {e}") else: raise SystemExit( f"ERROR: {name} now imports. Upstream has fixed it, so the " f"-e {name} exclusion in the spec must be REMOVED, otherwise this " f"module stops being covered by the import check." ) # The exclusion list must be exactly those three. Read it off the module list # that the RPM import check used, so this cannot drift from reality. pkg_dir = Path(brainsynth.__file__).parent installed = set() for path in pkg_dir.rglob("*.py"): rel = path.relative_to(pkg_dir).with_suffix("") parts = list(rel.parts) if parts[-1] == "__init__": parts.pop() installed.add(".".join(["brainsynth", *parts])) missing = set(EXPECTED) - installed assert not missing, f"excluded modules that are not even installed: {missing}" # The exclusion list must stay SMALL relative to what is shipped. If a future # version broke half the tree, blanket-excluding it would still satisfy the # per-module checks above; this is the sanity bound on that. assert len(EXPECTED) * 4 < len(installed), (len(EXPECTED), len(installed)) print(f"{len(installed)} python modules installed; exactly {len(EXPECTED)} excluded") # --- (b) the template surfaces --------------------------------------------- surf_dir = Path(brainsynth.__file__).parent / "resources" / "template" / "surf" def read_fs_surface(path): """Minimal FreeSurfer binary triangle-surface reader. Deliberately not nibabel: this asserts the bytes on disk are what we think they are, and a reader that silently tolerates a truncated file would defeat the negative control below. """ data = Path(path).read_bytes() if data[:3] != b"\xff\xff\xfe": raise ValueError(f"{path}: not a FreeSurfer binary surface") end = data.index(b"\n\n", 3) + 2 comment = data[3:end].decode("utf-8", "replace").strip() n_vertices, n_faces = struct.unpack(">ii", data[end : end + 8]) body = end + 8 need = body + n_vertices * 12 + n_faces * 12 if len(data) < need: raise ValueError( f"{path}: truncated -- header claims {n_vertices} vertices and " f"{n_faces} faces ({need} bytes), file is {len(data)}" ) vertices = np.frombuffer(data, ">f4", n_vertices * 3, body).reshape(-1, 3) faces = np.frombuffer(data, ">i4", n_faces * 3, body + n_vertices * 12) return comment, vertices, faces.reshape(-1, 3) # Computed, not copied. brainnet's DeepSurferTopology base mesh has 120 faces; # each subdivision quadruples the faces, and a closed triangulated surface has # F = 2V - 4. Level 6 is therefore 491520 faces and 245762 vertices. If a file # were replaced with, say, an fsaverage surface, its ico7 count of # 10 * 4**7 + 2 = 163842 would not match and this would fail. LEVEL = 6 EXPECT_FACES = 120 * 4**LEVEL EXPECT_VERTICES = (EXPECT_FACES + 4) // 2 assert (EXPECT_FACES, EXPECT_VERTICES) == (491520, 245762) surfaces = {} for name in ("lh.white", "lh.sphere.reg", "rh.white", "rh.sphere.reg"): path = surf_dir / name assert path.is_file(), f"template surface not installed: {path}" assert path.stat().st_size > 8_000_000, (name, path.stat().st_size) comment, vertices, faces = read_fs_surface(path) assert len(vertices) == EXPECT_VERTICES, (name, len(vertices)) assert len(faces) == EXPECT_FACES, (name, len(faces)) assert np.isfinite(vertices).all(), name surfaces[name] = vertices print(f"{name}: {len(vertices)} vertices, {len(faces)} faces -- {comment}") # The two spheres must actually be spheres of radius 100, which is what # "sphere.reg" means and what a resampling target has to be. for name in ("lh.sphere.reg", "rh.sphere.reg"): radius = np.linalg.norm(surfaces[name], axis=1) assert abs(radius.mean() - 100.0) < 1e-3, (name, radius.mean()) assert radius.std() < 1e-2, (name, radius.std()) # NEGATIVE CONTROL: the reader above must REJECT a truncated surface. Without # this, "read_fs_surface did not raise" would be consistent with it not # checking anything. import tempfile with tempfile.NamedTemporaryFile(suffix=".white", delete=False) as fh: fh.write((surf_dir / "lh.white").read_bytes()[: 1024 * 1024]) bad = fh.name try: read_fs_surface(bad) except ValueError as e: assert "truncated" in str(e), e print("negative control OK: a truncated surface is rejected --", e) else: raise SystemExit( "ERROR: read_fs_surface accepted a truncated file, so the surface " "assertions above prove nothing." ) finally: Path(bad).unlink() # --- the colour lookup tables ---------------------------------------------- lut_dir = Path(brainsynth.__file__).parent / "constants" / "lut" for name in ("brainseg.lut", "brainseg_with_extracerebral.lut"): path = lut_dir / name assert path.is_file(), f"LUT not installed: {path}" rows = [ line.split() for line in path.read_text().splitlines() if line.strip() and not line.startswith("#") ] assert len(rows) > 10, (name, len(rows)) # index name R G B A assert all(len(r) >= 5 for r in rows), name assert all(r[0].isdigit() for r in rows), name print(f"{name}: {len(rows)} labels") # --- (c) a real transform on the CPU --------------------------------------- # Not just "it imported": run a transform and assert an exact, hand-computable # result, so a torch that imports but cannot compute would fail here. Every # tensor is created on, and asserted to stay on, the CPU device. img = torch.arange(2 * 3 * 4, dtype=torch.float32, device=CPU).reshape(1, 2, 3, 4) assert img.device.type == "cpu", img.device norm = T.IntensityNormalization()(img.clone()) assert norm.device.type == "cpu", norm.device assert norm.shape == img.shape, norm.shape assert abs(float(norm.min()) - 0.0) < 1e-6, float(norm.min()) assert abs(float(norm.max()) - 1.0) < 1e-6, float(norm.max()) smoothed = T.GaussianSmooth(1.0)(img.clone()) assert smoothed.device.type == "cpu", smoothed.device assert smoothed.shape == img.shape, smoothed.shape assert torch.isfinite(smoothed).all() # Smoothing must actually change the image, and must not change its total mass # by much. "It returned a tensor" is not a result. assert not torch.equal(smoothed, img), "GaussianSmooth was a no-op" print("CPU transforms OK: IntensityNormalization -> [0, 1], GaussianSmooth ran") # The Template loader is the thing brainnet and charm reach for. tpl = brainsynth.resources.Template() lh = tpl.load_surface("lh", "white") assert lh["vertices"].shape == (EXPECT_VERTICES, 3), lh["vertices"].shape assert lh["faces"].shape == (EXPECT_FACES, 3), lh["faces"].shape assert lh["vertices"].dtype is torch.float32 print("Template().load_surface('lh', 'white') OK:", tuple(lh["vertices"].shape)) print("ALL BRAINSYNTH CHECKS PASSED") EOF # Mask every accelerator, whichever backend this torch was built against, so # the CPU path is what runs here regardless of the builder. CUDA_VISIBLE_DEVICES # covers an NVIDIA build; HIP_VISIBLE_DEVICES and ROCR_VISIBLE_DEVICES cover # Fedora's actual ROCm build, which reports itself through torch.cuda. OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 \ CUDA_VISIBLE_DEVICES="" HIP_VISIBLE_DEVICES="" ROCR_VISIBLE_DEVICES="" \ PYTHONPATH=%{buildroot}%{python3_sitelib} \ %{python3} %{_builddir}/brainsynth-check.py %files -n python3-%{srcname} -f %{pyproject_files} %license LICENSE %doc README.md # Upstream's data-preparation and template-building scripts. Not installed as # executables: they carry hardcoded /mnt/projects and /mrhome paths from the # author's cluster and need FreeSurfer, surfa and a training corpus. They are # shipped as documentation because they are the only record of how the template # surfaces and the training data were produced. %doc tools/ %changelog * Mon Sep 28 2026 Morgan Hough - 0.1-1 - Initial package, from the v0.1 git tag. NOT from PyPI: brainsynth is not on PyPI, and the neighbouring PyPI "brainnet" is unrelated software (v0.0.01, MIT, github.com/CryptoNyxz). - Middle link of the chain that unblocks SimNIBS charm (pytorch-ignite -> brainsynth -> brainnet -> charm / mesh_tools.meshing / optimization.opt_struct). - The four template surfaces in resources/template/surf are NOT fsaverage-derived, so there is no FreeSurfer licence question. Re-verified three ways rather than taken on trust: the headers read "created by jesperdn" (April/May 2025); the topology is 245762 vertices / 491520 faces, which is level 6 of brainnet's DeepSurfer 120-face base mesh and is NOT in fsaverage's icosahedral series (10*4**i+2, i.e. 163842 at ico7); and rh.white is EXACTLY resources/left-to-right.lta applied to lh.white (max deviation 0.0000 mm over 245762 vertices), which no pair of real fsaverage hemispheres would be. - FOUR shipped modules are unimportable upstream and are excluded from the import check, with a negative control in %%check that proves each fails for its documented reason and FAILS THE BUILD if upstream fixes one: config.utilities, prepare_freesurfer_data and process_additional_images all do `from brainsynth import root_dir`, which __init__.py does not define, and synthesizer_test imports brainnet (a dependency cycle) for a name brainnet v0.2 does not have. scikit-learn and PyYAML are BuildRequired precisely so those failures surface as the root_dir bug rather than as a missing optional dependency. See docs/upstream/brainsynth-brainnet-packaging.md. - PyYAML is Recommends, not Requires: upstream declares it nowhere although config/utilities.py imports yaml at module scope, but that module cannot be imported anyway until root_dir is fixed. - The CPU assertions do NOT test torch.cuda.is_available(). Fedora's python3-torch is a ROCm/HIP build that reports itself through the torch.cuda namespace, so that flag is True on any machine with an AMD GPU and says nothing about CUDA; the accelerators are masked in the environment and every tensor is asserted to be on the CPU device instead. See docs/cuda-inventory.md. - f44-x86_64 only for now, to match python-pytorch-ignite: upstream ignite caps requires-python at <3.15 and f45/rawhide ship 3.15, so brainnet cannot work there yet either.