-
Notifications
You must be signed in to change notification settings - Fork 2
Pix 49 log skip error in db #658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8028f31
951bc2e
7fd670c
74820dc
24b8ce6
d94d8af
b3942ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,3 +74,42 @@ def test_processed_images_for_project(rows_in_session): | |
| processed = exported_images_for_project("i-am-a-project") | ||
| assert len(processed) == 1 | ||
| assert processed[0].accession_number == "123" | ||
|
|
||
|
|
||
| def test_reimport_of_previously_skipped_image(example_messages_df, rows_in_session): | ||
| """ | ||
| GIVEN an image that previously had all instances skipped during anonymisation and has | ||
| skip_reasons recorded against it, but has not been exported | ||
| WHEN the same messages are re-imported (filtered again for the same project) | ||
| THEN no duplicate row should be added for that image (row-level filtering) | ||
| and the image should not be returned for reprocessing, as it previously failed | ||
| anonymisation (export-status filtering), with its recorded skip_reasons left untouched | ||
| """ | ||
| extract = rows_in_session.query(Extract).one() | ||
| previously_skipped_image = ( | ||
| rows_in_session.query(Image) | ||
| .filter(Image.extract == extract, Image.accession_number == "234") | ||
| .one() | ||
| ) | ||
| skip_reasons = {"DICOM instance discarded as series has too few instances": 3} | ||
| previously_skipped_image.skip_reasons = skip_reasons | ||
| rows_in_session.commit() | ||
|
Comment on lines
+88
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think setting up the data here is OK for this test, but if we start having multiple tests that need it (e.g. as part of #659) then we should look at moving this to a fixture that builds on def rows_in_session(db_session) -> Session: |
||
|
|
||
| output = filter_exported_or_add_to_db(example_messages_df) | ||
|
|
||
| # Row-level: re-importing must not create a duplicate row for the existing image | ||
| images = rows_in_session.query(Image).filter(Image.extract == extract).all() | ||
| assert len(images) == len(example_messages_df) | ||
|
|
||
| # Filtering-level: previously skipped images are not queued again, | ||
| # nor are already-exported images | ||
| assert "234" not in output.accession_number.to_numpy() | ||
| assert "123" not in output.accession_number.to_numpy() | ||
|
Comment on lines
+104
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would pass even if |
||
|
|
||
| # The recorded skip reasons must survive the re-import untouched | ||
| reloaded_image = ( | ||
| rows_in_session.query(Image) | ||
| .filter(Image.extract == extract, Image.accession_number == "234") | ||
| .one() | ||
| ) | ||
| assert reloaded_image.skip_reasons == skip_reasons | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,7 @@ | |
| anonymise_dicom_and_update_db, | ||
| get_series_to_skip, | ||
| parse_validation_results, | ||
| update_db_with_skip_failure_reason, | ||
| write_dataset_to_bytes, | ||
| ) | ||
| from pydicom import dcmread | ||
|
|
@@ -492,6 +493,11 @@ def _anonymise_study_instances( | |
|
|
||
| if not anonymised_instances_bytes: | ||
| message = f"All instances have been skipped for study: {dict(skipped_instance_counts)}" | ||
| update_db_with_skip_failure_reason( | ||
| project_name=project_name, | ||
| study_info=study_info, | ||
| skip_reasons=dict(skipped_instance_counts), | ||
| ) | ||
|
Comment on lines
+496
to
+500
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this fails (e.g due to a db issue), the wrong message gets passed on back to the calling def _anonymise_study_and_upload( Right now, I think it'll fallback to except DBAPIError as e: which isn't catastrophic but means PixlDiscardError doesn't get raised and the wrong failure reason is recorded and sent via telemetry. Or if another error is raised it could get handled by the except Exception as e: with whatever error gets passed back. That means we lose the info saying "all instances have been skipped" entirely. So I think worth wrapping in a try-except so that even if the database write has an issue, the current run and metrics aren't affected. |
||
| raise PixlDiscardError(message) | ||
|
|
||
| with logger.contextualize(pseudo_study_uid=anonymised_study_uid): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,20 @@ | |
| engine = create_engine(url) | ||
|
|
||
|
|
||
| def record_skip_reasons_for_study( | ||
| project_slug: str, study_info: StudyInfo, skip_reasons: dict[str, int] | ||
| ) -> None: | ||
| """ | ||
| Record the reasons (and instance counts) a study's instances were skipped for, | ||
| against the existing image record for that study. | ||
| """ | ||
| PixlSession = sessionmaker(engine) | ||
| with PixlSession() as pixl_session, pixl_session.begin(): | ||
| existing_image = get_unexported_image(project_slug, study_info, pixl_session) | ||
| existing_image.skip_reasons = skip_reasons | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should keep in mind for #659 that this is currently set, and then never unset. So when we allow skipped studies to be retried, we should ensure that the column in the database can be cleared. |
||
| pixl_session.add(existing_image) | ||
|
|
||
|
|
||
| def get_uniq_pseudo_study_uid_and_update_db( | ||
| project_slug: str, original_study_info: StudyInfo | ||
| ) -> UID: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,12 +41,14 @@ | |
| from pixl_dcmd.main import ( | ||
| anonymise_dicom_and_update_db, | ||
| _anonymise_dicom_from_scheme, | ||
| _clean_dicom_image_pixels, | ||
| anonymise_and_validate_dicom, | ||
| anonymise_dicom, | ||
| get_series_to_skip, | ||
| _enforce_allowlist, | ||
| _should_exclude_series, | ||
| _should_exclude_manufacturer, | ||
| update_db_with_skip_failure_reason, | ||
| ) | ||
| from pytest_pixl.dicom import generate_dicom_dataset | ||
| from pytest_pixl.helpers import run_subprocess | ||
|
|
@@ -263,6 +265,51 @@ def test_anonymise_with_clean_dicom_image_pixels( | |
| assert np.all(compare_clean_region_with_zeros) | ||
|
|
||
|
|
||
| def test_clean_dicom_image_pixels_encapsulates_compressed_pixel_data( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be in this PR? |
||
| monkeypatch, ultrasound_project_config | ||
| ): | ||
| """ | ||
| GIVEN a DICOM dataset with a compressed transfer syntax | ||
| WHEN pixel cleaning is applied | ||
| THEN the cleaned pixel data should be written back in encapsulated (fragmented) form | ||
| rather than as a raw, native pixel data byte string | ||
| """ | ||
| dataset = generate_dicom_dataset(Modality="US") | ||
| dataset.file_meta.TransferSyntaxUID = pydicom.uid.JPEGBaseline8Bit | ||
|
|
||
| cleaned_pixels = np.zeros((2, 2), dtype=np.uint8) | ||
| monkeypatch.setattr( | ||
| "pixl_dcmd.main.has_burned_pixels", lambda *args, **kwargs: object() | ||
| ) | ||
| monkeypatch.setattr( | ||
| "pixl_dcmd.main.clean_pixel_data", lambda *args, **kwargs: cleaned_pixels | ||
| ) | ||
|
|
||
| _clean_dicom_image_pixels(dataset, ultrasound_project_config) | ||
|
|
||
| assert dataset.PixelData == pydicom.encaps.encapsulate([cleaned_pixels.tobytes()]) | ||
|
|
||
|
|
||
| def test_update_db_with_skip_failure_reason(monkeypatch): | ||
| """ | ||
| GIVEN a study that failed de-identification with some skip reasons | ||
| WHEN update_db_with_skip_failure_reason is called | ||
| THEN the skip reasons should be recorded against the study in the database | ||
| """ | ||
| recorded_calls = [] | ||
| monkeypatch.setattr( | ||
| "pixl_dcmd.main.record_skip_reasons_for_study", | ||
| lambda *args: recorded_calls.append(args), | ||
| ) | ||
|
|
||
| study_info = get_study_info(generate_dicom_dataset()) | ||
| skip_reasons = {"Instance discarded due to its manufacturer": 2} | ||
|
|
||
| update_db_with_skip_failure_reason("test-project", study_info, skip_reasons) | ||
|
|
||
| assert recorded_calls == [("test-project", study_info, skip_reasons)] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This isn't really testing anything as you're mocking the only function that I'm assuming the function was set up like that to follow the existing structure in the code, and the test is here to improve reported coverage? My thoughts are that this test can be removed as you're doing the actual testing in def test_record_skip_reasons_for_study |
||
|
|
||
|
|
||
| @pytest.fixture | ||
| def dummy_manufacturer() -> Manufacturer: | ||
| return Manufacturer(regex="^company", exclude_series_numbers=[]) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # Copyright (c) University College London Hospitals NHS Foundation Trust | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| """ | ||
| Add skip reasons column to image table | ||
|
|
||
| Revision ID: 4226124af0db | ||
| Revises: d947cc715eb1 | ||
| Create Date: 2026-08-12 15:15:01.560015 | ||
|
|
||
| """ | ||
|
|
||
| from collections.abc import Sequence | ||
| from typing import Union | ||
|
|
||
| import sqlalchemy as sa | ||
| from sqlalchemy.dialects import postgresql | ||
|
|
||
| from alembic import op | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "4226124af0db" | ||
| down_revision: Union[str, None] = "d947cc715eb1" | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| # ### commands auto generated by Alembic - please adjust! ### | ||
| op.add_column( | ||
| "image", | ||
| sa.Column( | ||
| "skip_reasons", | ||
| postgresql.JSONB(astext_type=sa.Text()).with_variant(sa.JSON(), "sqlite"), | ||
| nullable=True, | ||
| ), | ||
| schema="pixl_pipeline", | ||
| ) | ||
| # ### end Alembic commands ### | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| # ### commands auto generated by Alembic - please adjust! ### | ||
| op.drop_column("image", "skip_reasons", schema="pixl_pipeline") | ||
| # ### end Alembic commands ### |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good that we're making it clear here that there are now two reasons why a study may not be added to the imaging queue. However, we should propagate that change back up to def populate_queue_and_db( where it currently says
logger.info("Filtering out exported images and uploading new ones to the database"), as well as to functions and docstrings in between.And possibly out of scope for this PR, I think some additional logging about what studies are skipped and why may be useful - e.g. this study was skipped because it was exported already, or has been skipped for x reason. Or in this function just log the overall numbers for each reason.