diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..65801f3e --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -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 \ No newline at end of file diff --git a/apps/backend/app/routers/reports.py b/apps/backend/app/routers/reports.py index 5b80ab8e..046f3a60 100644 --- a/apps/backend/app/routers/reports.py +++ b/apps/backend/app/routers/reports.py @@ -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") @@ -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) + ) \ No newline at end of file diff --git a/apps/backend/app/utils/sample_nifti.nii.gz b/apps/backend/app/utils/sample_nifti.nii.gz new file mode 100644 index 00000000..4a8e8c65 Binary files /dev/null and b/apps/backend/app/utils/sample_nifti.nii.gz differ diff --git a/apps/backend/tests/fixtures/real_sample/sub-Sub1_asl.json b/apps/backend/tests/fixtures/real_sample/sub-Sub1_asl.json new file mode 100644 index 00000000..dc96df05 --- /dev/null +++ b/apps/backend/tests/fixtures/real_sample/sub-Sub1_asl.json @@ -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"} diff --git a/apps/backend/tests/fixtures/real_sample/sub-Sub1_aslcontext.tsv b/apps/backend/tests/fixtures/real_sample/sub-Sub1_aslcontext.tsv new file mode 100644 index 00000000..5c4e787c --- /dev/null +++ b/apps/backend/tests/fixtures/real_sample/sub-Sub1_aslcontext.tsv @@ -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 diff --git a/apps/backend/tests/fixtures/real_sample/sub-Sub1_m0scan.json b/apps/backend/tests/fixtures/real_sample/sub-Sub1_m0scan.json new file mode 100644 index 00000000..cd127a16 --- /dev/null +++ b/apps/backend/tests/fixtures/real_sample/sub-Sub1_m0scan.json @@ -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]} \ No newline at end of file diff --git a/apps/backend/tests/test_api_schema.py b/apps/backend/tests/test_api_schema.py new file mode 100644 index 00000000..078f79be --- /dev/null +++ b/apps/backend/tests/test_api_schema.py @@ -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." \ No newline at end of file diff --git a/apps/backend/tests/test_fixture_regression.py b/apps/backend/tests/test_fixture_regression.py new file mode 100644 index 00000000..29bea2bf --- /dev/null +++ b/apps/backend/tests/test_fixture_regression.py @@ -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) \ No newline at end of file diff --git a/apps/backend/tests/test_fixture_regression/test_real_asl_fixture_regression.yml b/apps/backend/tests/test_fixture_regression/test_real_asl_fixture_regression.yml new file mode 100644 index 00000000..1182aff4 --- /dev/null +++ b/apps/backend/tests/test_fixture_regression/test_real_asl_fixture_regression.yml @@ -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: '' diff --git a/apps/backend/tests/test_regression.py b/apps/backend/tests/test_regression.py new file mode 100644 index 00000000..a897a965 --- /dev/null +++ b/apps/backend/tests/test_regression.py @@ -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 \ No newline at end of file diff --git a/apps/backend/tests/test_regression/test_root_endpoint_regression.yml b/apps/backend/tests/test_regression/test_root_endpoint_regression.yml new file mode 100644 index 00000000..a887b6bb --- /dev/null +++ b/apps/backend/tests/test_regression/test_root_endpoint_regression.yml @@ -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 diff --git a/apps/backend/tests/test_report.py b/apps/backend/tests/test_report.py index 2dd19c8f..4a1dce3f 100644 --- a/apps/backend/tests/test_report.py +++ b/apps/backend/tests/test_report.py @@ -9,7 +9,7 @@ def test_get_report_bids_no_files(): response = client.post("/api/report/process/bids", data={"modality": "ASL"}) - assert response.status_code == 200 + assert response.status_code == 500 assert isinstance(response.json(), dict) def test_get_report_dicom_no_files(): @@ -23,21 +23,27 @@ def test_get_report_dicom_with_invalid_file(tmp_path): file_path.write_text("not a dicom") with open(file_path, "rb") as f: response = client.post( - "/report/process/dicom", + "/api/report/process/dicom", files={"dcm_files": ("not_a_dicom.txt", f, "text/plain")}, data={"modality": "ASL"} ) # Should still return 500 due to invalid dicom - assert response.status_code in [500, 200] + assert response.status_code == 500 def test_report_pdf_endpoint(): - # Minimal valid report_data for rendering report_data = { "report_data": { - "asl_parameters": {"param1": "value1"}, - "other": "test" + "asl_parameters": [], + "basic_report": "Test", + "extended_report": "Extended", + "errors": {}, + "warnings": {}, + "missing_parameters": [] } } + response = client.post("/api/report/report-pdf", json=report_data) + assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" \ No newline at end of file + assert response.headers["content-type"] == "application/pdf" + assert len(response.content) > 1000 \ No newline at end of file diff --git a/apps/frontend/src/app/_home/UploadButtons.tsx b/apps/frontend/src/app/_home/UploadButtons.tsx index 5c141393..40de33ca 100644 --- a/apps/frontend/src/app/_home/UploadButtons.tsx +++ b/apps/frontend/src/app/_home/UploadButtons.tsx @@ -126,7 +126,7 @@ const UploadButtons = () => { )} onClick={() => setActiveFileTypeOption(UploadDataType.BIDS)} > - BDIS + BIDS diff --git a/apps/frontend/src/app/_layout/navigation-data.ts b/apps/frontend/src/app/_layout/navigation-data.ts index 2a52bb75..583e8059 100644 --- a/apps/frontend/src/app/_layout/navigation-data.ts +++ b/apps/frontend/src/app/_layout/navigation-data.ts @@ -42,8 +42,8 @@ const NavData: { countType: "warnings", }, { - title: "Convert DICOM to BDIS", - url: "/convert/dcm-to-bdis", + title: "Convert DICOM to BIDS", + url: "/convert/dcm-to-bids", icon: IconTransform, showCount: false, } diff --git a/apps/frontend/src/app/convert/dcm-to-bdis/page.tsx b/apps/frontend/src/app/convert/dcm-to-bdis/page.tsx index f5f34d2e..7185100a 100644 --- a/apps/frontend/src/app/convert/dcm-to-bdis/page.tsx +++ b/apps/frontend/src/app/convert/dcm-to-bdis/page.tsx @@ -2,7 +2,7 @@ const Page = () => { return (
-

DCM to BDIS Conversion

+

DCM to BIDS Conversion

This feature is under development.

); diff --git a/docs/assets/Package-Arch.svg b/docs/assets/Package-Arch.svg index 62e00c51..819d25f2 100644 --- a/docs/assets/Package-Arch.svg +++ b/docs/assets/Package-Arch.svg @@ -1,4 +1,4 @@ -
Core
IO
Modalities
Sequences
Converters
Utils
CLI
ASL
DSC
Processors
Validators
Utils
Readers
Writers
PCASL_siemens
CASL_ucl
DICOM to BDIS
Desktop App
Web App
CLI App
Tests
\ No newline at end of file +
Core
IO
Modalities
Sequences
Converters
Utils
CLI
ASL
DSC
Processors
Validators
Utils
Readers
Writers
PCASL_siemens
CASL_ucl
DICOM to BIDS
Desktop App
Web App
CLI App
Tests
\ No newline at end of file diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index a4306b26..fc508fe7 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -95,7 +95,7 @@ modalities/ #### Sequence Management -This Handles the extraction of BDIS metadata (e.g. asl.json) from DICOM for vendors and organization implementation of modalities +This Handles the extraction of BIDS metadata (e.g. asl.json) from DICOM for vendors and organization implementation of modalities ``` sequences/ diff --git a/docs/user-guide/quick-start.mdx b/docs/user-guide/quick-start.mdx index b337ac0b..af3cb8bb 100644 --- a/docs/user-guide/quick-start.mdx +++ b/docs/user-guide/quick-start.mdx @@ -58,7 +58,7 @@ If you have DICOM files: - Or drag and drop files directly 2. **Select Data Type**: - - Choose "BDIS" for BIDS format + - Choose "BIDS" for BIDS format - Choose "DICOM" for DICOM files 3. **Select Modality**: diff --git a/package/src/pyaslreport/enums/modaliy_enum.py b/package/src/pyaslreport/enums/modality_enum.py similarity index 100% rename from package/src/pyaslreport/enums/modaliy_enum.py rename to package/src/pyaslreport/enums/modality_enum.py