Skip to content
Open
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
43 changes: 43 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Backend CI

on:
push:
paths:
- "apps/backend/**"
- "package/**"
pull_request:
paths:
- "apps/backend/**"
- "package/**"

jobs:
test:
runs-on: ubuntu-latest

strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
cd apps/backend
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-regressions httpx

- name: Install package (editable)
run: |
cd package
pip install -e .

- name: Run tests
run: |
cd apps/backend
pytest -v
13 changes: 9 additions & 4 deletions apps/backend/app/routers/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from weasyprint import HTML
from app.utils.report_template import render_report_html
from app.utils.lib import default_serializer, save_upload, remove_dir
from starlette.background import BackgroundTask
import os

report_router = APIRouter(prefix="/report")

Expand Down Expand Up @@ -122,12 +124,15 @@ async def get_report_dicom(

@report_router.post("/report-pdf")
async def download_pdf(report_data: dict):
print("--------------------------------")
print(report_data["report_data"]["asl_parameters"])
print("--------------------------------")
html_content = render_report_html(report_data["report_data"])

with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
HTML(string=html_content).write_pdf(tmp.name)
tmp_path = tmp.name
return FileResponse(tmp_path, media_type="application/pdf", filename="report.pdf")

return FileResponse(
tmp_path,
media_type="application/pdf",
filename="report.pdf",
background=BackgroundTask(os.unlink, tmp_path)
)
Binary file added apps/backend/app/utils/sample_nifti.nii.gz
Binary file not shown.
37 changes: 37 additions & 0 deletions apps/backend/tests/fixtures/real_sample/sub-Sub1_asl.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{"Manufacturer":"Siemens",
"ManufacturersModelName":"TrioTim",
"SoftwareVersions":"N4_VB17A_LATEST_20090307",
"MagneticFieldStrength":3,
"ReceiveCoilName":"32Ch_Head",
"ReceiveCoilActiveElements":"C:HEA;HEP",
"MRAcquisitionType":"3D",
"PulseSequenceType":"3Dgrase",
"PulseSequenceDetails":"Bremen sequence: fme_ASL_Collection_002A for TrioTim-syngo_MR_B17",
"NumberShots":2,
"ScanningSequence":"RM",
"SequenceVariant":"SK",
"ScanOptions":"SAT1_FS",
"SequenceName":"grs3d3d1_512t0",
"PartialFourier":1,
"PhaseEncodingDirection":"j-",
"EffectiveEchoSpacing":0.0005,
"EchoTime":0.01192,
"DwellTime":3.4e-06,
"FlipAngle":180,
"RepetitionTimePreparation":3.5,
"ArterialSpinLabelingType":"PASL",
"PostLabelingDelay":[0.3,0.3,0.6,0.6,0.9,0.9,1.2,1.2,1.5,1.5,1.8,1.8,2.1,2.1,2.4,2.4,2.7,2.7,3,3],
"BackgroundSuppression":true,
"M0Type":"Separate",
"TotalAcquiredPairs":10,
"VascularCrushing":false,
"AcquisitionVoxelSize":[8,4,6],
"BackgroundSuppressionNumberPulses":2,
"BackgroundSuppressionPulseTime":[0.15,0.2],
"LabelingLocationDescription":"Labeling slab parallel to the imaging volume with a 2cm gap",
"LabelingDistance":10,
"PASLType":"FAIR",
"LabelingSlabThickness":115.5,
"BolusCutOffFlag":true,
"BolusCutOffDelayTime":[0.7,1.6],
"BolusCutOffTechnique":"Q2TIPS"}
21 changes: 21 additions & 0 deletions apps/backend/tests/fixtures/real_sample/sub-Sub1_aslcontext.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
volume_type
label
control
label
control
label
control
label
control
label
control
label
control
label
control
label
control
label
control
label
control
23 changes: 23 additions & 0 deletions apps/backend/tests/fixtures/real_sample/sub-Sub1_m0scan.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{"Manufacturer":"Siemens",
"ManufacturersModelName":"TrioTim",
"SoftwareVersions":"N4_VB17A_LATEST_20090307",
"MagneticFieldStrength":3,
"ReceiveCoilName":"32Ch_Head",
"ReceiveCoilActiveElements":"C:HEA;HEP",
"MRAcquisitionType":"3D",
"PulseSequenceType":"3Dgrase",
"ScanningSequence":"RM",
"SequenceVariant":"SK",
"ScanOptions":"SAT1_FS",
"SequenceName":"grs3d3d1_1152t0",
"PulseSequenceDetails":"Bremen sequence: fme_ASL_Collection_002A",
"PartialFourier":1,
"PhaseEncodingDirection":"j-",
"EffectiveEchoSpacing":0.00052,
"TotalReadoutTime":0.0104,
"EchoTime":0.01614,
"DwellTime":3.4e-06,
"FlipAngle":180,
"RepetitionTimePreparation":6,
"IntendedFor":"perf/sub-Sub1_asl.nii.gz",
"AcquisitionVoxelsize":[2,2,5]}
14 changes: 14 additions & 0 deletions apps/backend/tests/test_api_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)


def test_bids_endpoint_requires_files():
response = client.post(
"/api/report/process/bids",
data={"modality": "ASL"}
)

assert response.status_code == 500
assert response.json()["detail"] == "No files provided for ASL processing."
43 changes: 43 additions & 0 deletions apps/backend/tests/test_fixture_regression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import os
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

FIXTURE_DIR = os.path.join(
os.path.dirname(__file__),
"fixtures",
"real_sample"
)

def test_real_asl_fixture_regression(data_regression):
sample_nifti_path = os.path.join(
os.path.dirname(__file__),
"..",
"app",
"utils",
"sample_nifti.nii.gz"
)

with open(os.path.join(FIXTURE_DIR, "sub-Sub1_asl.json"), "rb") as f_asl, \
open(os.path.join(FIXTURE_DIR, "sub-Sub1_m0scan.json"), "rb") as f_m0, \
open(os.path.join(FIXTURE_DIR, "sub-Sub1_aslcontext.tsv"), "rb") as f_tsv, \
open(sample_nifti_path, "rb") as f_nifti:

response = client.post(
"/api/report/process/bids",
data={"modality": "ASL"},
files=[
("files", ("sub-Sub1_asl.json", f_asl, "application/json")),
("files", ("sub-Sub1_m0scan.json", f_m0, "application/json")),
("files", ("sub-Sub1_aslcontext.tsv", f_tsv, "text/tab-separated-values")),
("nifti_file", ("sample_nifti.nii.gz", f_nifti, "application/octet-stream")),
],
)

assert response.status_code == 200

data = response.json()
data.pop("nifti_slice_number", None)

data_regression.check(data)
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
asl_parameters:
- - Magnetic Field Strength
- 3T
- - Manufacturer
- Siemens
- - Manufacturer's Model Name
- TrioTim
- - PLD Type
- multi-PLD
- - PASL Type
- FAIR
- - ASL Type
- PASL
- - MR Acquisition Type
- 3D
- - Pulse Sequence Type
- GRASE
- - Echo Time
- 11.92ms
- - Repetition Time
- 3500ms
- - Flip Angle
- 180
- - In-plane Resolution
- 8x4mm^2
- - Slice Thickness
- 6mm
- - Inversion Time
- 300ms (1 repeat), 600ms (1 repeat), 900ms (1 repeat), 1200ms (1 repeat), 1500ms
(1 repeat), 1800ms (1 repeat), 2100ms (1 repeat), 2400ms (1 repeat), 2700ms (1
repeat), 3000ms (1 repeat)
- - Labeling Slab Thickness
- 115.5mm
- - Bolus Cutoff Flag
- with
- - Bolus Cutoff Technique
- Q2TIPS
- - Bolus Cutoff Delay Time
- from 700ms to 1600ms
- - Background Suppression
- with
- - Background Suppression Number of Pulses
- 2
- - Background Suppression Pulse Time
- 150ms and 200ms
- - Total Acquired Pairs
- 10
basic_report: 'ASL was acquired on a 3T Siemens TrioTim scanner using multi-PLD FAIR
PASL labeling and a 3D GRASE readout with the following parameters: TE = 11.92ms,
TR = 3500ms, flip angle 180 degrees, in-plane resolution 8x4mm^2, 4 slices with
6mm thickness, inversion time 300ms (1 repeat), 600ms (1 repeat), 900ms (1 repeat),
1200ms (1 repeat), 1500ms (1 repeat), 1800ms (1 repeat), 2100ms (1 repeat), 2400ms
(1 repeat), 2700ms (1 repeat), 3000ms (1 repeat), labeling slab thickness 115.5mm,
with bolus saturation using Q2TIPS pulse applied from 700ms to 1600ms after the
labeling, with background suppression with 2 pulses at 150ms and 200ms after the
start of labeling. In total, 10 label-control pairs were acquired. There is inconsistency
in EchoTime between M0 and ASL scans. TR for M0 is 6000ms.'
errors:
m0_error:
- - 'ERROR: Discrepancy in ''EchoTime'' for ASL file ''sub-Sub1_asl.json'' and M0
file ''sub-Sub1_m0scan.json'': ASL value = 11.92, M0 value = 16.14, difference
= 4.22, exceeds error threshold 0.1'
errors_concise: {}
errors_concise_text: ''
extended_parameters: []
extended_report: 'ASL was acquired on a 3T Siemens TrioTim scanner using multi-PLD
FAIR PASL labeling and a 3D GRASE readout with the following parameters: TE = 11.92ms,
TR = 3500ms, flip angle 180 degrees, in-plane resolution 8x4mm^2, 4 slices with
6mm thickness, inversion time 300ms (1 repeat), 600ms (1 repeat), 900ms (1 repeat),
1200ms (1 repeat), 1500ms (1 repeat), 1800ms (1 repeat), 2100ms (1 repeat), 2400ms
(1 repeat), 2700ms (1 repeat), 3000ms (1 repeat), labeling slab thickness 115.5mm,
with bolus saturation using Q2TIPS pulse applied from 700ms to 1600ms after the
labeling, with background suppression with 2 pulses at 150ms and 200ms after the
start of labeling. In total, 10 label-control pairs were acquired. There is inconsistency
in EchoTime between M0 and ASL scans. TR for M0 is 6000ms.'
inconsistencies: ''
m0_concise_error: 'EchoTime (M0): Discrepancy between ASL JSON and M0 JSON'
m0_concise_warning: For sub-Sub1_asl.json, no M0 is provided and BS pulses with known
timings are on. BS-pulse efficiency has to be calculated to enable absolute quantification.
m0_parameters:
- - M0 Type
- Separate
- - M0 TR
- 6000
major_errors: {}
major_errors_concise: {}
major_errors_concise_text: ''
major_inconsistencies: ''
missing_required_parameters: {}
warning_inconsistencies: ''
warnings:
m0_warning:
- - For sub-Sub1_asl.json, no M0 is provided and BS pulses with known timings are
on. BS-pulse efficiency has to be calculated to enable absolute quantification.
warnings_concise: {}
warnings_concise_text: ''
34 changes: 34 additions & 0 deletions apps/backend/tests/test_regression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)


def normalize_response(data: dict) -> dict:
"""
Remove volatile fields to ensure deterministic regression.
"""
data.pop("nifti_slice_number", None)
return data


def test_root_endpoint_regression(data_regression):
response = client.get("/")
assert response.status_code == 200

data = response.json()
data_regression.check(data)


def test_dicom_invalid_file_returns_500(tmp_path):
file_path = tmp_path / "invalid.txt"
file_path.write_text("not a dicom")

with open(file_path, "rb") as f:
response = client.post(
"/api/report/process/dicom",
files={"dcm_files": ("invalid.txt", f, "text/plain")},
data={"modality": "ASL"},
)

assert response.status_code == 500
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Specs:
Framework: FastAPI
Operating System: OS Independent
Programming Language: Python
authors:
- Ibrahim Abdelazim: ibrahim.abdelazim@fau.de
- Hanliang Xu: hxu110@jh.edu
description: This service provides an API for generating ASL methods parameters based
on user input. It is designed to be used in conjunction with the ASL Methods Parameter
Generator frontend application.
license: MIT
name: ASL Methods Parameter Generator API Service
organization: The ISMRM Open Science Initiative for Perfusion Imaging
supervisors:
- Jan Petr
- David Thomas
version: 0.0.1
Loading